This commit is contained in:
dualfroz committed 2026-06-10 12:41:14 +02:00
commit 4cf726709f
36 files changed
+8617

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
.env
*.env
node_modules/
__pycache__/
*.pyc
*.pyo
.DS_Store
dist/
build/
*.log
Generated
+2649
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "fxg"
version = "0.1.0"
edition = "2021"
description = "Multi-repo git workflow manager"
[[bin]]
name = "fxg"
path = "src/main.rs"
[dependencies]
anyhow = "1"
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = ["derive"] }
crossterm = "0.28"
dirs = "5"
git2 = "0.19"
inquire = "0.7"
keyring = "3"
ratatui = "0.28"
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
toml = "0.8"
+322
View File
@@ -0,0 +1,322 @@
# fxg
Multi-repository git workflow manager. Zarządzaj repozytoriami na GitHub, GitLab i Gitea z jednego miejsca.
## Install
```bash
# Linux — opcjonalna zależność systemowa
sudo apt install libgit2-dev # Ubuntu/Debian
sudo pacman -S libgit2 # Arch
cargo install --path .
fxg --version
```
Binarny plik ląduje w `~/.cargo/bin/fxg` — już jest w PATH po standardowej instalacji Rust. Działa na Windows i Linux.
---
## Pierwsze uruchomienie
```bash
fxg config # ustaw workspace_root, username, email
fxg auth add --provider github --token ghp_xxxxx
```
Bez tych dwóch kroków większość komend nie zadziała.
---
## `fxg status`
Otwiera TUI — tabelę wszystkich repozytoriów z `workspace_root`:
```
┌ 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 │
└─────────────────────────────────────────────────────────────────────────────────┘
```
Kolory: żółty = dirty (niezcommitowane zmiany), czerwony = behind (trzeba pullować).
**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)
```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)
```
> **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.
---
## `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: `<provider>/<alias>`, 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"
```
| 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 |
---
## 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)
```
---
## Providery
| Provider | wartość `--provider` | Uwagi |
|---|---|---|
| GitHub | `github` | github.com |
| GitLab | `gitlab` | gitlab.com |
| Gitea | `gitea` | self-hosted, wymaga `--url` |
| Forgejo | `forgejo` | fork Gitea, wymaga `--url` |
+282
View File
@@ -0,0 +1,282 @@
# fxg — strategia cross-platform
> Cel: jedna binarka, trzy systemy, instalacja w 30 sekund, zero wiedzy technicznej wymaganej.
---
## Stan obecny
Rust kompiluje się do natywnych binarek bez zewnętrznych zależności runtime. To dobra podstawa — nie trzeba instalować Pythona, Node.js ani JVM. Ale dziś jedynym sposobem instalacji jest `cargo install --path .`, co wymaga zainstalowanego Rusta i środowiska build. Dla grafika — nierealne.
Obecny problem z `keyring`: na Linuksie wymaga `libdbus` lub `libsecret`. Do rozwiązania przy pakowaniu.
---
## 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.
**Linux:** Wklejasz jedną linię w terminal. Po 10 sekundach piszesz `fxg status` i to działa.
**macOS:** `brew install`. Jedna komenda.
---
## Strategia dystrybucji — GitHub Releases + CI
### GitHub Actions — automatyczne buildowanie binarek
Przy każdym tagu (`v*.*.*`) CI buduje binarkę na 4 targety:
```yaml
# .github/workflows/release.yml
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-musl # statyczna binarka — działa na każdym Linuksie
artifact: fxg-linux-x64
- os: ubuntu-latest
target: aarch64-unknown-linux-musl # ARM (np. Raspberry Pi, nowe serwery)
artifact: fxg-linux-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
artifact: fxg-windows-x64.exe
- os: macos-latest
target: x86_64-apple-darwin
artifact: fxg-macos-x64
- os: macos-latest
target: aarch64-apple-darwin # Apple Silicon (M1/M2/M3)
artifact: fxg-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).
Pliki lądują w GitHub Releases jako Assets. Użytkownik pobiera odpowiedni plik dla swojego systemu.
---
## Linux — instalacja
### Skrypt jednolinijkowy (docelowy UX)
```bash
curl -fsSL https://github.com/twoj-user/fxg/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`
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)
```bash
#!/bin/bash
set -e
ARCH=$(uname -m)
OS=linux
case $ARCH in
x86_64) FILE="fxg-linux-x64" ;;
aarch64) FILE="fxg-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}"
mkdir -p ~/.local/bin
curl -fsSL "$URL" -o ~/.local/bin/fxg
chmod +x ~/.local/bin/fxg
# Dodaj do PATH jeśli brak
if ! echo "$PATH" | grep -q "$HOME/.local/bin"; then
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
export PATH="$HOME/.local/bin:$PATH"
fi
echo "fxg zainstalowany. Uruchamianie konfiguracji..."
fxg setup
```
### Opcjonalnie: pakiety .deb i .rpm
Generowane przez `cargo-deb` i `cargo-rpm` w CI — przydatne dla systemów zarządzanych przez IT.
```bash
# Ubuntu/Debian
sudo dpkg -i fxg_0.1.0_amd64.deb
# Fedora/RHEL/CentOS
sudo rpm -i fxg-0.1.0.x86_64.rpm
```
---
## Windows — instalacja
### 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**.
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
use std::os::windows::process::CommandExt;
if !is_launched_from_terminal() {
std::process::Command::new("cmd")
.args(["/k", "fxg.exe", "setup"])
.creation_flags(0x00000010) // CREATE_NEW_CONSOLE
.spawn().ok();
std::process::exit(0);
}
}
```
### Opcja B: Skrypt PowerShell (instalacja systemowa)
```powershell
# Wklej w PowerShell jako Administrator:
iwr https://github.com/twoj-user/fxg/releases/latest/download/install.ps1 | iex
```
Skrypt:
1. Pobiera `fxg-windows-x64.exe`
2. Umieszcza w `%USERPROFILE%\AppData\Local\fxg\`
3. Dodaje ten folder do `$PATH` (przez `[Environment]::SetEnvironmentVariable`)
4. Uruchamia `fxg setup` w nowym oknie terminalu
### Opcja C: WinGet (Microsoft Store CLI) — przyszłość
```
winget install fxg
```
Wymaga zgłoszenia pakietu do repozytorium `winget-pkgs`.
---
## macOS — instalacja
### Homebrew (docelowy UX)
```bash
brew install twoj-user/tap/fxg
```
Wymaga stworzenia własnego "tap" (repozytorium z formularzem Homebrew):
```ruby
# Formula/fxg.rb
class Fxg < Formula
desc "Multi-repository git workflow manager"
homepage "https://github.com/twoj-user/fxg"
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"
sha256 "..."
else
url "https://github.com/twoj-user/fxg/releases/download/v0.1.0/fxg-macos-x64"
sha256 "..."
end
end
def install
bin.install "fxg-macos-arm64" => "fxg"
end
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.
Implementacja w `src/app.rs`, przed każdym `match cli.command`:
```rust
// src/app.rs
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!("Przeprowadźmy krótką konfigurację (zajmie ~2 minuty).\n");
first_run_setup().await?;
}
let cfg = config::load_config()?;
// ... reszta
}
async fn first_run_setup() -> Result<()> {
// 1. Uruchom config TUI
let cfg = config::run_first_time().await?;
// 2. Zapytaj o dodanie konta
if inquire::Confirm::new("Czy chcesz teraz podłączyć konto GitHub/GitLab?")
.with_default(true).prompt()? {
// Uruchom auth add wizard
auth::run_add_wizard(&cfg).await?;
}
println!("\nGotowe! Wpisz 'fxg status' żeby zobaczyć swoje projekty.");
Ok(())
}
```
---
## Systemowe zależności — problemy i rozwiązania
| 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 |
| `openssl` | Różne wersje na różnych systemach | Feature `native-tls` → `rustls` — TLS w całości wbudowany w Rust |
```toml
# Cargo.toml — docelowa konfiguracja dla statycznych binarek
[dependencies]
git2 = { version = "0.19", features = ["vendored"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
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ę.
Kryteria dobrej nazwy narzędzia CLI:
- Krótka (2–4 znaki idealne, max 6)
- Łatwa do wymówienia i zapamiętania
- Nie koliduje z istniejącymi komendami systemowymi
- Nawiązuje do funkcji (opcjonalnie)
Kilka luźnych kierunków (bez rekomendacji — ostateczna decyzja należy do Ciebie):
- Coś od "workspace" / "repos" / "git"
- Coś sugerującego "wielość" lub "hub"
- Akronim który znaczy coś po polsku, a po angielsku brzmi jak komenda
Niezależnie od wyboru nazwy — warto go podjąć przed stworzeniem Homebrew tap i pierwszym publicznym Release, bo zmiana nazwy binarki po publikacji jest uciążliwa.
---
## Roadmap dystrybucji
```
v0.1 (teraz) cargo install — tylko dla developerów
v0.2 GitHub Releases z binaries (Linux + Windows + macOS)
Skrypt instalacyjny curl | bash
v0.3 First-run wizard (bez potrzeby wpisywania komend setup)
PowerShell install script dla Windows
v0.4 Homebrew tap dla macOS
v1.0 Stabilny API, binarka klikalna na Windows
WinGet (opcjonalnie)
Pakiety .deb/.rpm (opcjonalnie)
```
+198
View File
@@ -0,0 +1,198 @@
# Poradnik dla grafika — jak używać fxg
> Ten poradnik zakłada, że nigdy wcześniej nie używałeś terminalu do pracy z kodem. Spokojnie — to jest prostsze niż myślisz.
---
## 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.
**Dla Ciebie oznacza to:** zamiast wchodzić na stronę GitHuba i klikać w każde repozytorium osobno, wpisujesz jedną komendę i widzisz status wszystkiego.
---
## Krok 1 — Instalacja
### Windows
1. Pobierz plik `fxg.exe` z sekcji [Releases](https://github.com/twoj-team/fxg/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
> Jeśli system pyta "Czy chcesz uruchomić ten plik?" — kliknij "Tak" / "Uruchom mimo to".
### Linux (np. Linux Mint)
Otwórz terminal i wklej:
```
curl -fsSL https://github.com/twoj-team/fxg/install.sh | bash
```
Gotowe.
### macOS
```
brew install twoj-team/tap/fxg
```
(wymaga Homebrew — jeśli nie masz, zapytaj kolegę programistę)
---
## Krok 2 — Pierwsze uruchomienie (robisz to RAZ)
Otwórz terminal i wpisz:
```
fxg config
```
Pojawi się ekran konfiguracji. Ustaw:
| Pole | Co wpisać |
|---|---|
| `workspace_root` | Folder gdzie trzymasz projekty, np. `C:\Users\TwojeImie\projekty` lub `/home/twoje/projekty` |
| `username` | Twoje imię i nazwisko lub nick (to będzie widoczne w commitach) |
| `email` | Twój email z GitHuba |
Poruszasz się strzałkami, edytujesz pole po naciśnięciu Enter, zapisujesz przez `q` lub `Ctrl+S`.
---
## Krok 3 — Podłącz konto GitHub (robisz to RAZ)
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")
4. Zaznacz: `repo` (cały zakres)
5. Kliknij **"Generate token"** na dole
6. **Skopiuj token** — zobaczysz go tylko raz!
Teraz wróć do terminalu i wpisz (zamień `TWOJ_TOKEN` na skopiowany token):
```
fxg auth add --provider github --token TWOJ_TOKEN
```
Gotowe. Token jest bezpiecznie zapisany na Twoim komputerze.
---
## Codzienne użycie
### Sprawdź co się dzieje we wszystkich projektach
```
fxg status
```
Zobaczysz tabelę z projektami. Co oznaczają kolumny:
```
# NAZWA BRANCH AHEAD BEHIND DIRTY OSTATNI COMMIT
1 projekt-strona main 0 2 ✗ 2h temu
2 projekt-logo develop 1 0 ✓ wczoraj
```
| Kolumna | Co znaczy |
|---|---|
| `AHEAD` | Masz zmiany gotowe do wysłania na serwer |
| `BEHIND` | Na serwerze są nowsze zmiany — ktoś coś dodał |
| `DIRTY ✓` | Masz niezapisane zmiany (jak "niezapisany dokument") |
**Poruszanie się:** strzałki góra/dół, `Enter` żeby wejść w szczegóły projektu, `q` żeby wyjść.
---
### Pobierz najnowszą wersję projektu
Jeśli `BEHIND` pokazuje liczbę większą od 0, ktoś dodał zmiany na serwerze. Pobierz je:
```
fxg sync pull --all
```
To pobierze najnowszą wersję **wszystkich** projektów naraz.
---
### Wyślij swoje zmiany na serwer
Masz nowe pliki (np. nowe grafiki, zaktualizowane assets)? Użyj interaktywnego trybu:
```
fxg sync
```
Pojawi się ekran z listą projektów. Obsługa:
1. **Zaznacz projekt** — naciśnij `Spacja` przy projekcie który chcesz wysłać. Możesz zaznaczyć kilka.
- `a` — zaznacz wszystkie naraz
2. **Naciśnij Enter** — przejdziesz do wyboru akcji
3. **Wybierz "Commit"** — strzałkami, potem Enter. Wpisz opis zmian (np. "nowe bannery dla kampanii Q2"), Enter.
4. **Wybierz "Push"** — to faktycznie wysyła na serwer.
> **Commit vs Push:** Commit to jak "zapisanie wersji" lokalnie. Push to wysłanie tej wersji na serwer. Zawsze robisz najpierw Commit, potem Push.
---
### Szybki push z linii komend (jeśli wiesz co robisz)
```
fxg sync push --all -m "aktualizacja grafik landing page"
```
To jednocześnie commita (z wiadomością po `-m`) i pushuje wszystkie zmienione projekty.
---
## Słowniczek
| Słowo | Po ludzku |
|---|---|
| **Repozytorium** (repo) | Folder projektu na GitHubie. Jak teczka z plikami projektu. |
| **Commit** | Zapisanie wersji plików z opisem "co zmieniłem". Jak ctrl+S z etykietką. |
| **Push** | Wysłanie swoich zmian na serwer GitHuba. |
| **Pull** | Pobranie zmian z serwera na swój komputer. |
| **Branch** | Wersja projektu. `main` to główna, `develop` to robocza. |
| **Dirty** | Pliki zmienione ale jeszcze nie scommitowane. |
| **Merge** | Połączenie dwóch wersji projektu w jedną. Robi to zwykle programista. |
---
## 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`
**"Permission denied" lub "access denied"**
- Linux/Mac: wpisz `chmod +x fxg` 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`
**Nie widzę swoich projektów w `fxg status`**
- Sprawdź czy `workspace_root` w `fxg 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ć
- Jeśli `DIRTY` jest `✗`, ale `AHEAD` > 0 — wystarczy sam push bez commita
---
## 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
```
+314
View File
@@ -0,0 +1,314 @@
# fxg — pomysły na QoL (Quality of Life)
> Dokument roboczy. Każdy pomysł ma tytuł, opis, przykład użycia i szacunkową trudność implementacji.
---
## Wysoki priorytet
### 1. `fxg 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ą
# z filtrowaniem
fxg clone --mine --filter "frontend" # tylko repo z "frontend" w nazwie
fxg 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`.
**Trudność:** średnia — wymaga nowej metody w każdym providerze (GitHub/GitLab/Gitea API wszystkie to obsługują).
---
### 2. Tagi jako organizacja folderów lokalnych
**Problem:** Masz 50 repo i wszystkie są w jednym `workspace_root/`. Chaos.
**Rozwiązanie:** Tag przypisany do repo = podfolder w `workspace_root`. repo z tagiem `dev` ląduje w `workspace_root/dev/`, repo z tagiem `client-acme` ląduje w `workspace_root/client-acme/`.
```bash
# przy tworzeniu repo
fxg init moj-projekt --tags dev,frontend
# retroaktywnie
fxg tag moj-projekt add client-acme
fxg 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
```
**Wynikowa struktura:**
```
~/projekty/
├── dev/
│ ├── api-backend/
│ └── shared-utils/
├── client-acme/
│ ├── acme-website/
│ └── acme-mobile/
└── archived/
└── stary-projekt/
```
**Implementacja:** Przechowywać tagi per-repo w `workspaces.toml` lub osobnym `tags.toml`. Komenda `organize` tworzy podfoldery i przenosi repo (lub tworzy symlinki — symlinki są bezpieczniejsze, bo oryginał zostaje w miejscu).
**Trudność:** średnia — logika przenoszenia + aktualizacja `workspace_root` per-repo.
---
### 3. Domyślna paczka plików przy `fxg 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
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
```
Dodatkowo: **szablony zawartości pliku README** konfigurowalne w `config.toml` (`readme_template = "heartbyte-default"` już istnieje w konfigu, ale nie jest zaimplementowane).
**Trudność:** niska — już mamy `InitConfig` z polami `readme`, `changelog` itd., wystarczy ustawić domyślne wartości z configa zamiast false.
---
### 4. `fxg 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
```
Usuwa gałęzie które są już zmergowane do `main`/`master` — lokalnie i opcjonalnie zdalnie.
**Implementacja:** `git2` — `repo.branches()`, sprawdzenie czy jest zmergowana przez `repo.merge_analysis()`. Już mamy `delete_branch()` w `sync/mod.rs`.
**Trudność:** niska — wszystkie potrzebne funkcje `git2` już są w kodzie.
---
## Średni priorytet
### 5. Shell completions (bash / zsh / fish / PowerShell)
Wpisanie `fxg <Tab>` podpowiada:
- subkomendy (`status`, `sync`, `init`...)
- nazwy repo dla `fxg status <Tab>` → lista repo z workspace_root
- nazwy workspace dla `--workspace <Tab>`
- aliasy kont dla `fxg auth switch <Tab>`
```bash
fxg completions bash >> ~/.bashrc
fxg completions zsh >> ~/.zshrc
fxg completions fish > ~/.config/fish/completions/fxg.fish
fxg 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).
**Trudność:** niska (statyczne) / średnia (dynamiczne nazwy repo).
---
### 6. `fxg status --watch` — live monitoring workspace
```bash
fxg status --watch # odświeża TUI co 30 sekund
fxg 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.
**Implementacja:** Tokio timer w pętli TUI, wywołuje `scan_workspace()` co N sekund. Detekcja zmian przez porównanie `ahead/behind` z poprzednim stanem.
**Trudność:** niska — już mamy `scan_workspace()` i pętlę TUI.
---
### 7. Szablony projektów (`fxg 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
```
Każdy szablon tworzy kompletną strukturę katalogów i pliki startowe:
- `react-app` → `src/`, `public/`, `package.json` z React, `.eslintrc`, Dockerfile
- `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.
**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
```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
```
Wyświetla TUI tabelę: repo | numer | tytuł | autor | data | etykiety.
Enter na PR otwiera URL w przeglądarce.
**Implementacja:** Nowa metoda `list_pull_requests()` w trait `Provider`, wywołanie API. GitHub/GitLab/Gitea wszystkie to mają.
**Trudność:** średnia — nowa metoda w każdym providerze.
---
### 9. Globalny `.fxgignore` — repo do pominięcia
```
# ~/.fxg/ignore
archived-* # wszystkie repo zaczynające się od "archived-"
old-monorepo # konkretne repo po nazwie
```
`scan_workspace()` pomija pasujące foldery. Przydatne gdy `workspace_root` zawiera też foldery inne niż repo git.
**Implementacja:** Dodać `ignore_patterns: Vec<String>` do `AppConfig`, sprawdzać przy `scan_workspace()`.
**Trudność:** niska.
---
### 10. `fxg 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
```
ASCII wykres słupkowy commitów per dzień:
```
Aktywność (ostatnie 30 dni) — api-backend
May 01 ████████░░░░░░░░░░░░░ 8
May 08 ████████████░░░░░░░░░ 12
May 15 ██████░░░░░░░░░░░░░░░ 6
May 22 ██████████████████░░░ 18
```
**Implementacja:** `revwalk` po historii commitów + grupowanie po dniach. Już mamy `git2` w projekcie.
**Trudność:** niska.
---
## Przyszłość / ambitne
### 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.
**Rozwiązanie:** Towarzyszące rozszerzenie VS Code które automatycznie zapisuje czas spędzony na plikach w formacie rozumianym przez `fxg`.
```
fxg stats time # pokazuje automatycznie zbierane dane
```
---
### 12. Webhook przy każdym sync
Opcjonalny URL w `config.toml`:
```toml
sync_webhook = "https://discord.com/api/webhooks/..."
```
Po każdym udanym `push` wysyła embed z: repo, branch, commit message, autor.
Identyczny mechanizm jak audit log w innych projektach z tego zespołu.
**Trudność:** niska — prosty `reqwest::post` po każdym `push_repo()`.
---
### 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
```
Wysyła `git diff --staged` do API (np. Claude lub OpenAI), dostaje propozycję wiadomości w stylu conventional commits.
Wymaga klucza API w konfiguracji.
---
### 14. `fxg release` multi-platform publish
Po stworzeniu release tagu — automatyczne wyzwolenie publishu na:
- `crates.io` (dla Rust)
- `npm` (dla Node.js)
- `PyPI` (dla Python)
Wykrywa typ projektu przez `detect_project_type()` (już istnieje w `summary/mod.rs`).
---
### 15. Interaktywny diff viewer
W widoku szczegółowym repo (`fxg status <repo>`) — klawisz `d` otwiera inlinowy diff zmienionych plików z kolorowym podświetleniem:
```
M src/components/Nav.tsx [d] diff [s] stage [r] revert
─────────────────────────────────────────
- const navItems = ["Home", "About"];
+ const navItems = ["Home", "About", "Contact"];
```
**Trudność:** wysoka — wymaga parsowania diff output i renderowania w ratatui.
---
## Szybkie podsumowanie priorytetów
| # | Pomysł | Priorytet | Trudność |
|---|---|---|---|
| 1 | `fxg 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 |
| 5 | Shell completions | Średni | Niska–Średnia |
| 6 | `status --watch` | Średni | Niska |
| 7 | Szablony projektów | Średni | Średnia |
| 8 | `pr list` | Średni | Średnia |
| 9 | `.fxgignore` | Średni | Niska |
| 10 | `stats --trend` | Średni | Niska |
| 11 | VSCode extension | Przyszłość | Wysoka |
| 12 | Webhook przy sync | Przyszłość | Niska |
| 13 | AI commit messages | Przyszłość | Średnia |
| 14 | Multi-platform publish | Przyszłość | Wysoka |
| 15 | Diff viewer w TUI | Przyszłość | Wysoka |
+223
View File
@@ -0,0 +1,223 @@
pub mod ui;
use crate::auth::load_accounts;
use crate::cli::AdoptArgs;
use crate::config::AppConfig;
use crate::providers::{default_account, make_provider};
use anyhow::Result;
use git2::{IndexAddOption, Repository, Signature};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct AdoptResult {
pub name: String,
pub success: bool,
pub detail: String,
pub url: Option<String>,
}
impl AdoptResult {
fn ok(name: &str, detail: impl Into<String>, url: impl Into<String>) -> Self {
Self { name: name.into(), success: true, detail: detail.into(), url: Some(url.into()) }
}
fn err(name: &str, detail: impl Into<String>) -> Self {
Self { name: name.into(), success: false, detail: detail.into(), url: None }
}
}
/// Scan for adoptable folders using the two-tier model:
///
/// Tier 1 — direct children of `root` that are NOT in `groups` and have no `.git`.
/// Tier 2 — direct children of each `root/<group>` folder that have no `.git`.
///
/// Never recurses further. Hidden dirs and the group folders themselves are excluded.
pub fn scan_non_git(root: &Path, groups: &[String]) -> Vec<PathBuf> {
let mut out = Vec::new();
let group_set: std::collections::HashSet<&str> = groups.iter().map(|s| s.as_str()).collect();
// Tier 1: direct children of workspace_root
if let Ok(entries) = std::fs::read_dir(root) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() { continue; }
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.starts_with('.') { continue; }
if group_set.contains(name.as_ref()) { continue; } // group container, not a project
if path.join(".git").exists() { continue; }
out.push(path);
}
}
// Tier 2: direct children of each group folder
for group in groups {
let group_dir = root.join(group);
if !group_dir.is_dir() { continue; }
if let Ok(entries) = std::fs::read_dir(&group_dir) {
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() { continue; }
let name = path.file_name().unwrap_or_default().to_string_lossy();
if name.starts_with('.') { continue; }
if path.join(".git").exists() { continue; }
out.push(path);
}
}
}
out.sort();
out
}
/// Core adopt operation for a single folder:
/// git init → stage all → initial commit → create remote repo → add remote → push
pub async fn adopt_folder(
path: &Path,
cfg: &AppConfig,
private: bool,
commit_msg: &str,
provider_hint: Option<&str>,
) -> AdoptResult {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
// ── git init ─────────────────────────────────────────────────────────────
let repo = match Repository::init(path) {
Ok(r) => r,
Err(e) => return AdoptResult::err(&name, format!("git init: {}", e)),
};
// ── stage everything ─────────────────────────────────────────────────────
let mut index = match repo.index() {
Ok(i) => i,
Err(e) => return AdoptResult::err(&name, format!("index: {}", e)),
};
if let Err(e) = index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None) {
return AdoptResult::err(&name, format!("git add: {}", e));
}
if let Err(e) = index.write() {
return AdoptResult::err(&name, format!("index write: {}", e));
}
// ── initial commit ────────────────────────────────────────────────────────
let tree_id = match index.write_tree() {
Ok(id) => id,
Err(e) => return AdoptResult::err(&name, format!("write tree: {}", e)),
};
let tree = match repo.find_tree(tree_id) {
Ok(t) => t,
Err(e) => return AdoptResult::err(&name, format!("find tree: {}", e)),
};
let sig = match Signature::now(&cfg.username, &cfg.email) {
Ok(s) => s,
Err(e) => return AdoptResult::err(&name, format!("signature: {}", e)),
};
if let Err(e) = repo.commit(Some("HEAD"), &sig, &sig, commit_msg, &tree, &[]) {
return AdoptResult::err(&name, format!("commit: {}", e));
}
// ── create remote repo via API ────────────────────────────────────────────
let accounts = load_accounts(cfg).unwrap_or_default();
let acc = match default_account(&accounts, provider_hint.or(Some(&cfg.default_provider))) {
Some(a) => a,
None => return AdoptResult::err(&name, "no provider account configured — run: fxg auth add"),
};
let provider = match make_provider(acc) {
Ok(p) => p,
Err(e) => return AdoptResult::err(&name, format!("provider: {}", e)),
};
let repo_info = match provider.create_repo(&name, private, "").await {
Ok(r) => r,
Err(e) => return AdoptResult::err(&name, format!("create remote repo: {}", e)),
};
// ── git remote add origin ─────────────────────────────────────────────────
// Prefer SSH URL; fall back to HTTPS clone URL
let remote_url = if !repo_info.ssh_url.is_empty() {
repo_info.ssh_url.clone()
} else {
repo_info.url.clone()
};
if let Err(e) = repo.remote("origin", &remote_url) {
return AdoptResult::err(&name, format!("remote add: {}", e));
}
// ── push ──────────────────────────────────────────────────────────────────
let token = crate::auth::get_token(&acc.id()).unwrap_or_default();
let mut cb = git2::RemoteCallbacks::new();
cb.credentials(move |_url, user, allowed| {
if allowed.contains(git2::CredentialType::SSH_KEY) {
git2::Cred::ssh_key_from_agent(user.unwrap_or("git"))
} else {
git2::Cred::userpass_plaintext(user.unwrap_or("git"), &token)
}
});
let mut push_opts = git2::PushOptions::new();
push_opts.remote_callbacks(cb);
let mut remote = match repo.find_remote("origin") {
Ok(r) => r,
Err(e) => return AdoptResult::err(&name, format!("find remote: {}", e)),
};
let branch = repo.head()
.ok()
.and_then(|h| h.shorthand().map(|s| s.to_string()))
.unwrap_or_else(|| cfg.default_branch.clone());
let refspec = format!("refs/heads/{0}:refs/heads/{0}", branch);
if let Err(e) = remote.push(&[&refspec], Some(&mut push_opts)) {
return AdoptResult::err(&name, format!("push: {}", e));
}
AdoptResult::ok(&name, format!("pushed {}", branch), repo_info.url)
}
// ── entry point ───────────────────────────────────────────────────────────────
pub async fn run(args: AdoptArgs, cfg: &AppConfig) -> Result<()> {
let private = args.visibility != "public";
let provider_hint = args.provider.as_deref();
// Single named folder
if let Some(name) = &args.name {
let path = cfg.workspace_root.join(name);
if !path.is_dir() {
anyhow::bail!("folder '{}' not found in workspace_root", name);
}
if path.join(".git").exists() {
anyhow::bail!("'{}' already has a git repo", name);
}
let result = adopt_folder(&path, cfg, private, &args.message, provider_hint).await;
print_result(&result);
return Ok(());
}
let candidates = scan_non_git(&cfg.workspace_root, &cfg.workspace_groups);
if candidates.is_empty() {
println!("Wszystkie foldery w workspace_root już mają .git. Nic do adoptowania.");
return Ok(());
}
if args.all {
// Batch — no TUI
println!("Adoptuję {} folderów...\n", candidates.len());
for path in &candidates {
let result = adopt_folder(path, cfg, private, &args.message, provider_hint).await;
print_result(&result);
}
} else {
// Interactive TUI
ui::run_adopt_tui(candidates, &cfg.workspace_root, cfg, private, &args.message, provider_hint).await?;
}
Ok(())
}
fn print_result(r: &AdoptResult) {
if r.success {
let url = r.url.as_deref().unwrap_or("");
println!(" ✓ {} → {}", r.name, url);
} else {
println!(" ✗ {} — {}", r.name, r.detail);
}
}
+192
View File
@@ -0,0 +1,192 @@
use crate::adopt::{adopt_folder, AdoptResult};
use crate::config::AppConfig;
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
use std::path::{Path, PathBuf};
#[derive(PartialEq)]
enum Mode { Select, Progress }
struct State {
folders: Vec<PathBuf>,
workspace_root: PathBuf,
selected: Vec<bool>,
cursor: usize,
mode: Mode,
results: Vec<AdoptResult>,
quit: bool,
}
impl State {
fn new(folders: Vec<PathBuf>, workspace_root: PathBuf) -> Self {
let len = folders.len();
Self {
folders, workspace_root,
selected: vec![false; len],
cursor: 0, mode: Mode::Select,
results: vec![], quit: false,
}
}
fn toggle(&mut self) {
if let Some(v) = self.selected.get_mut(self.cursor) { *v = !*v; }
}
fn selected_paths(&self) -> Vec<PathBuf> {
self.folders.iter().enumerate()
.filter(|(i, _)| self.selected[*i])
.map(|(_, p)| p.clone())
.collect()
}
}
/// Show path relative to workspace_root, e.g. "clients/client-a"
fn rel_name(p: &Path, root: &Path) -> String {
p.strip_prefix(root)
.map(|r| r.to_string_lossy().to_string())
.unwrap_or_else(|_| p.file_name().unwrap_or_default().to_string_lossy().to_string())
}
fn render_select(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let selected_count = state.selected.iter().filter(|&&v| v).count();
let title = format!(
" Foldery bez .git ({} zaznaczonych — Enter aby adoptować) ",
selected_count
);
let block = Block::default().title(title).borders(Borders::ALL);
let items: Vec<ListItem> = state.folders.iter().enumerate().map(|(i, p)| {
let check = if state.selected[i] { "[x]" } else { "[ ]" };
let style = if i == state.cursor {
Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let label = rel_name(p, &state.workspace_root);
ListItem::new(format!(" {} {}", check, label)).style(style)
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render_progress(f: &mut Frame, results: &[AdoptResult], total: usize, area: ratatui::layout::Rect) {
let done = results.len();
let title = format!(" Adoptowanie ({}/{}) ", done, total);
let block = Block::default().title(title).borders(Borders::ALL);
let items: Vec<ListItem> = results.iter().map(|r| {
if r.success {
let url = r.url.as_deref().unwrap_or("");
ListItem::new(format!(" ✓ {} → {}", r.name, url))
.style(Style::default().fg(Color::Green))
} else {
ListItem::new(format!(" ✗ {} — {}", r.name, r.detail))
.style(Style::default().fg(Color::Red))
}
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render(f: &mut Frame, state: &State, total_to_adopt: usize) {
let area = f.area();
let outer = Block::default().title(" fxg — Adopt ").borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
match state.mode {
Mode::Select => render_select(f, state, chunks[0]),
Mode::Progress => render_progress(f, &state.results, total_to_adopt, chunks[0]),
}
let hint = match state.mode {
Mode::Select => " [Space] zaznacz [a] wszystkie [Enter] adoptuj zaznaczone [q] wyjdź",
Mode::Progress => " [q] wyjdź",
};
f.render_widget(
Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
}
fn handle_key(state: &mut State, key: crossterm::event::KeyEvent) {
match state.mode {
Mode::Select => match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Char('a') => state.selected.iter_mut().for_each(|v| *v = true),
KeyCode::Char(' ') => state.toggle(),
KeyCode::Up | KeyCode::Char('k') => { if state.cursor > 0 { state.cursor -= 1; } }
KeyCode::Down | KeyCode::Char('j') => {
if state.cursor + 1 < state.folders.len() { state.cursor += 1; }
}
KeyCode::Enter => {
if state.selected.iter().any(|&v| v) {
state.mode = Mode::Progress;
}
}
_ => {}
},
Mode::Progress => {
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) {
state.quit = true;
}
}
}
}
pub async fn run_adopt_tui(
folders: Vec<PathBuf>,
workspace_root: &Path,
cfg: &AppConfig,
private: bool,
commit_msg: &str,
provider_hint: Option<&str>,
) -> Result<()> {
let mut state = State::new(folders, workspace_root.to_path_buf());
let mut pending: Option<Vec<PathBuf>> = None;
let mut total_to_adopt = 0usize;
// We need to leave TUI to run async adopt (git2 + HTTP) then re-enter for results.
// Strategy: collect selection in TUI, exit TUI, run adopts, show summary in plain text.
// This avoids threading git2/reqwest into the ratatui event loop.
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state, total_to_adopt))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
handle_key(&mut state, key);
// When user confirms (mode flipped to Progress), capture selection and break
if state.mode == Mode::Progress && pending.is_none() {
pending = Some(state.selected_paths());
total_to_adopt = pending.as_ref().unwrap().len();
state.quit = true; // exit TUI loop, run adopts outside
}
}
Ok(state.quit)
})?;
let paths = match pending {
Some(p) if !p.is_empty() => p,
_ => return Ok(()),
};
// Run adopts outside TUI (async + network)
println!("\nAdoptuję {} projekt(ów)...\n", paths.len());
for path in &paths {
let label = rel_name(path, cfg.workspace_root.as_path());
let result = adopt_folder(path, cfg, private, commit_msg, provider_hint).await;
if result.success {
println!(" ✓ {} → {}", label, result.url.as_deref().unwrap_or(""));
} else {
println!(" ✗ {} — {}", label, result.detail);
}
}
println!();
Ok(())
}
+51
View File
@@ -0,0 +1,51 @@
use crate::cli::{Cli, Commands};
use anyhow::Result;
pub async fn run(cli: Cli) -> Result<()> {
match cli.command {
Commands::Config => {
let cfg = crate::config::load_config()?;
crate::config::run(cfg).await
}
Commands::Auth(args) => {
let cfg = crate::config::load_config()?;
crate::auth::run(args, &cfg).await
}
Commands::Init(args) => {
let cfg = crate::config::load_config()?;
crate::init::run(args, &cfg).await
}
Commands::Status(args) => {
let cfg = crate::config::load_config()?;
crate::status::run(args, &cfg).await
}
Commands::Sync(args) => {
let cfg = crate::config::load_config()?;
crate::sync::run(args, &cfg).await
}
Commands::Release(args) => {
let cfg = crate::config::load_config()?;
crate::release::run(args, &cfg).await
}
Commands::Refactor(args) => {
let cfg = crate::config::load_config()?;
crate::refactor::run(args, &cfg).await
}
Commands::Stats(args) => {
let cfg = crate::config::load_config()?;
crate::stats::run(args, &cfg).await
}
Commands::Workspace(args) => {
let cfg = crate::config::load_config()?;
crate::workspace::run(args, &cfg).await
}
Commands::Summary(args) => {
let cfg = crate::config::load_config()?;
crate::summary::run(args, &cfg).await
}
Commands::Adopt(args) => {
let cfg = crate::config::load_config()?;
crate::adopt::run(args, &cfg).await
}
}
}
+146
View File
@@ -0,0 +1,146 @@
pub mod ui;
use crate::cli::{AuthAction, AuthArgs};
use crate::config::AppConfig;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
pub provider: String,
pub alias: String,
pub username: String,
pub email: String,
pub base_url: Option<String>,
pub is_default: bool,
}
impl Account {
pub fn id(&self) -> String {
format!("{}/{}", self.provider, self.alias)
}
}
// ── persistence ───────────────────────────────────────────────────────────────
#[derive(Serialize, Deserialize, Default)]
struct AccountsFile {
accounts: Vec<Account>,
}
pub fn accounts_path(cfg: &AppConfig) -> PathBuf {
cfg.data_dir.join("accounts.toml")
}
pub fn load_accounts(cfg: &AppConfig) -> Result<Vec<Account>> {
let path = accounts_path(cfg);
if path.exists() {
let raw = std::fs::read_to_string(&path)?;
let file: AccountsFile = toml::from_str(&raw)?;
Ok(file.accounts)
} else {
Ok(vec![])
}
}
pub fn save_accounts(accounts: &[Account], cfg: &AppConfig) -> Result<()> {
let path = accounts_path(cfg);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let file = AccountsFile { accounts: accounts.to_vec() };
std::fs::write(path, toml::to_string_pretty(&file)?)?;
Ok(())
}
// ── keyring ───────────────────────────────────────────────────────────────────
const SERVICE: &str = "fxg";
pub fn store_token(id: &str, token: &str) -> Result<()> {
keyring::Entry::new(SERVICE, id)?.set_password(token)?;
Ok(())
}
pub fn get_token(id: &str) -> Result<String> {
Ok(keyring::Entry::new(SERVICE, id)?.get_password()?)
}
pub fn delete_token(id: &str) -> Result<()> {
keyring::Entry::new(SERVICE, id)?.delete_credential()?;
Ok(())
}
// ── mutation helpers ──────────────────────────────────────────────────────────
fn add_account(
accounts: &mut Vec<Account>,
account: Account,
token: &str,
cfg: &AppConfig,
) -> Result<()> {
let id = account.id();
accounts.retain(|a| a.id() != id);
accounts.push(account);
save_accounts(accounts, cfg)?;
store_token(&id, token)?;
Ok(())
}
fn remove_account(accounts: &mut Vec<Account>, id: &str, cfg: &AppConfig) -> Result<()> {
accounts.retain(|a| a.id() != id);
save_accounts(accounts, cfg)?;
let _ = delete_token(id);
Ok(())
}
fn switch_default(accounts: &mut Vec<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() {
a.is_default = a.id() == id;
}
save_accounts(accounts, cfg)?;
Ok(())
}
// ── entry point ───────────────────────────────────────────────────────────────
pub async fn run(args: AuthArgs, cfg: &AppConfig) -> Result<()> {
let mut accounts = load_accounts(cfg)?;
match args.action {
None => {
let updated = ui::run_auth_tui(accounts, cfg)?;
save_accounts(&updated, cfg)?;
}
Some(AuthAction::Add { provider, token, alias, url }) => {
let provider = provider.unwrap_or_else(|| cfg.default_provider.clone());
let alias = alias.unwrap_or_else(|| "personal".into());
let token = token.ok_or_else(|| anyhow::anyhow!("--token is required"))?;
let account = Account {
provider, alias, username: cfg.username.clone(),
email: cfg.email.clone(), base_url: url, is_default: accounts.is_empty(),
};
add_account(&mut accounts, account, &token, cfg)?;
println!("✓ Account added");
}
Some(AuthAction::Remove { account }) => {
remove_account(&mut accounts, &account, cfg)?;
println!("✓ Account removed");
}
Some(AuthAction::Switch { account }) => {
switch_default(&mut accounts, &account, cfg)?;
println!("✓ Default switched to {}", account);
}
Some(AuthAction::Test { account }) => {
let id = account.unwrap_or_else(|| {
accounts.iter().find(|a| a.is_default).map(|a| a.id()).unwrap_or_default()
});
let token = get_token(&id)?;
println!("Token for {}: {}…", id, &token[..token.len().min(8)]);
}
}
Ok(())
}
+118
View File
@@ -0,0 +1,118 @@
use crate::auth::Account;
use crate::config::AppConfig;
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, Cell, Paragraph, Row, Table},
Frame,
};
struct State {
accounts: Vec<Account>,
selected: usize,
status: Option<String>,
quit: bool,
}
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default()
.title(" fxg — Authenticated Accounts ")
.borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(2)])
.split(inner);
let header = Row::new(vec!["", "Provider", "Alias", "Email", "Status"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = state.accounts.iter().enumerate().map(|(i, a)| {
let star = if a.is_default { "★" } else { " " };
let status_cell = Cell::from("✓ active").style(Style::default().fg(Color::Green));
let style = if i == state.selected {
Style::default().bg(Color::DarkGray)
} else {
Style::default()
};
Row::new(vec![
Cell::from(format!("{} [{}]", star, i + 1)),
Cell::from(a.provider.clone()),
Cell::from(a.alias.clone()),
Cell::from(a.email.clone()),
status_cell,
]).style(style)
}).collect();
let widths = [
Constraint::Length(6),
Constraint::Length(10),
Constraint::Length(12),
Constraint::Fill(1),
Constraint::Length(10),
];
let table = Table::new(rows, widths).header(header);
f.render_widget(table, chunks[0]);
let hint = state.status.as_deref().unwrap_or(
" [a] add [r] remove [s] set default [t] test [q] quit"
);
f.render_widget(
Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
}
pub fn run_auth_tui(mut accounts: Vec<Account>, _cfg: &AppConfig) -> Result<Vec<Account>> {
let mut state = State { accounts: accounts.clone(), selected: 0, status: None, quit: false };
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
state.status = None;
match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Up | KeyCode::Char('k') => {
if state.selected > 0 { state.selected -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if state.selected + 1 < state.accounts.len() { state.selected += 1; }
}
KeyCode::Char('a') => {
// must restore terminal temporarily for inquire
// handled outside the raw-mode loop
state.status = Some(" Press [q] first, then use: fxg auth add --provider <p> --token <t>".into());
}
KeyCode::Char('r') => {
if let Some(a) = state.accounts.get(state.selected) {
let id = a.id();
state.accounts.retain(|x| x.id() != id);
let _ = crate::auth::delete_token(&id);
if state.selected > 0 { state.selected -= 1; }
state.status = Some(format!(" ✓ Removed {}", id));
}
}
KeyCode::Char('s') => {
if let Some(a) = state.accounts.get(state.selected) {
let id = a.id();
for acc in state.accounts.iter_mut() {
acc.is_default = acc.id() == id;
}
state.status = Some(format!(" ✓ Default set to {}", id));
}
}
_ => {}
}
}
Ok(state.quit)
})?;
accounts = state.accounts;
Ok(accounts)
}
+229
View File
@@ -0,0 +1,229 @@
use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
#[command(name = "fxg", about = "Multi-repo git workflow manager", version)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Configure fxg 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),
}
#[derive(Args)]
pub struct AuthArgs {
#[command(subcommand)]
pub action: Option<AuthAction>,
}
#[derive(Subcommand)]
pub enum AuthAction {
Add {
#[arg(long)] provider: Option<String>,
#[arg(long)] token: Option<String>,
#[arg(long)] alias: Option<String>,
#[arg(long)] url: Option<String>,
},
Remove { account: String },
Switch { account: String },
Test { account: Option<String> },
}
#[derive(Args)]
pub struct InitArgs {
pub name: Option<String>,
#[arg(long)] pub provider: Option<String>,
#[arg(long, value_delimiter = ',')] pub tags: Vec<String>,
#[arg(long, default_value = "private")] pub status: String,
#[arg(long, default_value = "main")] pub branch: String,
#[arg(long)] pub batch: Option<String>,
}
#[derive(Args)]
pub struct StatusArgs {
pub repo: Option<String>,
#[arg(long)] pub workspace: Option<String>,
#[arg(long)] pub filter: Option<String>,
}
#[derive(Args)]
pub struct SyncArgs {
#[command(subcommand)]
pub action: Option<SyncAction>,
}
#[derive(Subcommand)]
pub enum SyncAction {
Pull {
#[arg(long)] all: bool,
#[arg(long)] workspace: Option<String>,
},
Commit {
#[arg(short = 'm', long)] message: Option<String>,
#[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<String>,
},
Branch(BranchArgs),
Pr(PrArgs),
}
#[derive(Args)]
pub struct PrArgs {
#[command(subcommand)]
pub action: PrAction,
}
#[derive(Subcommand)]
pub enum PrAction {
Create {
#[arg(long)] title: Option<String>,
#[arg(long)] base: Option<String>,
#[arg(long)] head: Option<String>,
#[arg(long, value_delimiter = ',')] reviewers: Vec<String>,
#[arg(long, value_delimiter = ',')] labels: Vec<String>,
#[arg(long)] repo: Option<String>,
},
}
#[derive(Args)]
pub struct BranchArgs {
#[command(subcommand)]
pub action: BranchAction,
}
#[derive(Subcommand)]
pub enum BranchAction {
Create { name: String, #[arg(long, value_delimiter = ',')] repos: Vec<String> },
Delete { name: String },
Switch { name: String, #[arg(long)] all: bool },
}
#[derive(Args)]
pub struct ReleaseArgs {
#[command(subcommand)]
pub action: Option<ReleaseAction>,
}
#[derive(Subcommand)]
pub enum ReleaseAction {
Create {
version: Option<String>,
#[arg(long, default_value = "patch")] release_type: String,
#[arg(long)] generate_changelog: bool,
},
List,
Tag { version: String, #[arg(long, value_delimiter = ',')] repos: Vec<String> },
}
#[derive(Args)]
pub struct RefactorArgs {
#[command(subcommand)]
pub action: Option<RefactorAction>,
}
#[derive(Subcommand)]
pub enum RefactorAction {
Rename {
kind: String, from: String, to: String,
#[arg(long, value_delimiter = ',')] repos: Vec<String>,
#[arg(long)] dry_run: bool,
},
Clean { target: String },
Find {
pattern: String,
#[arg(long)] repos: Option<String>,
#[arg(long = "type")] find_type: Option<String>,
},
Generate { target: String },
}
#[derive(Args)]
pub struct StatsArgs {
#[command(subcommand)]
pub action: Option<StatsAction>,
#[arg(long)] pub repo: Option<String>,
#[arg(long)] pub workspace: Option<String>,
/// Save output to <repo>/.fxg/stats.md
#[arg(long)] pub save: bool,
}
#[derive(Subcommand)]
pub enum StatsAction {
Time {
#[arg(long)] repo: Option<String>,
#[arg(long)] since: Option<String>,
},
Lines, Languages, Functions, Security,
}
#[derive(Args)]
pub struct WorkspaceArgs {
#[command(subcommand)]
pub action: Option<WorkspaceAction>,
}
#[derive(Subcommand)]
pub enum WorkspaceAction {
Create { name: String },
Add { workspace: String, repos: Vec<String> },
Remove { name: String },
List,
}
#[derive(Args)]
pub struct SummaryArgs {
/// Repository name (defaults to current directory)
pub repo: Option<String>,
#[arg(long)] pub workspace: Option<String>,
/// Print to stdout instead of saving to <repo>/.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<String>,
/// Adopt all non-git folders without prompting
#[arg(long)] pub all: bool,
/// Provider to use (default: from config)
#[arg(long)] pub provider: Option<String>,
/// 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,
}
+141
View File
@@ -0,0 +1,141 @@
pub mod ui;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub workspace_root: PathBuf,
pub data_dir: PathBuf,
pub default_provider: String,
pub default_branch: String,
pub default_visibility: String,
pub editor: String,
pub username: String,
pub email: String,
pub gitignore_template: String,
pub readme_template: String,
pub share_stats: bool,
pub timezone: String,
pub vsc_data_dir: PathBuf,
/// Folder names inside workspace_root treated as organisational groups.
/// Their direct children are scanned as repos, not the group folder itself.
/// Example: ["clients", "internal", "opensource"]
#[serde(default)]
pub workspace_groups: Vec<String>,
}
impl Default for AppConfig {
fn default() -> Self {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("~"));
Self {
workspace_root: home.join("projects"),
data_dir: home.join(".fxg"),
default_provider: "github".into(),
default_branch: "main".into(),
default_visibility: "private".into(),
editor: std::env::var("EDITOR").unwrap_or_else(|_| "vim".into()),
username: String::new(),
email: String::new(),
gitignore_template: "node".into(),
readme_template: "heartbyte-default".into(),
share_stats: false,
timezone: "Europe/Warsaw".into(),
vsc_data_dir: home.join(".fxg/vsc-data"),
workspace_groups: vec![],
}
}
}
impl AppConfig {
pub fn fields(&self) -> Vec<(&'static str, String)> {
vec![
("workspace_root", self.workspace_root.to_string_lossy().into()),
("data_dir", self.data_dir.to_string_lossy().into()),
("default_provider", self.default_provider.clone()),
("default_branch", self.default_branch.clone()),
("default_visibility", self.default_visibility.clone()),
("editor", self.editor.clone()),
("username", self.username.clone()),
("email", self.email.clone()),
("gitignore_template", self.gitignore_template.clone()),
("readme_template", self.readme_template.clone()),
("share_stats", self.share_stats.to_string()),
("timezone", self.timezone.clone()),
("vsc_data_dir", self.vsc_data_dir.to_string_lossy().into()),
("workspace_groups", self.workspace_groups.join(", ")),
]
}
pub fn set_field(&mut self, key: &str, value: String) -> Result<()> {
match key {
"workspace_root" => self.workspace_root = PathBuf::from(&value),
"data_dir" => self.data_dir = PathBuf::from(&value),
"default_provider" => self.default_provider = value,
"default_branch" => self.default_branch = value,
"default_visibility" => self.default_visibility = value,
"editor" => self.editor = value,
"username" => self.username = value,
"email" => self.email = value,
"gitignore_template" => self.gitignore_template = value,
"readme_template" => self.readme_template = value,
"share_stats" => self.share_stats = value == "true",
"timezone" => self.timezone = value,
"vsc_data_dir" => self.vsc_data_dir = PathBuf::from(&value),
"workspace_groups" => {
self.workspace_groups = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
other => anyhow::bail!("unknown config key: {}", other),
}
Ok(())
}
}
pub fn config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("~"))
.join(".fxg")
.join("config.toml")
}
pub fn load_config() -> Result<AppConfig> {
let path = config_path();
if path.exists() {
let raw = std::fs::read_to_string(&path)?;
Ok(toml::from_str(&raw)?)
} else {
Ok(AppConfig::default())
}
}
pub fn save_config(cfg: &AppConfig) -> Result<()> {
let path = config_path();
ensure_parent(&path)?;
std::fs::write(path, toml::to_string_pretty(cfg)?)?;
Ok(())
}
#[allow(dead_code)]
pub fn ensure_data_dir(cfg: &AppConfig) -> Result<()> {
std::fs::create_dir_all(&cfg.data_dir)?;
std::fs::create_dir_all(&cfg.vsc_data_dir)?;
Ok(())
}
fn ensure_parent(p: &Path) -> Result<()> {
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir)?;
}
Ok(())
}
pub async fn run(cfg: AppConfig) -> Result<()> {
let updated = ui::run_config_tui(cfg)?;
save_config(&updated)?;
Ok(())
}
+137
View File
@@ -0,0 +1,137 @@
use crate::config::AppConfig;
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode, KeyEvent};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
struct State {
fields: Vec<(&'static str, String)>,
selected: usize,
editing: bool,
edit_buf: String,
quit: bool,
}
impl State {
fn new(cfg: &AppConfig) -> Self {
Self {
fields: cfg.fields(),
selected: 0,
editing: false,
edit_buf: String::new(),
quit: false,
}
}
fn to_config(&self, base: AppConfig) -> AppConfig {
let mut cfg = base;
let labels: Vec<&str> = self.fields.iter().map(|(k, _)| *k).collect();
for (i, (_, val)) in self.fields.iter().enumerate() {
let _ = cfg.set_field(labels[i], val.clone());
}
cfg
}
}
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default()
.title(" fxg — Configuration ")
.borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
let items: Vec<ListItem> = state.fields.iter().enumerate().map(|(i, (key, val))| {
let active = i == state.selected;
let value_str = if active && state.editing {
format!("{}_", state.edit_buf)
} else {
val.clone()
};
let line = Line::from(vec![
Span::raw(format!(" [{:2}] ", i + 1)),
Span::styled(format!("{:<22}", key), Style::default().fg(Color::Cyan)),
Span::raw(" "),
Span::raw(value_str),
]);
let style = if active {
Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(line).style(style)
}).collect();
f.render_widget(List::new(items), chunks[0]);
let hint = if state.editing {
" [Enter] confirm [Esc] cancel [Backspace] delete"
} else {
" [↑/↓] navigate [Enter] edit [r] reset [q] save & quit"
};
f.render_widget(
Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
}
fn handle_key(state: &mut State, key: KeyEvent) {
if state.editing {
match key.code {
KeyCode::Enter => {
state.fields[state.selected].1 = state.edit_buf.clone();
state.editing = false;
state.edit_buf.clear();
}
KeyCode::Esc => {
state.editing = false;
state.edit_buf.clear();
}
KeyCode::Char(c) => state.edit_buf.push(c),
KeyCode::Backspace => { state.edit_buf.pop(); }
_ => {}
}
return;
}
match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Char('r') => {
let defaults = AppConfig::default().fields();
state.fields = defaults;
}
KeyCode::Up | KeyCode::Char('k') => {
if state.selected > 0 { state.selected -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if state.selected + 1 < state.fields.len() { state.selected += 1; }
}
KeyCode::Enter => {
state.edit_buf = state.fields[state.selected].1.clone();
state.editing = true;
}
_ => {}
}
}
pub fn run_config_tui(cfg: AppConfig) -> Result<AppConfig> {
let mut state = State::new(&cfg);
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
handle_key(&mut state, key);
}
Ok(state.quit)
})?;
Ok(state.to_config(cfg))
}
+203
View File
@@ -0,0 +1,203 @@
pub mod wizard;
use crate::auth::load_accounts;
use crate::cli::InitArgs;
use crate::config::AppConfig;
use crate::providers::make_provider;
use anyhow::Result;
use git2::{IndexAddOption, Repository, Signature};
use std::path::{Path, PathBuf};
use std::time::Instant;
pub struct InitConfig {
pub name: String,
pub account_id: String,
pub private: bool,
pub tags: Vec<String>,
#[allow(dead_code)]
pub branch: String,
pub extra_branches: Vec<String>,
pub readme: bool,
pub gitignore: bool,
pub changelog: bool,
pub env_example: bool,
pub license: bool,
pub contributing: bool,
pub template: String,
pub collaborators: Vec<(String, String)>,
}
// ── git helpers ───────────────────────────────────────────────────────────────
pub fn git_init(path: &Path) -> Result<Repository> { Ok(Repository::init(path)?) }
pub fn write_files(path: &Path, ic: &InitConfig) -> Result<()> {
if ic.readme { std::fs::write(path.join("README.md"), format!("# {}\n", ic.name))?; }
if ic.gitignore { std::fs::write(path.join(".gitignore"), gitignore_content(&ic.template))?; }
if ic.changelog { std::fs::write(path.join("CHANGELOG.md"), "# Changelog\n\nAll notable changes...\n")?; }
if ic.env_example { std::fs::write(path.join(".env.example"), "# Environment variables\n")?; }
if ic.license {
std::fs::write(path.join("LICENSE"), license_mit())?;
}
if ic.contributing {
std::fs::write(path.join("CONTRIBUTING.md"), contributing_md())?;
}
Ok(())
}
fn gitignore_content(t: &str) -> &'static str {
match t { "rust" => "/target\nCargo.lock\n", "python" => "__pycache__/\n*.pyc\n.venv/\n", _ => "/node_modules\ndist/\n.env\n" }
}
fn license_mit() -> &'static str {
"MIT License\n\nCopyright (c) [year] [author]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
}
fn contributing_md() -> &'static str {
"# Contributing\n\nThank you for considering contributing!\n\n## How to contribute\n\n1. Fork the repository\n2. Create a feature branch: `git checkout -b feat/my-feature`\n3. Commit your changes: `git commit -m 'feat: add my feature'`\n4. Push to the branch: `git push origin feat/my-feature`\n5. Open a pull request\n\n## Code style\n\n- Follow existing conventions\n- Write tests for new functionality\n- Update documentation as needed\n"
}
pub fn initial_commit(repo: &Repository, username: &str, email: &str) -> Result<()> {
let mut idx = repo.index()?;
idx.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
idx.write()?;
let tree = repo.find_tree(idx.write_tree()?)?;
let sig = Signature::now(username, email)?;
repo.commit(Some("HEAD"), &sig, &sig, "chore: init", &tree, &[])?;
Ok(())
}
pub fn push_to_remote(repo: &Repository, token: &str, refspec: &str) -> Result<()> {
let mut remote = repo.find_remote("origin")?;
let t = token.to_string();
let mut cb = git2::RemoteCallbacks::new();
cb.credentials(move |_url, user, allowed| {
if allowed.contains(git2::CredentialType::SSH_KEY) { git2::Cred::ssh_key_from_agent(user.unwrap_or("git")) }
else { git2::Cred::userpass_plaintext(user.unwrap_or("git"), &t) }
});
let mut opts = git2::PushOptions::new();
opts.remote_callbacks(cb);
remote.push(&[refspec], Some(&mut opts))?;
Ok(())
}
// ── orchestration ─────────────────────────────────────────────────────────────
pub async fn execute(ic: &InitConfig, cfg: &AppConfig) -> Result<()> {
let accounts = load_accounts(cfg)?;
let account = accounts.iter().find(|a| a.id() == ic.account_id)
.ok_or_else(|| anyhow::anyhow!("account '{}' not found — run: fxg auth add", ic.account_id))?;
let token = crate::auth::get_token(&account.id())?;
let provider = make_provider(account)?;
let t0 = Instant::now();
let info = provider.create_repo(&ic.name, ic.private, "").await?;
println!(" ✓ Created remote repo {} ({})", ic.name, ic.account_id);
if !ic.tags.is_empty() {
if let Err(e) = provider.set_topics(&ic.name, &ic.tags).await {
println!(" ⚠ set_topics: {}", e);
} else {
println!(" ✓ Applied tags: {}", ic.tags.join(", "));
}
}
let repo_path: PathBuf = cfg.workspace_root.join(&ic.name);
std::fs::create_dir_all(&repo_path)?;
println!(" ✓ mkdir {}", repo_path.display());
let repo = git_init(&repo_path)?;
println!(" ✓ git init");
write_files(&repo_path, ic)?;
println!(" ✓ Created initial files");
if let Err(e) = initial_commit(&repo, &cfg.username, &cfg.email) {
println!(" ✗ initial commit: {}", e);
} else {
println!(" ✓ Initial commit: \"chore: init\"");
}
repo.remote("origin", &info.ssh_url)?;
println!(" ✓ git remote add origin {}", info.ssh_url);
if let Err(e) = push_to_remote(&repo, &token, "refs/heads/main:refs/heads/main") {
println!(" ✗ git push: {}", e);
} else {
println!(" ✓ git push -u origin main");
}
for branch in &ic.extra_branches {
let head = repo.head()?.peel_to_commit()?;
let _ = repo.branch(branch, &head, false);
println!(" ✓ Created branch: {}", branch);
}
for (user, role) in &ic.collaborators {
if let Err(e) = provider.add_collaborator(&ic.name, user, role).await {
println!(" ✗ collaborator {}: {}", user, e);
} else {
println!(" ✓ Added collaborator: {} ({})", user, role);
}
}
println!("\n Done in {:.1}s · {}", t0.elapsed().as_secs_f64(), info.url);
Ok(())
}
// ── entry point ───────────────────────────────────────────────────────────────
fn build_noninteractive(args: &InitArgs, accounts: &[crate::auth::Account]) -> Option<InitConfig> {
let name = args.name.clone()?;
let account = crate::providers::default_account(accounts, args.provider.as_deref())?;
Some(InitConfig {
name, account_id: account.id(),
private: args.status != "public",
tags: args.tags.clone(),
branch: args.branch.clone(),
extra_branches: vec![],
readme: true, gitignore: true, changelog: true, env_example: true,
license: false, contributing: false,
template: "blank".into(), collaborators: vec![],
})
}
pub async fn run(args: InitArgs, cfg: &AppConfig) -> Result<()> {
let accounts = load_accounts(cfg)?;
// --batch mode
if let Some(batch_input) = &args.batch {
let names: Vec<String> = if std::path::Path::new(batch_input).exists() {
std::fs::read_to_string(batch_input)?
.lines().map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect()
} else {
batch_input.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
};
let account = crate::providers::default_account(&accounts, args.provider.as_deref())
.ok_or_else(|| anyhow::anyhow!("no account configured"))?;
for name in names {
let ic = InitConfig {
name: name.clone(), account_id: account.id(),
private: args.status != "public", tags: args.tags.clone(),
branch: args.branch.clone(), extra_branches: vec![],
readme: true, gitignore: true, changelog: false, env_example: false,
license: false, contributing: false,
template: "blank".into(), collaborators: vec![],
};
println!("\n══ {} ══", name);
if let Err(e) = execute(&ic, cfg).await { println!(" ✗ {}", e); }
}
return Ok(());
}
// flags-only mode: name + provider both given → skip wizard
if args.name.is_some() && args.provider.is_some() {
let ic = build_noninteractive(&args, &accounts)
.ok_or_else(|| anyhow::anyhow!("no matching account for provider '{}'", args.provider.as_deref().unwrap_or("")))?;
return execute(&ic, cfg).await;
}
// interactive wizard
let ic = wizard::run_wizard(args.name.as_deref(), &accounts)?;
execute(&ic, cfg).await
}
+134
View File
@@ -0,0 +1,134 @@
use crate::auth::Account;
use crate::init::InitConfig;
use anyhow::Result;
use inquire::{Confirm, MultiSelect, Select, Text};
pub fn run_wizard(prefill_name: Option<&str>, accounts: &[Account]) -> Result<InitConfig> {
println!("\n══════════════════════════════════════════");
println!(" fxg — New Repository");
println!("══════════════════════════════════════════\n");
// Step 1 — Name
let name = {
let mut t = Text::new("Step 1/7 · Repository name:");
if let Some(n) = prefill_name {
t = t.with_default(n);
}
t.with_validator(|v: &str| {
use inquire::validator::Validation;
if v.is_empty() {
Ok(Validation::Invalid("Name cannot be empty".into()))
} else if v.contains(' ') {
Ok(Validation::Invalid("Name cannot contain spaces".into()))
} else {
Ok(Validation::Valid)
}
}).prompt()?
};
// Step 2 — Provider & Account
let account_id = if accounts.is_empty() {
anyhow::bail!("No accounts configured. Run: fxg auth add");
} else {
let choices: Vec<String> = accounts.iter().map(|a| a.id()).collect();
let default_idx = accounts.iter().position(|a| a.is_default).unwrap_or(0);
Select::new("Step 2/7 · Provider & Account:", choices.clone())
.with_starting_cursor(default_idx)
.prompt()?
};
// Step 3 — Visibility
let visibility = Select::new(
"Step 3/7 · Visibility:",
vec!["private", "public", "internal"],
).prompt()?;
// Step 4 — Tags
let tags_raw = Text::new("Step 4/7 · Tags (comma-separated):")
.with_default("")
.prompt()?;
let tags: Vec<String> = tags_raw
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
// Step 5 — Files
let file_options = vec![
"README.md", ".gitignore", "CHANGELOG.md", ".env.example", "LICENSE", "CONTRIBUTING.md",
];
let selected_files = MultiSelect::new("Step 5/7 · Initial files:", file_options.clone())
.with_default(&[0, 1, 2, 3])
.prompt()?;
let template = Select::new(
" Template:",
vec!["node", "rust", "python", "blank"],
).prompt()?;
// Step 6 — Branches
let extra_raw = Text::new("Step 6/7 · Extra branches (comma-separated):")
.with_default("dev")
.prompt()?;
let extra_branches: Vec<String> = extra_raw
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
// Step 7 — Collaborators
let add_collabs = Confirm::new("Step 7/7 · Add collaborators?")
.with_default(false)
.prompt()?;
let collaborators = if add_collabs {
collect_collaborators()?
} else {
vec![]
};
println!("\n──────────────────────────────────────────");
let confirmed = Confirm::new("Proceed with creation?")
.with_default(true)
.prompt()?;
if !confirmed {
anyhow::bail!("Cancelled by user");
}
Ok(InitConfig {
name,
account_id,
private: visibility == "private" || visibility == "internal",
tags,
branch: "main".into(),
extra_branches,
readme: selected_files.contains(&"README.md"),
gitignore: selected_files.contains(&".gitignore"),
changelog: selected_files.contains(&"CHANGELOG.md"),
env_example: selected_files.contains(&".env.example"),
license: selected_files.contains(&"LICENSE"),
contributing: selected_files.contains(&"CONTRIBUTING.md"),
template: template.to_string(),
collaborators,
})
}
fn collect_collaborators() -> Result<Vec<(String, String)>> {
let mut result = Vec::new();
loop {
let username = Text::new(" Collaborator username (blank to finish):")
.prompt()?;
if username.is_empty() { break; }
let role = Select::new(" Role:", vec!["read", "write", "admin"])
.with_starting_cursor(1)
.prompt()?;
result.push((username, role.to_string()));
let more = Confirm::new(" Add another?").with_default(false).prompt()?;
if !more { break; }
}
Ok(result)
}
+28
View File
@@ -0,0 +1,28 @@
mod adopt;
mod app;
mod auth;
mod cli;
mod config;
mod init;
mod providers;
mod refactor;
mod release;
mod stats;
mod summary;
mod status;
mod sync;
mod tui;
mod workspace;
use anyhow::Result;
use clap::Parser;
#[tokio::main]
async fn main() -> Result<()> {
let cli = cli::Cli::parse();
if let Err(e) = app::run(cli).await {
eprintln!("error: {:#}", e);
std::process::exit(1);
}
Ok(())
}
+191
View File
@@ -0,0 +1,191 @@
use super::{PrInfo, Provider, RepoInfo, RepoMeta};
use anyhow::{Context, Result};
use async_trait::async_trait;
use reqwest::{header, Client, Response};
use serde_json::{json, Value};
/// Gitea / Forgejo REST API v1 provider.
pub struct GiteaProvider {
client: Client,
owner: String,
base_url: String, // e.g. "https://gitea.example.com/api/v1"
}
impl GiteaProvider {
pub fn new(base_url: &str, owner: &str, token: &str) -> Result<Self> {
let mut headers = header::HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_str(&format!("token {}", token))?,
);
headers.insert(header::USER_AGENT, header::HeaderValue::from_static("fxg/0.1"));
let client = Client::builder().default_headers(headers).build()?;
Ok(Self {
client,
owner: owner.to_string(),
base_url: base_url.trim_end_matches('/').to_string(),
})
}
fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
}
async fn check(resp: Response) -> Result<Value> {
let status = resp.status();
let text = resp.text().await?;
if status.is_success() {
Ok(serde_json::from_str(&text).unwrap_or(Value::Null))
} else {
anyhow::bail!("Gitea API {} — {}", status, text)
}
}
}
#[async_trait]
impl Provider for GiteaProvider {
async fn create_repo(&self, name: &str, private: bool, description: &str) -> Result<RepoInfo> {
let body = json!({ "name": name, "private": private, "description": description, "auto_init": false });
let resp = self.client.post(self.url("user/repos")).json(&body).send().await?;
let v = Self::check(resp).await.context("create_repo")?;
Ok(RepoInfo {
name: v["name"].as_str().unwrap_or(name).to_string(),
url: v["html_url"].as_str().unwrap_or("").to_string(),
ssh_url: v["ssh_url"].as_str().unwrap_or("").to_string(),
})
}
async fn delete_repo(&self, name: &str) -> Result<()> {
let resp = self.client
.delete(self.url(&format!("repos/{}/{}", self.owner, name)))
.send().await?;
Self::check(resp).await.context("delete_repo")?;
Ok(())
}
async fn set_topics(&self, repo: &str, topics: &[String]) -> Result<()> {
let body = json!({ "topics": topics });
let resp = self.client
.put(self.url(&format!("repos/{}/{}/topics", self.owner, repo)))
.json(&body).send().await?;
Self::check(resp).await.context("set_topics")?;
Ok(())
}
async fn create_branch(&self, repo: &str, branch: &str, from: &str) -> Result<()> {
let body = json!({ "new_branch_name": branch, "old_branch_name": from });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/branches", self.owner, repo)))
.json(&body).send().await?;
Self::check(resp).await.context("create_branch")?;
Ok(())
}
async fn create_pull_request(
&self, repo: &str, title: &str, body: &str, head: &str, base: &str,
) -> Result<PrInfo> {
let payload = json!({ "title": title, "body": body, "head": head, "base": base });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/pulls", self.owner, repo)))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_pull_request")?;
Ok(PrInfo {
number: v["number"].as_u64().unwrap_or(0),
url: v["html_url"].as_str().unwrap_or("").to_string(),
})
}
async fn request_reviewers(
&self, repo: &str, pr_number: u64, reviewers: &[String],
) -> Result<()> {
let body = json!({ "reviewers": reviewers });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/pulls/{}/requested_reviewers", self.owner, repo, pr_number)))
.json(&body).send().await?;
Self::check(resp).await.context("request_reviewers")?;
Ok(())
}
async fn add_labels(
&self, repo: &str, issue_number: u64, labels: &[String],
) -> Result<()> {
// Fetch existing labels to find IDs; create any that are missing
let list_resp = self.client
.get(self.url(&format!("repos/{}/{}/labels", self.owner, repo)))
.send().await?;
let existing: Vec<(u64, String)> = if list_resp.status().is_success() {
list_resp.json::<serde_json::Value>().await
.ok()
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|l| l["id"].as_u64().zip(l["name"].as_str().map(|s| s.to_string())))
.collect()
} else {
vec![]
};
let mut ids: Vec<u64> = Vec::new();
for label in labels {
if let Some((id, _)) = existing.iter().find(|(_, n)| n == label) {
ids.push(*id);
} else {
let body = json!({ "name": label, "color": "#0075ca" });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/labels", self.owner, repo)))
.json(&body).send().await?;
if let Ok(v) = Self::check(resp).await {
if let Some(id) = v["id"].as_u64() { ids.push(id); }
}
}
}
if !ids.is_empty() {
let body = json!({ "labels": ids });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/issues/{}/labels", self.owner, repo, issue_number)))
.json(&body).send().await?;
Self::check(resp).await.context("add_labels")?;
}
Ok(())
}
async fn add_collaborator(&self, repo: &str, user: &str, role: &str) -> Result<()> {
let permission = match role { "read" => "read", "admin" => "admin", _ => "write" };
let body = json!({ "permission": permission });
let resp = self.client
.put(self.url(&format!("repos/{}/{}/collaborators/{}", self.owner, repo, user)))
.json(&body).send().await?;
Self::check(resp).await.context("add_collaborator")?;
Ok(())
}
async fn create_release(
&self, repo: &str, tag: &str, title: &str, body: &str, draft: bool,
) -> Result<String> {
let payload = json!({ "tag_name": tag, "name": title, "body": body, "draft": draft });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/releases", self.owner, repo)))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_release")?;
Ok(v["html_url"].as_str().unwrap_or("").to_string())
}
async fn repo_meta(&self, repo: &str) -> Result<RepoMeta> {
let resp = self.client
.get(self.url(&format!("repos/{}/{}", self.owner, repo)))
.send().await?;
let v = Self::check(resp).await.context("repo_meta")?;
Ok(RepoMeta {
open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32,
open_prs: v["open_pr_counter"].as_u64().unwrap_or(0) as u32,
stars: v["stars_count"].as_u64().unwrap_or(0) as u32,
})
}
async fn whoami(&self) -> Result<String> {
let resp = self.client.get(self.url("user")).send().await?;
let v = Self::check(resp).await.context("whoami")?;
Ok(v["login"].as_str().unwrap_or("unknown").to_string())
}
}
+182
View File
@@ -0,0 +1,182 @@
use super::{PrInfo, Provider, RepoInfo, RepoMeta};
use anyhow::{Context, Result};
use async_trait::async_trait;
use reqwest::{header, Client, Response};
use serde_json::{json, Value};
pub struct GitHubProvider {
client: Client,
owner: String,
base_url: String,
}
impl GitHubProvider {
pub fn new(base_url: &str, owner: &str, token: &str) -> Result<Self> {
let mut headers = header::HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_str(&format!("Bearer {}", token))?,
);
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static("application/vnd.github+json"),
);
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static("fxg/0.1"),
);
headers.insert(
"X-GitHub-Api-Version",
header::HeaderValue::from_static("2022-11-28"),
);
let client = Client::builder().default_headers(headers).build()?;
Ok(Self { client, owner: owner.to_string(), base_url: base_url.trim_end_matches('/').to_string() })
}
fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
}
async fn check(resp: Response) -> Result<Value> {
let status = resp.status();
let text = resp.text().await?;
if status.is_success() {
Ok(serde_json::from_str(&text).unwrap_or(Value::Null))
} else {
anyhow::bail!("GitHub API {} — {}", status, text)
}
}
}
#[async_trait]
impl Provider for GitHubProvider {
async fn create_repo(&self, name: &str, private: bool, description: &str) -> Result<RepoInfo> {
let body = json!({ "name": name, "private": private, "description": description });
let resp = self.client.post(self.url("user/repos")).json(&body).send().await?;
let v = Self::check(resp).await.context("create_repo")?;
Ok(RepoInfo {
name: v["name"].as_str().unwrap_or(name).to_string(),
url: v["html_url"].as_str().unwrap_or("").to_string(),
ssh_url: v["ssh_url"].as_str().unwrap_or("").to_string(),
})
}
async fn delete_repo(&self, name: &str) -> Result<()> {
let resp = self.client
.delete(self.url(&format!("repos/{}/{}", self.owner, name)))
.send().await?;
Self::check(resp).await.context("delete_repo")?;
Ok(())
}
async fn set_topics(&self, repo: &str, topics: &[String]) -> Result<()> {
let body = json!({ "names": topics });
let resp = self.client
.put(self.url(&format!("repos/{}/{}/topics", self.owner, repo)))
.json(&body).send().await?;
Self::check(resp).await.context("set_topics")?;
Ok(())
}
async fn create_branch(&self, repo: &str, branch: &str, from: &str) -> Result<()> {
// Resolve 'from' to a SHA first
let ref_url = self.url(&format!("repos/{}/{}/git/ref/heads/{}", self.owner, repo, from));
let resp = self.client.get(&ref_url).send().await?;
let v = Self::check(resp).await.context("resolve ref")?;
let sha = v["object"]["sha"].as_str()
.ok_or_else(|| anyhow::anyhow!("could not resolve SHA for {}", from))?
.to_string();
let body = json!({ "ref": format!("refs/heads/{}", branch), "sha": sha });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/git/refs", self.owner, repo)))
.json(&body).send().await?;
Self::check(resp).await.context("create_branch")?;
Ok(())
}
async fn create_pull_request(
&self, repo: &str, title: &str, body: &str, head: &str, base: &str,
) -> Result<PrInfo> {
let payload = json!({ "title": title, "body": body, "head": head, "base": base });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/pulls", self.owner, repo)))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_pull_request")?;
Ok(PrInfo {
number: v["number"].as_u64().unwrap_or(0),
url: v["html_url"].as_str().unwrap_or("").to_string(),
})
}
async fn add_collaborator(&self, repo: &str, user: &str, role: &str) -> Result<()> {
let body = json!({ "permission": role });
let resp = self.client
.put(self.url(&format!("repos/{}/{}/collaborators/{}", self.owner, repo, user)))
.json(&body).send().await?;
Self::check(resp).await.context("add_collaborator")?;
Ok(())
}
async fn create_release(
&self, repo: &str, tag: &str, title: &str, body: &str, draft: bool,
) -> Result<String> {
let payload = json!({ "tag_name": tag, "name": title, "body": body, "draft": draft });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/releases", self.owner, repo)))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_release")?;
Ok(v["html_url"].as_str().unwrap_or("").to_string())
}
async fn repo_meta(&self, repo: &str) -> Result<RepoMeta> {
let resp = self.client
.get(self.url(&format!("repos/{}/{}", self.owner, repo)))
.send().await?;
let v = Self::check(resp).await.context("repo_meta")?;
let pr_resp = self.client
.get(self.url(&format!("repos/{}/{}/pulls?state=open&per_page=100", self.owner, repo)))
.send().await?;
let open_prs = if pr_resp.status().is_success() {
let pr_list: serde_json::Value = pr_resp.json().await.unwrap_or(serde_json::Value::Array(vec![]));
pr_list.as_array().map(|a| a.len() as u32).unwrap_or(0)
} else {
0
};
Ok(RepoMeta {
open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32,
open_prs,
stars: v["stargazers_count"].as_u64().unwrap_or(0) as u32,
})
}
async fn request_reviewers(
&self, repo: &str, pr_number: u64, reviewers: &[String],
) -> Result<()> {
let body = json!({ "reviewers": reviewers });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/pulls/{}/requested_reviewers", self.owner, repo, pr_number)))
.json(&body).send().await?;
Self::check(resp).await.context("request_reviewers")?;
Ok(())
}
async fn add_labels(
&self, repo: &str, issue_number: u64, labels: &[String],
) -> Result<()> {
let body = json!({ "labels": labels });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/issues/{}/labels", self.owner, repo, issue_number)))
.json(&body).send().await?;
Self::check(resp).await.context("add_labels")?;
Ok(())
}
async fn whoami(&self) -> Result<String> {
let resp = self.client.get(self.url("user")).send().await?;
let v = Self::check(resp).await.context("whoami")?;
Ok(v["login"].as_str().unwrap_or("unknown").to_string())
}
}
+193
View File
@@ -0,0 +1,193 @@
use super::{PrInfo, Provider, RepoInfo, RepoMeta};
use anyhow::{Context, Result};
use async_trait::async_trait;
use reqwest::{header, Client, Response};
use serde_json::{json, Value};
pub struct GitLabProvider {
client: Client,
namespace: String,
base_url: String,
}
impl GitLabProvider {
pub fn new(base_url: &str, namespace: &str, token: &str) -> Result<Self> {
let mut headers = header::HeaderMap::new();
headers.insert(
"PRIVATE-TOKEN",
header::HeaderValue::from_str(token)?,
);
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static("fxg/0.1"),
);
let client = Client::builder().default_headers(headers).build()?;
Ok(Self {
client,
namespace: namespace.to_string(),
base_url: base_url.trim_end_matches('/').to_string(),
})
}
fn url(&self, path: &str) -> String {
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
}
fn repo_id(&self, repo: &str) -> String {
// Percent-encode "namespace/repo" for use in URL path segments
let raw = format!("{}/{}", self.namespace, repo);
raw.replace('/', "%2F")
}
async fn check(resp: Response) -> Result<Value> {
let status = resp.status();
let text = resp.text().await?;
if status.is_success() {
Ok(serde_json::from_str(&text).unwrap_or(Value::Null))
} else {
anyhow::bail!("GitLab API {} — {}", status, text)
}
}
}
#[async_trait]
impl Provider for GitLabProvider {
async fn create_repo(&self, name: &str, private: bool, description: &str) -> Result<RepoInfo> {
let visibility = if private { "private" } else { "public" };
let body = json!({
"name": name,
"visibility": visibility,
"description": description,
"namespace_path": self.namespace,
});
let resp = self.client.post(self.url("projects")).json(&body).send().await?;
let v = Self::check(resp).await.context("create_repo")?;
Ok(RepoInfo {
name: v["name"].as_str().unwrap_or(name).to_string(),
url: v["web_url"].as_str().unwrap_or("").to_string(),
ssh_url: v["ssh_url_to_repo"].as_str().unwrap_or("").to_string(),
})
}
async fn delete_repo(&self, name: &str) -> Result<()> {
let resp = self.client
.delete(self.url(&format!("projects/{}", self.repo_id(name))))
.send().await?;
Self::check(resp).await.context("delete_repo")?;
Ok(())
}
async fn set_topics(&self, repo: &str, topics: &[String]) -> Result<()> {
let body = json!({ "topics": topics });
let resp = self.client
.put(self.url(&format!("projects/{}", self.repo_id(repo))))
.json(&body).send().await?;
Self::check(resp).await.context("set_topics")?;
Ok(())
}
async fn create_branch(&self, repo: &str, branch: &str, from: &str) -> Result<()> {
let body = json!({ "branch": branch, "ref": from });
let resp = self.client
.post(self.url(&format!("projects/{}/repository/branches", self.repo_id(repo))))
.json(&body).send().await?;
Self::check(resp).await.context("create_branch")?;
Ok(())
}
async fn create_pull_request(
&self, repo: &str, title: &str, body: &str, head: &str, base: &str,
) -> Result<PrInfo> {
let payload = json!({
"title": title, "description": body,
"source_branch": head, "target_branch": base,
});
let resp = self.client
.post(self.url(&format!("projects/{}/merge_requests", self.repo_id(repo))))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_pull_request")?;
Ok(PrInfo {
number: v["iid"].as_u64().unwrap_or(0),
url: v["web_url"].as_str().unwrap_or("").to_string(),
})
}
async fn request_reviewers(
&self, repo: &str, pr_number: u64, reviewers: &[String],
) -> Result<()> {
// Resolve usernames to GitLab user IDs
let mut reviewer_ids: Vec<u64> = Vec::new();
for username in reviewers {
let resp = self.client
.get(self.url(&format!("users?username={}", username)))
.send().await?;
if let Ok(v) = Self::check(resp).await {
if let Some(id) = v.as_array()
.and_then(|a| a.first())
.and_then(|u| u["id"].as_u64())
{
reviewer_ids.push(id);
}
}
}
if !reviewer_ids.is_empty() {
let body = json!({ "reviewer_ids": reviewer_ids });
let resp = self.client
.put(self.url(&format!("projects/{}/merge_requests/{}", self.repo_id(repo), pr_number)))
.json(&body).send().await?;
Self::check(resp).await.context("request_reviewers")?;
}
Ok(())
}
async fn add_labels(
&self, repo: &str, pr_number: u64, labels: &[String],
) -> Result<()> {
let body = json!({ "add_labels": labels.join(",") });
let resp = self.client
.put(self.url(&format!("projects/{}/merge_requests/{}", self.repo_id(repo), pr_number)))
.json(&body).send().await?;
Self::check(resp).await.context("add_labels")?;
Ok(())
}
async fn add_collaborator(&self, repo: &str, user: &str, role: &str) -> Result<()> {
// GitLab access levels: 30=developer, 40=maintainer, 50=owner
let level: u8 = match role { "read" => 20, "write" => 30, _ => 40 };
let body = json!({ "user_id": user, "access_level": level });
let resp = self.client
.post(self.url(&format!("projects/{}/members", self.repo_id(repo))))
.json(&body).send().await?;
Self::check(resp).await.context("add_collaborator")?;
Ok(())
}
async fn create_release(
&self, repo: &str, tag: &str, title: &str, body: &str, _draft: bool,
) -> Result<String> {
let payload = json!({ "tag_name": tag, "name": title, "description": body });
let resp = self.client
.post(self.url(&format!("projects/{}/releases", self.repo_id(repo))))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_release")?;
Ok(v["_links"]["self"].as_str().unwrap_or("").to_string())
}
async fn repo_meta(&self, repo: &str) -> Result<RepoMeta> {
let resp = self.client
.get(self.url(&format!("projects/{}", self.repo_id(repo))))
.send().await?;
let v = Self::check(resp).await.context("repo_meta")?;
Ok(RepoMeta {
open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32,
open_prs: v["merge_requests_count"].as_u64().unwrap_or(0) as u32,
stars: v["star_count"].as_u64().unwrap_or(0) as u32,
})
}
async fn whoami(&self) -> Result<String> {
let resp = self.client.get(self.url("user")).send().await?;
let v = Self::check(resp).await.context("whoami")?;
Ok(v["username"].as_str().unwrap_or("unknown").to_string())
}
}
+123
View File
@@ -0,0 +1,123 @@
mod gitea;
mod github;
mod gitlab;
pub use gitea::GiteaProvider;
pub use github::GitHubProvider;
pub use gitlab::GitLabProvider;
use crate::auth::Account;
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoInfo {
pub name: String,
pub url: String,
pub ssh_url: String,
}
#[derive(Debug, Clone)]
pub struct PrInfo {
pub number: u64,
pub url: String,
}
#[derive(Debug, Clone, Default)]
#[allow(dead_code)]
pub struct RepoMeta {
pub open_issues: u32,
pub open_prs: u32,
pub stars: u32,
}
#[async_trait]
#[allow(dead_code)]
pub trait Provider: Send + Sync {
/// Create a new remote repository.
async fn create_repo(
&self, name: &str, private: bool, description: &str,
) -> Result<RepoInfo>;
/// Delete a remote repository.
async fn delete_repo(&self, name: &str) -> Result<()>;
/// Replace the topic list on a repository.
async fn set_topics(&self, repo: &str, topics: &[String]) -> Result<()>;
/// Create a branch from an existing ref.
async fn create_branch(&self, repo: &str, branch: &str, from: &str) -> Result<()>;
/// Open a pull-request / merge-request.
async fn create_pull_request(
&self, repo: &str, title: &str, body: &str, head: &str, base: &str,
) -> Result<PrInfo>;
/// Request reviewers for an open pull request / merge request.
async fn request_reviewers(
&self, _repo: &str, _pr_number: u64, _reviewers: &[String],
) -> Result<()> {
Ok(())
}
/// Add labels to an open pull request / issue.
async fn add_labels(
&self, _repo: &str, _issue_number: u64, _labels: &[String],
) -> Result<()> {
Ok(())
}
/// Add a collaborator to a repository.
async fn add_collaborator(&self, repo: &str, user: &str, role: &str) -> Result<()>;
/// Create a release tag with release notes.
async fn create_release(
&self, repo: &str, tag: &str, title: &str, body: &str, draft: bool,
) -> Result<String>;
/// Fetch basic metadata (issues, PRs, stars).
async fn repo_meta(&self, repo: &str) -> Result<RepoMeta>;
/// Verify the token works, return the authenticated username.
async fn whoami(&self) -> Result<String>;
}
/// Build a boxed `Provider` from an `Account`.
/// The token must already be present in the keyring.
pub fn make_provider(account: &Account) -> Result<Box<dyn Provider>> {
let token = crate::auth::get_token(&account.id())?;
match account.provider.as_str() {
"github" => {
let base = account.base_url.as_deref()
.unwrap_or("https://api.github.com");
Ok(Box::new(GitHubProvider::new(base, &account.username, &token)?))
}
"gitlab" => {
let base = account.base_url.as_deref()
.unwrap_or("https://gitlab.com/api/v4");
Ok(Box::new(GitLabProvider::new(base, &account.username, &token)?))
}
"gitea" | "forgejo" => {
let base = account.base_url.as_deref()
.ok_or_else(|| anyhow::anyhow!("gitea/forgejo requires base_url in account config"))?;
let api_base = if base.ends_with("/api/v1") { base.to_string() } else { format!("{}/api/v1", base.trim_end_matches('/')) };
Ok(Box::new(GiteaProvider::new(&api_base, &account.username, &token)?))
}
other => anyhow::bail!("unsupported provider: {}", other),
}
}
/// Resolve the default account for a given provider name (or the global default).
pub fn default_account<'a>(
accounts: &'a [crate::auth::Account],
provider_hint: Option<&str>,
) -> Option<&'a crate::auth::Account> {
if let Some(p) = provider_hint {
accounts.iter().find(|a| a.provider == p && a.is_default)
.or_else(|| accounts.iter().find(|a| a.provider == p))
} else {
accounts.iter().find(|a| a.is_default)
.or_else(|| accounts.first())
}
}
+185
View File
@@ -0,0 +1,185 @@
pub mod ui;
use crate::cli::{RefactorAction, RefactorArgs};
use crate::config::AppConfig;
use anyhow::Result;
use std::path::Path;
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct FindResult {
pub repo: String,
pub file: String,
pub line: usize,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct CleanResult {
pub file: String,
pub kind: String,
pub line: usize,
pub detail: String,
}
// ── find ──────────────────────────────────────────────────────────────────────
pub fn find_in_dir(pattern: &str, root: &Path, repo_name: &str) -> Vec<FindResult> {
let mut results = Vec::new();
let rx = match regex_simple(pattern) {
Some(r) => r,
None => return results,
};
walk_files(root, &mut |file_path, rel| {
if let Ok(content) = std::fs::read_to_string(&file_path) {
for (i, line) in content.lines().enumerate() {
if rx(line) {
results.push(FindResult {
repo: repo_name.to_string(),
file: rel.clone(),
line: i + 1,
text: line.trim().chars().take(120).collect(),
});
}
}
}
});
results
}
// ── rename ────────────────────────────────────────────────────────────────────
pub fn rename_in_dir(from: &str, to: &str, root: &Path, dry_run: bool) -> Vec<FindResult> {
let mut hits = Vec::new();
walk_files(root, &mut |file_path, rel| {
if let Ok(content) = std::fs::read_to_string(&file_path) {
if content.contains(from) {
let count = content.matches(from).count();
hits.push(FindResult {
repo: String::new(),
file: rel.clone(),
line: count,
text: format!("{} occurrence(s)", count),
});
if !dry_run {
let _ = std::fs::write(&file_path, content.replace(from, to));
}
}
}
});
hits
}
// ── clean ─────────────────────────────────────────────────────────────────────
pub fn find_unused(kind: &str, root: &Path) -> Vec<CleanResult> {
let mut results = Vec::new();
let pattern: &dyn Fn(&str) -> bool = match kind {
"unused-imports" => &|l: &str| l.trim_start().starts_with("use ") && l.contains("// unused"),
"unused-vars" => &|l: &str| l.contains("_unused") || l.contains("let _"),
"dead-code" => &|l: &str| l.contains("#[allow(dead_code)]"),
"unused-functions" => &|l: &str| l.contains("fn ") && l.contains("// dead"),
_ => return results,
};
walk_files(root, &mut |file_path, rel| {
if let Ok(content) = std::fs::read_to_string(&file_path) {
for (i, line) in content.lines().enumerate() {
if pattern(line) {
results.push(CleanResult {
file: rel.clone(), kind: kind.to_string(),
line: i + 1, detail: line.trim().chars().take(80).collect(),
});
}
}
}
});
results
}
// ── generate ──────────────────────────────────────────────────────────────────
pub fn generate_todo_list(root: &Path) -> Vec<FindResult> {
find_in_dir("TODO|FIXME|HACK|XXX", root, "")
}
// ── helpers ───────────────────────────────────────────────────────────────────
fn walk_files<F: FnMut(std::path::PathBuf, String)>(root: &Path, cb: &mut F) {
let Ok(entries) = std::fs::read_dir(root) else { return };
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if !matches!(name.as_ref(), ".git" | "target" | "node_modules" | ".next") {
walk_files(&path, cb);
}
} else if let Some(ext) = path.extension() {
if matches!(ext.to_string_lossy().as_ref(), "rs" | "ts" | "js" | "py" | "go" | "md") {
let rel = path.to_string_lossy().to_string();
cb(path, rel);
}
}
}
}
fn regex_simple(pattern: &str) -> Option<Box<dyn Fn(&str) -> bool>> {
// Simple substring/literal match (not full regex to avoid extra dep)
let p = pattern.to_string();
Some(Box::new(move |line: &str| line.contains(p.as_str())))
}
// ── entry point ───────────────────────────────────────────────────────────────
pub async fn run(args: RefactorArgs, cfg: &AppConfig) -> Result<()> {
let root = &cfg.workspace_root;
match args.action {
None => ui::run_refactor_tui(cfg),
Some(RefactorAction::Find { pattern, repos, .. }) => {
let search_root = repos.as_ref()
.map(|r| root.join(r))
.unwrap_or_else(|| root.clone());
let results = find_in_dir(&pattern, &search_root, "");
if results.is_empty() {
println!("No matches found for '{}'", pattern);
} else {
for r in &results {
println!(" {}:{} — {}", r.file, r.line, r.text);
}
println!("\n {} match(es)", results.len());
}
Ok(())
}
Some(RefactorAction::Rename { from, to, repos, dry_run, kind: _ }) => {
let search_root = repos.first()
.map(|r| root.join(r))
.unwrap_or_else(|| root.clone());
let hits = rename_in_dir(&from, &to, &search_root, dry_run);
let label = if dry_run { "Would rename" } else { "Renamed" };
for h in &hits {
println!(" {} {} occurrence(s) in {}", label, h.line, h.file);
}
println!("\n {} file(s) affected", hits.len());
Ok(())
}
Some(RefactorAction::Clean { target }) => {
let results = find_unused(&target, root);
for r in &results {
println!(" {}:{} — {}", r.file, r.line, r.detail);
}
println!("\n {} item(s) flagged", results.len());
Ok(())
}
Some(RefactorAction::Generate { target }) => {
match target.as_str() {
"todo" => {
for r in generate_todo_list(root) {
println!(" {}:{} — {}", r.file, r.line, r.text);
}
}
_ => println!("Unknown generate target: {}", target),
}
Ok(())
}
}
}
+180
View File
@@ -0,0 +1,180 @@
use crate::config::AppConfig;
use crate::refactor::{CleanResult, FindResult};
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
#[derive(PartialEq)]
enum View { Menu, FindResults, CleanResults }
struct State {
view: View,
find_results: Vec<FindResult>,
clean_results: Vec<CleanResult>,
cursor: usize,
scroll: usize,
input_buf: String,
input_active: bool,
status: Option<String>,
quit: bool,
}
const MENU_ITEMS: &[&str] = &[
"[1] Rename identifier",
"[2] Find pattern",
"[3] Clean unused",
"[4] Generate TODO list",
];
impl State {
fn new() -> Self {
Self {
view: View::Menu, find_results: vec![], clean_results: vec![],
cursor: 0, scroll: 0, input_buf: String::new(), input_active: false,
status: None, quit: false,
}
}
}
fn render_menu(f: &mut Frame, state: &State, area: Rect) {
let block = Block::default().title(" Refactor operations ").borders(Borders::ALL);
let items: Vec<ListItem> = MENU_ITEMS.iter().enumerate().map(|(i, item)| {
let style = if i == state.cursor { Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD) }
else { Style::default() };
ListItem::new(format!(" {}", item)).style(style)
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render_find(f: &mut Frame, state: &State, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
// Left: file list
let left_block = Block::default().title(" Files ").borders(Borders::ALL);
let items: Vec<ListItem> = state.find_results.iter().enumerate()
.skip(state.scroll)
.take(chunks[0].height.saturating_sub(2) as usize)
.map(|(i, r)| {
let style = if i == state.cursor { Style::default().bg(Color::DarkGray) }
else { Style::default() };
ListItem::new(format!(" {}:{}", r.file.split('/').last().unwrap_or(&r.file), r.line)).style(style)
}).collect();
f.render_widget(List::new(items).block(left_block), chunks[0]);
// Right: context
let right_block = Block::default().title(" Context ").borders(Borders::ALL);
let preview = state.find_results.get(state.cursor)
.map(|r| format!(" {}:{}\n\n {}", r.file, r.line, r.text))
.unwrap_or_else(|| " No selection".into());
f.render_widget(Paragraph::new(preview).block(right_block), chunks[1]);
}
fn render_clean(f: &mut Frame, state: &State, area: Rect) {
let block = Block::default().title(" Clean results ").borders(Borders::ALL);
let items: Vec<ListItem> = state.clean_results.iter().map(|r| {
ListItem::new(format!(" {}:{} [{}] — {}", r.file, r.line, r.kind, r.detail))
.style(Style::default().fg(Color::Yellow))
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default().title(" fxg — Refactor ").borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(2)])
.split(inner);
match state.view {
View::Menu => render_menu(f, state, chunks[0]),
View::FindResults => render_find(f, state, chunks[0]),
View::CleanResults => render_clean(f, state, chunks[0]),
}
let hint_text = if state.input_active {
format!(" Pattern: {}_ ", state.input_buf)
} else {
let base = match state.view {
View::Menu => " [Enter] select [q] quit",
View::FindResults => " [j/k] navigate [Esc] back [q] quit",
View::CleanResults => " [Esc] back [q] quit",
};
if let Some(s) = &state.status { format!(" {} — {}", s, base) } else { base.into() }
};
f.render_widget(Paragraph::new(hint_text).style(Style::default().fg(Color::DarkGray)), chunks[1]);
}
fn handle_key(state: &mut State, key: crossterm::event::KeyEvent, cfg: &AppConfig) {
if state.input_active {
match key.code {
KeyCode::Enter => {
let results = crate::refactor::find_in_dir(&state.input_buf, &cfg.workspace_root, "workspace");
state.find_results = results;
state.cursor = 0;
state.scroll = 0;
state.view = View::FindResults;
state.input_active = false;
}
KeyCode::Esc => { state.input_active = false; state.input_buf.clear(); }
KeyCode::Char(c) => state.input_buf.push(c),
KeyCode::Backspace => { state.input_buf.pop(); }
_ => {}
}
return;
}
match state.view {
View::Menu => match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Up | KeyCode::Char('k') => { if state.cursor > 0 { state.cursor -= 1; } }
KeyCode::Down | KeyCode::Char('j') => { if state.cursor + 1 < MENU_ITEMS.len() { state.cursor += 1; } }
KeyCode::Enter | KeyCode::Char('2') => {
state.input_active = true;
state.input_buf.clear();
state.status = Some("Type pattern and press Enter".into());
}
KeyCode::Char('3') => {
state.clean_results = crate::refactor::find_unused("dead-code", &cfg.workspace_root);
state.view = View::CleanResults;
state.cursor = 0;
}
KeyCode::Char('4') => {
state.find_results = crate::refactor::generate_todo_list(&cfg.workspace_root);
state.view = View::FindResults;
state.cursor = 0;
}
_ => {}
},
View::FindResults | View::CleanResults => match key.code {
KeyCode::Esc | KeyCode::Char('b') => { state.view = View::Menu; state.cursor = 0; }
KeyCode::Char('q') => state.quit = true,
KeyCode::Up | KeyCode::Char('k') => { if state.cursor > 0 { state.cursor -= 1; } }
KeyCode::Down | KeyCode::Char('j') => state.cursor += 1,
_ => {}
},
}
}
pub fn run_refactor_tui(cfg: &AppConfig) -> Result<()> {
let mut state = State::new();
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
handle_key(&mut state, key, cfg);
}
Ok(state.quit)
})
}
+160
View File
@@ -0,0 +1,160 @@
pub mod ui;
use crate::auth::load_accounts;
use crate::cli::{ReleaseAction, ReleaseArgs};
use crate::config::AppConfig;
use crate::providers::make_provider;
use anyhow::Result;
use git2::{Repository, Signature};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct ChangelogEntry {
pub kind: String,
pub scope: Option<String>,
pub message: String,
pub sha: String,
}
fn parse_cc(subject: &str) -> Option<(String, Option<String>, String)> {
let (head, tail) = subject.split_once(':')?;
let msg = tail.trim().to_string();
if let Some((kind, scope_raw)) = head.split_once('(') {
Some((kind.to_string(), Some(scope_raw.trim_end_matches(')').to_string()), msg))
} else {
Some((head.to_string(), None, msg))
}
}
pub fn generate_changelog(repo: &Repository, since_tag: Option<&str>) -> Result<Vec<ChangelogEntry>> {
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
let stop_id = since_tag.and_then(|t| repo.revparse_single(t).ok().map(|o| o.id()));
let mut entries = Vec::new();
for id in revwalk {
let id = id?;
if stop_id.map(|s| s == id).unwrap_or(false) { break; }
let commit = repo.find_commit(id)?;
if let Some((kind, scope, message)) = parse_cc(commit.summary().unwrap_or("")) {
entries.push(ChangelogEntry { kind, scope, message, sha: id.to_string()[..7].to_string() });
}
}
Ok(entries)
}
pub fn format_changelog_md(entries: &[ChangelogEntry], version: &str) -> String {
let mut out = format!("## {}\n\n", version);
for (section, header) in &[("feat","### Features"),("fix","### Bug Fixes"),("refactor","### Refactoring"),("chore","### Chores"),("docs","### Documentation")] {
let group: Vec<_> = entries.iter().filter(|e| e.kind == *section).collect();
if group.is_empty() { continue; }
out.push_str(&format!("{}\n\n", header));
for e in group {
let scope_str = e.scope.as_ref().map(|s| format!("**{}**: ", s)).unwrap_or_default();
out.push_str(&format!("- {}{} ({})\n", scope_str, e.message, e.sha));
}
out.push('\n');
}
out
}
fn latest_tag(repo: &Repository) -> Option<String> {
repo.tag_names(None).ok()?.iter().flatten().map(|s| s.to_string()).last()
}
fn bump_version(tag: Option<&str>, release_type: &str) -> String {
let v = tag.unwrap_or("v0.0.0").trim_start_matches('v');
let p: Vec<u64> = v.split('.').filter_map(|x| x.parse().ok()).collect();
let (mj, mn, pt) = (p.first().copied().unwrap_or(0), p.get(1).copied().unwrap_or(0), p.get(2).copied().unwrap_or(0));
match release_type { "major" => format!("v{}.0.0", mj+1), "minor" => format!("v{}.{}.0", mj, mn+1), _ => format!("v{}.{}.{}", mj, mn, pt+1) }
}
fn create_local_tag(repo: &Repository, version: &str, cfg: &AppConfig) -> Result<()> {
let head = repo.head()?.peel_to_commit()?;
let sig = Signature::now(&cfg.username, &cfg.email)?;
repo.tag(version, head.as_object(), &sig, &format!("Release {}", version), false)?;
Ok(())
}
fn push_tag(repo: &Repository, token: &str, version: &str) -> Result<()> {
let t = token.to_string();
let mut remote = repo.find_remote("origin")?;
let mut cb = git2::RemoteCallbacks::new();
cb.credentials(move |_url, user, allowed| {
if allowed.contains(git2::CredentialType::SSH_KEY) { git2::Cred::ssh_key_from_agent(user.unwrap_or("git")) }
else { git2::Cred::userpass_plaintext(user.unwrap_or("git"), &t) }
});
let mut opts = git2::PushOptions::new();
opts.remote_callbacks(cb);
let refspec = format!("refs/tags/{0}:refs/tags/{0}", version);
remote.push(&[&refspec], Some(&mut opts))?;
Ok(())
}
fn tag_one_repo(path: &Path, version: &str, token: &str, cfg: &AppConfig) -> Result<()> {
let repo = Repository::open(path)?;
create_local_tag(&repo, version, cfg)?;
push_tag(&repo, token, version)?;
Ok(())
}
pub async fn run(args: ReleaseArgs, cfg: &AppConfig) -> Result<()> {
let cwd = std::env::current_dir()?;
let repo = Repository::open(&cwd)?;
let accounts = load_accounts(cfg)?;
let account = crate::providers::default_account(&accounts, None)
.ok_or_else(|| anyhow::anyhow!("no account configured — run: fxg auth add"))?;
let token = crate::auth::get_token(&account.id())?;
match args.action {
None => {
let entries = generate_changelog(&repo, latest_tag(&repo).as_deref())?;
if let Some((version, title, body, draft)) = ui::run_release_tui(&repo, entries)? {
println!(" Publishing {}…", version);
create_local_tag(&repo, &version, cfg)?;
push_tag(&repo, &token, &version)?;
println!(" ✓ Tag pushed");
let provider = make_provider(account)?;
let repo_name = cwd.file_name().unwrap_or_default().to_string_lossy();
match provider.create_release(&repo_name, &version, &title, &body, draft).await {
Ok(url) => println!(" ✓ Release created: {}", url),
Err(e) => println!(" ✗ Release API: {}", e),
}
}
}
Some(ReleaseAction::Create { version, release_type, generate_changelog: gc }) => {
let last = latest_tag(&repo);
let ver = version.unwrap_or_else(|| bump_version(last.as_deref(), &release_type));
let body = if gc { let e = generate_changelog(&repo, last.as_deref())?; format_changelog_md(&e, &ver) } else { String::new() };
create_local_tag(&repo, &ver, cfg)?;
println!("✓ Created tag {}", ver);
if let Err(e) = push_tag(&repo, &token, &ver) { println!(" ⚠ push tag: {}", e); } else { println!("✓ Pushed tag"); }
if !body.is_empty() {
let provider = make_provider(account)?;
let repo_name = cwd.file_name().unwrap_or_default().to_string_lossy();
match provider.create_release(&repo_name, &ver, &ver, &body, false).await {
Ok(url) => println!("✓ Release: {}", url),
Err(e) => println!("⚠ Release API: {}", e),
}
}
}
Some(ReleaseAction::List) => {
let names = repo.tag_names(None)?;
for name in names.iter().flatten() { println!(" {}", name); }
}
Some(ReleaseAction::Tag { version, repos }) => {
let targets: Vec<_> = if repos.is_empty() {
vec![cwd.clone()]
} else {
repos.iter().map(|r| cfg.workspace_root.join(r)).collect()
};
for path in targets {
let name = path.file_name().unwrap_or_default().to_string_lossy();
match tag_one_repo(&path, &version, &token, cfg) {
Ok(()) => println!(" ✓ {} tagged {}", name, version),
Err(e) => println!(" ✗ {}: {}", name, e),
}
}
}
}
Ok(())
}
+185
View File
@@ -0,0 +1,185 @@
use crate::release::{format_changelog_md, ChangelogEntry};
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use git2::Repository;
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
#[derive(PartialEq, Clone, Copy)]
enum Step { Version, ReleaseType, ChangelogReview, Confirm }
struct State {
step: Step,
version: String,
rtype: usize, // index into RELEASE_TYPES
changelog: String,
draft: bool,
input_buf: String,
quit: bool,
confirmed: bool,
scroll: u16,
}
const RELEASE_TYPES: &[&str] = &["patch", "minor", "major"];
const STEPS: &[&str] = &["Version", "Type", "Changelog", "Confirm"];
impl State {
fn new(version: String, changelog: String) -> Self {
Self {
step: Step::Version, version: version.clone(), rtype: 0,
changelog, draft: false, input_buf: version,
quit: false, confirmed: false, scroll: 0,
}
}
fn next(&mut self) {
self.step = match self.step {
Step::Version => { self.version = self.input_buf.clone(); Step::ReleaseType }
Step::ReleaseType => Step::ChangelogReview,
Step::ChangelogReview => Step::Confirm,
Step::Confirm => { self.confirmed = true; self.quit = true; return; }
};
}
fn prev(&mut self) {
self.step = match self.step {
Step::ReleaseType => Step::Version,
Step::ChangelogReview => Step::ReleaseType,
Step::Confirm => Step::ChangelogReview,
_ => return,
};
}
}
fn step_bar(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let idx = match state.step { Step::Version => 0, Step::ReleaseType => 1, Step::ChangelogReview => 2, Step::Confirm => 3 };
let text = STEPS.iter().enumerate().map(|(i, s)| {
if i == idx { format!(" [{}] ", s) } else { format!(" {} ", s) }
}).collect::<Vec<_>>().join("→");
f.render_widget(
Paragraph::new(text).style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
area,
);
}
fn render_version(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let block = Block::default().title(" Version tag ").borders(Borders::ALL);
let text = format!(" > {}_ \n\n e.g. v2.1.0", state.input_buf);
f.render_widget(Paragraph::new(text).block(block), area);
}
fn render_rtype(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let block = Block::default().title(" Release type ").borders(Borders::ALL);
let items: Vec<ListItem> = RELEASE_TYPES.iter().enumerate().map(|(i, t)| {
let style = if i == state.rtype { Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) }
else { Style::default() };
ListItem::new(format!(" {}", t)).style(style)
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render_changelog(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let block = Block::default().title(" Changelog preview (↑/↓ scroll) ").borders(Borders::ALL);
let lines: Vec<Line> = state.changelog.lines().map(|l| Line::from(l.to_string())).collect();
f.render_widget(Paragraph::new(lines).block(block).scroll((state.scroll, 0)), area);
}
fn render_confirm(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let block = Block::default().title(" Confirm release ").borders(Borders::ALL);
let text = format!(
"\n Version : {}\n Type : {}\n Draft : {}\n\n [Enter] publish [d] toggle draft [Esc] cancel",
state.version, RELEASE_TYPES[state.rtype], state.draft
);
f.render_widget(Paragraph::new(text).block(block), area);
}
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default().title(" fxg — New Release ").borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
step_bar(f, state, chunks[0]);
match state.step {
Step::Version => render_version(f, state, chunks[1]),
Step::ReleaseType => render_rtype(f, state, chunks[1]),
Step::ChangelogReview => render_changelog(f, state, chunks[1]),
Step::Confirm => render_confirm(f, state, chunks[1]),
}
f.render_widget(
Paragraph::new(" [Enter] next [b] back [Esc] cancel")
.style(Style::default().fg(Color::DarkGray)),
chunks[2],
);
}
fn handle_key(state: &mut State, key: crossterm::event::KeyEvent) {
match state.step {
Step::Version => match key.code {
KeyCode::Enter => state.next(),
KeyCode::Esc => state.quit = true,
KeyCode::Char('b') => state.prev(),
KeyCode::Char(c) => state.input_buf.push(c),
KeyCode::Backspace => { state.input_buf.pop(); }
_ => {}
},
Step::ReleaseType => match key.code {
KeyCode::Enter => state.next(),
KeyCode::Esc => state.quit = true,
KeyCode::Char('b') => state.prev(),
KeyCode::Up | KeyCode::Char('k') => { if state.rtype > 0 { state.rtype -= 1; } }
KeyCode::Down | KeyCode::Char('j') => { if state.rtype + 1 < RELEASE_TYPES.len() { state.rtype += 1; } }
_ => {}
},
Step::ChangelogReview => match key.code {
KeyCode::Enter => state.next(),
KeyCode::Esc => state.quit = true,
KeyCode::Char('b') => state.prev(),
KeyCode::Up | KeyCode::Char('k') => { if state.scroll > 0 { state.scroll -= 1; } }
KeyCode::Down | KeyCode::Char('j') => state.scroll += 1,
_ => {}
},
Step::Confirm => match key.code {
KeyCode::Enter => state.next(),
KeyCode::Esc => state.quit = true,
KeyCode::Char('d') => state.draft = !state.draft,
KeyCode::Char('b') => state.prev(),
_ => {}
},
}
}
pub fn run_release_tui(
_repo: &Repository,
entries: Vec<ChangelogEntry>,
) -> Result<Option<(String, String, String, bool)>> {
let version = "v0.1.0".to_string();
let changelog = format_changelog_md(&entries, &version);
let mut state = State::new(version, changelog);
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
handle_key(&mut state, key);
}
Ok(state.quit)
})?;
if state.confirmed {
let body = format_changelog_md(&entries, &state.version);
Ok(Some((state.version.clone(), state.version, body, state.draft)))
} else {
Ok(None)
}
}
+292
View File
@@ -0,0 +1,292 @@
pub mod ui;
use crate::cli::{StatsAction, StatsArgs};
use crate::config::AppConfig;
use anyhow::Result;
use std::path::Path;
#[derive(Debug, Clone, Default)]
pub struct CodeStats {
pub total_lines: usize,
pub file_count: usize,
pub function_count: usize,
pub avg_fn_length: f64,
pub languages: Vec<(String, f64)>,
pub todos: Vec<TodoItem>,
pub sec_warnings: Vec<SecWarning>,
pub time_entries: Vec<TimeEntry>,
}
#[derive(Debug, Clone)]
pub struct TodoItem {
pub file: String,
pub line: usize,
pub kind: String,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct SecWarning {
pub file: String,
pub line: usize,
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct TimeEntry {
pub file: String,
pub duration_mins: u64, // stored as minutes
}
// ── VSC time data ─────────────────────────────────────────────────────────────
/// Load time-per-file data from ~/.fxg/vsc-data/<repo_name>.json
/// Expected format: `{ "files": { "src/main.rs": 3600 } }` (seconds)
pub fn load_vsc_data(vsc_dir: &Path, repo_name: &str) -> Vec<TimeEntry> {
let path = vsc_dir.join(format!("{}.json", repo_name));
let Ok(raw) = std::fs::read_to_string(&path) else { return vec![] };
let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw) else { return vec![] };
let Some(files) = val.get("files").and_then(|f| f.as_object()) else { return vec![] };
let mut entries: Vec<TimeEntry> = files.iter()
.filter_map(|(file, secs)| {
let s = secs.as_u64()?;
Some(TimeEntry { file: file.clone(), duration_mins: s / 60 })
}).collect();
entries.sort_by(|a, b| b.duration_mins.cmp(&a.duration_mins));
entries
}
// ── language detection ────────────────────────────────────────────────────────
fn detect_lang(ext: &str) -> Option<&'static str> {
Some(match ext {
"rs" => "Rust",
"ts" | "tsx" => "TypeScript",
"js" | "jsx" => "JavaScript",
"py" => "Python",
"go" => "Go",
"java" => "Java",
"sql" => "SQL",
"sh" => "Shell",
"toml" => "TOML",
"json" => "JSON",
"md" => "Markdown",
_ => return None,
})
}
fn count_fns(content: &str, lang: &str) -> usize {
let pat = match lang { "Rust" => " fn ", "Python" => "def ", "Go" => "func ", _ => "function " };
content.matches(pat).count()
}
// ── secret heuristics ─────────────────────────────────────────────────────────
fn scan_secrets(content: &str, file: &str) -> Vec<SecWarning> {
let mut warnings = Vec::new();
for (i, line) in content.lines().enumerate() {
let l = line.to_lowercase();
if (l.contains("secret") || l.contains("api_key") || l.contains("password") || l.contains("jwt_secret"))
&& line.contains('=') && !line.trim().starts_with("//") && !line.trim().starts_with('#')
&& !line.ends_with("\"\"") && !line.ends_with("''")
{
warnings.push(SecWarning { file: file.to_string(), line: i + 1, reason: "Possible hardcoded secret".into() });
}
}
warnings
}
// ── file walker ───────────────────────────────────────────────────────────────
fn walk_collect(root: &Path, stats: &mut CodeStats) {
let Ok(entries) = std::fs::read_dir(root) else { return };
let mut lang_lines: std::collections::HashMap<String, usize> = Default::default();
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
let name = path.file_name().unwrap_or_default().to_string_lossy();
if !matches!(name.as_ref(), ".git" | "target" | "node_modules") { walk_collect(&path, stats); }
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let lang = match detect_lang(ext) { Some(l) => l, None => continue };
let Ok(content) = std::fs::read_to_string(&path) else { continue };
let line_count = content.lines().count();
stats.total_lines += line_count;
stats.file_count += 1;
*lang_lines.entry(lang.to_string()).or_default() += line_count;
stats.function_count += count_fns(&content, lang);
let rel = path.to_string_lossy().to_string();
for (i, line) in content.lines().enumerate() {
for kw in &["TODO", "FIXME", "HACK", "XXX"] {
if line.contains(kw) {
stats.todos.push(TodoItem { file: rel.clone(), line: i + 1, kind: kw.to_string(), text: line.trim().chars().take(80).collect() });
break;
}
}
}
stats.sec_warnings.extend(scan_secrets(&content, &rel));
}
let total: usize = lang_lines.values().sum();
stats.languages = lang_lines.iter()
.map(|(l, c)| (l.clone(), if total > 0 { *c as f64 / total as f64 * 100.0 } else { 0.0 }))
.collect();
stats.languages.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
}
pub fn collect_stats(root: &Path, vsc_dir: Option<&Path>) -> Result<CodeStats> {
let mut stats = CodeStats::default();
walk_collect(root, &mut stats);
if stats.function_count > 0 {
stats.avg_fn_length = stats.total_lines as f64 / stats.function_count as f64;
}
if let Some(vsc) = vsc_dir {
let repo_name = root.file_name().unwrap_or_default().to_string_lossy();
stats.time_entries = load_vsc_data(vsc, &repo_name);
}
Ok(stats)
}
pub async fn run(args: StatsArgs, cfg: &AppConfig) -> Result<()> {
let root = args.repo.as_ref().map(|r| cfg.workspace_root.join(r)).unwrap_or_else(|| cfg.workspace_root.clone());
let vsc_dir = cfg.vsc_data_dir.exists().then(|| cfg.vsc_data_dir.as_path());
let stats = collect_stats(&root, vsc_dir)?;
let save = args.save;
let mut output = String::new();
match args.action {
None => {
if save {
let md = stats_to_md(&stats, &root);
save_stats_md(&root, &md)?;
println!(" ✓ Saved to {}/.fxg/stats.md", root.file_name().unwrap_or_default().to_string_lossy());
} else {
return ui::run_stats_tui(stats);
}
}
Some(StatsAction::Security) => {
if stats.sec_warnings.is_empty() {
output.push_str(" ✓ No security warnings found\n");
} else {
for w in &stats.sec_warnings {
output.push_str(&format!(" ⚠ {}:{} — {}\n", w.file, w.line, w.reason));
}
}
}
Some(StatsAction::Lines) => {
output.push_str(&format!(" Lines: {} Files: {}\n", stats.total_lines, stats.file_count));
}
Some(StatsAction::Languages) => {
for (l, p) in &stats.languages {
output.push_str(&format!(" {:15} {:.1}%\n", l, p));
}
}
Some(StatsAction::Functions) => {
output.push_str(&format!(" Functions: {} Avg length: {:.1} lines\n", stats.function_count, stats.avg_fn_length));
}
Some(StatsAction::Time { repo, since }) => {
let r = repo.as_ref().map(|r| cfg.workspace_root.join(r)).unwrap_or_else(|| root.clone());
let name = r.file_name().unwrap_or_default().to_string_lossy();
let mut entries = load_vsc_data(&cfg.vsc_data_dir, &name);
if let Some(since_str) = &since {
use chrono::NaiveDate;
if let Ok(date) = NaiveDate::parse_from_str(since_str, "%Y-%m-%d") {
let since_ts = date.and_hms_opt(0, 0, 0)
.map(|dt| dt.and_utc().timestamp())
.unwrap_or(0);
entries.retain(|e| {
let full_path = r.join(&e.file);
std::fs::metadata(&full_path)
.and_then(|m| m.modified())
.map(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64 >= since_ts)
.unwrap_or(true)
})
.unwrap_or(true)
});
}
}
if entries.is_empty() {
output.push_str(&format!(" No VSC data at {}/{}.json\n", cfg.vsc_data_dir.display(), name));
} else {
let max = entries.first().map(|e| e.duration_mins).unwrap_or(1);
output.push_str(&format!(" {:40} {:>8} BAR\n", "FILE", "TIME"));
output.push_str(&format!(" {}\n", "─".repeat(70)));
for e in &entries {
let bar_w = (e.duration_mins as f64 / max as f64 * 20.0).round() as usize;
output.push_str(&format!(" {:40} {:5}m {}\n",
e.file.split('/').last().unwrap_or(&e.file),
e.duration_mins,
"█".repeat(bar_w)));
}
let total_mins: u64 = entries.iter().map(|e| e.duration_mins).sum();
output.push_str(&format!("\n Total: {}h {}m\n", total_mins / 60, total_mins % 60));
}
}
}
if !output.is_empty() {
print!("{}", output);
if save {
let md = format!("# Stats\n\n```\n{}```\n", output);
save_stats_md(&root, &md)?;
println!(" ✓ Saved to {}/.fxg/stats.md", root.file_name().unwrap_or_default().to_string_lossy());
}
}
Ok(())
}
fn save_stats_md(root: &Path, content: &str) -> Result<()> {
let dir = root.join(".fxg");
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("stats.md"), content)?;
Ok(())
}
fn stats_to_md(stats: &CodeStats, root: &Path) -> String {
let name = root.file_name().unwrap_or_default().to_string_lossy();
let mut md = format!("# Stats — {}\n\n", name);
md.push_str(&format!("> Generated {}\n\n", chrono::Utc::now().format("%Y-%m-%d %H:%M UTC")));
md.push_str("## Summary\n\n");
md.push_str(&format!("| Metric | Value |\n|---|---|\n"));
md.push_str(&format!("| Total lines | {} |\n", stats.total_lines));
md.push_str(&format!("| Files | {} |\n", stats.file_count));
md.push_str(&format!("| Functions | {} |\n", stats.function_count));
md.push_str(&format!("| Avg fn length | {:.1} lines |\n", stats.avg_fn_length));
md.push_str(&format!("| TODOs | {} |\n", stats.todos.len()));
md.push_str(&format!("| Security warnings | {} |\n\n", stats.sec_warnings.len()));
if !stats.languages.is_empty() {
md.push_str("## Languages\n\n| Language | Share |\n|---|---|\n");
for (lang, pct) in &stats.languages {
md.push_str(&format!("| {} | {:.1}% |\n", lang, pct));
}
md.push('\n');
}
if !stats.todos.is_empty() {
md.push_str("## TODOs\n\n| Kind | File | Line |\n|---|---|---|\n");
for t in &stats.todos {
let f = t.file.split(['/', '\\']).last().unwrap_or(&t.file);
md.push_str(&format!("| `{}` | `{}` | {} |\n", t.kind, f, t.line));
}
md.push('\n');
}
if !stats.sec_warnings.is_empty() {
md.push_str("## Security Warnings\n\n| File | Line | Reason |\n|---|---|---|\n");
for w in &stats.sec_warnings {
let f = w.file.split(['/', '\\']).last().unwrap_or(&w.file);
md.push_str(&format!("| `{}` | {} | {} |\n", f, w.line, w.reason));
}
md.push('\n');
}
md
}
+176
View File
@@ -0,0 +1,176 @@
use crate::stats::{CodeStats, SecWarning, TodoItem};
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, Gauge, List, ListItem, Paragraph, Row, Table, Tabs},
Frame,
};
#[derive(Clone, Copy, PartialEq)]
enum Tab { Overview, Security, Todos, Languages }
struct State {
stats: CodeStats,
tab: Tab,
scroll: usize,
quit: bool,
}
const TABS: &[&str] = &["Overview", "Security", "TODOs", "Languages"];
fn tab_index(t: Tab) -> usize {
match t { Tab::Overview => 0, Tab::Security => 1, Tab::Todos => 2, Tab::Languages => 3 }
}
fn make_bar(value: f64, max: f64, width: usize) -> String {
if max == 0.0 { return " ".repeat(width); }
let filled = ((value / max) * width as f64).round() as usize;
let filled = filled.min(width);
format!("{}{}", "█".repeat(filled), "░".repeat(width - filled))
}
fn render_overview(f: &mut Frame, stats: &CodeStats, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(8), Constraint::Fill(1)])
.split(area);
// Summary block
let summary = vec![
Line::from(vec![Span::raw(" Lines of code : "), Span::styled(stats.total_lines.to_string(), Style::default().fg(Color::Cyan))]),
Line::from(vec![Span::raw(" Files : "), Span::raw(stats.file_count.to_string())]),
Line::from(vec![Span::raw(" Functions : "), Span::raw(format!("{} avg length: {:.1} lines", stats.function_count, stats.avg_fn_length))]),
Line::from(""),
Line::from(vec![
Span::raw(" TODOs: "),
Span::styled(stats.todos.len().to_string(), Style::default().fg(Color::Yellow)),
Span::raw(" Security warnings: "),
Span::styled(stats.sec_warnings.len().to_string(),
if stats.sec_warnings.is_empty() { Style::default().fg(Color::Green) } else { Style::default().fg(Color::Red) }),
]),
];
f.render_widget(Paragraph::new(summary).block(Block::default().title(" Summary ").borders(Borders::ALL)), chunks[0]);
// Language bars
let lang_block = Block::default().title(" Languages ").borders(Borders::ALL);
let max_pct = stats.languages.iter().map(|(_, p)| *p).fold(0.0_f64, f64::max);
let items: Vec<ListItem> = stats.languages.iter().take(8).map(|(lang, pct)| {
let bar = make_bar(*pct, max_pct, 20);
ListItem::new(format!(" {:15} {:5.1}% {}", lang, pct, bar))
}).collect();
f.render_widget(List::new(items).block(lang_block), chunks[1]);
}
fn render_security(f: &mut Frame, warnings: &[SecWarning], scroll: usize, area: Rect) {
let block = Block::default().title(format!(" Security — {} warning(s) ", warnings.len())).borders(Borders::ALL);
let items: Vec<ListItem> = warnings.iter().skip(scroll).map(|w| {
ListItem::new(format!(" ⚠ {}:{} — {}", w.file.split('/').last().unwrap_or(&w.file), w.line, w.reason))
.style(Style::default().fg(Color::Yellow))
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render_todos(f: &mut Frame, todos: &[TodoItem], scroll: usize, area: Rect) {
let block = Block::default().title(format!(" TODOs / FIXMEs — {} item(s) ", todos.len())).borders(Borders::ALL);
let rows: Vec<Row> = todos.iter().skip(scroll).map(|t| {
let kind_color = match t.kind.as_str() {
"FIXME" => Color::Red, "HACK" => Color::Magenta, _ => Color::Yellow,
};
Row::new(vec![
Cell::from(t.kind.clone()).style(Style::default().fg(kind_color)),
Cell::from(t.file.split('/').last().unwrap_or(&t.file).to_string()),
Cell::from(t.line.to_string()),
Cell::from(t.text.clone()),
])
}).collect();
let widths = [Constraint::Length(7), Constraint::Length(22), Constraint::Length(6), Constraint::Fill(1)];
let header = Row::new(["KIND", "FILE", "LINE", "TEXT"])
.style(Style::default().add_modifier(Modifier::BOLD));
f.render_widget(Table::new(rows, widths).header(header).block(block), area);
}
fn render_languages(f: &mut Frame, stats: &CodeStats, area: Rect) {
let block = Block::default().title(" Language breakdown ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let _bar_height: u16 = 3;
let bars: Vec<_> = stats.languages.iter().take(6).collect();
if bars.is_empty() { return; }
let constraints: Vec<Constraint> = bars.iter().map(|_| Constraint::Fill(1)).collect();
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(inner);
let max = bars.iter().map(|(_, p)| *p).fold(0.0_f64, f64::max);
for (i, (lang, pct)) in bars.iter().enumerate() {
let gauge = Gauge::default()
.block(Block::default().title(lang.as_str()))
.gauge_style(Style::default().fg(Color::Cyan))
.ratio((pct / max).min(1.0));
f.render_widget(gauge, cols[i]);
}
}
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default().title(" fxg stats ").borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
// Tab bar
let tab_titles: Vec<Line> = TABS.iter().map(|t| Line::from(*t)).collect();
f.render_widget(
Tabs::new(tab_titles).select(tab_index(state.tab))
.style(Style::default())
.highlight_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))
.block(Block::default().borders(Borders::BOTTOM)),
chunks[0],
);
match state.tab {
Tab::Overview => render_overview(f, &state.stats, chunks[1]),
Tab::Security => render_security(f, &state.stats.sec_warnings, state.scroll, chunks[1]),
Tab::Todos => render_todos(f, &state.stats.todos, state.scroll, chunks[1]),
Tab::Languages => render_languages(f, &state.stats, chunks[1]),
}
f.render_widget(
Paragraph::new(" [1-4] tabs [j/k] scroll [e] export [q] quit")
.style(Style::default().fg(Color::DarkGray)),
chunks[2],
);
}
pub fn run_stats_tui(stats: CodeStats) -> Result<()> {
let mut state = State { stats, tab: Tab::Overview, scroll: 0, quit: false };
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Tab | KeyCode::Right => state.tab = match state.tab { Tab::Overview => Tab::Security, Tab::Security => Tab::Todos, Tab::Todos => Tab::Languages, Tab::Languages => Tab::Overview },
KeyCode::BackTab | KeyCode::Left => state.tab = match state.tab { Tab::Overview => Tab::Languages, Tab::Security => Tab::Overview, Tab::Todos => Tab::Security, Tab::Languages => Tab::Todos },
KeyCode::Char('1') => { state.tab = Tab::Overview; state.scroll = 0; }
KeyCode::Char('2') => { state.tab = Tab::Security; state.scroll = 0; }
KeyCode::Char('3') => { state.tab = Tab::Todos; state.scroll = 0; }
KeyCode::Char('4') => { state.tab = Tab::Languages; state.scroll = 0; }
KeyCode::Down | KeyCode::Char('j') => state.scroll += 1,
KeyCode::Up | KeyCode::Char('k') => { if state.scroll > 0 { state.scroll -= 1; } }
_ => {}
}
}
Ok(state.quit)
})
}
+168
View File
@@ -0,0 +1,168 @@
pub mod ui;
use crate::cli::StatusArgs;
use crate::config::AppConfig;
use crate::workspace::load_workspaces;
use anyhow::Result;
use chrono::Utc;
use git2::{Repository, StatusOptions};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct RepoSummary {
pub name: String,
pub path: PathBuf,
pub branch: String,
pub ahead: usize,
pub behind: usize,
pub is_dirty: bool,
#[allow(dead_code)]
pub last_commit_msg: String,
pub last_commit_age: String,
}
#[derive(Debug, Clone)]
pub struct CommitLine {
pub sha: String,
pub msg: String,
pub age: String,
pub author: String,
}
#[derive(Debug, Clone)]
pub struct RepoDetail {
pub summary: RepoSummary,
pub modified_files: Vec<(char, String)>,
pub recent_commits: Vec<CommitLine>,
#[allow(dead_code)] pub meta_issues: Option<u32>,
#[allow(dead_code)] pub meta_prs: Option<u32>,
#[allow(dead_code)] pub meta_stars: Option<u32>,
}
// ── git helpers ───────────────────────────────────────────────────────────────
fn format_age(secs: i64) -> String {
let diff = Utc::now().timestamp() - secs;
match diff {
d if d < 3600 => format!("{}m ago", d / 60),
d if d < 86400 => format!("{}h ago", d / 3600),
d if d < 86400 * 2 => "yesterday".into(),
d => format!("{}d ago", d / 86400),
}
}
fn ahead_behind(repo: &Repository, branch: &str) -> (usize, usize) {
let local = match repo.head().and_then(|h| h.peel_to_commit()) {
Ok(c) => c.id(),
Err(_) => return (0, 0),
};
let upstream_ref = format!("refs/remotes/origin/{}", branch);
match repo.refname_to_id(&upstream_ref) {
Ok(upstream) => repo.graph_ahead_behind(local, upstream).unwrap_or((0, 0)),
Err(_) => (0, 0),
}
}
pub fn repo_summary(path: &Path) -> Result<RepoSummary> {
let repo = Repository::open(path)?;
let head = repo.head()?;
let branch = head.shorthand().unwrap_or("HEAD").to_string();
let commit = head.peel_to_commit()?;
let msg = commit.summary().unwrap_or("").chars().take(60).collect::<String>();
let age = format_age(commit.time().seconds());
let mut opts = StatusOptions::new();
opts.include_untracked(true);
let is_dirty = repo.statuses(Some(&mut opts)).map(|s| !s.is_empty()).unwrap_or(false);
let (ahead, behind) = ahead_behind(&repo, &branch);
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
Ok(RepoSummary { name, path: path.to_path_buf(), branch, ahead, behind, is_dirty, last_commit_msg: msg, last_commit_age: age })
}
pub fn repo_detail(path: &Path) -> Result<RepoDetail> {
let repo = Repository::open(path)?;
let summary = repo_summary(path)?;
// Modified files
let mut sopts = StatusOptions::new();
sopts.include_untracked(true);
let statuses = repo.statuses(Some(&mut sopts))?;
let modified_files = statuses.iter().map(|e| {
let st = e.status();
let ch = if st.contains(git2::Status::INDEX_NEW) || st.contains(git2::Status::WT_NEW) { '?' }
else if st.contains(git2::Status::INDEX_DELETED) || st.contains(git2::Status::WT_DELETED) { 'D' }
else { 'M' };
(ch, e.path().unwrap_or("").to_string())
}).collect();
// Recent commits
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
let recent_commits: Vec<CommitLine> = revwalk.take(5).filter_map(|id| {
let id = id.ok()?;
let commit = repo.find_commit(id).ok()?;
let sha = id.to_string()[..7].to_string();
let msg = commit.summary().unwrap_or("").chars().take(50).collect::<String>();
let age = format_age(commit.time().seconds());
let author = commit.author().name().unwrap_or("").to_string();
Some(CommitLine { sha, msg, age, author })
}).collect();
Ok(RepoDetail { summary, modified_files, recent_commits, meta_issues: None, meta_prs: None, meta_stars: None })
}
pub fn scan_workspace(root: &Path) -> Result<Vec<RepoSummary>> {
let entries = match std::fs::read_dir(root) {
Ok(e) => e,
Err(_) => return Ok(vec![]),
};
let mut repos = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.join(".git").exists() {
if let Ok(s) = repo_summary(&path) {
repos.push(s);
}
}
}
repos.sort_by(|a, b| a.name.cmp(&b.name));
Ok(repos)
}
pub fn apply_filter(mut repos: Vec<RepoSummary>, filter: &str) -> Vec<RepoSummary> {
repos.retain(|r| match filter {
"dirty" => r.is_dirty,
"behind" => r.behind > 0,
"ahead" => r.ahead > 0,
"clean" => !r.is_dirty && r.behind == 0,
_ => true,
});
repos
}
pub async fn run(args: StatusArgs, cfg: &AppConfig) -> Result<()> {
if let Some(repo_name) = &args.repo {
let path = cfg.workspace_root.join(repo_name);
let detail = repo_detail(&path)?;
return ui::run_detail_tui(detail);
}
if let Some(ws_name) = &args.workspace {
let wss = load_workspaces(cfg)?;
let ws = wss.iter().find(|w| &w.name == ws_name)
.ok_or_else(|| anyhow::anyhow!("workspace '{}' not found", ws_name))?;
let mut repos = Vec::new();
for path in crate::workspace::resolve_paths(ws, cfg) {
if let Ok(s) = repo_summary(&path) { repos.push(s); }
}
if let Some(f) = &args.filter { repos = apply_filter(repos, f); }
return ui::run_overview_tui(repos);
}
let mut repos = scan_workspace(&cfg.workspace_root)?;
if let Some(f) = &args.filter { repos = apply_filter(repos, f); }
ui::run_overview_tui(repos)
}
+160
View File
@@ -0,0 +1,160 @@
use crate::status::{RepoDetail, RepoSummary};
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table},
Frame,
};
// ── overview TUI ──────────────────────────────────────────────────────────────
struct OverviewState {
repos: Vec<RepoSummary>,
selected: usize,
quit: bool,
}
fn render_overview(f: &mut Frame, state: &OverviewState) {
let area = f.area();
let outer = Block::default()
.title(format!(" fxg — Workspace Status · {} repos ", state.repos.len()))
.borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
let header = Row::new(vec!["#", "NAME", "BRANCH", "AHEAD", "BEHIND", "DIRTY", "LAST COMMIT"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = state.repos.iter().enumerate().map(|(i, r)| {
let dirty = if r.is_dirty { "✓" } else { "✗" };
let row_style = if i == state.selected {
Style::default().bg(Color::DarkGray)
} else if r.is_dirty {
Style::default().fg(Color::Yellow)
} else if r.behind > 0 {
Style::default().fg(Color::Red)
} else {
Style::default()
};
Row::new(vec![
Cell::from(format!("{}", i + 1)),
Cell::from(r.name.clone()),
Cell::from(r.branch.clone()),
Cell::from(r.ahead.to_string()),
Cell::from(r.behind.to_string()),
Cell::from(dirty),
Cell::from(r.last_commit_age.clone()),
]).style(row_style)
}).collect();
let widths = [
Constraint::Length(4), Constraint::Fill(1), Constraint::Length(14),
Constraint::Length(6), Constraint::Length(7), Constraint::Length(6),
Constraint::Length(12),
];
f.render_widget(Table::new(rows, widths).header(header), chunks[0]);
f.render_widget(
Paragraph::new(" [↑/↓] navigate [Enter] drill-in [f] filter [q] quit")
.style(Style::default().fg(Color::DarkGray)),
chunks[1],
);
}
pub fn run_overview_tui(repos: Vec<RepoSummary>) -> Result<()> {
let mut state = OverviewState { repos, selected: 0, quit: false };
tui::run_loop(|terminal| {
terminal.draw(|f| render_overview(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Up | KeyCode::Char('k') => {
if state.selected > 0 { state.selected -= 1; }
}
KeyCode::Down | KeyCode::Char('j') => {
if state.selected + 1 < state.repos.len() { state.selected += 1; }
}
KeyCode::Enter => {
if let Some(r) = state.repos.get(state.selected) {
if let Ok(detail) = crate::status::repo_detail(&r.path) {
// temporarily exit and re-enter for the detail view
state.quit = true;
// Note: after this loop ends we open detail TUI
let _ = run_detail_tui(detail);
}
}
}
_ => {}
}
}
Ok(state.quit)
})
}
// ── detail TUI ────────────────────────────────────────────────────────────────
fn render_detail(f: &mut Frame, detail: &RepoDetail) {
let area = f.area();
let outer = Block::default()
.title(format!(" {} · {} ", detail.summary.name, detail.summary.branch))
.borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(4), Constraint::Fill(1), Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
// Header info
let info = vec![
Line::from(vec![Span::raw(" Branch: "), Span::styled(&detail.summary.branch, Style::default().fg(Color::Cyan))]),
Line::from(vec![
Span::raw(" Status: "),
Span::raw(format!("+{} ahead -{} behind dirty: {}", detail.summary.ahead, detail.summary.behind,
if detail.summary.is_dirty { "yes" } else { "no" })),
]),
];
f.render_widget(Paragraph::new(info), chunks[0]);
// Modified files
let file_block = Block::default().title(" Modified files ").borders(Borders::ALL);
let files: Vec<ListItem> = detail.modified_files.iter()
.map(|(ch, path)| ListItem::new(format!(" {} {}", ch, path)))
.collect();
f.render_widget(List::new(files).block(file_block), chunks[1]);
// Recent commits
let commit_block = Block::default().title(" Recent commits ").borders(Borders::ALL);
let commits: Vec<ListItem> = detail.recent_commits.iter()
.map(|c| ListItem::new(format!(" {} {:<50} {} {}", c.sha, c.msg, c.age, c.author)))
.collect();
f.render_widget(List::new(commits).block(commit_block), chunks[2]);
f.render_widget(
Paragraph::new(" [p] pull [P] push [c] commit [b] branches [q] back")
.style(Style::default().fg(Color::DarkGray)),
chunks[3],
);
}
pub fn run_detail_tui(detail: RepoDetail) -> Result<()> {
let mut quit = false;
tui::run_loop(|terminal| {
terminal.draw(|f| render_detail(f, &detail))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) {
quit = true;
}
}
Ok(quit)
})
}
+334
View File
@@ -0,0 +1,334 @@
use crate::config::AppConfig;
use crate::cli::SummaryArgs;
use crate::stats::collect_stats;
use anyhow::Result;
use git2::Repository;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
// ── public entry point ────────────────────────────────────────────────────────
pub async fn run(args: SummaryArgs, cfg: &AppConfig) -> Result<()> {
let targets: Vec<PathBuf> = if let Some(ws_name) = &args.workspace {
let wss = crate::workspace::load_workspaces(cfg)?;
let ws = wss.iter().find(|w| w.name == *ws_name)
.ok_or_else(|| anyhow::anyhow!("workspace '{}' not found", ws_name))?;
crate::workspace::resolve_paths(ws, cfg)
} else if let Some(name) = &args.repo {
vec![cfg.workspace_root.join(name)]
} else {
vec![std::env::current_dir()?]
};
for path in &targets {
let md = build_summary(path, cfg)?;
if args.print {
println!("{}", md);
} else {
let out_dir = path.join(".fxg");
std::fs::create_dir_all(&out_dir)?;
let out_path = out_dir.join("summary.md");
std::fs::write(&out_path, &md)?;
println!(" ✓ {} → {}", path.file_name().unwrap_or_default().to_string_lossy(), out_path.display());
}
}
Ok(())
}
// ── builder ───────────────────────────────────────────────────────────────────
fn build_summary(root: &Path, _cfg: &AppConfig) -> Result<String> {
let name = root.file_name().unwrap_or_default().to_string_lossy().to_string();
let stats = collect_stats(root, None)?;
let git_info = git_summary(root);
let deps = detect_dependencies(root);
let project_type = detect_project_type(root);
let tree = dir_tree(root, 0, 3);
let mut md = String::new();
// Header
md.push_str(&format!("# {}\n\n", name));
md.push_str(&format!("> Generated by fxg on {}\n\n", chrono::Utc::now().format("%Y-%m-%d %H:%M UTC")));
// Overview table
md.push_str("## Overview\n\n");
md.push_str("| Field | Value |\n|---|---|\n");
md.push_str(&format!("| Project type | {} |\n", project_type));
md.push_str(&format!("| Total files | {} |\n", stats.file_count));
md.push_str(&format!("| Total lines | {} |\n", stats.total_lines));
md.push_str(&format!("| Functions | {} |\n", stats.function_count));
if let Some(ref gi) = git_info {
md.push_str(&format!("| Branch | `{}` |\n", gi.branch));
md.push_str(&format!("| Commits | {} |\n", gi.commit_count));
md.push_str(&format!("| Contributors | {} |\n", gi.contributors.len()));
md.push_str(&format!("| Last commit | {} |\n", gi.last_commit));
if !gi.branches.is_empty() {
md.push_str(&format!("| Branches | {} |\n", gi.branches.join(", ")));
}
}
md.push('\n');
// Languages
if !stats.languages.is_empty() {
md.push_str("## Languages\n\n");
md.push_str("| Language | Share |\n|---|---|\n");
for (lang, pct) in &stats.languages {
let bar = "█".repeat((*pct / 5.0).round() as usize);
md.push_str(&format!("| {} | {:.1}% {} |\n", lang, pct, bar));
}
md.push('\n');
}
// Dependencies
if !deps.is_empty() {
md.push_str("## Dependencies\n\n");
for (kind, list) in &deps {
md.push_str(&format!("### {}\n\n", kind));
for dep in list {
md.push_str(&format!("- `{}`\n", dep));
}
md.push('\n');
}
}
// File structure
md.push_str("## Structure\n\n");
md.push_str("```\n");
md.push_str(&tree);
md.push_str("```\n\n");
// Git contributors
if let Some(ref gi) = git_info {
if !gi.contributors.is_empty() {
md.push_str("## Contributors\n\n");
md.push_str("| Author | Commits |\n|---|---|\n");
let mut sorted: Vec<_> = gi.contributors.iter().collect();
sorted.sort_by(|a, b| b.1.cmp(a.1));
for (author, count) in sorted.iter().take(10) {
md.push_str(&format!("| {} | {} |\n", author, count));
}
md.push('\n');
}
}
// TODOs
if !stats.todos.is_empty() {
md.push_str("## TODOs & FIXMEs\n\n");
md.push_str("| Kind | File | Line | Text |\n|---|---|---|---|\n");
for t in stats.todos.iter().take(50) {
let short_file = t.file.split(['/', '\\']).last().unwrap_or(&t.file);
md.push_str(&format!("| `{}` | `{}` | {} | {} |\n",
t.kind, short_file, t.line, t.text.replace('|', "\\|")));
}
if stats.todos.len() > 50 {
md.push_str(&format!("\n_...and {} more_\n", stats.todos.len() - 50));
}
md.push('\n');
}
// Security warnings
if !stats.sec_warnings.is_empty() {
md.push_str("## Security Warnings\n\n");
md.push_str("> ⚠ These are heuristic detections and may include false positives.\n\n");
md.push_str("| File | Line | Reason |\n|---|---|---|\n");
for w in &stats.sec_warnings {
let short_file = w.file.split(['/', '\\']).last().unwrap_or(&w.file);
md.push_str(&format!("| `{}` | {} | {} |\n", short_file, w.line, w.reason));
}
md.push('\n');
}
Ok(md)
}
// ── git analysis ──────────────────────────────────────────────────────────────
struct GitInfo {
branch: String,
commit_count: usize,
last_commit: String,
contributors: HashMap<String, usize>,
branches: Vec<String>,
}
fn git_summary(root: &Path) -> Option<GitInfo> {
let repo = Repository::open(root).ok()?;
let head = repo.head().ok()?;
let branch = head.shorthand().unwrap_or("HEAD").to_string();
// Count commits and contributors
let mut revwalk = repo.revwalk().ok()?;
revwalk.push_head().ok()?;
let mut commit_count = 0usize;
let mut contributors: HashMap<String, usize> = HashMap::new();
let mut last_commit = String::from("unknown");
for (i, id) in revwalk.enumerate() {
let id = id.ok()?;
let commit = repo.find_commit(id).ok()?;
if i == 0 {
let ts = commit.time().seconds();
last_commit = format_ts(ts);
}
let author = commit.author().name().unwrap_or("unknown").to_string();
*contributors.entry(author).or_default() += 1;
commit_count += 1;
}
// List local branches
let branches: Vec<String> = repo.branches(Some(git2::BranchType::Local))
.ok()?
.filter_map(|b| b.ok())
.filter_map(|(b, _)| b.name().ok()?.map(|n| n.to_string()))
.collect();
Some(GitInfo { branch, commit_count, last_commit, contributors, branches })
}
fn format_ts(secs: i64) -> String {
use chrono::{TimeZone, Utc};
Utc.timestamp_opt(secs, 0)
.single()
.map(|dt| dt.format("%Y-%m-%d").to_string())
.unwrap_or_else(|| "unknown".to_string())
}
// ── dependency detection ──────────────────────────────────────────────────────
fn detect_dependencies(root: &Path) -> Vec<(String, Vec<String>)> {
let mut result = Vec::new();
// Cargo.toml
let cargo = root.join("Cargo.toml");
if cargo.exists() {
if let Ok(raw) = std::fs::read_to_string(&cargo) {
if let Ok(val) = toml::from_str::<toml::Value>(&raw) {
let mut deps = Vec::new();
for section in &["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(table) = val.get(section).and_then(|v| v.as_table()) {
for (name, ver) in table {
let ver_str = match ver {
toml::Value::String(s) => s.clone(),
toml::Value::Table(t) => t.get("version")
.and_then(|v| v.as_str())
.unwrap_or("*")
.to_string(),
_ => "*".to_string(),
};
deps.push(format!("{} = \"{}\"", name, ver_str));
}
}
}
if !deps.is_empty() {
deps.sort();
result.push(("Rust (Cargo)".to_string(), deps));
}
}
}
}
// package.json
let pkg = root.join("package.json");
if pkg.exists() {
if let Ok(raw) = std::fs::read_to_string(&pkg) {
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&raw) {
let mut deps = Vec::new();
for section in &["dependencies", "devDependencies"] {
if let Some(obj) = val.get(section).and_then(|v| v.as_object()) {
for (name, ver) in obj {
deps.push(format!("{}: {}", name, ver.as_str().unwrap_or("*")));
}
}
}
if !deps.is_empty() {
deps.sort();
result.push(("Node.js (npm/yarn)".to_string(), deps));
}
}
}
}
// requirements.txt
let reqs = root.join("requirements.txt");
if reqs.exists() {
if let Ok(raw) = std::fs::read_to_string(&reqs) {
let deps: Vec<String> = raw.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.collect();
if !deps.is_empty() {
result.push(("Python (pip)".to_string(), deps));
}
}
}
// go.mod
let gomod = root.join("go.mod");
if gomod.exists() {
if let Ok(raw) = std::fs::read_to_string(&gomod) {
let deps: Vec<String> = raw.lines()
.skip_while(|l| !l.trim_start().starts_with("require"))
.skip(1)
.take_while(|l| l.trim() != ")")
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && l != ")")
.collect();
if !deps.is_empty() {
result.push(("Go (go.mod)".to_string(), deps));
}
}
}
result
}
// ── project type detection ────────────────────────────────────────────────────
fn detect_project_type(root: &Path) -> &'static str {
if root.join("Cargo.toml").exists() { return "Rust"; }
if root.join("package.json").exists() { return "Node.js"; }
if root.join("requirements.txt").exists(){ return "Python"; }
if root.join("go.mod").exists() { return "Go"; }
if root.join("pom.xml").exists() { return "Java (Maven)"; }
if root.join("build.gradle").exists() { return "Java (Gradle)"; }
if root.join("Makefile").exists() { return "C/C++ (Make)"; }
"Unknown"
}
// ── directory tree ────────────────────────────────────────────────────────────
fn dir_tree(root: &Path, depth: usize, max_depth: usize) -> String {
if depth > max_depth { return String::new(); }
let Ok(entries) = std::fs::read_dir(root) else { return String::new() };
let indent = " ".repeat(depth);
let mut out = String::new();
let mut entries: Vec<_> = entries.flatten().collect();
entries.sort_by_key(|e| {
let is_dir = e.path().is_dir();
(if is_dir { 0 } else { 1 }, e.file_name())
});
for entry in &entries {
let path = entry.path();
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
// skip hidden/build dirs
if matches!(name.as_str(), ".git" | "target" | "node_modules" | ".next" | "dist" | ".fxg") {
continue;
}
// skip hidden files at depth 0
if depth == 0 && name.starts_with('.') {
continue;
}
if path.is_dir() {
out.push_str(&format!("{}{}/\n", indent, name));
out.push_str(&dir_tree(&path, depth + 1, max_depth));
} else {
out.push_str(&format!("{}{}\n", indent, name));
}
}
out
}
+203
View File
@@ -0,0 +1,203 @@
pub mod pr;
pub mod ui;
use crate::cli::{BranchAction, SyncAction, SyncArgs};
use crate::config::AppConfig;
use crate::workspace::{load_workspaces, resolve_paths};
use anyhow::Result;
use git2::{build::CheckoutBuilder, BranchType, IndexAddOption, Repository, Signature};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct SyncResult {
pub repo: String,
pub action: String,
pub success: bool,
pub detail: String,
}
impl SyncResult {
pub fn ok(repo: &str, action: &str, detail: impl Into<String>) -> Self {
Self { repo: repo.into(), action: action.into(), success: true, detail: detail.into() }
}
pub fn err(repo: &str, action: &str, detail: impl Into<String>) -> Self {
Self { repo: repo.into(), action: action.into(), success: false, detail: detail.into() }
}
}
fn make_callbacks(token: String) -> git2::RemoteCallbacks<'static> {
let mut cb = git2::RemoteCallbacks::new();
cb.credentials(move |_url, user, allowed| {
if allowed.contains(git2::CredentialType::SSH_KEY) { git2::Cred::ssh_key_from_agent(user.unwrap_or("git")) }
else { git2::Cred::userpass_plaintext(user.unwrap_or("git"), &token) }
});
cb
}
pub fn pull_repo(path: &Path, token: &str) -> Result<SyncResult> {
let repo = Repository::open(path)?;
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let branch = repo.head()?.shorthand().unwrap_or("main").to_string();
let mut fetch_opts = git2::FetchOptions::new();
fetch_opts.remote_callbacks(make_callbacks(token.to_string()));
let mut remote = repo.find_remote("origin")?;
remote.fetch(&[&branch], Some(&mut fetch_opts), None)?;
let fetch_head = repo.find_reference("FETCH_HEAD")?;
let fetch_commit = repo.reference_to_annotated_commit(&fetch_head)?;
let (analysis, _) = repo.merge_analysis(&[&fetch_commit])?;
if analysis.is_up_to_date() { return Ok(SyncResult::ok(&name, "pull", "Already up to date")); }
if analysis.is_fast_forward() {
let refname = format!("refs/heads/{}", branch);
let mut reference = repo.find_reference(&refname)?;
reference.set_target(fetch_commit.id(), "Fast-forward")?;
repo.set_head(&refname)?;
repo.checkout_head(Some(CheckoutBuilder::default().force()))?;
return Ok(SyncResult::ok(&name, "pull", "Fast-forwarded"));
}
Ok(SyncResult::err(&name, "pull", "Merge required — not fast-forward"))
}
pub fn commit_repo(path: &Path, message: &str, username: &str, email: &str) -> Result<SyncResult> {
let repo = Repository::open(path)?;
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let mut index = repo.index()?;
index.add_all(["*"].iter(), IndexAddOption::DEFAULT, None)?;
index.write()?;
let tree = repo.find_tree(index.write_tree()?)?;
let sig = Signature::now(username, email)?;
let parent = repo.head()?.peel_to_commit()?;
repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &[&parent])?;
Ok(SyncResult::ok(&name, "commit", message))
}
pub fn push_repo(path: &Path, token: &str, force_with_lease: bool) -> Result<SyncResult> {
let repo = Repository::open(path)?;
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let branch = repo.head()?.shorthand().unwrap_or("main").to_string();
let mut remote = repo.find_remote("origin")?;
let mut opts = git2::PushOptions::new();
opts.remote_callbacks(make_callbacks(token.to_string()));
let prefix = if force_with_lease { "+" } else { "" };
let refspec = format!("{}refs/heads/{1}:refs/heads/{1}", prefix, branch);
remote.push(&[&refspec], Some(&mut opts))?;
Ok(SyncResult::ok(&name, "push", format!("Pushed {}", branch)))
}
pub fn create_branch(path: &Path, name: &str) -> Result<()> {
let repo = Repository::open(path)?;
let commit = repo.head()?.peel_to_commit()?;
repo.branch(name, &commit, false)?;
Ok(())
}
pub fn delete_branch(path: &Path, name: &str) -> Result<()> {
let repo = Repository::open(path)?;
repo.find_branch(name, BranchType::Local)?.delete()?;
Ok(())
}
pub fn switch_branch(path: &Path, name: &str) -> Result<()> {
let repo = Repository::open(path)?;
let obj = repo.revparse_single(&format!("refs/heads/{}", name))?;
repo.checkout_tree(&obj, None)?;
repo.set_head(&format!("refs/heads/{}", name))?;
Ok(())
}
fn repos_for_workspace(workspace: &str, cfg: &AppConfig) -> Result<Vec<crate::status::RepoSummary>> {
let wss = load_workspaces(cfg)?;
let ws = wss.iter().find(|w| w.name == workspace)
.ok_or_else(|| anyhow::anyhow!("workspace '{}' not found", workspace))?;
let paths = resolve_paths(ws, cfg);
let mut repos = Vec::new();
for path in paths {
if let Ok(s) = crate::status::repo_summary(&path) { repos.push(s); }
}
Ok(repos)
}
fn print_result(sr: &SyncResult) {
println!(" {} {}: {}", if sr.success { "✓" } else { "✗" }, sr.repo, sr.detail);
}
pub async fn run(args: SyncArgs, cfg: &AppConfig) -> Result<()> {
let accounts = crate::auth::load_accounts(cfg).unwrap_or_default();
let token = crate::providers::default_account(&accounts, Some(&cfg.default_provider))
.and_then(|a| crate::auth::get_token(&a.id()).ok())
.unwrap_or_default();
match args.action {
None => {
let repos = crate::status::scan_workspace(&cfg.workspace_root)?;
ui::run_sync_tui(repos, cfg)
}
Some(SyncAction::Pull { all, workspace }) => {
let repos = if let Some(ws) = workspace {
repos_for_workspace(&ws, cfg)?
} else if all {
crate::status::scan_workspace(&cfg.workspace_root)?
} else {
let cwd = std::env::current_dir()?;
vec![crate::status::repo_summary(&cwd)?]
};
for r in &repos {
print_result(&pull_repo(&r.path, &token).unwrap_or_else(|e| SyncResult::err(&r.name, "pull", e.to_string())));
}
Ok(())
}
Some(SyncAction::Commit { message, all }) => {
let msg = message.unwrap_or_else(|| "chore: sync".into());
let repos = if all { crate::status::scan_workspace(&cfg.workspace_root)? }
else { vec![crate::status::repo_summary(&std::env::current_dir()?)? ] };
for r in repos.iter().filter(|r| r.is_dirty) {
print_result(&commit_repo(&r.path, &msg, &cfg.username, &cfg.email)
.unwrap_or_else(|e| SyncResult::err(&r.name, "commit", e.to_string())));
}
Ok(())
}
Some(SyncAction::Push { all, force_with_lease, message }) => {
let repos = if all { crate::status::scan_workspace(&cfg.workspace_root)? }
else { vec![crate::status::repo_summary(&std::env::current_dir()?)? ] };
// If -m given, commit dirty repos first
if let Some(msg) = &message {
for r in repos.iter().filter(|r| r.is_dirty) {
let _ = commit_repo(&r.path, msg, &cfg.username, &cfg.email);
}
}
for r in &repos {
print_result(&push_repo(&r.path, &token, force_with_lease)
.unwrap_or_else(|e| SyncResult::err(&r.name, "push", e.to_string())));
}
Ok(())
}
Some(SyncAction::Branch(bargs)) => {
match bargs.action {
BranchAction::Create { name, repos } => {
let paths: Vec<_> = if repos.is_empty() {
vec![std::env::current_dir()?]
} else {
repos.iter().map(|r| cfg.workspace_root.join(r)).collect()
};
for path in &paths {
let rname = path.file_name().unwrap_or_default().to_string_lossy();
match create_branch(path, &name) {
Ok(()) => println!(" ✓ {} — created branch {}", rname, name),
Err(e) => println!(" ✗ {}: {}", rname, e),
}
}
}
BranchAction::Delete { name } => delete_branch(&std::env::current_dir()?, &name)?,
BranchAction::Switch { name, all } => {
let repos = if all { crate::status::scan_workspace(&cfg.workspace_root)? }
else { vec![crate::status::repo_summary(&std::env::current_dir()?)? ] };
for r in &repos { let _ = switch_branch(&r.path, &name); }
}
}
Ok(())
}
Some(SyncAction::Pr(pr_args)) => pr::run(pr_args, cfg).await,
}
}
+73
View File
@@ -0,0 +1,73 @@
use crate::auth::load_accounts;
use crate::cli::{PrAction, PrArgs};
use crate::config::AppConfig;
use crate::providers::make_provider;
use anyhow::Result;
use inquire::Text;
fn open_editor_for_body() -> Result<String> {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "notepad".into());
let tmp = std::env::temp_dir().join("fxg_pr_body.md");
std::fs::write(&tmp, "# PR Description\n\n")?;
std::process::Command::new(&editor).arg(&tmp).status()?;
Ok(std::fs::read_to_string(&tmp).unwrap_or_default())
}
fn current_branch() -> String {
let cwd = std::env::current_dir().unwrap_or_default();
let Ok(repo) = git2::Repository::open(&cwd) else { return "main".into() };
let Ok(head) = repo.head() else { return "main".into() };
head.shorthand().unwrap_or("main").to_string()
}
pub async fn run(args: PrArgs, cfg: &AppConfig) -> Result<()> {
match args.action {
PrAction::Create { title, base, head, reviewers, labels, repo } => {
// Resolve fields — prompt for any that are missing
let title = match title {
Some(t) => t,
None => Text::new("PR title:").prompt()?,
};
let head = match head {
Some(h) => h,
None => {
let cur = current_branch();
Text::new("Head branch:").with_default(&cur).prompt()?
}
};
let base = match base {
Some(b) => b,
None => Text::new("Base branch:").with_default("main").prompt()?,
};
let body = open_editor_for_body()?;
let repo_name = match repo {
Some(r) => r,
None => std::env::current_dir()?
.file_name().unwrap_or_default()
.to_string_lossy().to_string(),
};
let accounts = load_accounts(cfg)?;
let account = crate::providers::default_account(&accounts, None)
.ok_or_else(|| anyhow::anyhow!("no account configured"))?;
let provider = make_provider(account)?;
let pr = provider.create_pull_request(&repo_name, &title, &body, &head, &base).await?;
println!(" ✓ PR #{} created: {}", pr.number, pr.url);
if !reviewers.is_empty() {
match provider.request_reviewers(&repo_name, pr.number, &reviewers).await {
Ok(()) => println!(" ✓ Reviewers requested: {}", reviewers.join(", ")),
Err(e) => println!(" ⚠ Could not request reviewers: {}", e),
}
}
if !labels.is_empty() {
match provider.add_labels(&repo_name, pr.number, &labels).await {
Ok(()) => println!(" ✓ Labels added: {}", labels.join(", ")),
Err(e) => println!(" ⚠ Could not add labels: {}", e),
}
}
Ok(())
}
}
}
+201
View File
@@ -0,0 +1,201 @@
use crate::config::AppConfig;
use crate::status::RepoSummary;
use crate::sync::SyncResult;
use crate::tui;
use anyhow::Result;
use crossterm::event::{Event, KeyCode};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
#[derive(PartialEq)]
enum Mode { RepoSelect, ActionMenu, CommitInput, Progress }
struct State {
repos: Vec<RepoSummary>,
selected_repos: Vec<bool>,
cursor: usize,
mode: Mode,
action_cursor: usize,
commit_msg: String,
results: Vec<SyncResult>,
quit: bool,
}
const ACTIONS: &[&str] = &["Pull", "Commit", "Push", "New branch", "Create PR"];
impl State {
fn new(repos: Vec<RepoSummary>) -> Self {
let len = repos.len();
Self {
repos, selected_repos: vec![false; len],
cursor: 0, mode: Mode::RepoSelect,
action_cursor: 0, commit_msg: String::new(),
results: vec![], quit: false,
}
}
fn toggle(&mut self) {
if let Some(v) = self.selected_repos.get_mut(self.cursor) { *v = !*v; }
}
fn selected_names(&self) -> Vec<String> {
self.repos.iter().enumerate()
.filter(|(i, _)| self.selected_repos[*i])
.map(|(_, r)| r.name.clone())
.collect()
}
}
fn render_repo_select(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let block = Block::default().title(" Select repositories (Space = toggle, Enter = confirm) ").borders(Borders::ALL);
let items: Vec<ListItem> = state.repos.iter().enumerate().map(|(i, r)| {
let check = if state.selected_repos[i] { "[x]" } else { "[ ]" };
let dirty = if r.is_dirty { " ✓" } else { "" };
let style = if i == state.cursor { Style::default().bg(Color::DarkGray) } else { Style::default() };
ListItem::new(format!(" {} {:30} {}{}", check, r.name, r.branch, dirty)).style(style)
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render_action_menu(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let repos = state.selected_names();
let title = format!(" Action for: {} ", repos.join(", "));
let block = Block::default().title(title).borders(Borders::ALL);
let items: Vec<ListItem> = ACTIONS.iter().enumerate().map(|(i, a)| {
let style = if i == state.action_cursor { Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD) }
else { Style::default() };
ListItem::new(format!(" {}", a)).style(style)
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render_commit_input(f: &mut Frame, state: &State, area: ratatui::layout::Rect) {
let repos = state.selected_names();
let title = format!(" Commit message for: {} ", repos.join(", "));
let block = Block::default().title(title).borders(Borders::ALL);
let display = format!(" > {}_", state.commit_msg);
f.render_widget(Paragraph::new(display).block(block), area);
}
fn render_progress(f: &mut Frame, results: &[SyncResult], area: ratatui::layout::Rect) {
let block = Block::default().title(" Progress ").borders(Borders::ALL);
let items: Vec<ListItem> = results.iter().map(|r| {
let (icon, color) = if r.success { ("✓", Color::Green) } else { ("✗", Color::Red) };
ListItem::new(format!(" {} {} — {} — {}", icon, r.repo, r.action, r.detail))
.style(Style::default().fg(color))
}).collect();
f.render_widget(List::new(items).block(block), area);
}
fn render(f: &mut Frame, state: &State) {
let area = f.area();
let outer = Block::default().title(" fxg — Sync ").borders(Borders::ALL);
let inner = outer.inner(area);
f.render_widget(outer, area);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Fill(1), Constraint::Length(1)])
.split(inner);
match state.mode {
Mode::RepoSelect => render_repo_select(f, state, chunks[0]),
Mode::ActionMenu => render_action_menu(f, state, chunks[0]),
Mode::CommitInput => render_commit_input(f, state, chunks[0]),
Mode::Progress => render_progress(f, &state.results, chunks[0]),
}
let hint = match state.mode {
Mode::RepoSelect => " [Space] toggle [a] select all [Enter] confirm [q] quit",
Mode::ActionMenu => " [↑/↓] navigate [Enter] execute [Esc] back",
Mode::CommitInput => " [Type] commit message [Enter] commit [Esc] back",
Mode::Progress => " [q] quit",
};
f.render_widget(Paragraph::new(hint).style(Style::default().fg(Color::DarkGray)), chunks[1]);
}
fn handle_key(state: &mut State, key: crossterm::event::KeyEvent, cfg: &AppConfig) {
match state.mode {
Mode::RepoSelect => match key.code {
KeyCode::Char('q') | KeyCode::Esc => state.quit = true,
KeyCode::Char('a') => state.selected_repos.iter_mut().for_each(|v| *v = true),
KeyCode::Char(' ') => state.toggle(),
KeyCode::Up | KeyCode::Char('k') => { if state.cursor > 0 { state.cursor -= 1; } }
KeyCode::Down | KeyCode::Char('j') => { if state.cursor + 1 < state.repos.len() { state.cursor += 1; } }
KeyCode::Enter => {
if state.selected_repos.iter().any(|&v| v) { state.mode = Mode::ActionMenu; }
}
_ => {}
},
Mode::ActionMenu => match key.code {
KeyCode::Esc => state.mode = Mode::RepoSelect,
KeyCode::Up | KeyCode::Char('k') => { if state.action_cursor > 0 { state.action_cursor -= 1; } }
KeyCode::Down | KeyCode::Char('j') => { if state.action_cursor + 1 < ACTIONS.len() { state.action_cursor += 1; } }
KeyCode::Enter => {
if state.action_cursor == 1 {
state.commit_msg.clear();
state.mode = Mode::CommitInput;
} else {
execute_action(state, cfg);
state.mode = Mode::Progress;
}
}
_ => {}
},
Mode::CommitInput => match key.code {
KeyCode::Esc => state.mode = Mode::ActionMenu,
KeyCode::Enter => {
if !state.commit_msg.is_empty() {
execute_action(state, cfg);
state.mode = Mode::Progress;
}
}
KeyCode::Backspace => { state.commit_msg.pop(); }
KeyCode::Char(c) => state.commit_msg.push(c),
_ => {}
},
Mode::Progress => {
if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) { state.quit = true; }
}
}
}
fn execute_action(state: &mut State, cfg: &AppConfig) {
let accounts = crate::auth::load_accounts(cfg).unwrap_or_default();
let token = crate::providers::default_account(&accounts, Some(&cfg.default_provider))
.and_then(|a| crate::auth::get_token(&a.id()).ok())
.unwrap_or_default();
let selected: Vec<_> = state.repos.iter()
.enumerate()
.filter(|(i, _)| state.selected_repos[*i])
.map(|(_, r)| r.clone())
.collect();
for repo in &selected {
let result = match state.action_cursor {
0 => crate::sync::pull_repo(&repo.path, &token)
.unwrap_or_else(|e| SyncResult::err(&repo.name, "pull", e.to_string())),
1 => crate::sync::commit_repo(&repo.path, &state.commit_msg, &cfg.username, &cfg.email)
.unwrap_or_else(|e| SyncResult::err(&repo.name, "commit", e.to_string())),
2 => crate::sync::push_repo(&repo.path, &token, false)
.unwrap_or_else(|e| SyncResult::err(&repo.name, "push", e.to_string())),
_ => SyncResult::err(&repo.name, ACTIONS[state.action_cursor],
"niedostepne w TUI — uzyj: fxg sync branch create <name> / fxg sync pr create"),
};
state.results.push(result);
}
}
pub fn run_sync_tui(repos: Vec<RepoSummary>, cfg: &AppConfig) -> Result<()> {
let mut state = State::new(repos);
tui::run_loop(|terminal| {
terminal.draw(|f| render(f, &state))?;
if let Some(Event::Key(key)) = tui::next_event(250)? {
handle_key(&mut state, key, cfg);
}
Ok(state.quit)
})
}
+84
View File
@@ -0,0 +1,84 @@
use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout, Rect},
Terminal,
};
use std::{io, time::Duration};
pub type Tty = Terminal<CrosstermBackend<io::Stdout>>;
/// Set up raw-mode alternate-screen terminal.
pub fn setup() -> Result<Tty> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
Ok(Terminal::new(backend)?)
}
/// Restore terminal to normal mode.
pub fn teardown(mut terminal: Tty) -> Result<()> {
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
/// Poll for the next crossterm event with a timeout.
pub fn next_event(timeout_ms: u64) -> Result<Option<Event>> {
if event::poll(Duration::from_millis(timeout_ms))? {
Ok(Some(event::read()?))
} else {
Ok(None)
}
}
/// Return a centred `Rect` inside `area` using percentage sizes.
#[allow(dead_code)]
pub fn centered_rect(pct_x: u16, pct_y: u16, area: Rect) -> Rect {
let vert = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - pct_y) / 2),
Constraint::Percentage(pct_y),
Constraint::Percentage((100 - pct_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - pct_x) / 2),
Constraint::Percentage(pct_x),
Constraint::Percentage((100 - pct_x) / 2),
])
.split(vert[1])[1]
}
/// Run `draw_fn` inside a properly-torn-down terminal.
/// The closure returns `true` to quit, `false` to keep running.
pub fn run_loop<F>(mut draw_fn: F) -> Result<()>
where
F: FnMut(&mut Tty) -> Result<bool>,
{
let mut terminal = setup()?;
let result = (|| -> Result<()> {
loop {
if draw_fn(&mut terminal)? {
break;
}
}
Ok(())
})();
teardown(terminal)?;
result
}
+124
View File
@@ -0,0 +1,124 @@
use crate::cli::{WorkspaceAction, WorkspaceArgs};
use crate::config::AppConfig;
use anyhow::Result;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
pub name: String,
pub repos: Vec<String>,
pub created_at: String,
}
#[derive(Serialize, Deserialize, Default)]
struct WorkspacesFile {
workspaces: Vec<Workspace>,
}
pub fn workspaces_path(cfg: &AppConfig) -> PathBuf {
cfg.data_dir.join("workspaces.toml")
}
pub fn load_workspaces(cfg: &AppConfig) -> Result<Vec<Workspace>> {
let path = workspaces_path(cfg);
if path.exists() {
let raw = std::fs::read_to_string(&path)?;
let file: WorkspacesFile = toml::from_str(&raw)?;
Ok(file.workspaces)
} else {
Ok(vec![])
}
}
pub fn save_workspaces(workspaces: &[Workspace], cfg: &AppConfig) -> Result<()> {
let path = workspaces_path(cfg);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let file = WorkspacesFile { workspaces: workspaces.to_vec() };
std::fs::write(path, toml::to_string_pretty(&file)?)?;
Ok(())
}
pub fn create_workspace(name: &str, workspaces: &mut Vec<Workspace>, cfg: &AppConfig) -> Result<()> {
anyhow::ensure!(!workspaces.iter().any(|w| w.name == name), "workspace '{}' already exists", name);
workspaces.push(Workspace {
name: name.to_string(),
repos: vec![],
created_at: Utc::now().to_rfc3339(),
});
save_workspaces(workspaces, cfg)?;
Ok(())
}
pub fn add_repos_to_workspace(
name: &str,
repos: &[String],
workspaces: &mut Vec<Workspace>,
cfg: &AppConfig,
) -> Result<()> {
let ws = workspaces
.iter_mut()
.find(|w| w.name == name)
.ok_or_else(|| anyhow::anyhow!("workspace '{}' not found", name))?;
for repo in repos {
if !ws.repos.contains(repo) {
ws.repos.push(repo.clone());
}
}
save_workspaces(workspaces, cfg)?;
Ok(())
}
pub fn remove_workspace(name: &str, workspaces: &mut Vec<Workspace>, cfg: &AppConfig) -> Result<()> {
let before = workspaces.len();
workspaces.retain(|w| w.name != name);
anyhow::ensure!(workspaces.len() < before, "workspace '{}' not found", name);
save_workspaces(workspaces, cfg)?;
Ok(())
}
/// Map repo names to absolute paths under workspace_root.
pub fn resolve_paths(ws: &Workspace, cfg: &AppConfig) -> Vec<PathBuf> {
ws.repos.iter()
.map(|r| cfg.workspace_root.join(r))
.filter(|p| p.exists())
.collect()
}
fn print_workspace_table(workspaces: &[Workspace]) {
println!("{:<20} {:<6} {}", "NAME", "REPOS", "CREATED");
println!("{}", "─".repeat(50));
for ws in workspaces {
println!("{:<20} {:<6} {}", ws.name, ws.repos.len(), &ws.created_at[..10]);
}
}
pub async fn run(args: WorkspaceArgs, cfg: &AppConfig) -> Result<()> {
let mut workspaces = load_workspaces(cfg)?;
match args.action {
None | Some(WorkspaceAction::List) => {
if workspaces.is_empty() {
println!("No workspaces yet. Use: fxg workspace create <name>");
} else {
print_workspace_table(&workspaces);
}
}
Some(WorkspaceAction::Create { name }) => {
create_workspace(&name, &mut workspaces, cfg)?;
println!("✓ Workspace '{}' created", name);
}
Some(WorkspaceAction::Add { workspace, repos }) => {
add_repos_to_workspace(&workspace, &repos, &mut workspaces, cfg)?;
println!("✓ Added {} repos to workspace '{}'", repos.len(), workspace);
}
Some(WorkspaceAction::Remove { name }) => {
remove_workspace(&name, &mut workspaces, cfg)?;
println!("✓ Workspace '{}' removed", name);
}
}
Ok(())
}