580f4b96
Add man pages, format-patch import and a commit browser
a73x 2026-03-21 10:05
Commit message
.github/workflows/ci.yml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,21 @@ | |||
| 1 | name: CI | ||
| 2 | |||
| 3 | on: | ||
| 4 | push: | ||
| 5 | branches: [main] | ||
| 6 | pull_request: | ||
| 7 | branches: [main] | ||
| 8 | |||
| 9 | env: | ||
| 10 | CARGO_TERM_COLOR: always | ||
| 11 | |||
| 12 | jobs: | ||
| 13 | ci: | ||
| 14 | runs-on: ubuntu-latest | ||
| 15 | steps: | ||
| 16 | - uses: actions/checkout@v4 | ||
| 17 | - uses: dtolnay/rust-toolchain@stable | ||
| 18 | with: | ||
| 19 | components: clippy, rustfmt | ||
| 20 | - uses: Swatinem/rust-cache@v2 | ||
| 21 | - run: make ci | ||
.github/workflows/release.yml
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,58 @@ | |||
| 1 | name: Release | ||
| 2 | |||
| 3 | on: | ||
| 4 | push: | ||
| 5 | tags: ["v*"] | ||
| 6 | |||
| 7 | permissions: | ||
| 8 | contents: write | ||
| 9 | |||
| 10 | env: | ||
| 11 | CARGO_TERM_COLOR: always | ||
| 12 | |||
| 13 | jobs: | ||
| 14 | build: | ||
| 15 | strategy: | ||
| 16 | matrix: | ||
| 17 | include: | ||
| 18 | - target: x86_64-unknown-linux-gnu | ||
| 19 | os: ubuntu-latest | ||
| 20 | - target: aarch64-unknown-linux-gnu | ||
| 21 | os: ubuntu-latest | ||
| 22 | - target: x86_64-apple-darwin | ||
| 23 | os: macos-latest | ||
| 24 | - target: aarch64-apple-darwin | ||
| 25 | os: macos-latest | ||
| 26 | runs-on: ${{ matrix.os }} | ||
| 27 | steps: | ||
| 28 | - uses: actions/checkout@v4 | ||
| 29 | - uses: dtolnay/rust-toolchain@stable | ||
| 30 | with: | ||
| 31 | targets: ${{ matrix.target }} | ||
| 32 | - name: Install cross-compilation tools | ||
| 33 | if: matrix.target == 'aarch64-unknown-linux-gnu' | ||
| 34 | run: | | ||
| 35 | sudo apt-get update | ||
| 36 | sudo apt-get install -y gcc-aarch64-linux-gnu | ||
| 37 | echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV | ||
| 38 | - run: cargo build --release --target ${{ matrix.target }} | ||
| 39 | - name: Package binary | ||
| 40 | run: | | ||
| 41 | cd target/${{ matrix.target }}/release | ||
| 42 | tar czf ../../../git-collab-${{ matrix.target }}.tar.gz git-collab | ||
| 43 | - uses: actions/upload-artifact@v4 | ||
| 44 | with: | ||
| 45 | name: git-collab-${{ matrix.target }} | ||
| 46 | path: git-collab-${{ matrix.target }}.tar.gz | ||
| 47 | |||
| 48 | release: | ||
| 49 | needs: build | ||
| 50 | runs-on: ubuntu-latest | ||
| 51 | steps: | ||
| 52 | - uses: actions/download-artifact@v4 | ||
| 53 | with: | ||
| 54 | merge-multiple: true | ||
| 55 | - uses: softprops/action-gh-release@v2 | ||
| 56 | with: | ||
| 57 | files: git-collab-*.tar.gz | ||
| 58 | generate_release_notes: true | ||
.gitignore
| Old | New | ||
|---|---|---|---|
| @@ -1 +1,2 @@ | |||
| 1 | /target | 1 | /target |
| 2 | /man | ||
CLAUDE.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,35 @@ | |||
| 1 | # git-collab Development Guidelines | ||
| 2 | |||
| 3 | Auto-generated from all feature plans. Last updated: 2026-03-21 | ||
| 4 | |||
| 5 | ## Active Technologies | ||
| 6 | - Rust 2021 edition + git2 0.19, clap 4 (derive), ed25519-dalek 2, base64 0.22, serde/serde_json 1, dirs 5, thiserror 2 (003-key-trust-allowlist) | ||
| 7 | - Git refs under `.git/refs/collab/`, trusted keys file at `.git/collab/trusted-keys` (plain text, not a git object) (003-key-trust-allowlist) | ||
| 8 | - Rust 2021 edition + ratatui 0.30, crossterm 0.29, git2 0.19 (004-dashboard-filtering) | ||
| 9 | - N/A (ephemeral filter state, no persistence) (004-dashboard-filtering) | ||
| 10 | |||
| 11 | - Rust 2021 edition + git2 0.19, clap 4, serde/serde_json 1, chrono 0.4, thiserror 2. New: `ed25519-dalek`, `rand`, `base64` (001-gpg-event-signing) | ||
| 12 | |||
| 13 | ## Project Structure | ||
| 14 | |||
| 15 | ```text | ||
| 16 | src/ | ||
| 17 | tests/ | ||
| 18 | ``` | ||
| 19 | |||
| 20 | ## Commands | ||
| 21 | |||
| 22 | cargo test [ONLY COMMANDS FOR ACTIVE TECHNOLOGIES][ONLY COMMANDS FOR ACTIVE TECHNOLOGIES] cargo clippy | ||
| 23 | |||
| 24 | ## Code Style | ||
| 25 | |||
| 26 | Rust 2021 edition: Follow standard conventions | ||
| 27 | |||
| 28 | ## Recent Changes | ||
| 29 | - 004-dashboard-filtering: Added Rust 2021 edition + ratatui 0.30, crossterm 0.29, git2 0.19 | ||
| 30 | - 003-key-trust-allowlist: Added Rust 2021 edition + git2 0.19, clap 4 (derive), ed25519-dalek 2, base64 0.22, serde/serde_json 1, dirs 5, thiserror 2 | ||
| 31 | |||
| 32 | - 001-gpg-event-signing: Added Rust 2021 edition + git2 0.19, clap 4, serde/serde_json 1, chrono 0.4, thiserror 2. New: `ed25519-dalek`, `rand`, `base64` | ||
| 33 | |||
| 34 | <!-- MANUAL ADDITIONS START --> | ||
| 35 | <!-- MANUAL ADDITIONS END --> | ||
Cargo.lock
| Old | New | ||
|---|---|---|---|
| @@ -245,6 +245,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 245 | checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" | 245 | checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" |
| 246 | 246 | ||
| 247 | [[package]] | 247 | [[package]] |
| 248 | name = "clap_mangen" | ||
| 249 | version = "0.2.33" | ||
| 250 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 251 | checksum = "7e30ffc187e2e3aeafcd1c6e2aa416e29739454c0ccaa419226d5ecd181f2d78" | ||
| 252 | dependencies = [ | ||
| 253 | "clap", | ||
| 254 | "roff", | ||
| 255 | ] | ||
| 256 | |||
| 257 | [[package]] | ||
| 248 | name = "colorchoice" | 258 | name = "colorchoice" |
| 249 | version = "1.0.5" | 259 | version = "1.0.5" |
| 250 | source = "registry+https://github.com/rust-lang/crates.io-index" | 260 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -687,6 +697,7 @@ dependencies = [ | |||
| 687 | "base64", | 697 | "base64", |
| 688 | "chrono", | 698 | "chrono", |
| 689 | "clap", | 699 | "clap", |
| 700 | "clap_mangen", | ||
| 690 | "crossterm", | 701 | "crossterm", |
| 691 | "dirs", | 702 | "dirs", |
| 692 | "ed25519-dalek", | 703 | "ed25519-dalek", |
| @@ -1600,6 +1611,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1600 | checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" | 1611 | checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" |
| 1601 | 1612 | ||
| 1602 | [[package]] | 1613 | [[package]] |
| 1614 | name = "roff" | ||
| 1615 | version = "1.1.0" | ||
| 1616 | source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| 1617 | checksum = "dbf2048e0e979efb2ca7b91c4f1a8d77c91853e9b987c94c555668a8994915ad" | ||
| 1618 | |||
| 1619 | [[package]] | ||
| 1603 | name = "rustc_version" | 1620 | name = "rustc_version" |
| 1604 | version = "0.4.1" | 1621 | version = "0.4.1" |
| 1605 | source = "registry+https://github.com/rust-lang/crates.io-index" | 1622 | source = "registry+https://github.com/rust-lang/crates.io-index" |
Cargo.toml
| Old | New | ||
|---|---|---|---|
| @@ -17,5 +17,9 @@ rand_core = { version = "0.6", features = ["getrandom"] } | |||
| 17 | base64 = "0.22" | 17 | base64 = "0.22" |
| 18 | dirs = "5" | 18 | dirs = "5" |
| 19 | 19 | ||
| 20 | [build-dependencies] | ||
| 21 | clap = { version = "4", features = ["derive"] } | ||
| 22 | clap_mangen = "0.2" | ||
| 23 | |||
| 20 | [dev-dependencies] | 24 | [dev-dependencies] |
| 21 | tempfile = "3" | 25 | tempfile = "3" |
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -30,15 +30,31 @@ ci: lint test build | |||
| 30 | 30 | ||
| 31 | # --- Install --- | 31 | # --- Install --- |
| 32 | install: PROFILE := release | 32 | install: PROFILE := release |
| 33 | install: build | 33 | install: build install-man |
| 34 | install -Dm755 target/release/$(BIN) $(PREFIX)/bin/$(BIN) | 34 | install -Dm755 target/release/$(BIN) $(PREFIX)/bin/$(BIN) |
| 35 | 35 | ||
| 36 | uninstall: | 36 | uninstall: |
| 37 | rm -f $(PREFIX)/bin/$(BIN) | 37 | rm -f $(PREFIX)/bin/$(BIN) |
| 38 | 38 | ||
| 39 | clean: | 39 | clean: clean-man |
| 40 | $(CARGO) clean | 40 | $(CARGO) clean |
| 41 | 41 | ||
| 42 | # --- Man pages --- | ||
| 43 | MAN_DIR := man/man1 | ||
| 44 | |||
| 45 | .PHONY: man install-man clean-man | ||
| 46 | |||
| 47 | man: | ||
| 48 | MAN_OUT_DIR=$(MAN_DIR) $(CARGO) build | ||
| 49 | @echo "Man pages written to $(MAN_DIR)/" | ||
| 50 | |||
| 51 | install-man: man | ||
| 52 | install -d $(PREFIX)/share/man/man1 | ||
| 53 | install -m644 $(MAN_DIR)/*.1 $(PREFIX)/share/man/man1/ | ||
| 54 | |||
| 55 | clean-man: | ||
| 56 | rm -rf man/ | ||
| 57 | |||
| 42 | # --- Dev helpers --- | 58 | # --- Dev helpers --- |
| 43 | .PHONY: run dev dashboard | 59 | .PHONY: run dev dashboard |
| 44 | 60 | ||
build.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,37 @@ | |||
| 1 | use std::env; | ||
| 2 | use std::fs; | ||
| 3 | use std::path::PathBuf; | ||
| 4 | |||
| 5 | include!("src/cli.rs"); | ||
| 6 | |||
| 7 | fn main() { | ||
| 8 | let out = PathBuf::from( | ||
| 9 | env::var("MAN_OUT_DIR").unwrap_or_else(|_| { | ||
| 10 | env::var("OUT_DIR").expect("OUT_DIR not set") | ||
| 11 | }), | ||
| 12 | ); | ||
| 13 | |||
| 14 | let cmd = <Cli as clap::CommandFactory>::command(); | ||
| 15 | generate_manpages(&cmd, &out); | ||
| 16 | } | ||
| 17 | |||
| 18 | fn generate_manpages(cmd: &clap::Command, out: &PathBuf) { | ||
| 19 | let man = clap_mangen::Man::new(cmd.clone()); | ||
| 20 | let name = cmd.get_name().to_string(); | ||
| 21 | let mut buf = Vec::new(); | ||
| 22 | man.render(&mut buf).expect("failed to render man page"); | ||
| 23 | fs::create_dir_all(out).expect("failed to create man output dir"); | ||
| 24 | fs::write(out.join(format!("{name}.1")), buf) | ||
| 25 | .expect("failed to write man page"); | ||
| 26 | |||
| 27 | for sub in cmd.get_subcommands() { | ||
| 28 | if sub.is_hide_set() { | ||
| 29 | continue; | ||
| 30 | } | ||
| 31 | let sub_name = format!("{}-{}", cmd.get_name(), sub.get_name()); | ||
| 32 | // Leak is fine: build scripts are short-lived processes. clap requires 'static str for name(). | ||
| 33 | let sub_name: &'static str = Box::leak(sub_name.into_boxed_str()); | ||
| 34 | let sub_cmd = sub.clone().name(sub_name); | ||
| 35 | generate_manpages(&sub_cmd, out); | ||
| 36 | } | ||
| 37 | } | ||
src/cli.rs
| Old | New | ||
|---|---|---|---|
| @@ -165,9 +165,9 @@ pub enum PatchCmd { | |||
| 165 | /// Base branch ref | 165 | /// Base branch ref |
| 166 | #[arg(long, default_value = "main")] | 166 | #[arg(long, default_value = "main")] |
| 167 | base: String, | 167 | base: String, |
| 168 | /// Head commit to review | 168 | /// Head commit to review (defaults to HEAD) |
| 169 | #[arg(long)] | 169 | #[arg(long)] |
| 170 | head: String, | 170 | head: Option<String>, |
| 171 | /// Issue ID this patch fixes (auto-closes on merge) | 171 | /// Issue ID this patch fixes (auto-closes on merge) |
| 172 | #[arg(long)] | 172 | #[arg(long)] |
| 173 | fixes: Option<String>, | 173 | fixes: Option<String>, |
| @@ -217,9 +217,9 @@ pub enum PatchCmd { | |||
| 217 | Revise { | 217 | Revise { |
| 218 | /// Patch ID (prefix match) | 218 | /// Patch ID (prefix match) |
| 219 | id: String, | 219 | id: String, |
| 220 | /// New head commit | 220 | /// New head commit (defaults to HEAD) |
| 221 | #[arg(long)] | 221 | #[arg(long)] |
| 222 | head: String, | 222 | head: Option<String>, |
| 223 | /// Updated description | 223 | /// Updated description |
| 224 | #[arg(short, long)] | 224 | #[arg(short, long)] |
| 225 | body: Option<String>, | 225 | body: Option<String>, |
| @@ -237,4 +237,9 @@ pub enum PatchCmd { | |||
| 237 | #[arg(short, long)] | 237 | #[arg(short, long)] |
| 238 | reason: Option<String>, | 238 | reason: Option<String>, |
| 239 | }, | 239 | }, |
| 240 | /// Import patches from format-patch files | ||
| 241 | Import { | ||
| 242 | /// One or more .patch files to import | ||
| 243 | files: Vec<std::path::PathBuf>, | ||
| 244 | }, | ||
| 240 | } | 245 | } |
src/editor.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,275 @@ | |||
| 1 | use std::process::Command; | ||
| 2 | |||
| 3 | use crate::error::Error; | ||
| 4 | |||
| 5 | /// Resolve the user's preferred editor by checking $VISUAL, then $EDITOR. | ||
| 6 | /// Returns `None` if neither is set or both are empty. | ||
| 7 | pub fn resolve_editor() -> Option<String> { | ||
| 8 | resolve_editor_from( | ||
| 9 | std::env::var("VISUAL").ok().as_deref(), | ||
| 10 | std::env::var("EDITOR").ok().as_deref(), | ||
| 11 | ) | ||
| 12 | } | ||
| 13 | |||
| 14 | /// Inner helper: resolve editor from explicit values (testable without env mutation). | ||
| 15 | fn resolve_editor_from(visual: Option<&str>, editor: Option<&str>) -> Option<String> { | ||
| 16 | for val in [visual, editor].into_iter().flatten() { | ||
| 17 | let trimmed = val.trim(); | ||
| 18 | if !trimmed.is_empty() { | ||
| 19 | return Some(trimmed.to_string()); | ||
| 20 | } | ||
| 21 | } | ||
| 22 | None | ||
| 23 | } | ||
| 24 | |||
| 25 | /// Launch the editor at a specific file and line number. | ||
| 26 | /// | ||
| 27 | /// The editor string is split on whitespace to support editors like `code --wait`. | ||
| 28 | /// The command is invoked as: `<editor...> +{line} {file}`. | ||
| 29 | pub fn open_editor_at(file: &str, line: u32) -> Result<(), Error> { | ||
| 30 | let editor_str = resolve_editor().ok_or_else(|| { | ||
| 31 | Error::Cmd("No editor configured. Set $EDITOR or $VISUAL.".to_string()) | ||
| 32 | })?; | ||
| 33 | |||
| 34 | open_editor_at_with(file, line, &editor_str) | ||
| 35 | } | ||
| 36 | |||
| 37 | /// Inner helper: launch an editor command at a specific file and line. | ||
| 38 | /// Separated from `open_editor_at` so tests can pass an explicit editor string | ||
| 39 | /// without mutating environment variables. | ||
| 40 | fn open_editor_at_with(file: &str, line: u32, editor_str: &str) -> Result<(), Error> { | ||
| 41 | if !std::path::Path::new(file).exists() { | ||
| 42 | return Err(Error::Cmd(format!("File not found: {}", file))); | ||
| 43 | } | ||
| 44 | |||
| 45 | let parts: Vec<&str> = editor_str.split_whitespace().collect(); | ||
| 46 | if parts.is_empty() { | ||
| 47 | return Err(Error::Cmd("Editor command is empty".to_string())); | ||
| 48 | } | ||
| 49 | |||
| 50 | let program = parts[0]; | ||
| 51 | let extra_args = &parts[1..]; | ||
| 52 | |||
| 53 | let status = Command::new(program) | ||
| 54 | .args(extra_args) | ||
| 55 | .arg(format!("+{}", line)) | ||
| 56 | .arg(file) | ||
| 57 | .status() | ||
| 58 | .map_err(|e| Error::Cmd(format!("Failed to launch editor '{}': {}", program, e)))?; | ||
| 59 | |||
| 60 | if !status.success() { | ||
| 61 | let code = status.code().unwrap_or(-1); | ||
| 62 | return Err(Error::Cmd(format!("Editor exited with status: {}", code))); | ||
| 63 | } | ||
| 64 | |||
| 65 | Ok(()) | ||
| 66 | } | ||
| 67 | |||
| 68 | /// Given a list of comment positions `(file, line, rendered_row)` and the | ||
| 69 | /// current scroll position, find the comment whose rendered row is closest to | ||
| 70 | /// and at or above the scroll position. Returns `(file, line)` if found. | ||
| 71 | /// | ||
| 72 | /// The `rendered_position` (third tuple element) is the row index within the | ||
| 73 | /// rendered diff text where this inline comment appears. | ||
| 74 | pub fn find_comment_at_scroll( | ||
| 75 | comments: &[(String, u32, usize)], | ||
| 76 | scroll_pos: u16, | ||
| 77 | ) -> Option<(String, u32)> { | ||
| 78 | let scroll = scroll_pos as usize; | ||
| 79 | let mut best: Option<&(String, u32, usize)> = None; | ||
| 80 | |||
| 81 | for entry in comments { | ||
| 82 | let rendered = entry.2; | ||
| 83 | if rendered <= scroll { | ||
| 84 | match best { | ||
| 85 | Some(prev) if rendered > prev.2 => best = Some(entry), | ||
| 86 | None => best = Some(entry), | ||
| 87 | _ => {} | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | best.map(|e| (e.0.clone(), e.1)) | ||
| 93 | } | ||
| 94 | |||
| 95 | #[cfg(test)] | ||
| 96 | mod tests { | ||
| 97 | use super::*; | ||
| 98 | use std::io::Write; | ||
| 99 | |||
| 100 | // ---- resolve_editor tests (pure, no env mutation) ---- | ||
| 101 | |||
| 102 | #[test] | ||
| 103 | fn test_resolve_editor_visual_takes_precedence() { | ||
| 104 | let result = resolve_editor_from(Some("nvim"), Some("vi")); | ||
| 105 | assert_eq!(result, Some("nvim".to_string())); | ||
| 106 | } | ||
| 107 | |||
| 108 | #[test] | ||
| 109 | fn test_resolve_editor_falls_back_to_editor() { | ||
| 110 | let result = resolve_editor_from(None, Some("nano")); | ||
| 111 | assert_eq!(result, Some("nano".to_string())); | ||
| 112 | } | ||
| 113 | |||
| 114 | #[test] | ||
| 115 | fn test_resolve_editor_none_when_unset() { | ||
| 116 | let result = resolve_editor_from(None, None); | ||
| 117 | assert_eq!(result, None); | ||
| 118 | } | ||
| 119 | |||
| 120 | #[test] | ||
| 121 | fn test_resolve_editor_skips_empty_visual() { | ||
| 122 | let result = resolve_editor_from(Some(" "), Some("vim")); | ||
| 123 | assert_eq!(result, Some("vim".to_string())); | ||
| 124 | } | ||
| 125 | |||
| 126 | #[test] | ||
| 127 | fn test_resolve_editor_both_empty() { | ||
| 128 | let result = resolve_editor_from(Some(""), Some("")); | ||
| 129 | assert_eq!(result, None); | ||
| 130 | } | ||
| 131 | |||
| 132 | #[test] | ||
| 133 | fn test_resolve_editor_trims_whitespace() { | ||
| 134 | let result = resolve_editor_from(None, Some(" vim ")); | ||
| 135 | assert_eq!(result, Some("vim".to_string())); | ||
| 136 | } | ||
| 137 | |||
| 138 | // ---- find_comment_at_scroll tests ---- | ||
| 139 | |||
| 140 | #[test] | ||
| 141 | fn test_find_comment_at_scroll_empty() { | ||
| 142 | let comments: Vec<(String, u32, usize)> = vec![]; | ||
| 143 | assert_eq!(find_comment_at_scroll(&comments, 10), None); | ||
| 144 | } | ||
| 145 | |||
| 146 | #[test] | ||
| 147 | fn test_find_comment_at_scroll_exact_match() { | ||
| 148 | let comments = vec![ | ||
| 149 | ("src/main.rs".to_string(), 42, 10), | ||
| 150 | ("src/lib.rs".to_string(), 15, 20), | ||
| 151 | ]; | ||
| 152 | let result = find_comment_at_scroll(&comments, 10); | ||
| 153 | assert_eq!(result, Some(("src/main.rs".to_string(), 42))); | ||
| 154 | } | ||
| 155 | |||
| 156 | #[test] | ||
| 157 | fn test_find_comment_at_scroll_picks_closest_above() { | ||
| 158 | let comments = vec![ | ||
| 159 | ("a.rs".to_string(), 1, 5), | ||
| 160 | ("b.rs".to_string(), 2, 15), | ||
| 161 | ("c.rs".to_string(), 3, 25), | ||
| 162 | ]; | ||
| 163 | // Scroll is at 20, closest at-or-above is rendered_pos=15 | ||
| 164 | let result = find_comment_at_scroll(&comments, 20); | ||
| 165 | assert_eq!(result, Some(("b.rs".to_string(), 2))); | ||
| 166 | } | ||
| 167 | |||
| 168 | #[test] | ||
| 169 | fn test_find_comment_at_scroll_all_below() { | ||
| 170 | let comments = vec![ | ||
| 171 | ("a.rs".to_string(), 1, 30), | ||
| 172 | ("b.rs".to_string(), 2, 40), | ||
| 173 | ]; | ||
| 174 | // Scroll at 10, all comments are below | ||
| 175 | let result = find_comment_at_scroll(&comments, 10); | ||
| 176 | assert_eq!(result, None); | ||
| 177 | } | ||
| 178 | |||
| 179 | #[test] | ||
| 180 | fn test_find_comment_at_scroll_first_wins_on_tie() { | ||
| 181 | // Two comments at same rendered position -- first in list wins | ||
| 182 | // because our > comparison doesn't replace when equal. | ||
| 183 | let comments = vec![ | ||
| 184 | ("a.rs".to_string(), 1, 10), | ||
| 185 | ("b.rs".to_string(), 2, 10), | ||
| 186 | ]; | ||
| 187 | let result = find_comment_at_scroll(&comments, 10); | ||
| 188 | assert_eq!(result, Some(("a.rs".to_string(), 1))); | ||
| 189 | } | ||
| 190 | |||
| 191 | #[test] | ||
| 192 | fn test_find_comment_at_scroll_scroll_at_zero() { | ||
| 193 | let comments = vec![ | ||
| 194 | ("a.rs".to_string(), 1, 0), | ||
| 195 | ("b.rs".to_string(), 2, 5), | ||
| 196 | ]; | ||
| 197 | let result = find_comment_at_scroll(&comments, 0); | ||
| 198 | assert_eq!(result, Some(("a.rs".to_string(), 1))); | ||
| 199 | } | ||
| 200 | |||
| 201 | #[test] | ||
| 202 | fn test_find_comment_at_scroll_single_comment_above() { | ||
| 203 | let comments = vec![("only.rs".to_string(), 99, 3)]; | ||
| 204 | let result = find_comment_at_scroll(&comments, 100); | ||
| 205 | assert_eq!(result, Some(("only.rs".to_string(), 99))); | ||
| 206 | } | ||
| 207 | |||
| 208 | // ---- open_editor_at tests (using inner helper, no env mutation) ---- | ||
| 209 | |||
| 210 | #[test] | ||
| 211 | fn test_open_editor_at_success_with_true() { | ||
| 212 | let mut tmp = tempfile::NamedTempFile::new().unwrap(); | ||
| 213 | writeln!(tmp, "hello").unwrap(); | ||
| 214 | let path = tmp.path().to_str().unwrap().to_string(); | ||
| 215 | |||
| 216 | let result = open_editor_at_with(&path, 1, "true"); | ||
| 217 | assert!(result.is_ok(), "Expected Ok, got {:?}", result); | ||
| 218 | } | ||
| 219 | |||
| 220 | #[test] | ||
| 221 | fn test_open_editor_at_file_not_found() { | ||
| 222 | let result = | ||
| 223 | open_editor_at_with("/tmp/nonexistent_file_for_test_abc123xyz.txt", 1, "true"); | ||
| 224 | assert!(result.is_err()); | ||
| 225 | let err_msg = format!("{}", result.unwrap_err()); | ||
| 226 | assert!( | ||
| 227 | err_msg.contains("File not found"), | ||
| 228 | "Unexpected error: {}", | ||
| 229 | err_msg | ||
| 230 | ); | ||
| 231 | } | ||
| 232 | |||
| 233 | #[test] | ||
| 234 | fn test_open_editor_at_nonzero_exit() { | ||
| 235 | let mut tmp = tempfile::NamedTempFile::new().unwrap(); | ||
| 236 | writeln!(tmp, "hello").unwrap(); | ||
| 237 | let path = tmp.path().to_str().unwrap().to_string(); | ||
| 238 | |||
| 239 | let result = open_editor_at_with(&path, 1, "false"); | ||
| 240 | assert!(result.is_err()); | ||
| 241 | let err_msg = format!("{}", result.unwrap_err()); | ||
| 242 | assert!( | ||
| 243 | err_msg.contains("Editor exited with status"), | ||
| 244 | "Unexpected error: {}", | ||
| 245 | err_msg | ||
| 246 | ); | ||
| 247 | } | ||
| 248 | |||
| 249 | #[test] | ||
| 250 | fn test_open_editor_at_with_args_in_editor_string() { | ||
| 251 | // `true` ignores all arguments, so "true --wait" should succeed | ||
| 252 | let mut tmp = tempfile::NamedTempFile::new().unwrap(); | ||
| 253 | writeln!(tmp, "hello").unwrap(); | ||
| 254 | let path = tmp.path().to_str().unwrap().to_string(); | ||
| 255 | |||
| 256 | let result = open_editor_at_with(&path, 42, "true --wait"); | ||
| 257 | assert!(result.is_ok(), "Expected Ok, got {:?}", result); | ||
| 258 | } | ||
| 259 | |||
| 260 | #[test] | ||
| 261 | fn test_open_editor_at_bad_command() { | ||
| 262 | let mut tmp = tempfile::NamedTempFile::new().unwrap(); | ||
| 263 | writeln!(tmp, "hello").unwrap(); | ||
| 264 | let path = tmp.path().to_str().unwrap().to_string(); | ||
| 265 | |||
| 266 | let result = open_editor_at_with(&path, 1, "nonexistent_editor_binary_xyz"); | ||
| 267 | assert!(result.is_err()); | ||
| 268 | let err_msg = format!("{}", result.unwrap_err()); | ||
| 269 | assert!( | ||
| 270 | err_msg.contains("Failed to launch editor"), | ||
| 271 | "Unexpected error: {}", | ||
| 272 | err_msg | ||
| 273 | ); | ||
| 274 | } | ||
| 275 | } | ||
src/error.rs
| Old | New | ||
|---|---|---|---|
| @@ -25,4 +25,10 @@ pub enum Error { | |||
| 25 | 25 | ||
| 26 | #[error("untrusted key: {0}")] | 26 | #[error("untrusted key: {0}")] |
| 27 | UntrustedKey(String), | 27 | UntrustedKey(String), |
| 28 | |||
| 29 | #[error("malformed patch: {0}")] | ||
| 30 | MalformedPatch(String), | ||
| 31 | |||
| 32 | #[error("patch apply failed: {0}")] | ||
| 33 | PatchApplyFailed(String), | ||
| 28 | } | 34 | } |
src/lib.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,6 @@ | |||
| 1 | pub mod cli; | 1 | pub mod cli; |
| 2 | pub mod dag; | 2 | pub mod dag; |
| 3 | pub mod editor; | ||
| 3 | pub mod error; | 4 | pub mod error; |
| 4 | pub mod event; | 5 | pub mod event; |
| 5 | pub mod identity; | 6 | pub mod identity; |
| @@ -133,6 +134,10 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 133 | head, | 134 | head, |
| 134 | fixes, | 135 | fixes, |
| 135 | } => { | 136 | } => { |
| 137 | let head = match head { | ||
| 138 | Some(h) => h, | ||
| 139 | None => repo.head()?.peel_to_commit()?.id().to_string(), | ||
| 140 | }; | ||
| 136 | let id = patch::create(repo, &title, &body, &base, &head, fixes.as_deref())?; | 141 | let id = patch::create(repo, &title, &body, &base, &head, fixes.as_deref())?; |
| 137 | println!("Created patch {:.8}", id); | 142 | println!("Created patch {:.8}", id); |
| 138 | Ok(()) | 143 | Ok(()) |
| @@ -236,6 +241,10 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 236 | Ok(()) | 241 | Ok(()) |
| 237 | } | 242 | } |
| 238 | PatchCmd::Revise { id, head, body } => { | 243 | PatchCmd::Revise { id, head, body } => { |
| 244 | let head = match head { | ||
| 245 | Some(h) => h, | ||
| 246 | None => repo.head()?.peel_to_commit()?.id().to_string(), | ||
| 247 | }; | ||
| 239 | patch::revise(repo, &id, &head, body.as_deref())?; | 248 | patch::revise(repo, &id, &head, body.as_deref())?; |
| 240 | println!("Patch revised."); | 249 | println!("Patch revised."); |
| 241 | Ok(()) | 250 | Ok(()) |
| @@ -250,6 +259,13 @@ pub fn run(cli: cli::Cli, repo: &Repository) -> Result<(), error::Error> { | |||
| 250 | println!("Patch closed."); | 259 | println!("Patch closed."); |
| 251 | Ok(()) | 260 | Ok(()) |
| 252 | } | 261 | } |
| 262 | PatchCmd::Import { files } => { | ||
| 263 | let ids = patch::import_series(repo, &files)?; | ||
| 264 | for id in &ids { | ||
| 265 | println!("Imported patch {:.8}", id); | ||
| 266 | } | ||
| 267 | Ok(()) | ||
| 268 | } | ||
| 253 | }, | 269 | }, |
| 254 | Commands::Dashboard => tui::run(repo), | 270 | Commands::Dashboard => tui::run(repo), |
| 255 | Commands::Sync { remote } => sync::sync(repo, &remote), | 271 | Commands::Sync { remote } => sync::sync(repo, &remote), |
src/patch.rs
| Old | New | ||
|---|---|---|---|
| @@ -1,4 +1,6 @@ | |||
| 1 | use git2::{DiffFormat, Repository}; | 1 | use std::path::Path; |
| 2 | |||
| 3 | use git2::{Diff, DiffFormat, Repository}; | ||
| 2 | 4 | ||
| 3 | use crate::dag; | 5 | use crate::dag; |
| 4 | use crate::error::Error; | 6 | use crate::error::Error; |
| @@ -281,3 +283,187 @@ pub fn close( | |||
| 281 | dag::append_event(repo, &ref_name, &event, &sk)?; | 283 | dag::append_event(repo, &ref_name, &event, &sk)?; |
| 282 | Ok(()) | 284 | Ok(()) |
| 283 | } | 285 | } |
| 286 | |||
| 287 | // --------------------------------------------------------------------------- | ||
| 288 | // Patch import from format-patch files | ||
| 289 | // --------------------------------------------------------------------------- | ||
| 290 | |||
| 291 | /// Parsed metadata from a git format-patch mbox header. | ||
| 292 | struct PatchHeader { | ||
| 293 | subject: String, | ||
| 294 | body: String, | ||
| 295 | } | ||
| 296 | |||
| 297 | /// Parse a format-patch file into its mbox header metadata and the raw diff portion. | ||
| 298 | fn parse_format_patch(content: &str) -> Result<(PatchHeader, String), Error> { | ||
| 299 | // Find the "---" separator that divides the commit message from the diffstat/diff. | ||
| 300 | // The diff starts at the first line matching "diff --git". | ||
| 301 | let diff_start = content | ||
| 302 | .find("\ndiff --git ") | ||
| 303 | .map(|i| i + 1) // skip the leading newline | ||
| 304 | .ok_or_else(|| Error::MalformedPatch("no 'diff --git' found in patch file".to_string()))?; | ||
| 305 | |||
| 306 | let header_section = &content[..diff_start]; | ||
| 307 | let diff_section = &content[diff_start..]; | ||
| 308 | |||
| 309 | // Extract Subject line | ||
| 310 | let subject_line = header_section | ||
| 311 | .lines() | ||
| 312 | .find(|l| l.starts_with("Subject:")) | ||
| 313 | .ok_or_else(|| Error::MalformedPatch("no Subject header found".to_string()))?; | ||
| 314 | |||
| 315 | // Strip "Subject: " prefix and optional "[PATCH] " or "[PATCH n/m] " prefix | ||
| 316 | let subject = subject_line | ||
| 317 | .strip_prefix("Subject:") | ||
| 318 | .unwrap() | ||
| 319 | .trim(); | ||
| 320 | let subject = if let Some(rest) = subject.strip_prefix("[PATCH") { | ||
| 321 | // Skip to the "] " closing bracket | ||
| 322 | if let Some(idx) = rest.find("] ") { | ||
| 323 | rest[idx + 2..].to_string() | ||
| 324 | } else { | ||
| 325 | subject.to_string() | ||
| 326 | } | ||
| 327 | } else { | ||
| 328 | subject.to_string() | ||
| 329 | }; | ||
| 330 | |||
| 331 | // Extract body: everything between the blank line after headers and the "---" separator | ||
| 332 | let body = extract_body(header_section); | ||
| 333 | |||
| 334 | // Trim trailing "-- \n2.xx.x\n" signature from diff | ||
| 335 | let diff_clean = trim_patch_signature(diff_section); | ||
| 336 | |||
| 337 | Ok((PatchHeader { subject, body }, diff_clean)) | ||
| 338 | } | ||
| 339 | |||
| 340 | /// Extract the commit message body from the header section. | ||
| 341 | /// The body is between the first blank line after headers and the "---" line. | ||
| 342 | fn extract_body(header_section: &str) -> String { | ||
| 343 | let lines: Vec<&str> = header_section.lines().collect(); | ||
| 344 | let mut body_start = None; | ||
| 345 | let mut body_end = None; | ||
| 346 | |||
| 347 | // Find first blank line (end of mail headers) | ||
| 348 | for (i, line) in lines.iter().enumerate() { | ||
| 349 | if line.is_empty() && body_start.is_none() { | ||
| 350 | body_start = Some(i + 1); | ||
| 351 | } | ||
| 352 | } | ||
| 353 | |||
| 354 | // Find the "---" separator line (start of diffstat) | ||
| 355 | for (i, line) in lines.iter().enumerate().rev() { | ||
| 356 | if *line == "---" { | ||
| 357 | body_end = Some(i); | ||
| 358 | break; | ||
| 359 | } | ||
| 360 | } | ||
| 361 | |||
| 362 | match (body_start, body_end) { | ||
| 363 | (Some(start), Some(end)) if start < end => { | ||
| 364 | lines[start..end].join("\n").trim().to_string() | ||
| 365 | } | ||
| 366 | (Some(start), None) => { | ||
| 367 | // No "---" separator, take everything after headers | ||
| 368 | lines[start..].join("\n").trim().to_string() | ||
| 369 | } | ||
| 370 | _ => String::new(), | ||
| 371 | } | ||
| 372 | } | ||
| 373 | |||
| 374 | /// Remove trailing git patch signature ("-- \n2.xx.x\n") from diff content. | ||
| 375 | fn trim_patch_signature(diff: &str) -> String { | ||
| 376 | if let Some(idx) = diff.rfind("\n-- \n") { | ||
| 377 | diff[..idx + 1].to_string() // keep the trailing newline | ||
| 378 | } else { | ||
| 379 | diff.to_string() | ||
| 380 | } | ||
| 381 | } | ||
| 382 | |||
| 383 | /// Import a single format-patch file, creating a commit and DAG entry. | ||
| 384 | /// Returns the patch ID. | ||
| 385 | pub fn import(repo: &Repository, patch_path: &Path) -> Result<String, Error> { | ||
| 386 | let content = std::fs::read_to_string(patch_path)?; | ||
| 387 | let (header, diff_text) = parse_format_patch(&content)?; | ||
| 388 | |||
| 389 | // Parse the diff with git2 | ||
| 390 | let diff = Diff::from_buffer(diff_text.as_bytes()) | ||
| 391 | .map_err(|e| Error::MalformedPatch(format!("invalid diff: {}", e)))?; | ||
| 392 | |||
| 393 | // Get the base (HEAD) commit and its tree | ||
| 394 | let head_ref = repo | ||
| 395 | .head() | ||
| 396 | .map_err(|e| Error::PatchApplyFailed(format!("cannot resolve HEAD: {}", e)))?; | ||
| 397 | let head_commit = head_ref | ||
| 398 | .peel_to_commit() | ||
| 399 | .map_err(|e| Error::PatchApplyFailed(format!("HEAD is not a commit: {}", e)))?; | ||
| 400 | let base_tree = head_commit.tree()?; | ||
| 401 | |||
| 402 | // Apply the diff to the base tree in-memory | ||
| 403 | let new_index = repo | ||
| 404 | .apply_to_tree(&base_tree, &diff, None) | ||
| 405 | .map_err(|e| Error::PatchApplyFailed(format!("apply failed: {}", e)))?; | ||
| 406 | |||
| 407 | // Write the index to a tree | ||
| 408 | let tree_oid = { | ||
| 409 | let mut idx = new_index; | ||
| 410 | idx.write_tree_to(repo)? | ||
| 411 | }; | ||
| 412 | let new_tree = repo.find_tree(tree_oid)?; | ||
| 413 | |||
| 414 | // Create a commit on a detached ref (no branch update) | ||
| 415 | let author = get_author(repo)?; | ||
| 416 | let sig = crate::identity::author_signature(&author)?; | ||
| 417 | let commit_msg = format!("imported: {}", header.subject); | ||
| 418 | let commit_oid = repo.commit( | ||
| 419 | None, // don't update any ref | ||
| 420 | &sig, | ||
| 421 | &sig, | ||
| 422 | &commit_msg, | ||
| 423 | &new_tree, | ||
| 424 | &[&head_commit], | ||
| 425 | )?; | ||
| 426 | |||
| 427 | // Determine the base branch name from HEAD | ||
| 428 | let base_ref = repo | ||
| 429 | .head()? | ||
| 430 | .shorthand() | ||
| 431 | .unwrap_or("main") | ||
| 432 | .to_string(); | ||
| 433 | |||
| 434 | // Create a DAG entry using the existing patch create infrastructure | ||
| 435 | let id = create( | ||
| 436 | repo, | ||
| 437 | &header.subject, | ||
| 438 | &header.body, | ||
| 439 | &base_ref, | ||
| 440 | &commit_oid.to_string(), | ||
| 441 | None, | ||
| 442 | )?; | ||
| 443 | |||
| 444 | Ok(id) | ||
| 445 | } | ||
| 446 | |||
| 447 | /// Import a series of format-patch files. If any fails, rolls back all | ||
| 448 | /// previously imported patches from this series. | ||
| 449 | pub fn import_series(repo: &Repository, files: &[impl AsRef<Path>]) -> Result<Vec<String>, Error> { | ||
| 450 | let mut imported_ids: Vec<String> = Vec::new(); | ||
| 451 | |||
| 452 | for file in files { | ||
| 453 | match import(repo, file.as_ref()) { | ||
| 454 | Ok(id) => imported_ids.push(id), | ||
| 455 | Err(e) => { | ||
| 456 | // Rollback: delete all refs created in this series | ||
| 457 | for id in &imported_ids { | ||
| 458 | let ref_name = format!("refs/collab/patches/{}", id); | ||
| 459 | if let Ok(mut reference) = repo.find_reference(&ref_name) { | ||
| 460 | let _ = reference.delete(); | ||
| 461 | } | ||
| 462 | } | ||
| 463 | return Err(e); | ||
| 464 | } | ||
| 465 | } | ||
| 466 | } | ||
| 467 | |||
| 468 | Ok(imported_ids) | ||
| 469 | } | ||
src/trust.rs
| Old | New | ||
|---|---|---|---|
| @@ -134,19 +134,19 @@ pub fn remove_trusted_key(repo: &Repository, pubkey: &str) -> Result<TrustedKey, | |||
| 134 | let policy = load_trust_policy(repo)?; | 134 | let policy = load_trust_policy(repo)?; |
| 135 | match policy { | 135 | match policy { |
| 136 | TrustPolicy::Unconfigured => { | 136 | TrustPolicy::Unconfigured => { |
| 137 | return Err(Error::Cmd(format!( | 137 | Err(Error::Cmd(format!( |
| 138 | "key {} is not in the trusted keys list", | 138 | "key {} is not in the trusted keys list", |
| 139 | pubkey | 139 | pubkey |
| 140 | ))); | 140 | ))) |
| 141 | } | 141 | } |
| 142 | TrustPolicy::Configured(keys) => { | 142 | TrustPolicy::Configured(keys) => { |
| 143 | let removed = keys.iter().find(|k| k.pubkey == pubkey).cloned(); | 143 | let removed = keys.iter().find(|k| k.pubkey == pubkey).cloned(); |
| 144 | match removed { | 144 | match removed { |
| 145 | None => { | 145 | None => { |
| 146 | return Err(Error::Cmd(format!( | 146 | Err(Error::Cmd(format!( |
| 147 | "key {} is not in the trusted keys list", | 147 | "key {} is not in the trusted keys list", |
| 148 | pubkey | 148 | pubkey |
| 149 | ))); | 149 | ))) |
| 150 | } | 150 | } |
| 151 | Some(removed_key) => { | 151 | Some(removed_key) => { |
| 152 | // Rewrite file without the removed key | 152 | // Rewrite file without the removed key |
src/tui.rs
| Old | New | ||
|---|---|---|---|
| @@ -5,31 +5,42 @@ use std::time::Duration; | |||
| 5 | use crossterm::event::{self, Event, KeyCode, KeyModifiers}; | 5 | use crossterm::event::{self, Event, KeyCode, KeyModifiers}; |
| 6 | use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; | 6 | use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}; |
| 7 | use crossterm::ExecutableCommand; | 7 | use crossterm::ExecutableCommand; |
| 8 | use git2::Repository; | 8 | use git2::{Oid, Repository}; |
| 9 | use ratatui::prelude::*; | 9 | use ratatui::prelude::*; |
| 10 | use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; | 10 | use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; |
| 11 | 11 | ||
| 12 | use crate::error::Error; | 12 | use crate::error::Error; |
| 13 | use crate::event::Action; | ||
| 13 | use crate::issue as issue_mod; | 14 | use crate::issue as issue_mod; |
| 14 | use crate::patch as patch_mod; | 15 | use crate::patch as patch_mod; |
| 15 | use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; | 16 | use crate::state::{self, IssueState, IssueStatus, PatchState, PatchStatus}; |
| 16 | 17 | ||
| 17 | #[derive(PartialEq)] | 18 | #[derive(Debug, PartialEq)] |
| 18 | enum Pane { | 19 | enum Pane { |
| 19 | ItemList, | 20 | ItemList, |
| 20 | Detail, | 21 | Detail, |
| 21 | } | 22 | } |
| 22 | 23 | ||
| 23 | #[derive(PartialEq, Clone, Copy)] | 24 | #[derive(Debug, PartialEq, Clone, Copy)] |
| 24 | enum Tab { | 25 | enum Tab { |
| 25 | Issues, | 26 | Issues, |
| 26 | Patches, | 27 | Patches, |
| 27 | } | 28 | } |
| 28 | 29 | ||
| 29 | #[derive(PartialEq)] | 30 | #[derive(Debug, PartialEq)] |
| 30 | enum ViewMode { | 31 | enum ViewMode { |
| 31 | Details, | 32 | Details, |
| 32 | Diff, | 33 | Diff, |
| 34 | CommitList, | ||
| 35 | CommitDetail, | ||
| 36 | } | ||
| 37 | |||
| 38 | #[derive(Debug, PartialEq)] | ||
| 39 | enum KeyAction { | ||
| 40 | Continue, | ||
| 41 | Quit, | ||
| 42 | Reload, | ||
| 43 | OpenCommitBrowser, | ||
| 33 | } | 44 | } |
| 34 | 45 | ||
| 35 | #[derive(Debug, PartialEq, Clone, Copy)] | 46 | #[derive(Debug, PartialEq, Clone, Copy)] |
| @@ -80,6 +91,8 @@ struct App { | |||
| 80 | input_buf: String, | 91 | input_buf: String, |
| 81 | create_title: String, | 92 | create_title: String, |
| 82 | status_msg: Option<String>, | 93 | status_msg: Option<String>, |
| 94 | event_history: Vec<(Oid, crate::event::Event)>, | ||
| 95 | event_list_state: ListState, | ||
| 83 | } | 96 | } |
| 84 | 97 | ||
| 85 | impl App { | 98 | impl App { |
| @@ -103,6 +116,8 @@ impl App { | |||
| 103 | input_buf: String::new(), | 116 | input_buf: String::new(), |
| 104 | create_title: String::new(), | 117 | create_title: String::new(), |
| 105 | status_msg: None, | 118 | status_msg: None, |
| 119 | event_history: Vec::new(), | ||
| 120 | event_list_state: ListState::default(), | ||
| 106 | } | 121 | } |
| 107 | } | 122 | } |
| 108 | 123 | ||
| @@ -215,7 +230,7 @@ impl App { | |||
| 215 | } | 230 | } |
| 216 | } | 231 | } |
| 217 | } | 232 | } |
| 218 | return false; | 233 | false |
| 219 | } | 234 | } |
| 220 | Tab::Patches => { | 235 | Tab::Patches => { |
| 221 | // From a patch, jump to the linked issue (fixes field) | 236 | // From a patch, jump to the linked issue (fixes field) |
| @@ -243,11 +258,188 @@ impl App { | |||
| 243 | } | 258 | } |
| 244 | } | 259 | } |
| 245 | } | 260 | } |
| 246 | return false; | 261 | false |
| 262 | } | ||
| 263 | } | ||
| 264 | } | ||
| 265 | |||
| 266 | fn handle_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> KeyAction { | ||
| 267 | // Handle CommitDetail mode first | ||
| 268 | if self.mode == ViewMode::CommitDetail { | ||
| 269 | match code { | ||
| 270 | KeyCode::Esc => { | ||
| 271 | self.mode = ViewMode::CommitList; | ||
| 272 | self.scroll = 0; | ||
| 273 | return KeyAction::Continue; | ||
| 274 | } | ||
| 275 | KeyCode::Char('q') => return KeyAction::Quit, | ||
| 276 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { | ||
| 277 | return KeyAction::Quit; | ||
| 278 | } | ||
| 279 | KeyCode::Char('j') | KeyCode::Down => { | ||
| 280 | self.scroll = self.scroll.saturating_add(1); | ||
| 281 | return KeyAction::Continue; | ||
| 282 | } | ||
| 283 | KeyCode::Char('k') | KeyCode::Up => { | ||
| 284 | self.scroll = self.scroll.saturating_sub(1); | ||
| 285 | return KeyAction::Continue; | ||
| 286 | } | ||
| 287 | KeyCode::PageDown => { | ||
| 288 | self.scroll = self.scroll.saturating_add(20); | ||
| 289 | return KeyAction::Continue; | ||
| 290 | } | ||
| 291 | KeyCode::PageUp => { | ||
| 292 | self.scroll = self.scroll.saturating_sub(20); | ||
| 293 | return KeyAction::Continue; | ||
| 294 | } | ||
| 295 | _ => return KeyAction::Continue, | ||
| 296 | } | ||
| 297 | } | ||
| 298 | |||
| 299 | // Handle CommitList mode | ||
| 300 | if self.mode == ViewMode::CommitList { | ||
| 301 | match code { | ||
| 302 | KeyCode::Esc => { | ||
| 303 | self.event_history.clear(); | ||
| 304 | self.event_list_state = ListState::default(); | ||
| 305 | self.mode = ViewMode::Details; | ||
| 306 | self.scroll = 0; | ||
| 307 | return KeyAction::Continue; | ||
| 308 | } | ||
| 309 | KeyCode::Char('q') => return KeyAction::Quit, | ||
| 310 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { | ||
| 311 | return KeyAction::Quit; | ||
| 312 | } | ||
| 313 | KeyCode::Char('j') | KeyCode::Down => { | ||
| 314 | let len = self.event_history.len(); | ||
| 315 | if len > 0 { | ||
| 316 | let current = self.event_list_state.selected().unwrap_or(0); | ||
| 317 | let new = (current + 1).min(len - 1); | ||
| 318 | self.event_list_state.select(Some(new)); | ||
| 319 | } | ||
| 320 | return KeyAction::Continue; | ||
| 321 | } | ||
| 322 | KeyCode::Char('k') | KeyCode::Up => { | ||
| 323 | if !self.event_history.is_empty() { | ||
| 324 | let current = self.event_list_state.selected().unwrap_or(0); | ||
| 325 | let new = current.saturating_sub(1); | ||
| 326 | self.event_list_state.select(Some(new)); | ||
| 327 | } | ||
| 328 | return KeyAction::Continue; | ||
| 329 | } | ||
| 330 | KeyCode::Enter => { | ||
| 331 | if self.event_list_state.selected().is_some() { | ||
| 332 | self.mode = ViewMode::CommitDetail; | ||
| 333 | self.scroll = 0; | ||
| 334 | } | ||
| 335 | return KeyAction::Continue; | ||
| 336 | } | ||
| 337 | _ => return KeyAction::Continue, | ||
| 338 | } | ||
| 339 | } | ||
| 340 | |||
| 341 | // Normal Details/Diff mode handling | ||
| 342 | match code { | ||
| 343 | KeyCode::Char('q') | KeyCode::Esc => KeyAction::Quit, | ||
| 344 | KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => KeyAction::Quit, | ||
| 345 | KeyCode::Char('c') => { | ||
| 346 | // Open commit browser: only when in detail pane with an item selected | ||
| 347 | if self.pane == Pane::Detail && self.list_state.selected().is_some() { | ||
| 348 | KeyAction::OpenCommitBrowser | ||
| 349 | } else { | ||
| 350 | KeyAction::Continue | ||
| 351 | } | ||
| 352 | } | ||
| 353 | KeyCode::Char('1') => { | ||
| 354 | self.switch_tab(Tab::Issues); | ||
| 355 | KeyAction::Continue | ||
| 356 | } | ||
| 357 | KeyCode::Char('2') => { | ||
| 358 | self.switch_tab(Tab::Patches); | ||
| 359 | KeyAction::Continue | ||
| 360 | } | ||
| 361 | KeyCode::Char('j') | KeyCode::Down => { | ||
| 362 | if self.pane == Pane::ItemList { | ||
| 363 | self.move_selection(1); | ||
| 364 | } else { | ||
| 365 | self.scroll = self.scroll.saturating_add(1); | ||
| 366 | } | ||
| 367 | KeyAction::Continue | ||
| 368 | } | ||
| 369 | KeyCode::Char('k') | KeyCode::Up => { | ||
| 370 | if self.pane == Pane::ItemList { | ||
| 371 | self.move_selection(-1); | ||
| 372 | } else { | ||
| 373 | self.scroll = self.scroll.saturating_sub(1); | ||
| 374 | } | ||
| 375 | KeyAction::Continue | ||
| 376 | } | ||
| 377 | KeyCode::PageDown => { | ||
| 378 | self.scroll = self.scroll.saturating_add(20); | ||
| 379 | KeyAction::Continue | ||
| 380 | } | ||
| 381 | KeyCode::PageUp => { | ||
| 382 | self.scroll = self.scroll.saturating_sub(20); | ||
| 383 | KeyAction::Continue | ||
| 384 | } | ||
| 385 | KeyCode::Tab | KeyCode::Enter => { | ||
| 386 | self.pane = match self.pane { | ||
| 387 | Pane::ItemList => Pane::Detail, | ||
| 388 | Pane::Detail => Pane::ItemList, | ||
| 389 | }; | ||
| 390 | KeyAction::Continue | ||
| 391 | } | ||
| 392 | KeyCode::Char('d') => { | ||
| 393 | if self.tab == Tab::Patches { | ||
| 394 | match self.mode { | ||
| 395 | ViewMode::Details => { | ||
| 396 | self.mode = ViewMode::Diff; | ||
| 397 | self.scroll = 0; | ||
| 398 | } | ||
| 399 | ViewMode::Diff => { | ||
| 400 | self.mode = ViewMode::Details; | ||
| 401 | self.scroll = 0; | ||
| 402 | } | ||
| 403 | _ => {} | ||
| 404 | } | ||
| 405 | } | ||
| 406 | KeyAction::Continue | ||
| 407 | } | ||
| 408 | KeyCode::Char('a') => { | ||
| 409 | self.status_filter = self.status_filter.next(); | ||
| 410 | let count = self.visible_count(); | ||
| 411 | self.list_state | ||
| 412 | .select(if count > 0 { Some(0) } else { None }); | ||
| 413 | KeyAction::Continue | ||
| 414 | } | ||
| 415 | KeyCode::Char('r') => KeyAction::Reload, | ||
| 416 | _ => KeyAction::Continue, | ||
| 417 | } | ||
| 418 | } | ||
| 419 | |||
| 420 | fn selected_item_id(&self) -> Option<String> { | ||
| 421 | let idx = self.list_state.selected()?; | ||
| 422 | match self.tab { | ||
| 423 | Tab::Issues => { | ||
| 424 | let visible = self.visible_issues(); | ||
| 425 | visible.get(idx).map(|i| i.id.clone()) | ||
| 426 | } | ||
| 427 | Tab::Patches => { | ||
| 428 | let visible = self.visible_patches(); | ||
| 429 | visible.get(idx).map(|p| p.id.clone()) | ||
| 247 | } | 430 | } |
| 248 | } | 431 | } |
| 249 | } | 432 | } |
| 250 | 433 | ||
| 434 | fn selected_ref_name(&self) -> Option<String> { | ||
| 435 | let id = self.selected_item_id()?; | ||
| 436 | let prefix = match self.tab { | ||
| 437 | Tab::Issues => "refs/collab/issues", | ||
| 438 | Tab::Patches => "refs/collab/patches", | ||
| 439 | }; | ||
| 440 | Some(format!("{}/{}", prefix, id)) | ||
| 441 | } | ||
| 442 | |||
| 251 | fn reload(&mut self, repo: &Repository) { | 443 | fn reload(&mut self, repo: &Repository) { |
| 252 | if let Ok(issues) = state::list_issues(repo) { | 444 | if let Ok(issues) = state::list_issues(repo) { |
| 253 | self.issues = issues; | 445 | self.issues = issues; |
| @@ -268,6 +460,115 @@ impl App { | |||
| 268 | } | 460 | } |
| 269 | } | 461 | } |
| 270 | 462 | ||
| 463 | fn action_type_label(action: &Action) -> &str { | ||
| 464 | match action { | ||
| 465 | Action::IssueOpen { .. } => "Issue Open", | ||
| 466 | Action::IssueComment { .. } => "Issue Comment", | ||
| 467 | Action::IssueClose { .. } => "Issue Close", | ||
| 468 | Action::IssueReopen => "Issue Reopen", | ||
| 469 | Action::PatchCreate { .. } => "Patch Create", | ||
| 470 | Action::PatchRevise { .. } => "Patch Revise", | ||
| 471 | Action::PatchReview { .. } => "Patch Review", | ||
| 472 | Action::PatchComment { .. } => "Patch Comment", | ||
| 473 | Action::PatchInlineComment { .. } => "Inline Comment", | ||
| 474 | Action::PatchClose { .. } => "Patch Close", | ||
| 475 | Action::PatchMerge => "Patch Merge", | ||
| 476 | Action::Merge => "Merge", | ||
| 477 | Action::IssueEdit { .. } => "Issue Edit", | ||
| 478 | Action::IssueLabel { .. } => "Issue Label", | ||
| 479 | Action::IssueUnlabel { .. } => "Issue Unlabel", | ||
| 480 | Action::IssueAssign { .. } => "Issue Assign", | ||
| 481 | Action::IssueUnassign { .. } => "Issue Unassign", | ||
| 482 | } | ||
| 483 | } | ||
| 484 | |||
| 485 | fn format_event_detail(oid: &Oid, event: &crate::event::Event) -> String { | ||
| 486 | let short_oid = &oid.to_string()[..7]; | ||
| 487 | let action_label = action_type_label(&event.action); | ||
| 488 | |||
| 489 | let mut detail = format!( | ||
| 490 | "Commit: {}\nAuthor: {} <{}>\nDate: {}\nType: {}\n", | ||
| 491 | short_oid, event.author.name, event.author.email, event.timestamp, action_label, | ||
| 492 | ); | ||
| 493 | |||
| 494 | // Action-specific payload | ||
| 495 | match &event.action { | ||
| 496 | Action::IssueOpen { title, body } => { | ||
| 497 | detail.push_str(&format!("\nTitle: {}\n", title)); | ||
| 498 | if !body.is_empty() { | ||
| 499 | detail.push_str(&format!("\n{}\n", body)); | ||
| 500 | } | ||
| 501 | } | ||
| 502 | Action::IssueComment { body } | Action::PatchComment { body } => { | ||
| 503 | detail.push_str(&format!("\n{}\n", body)); | ||
| 504 | } | ||
| 505 | Action::IssueClose { reason } | Action::PatchClose { reason } => { | ||
| 506 | if let Some(r) = reason { | ||
| 507 | detail.push_str(&format!("\nReason: {}\n", r)); | ||
| 508 | } | ||
| 509 | } | ||
| 510 | Action::PatchCreate { | ||
| 511 | title, | ||
| 512 | body, | ||
| 513 | base_ref, | ||
| 514 | head_commit, | ||
| 515 | .. | ||
| 516 | } => { | ||
| 517 | detail.push_str(&format!("\nTitle: {}\n", title)); | ||
| 518 | detail.push_str(&format!("Base: {}\n", base_ref)); | ||
| 519 | detail.push_str(&format!("Head: {}\n", head_commit)); | ||
| 520 | if !body.is_empty() { | ||
| 521 | detail.push_str(&format!("\n{}\n", body)); | ||
| 522 | } | ||
| 523 | } | ||
| 524 | Action::PatchRevise { body, head_commit } => { | ||
| 525 | detail.push_str(&format!("\nHead: {}\n", head_commit)); | ||
| 526 | if let Some(b) = body { | ||
| 527 | if !b.is_empty() { | ||
| 528 | detail.push_str(&format!("\n{}\n", b)); | ||
| 529 | } | ||
| 530 | } | ||
| 531 | } | ||
| 532 | Action::PatchReview { verdict, body } => { | ||
| 533 | detail.push_str(&format!("\nVerdict: {:?}\n", verdict)); | ||
| 534 | if !body.is_empty() { | ||
| 535 | detail.push_str(&format!("\n{}\n", body)); | ||
| 536 | } | ||
| 537 | } | ||
| 538 | Action::PatchInlineComment { file, line, body } => { | ||
| 539 | detail.push_str(&format!("\nFile: {}:{}\n", file, line)); | ||
| 540 | if !body.is_empty() { | ||
| 541 | detail.push_str(&format!("\n{}\n", body)); | ||
| 542 | } | ||
| 543 | } | ||
| 544 | Action::IssueEdit { title, body } => { | ||
| 545 | if let Some(t) = title { | ||
| 546 | detail.push_str(&format!("\nNew Title: {}\n", t)); | ||
| 547 | } | ||
| 548 | if let Some(b) = body { | ||
| 549 | if !b.is_empty() { | ||
| 550 | detail.push_str(&format!("\nNew Body: {}\n", b)); | ||
| 551 | } | ||
| 552 | } | ||
| 553 | } | ||
| 554 | Action::IssueLabel { label } => { | ||
| 555 | detail.push_str(&format!("\nLabel: {}\n", label)); | ||
| 556 | } | ||
| 557 | Action::IssueUnlabel { label } => { | ||
| 558 | detail.push_str(&format!("\nRemoved Label: {}\n", label)); | ||
| 559 | } | ||
| 560 | Action::IssueAssign { assignee } => { | ||
| 561 | detail.push_str(&format!("\nAssignee: {}\n", assignee)); | ||
| 562 | } | ||
| 563 | Action::IssueUnassign { assignee } => { | ||
| 564 | detail.push_str(&format!("\nRemoved Assignee: {}\n", assignee)); | ||
| 565 | } | ||
| 566 | Action::IssueReopen | Action::PatchMerge | Action::Merge => {} | ||
| 567 | } | ||
| 568 | |||
| 569 | detail | ||
| 570 | } | ||
| 571 | |||
| 271 | pub fn run(repo: &Repository) -> Result<(), Error> { | 572 | pub fn run(repo: &Repository) -> Result<(), Error> { |
| 272 | let issues = state::list_issues(repo)?; | 573 | let issues = state::list_issues(repo)?; |
| 273 | let patches = state::list_patches(repo)?; | 574 | let patches = state::list_patches(repo)?; |
| @@ -423,123 +724,112 @@ fn run_loop( | |||
| 423 | InputMode::Normal => {} | 724 | InputMode::Normal => {} |
| 424 | } | 725 | } |
| 425 | 726 | ||
| 426 | match key.code { | 727 | // Handle keys that need repo access or are run_loop-specific |
| 427 | KeyCode::Char('q') | KeyCode::Esc => return Ok(()), | 728 | // before delegating to handle_key |
| 428 | KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { | 729 | if app.mode == ViewMode::Details || app.mode == ViewMode::Diff { |
| 429 | return Ok(()) | 730 | match key.code { |
| 430 | } | 731 | KeyCode::Char('/') => { |
| 431 | KeyCode::Char('/') => { | 732 | app.input_mode = InputMode::Search; |
| 432 | app.input_mode = InputMode::Search; | 733 | app.search_query.clear(); |
| 433 | app.search_query.clear(); | 734 | continue; |
| 434 | } | ||
| 435 | KeyCode::Char('n') => { | ||
| 436 | if app.tab != Tab::Issues { | ||
| 437 | app.switch_tab(Tab::Issues); | ||
| 438 | } | 735 | } |
| 439 | app.input_mode = InputMode::CreateTitle; | 736 | KeyCode::Char('n') => { |
| 440 | app.input_buf.clear(); | 737 | if app.tab != Tab::Issues { |
| 441 | app.create_title.clear(); | 738 | app.switch_tab(Tab::Issues); |
| 442 | } | 739 | } |
| 443 | KeyCode::Char('1') => app.switch_tab(Tab::Issues), | 740 | app.input_mode = InputMode::CreateTitle; |
| 444 | KeyCode::Char('2') => app.switch_tab(Tab::Patches), | 741 | app.input_buf.clear(); |
| 445 | KeyCode::Char('j') | KeyCode::Down => { | 742 | app.create_title.clear(); |
| 446 | if app.pane == Pane::ItemList { | 743 | continue; |
| 447 | app.move_selection(1); | ||
| 448 | } else { | ||
| 449 | app.scroll = app.scroll.saturating_add(1); | ||
| 450 | } | 744 | } |
| 451 | } | 745 | KeyCode::Char('g') => { |
| 452 | KeyCode::Char('k') | KeyCode::Up => { | 746 | if !app.follow_link() { |
| 453 | if app.pane == Pane::ItemList { | 747 | app.status_msg = Some("No linked item to follow".to_string()); |
| 454 | app.move_selection(-1); | 748 | } |
| 455 | } else { | 749 | continue; |
| 456 | app.scroll = app.scroll.saturating_sub(1); | ||
| 457 | } | 750 | } |
| 458 | } | 751 | KeyCode::Char('o') => { |
| 459 | KeyCode::PageDown => app.scroll = app.scroll.saturating_add(20), | 752 | // Check out the relevant commit for local browsing |
| 460 | KeyCode::PageUp => app.scroll = app.scroll.saturating_sub(20), | 753 | let checkout_target = match app.tab { |
| 461 | KeyCode::Tab | KeyCode::Enter => { | 754 | Tab::Patches => { |
| 462 | app.pane = match app.pane { | 755 | let visible = app.visible_patches(); |
| 463 | Pane::ItemList => Pane::Detail, | 756 | app.list_state |
| 464 | Pane::Detail => Pane::ItemList, | 757 | .selected() |
| 465 | }; | 758 | .and_then(|idx| visible.get(idx)) |
| 466 | } | 759 | .map(|p| p.head_commit.clone()) |
| 467 | KeyCode::Char('d') => { | 760 | } |
| 468 | if app.tab == Tab::Patches { | 761 | Tab::Issues => { |
| 469 | app.mode = match app.mode { | 762 | // Find linked patch's head commit, or fall back to closing commit |
| 470 | ViewMode::Details => ViewMode::Diff, | 763 | let visible = app.visible_issues(); |
| 471 | ViewMode::Diff => ViewMode::Details, | 764 | app.list_state |
| 765 | .selected() | ||
| 766 | .and_then(|idx| visible.get(idx)) | ||
| 767 | .and_then(|issue| { | ||
| 768 | // Try linked patch first | ||
| 769 | app.patches | ||
| 770 | .iter() | ||
| 771 | .find(|p| p.fixes.as_deref() == Some(&issue.id)) | ||
| 772 | .map(|p| p.head_commit.clone()) | ||
| 773 | // Fall back to closing commit | ||
| 774 | .or_else(|| { | ||
| 775 | issue.closed_by.map(|oid| oid.to_string()) | ||
| 776 | }) | ||
| 777 | }) | ||
| 778 | } | ||
| 472 | }; | 779 | }; |
| 473 | app.scroll = 0; | 780 | if let Some(head) = checkout_target { |
| 474 | } | 781 | // Exit TUI, checkout, and return |
| 475 | } | 782 | terminal::disable_raw_mode()?; |
| 476 | KeyCode::Char('a') => { | 783 | stdout().execute(LeaveAlternateScreen)?; |
| 477 | app.status_filter = app.status_filter.next(); | 784 | let status = std::process::Command::new("git") |
| 478 | let count = app.visible_count(); | 785 | .args(["checkout", &head]) |
| 479 | app.list_state | 786 | .status(); |
| 480 | .select(if count > 0 { Some(0) } else { None }); | 787 | match status { |
| 481 | } | 788 | Ok(s) if s.success() => { |
| 482 | KeyCode::Char('g') => { | 789 | println!("Checked out commit: {:.8}", head); |
| 483 | if !app.follow_link() { | 790 | println!("Use 'git checkout -' to return."); |
| 484 | app.status_msg = Some("No linked item to follow".to_string()); | 791 | } |
| 792 | Ok(s) => { | ||
| 793 | eprintln!("git checkout exited with {}", s); | ||
| 794 | } | ||
| 795 | Err(e) => { | ||
| 796 | eprintln!("Failed to run git checkout: {}", e); | ||
| 797 | } | ||
| 798 | } | ||
| 799 | return Ok(()); | ||
| 800 | } else { | ||
| 801 | app.status_msg = | ||
| 802 | Some("No linked patch to check out".to_string()); | ||
| 803 | } | ||
| 804 | continue; | ||
| 485 | } | 805 | } |
| 806 | _ => {} | ||
| 486 | } | 807 | } |
| 487 | KeyCode::Char('o') => { | 808 | } |
| 488 | // Check out the relevant commit for local browsing | 809 | |
| 489 | let checkout_target = match app.tab { | 810 | match app.handle_key(key.code, key.modifiers) { |
| 490 | Tab::Patches => { | 811 | KeyAction::Quit => return Ok(()), |
| 491 | let visible = app.visible_patches(); | 812 | KeyAction::Reload => app.reload(repo), |
| 492 | app.list_state | 813 | KeyAction::OpenCommitBrowser => { |
| 493 | .selected() | 814 | if let Some(ref_name) = app.selected_ref_name() { |
| 494 | .and_then(|idx| visible.get(idx)) | 815 | match crate::dag::walk_events(repo, &ref_name) { |
| 495 | .map(|p| p.head_commit.clone()) | 816 | Ok(events) => { |
| 496 | } | 817 | app.event_history = events; |
| 497 | Tab::Issues => { | 818 | app.event_list_state = ListState::default(); |
| 498 | // Find linked patch's head commit, or fall back to closing commit | 819 | if !app.event_history.is_empty() { |
| 499 | let visible = app.visible_issues(); | 820 | app.event_list_state.select(Some(0)); |
| 500 | app.list_state | 821 | } |
| 501 | .selected() | 822 | app.mode = ViewMode::CommitList; |
| 502 | .and_then(|idx| visible.get(idx)) | 823 | app.scroll = 0; |
| 503 | .and_then(|issue| { | ||
| 504 | // Try linked patch first | ||
| 505 | app.patches | ||
| 506 | .iter() | ||
| 507 | .find(|p| p.fixes.as_deref() == Some(&issue.id)) | ||
| 508 | .map(|p| p.head_commit.clone()) | ||
| 509 | // Fall back to closing commit | ||
| 510 | .or_else(|| issue.closed_by.map(|oid| oid.to_string())) | ||
| 511 | }) | ||
| 512 | } | ||
| 513 | }; | ||
| 514 | if let Some(head) = checkout_target { | ||
| 515 | // Exit TUI, checkout, and return | ||
| 516 | terminal::disable_raw_mode()?; | ||
| 517 | stdout().execute(LeaveAlternateScreen)?; | ||
| 518 | let status = std::process::Command::new("git") | ||
| 519 | .args(["checkout", &head]) | ||
| 520 | .status(); | ||
| 521 | match status { | ||
| 522 | Ok(s) if s.success() => { | ||
| 523 | println!("Checked out commit: {:.8}", head); | ||
| 524 | println!("Use 'git checkout -' to return."); | ||
| 525 | } | ||
| 526 | Ok(s) => { | ||
| 527 | eprintln!("git checkout exited with {}", s); | ||
| 528 | } | 824 | } |
| 529 | Err(e) => { | 825 | Err(e) => { |
| 530 | eprintln!("Failed to run git checkout: {}", e); | 826 | app.status_msg = |
| 827 | Some(format!("Error loading events: {}", e)); | ||
| 531 | } | 828 | } |
| 532 | } | 829 | } |
| 533 | return Ok(()); | ||
| 534 | } else { | ||
| 535 | app.status_msg = | ||
| 536 | Some("No linked patch to check out".to_string()); | ||
| 537 | } | 830 | } |
| 538 | } | 831 | } |
| 539 | KeyCode::Char('r') => { | 832 | KeyAction::Continue => {} |
| 540 | app.reload(repo); | ||
| 541 | } | ||
| 542 | _ => {} | ||
| 543 | } | 833 | } |
| 544 | } | 834 | } |
| 545 | } | 835 | } |
| @@ -669,17 +959,75 @@ fn render_list(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 669 | } | 959 | } |
| 670 | } | 960 | } |
| 671 | 961 | ||
| 672 | fn render_detail(frame: &mut Frame, app: &App, area: Rect) { | 962 | fn render_detail(frame: &mut Frame, app: &mut App, area: Rect) { |
| 673 | let border_style = if app.pane == Pane::Detail { | 963 | let border_style = if app.pane == Pane::Detail { |
| 674 | Style::default().fg(Color::Yellow) | 964 | Style::default().fg(Color::Yellow) |
| 675 | } else { | 965 | } else { |
| 676 | Style::default().fg(Color::DarkGray) | 966 | Style::default().fg(Color::DarkGray) |
| 677 | }; | 967 | }; |
| 678 | 968 | ||
| 969 | // Handle commit browser modes | ||
| 970 | if app.mode == ViewMode::CommitList { | ||
| 971 | let items: Vec<ListItem> = app | ||
| 972 | .event_history | ||
| 973 | .iter() | ||
| 974 | .map(|(_oid, evt)| { | ||
| 975 | let label = action_type_label(&evt.action); | ||
| 976 | ListItem::new(format!( | ||
| 977 | "{} | {} | {}", | ||
| 978 | label, evt.author.name, evt.timestamp | ||
| 979 | )) | ||
| 980 | }) | ||
| 981 | .collect(); | ||
| 982 | |||
| 983 | let list = List::new(items) | ||
| 984 | .block( | ||
| 985 | Block::default() | ||
| 986 | .borders(Borders::ALL) | ||
| 987 | .title("Event History") | ||
| 988 | .border_style(border_style), | ||
| 989 | ) | ||
| 990 | .highlight_style( | ||
| 991 | Style::default() | ||
| 992 | .bg(Color::DarkGray) | ||
| 993 | .add_modifier(Modifier::BOLD), | ||
| 994 | ) | ||
| 995 | .highlight_symbol("> "); | ||
| 996 | |||
| 997 | frame.render_stateful_widget(list, area, &mut app.event_list_state); | ||
| 998 | return; | ||
| 999 | } | ||
| 1000 | |||
| 1001 | if app.mode == ViewMode::CommitDetail { | ||
| 1002 | let content = if let Some(idx) = app.event_list_state.selected() { | ||
| 1003 | if let Some((oid, evt)) = app.event_history.get(idx) { | ||
| 1004 | format_event_detail(oid, evt) | ||
| 1005 | } else { | ||
| 1006 | "No event selected.".to_string() | ||
| 1007 | } | ||
| 1008 | } else { | ||
| 1009 | "No event selected.".to_string() | ||
| 1010 | }; | ||
| 1011 | |||
| 1012 | let block = Block::default() | ||
| 1013 | .borders(Borders::ALL) | ||
| 1014 | .title("Event Detail") | ||
| 1015 | .border_style(border_style); | ||
| 1016 | |||
| 1017 | let para = Paragraph::new(content) | ||
| 1018 | .block(block) | ||
| 1019 | .wrap(Wrap { trim: false }) | ||
| 1020 | .scroll((app.scroll, 0)); | ||
| 1021 | |||
| 1022 | frame.render_widget(para, area); | ||
| 1023 | return; | ||
| 1024 | } | ||
| 1025 | |||
| 679 | let title = match (&app.tab, &app.mode) { | 1026 | let title = match (&app.tab, &app.mode) { |
| 680 | (Tab::Issues, _) => "Issue Details", | 1027 | (Tab::Issues, _) => "Issue Details", |
| 681 | (Tab::Patches, ViewMode::Details) => "Patch Details", | 1028 | (Tab::Patches, ViewMode::Details) => "Patch Details", |
| 682 | (Tab::Patches, ViewMode::Diff) => "Diff", | 1029 | (Tab::Patches, ViewMode::Diff) => "Diff", |
| 1030 | _ => "Details", | ||
| 683 | }; | 1031 | }; |
| 684 | 1032 | ||
| 685 | let content: Text = match app.tab { | 1033 | let content: Text = match app.tab { |
| @@ -705,6 +1053,7 @@ fn render_detail(frame: &mut Frame, app: &App, area: Rect) { | |||
| 705 | .unwrap_or("Loading..."); | 1053 | .unwrap_or("Loading..."); |
| 706 | colorize_diff(diff_text, &patch.inline_comments) | 1054 | colorize_diff(diff_text, &patch.inline_comments) |
| 707 | } | 1055 | } |
| 1056 | _ => Text::raw(""), | ||
| 708 | }, | 1057 | }, |
| 709 | None => Text::raw("No matches for current filter."), | 1058 | None => Text::raw("No matches for current filter."), |
| 710 | } | 1059 | } |
| @@ -1166,33 +1515,39 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { | |||
| 1166 | InputMode::Normal => {} | 1515 | InputMode::Normal => {} |
| 1167 | } | 1516 | } |
| 1168 | 1517 | ||
| 1169 | let mode_hint = if app.tab == Tab::Patches { | 1518 | // Show status message if present |
| 1170 | match app.mode { | 1519 | if let Some(ref msg) = app.status_msg { |
| 1171 | ViewMode::Details => " d:diff", | 1520 | let para = |
| 1172 | ViewMode::Diff => " d:details", | 1521 | Paragraph::new(format!(" {}", msg)).style(Style::default().bg(Color::Yellow).fg(Color::Black)); |
| 1522 | frame.render_widget(para, area); | ||
| 1523 | return; | ||
| 1524 | } | ||
| 1525 | |||
| 1526 | let mode_hint = match app.mode { | ||
| 1527 | ViewMode::CommitList => " Esc:back", | ||
| 1528 | ViewMode::CommitDetail => " Esc:back j/k:scroll", | ||
| 1529 | _ => { | ||
| 1530 | if app.tab == Tab::Patches { | ||
| 1531 | match app.mode { | ||
| 1532 | ViewMode::Details => " d:diff c:events", | ||
| 1533 | ViewMode::Diff => " d:details c:events", | ||
| 1534 | _ => "", | ||
| 1535 | } | ||
| 1536 | } else { | ||
| 1537 | " c:events" | ||
| 1538 | } | ||
| 1173 | } | 1539 | } |
| 1174 | } else { | ||
| 1175 | "" | ||
| 1176 | }; | 1540 | }; |
| 1177 | let filter_hint = match app.status_filter { | 1541 | let filter_hint = match app.status_filter { |
| 1178 | StatusFilter::Open => "a:show all", | 1542 | StatusFilter::Open => "a:show all", |
| 1179 | StatusFilter::All => "a:closed", | 1543 | StatusFilter::All => "a:closed", |
| 1180 | StatusFilter::Closed => "a:open only", | 1544 | StatusFilter::Closed => "a:open only", |
| 1181 | }; | 1545 | }; |
| 1182 | let text = if let Some(ref msg) = app.status_msg { | 1546 | let text = format!( |
| 1183 | format!(" {}", msg) | 1547 | " 1:issues 2:patches j/k:navigate Tab:pane {}{} /:search n:new issue g:follow o:checkout r:refresh q:quit", |
| 1184 | } else { | 1548 | filter_hint, mode_hint |
| 1185 | format!( | 1549 | ); |
| 1186 | " 1:issues 2:patches j/k:navigate Tab:pane {}{} /:search n:new issue g:follow o:checkout r:refresh q:quit", | 1550 | let para = Paragraph::new(text).style(Style::default().bg(Color::DarkGray).fg(Color::White)); |
| 1187 | filter_hint, mode_hint | ||
| 1188 | ) | ||
| 1189 | }; | ||
| 1190 | let style = if app.status_msg.is_some() { | ||
| 1191 | Style::default().bg(Color::Yellow).fg(Color::Black) | ||
| 1192 | } else { | ||
| 1193 | Style::default().bg(Color::DarkGray).fg(Color::White) | ||
| 1194 | }; | ||
| 1195 | let para = Paragraph::new(text).style(style); | ||
| 1196 | frame.render_widget(para, area); | 1551 | frame.render_widget(para, area); |
| 1197 | } | 1552 | } |
| 1198 | 1553 | ||
| @@ -1396,4 +1751,734 @@ mod tests { | |||
| 1396 | 1751 | ||
| 1397 | assert_eq!(app.input_mode, InputMode::Normal); | 1752 | assert_eq!(app.input_mode, InputMode::Normal); |
| 1398 | } | 1753 | } |
| 1754 | |||
| 1755 | // ── Commit browser test helpers ───────────────────────────────────── | ||
| 1756 | |||
| 1757 | use crate::event::ReviewVerdict; | ||
| 1758 | use ratatui::backend::TestBackend; | ||
| 1759 | use ratatui::buffer::Buffer; | ||
| 1760 | |||
| 1761 | fn test_author() -> Author { | ||
| 1762 | Author { | ||
| 1763 | name: "Test User".to_string(), | ||
| 1764 | email: "test@example.com".to_string(), | ||
| 1765 | } | ||
| 1766 | } | ||
| 1767 | |||
| 1768 | fn make_test_issues(n: usize) -> Vec<IssueState> { | ||
| 1769 | (0..n) | ||
| 1770 | .map(|i| IssueState { | ||
| 1771 | id: format!("{:08x}", i), | ||
| 1772 | title: format!("Issue {}", i), | ||
| 1773 | body: format!("Body for issue {}", i), | ||
| 1774 | status: if i % 2 == 0 { | ||
| 1775 | IssueStatus::Open | ||
| 1776 | } else { | ||
| 1777 | IssueStatus::Closed | ||
| 1778 | }, | ||
| 1779 | close_reason: if i % 2 == 1 { | ||
| 1780 | Some("done".to_string()) | ||
| 1781 | } else { | ||
| 1782 | None | ||
| 1783 | }, | ||
| 1784 | closed_by: None, | ||
| 1785 | labels: vec![], | ||
| 1786 | assignees: vec![], | ||
| 1787 | comments: Vec::new(), | ||
| 1788 | created_at: "2026-01-01T00:00:00Z".to_string(), | ||
| 1789 | author: test_author(), | ||
| 1790 | }) | ||
| 1791 | .collect() | ||
| 1792 | } | ||
| 1793 | |||
| 1794 | fn make_test_patches(n: usize) -> Vec<PatchState> { | ||
| 1795 | (0..n) | ||
| 1796 | .map(|i| PatchState { | ||
| 1797 | id: format!("p{:07x}", i), | ||
| 1798 | title: format!("Patch {}", i), | ||
| 1799 | body: format!("Body for patch {}", i), | ||
| 1800 | status: if i % 2 == 0 { | ||
| 1801 | PatchStatus::Open | ||
| 1802 | } else { | ||
| 1803 | PatchStatus::Closed | ||
| 1804 | }, | ||
| 1805 | base_ref: "main".to_string(), | ||
| 1806 | head_commit: format!("h{:07x}", i), | ||
| 1807 | fixes: None, | ||
| 1808 | comments: Vec::new(), | ||
| 1809 | inline_comments: Vec::new(), | ||
| 1810 | reviews: Vec::new(), | ||
| 1811 | created_at: "2026-01-01T00:00:00Z".to_string(), | ||
| 1812 | author: test_author(), | ||
| 1813 | }) | ||
| 1814 | .collect() | ||
| 1815 | } | ||
| 1816 | |||
| 1817 | fn make_app(issues: usize, patches: usize) -> App { | ||
| 1818 | App::new(make_test_issues(issues), make_test_patches(patches)) | ||
| 1819 | } | ||
| 1820 | |||
| 1821 | fn render_app(app: &mut App) -> Buffer { | ||
| 1822 | let backend = TestBackend::new(80, 24); | ||
| 1823 | let mut terminal = Terminal::new(backend).unwrap(); | ||
| 1824 | terminal.draw(|frame| ui(frame, app)).unwrap(); | ||
| 1825 | terminal.backend().buffer().clone() | ||
| 1826 | } | ||
| 1827 | |||
| 1828 | fn buffer_to_string(buf: &Buffer) -> String { | ||
| 1829 | let area = buf.area; | ||
| 1830 | let mut s = String::new(); | ||
| 1831 | for y in area.y..area.y + area.height { | ||
| 1832 | for x in area.x..area.x + area.width { | ||
| 1833 | let cell = buf.cell((x, y)).unwrap(); | ||
| 1834 | s.push_str(cell.symbol()); | ||
| 1835 | } | ||
| 1836 | s.push('\n'); | ||
| 1837 | } | ||
| 1838 | s | ||
| 1839 | } | ||
| 1840 | |||
| 1841 | fn assert_buffer_contains(buf: &Buffer, expected: &str) { | ||
| 1842 | let text = buffer_to_string(buf); | ||
| 1843 | assert!( | ||
| 1844 | text.contains(expected), | ||
| 1845 | "expected buffer to contain {:?}, but it was not found in:\n{}", | ||
| 1846 | expected, | ||
| 1847 | text | ||
| 1848 | ); | ||
| 1849 | } | ||
| 1850 | |||
| 1851 | /// Create sample event history for testing commit browser | ||
| 1852 | fn make_test_event_history() -> Vec<(Oid, crate::event::Event)> { | ||
| 1853 | let oid1 = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(); | ||
| 1854 | let oid2 = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(); | ||
| 1855 | let oid3 = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap(); | ||
| 1856 | |||
| 1857 | vec![ | ||
| 1858 | ( | ||
| 1859 | oid1, | ||
| 1860 | crate::event::Event { | ||
| 1861 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 1862 | author: test_author(), | ||
| 1863 | action: Action::IssueOpen { | ||
| 1864 | title: "Test Issue".to_string(), | ||
| 1865 | body: "This is the body".to_string(), | ||
| 1866 | }, | ||
| 1867 | }, | ||
| 1868 | ), | ||
| 1869 | ( | ||
| 1870 | oid2, | ||
| 1871 | crate::event::Event { | ||
| 1872 | timestamp: "2026-01-02T00:00:00Z".to_string(), | ||
| 1873 | author: Author { | ||
| 1874 | name: "Other User".to_string(), | ||
| 1875 | email: "other@example.com".to_string(), | ||
| 1876 | }, | ||
| 1877 | action: Action::IssueComment { | ||
| 1878 | body: "A comment on the issue".to_string(), | ||
| 1879 | }, | ||
| 1880 | }, | ||
| 1881 | ), | ||
| 1882 | ( | ||
| 1883 | oid3, | ||
| 1884 | crate::event::Event { | ||
| 1885 | timestamp: "2026-01-03T00:00:00Z".to_string(), | ||
| 1886 | author: test_author(), | ||
| 1887 | action: Action::IssueClose { | ||
| 1888 | reason: Some("fixed".to_string()), | ||
| 1889 | }, | ||
| 1890 | }, | ||
| 1891 | ), | ||
| 1892 | ] | ||
| 1893 | } | ||
| 1894 | |||
| 1895 | // ── action_type_label tests ────────────────────────────────────────── | ||
| 1896 | |||
| 1897 | #[test] | ||
| 1898 | fn test_action_type_label_issue_open() { | ||
| 1899 | let action = Action::IssueOpen { | ||
| 1900 | title: "t".to_string(), | ||
| 1901 | body: "b".to_string(), | ||
| 1902 | }; | ||
| 1903 | assert_eq!(action_type_label(&action), "Issue Open"); | ||
| 1904 | } | ||
| 1905 | |||
| 1906 | #[test] | ||
| 1907 | fn test_action_type_label_issue_comment() { | ||
| 1908 | let action = Action::IssueComment { | ||
| 1909 | body: "b".to_string(), | ||
| 1910 | }; | ||
| 1911 | assert_eq!(action_type_label(&action), "Issue Comment"); | ||
| 1912 | } | ||
| 1913 | |||
| 1914 | #[test] | ||
| 1915 | fn test_action_type_label_issue_close() { | ||
| 1916 | let action = Action::IssueClose { reason: None }; | ||
| 1917 | assert_eq!(action_type_label(&action), "Issue Close"); | ||
| 1918 | } | ||
| 1919 | |||
| 1920 | #[test] | ||
| 1921 | fn test_action_type_label_issue_reopen() { | ||
| 1922 | assert_eq!(action_type_label(&Action::IssueReopen), "Issue Reopen"); | ||
| 1923 | } | ||
| 1924 | |||
| 1925 | #[test] | ||
| 1926 | fn test_action_type_label_patch_create() { | ||
| 1927 | let action = Action::PatchCreate { | ||
| 1928 | title: "t".to_string(), | ||
| 1929 | body: "b".to_string(), | ||
| 1930 | base_ref: "main".to_string(), | ||
| 1931 | head_commit: "abc".to_string(), | ||
| 1932 | fixes: None, | ||
| 1933 | }; | ||
| 1934 | assert_eq!(action_type_label(&action), "Patch Create"); | ||
| 1935 | } | ||
| 1936 | |||
| 1937 | #[test] | ||
| 1938 | fn test_action_type_label_patch_review() { | ||
| 1939 | let action = Action::PatchReview { | ||
| 1940 | verdict: ReviewVerdict::Approve, | ||
| 1941 | body: "lgtm".to_string(), | ||
| 1942 | }; | ||
| 1943 | assert_eq!(action_type_label(&action), "Patch Review"); | ||
| 1944 | } | ||
| 1945 | |||
| 1946 | #[test] | ||
| 1947 | fn test_action_type_label_inline_comment() { | ||
| 1948 | let action = Action::PatchInlineComment { | ||
| 1949 | file: "src/main.rs".to_string(), | ||
| 1950 | line: 42, | ||
| 1951 | body: "nit".to_string(), | ||
| 1952 | }; | ||
| 1953 | assert_eq!(action_type_label(&action), "Inline Comment"); | ||
| 1954 | } | ||
| 1955 | |||
| 1956 | #[test] | ||
| 1957 | fn test_action_type_label_merge() { | ||
| 1958 | assert_eq!(action_type_label(&Action::Merge), "Merge"); | ||
| 1959 | } | ||
| 1960 | |||
| 1961 | // ── format_event_detail tests ──────────────────────────────────────── | ||
| 1962 | |||
| 1963 | #[test] | ||
| 1964 | fn test_format_event_detail_issue_open() { | ||
| 1965 | let oid = Oid::from_str("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap(); | ||
| 1966 | let event = crate::event::Event { | ||
| 1967 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 1968 | author: test_author(), | ||
| 1969 | action: Action::IssueOpen { | ||
| 1970 | title: "My Issue".to_string(), | ||
| 1971 | body: "Description here".to_string(), | ||
| 1972 | }, | ||
| 1973 | }; | ||
| 1974 | let detail = format_event_detail(&oid, &event); | ||
| 1975 | assert!(detail.contains("aaaaaaa")); | ||
| 1976 | assert!(detail.contains("Test User <test@example.com>")); | ||
| 1977 | assert!(detail.contains("2026-01-01T00:00:00Z")); | ||
| 1978 | assert!(detail.contains("Issue Open")); | ||
| 1979 | assert!(detail.contains("Title: My Issue")); | ||
| 1980 | assert!(detail.contains("Description here")); | ||
| 1981 | } | ||
| 1982 | |||
| 1983 | #[test] | ||
| 1984 | fn test_format_event_detail_issue_close_with_reason() { | ||
| 1985 | let oid = Oid::from_str("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb").unwrap(); | ||
| 1986 | let event = crate::event::Event { | ||
| 1987 | timestamp: "2026-02-01T00:00:00Z".to_string(), | ||
| 1988 | author: test_author(), | ||
| 1989 | action: Action::IssueClose { | ||
| 1990 | reason: Some("resolved".to_string()), | ||
| 1991 | }, | ||
| 1992 | }; | ||
| 1993 | let detail = format_event_detail(&oid, &event); | ||
| 1994 | assert!(detail.contains("Issue Close")); | ||
| 1995 | assert!(detail.contains("Reason: resolved")); | ||
| 1996 | } | ||
| 1997 | |||
| 1998 | #[test] | ||
| 1999 | fn test_format_event_detail_patch_review() { | ||
| 2000 | let oid = Oid::from_str("cccccccccccccccccccccccccccccccccccccccc").unwrap(); | ||
| 2001 | let event = crate::event::Event { | ||
| 2002 | timestamp: "2026-03-01T00:00:00Z".to_string(), | ||
| 2003 | author: test_author(), | ||
| 2004 | action: Action::PatchReview { | ||
| 2005 | verdict: ReviewVerdict::Approve, | ||
| 2006 | body: "Looks good!".to_string(), | ||
| 2007 | }, | ||
| 2008 | }; | ||
| 2009 | let detail = format_event_detail(&oid, &event); | ||
| 2010 | assert!(detail.contains("Patch Review")); | ||
| 2011 | assert!(detail.contains("Approve")); | ||
| 2012 | assert!(detail.contains("Looks good!")); | ||
| 2013 | } | ||
| 2014 | |||
| 2015 | #[test] | ||
| 2016 | fn test_format_event_detail_short_oid() { | ||
| 2017 | let oid = Oid::from_str("1234567890abcdef1234567890abcdef12345678").unwrap(); | ||
| 2018 | let event = crate::event::Event { | ||
| 2019 | timestamp: "2026-01-01T00:00:00Z".to_string(), | ||
| 2020 | author: test_author(), | ||
| 2021 | action: Action::IssueReopen, | ||
| 2022 | }; | ||
| 2023 | let detail = format_event_detail(&oid, &event); | ||
| 2024 | assert!(detail.contains("1234567")); | ||
| 2025 | assert!(detail.contains("Commit: 1234567\n")); | ||
| 2026 | } | ||
| 2027 | |||
| 2028 | // ── handle_key tests for 'c' key ───────────────────────────────────── | ||
| 2029 | |||
| 2030 | #[test] | ||
| 2031 | fn test_handle_key_c_in_detail_pane_returns_open_commit_browser() { | ||
| 2032 | let mut app = make_app(3, 0); | ||
| 2033 | app.pane = Pane::Detail; | ||
| 2034 | app.list_state.select(Some(0)); | ||
| 2035 | let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty()); | ||
| 2036 | assert_eq!(result, KeyAction::OpenCommitBrowser); | ||
| 2037 | } | ||
| 2038 | |||
| 2039 | #[test] | ||
| 2040 | fn test_handle_key_c_in_item_list_pane_is_noop() { | ||
| 2041 | let mut app = make_app(3, 0); | ||
| 2042 | app.pane = Pane::ItemList; | ||
| 2043 | app.list_state.select(Some(0)); | ||
| 2044 | let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty()); | ||
| 2045 | assert_eq!(result, KeyAction::Continue); | ||
| 2046 | } | ||
| 2047 | |||
| 2048 | #[test] | ||
| 2049 | fn test_handle_key_c_no_selection_is_noop() { | ||
| 2050 | let mut app = make_app(0, 0); | ||
| 2051 | app.pane = Pane::Detail; | ||
| 2052 | let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty()); | ||
| 2053 | assert_eq!(result, KeyAction::Continue); | ||
| 2054 | } | ||
| 2055 | |||
| 2056 | #[test] | ||
| 2057 | fn test_handle_key_ctrl_c_still_quits() { | ||
| 2058 | let mut app = make_app(3, 0); | ||
| 2059 | let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::CONTROL); | ||
| 2060 | assert_eq!(result, KeyAction::Quit); | ||
| 2061 | } | ||
| 2062 | |||
| 2063 | // ── CommitList navigation tests ────────────────────────────────────── | ||
| 2064 | |||
| 2065 | #[test] | ||
| 2066 | fn test_commit_list_navigate_down() { | ||
| 2067 | let mut app = make_app(3, 0); | ||
| 2068 | app.event_history = make_test_event_history(); | ||
| 2069 | app.event_list_state.select(Some(0)); | ||
| 2070 | app.mode = ViewMode::CommitList; | ||
| 2071 | |||
| 2072 | app.handle_key(KeyCode::Char('j'), KeyModifiers::empty()); | ||
| 2073 | assert_eq!(app.event_list_state.selected(), Some(1)); | ||
| 2074 | } | ||
| 2075 | |||
| 2076 | #[test] | ||
| 2077 | fn test_commit_list_navigate_up() { | ||
| 2078 | let mut app = make_app(3, 0); | ||
| 2079 | app.event_history = make_test_event_history(); | ||
| 2080 | app.event_list_state.select(Some(2)); | ||
| 2081 | app.mode = ViewMode::CommitList; | ||
| 2082 | |||
| 2083 | app.handle_key(KeyCode::Char('k'), KeyModifiers::empty()); | ||
| 2084 | assert_eq!(app.event_list_state.selected(), Some(1)); | ||
| 2085 | } | ||
| 2086 | |||
| 2087 | #[test] | ||
| 2088 | fn test_commit_list_navigate_clamp_bottom() { | ||
| 2089 | let mut app = make_app(3, 0); | ||
| 2090 | app.event_history = make_test_event_history(); | ||
| 2091 | app.event_list_state.select(Some(2)); | ||
| 2092 | app.mode = ViewMode::CommitList; | ||
| 2093 | |||
| 2094 | app.handle_key(KeyCode::Down, KeyModifiers::empty()); | ||
| 2095 | assert_eq!(app.event_list_state.selected(), Some(2)); | ||
| 2096 | } | ||
| 2097 | |||
| 2098 | #[test] | ||
| 2099 | fn test_commit_list_navigate_clamp_top() { | ||
| 2100 | let mut app = make_app(3, 0); | ||
| 2101 | app.event_history = make_test_event_history(); | ||
| 2102 | app.event_list_state.select(Some(0)); | ||
| 2103 | app.mode = ViewMode::CommitList; | ||
| 2104 | |||
| 2105 | app.handle_key(KeyCode::Up, KeyModifiers::empty()); | ||
| 2106 | assert_eq!(app.event_list_state.selected(), Some(0)); | ||
| 2107 | } | ||
| 2108 | |||
| 2109 | #[test] | ||
| 2110 | fn test_commit_list_escape_returns_to_details() { | ||
| 2111 | let mut app = make_app(3, 0); | ||
| 2112 | app.event_history = make_test_event_history(); | ||
| 2113 | app.event_list_state.select(Some(1)); | ||
| 2114 | app.mode = ViewMode::CommitList; | ||
| 2115 | |||
| 2116 | let result = app.handle_key(KeyCode::Esc, KeyModifiers::empty()); | ||
| 2117 | assert_eq!(result, KeyAction::Continue); | ||
| 2118 | assert_eq!(app.mode, ViewMode::Details); | ||
| 2119 | assert!(app.event_history.is_empty()); | ||
| 2120 | assert_eq!(app.event_list_state.selected(), None); | ||
| 2121 | } | ||
| 2122 | |||
| 2123 | #[test] | ||
| 2124 | fn test_commit_list_q_quits() { | ||
| 2125 | let mut app = make_app(3, 0); | ||
| 2126 | app.mode = ViewMode::CommitList; | ||
| 2127 | let result = app.handle_key(KeyCode::Char('q'), KeyModifiers::empty()); | ||
| 2128 | assert_eq!(result, KeyAction::Quit); | ||
| 2129 | } | ||
| 2130 | |||
| 2131 | // ── CommitDetail tests ─────────────────────────────────────────────── | ||
| 2132 | |||
| 2133 | #[test] | ||
| 2134 | fn test_commit_list_enter_opens_detail() { | ||
| 2135 | let mut app = make_app(3, 0); | ||
| 2136 | app.event_history = make_test_event_history(); | ||
| 2137 | app.event_list_state.select(Some(1)); | ||
| 2138 | app.mode = ViewMode::CommitList; | ||
| 2139 | |||
| 2140 | let result = app.handle_key(KeyCode::Enter, KeyModifiers::empty()); | ||
| 2141 | assert_eq!(result, KeyAction::Continue); | ||
| 2142 | assert_eq!(app.mode, ViewMode::CommitDetail); | ||
| 2143 | assert_eq!(app.scroll, 0); | ||
| 2144 | } | ||
| 2145 | |||
| 2146 | #[test] | ||
| 2147 | fn test_commit_list_enter_no_selection_stays() { | ||
| 2148 | let mut app = make_app(3, 0); | ||
| 2149 | app.event_history = make_test_event_history(); | ||
| 2150 | app.event_list_state = ListState::default(); | ||
| 2151 | app.mode = ViewMode::CommitList; | ||
| 2152 | |||
| 2153 | app.handle_key(KeyCode::Enter, KeyModifiers::empty()); | ||
| 2154 | assert_eq!(app.mode, ViewMode::CommitList); | ||
| 2155 | } | ||
| 2156 | |||
| 2157 | #[test] | ||
| 2158 | fn test_commit_detail_escape_returns_to_list() { | ||
| 2159 | let mut app = make_app(3, 0); | ||
| 2160 | app.event_history = make_test_event_history(); | ||
| 2161 | app.event_list_state.select(Some(0)); | ||
| 2162 | app.mode = ViewMode::CommitDetail; | ||
| 2163 | app.scroll = 5; | ||
| 2164 | |||
| 2165 | let result = app.handle_key(KeyCode::Esc, KeyModifiers::empty()); | ||
| 2166 | assert_eq!(result, KeyAction::Continue); | ||
| 2167 | assert_eq!(app.mode, ViewMode::CommitList); | ||
| 2168 | assert_eq!(app.scroll, 0); | ||
| 2169 | assert_eq!(app.event_history.len(), 3); | ||
| 2170 | } | ||
| 2171 | |||
| 2172 | #[test] | ||
| 2173 | fn test_commit_detail_scroll() { | ||
| 2174 | let mut app = make_app(3, 0); | ||
| 2175 | app.mode = ViewMode::CommitDetail; | ||
| 2176 | app.scroll = 0; | ||
| 2177 | |||
| 2178 | app.handle_key(KeyCode::Char('j'), KeyModifiers::empty()); | ||
| 2179 | assert_eq!(app.scroll, 1); | ||
| 2180 | app.handle_key(KeyCode::Char('j'), KeyModifiers::empty()); | ||
| 2181 | assert_eq!(app.scroll, 2); | ||
| 2182 | app.handle_key(KeyCode::Char('k'), KeyModifiers::empty()); | ||
| 2183 | assert_eq!(app.scroll, 1); | ||
| 2184 | } | ||
| 2185 | |||
| 2186 | #[test] | ||
| 2187 | fn test_commit_detail_page_scroll() { | ||
| 2188 | let mut app = make_app(3, 0); | ||
| 2189 | app.mode = ViewMode::CommitDetail; | ||
| 2190 | app.scroll = 0; | ||
| 2191 | |||
| 2192 | app.handle_key(KeyCode::PageDown, KeyModifiers::empty()); | ||
| 2193 | assert_eq!(app.scroll, 20); | ||
| 2194 | app.handle_key(KeyCode::PageUp, KeyModifiers::empty()); | ||
| 2195 | assert_eq!(app.scroll, 0); | ||
| 2196 | } | ||
| 2197 | |||
| 2198 | #[test] | ||
| 2199 | fn test_commit_detail_q_quits() { | ||
| 2200 | let mut app = make_app(3, 0); | ||
| 2201 | app.mode = ViewMode::CommitDetail; | ||
| 2202 | let result = app.handle_key(KeyCode::Char('q'), KeyModifiers::empty()); | ||
| 2203 | assert_eq!(result, KeyAction::Quit); | ||
| 2204 | } | ||
| 2205 | |||
| 2206 | // ── Guard tests ────────────────────────────────────────────────────── | ||
| 2207 | |||
| 2208 | #[test] | ||
| 2209 | fn test_c_ignored_in_commit_list_mode() { | ||
| 2210 | let mut app = make_app(3, 0); | ||
| 2211 | app.mode = ViewMode::CommitList; | ||
| 2212 | let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty()); | ||
| 2213 | assert_eq!(result, KeyAction::Continue); | ||
| 2214 | assert_eq!(app.mode, ViewMode::CommitList); | ||
| 2215 | } | ||
| 2216 | |||
| 2217 | #[test] | ||
| 2218 | fn test_c_ignored_in_commit_detail_mode() { | ||
| 2219 | let mut app = make_app(3, 0); | ||
| 2220 | app.mode = ViewMode::CommitDetail; | ||
| 2221 | let result = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty()); | ||
| 2222 | assert_eq!(result, KeyAction::Continue); | ||
| 2223 | assert_eq!(app.mode, ViewMode::CommitDetail); | ||
| 2224 | } | ||
| 2225 | |||
| 2226 | // ── handle_key basic tests ─────────────────────────────────────────── | ||
| 2227 | |||
| 2228 | #[test] | ||
| 2229 | fn test_handle_key_quit() { | ||
| 2230 | let mut app = make_app(3, 3); | ||
| 2231 | assert_eq!( | ||
| 2232 | app.handle_key(KeyCode::Char('q'), KeyModifiers::empty()), | ||
| 2233 | KeyAction::Quit | ||
| 2234 | ); | ||
| 2235 | } | ||
| 2236 | |||
| 2237 | #[test] | ||
| 2238 | fn test_handle_key_quit_esc() { | ||
| 2239 | let mut app = make_app(3, 3); | ||
| 2240 | assert_eq!( | ||
| 2241 | app.handle_key(KeyCode::Esc, KeyModifiers::empty()), | ||
| 2242 | KeyAction::Quit | ||
| 2243 | ); | ||
| 2244 | } | ||
| 2245 | |||
| 2246 | #[test] | ||
| 2247 | fn test_handle_key_quit_ctrl_c() { | ||
| 2248 | let mut app = make_app(3, 3); | ||
| 2249 | assert_eq!( | ||
| 2250 | app.handle_key(KeyCode::Char('c'), KeyModifiers::CONTROL), | ||
| 2251 | KeyAction::Quit | ||
| 2252 | ); | ||
| 2253 | } | ||
| 2254 | |||
| 2255 | #[test] | ||
| 2256 | fn test_handle_key_tab_switch() { | ||
| 2257 | let mut app = make_app(3, 3); | ||
| 2258 | app.handle_key(KeyCode::Char('2'), KeyModifiers::empty()); | ||
| 2259 | assert_eq!(app.tab, Tab::Patches); | ||
| 2260 | app.handle_key(KeyCode::Char('1'), KeyModifiers::empty()); | ||
| 2261 | assert_eq!(app.tab, Tab::Issues); | ||
| 2262 | } | ||
| 2263 | |||
| 2264 | #[test] | ||
| 2265 | fn test_handle_key_diff_toggle_patches() { | ||
| 2266 | let mut app = make_app(0, 3); | ||
| 2267 | app.switch_tab(Tab::Patches); | ||
| 2268 | assert_eq!(app.mode, ViewMode::Details); | ||
| 2269 | app.handle_key(KeyCode::Char('d'), KeyModifiers::empty()); | ||
| 2270 | assert_eq!(app.mode, ViewMode::Diff); | ||
| 2271 | app.handle_key(KeyCode::Char('d'), KeyModifiers::empty()); | ||
| 2272 | assert_eq!(app.mode, ViewMode::Details); | ||
| 2273 | } | ||
| 2274 | |||
| 2275 | #[test] | ||
| 2276 | fn test_handle_key_diff_noop_issues() { | ||
| 2277 | let mut app = make_app(3, 0); | ||
| 2278 | assert_eq!(app.tab, Tab::Issues); | ||
| 2279 | assert_eq!(app.mode, ViewMode::Details); | ||
| 2280 | app.handle_key(KeyCode::Char('d'), KeyModifiers::empty()); | ||
| 2281 | assert_eq!(app.mode, ViewMode::Details); | ||
| 2282 | } | ||
| 2283 | |||
| 2284 | #[test] | ||
| 2285 | fn test_handle_key_pane_toggle() { | ||
| 2286 | let mut app = make_app(3, 3); | ||
| 2287 | assert_eq!(app.pane, Pane::ItemList); | ||
| 2288 | app.handle_key(KeyCode::Tab, KeyModifiers::empty()); | ||
| 2289 | assert_eq!(app.pane, Pane::Detail); | ||
| 2290 | app.handle_key(KeyCode::Enter, KeyModifiers::empty()); | ||
| 2291 | assert_eq!(app.pane, Pane::ItemList); | ||
| 2292 | } | ||
| 2293 | |||
| 2294 | #[test] | ||
| 2295 | fn test_scroll_in_detail_pane() { | ||
| 2296 | let mut app = make_app(3, 0); | ||
| 2297 | app.list_state.select(Some(0)); | ||
| 2298 | app.pane = Pane::Detail; | ||
| 2299 | app.scroll = 0; | ||
| 2300 | |||
| 2301 | app.handle_key(KeyCode::Char('j'), KeyModifiers::empty()); | ||
| 2302 | assert_eq!(app.scroll, 1); | ||
| 2303 | assert_eq!(app.list_state.selected(), Some(0)); | ||
| 2304 | |||
| 2305 | app.handle_key(KeyCode::Char('k'), KeyModifiers::empty()); | ||
| 2306 | assert_eq!(app.scroll, 0); | ||
| 2307 | } | ||
| 2308 | |||
| 2309 | #[test] | ||
| 2310 | fn test_handle_key_reload() { | ||
| 2311 | let mut app = make_app(3, 3); | ||
| 2312 | assert_eq!( | ||
| 2313 | app.handle_key(KeyCode::Char('r'), KeyModifiers::empty()), | ||
| 2314 | KeyAction::Reload | ||
| 2315 | ); | ||
| 2316 | } | ||
| 2317 | |||
| 2318 | // ── selected_ref_name tests ────────────────────────────────────────── | ||
| 2319 | |||
| 2320 | #[test] | ||
| 2321 | fn test_selected_ref_name_issues() { | ||
| 2322 | let app = make_app(3, 0); | ||
| 2323 | let ref_name = app.selected_ref_name(); | ||
| 2324 | assert_eq!( | ||
| 2325 | ref_name, | ||
| 2326 | Some("refs/collab/issues/00000000".to_string()) | ||
| 2327 | ); | ||
| 2328 | } | ||
| 2329 | |||
| 2330 | #[test] | ||
| 2331 | fn test_selected_ref_name_patches() { | ||
| 2332 | let mut app = make_app(0, 3); | ||
| 2333 | app.switch_tab(Tab::Patches); | ||
| 2334 | let ref_name = app.selected_ref_name(); | ||
| 2335 | assert_eq!( | ||
| 2336 | ref_name, | ||
| 2337 | Some("refs/collab/patches/p0000000".to_string()) | ||
| 2338 | ); | ||
| 2339 | } | ||
| 2340 | |||
| 2341 | #[test] | ||
| 2342 | fn test_selected_ref_name_none_when_empty() { | ||
| 2343 | let app = make_app(0, 0); | ||
| 2344 | assert_eq!(app.selected_ref_name(), None); | ||
| 2345 | } | ||
| 2346 | |||
| 2347 | // ── Render tests ───────────────────────────────────────────────────── | ||
| 2348 | |||
| 2349 | #[test] | ||
| 2350 | fn test_render_issues_tab() { | ||
| 2351 | let mut app = make_app(3, 2); | ||
| 2352 | app.status_filter = StatusFilter::All; | ||
| 2353 | let buf = render_app(&mut app); | ||
| 2354 | assert_buffer_contains(&buf, "1:Issues"); | ||
| 2355 | assert_buffer_contains(&buf, "2:Patches"); | ||
| 2356 | assert_buffer_contains(&buf, "00000000"); | ||
| 2357 | assert_buffer_contains(&buf, "00000001"); | ||
| 2358 | assert_buffer_contains(&buf, "00000002"); | ||
| 2359 | assert_buffer_contains(&buf, "Issue 0"); | ||
| 2360 | } | ||
| 2361 | |||
| 2362 | #[test] | ||
| 2363 | fn test_render_patches_tab() { | ||
| 2364 | let mut app = make_app(2, 3); | ||
| 2365 | app.status_filter = StatusFilter::All; | ||
| 2366 | app.switch_tab(Tab::Patches); | ||
| 2367 | let buf = render_app(&mut app); | ||
| 2368 | assert_buffer_contains(&buf, "p0000000"); | ||
| 2369 | assert_buffer_contains(&buf, "p0000001"); | ||
| 2370 | assert_buffer_contains(&buf, "p0000002"); | ||
| 2371 | assert_buffer_contains(&buf, "Patch 0"); | ||
| 2372 | } | ||
| 2373 | |||
| 2374 | #[test] | ||
| 2375 | fn test_render_empty_state() { | ||
| 2376 | let mut app = make_app(0, 0); | ||
| 2377 | let buf = render_app(&mut app); | ||
| 2378 | assert_buffer_contains(&buf, "No matches for current filter."); | ||
| 2379 | } | ||
| 2380 | |||
| 2381 | #[test] | ||
| 2382 | fn test_render_footer_keys() { | ||
| 2383 | let mut app = make_app(3, 3); | ||
| 2384 | let buf = render_app(&mut app); | ||
| 2385 | assert_buffer_contains(&buf, "j/k:navigate"); | ||
| 2386 | assert_buffer_contains(&buf, "Tab:pane"); | ||
| 2387 | assert_buffer_contains(&buf, "c:events"); | ||
| 2388 | } | ||
| 2389 | |||
| 2390 | #[test] | ||
| 2391 | fn test_render_commit_list() { | ||
| 2392 | let mut app = make_app(3, 0); | ||
| 2393 | app.event_history = make_test_event_history(); | ||
| 2394 | app.event_list_state.select(Some(0)); | ||
| 2395 | app.mode = ViewMode::CommitList; | ||
| 2396 | let buf = render_app(&mut app); | ||
| 2397 | assert_buffer_contains(&buf, "Event History"); | ||
| 2398 | assert_buffer_contains(&buf, "Issue Open"); | ||
| 2399 | assert_buffer_contains(&buf, "Issue Comment"); | ||
| 2400 | assert_buffer_contains(&buf, "Issue Close"); | ||
| 2401 | } | ||
| 2402 | |||
| 2403 | #[test] | ||
| 2404 | fn test_render_commit_detail() { | ||
| 2405 | let mut app = make_app(3, 0); | ||
| 2406 | app.event_history = make_test_event_history(); | ||
| 2407 | app.event_list_state.select(Some(0)); | ||
| 2408 | app.mode = ViewMode::CommitDetail; | ||
| 2409 | let buf = render_app(&mut app); | ||
| 2410 | assert_buffer_contains(&buf, "Event Detail"); | ||
| 2411 | assert_buffer_contains(&buf, "aaaaaaa"); | ||
| 2412 | assert_buffer_contains(&buf, "Test User"); | ||
| 2413 | assert_buffer_contains(&buf, "Issue Open"); | ||
| 2414 | } | ||
| 2415 | |||
| 2416 | #[test] | ||
| 2417 | fn test_render_commit_list_footer() { | ||
| 2418 | let mut app = make_app(3, 0); | ||
| 2419 | app.mode = ViewMode::CommitList; | ||
| 2420 | let buf = render_app(&mut app); | ||
| 2421 | assert_buffer_contains(&buf, "Esc:back"); | ||
| 2422 | } | ||
| 2423 | |||
| 2424 | #[test] | ||
| 2425 | fn test_render_commit_detail_footer() { | ||
| 2426 | let mut app = make_app(3, 0); | ||
| 2427 | app.mode = ViewMode::CommitDetail; | ||
| 2428 | let buf = render_app(&mut app); | ||
| 2429 | assert_buffer_contains(&buf, "Esc:back"); | ||
| 2430 | assert_buffer_contains(&buf, "j/k:scroll"); | ||
| 2431 | } | ||
| 2432 | |||
| 2433 | #[test] | ||
| 2434 | fn test_render_small_terminal() { | ||
| 2435 | let mut app = make_app(3, 3); | ||
| 2436 | let backend = TestBackend::new(20, 10); | ||
| 2437 | let mut terminal = Terminal::new(backend).unwrap(); | ||
| 2438 | terminal.draw(|frame| ui(frame, &mut app)).unwrap(); | ||
| 2439 | } | ||
| 2440 | |||
| 2441 | // ── Integration: full browse flow ──────────────────────────────────── | ||
| 2442 | |||
| 2443 | #[test] | ||
| 2444 | fn test_full_commit_browse_flow() { | ||
| 2445 | let mut app = make_app(3, 0); | ||
| 2446 | app.pane = Pane::Detail; | ||
| 2447 | app.list_state.select(Some(0)); | ||
| 2448 | |||
| 2449 | let action = app.handle_key(KeyCode::Char('c'), KeyModifiers::empty()); | ||
| 2450 | assert_eq!(action, KeyAction::OpenCommitBrowser); | ||
| 2451 | |||
| 2452 | app.event_history = make_test_event_history(); | ||
| 2453 | app.event_list_state.select(Some(0)); | ||
| 2454 | app.mode = ViewMode::CommitList; | ||
| 2455 | app.scroll = 0; | ||
| 2456 | |||
| 2457 | app.handle_key(KeyCode::Char('j'), KeyModifiers::empty()); | ||
| 2458 | assert_eq!(app.event_list_state.selected(), Some(1)); | ||
| 2459 | |||
| 2460 | app.handle_key(KeyCode::Enter, KeyModifiers::empty()); | ||
| 2461 | assert_eq!(app.mode, ViewMode::CommitDetail); | ||
| 2462 | |||
| 2463 | app.handle_key(KeyCode::Char('j'), KeyModifiers::empty()); | ||
| 2464 | assert_eq!(app.scroll, 1); | ||
| 2465 | |||
| 2466 | app.handle_key(KeyCode::Esc, KeyModifiers::empty()); | ||
| 2467 | assert_eq!(app.mode, ViewMode::CommitList); | ||
| 2468 | assert_eq!(app.scroll, 0); | ||
| 2469 | |||
| 2470 | app.handle_key(KeyCode::Esc, KeyModifiers::empty()); | ||
| 2471 | assert_eq!(app.mode, ViewMode::Details); | ||
| 2472 | assert!(app.event_history.is_empty()); | ||
| 2473 | } | ||
| 2474 | |||
| 2475 | // ── Status message render test ─────────────────────────────────────── | ||
| 2476 | |||
| 2477 | #[test] | ||
| 2478 | fn test_render_status_message() { | ||
| 2479 | let mut app = make_app(3, 0); | ||
| 2480 | app.status_msg = Some("Error loading events: ref not found".to_string()); | ||
| 2481 | let buf = render_app(&mut app); | ||
| 2482 | assert_buffer_contains(&buf, "Error loading events"); | ||
| 2483 | } | ||
| 1399 | } | 2484 | } |
tests/patch_import_test.rs
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,298 @@ | |||
| 1 | use git2::Repository; | ||
| 2 | use std::path::{Path, PathBuf}; | ||
| 3 | use tempfile::TempDir; | ||
| 4 | |||
| 5 | use git_collab::event::Author; | ||
| 6 | use git_collab::patch; | ||
| 7 | use git_collab::state::{self, PatchStatus}; | ||
| 8 | |||
| 9 | // --------------------------------------------------------------------------- | ||
| 10 | // Helpers | ||
| 11 | // --------------------------------------------------------------------------- | ||
| 12 | |||
| 13 | fn alice() -> Author { | ||
| 14 | Author { | ||
| 15 | name: "Alice".to_string(), | ||
| 16 | email: "alice@example.com".to_string(), | ||
| 17 | } | ||
| 18 | } | ||
| 19 | |||
| 20 | /// Create a repo with an initial commit so we have a valid HEAD and tree. | ||
| 21 | fn init_repo_with_commit(dir: &Path, author: &Author) -> Repository { | ||
| 22 | let repo = Repository::init(dir).expect("init repo"); | ||
| 23 | { | ||
| 24 | let mut config = repo.config().unwrap(); | ||
| 25 | config.set_str("user.name", &author.name).unwrap(); | ||
| 26 | config.set_str("user.email", &author.email).unwrap(); | ||
| 27 | } | ||
| 28 | |||
| 29 | // Create an initial commit with a file so we have a valid tree/HEAD | ||
| 30 | let sig = git2::Signature::now(&author.name, &author.email).unwrap(); | ||
| 31 | let tree_oid = { | ||
| 32 | let blob_oid = repo.blob(b"initial content\n").unwrap(); | ||
| 33 | let mut tb = repo.treebuilder(None).unwrap(); | ||
| 34 | tb.insert("README", blob_oid, 0o100644).unwrap(); | ||
| 35 | tb.write().unwrap() | ||
| 36 | }; | ||
| 37 | { | ||
| 38 | let tree = repo.find_tree(tree_oid).unwrap(); | ||
| 39 | repo.commit(Some("refs/heads/main"), &sig, &sig, "Initial commit", &tree, &[]) | ||
| 40 | .unwrap(); | ||
| 41 | } | ||
| 42 | |||
| 43 | // Set HEAD to point to main | ||
| 44 | repo.set_head("refs/heads/main").unwrap(); | ||
| 45 | |||
| 46 | repo | ||
| 47 | } | ||
| 48 | |||
| 49 | /// Generate a valid git format-patch style .patch file content. | ||
| 50 | /// This creates a patch that adds a new file called `filename` with `content`. | ||
| 51 | fn make_format_patch( | ||
| 52 | from_name: &str, | ||
| 53 | from_email: &str, | ||
| 54 | subject: &str, | ||
| 55 | body: &str, | ||
| 56 | filename: &str, | ||
| 57 | content: &str, | ||
| 58 | ) -> String { | ||
| 59 | let date = "Thu, 19 Mar 2026 10:30:00 +0000"; | ||
| 60 | // Build the diff portion | ||
| 61 | let lines: Vec<&str> = content.lines().collect(); | ||
| 62 | let mut diff_lines = String::new(); | ||
| 63 | for line in &lines { | ||
| 64 | diff_lines.push_str(&format!("+{}\n", line)); | ||
| 65 | } | ||
| 66 | let line_count = lines.len(); | ||
| 67 | |||
| 68 | format!( | ||
| 69 | "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n\ | ||
| 70 | From: {} <{}>\n\ | ||
| 71 | Date: {}\n\ | ||
| 72 | Subject: [PATCH] {}\n\ | ||
| 73 | \n\ | ||
| 74 | {}\n\ | ||
| 75 | ---\n\ | ||
| 76 | {filename} | {line_count} +\n\ | ||
| 77 | 1 file changed, {line_count} insertions(+)\n\ | ||
| 78 | create mode 100644 {filename}\n\ | ||
| 79 | \n\ | ||
| 80 | diff --git a/{filename} b/{filename}\n\ | ||
| 81 | new file mode 100644\n\ | ||
| 82 | index 0000000..1234567\n\ | ||
| 83 | --- /dev/null\n\ | ||
| 84 | +++ b/{filename}\n\ | ||
| 85 | @@ -0,0 +1,{line_count} @@\n\ | ||
| 86 | {diff_lines}\ | ||
| 87 | -- \n\ | ||
| 88 | 2.40.0\n", | ||
| 89 | from_name, from_email, date, subject, body, | ||
| 90 | filename = filename, | ||
| 91 | line_count = line_count, | ||
| 92 | diff_lines = diff_lines, | ||
| 93 | ) | ||
| 94 | } | ||
| 95 | |||
| 96 | /// Write patch content to a file and return its path. | ||
| 97 | fn write_patch_file(dir: &Path, name: &str, content: &str) -> PathBuf { | ||
| 98 | let path = dir.join(name); | ||
| 99 | std::fs::write(&path, content).unwrap(); | ||
| 100 | path | ||
| 101 | } | ||
| 102 | |||
| 103 | // --------------------------------------------------------------------------- | ||
| 104 | // Tests | ||
| 105 | // --------------------------------------------------------------------------- | ||
| 106 | |||
| 107 | #[test] | ||
| 108 | fn test_import_single_patch_success() { | ||
| 109 | let tmp = TempDir::new().unwrap(); | ||
| 110 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 111 | |||
| 112 | let patch_content = make_format_patch( | ||
| 113 | "Bob", | ||
| 114 | "bob@example.com", | ||
| 115 | "Add hello.txt", | ||
| 116 | "This patch adds a hello file.", | ||
| 117 | "hello.txt", | ||
| 118 | "Hello, world!\n", | ||
| 119 | ); | ||
| 120 | |||
| 121 | let patch_dir = TempDir::new().unwrap(); | ||
| 122 | let patch_file = write_patch_file(patch_dir.path(), "0001-add-hello.patch", &patch_content); | ||
| 123 | |||
| 124 | let id = patch::import(&repo, &patch_file).unwrap(); | ||
| 125 | |||
| 126 | // Verify the patch was created in the DAG | ||
| 127 | let ref_name = format!("refs/collab/patches/{}", id); | ||
| 128 | let patch_state = state::PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 129 | |||
| 130 | assert_eq!(patch_state.title, "Add hello.txt"); | ||
| 131 | assert_eq!(patch_state.status, PatchStatus::Open); | ||
| 132 | assert_eq!(patch_state.author.name, "Alice"); // importer is the author in the DAG | ||
| 133 | assert!(!patch_state.head_commit.is_empty()); | ||
| 134 | assert_eq!(patch_state.base_ref, "main"); | ||
| 135 | } | ||
| 136 | |||
| 137 | #[test] | ||
| 138 | fn test_import_file_not_found() { | ||
| 139 | let tmp = TempDir::new().unwrap(); | ||
| 140 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 141 | |||
| 142 | let nonexistent = PathBuf::from("/tmp/does-not-exist-12345.patch"); | ||
| 143 | let result = patch::import(&repo, &nonexistent); | ||
| 144 | assert!(result.is_err()); | ||
| 145 | } | ||
| 146 | |||
| 147 | #[test] | ||
| 148 | fn test_import_malformed_patch() { | ||
| 149 | let tmp = TempDir::new().unwrap(); | ||
| 150 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 151 | |||
| 152 | let patch_dir = TempDir::new().unwrap(); | ||
| 153 | let patch_file = write_patch_file( | ||
| 154 | patch_dir.path(), | ||
| 155 | "bad.patch", | ||
| 156 | "This is not a valid patch file at all.\nJust random text.\n", | ||
| 157 | ); | ||
| 158 | |||
| 159 | let result = patch::import(&repo, &patch_file); | ||
| 160 | assert!(result.is_err()); | ||
| 161 | } | ||
| 162 | |||
| 163 | #[test] | ||
| 164 | fn test_import_creates_dag_entry_readable_by_show() { | ||
| 165 | let tmp = TempDir::new().unwrap(); | ||
| 166 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 167 | |||
| 168 | let patch_content = make_format_patch( | ||
| 169 | "Charlie", | ||
| 170 | "charlie@example.com", | ||
| 171 | "Fix bug in parser", | ||
| 172 | "Fixes an off-by-one error in the parser module.", | ||
| 173 | "parser.txt", | ||
| 174 | "fixed parser code\n", | ||
| 175 | ); | ||
| 176 | |||
| 177 | let patch_dir = TempDir::new().unwrap(); | ||
| 178 | let patch_file = write_patch_file(patch_dir.path(), "0001-fix-bug.patch", &patch_content); | ||
| 179 | |||
| 180 | let id = patch::import(&repo, &patch_file).unwrap(); | ||
| 181 | |||
| 182 | // Verify it can be resolved and read back through state infrastructure | ||
| 183 | let (ref_name, resolved_id) = state::resolve_patch_ref(&repo, &id[..8]).unwrap(); | ||
| 184 | assert_eq!(resolved_id, id); | ||
| 185 | |||
| 186 | let patch_state = state::PatchState::from_ref(&repo, &ref_name, &resolved_id).unwrap(); | ||
| 187 | assert_eq!(patch_state.title, "Fix bug in parser"); | ||
| 188 | assert!( | ||
| 189 | patch_state.body.contains("off-by-one"), | ||
| 190 | "body should contain the patch description" | ||
| 191 | ); | ||
| 192 | } | ||
| 193 | |||
| 194 | #[test] | ||
| 195 | fn test_import_series_multiple_files() { | ||
| 196 | let tmp = TempDir::new().unwrap(); | ||
| 197 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 198 | |||
| 199 | let patch1 = make_format_patch( | ||
| 200 | "Bob", | ||
| 201 | "bob@example.com", | ||
| 202 | "Add file one", | ||
| 203 | "First patch in series.", | ||
| 204 | "one.txt", | ||
| 205 | "one\n", | ||
| 206 | ); | ||
| 207 | |||
| 208 | let patch2 = make_format_patch( | ||
| 209 | "Bob", | ||
| 210 | "bob@example.com", | ||
| 211 | "Add file two", | ||
| 212 | "Second patch in series.", | ||
| 213 | "two.txt", | ||
| 214 | "two\n", | ||
| 215 | ); | ||
| 216 | |||
| 217 | let patch_dir = TempDir::new().unwrap(); | ||
| 218 | let f1 = write_patch_file(patch_dir.path(), "0001-add-one.patch", &patch1); | ||
| 219 | let f2 = write_patch_file(patch_dir.path(), "0002-add-two.patch", &patch2); | ||
| 220 | |||
| 221 | let ids = patch::import_series(&repo, &[f1, f2]).unwrap(); | ||
| 222 | assert_eq!(ids.len(), 2); | ||
| 223 | |||
| 224 | // Both should be valid patches in the DAG | ||
| 225 | for id in &ids { | ||
| 226 | let (ref_name, _) = state::resolve_patch_ref(&repo, &id[..8]).unwrap(); | ||
| 227 | let ps = state::PatchState::from_ref(&repo, &ref_name, id).unwrap(); | ||
| 228 | assert_eq!(ps.status, PatchStatus::Open); | ||
| 229 | } | ||
| 230 | } | ||
| 231 | |||
| 232 | #[test] | ||
| 233 | fn test_import_series_rollback_on_failure() { | ||
| 234 | let tmp = TempDir::new().unwrap(); | ||
| 235 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 236 | |||
| 237 | let good_patch = make_format_patch( | ||
| 238 | "Bob", | ||
| 239 | "bob@example.com", | ||
| 240 | "Add good file", | ||
| 241 | "A good patch.", | ||
| 242 | "good.txt", | ||
| 243 | "good\n", | ||
| 244 | ); | ||
| 245 | |||
| 246 | let patch_dir = TempDir::new().unwrap(); | ||
| 247 | let f1 = write_patch_file(patch_dir.path(), "0001-good.patch", &good_patch); | ||
| 248 | let f2 = PathBuf::from("/tmp/nonexistent-bad-patch-12345.patch"); | ||
| 249 | |||
| 250 | let result = patch::import_series(&repo, &[f1, f2]); | ||
| 251 | assert!(result.is_err()); | ||
| 252 | |||
| 253 | // After rollback, no patches should exist | ||
| 254 | let patches = state::list_patches(&repo).unwrap(); | ||
| 255 | assert_eq!(patches.len(), 0, "rollback should remove all imported patches"); | ||
| 256 | } | ||
| 257 | |||
| 258 | #[test] | ||
| 259 | fn test_import_patch_with_modification() { | ||
| 260 | // Test importing a patch that modifies an existing file (not just new files) | ||
| 261 | let tmp = TempDir::new().unwrap(); | ||
| 262 | let repo = init_repo_with_commit(tmp.path(), &alice()); | ||
| 263 | |||
| 264 | // Create a patch that modifies README (which exists in our initial commit) | ||
| 265 | let date = "Thu, 19 Mar 2026 10:30:00 +0000"; | ||
| 266 | let patch_content = format!( | ||
| 267 | "From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001\n\ | ||
| 268 | From: Bob <bob@example.com>\n\ | ||
| 269 | Date: {}\n\ | ||
| 270 | Subject: [PATCH] Update README\n\ | ||
| 271 | \n\ | ||
| 272 | Updated the README with more info.\n\ | ||
| 273 | ---\n\ | ||
| 274 | README | 2 +-\n\ | ||
| 275 | 1 file changed, 1 insertion(+), 1 deletion(-)\n\ | ||
| 276 | \n\ | ||
| 277 | diff --git a/README b/README\n\ | ||
| 278 | index 1234567..abcdef0 100644\n\ | ||
| 279 | --- a/README\n\ | ||
| 280 | +++ b/README\n\ | ||
| 281 | @@ -1 +1 @@\n\ | ||
| 282 | -initial content\n\ | ||
| 283 | +updated content\n\ | ||
| 284 | -- \n\ | ||
| 285 | 2.40.0\n", | ||
| 286 | date, | ||
| 287 | ); | ||
| 288 | |||
| 289 | let patch_dir = TempDir::new().unwrap(); | ||
| 290 | let patch_file = write_patch_file(patch_dir.path(), "0001-update-readme.patch", &patch_content); | ||
| 291 | |||
| 292 | let id = patch::import(&repo, &patch_file).unwrap(); | ||
| 293 | |||
| 294 | let ref_name = format!("refs/collab/patches/{}", id); | ||
| 295 | let ps = state::PatchState::from_ref(&repo, &ref_name, &id).unwrap(); | ||
| 296 | assert_eq!(ps.title, "Update README"); | ||
| 297 | assert_eq!(ps.status, PatchStatus::Open); | ||
| 298 | } | ||