Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
438 changes: 437 additions & 1 deletion Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,7 @@ push-all-new = "ctrl+shift+p"
fetch = "f"
fetch-all = "shift+f"

create-pull-request = "o"

open-help = "?"
```
25 changes: 24 additions & 1 deletion src/commander/bookmarks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -168,6 +168,29 @@ impl Commander {
Ok(bookmarks)
}

/// Get local bookmark names pointing to a specific commit.
/// Maps to `jj bookmark list -r <commit_id>`
#[instrument(level = "trace", skip(self))]
pub fn get_commit_bookmarks(&self, commit_id: &CommitId) -> Result<Vec<String>, 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 <bookmark>`
#[instrument(level = "trace", skip(self))]
Expand Down
17 changes: 17 additions & 0 deletions src/commander/jj.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, CommandError> {
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<String, CommandError> {
Expand Down
1 change: 1 addition & 0 deletions src/keybinds/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ pub struct LogTabKeybindsConfig {
pub describe: Option<Keybind>,
pub edit_revset: Option<Keybind>,
pub set_bookmark: Option<Keybind>,
pub create_pull_request: Option<Keybind>,
pub open_files: Option<Keybind>,
pub rebase: Option<Keybind>,

Expand Down
6 changes: 5 additions & 1 deletion src/keybinds/log_tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -43,6 +43,7 @@ pub enum LogTabEvent {
Describe,
EditRevset,
SetBookmark,
CreatePullRequest,
OpenFiles,

Push {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
131 changes: 131 additions & 0 deletions src/ui/bookmark_set_popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OllamaModel>,
}

#[derive(serde::Deserialize)]
struct OllamaModel {
model: String,
}

fn fetch_random_name() -> anyhow::Result<String> {
let words: Vec<String> = 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<String> {
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::<Vec<_>>()
.join("-");

Ok(name)
}

pub struct BookmarkSetPopup<'a> {
pub change_id: Option<ChangeId>,
commit_id: CommitId,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)?
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -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)?;
Expand Down
30 changes: 26 additions & 4 deletions src/ui/log_tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,14 +25,28 @@ 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;
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<String> {
// 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()),
Expand Down