diff --git a/Cargo.lock b/Cargo.lock index 73b6ea7..a5d2967 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.3" @@ -251,6 +257,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossterm" version = "0.28.1" @@ -321,6 +336,17 @@ dependencies = [ "serde", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dyn-clone" version = "1.0.19" @@ -355,6 +381,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -367,12 +403,32 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fuchsia-cprng" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.3.3" @@ -438,12 +494,114 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -568,6 +726,7 @@ dependencies = [ "tracing-subscriber", "tui-textarea", "tui_confirm_dialog", + "ureq", "version-compare", ] @@ -583,6 +742,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "lock_api" version = "0.4.13" @@ -620,6 +785,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.0.4" @@ -723,12 +898,27 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -822,7 +1012,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom", + "getrandom 0.3.3", ] [[package]] @@ -924,6 +1114,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "0.38.44" @@ -937,6 +1141,41 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.21" @@ -1103,6 +1342,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "simdutf8" version = "0.1.5" @@ -1121,6 +1366,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -1155,6 +1406,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.104" @@ -1166,6 +1423,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tempdir" version = "0.3.7" @@ -1267,6 +1535,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml" version = "0.8.23" @@ -1433,6 +1711,48 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1524,6 +1844,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -1605,6 +1943,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -1769,6 +2116,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.26" @@ -1788,3 +2164,63 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 2c6759f..b2ccaed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ tracing-log = "0.2.0" tracing-subscriber = "0.3.19" tui-textarea = "0.7.0" tui_confirm_dialog = "0.3.1" +ureq = { version = "2", features = ["json"] } version-compare = "0.2.0" # Release build optimize size. diff --git a/docs/keybindings.md b/docs/keybindings.md index 1752407..4be9e87 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -49,5 +49,7 @@ push-all-new = "ctrl+shift+p" fetch = "f" fetch-all = "shift+f" +create-pull-request = "o" + open-help = "?" ``` diff --git a/src/commander/bookmarks.rs b/src/commander/bookmarks.rs index 43bd892..21d5b02 100644 --- a/src/commander/bookmarks.rs +++ b/src/commander/bookmarks.rs @@ -7,7 +7,7 @@ other jj bookmark commands are defined in module [jj][super::jj]. It is mostly used in the [bookmarks_tab][crate::ui::bookmarks_tab] module. */ use crate::{ - commander::{CommandError, Commander, RemoveEndLine}, + commander::{CommandError, Commander, RemoveEndLine, ids::CommitId}, env::DiffFormat, }; use ansi_to_tui::IntoText; @@ -168,6 +168,29 @@ impl Commander { Ok(bookmarks) } + /// Get local bookmark names pointing to a specific commit. + /// Maps to `jj bookmark list -r ` + #[instrument(level = "trace", skip(self))] + pub fn get_commit_bookmarks(&self, commit_id: &CommitId) -> Result, CommandError> { + let output = self.execute_jj_command( + vec![ + "bookmark", + "list", + "-r", + commit_id.as_str(), + "-T", + r#"name ++ "\n""#, + ], + false, + true, + )?; + Ok(output + .lines() + .filter(|s| !s.is_empty()) + .map(String::from) + .collect()) + } + /// Get bookmark details. /// Maps to `jj show ` #[instrument(level = "trace", skip(self))] diff --git a/src/commander/jj.rs b/src/commander/jj.rs index 9634a8d..c16e08a 100644 --- a/src/commander/jj.rs +++ b/src/commander/jj.rs @@ -174,6 +174,23 @@ impl Commander { self.execute_jj_command(args, true, true) } + /// Get git remote URL. Maps to `jj git remote list` + #[instrument(level = "trace", skip(self))] + pub fn get_git_remote_url(&self, remote: &str) -> Result { + let output = self.execute_jj_command(vec!["git", "remote", "list"], false, true)?; + for line in output.lines() { + if let Some((name, url)) = line.split_once(' ') { + if name == remote { + return Ok(url.trim().to_owned()); + } + } + } + Err(CommandError::Status( + format!("Remote '{remote}' not found"), + None, + )) + } + /// Git fetch. Maps to `jj git fetch` #[instrument(level = "trace", skip(self))] pub fn git_fetch(&self, all_remotes: bool) -> Result { diff --git a/src/keybinds/config.rs b/src/keybinds/config.rs index fd70a7d..b52dddc 100644 --- a/src/keybinds/config.rs +++ b/src/keybinds/config.rs @@ -41,6 +41,7 @@ pub struct LogTabKeybindsConfig { pub describe: Option, pub edit_revset: Option, pub set_bookmark: Option, + pub create_pull_request: Option, pub open_files: Option, pub rebase: Option, diff --git a/src/keybinds/log_tab.rs b/src/keybinds/log_tab.rs index 1bd43f8..1913406 100644 --- a/src/keybinds/log_tab.rs +++ b/src/keybinds/log_tab.rs @@ -4,7 +4,7 @@ use ratatui::crossterm::event::KeyEvent; use crate::{make_keybinds_help, set_keybinds, update_keybinds}; -use super::{Shortcut, config::LogTabKeybindsConfig, keybinds_store::KeybindsStore}; +use super::{config::LogTabKeybindsConfig, keybinds_store::KeybindsStore, Shortcut}; #[derive(Debug)] pub struct LogTabKeybinds { @@ -43,6 +43,7 @@ pub enum LogTabEvent { Describe, EditRevset, SetBookmark, + CreatePullRequest, OpenFiles, Push { @@ -89,6 +90,7 @@ impl Default for LogTabKeybinds { LogTabEvent::Describe => "d", LogTabEvent::EditRevset => "r", LogTabEvent::SetBookmark => "b", + LogTabEvent::CreatePullRequest => "o", LogTabEvent::OpenFiles => "enter", event_push(false, false) => "p", event_push(false, true) => "ctrl+p", @@ -135,6 +137,7 @@ impl LogTabKeybinds { LogTabEvent::Describe => config.describe, LogTabEvent::EditRevset => config.edit_revset, LogTabEvent::SetBookmark => config.set_bookmark, + LogTabEvent::CreatePullRequest => config.create_pull_request, LogTabEvent::OpenFiles => config.open_files, LogTabEvent::Rebase => config.rebase, event_push(false, false) => config.push, @@ -166,6 +169,7 @@ impl LogTabKeybinds { LogTabEvent::Squash { ignore_immutable: false } => "squash @ into the selected change", LogTabEvent::Squash { ignore_immutable: true } => "squash @ into the selected change ignoring immutability", LogTabEvent::SetBookmark => "set bookmark", + LogTabEvent::CreatePullRequest => "create pull request", LogTabEvent::Fetch { all_remotes: false } => "git fetch", LogTabEvent::Fetch { all_remotes: true } => "git fetch all remotes", event_push(false, false) => "git push", diff --git a/src/ui/bookmark_set_popup.rs b/src/ui/bookmark_set_popup.rs index 5b72ede..a32f51b 100644 --- a/src/ui/bookmark_set_popup.rs +++ b/src/ui/bookmark_set_popup.rs @@ -29,10 +29,99 @@ enum BookmarkSetOption { CreateBookmark, // Name, exists GeneratedName(String, bool), + AiGenerate, + RandomName, Bookmark(Bookmark), Error(String), } +#[derive(serde::Serialize)] +struct OllamaGenerateRequest { + model: String, + prompt: String, + stream: bool, +} + +#[derive(serde::Deserialize)] +struct OllamaGenerateResponse { + response: String, +} + +#[derive(serde::Deserialize)] +struct OllamaTagsResponse { + models: Vec, +} + +#[derive(serde::Deserialize)] +struct OllamaModel { + model: String, +} + +fn fetch_random_name() -> anyhow::Result { + let words: Vec = ureq::get("https://random-word-api.herokuapp.com/word?number=2") + .call() + .map_err(|e| anyhow::anyhow!("Could not reach random word API: {e}"))? + .into_json() + .map_err(|e| anyhow::anyhow!("Failed to parse random word response: {e}"))?; + + Ok(words.join("-")) +} + +fn call_ollama_for_name(description: &str) -> anyhow::Result { + let tags: OllamaTagsResponse = ureq::get("http://localhost:11434/api/tags") + .call() + .map_err(|e| anyhow::anyhow!("Could not connect to Ollama: {e}"))? + .into_json() + .map_err(|e| anyhow::anyhow!("Failed to parse Ollama models: {e}"))?; + + let model = tags + .models + .into_iter() + .next() + .map(|m| m.model) + .ok_or_else(|| anyhow::anyhow!("No Ollama models found"))?; + + let prompt = format!( + "Generate a short git branch name slug for this commit description. \ + Use lowercase letters and hyphens only, max 5 words, no prefix. \ + Reply with ONLY the slug, nothing else.\n\nCommit description: {description}" + ); + + let resp: OllamaGenerateResponse = + ureq::post("http://localhost:11434/api/generate") + .send_json(&OllamaGenerateRequest { + model, + prompt, + stream: false, + }) + .map_err(|e| anyhow::anyhow!("Ollama generate request failed: {e}"))? + .into_json() + .map_err(|e| anyhow::anyhow!("Failed to parse Ollama response: {e}"))?; + + // Sanitize: keep only lowercase alphanumeric and hyphens + let sanitized: String = resp + .response + .trim() + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + + // Collapse consecutive hyphens and trim leading/trailing hyphens + let name = sanitized + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + + Ok(name) +} + pub struct BookmarkSetPopup<'a> { pub change_id: Option, commit_id: CommitId, @@ -66,6 +155,9 @@ fn generate_options( options.push(BookmarkSetOption::GeneratedName(generated_name, exists)); } + options.push(BookmarkSetOption::AiGenerate); + options.push(BookmarkSetOption::RandomName); + match bookmarks.as_ref() { Ok(bookmarks) => { for bookmark in bookmarks @@ -121,6 +213,27 @@ impl BookmarkSetPopup<'_> { self.creating = Some(TextArea::default()); } + fn on_random_naming(&mut self) { + let name = fetch_random_name().unwrap_or_default(); + let mut textarea = TextArea::default(); + textarea.insert_str(&name); + self.creating = Some(textarea); + } + + fn on_ai_generating(&mut self, commander: &mut Commander) { + let description = commander + .get_commit_description(&self.commit_id) + .unwrap_or_default(); + let name = if description.trim().is_empty() { + String::new() + } else { + call_ollama_for_name(&description).unwrap_or_default() + }; + let mut textarea = TextArea::default(); + textarea.insert_str(&name); + self.creating = Some(textarea); + } + fn create_bookmark(&self, commander: &mut Commander, name: &str) -> Result<()> { if commander .get_bookmarks_list(false)? @@ -207,6 +320,12 @@ impl Component for BookmarkSetPopup<'_> { } Text::raw(text).fg(Color::Yellow) } + BookmarkSetOption::AiGenerate => { + Text::raw("(A)I generate bookmark").fg(Color::Yellow) + } + BookmarkSetOption::RandomName => { + Text::raw("(R)andom name").fg(Color::Yellow) + } BookmarkSetOption::Bookmark(bookmark) => { Text::raw(bookmark.to_string()).fg(Color::Magenta) } @@ -297,6 +416,12 @@ impl Component for BookmarkSetPopup<'_> { KeyCode::Char('c') => { self.on_creating(); } + KeyCode::Char('a') | KeyCode::Char('A') => { + self.on_ai_generating(commander); + } + KeyCode::Char('r') | KeyCode::Char('R') => { + self.on_random_naming(); + } KeyCode::Enter => { if let Some(action) = self .list_state @@ -314,6 +439,12 @@ impl Component for BookmarkSetPopup<'_> { ComponentAction::SetPopup(None), )); } + BookmarkSetOption::AiGenerate => { + self.on_ai_generating(commander); + } + BookmarkSetOption::RandomName => { + self.on_random_naming(); + } BookmarkSetOption::Bookmark(bookmark) => { commander.set_bookmark_commit(&bookmark.name, &self.commit_id)?; self.tx.send(true)?; diff --git a/src/ui/log_tab.rs b/src/ui/log_tab.rs index 8440b00..820092c 100644 --- a/src/ui/log_tab.rs +++ b/src/ui/log_tab.rs @@ -13,12 +13,10 @@ use tui_confirm_dialog::{ButtonLabel, ConfirmDialog, ConfirmDialogState, Listene use tui_textarea::{CursorMove, TextArea}; use crate::{ - ComponentInputResult, - commander::{CommandError, Commander, log::Head}, + commander::{log::Head, CommandError, Commander}, env::{Config, DiffFormat}, keybinds::{LogTabEvent, LogTabKeybinds}, ui::{ - Component, ComponentAction, bookmark_set_popup::BookmarkSetPopup, help_popup::HelpPopup, loader_popup::LoaderPopup, @@ -27,7 +25,9 @@ use crate::{ panel::LogPanel, rebase_popup::RebasePopup, utils::{centered_rect, centered_rect_line_height, tabs_to_spaces}, + Component, ComponentAction, }, + ComponentInputResult, }; const NEW_POPUP_ID: u16 = 1; @@ -35,6 +35,18 @@ const EDIT_POPUP_ID: u16 = 2; const ABANDON_POPUP_ID: u16 = 3; const SQUASH_POPUP_ID: u16 = 4; +fn build_github_pr_url(remote_url: &str, branch: &str) -> Option { + // Normalize SSH and HTTPS remote URLs to a base GitHub HTTPS URL + let base = if let Some(path) = remote_url.strip_prefix("git@github.com:") { + format!("https://github.com/{}", path.trim_end_matches(".git")) + } else if let Some(path) = remote_url.strip_prefix("https://github.com/") { + format!("https://github.com/{}", path.trim_end_matches(".git")) + } else { + return None; + }; + Some(format!("{base}/compare/{branch}?expand=1")) +} + /// Log tab. Shows `jj log` in main panel and shows selected change details of in details panel. pub struct LogTab<'a> { /// The revset filter to apply to jj log @@ -269,7 +281,7 @@ impl<'a> LogTab<'a> { ComponentAction::SetPopup(Some(Box::new(MessagePopup { title: " Edit ".into(), messages: vec![ - "The change cannot be edited because it is immutable.".into(), + "The change cannot be edited because it is immutable.".into() ] .into(), text_align: None, @@ -375,6 +387,16 @@ impl<'a> LogTab<'a> { )))), )); } + LogTabEvent::CreatePullRequest => { + let bookmarks = commander.get_commit_bookmarks(&self.head.commit_id)?; + if let Some(bookmark) = bookmarks.into_iter().next() { + if let Ok(remote_url) = commander.get_git_remote_url("origin") { + if let Some(pr_url) = build_github_pr_url(&remote_url, &bookmark) { + let _ = std::process::Command::new("open").arg(&pr_url).spawn(); + } + } + } + } LogTabEvent::OpenFiles => { return Ok(ComponentInputResult::HandledAction( ComponentAction::ViewFiles(self.head.clone()),