diff --git a/crates/but/src/args.rs b/crates/but/src/args.rs index 12ab2ba57d..ce91ad0d1d 100644 --- a/crates/but/src/args.rs +++ b/crates/but/src/args.rs @@ -95,6 +95,9 @@ pub enum Subcommands { review: bool, }, + /// Launch the experimental Lazy TUI interface. + Lazy, + /// Combines two entities together to perform an operation. /// /// The `rub` command is a simple verb that helps you do a number of editing diff --git a/crates/but/src/lazy/absorb.rs b/crates/but/src/lazy/absorb.rs new file mode 100644 index 0000000000..3382153c51 --- /dev/null +++ b/crates/but/src/lazy/absorb.rs @@ -0,0 +1,89 @@ +use anyhow::Result; + +use super::app::{LazyApp, Panel}; +use crate::{ + absorb, + utils::{OutputChannel, OutputFormat}, +}; + +impl LazyApp { + pub(super) fn open_absorb_modal(&mut self) { + if !matches!(self.active_panel, Panel::Status) { + self.command_log + .push("Switch to the Status panel to absorb changes".to_string()); + return; + } + + if !self.is_unassigned_header_selected() { + self.command_log + .push("Select 'Unassigned Files' to absorb all pending changes".to_string()); + return; + } + + if self.unassigned_files.is_empty() { + self.command_log + .push("No unassigned changes available to absorb".to_string()); + return; + } + + let (summary, _) = self.summarize_unassigned_files(); + if summary.file_count == 0 { + self.command_log + .push("No unassigned changes available to absorb".to_string()); + return; + } + + self.absorb_summary = Some(summary.clone()); + self.show_absorb_modal = true; + self.command_log.push(format!( + "Preparing to absorb {} unassigned file(s)", + summary.file_count + )); + } + + pub(super) fn cancel_absorb_modal(&mut self) { + self.reset_absorb_modal_state(); + self.command_log.push("Canceled absorb".to_string()); + } + + pub(super) fn confirm_absorb_modal(&mut self) { + match self.perform_absorb() { + Ok(true) => self.reset_absorb_modal_state(), + Ok(false) => {} + Err(e) => self.command_log.push(format!("Failed to absorb: {}", e)), + } + } + + fn perform_absorb(&mut self) -> Result { + if self.unassigned_files.is_empty() { + self.command_log + .push("No unassigned changes to absorb".to_string()); + return Ok(false); + } + + let project = gitbutler_project::get(self.project_id)?; + let mut out = OutputChannel::new_without_pager_non_json(OutputFormat::None); + self.command_log + .push("Running absorb on unassigned changes".to_string()); + absorb::handle(&project, &mut out, None)?; + + if let Some(summary) = self.absorb_summary.clone() { + self.command_log.push(format!( + "Absorbed {} file(s) (+{} / -{})", + summary.file_count, summary.total_additions, summary.total_removals + )); + } else { + self.command_log + .push("Absorbed unassigned changes".to_string()); + } + + self.load_data_with_project(&project)?; + self.update_main_view(); + Ok(true) + } + + fn reset_absorb_modal_state(&mut self) { + self.show_absorb_modal = false; + self.absorb_summary = None; + } +} diff --git a/crates/but/src/lazy/app.rs b/crates/but/src/lazy/app.rs new file mode 100644 index 0000000000..931b80bc40 --- /dev/null +++ b/crates/but/src/lazy/app.rs @@ -0,0 +1,1924 @@ +use anyhow::{Result, anyhow}; +use bstr::ByteSlice; +use but_api::json::HexHash; +use but_oxidize::{OidExt, TimeExt}; +use but_settings::AppSettings; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use gitbutler_project::{Project, ProjectId}; +use gix::date::time::CustomFormat; +use ratatui::{ + Terminal, + backend::CrosstermBackend, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::ListState, +}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + io, + time::Duration, +}; + +use super::render::ui; +use crate::status::assignment::FileAssignment; +use but_forge::ForgeReview; +use but_hunk_assignment::HunkAssignment; +use but_workspace::ui::StackDetails; +use gitbutler_branch_actions::{ + integrate_upstream, + upstream_integration::{ + BranchStatus as UpstreamBranchStatus, Resolution, ResolutionApproach, StackStatuses, + }, + upstream_integration_statuses, +}; +use gitbutler_command_context::CommandContext; + +pub(super) const DATE_ONLY: CustomFormat = CustomFormat::new("%Y-%m-%d"); + +#[derive(PartialEq, Eq)] +pub(super) enum Panel { + Upstream, + Status, + Oplog, +} + +pub(super) struct LazyApp { + pub(super) active_panel: Panel, + pub(super) unassigned_files: Vec, + pub(super) stacks: Vec, + pub(super) oplog_entries: Vec, + pub(super) upstream_info: Option, + pub(super) upstream_integration_status: Option, + pub(super) status_state: ListState, + pub(super) oplog_state: ListState, + pub(super) command_log: Vec, + pub(super) main_view_content: Vec>, + pub(super) should_quit: bool, + pub(super) show_help: bool, + pub(super) help_scroll: u16, + pub(super) command_log_visible: bool, + pub(super) show_restore_modal: bool, + pub(super) show_update_modal: bool, + pub(super) project_id: ProjectId, + pub(super) last_refresh: std::time::Instant, + pub(super) last_fetch: std::time::Instant, + // Panel areas for mouse click detection + pub(super) upstream_area: Option, + pub(super) status_area: Option, + pub(super) oplog_area: Option, + pub(super) details_area: Option, + // Commit modal state + pub(super) show_commit_modal: bool, + pub(super) commit_subject: String, + pub(super) commit_message: String, + pub(super) commit_modal_focus: CommitModalFocus, + pub(super) commit_branch_options: Vec, + pub(super) commit_selected_branch_idx: usize, + pub(super) commit_new_branch_name: String, + pub(super) commit_only_mode: bool, + pub(super) commit_files: Vec, + pub(super) commit_selected_file_idx: usize, + pub(super) commit_selected_file_paths: HashSet, + pub(super) show_reword_modal: bool, + pub(super) reword_subject: String, + pub(super) reword_message: String, + pub(super) reword_modal_focus: RewordModalFocus, + pub(super) reword_target: Option, + pub(super) show_uncommit_modal: bool, + pub(super) uncommit_target: Option, + pub(super) show_diff_modal: bool, + pub(super) diff_modal_files: Vec, + pub(super) diff_modal_selected_file: usize, + pub(super) diff_modal_scroll: u16, + pub(super) show_branch_rename_modal: bool, + pub(super) branch_rename_input: String, + pub(super) branch_rename_target: Option, + pub(super) show_absorb_modal: bool, + pub(super) absorb_summary: Option, + pub(super) restore_target: Option, + pub(super) show_squash_modal: bool, + pub(super) squash_target: Option, + // Details pane state + pub(super) details_selected: bool, + pub(super) details_scroll: u16, +} + +#[derive(Debug, Clone)] +pub(super) struct CommitBranchOption { + pub(super) stack_id: Option, + pub(super) branch_name: String, + pub(super) is_new_branch: bool, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) enum CommitModalFocus { + BranchSelect, + Files, + NewBranchName, + Subject, + Message, +} + +#[allow(dead_code)] +pub(super) struct UpstreamInfo { + pub(super) behind_count: usize, + latest_commit: String, + message: String, + commit_date: String, + pub(super) last_fetched_ms: Option, + commits: Vec, +} + +#[allow(dead_code)] +pub(super) struct UpstreamCommitInfo { + pub(super) id: String, + pub(super) full_id: String, + pub(super) message: String, + pub(super) author: String, + pub(super) created_at: String, +} + +#[allow(dead_code)] +pub(super) struct StackInfo { + pub(super) id: Option, + pub(super) name: String, + pub(super) branches: Vec, +} + +#[derive(Clone)] +pub(super) struct BranchInfo { + pub(super) name: String, + pub(super) commits: Vec, + pub(super) assignments: Vec, +} + +#[derive(Clone)] +pub(super) struct CommitInfo { + pub(super) id: String, + pub(super) full_id: String, + pub(super) message: String, + pub(super) author: String, + pub(super) author_email: String, + pub(super) author_date: String, + pub(super) committer: String, + pub(super) committer_email: String, + pub(super) committer_date: String, + pub(super) state: but_workspace::ui::CommitState, +} + +#[derive(Clone)] +pub(super) struct CommitFileOption { + pub(super) file: FileAssignment, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum RewordModalFocus { + Subject, + Message, +} + +pub(super) struct RewordTargetInfo { + pub(super) stack_id: gitbutler_stack::StackId, + pub(super) branch_name: String, + pub(super) commit_short_id: String, + pub(super) commit_full_id: String, + pub(super) original_message: String, +} + +#[derive(Clone)] +pub(super) struct UncommitTargetInfo { + pub(super) stack_id: gitbutler_stack::StackId, + pub(super) branch_name: String, + pub(super) commit_short_id: String, + pub(super) commit_full_id: String, + pub(super) commit_message: String, +} + +#[derive(Clone, Default)] +pub(super) struct AbsorbSummary { + pub(super) file_count: usize, + pub(super) hunk_count: usize, + pub(super) total_additions: usize, + pub(super) total_removals: usize, +} + +pub(super) struct SquashTargetInfo { + pub(super) stack_id: gitbutler_stack::StackId, + pub(super) branch_name: String, + pub(super) source_short_id: String, + pub(super) source_full_id: String, + pub(super) source_message: String, + pub(super) destination_short_id: String, + pub(super) destination_full_id: String, + pub(super) destination_message: String, +} + +#[derive(Clone)] +pub(super) struct UnassignedFileStat { + pub(super) path: String, + pub(super) additions: usize, + pub(super) removals: usize, +} + +#[derive(Clone)] +pub(super) struct CommitDiffFile { + pub(super) path: String, + pub(super) status: but_core::ui::TreeStatus, + pub(super) lines: Vec, +} + +#[derive(Clone)] +pub(super) struct CommitDiffLine { + pub(super) text: String, + pub(super) kind: DiffLineKind, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum DiffLineKind { + Header, + Added, + Removed, + Context, + Info, +} + +#[derive(Clone)] +pub(super) struct OplogEntry { + pub(super) id: String, + pub(super) full_id: String, + pub(super) operation: String, + pub(super) title: String, + pub(super) time: String, +} + +pub(super) struct BranchRenameTarget { + pub(super) stack_id: gitbutler_stack::StackId, + pub(super) current_name: String, +} + +impl LazyApp { + fn new(project: &Project) -> Result { + let now = std::time::Instant::now(); + let mut app = Self { + active_panel: Panel::Status, + unassigned_files: Vec::new(), + stacks: Vec::new(), + oplog_entries: Vec::new(), + upstream_info: None, + upstream_integration_status: None, + status_state: ListState::default(), + oplog_state: ListState::default(), + command_log: Vec::new(), + main_view_content: Vec::new(), + should_quit: false, + show_help: false, + help_scroll: 0, + command_log_visible: true, + show_restore_modal: false, + show_update_modal: false, + project_id: project.id, + last_refresh: now, + last_fetch: now, + upstream_area: None, + status_area: None, + oplog_area: None, + details_area: None, + show_commit_modal: false, + commit_subject: String::new(), + commit_message: String::new(), + commit_modal_focus: CommitModalFocus::BranchSelect, + commit_branch_options: Vec::new(), + commit_selected_branch_idx: 0, + commit_new_branch_name: String::new(), + commit_only_mode: false, + commit_files: Vec::new(), + commit_selected_file_idx: 0, + commit_selected_file_paths: HashSet::new(), + show_reword_modal: false, + reword_subject: String::new(), + reword_message: String::new(), + reword_modal_focus: RewordModalFocus::Subject, + reword_target: None, + show_uncommit_modal: false, + uncommit_target: None, + show_diff_modal: false, + diff_modal_files: Vec::new(), + diff_modal_selected_file: 0, + diff_modal_scroll: 0, + show_branch_rename_modal: false, + branch_rename_input: String::new(), + branch_rename_target: None, + show_absorb_modal: false, + absorb_summary: None, + restore_target: None, + show_squash_modal: false, + squash_target: None, + details_selected: false, + details_scroll: 0, + }; + + app.load_data_with_project(project)?; + app.command_log + .push("GitButler Lazy TUI started".to_string()); + + // Select first item in status list + let status_item_count = app.count_status_items(); + if status_item_count > 0 { + app.status_state.select(Some(0)); + } + if !app.oplog_entries.is_empty() { + app.oplog_state.select(Some(0)); + } + + app.update_main_view(); + Ok(app) + } + + pub(super) fn load_data_with_project(&mut self, project: &Project) -> Result<()> { + self.load_data(project.id)?; + + let command_context = Self::open_command_context(project); + + // Load upstream state information separately since it needs the project + self.command_log + .push("but_api::legacy::virtual_branches::get_base_branch_data()".to_string()); + self.upstream_info = but_api::legacy::virtual_branches::get_base_branch_data(project.id) + .ok() + .flatten() + .and_then(|base_branch| { + if base_branch.behind > 0 { + let ctx = command_context.as_ref()?; + let repo = ctx.gix_repo().ok()?; + let commit_obj = repo.find_commit(base_branch.current_sha.to_gix()).ok()?; + let commit = commit_obj.decode().ok()?; + let commit_message = commit + .message + .to_string() + .replace('\n', " ") + .chars() + .take(50) + .collect::(); + let formatted_date = commit.committer().time().ok()?.format_or_unix(DATE_ONLY); + + // Collect upstream commits from base_branch + let mut all_upstream_commits = Vec::new(); + for uc in &base_branch.upstream_commits { + let created_at = { + let seconds = (uc.created_at / 1000) as i64; + let dt = + chrono::DateTime::from_timestamp(seconds, 0).unwrap_or_default(); + dt.format("%Y-%m-%d %H:%M:%S").to_string() + }; + all_upstream_commits.push(UpstreamCommitInfo { + id: uc.id.to_string()[..7].to_string(), + full_id: uc.id.to_string(), + message: uc.description.to_string(), + author: uc.author.name.clone(), + created_at, + }); + } + + Some(UpstreamInfo { + behind_count: base_branch.behind, + latest_commit: base_branch.current_sha.to_string()[..7].to_string(), + message: commit_message, + commit_date: formatted_date, + last_fetched_ms: base_branch.last_fetched_ms, + commits: all_upstream_commits, + }) + } else { + None + } + }); + + if let Some(ctx) = command_context.as_ref() { + self.refresh_upstream_statuses(ctx); + } else { + self.upstream_integration_status = None; + } + + Ok(()) + } + + fn load_data(&mut self, project_id: ProjectId) -> Result<()> { + // Clear existing data + self.unassigned_files.clear(); + self.stacks.clear(); + self.oplog_entries.clear(); + + // Load unassigned files and stacks + self.command_log + .push("but_api::legacy::workspace::stacks()".to_string()); + let stacks = but_api::legacy::workspace::stacks(project_id, None)?; + + self.command_log + .push("but_api::legacy::diff::changes_in_worktree()".to_string()); + let worktree_changes = but_api::legacy::diff::changes_in_worktree(project_id)?; + + let mut by_file: BTreeMap> = BTreeMap::new(); + for assignment in worktree_changes.assignments { + by_file + .entry(assignment.path_bytes.clone()) + .or_default() + .push(assignment); + } + + let mut assignments_by_file: BTreeMap = BTreeMap::new(); + for (path, assignments) in &by_file { + assignments_by_file.insert( + path.clone(), + FileAssignment::from_assignments(path, assignments), + ); + } + + // Get unassigned files + self.unassigned_files = + crate::status::assignment::filter_by_stack_id(assignments_by_file.values(), &None); + + // Load stacks and branches + for stack in stacks { + self.command_log.push(format!( + "but_api::legacy::workspace::stack_details({:?})", + stack.id + )); + let details = but_api::legacy::workspace::stack_details(project_id, stack.id)?; + let assignments = crate::status::assignment::filter_by_stack_id( + assignments_by_file.values(), + &stack.id, + ); + + let stack_info = self.convert_stack_details(stack.id, details, assignments)?; + self.stacks.push(stack_info); + } + + // Load oplog entries + self.command_log + .push("but_api::legacy::oplog::list_snapshots()".to_string()); + let snapshots = but_api::legacy::oplog::list_snapshots(project_id, 50, None, None)?; + for snapshot in snapshots { + let operation = if let Some(details) = &snapshot.details { + match details.operation { + gitbutler_oplog::entry::OperationKind::CreateCommit => "CREATE", + gitbutler_oplog::entry::OperationKind::CreateBranch => "BRANCH", + gitbutler_oplog::entry::OperationKind::AmendCommit => "AMEND", + gitbutler_oplog::entry::OperationKind::UndoCommit => "UNDO", + gitbutler_oplog::entry::OperationKind::SquashCommit => "SQUASH", + gitbutler_oplog::entry::OperationKind::UpdateCommitMessage => "REWORD", + gitbutler_oplog::entry::OperationKind::MoveCommit => "MOVE", + gitbutler_oplog::entry::OperationKind::RestoreFromSnapshot => "RESTORE", + gitbutler_oplog::entry::OperationKind::ApplyBranch => "APPLY", + gitbutler_oplog::entry::OperationKind::UnapplyBranch => "UNAPPLY", + _ => "OTHER", + } + } else { + "UNKNOWN" + }; + + let time = snapshot.created_at.to_gix(); + let time_string = time + .format(gix::date::time::format::ISO8601) + .unwrap_or_else(|_| time.seconds.to_string()); + + let commit_id = snapshot.commit_id.to_string(); + let short_id = commit_id[..7].to_string(); + self.oplog_entries.push(OplogEntry { + id: short_id, + full_id: commit_id, + operation: operation.to_string(), + title: snapshot + .details + .as_ref() + .map(|d| d.title.clone()) + .unwrap_or_default(), + time: time_string, + }); + } + + Ok(()) + } + + fn convert_stack_details( + &self, + stack_id: Option, + details: StackDetails, + assignments: Vec, + ) -> Result { + let mut branches = Vec::new(); + + for (idx, branch) in details.branch_details.into_iter().enumerate() { + let commits = branch + .commits + .iter() + .map(|c| { + let message = c.message.to_str().unwrap_or(""); + + // Format dates + let author_date = { + let seconds = (c.created_at / 1000) as i64; + let dt = chrono::DateTime::from_timestamp(seconds, 0).unwrap_or_default(); + dt.format("%Y-%m-%d %H:%M:%S").to_string() + }; + + CommitInfo { + id: c.id.to_string()[..7].to_string(), + full_id: c.id.to_string(), + message: message.to_string(), + author: c.author.name.clone(), + author_email: c.author.email.clone(), + author_date: author_date.clone(), + committer: c.author.name.clone(), // We don't have separate committer info + committer_email: c.author.email.clone(), + committer_date: author_date, + state: c.state.clone(), + } + }) + .collect(); + + // Assign all stack files to the first branch (top of stack) + let branch_assignments = if idx == 0 { + assignments.clone() + } else { + Vec::new() + }; + + branches.push(BranchInfo { + name: branch.name.to_string(), + commits, + assignments: branch_assignments, + }); + } + + Ok(StackInfo { + id: stack_id, + name: details.derived_name, + branches, + }) + } + + pub(super) fn count_status_items(&self) -> usize { + let mut count = 0; + if !self.unassigned_files.is_empty() { + count += 1; // Header for unassigned files + count += self.unassigned_files.len(); + } + for stack in &self.stacks { + for branch in &stack.branches { + count += 1; // Branch header + count += branch.assignments.len(); // Assigned files + count += branch.commits.len(); // Commits + } + if !stack.branches.is_empty() { + count += 1; // Blank line between stacks + } + } + count + } + + pub(super) fn refresh(&mut self) -> Result<()> { + let project_id = self.project_id; + + // Store current selection indices + let status_idx = self.status_state.selected(); + let oplog_idx = self.oplog_state.selected(); + + // Reload data - need to load project to refresh upstream info + let project = gitbutler_project::get(project_id)?; + self.load_data_with_project(&project)?; + + // Restore selections if still valid + if let Some(idx) = status_idx { + let total_items = self.count_status_items(); + if idx < total_items { + self.status_state.select(Some(idx)); + } else if total_items > 0 { + self.status_state.select(Some(0)); + } + } + + if let Some(idx) = oplog_idx { + if idx < self.oplog_entries.len() { + self.oplog_state.select(Some(idx)); + } else if !self.oplog_entries.is_empty() { + self.oplog_state.select(Some(0)); + } + } + + self.update_main_view(); + self.command_log.push("Refreshed data".to_string()); + self.last_refresh = std::time::Instant::now(); + Ok(()) + } + + pub(super) fn fetch_upstream(&mut self) -> Result<()> { + self.command_log + .push("but_api::legacy::virtual_branches::fetch_from_remotes()".to_string()); + + match but_api::legacy::virtual_branches::fetch_from_remotes( + self.project_id, + Some("manual-fetch".to_string()), + ) { + Ok(base_branch) => { + if base_branch.behind > 0 { + self.command_log.push(format!( + "Fetch completed: {} new commits", + base_branch.behind + )); + } else { + self.command_log + .push("Fetch completed: up to date".to_string()); + } + + // Show fetch results in main view + self.main_view_content.clear(); + self.main_view_content.push(Line::from(vec![Span::styled( + "Fetch Results", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + )])); + self.main_view_content.push(Line::from("")); + + self.main_view_content.push(Line::from(vec![ + Span::styled("Remote: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!( + "{}/{}", + base_branch.remote_name, base_branch.branch_name + )), + ])); + self.main_view_content.push(Line::from("")); + + if base_branch.behind > 0 { + self.main_view_content.push(Line::from(vec![ + Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + format!("{} new commits available", base_branch.behind), + Style::default().fg(Color::Yellow), + ), + ])); + } else { + self.main_view_content.push(Line::from(vec![ + Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled("Up to date", Style::default().fg(Color::Green)), + ])); + } + self.main_view_content.push(Line::from("")); + + if base_branch.conflicted { + self.main_view_content.push(Line::from(vec![ + Span::styled("⚠ ", Style::default().fg(Color::Red)), + Span::styled("Conflicted with upstream", Style::default().fg(Color::Red)), + ])); + self.main_view_content.push(Line::from("")); + } + + if base_branch.diverged { + self.main_view_content.push(Line::from(vec![ + Span::styled("⚠ ", Style::default().fg(Color::Yellow)), + Span::styled("Diverged from upstream", Style::default().fg(Color::Yellow)), + ])); + self.main_view_content.push(Line::from(vec![ + Span::raw(" Ahead: "), + Span::styled( + format!("{} commits", base_branch.diverged_ahead.len()), + Style::default().fg(Color::Cyan), + ), + ])); + self.main_view_content.push(Line::from(vec![ + Span::raw(" Behind: "), + Span::styled( + format!("{} commits", base_branch.diverged_behind.len()), + Style::default().fg(Color::Cyan), + ), + ])); + self.main_view_content.push(Line::from("")); + } + + // Reload data to update the upstream panel and commit list + let project = gitbutler_project::get(self.project_id)?; + self.load_data_with_project(&project)?; + self.update_main_view(); + self.last_fetch = std::time::Instant::now(); + + Ok(()) + } + Err(e) => { + self.command_log.push(format!("Fetch error: {}", e)); + + // Show error in main view + self.main_view_content.clear(); + self.main_view_content.push(Line::from(vec![Span::styled( + "Fetch Failed", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )])); + self.main_view_content.push(Line::from("")); + self.main_view_content.push(Line::from(vec![ + Span::styled("Error: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(e.to_string()), + ])); + + Err(e) + } + } + } + + pub(super) fn active_panel_name(&self) -> &str { + match self.active_panel { + Panel::Upstream => "Upstream", + Panel::Status => "Status", + Panel::Oplog => "Oplog", + } + } + + pub(super) fn next_panel(&mut self) { + self.active_panel = match self.active_panel { + Panel::Upstream => Panel::Status, + Panel::Status => Panel::Oplog, + Panel::Oplog => { + if self.upstream_info.is_some() { + Panel::Upstream + } else { + Panel::Status + } + } + }; + } + + pub(super) fn prev_panel(&mut self) { + self.active_panel = match self.active_panel { + Panel::Upstream => Panel::Oplog, + Panel::Status => { + if self.upstream_info.is_some() { + Panel::Upstream + } else { + Panel::Oplog + } + } + Panel::Oplog => Panel::Status, + }; + } + + fn is_blank_line(&self, idx: usize) -> bool { + let mut current_idx = 0; + + // Check if unassigned files section has a blank line at this position + if !self.unassigned_files.is_empty() { + current_idx += 1; // Header + current_idx += self.unassigned_files.len(); + if current_idx == idx { + return true; // Blank line after unassigned files + } + current_idx += 1; // Blank line + } + + // Check stacks for blank lines + for stack in &self.stacks { + for branch in &stack.branches { + current_idx += 1; // Branch header + current_idx += branch.assignments.len(); // Assigned files + current_idx += branch.commits.len(); // Commits + } + // Blank line between stacks + if !stack.branches.is_empty() { + if current_idx == idx { + return true; // This is a blank line + } + current_idx += 1; + } + } + + false + } + + pub(super) fn select_next(&mut self) { + match self.active_panel { + Panel::Upstream => { + // No selection for upstream panel + } + Panel::Status => { + let total_items = self.count_status_items(); + if total_items > 0 { + let mut i = match self.status_state.selected() { + Some(i) => { + if i >= total_items - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + + // Skip blank lines when moving forward + let mut attempts = 0; + while self.is_blank_line(i) && attempts < total_items { + i = if i >= total_items - 1 { 0 } else { i + 1 }; + attempts += 1; + } + + self.status_state.select(Some(i)); + } + } + Panel::Oplog => { + if !self.oplog_entries.is_empty() { + let i = match self.oplog_state.selected() { + Some(i) => { + if i >= self.oplog_entries.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.oplog_state.select(Some(i)); + } + } + } + } + + pub(super) fn select_prev(&mut self) { + match self.active_panel { + Panel::Upstream => { + // No selection for upstream panel + } + Panel::Status => { + let total_items = self.count_status_items(); + if total_items > 0 { + let mut i = match self.status_state.selected() { + Some(i) => { + if i == 0 { + total_items - 1 + } else { + i - 1 + } + } + None => 0, + }; + + // Skip blank lines when moving backward + let mut attempts = 0; + while self.is_blank_line(i) && attempts < total_items { + i = if i == 0 { total_items - 1 } else { i - 1 }; + attempts += 1; + } + + self.status_state.select(Some(i)); + } + } + Panel::Oplog => { + if !self.oplog_entries.is_empty() { + let i = match self.oplog_state.selected() { + Some(i) => { + if i == 0 { + self.oplog_entries.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.oplog_state.select(Some(i)); + } + } + } + } + + fn branch_ranges(&self) -> Vec<(usize, usize)> { + let mut ranges = Vec::new(); + let mut idx = 0; + + if !self.unassigned_files.is_empty() { + idx += 1; // Header + idx += self.unassigned_files.len(); + idx += 1; // Blank line after unassigned files + } + + for stack in &self.stacks { + for branch in &stack.branches { + let start = idx; + idx += 1; // Branch header + idx += branch.assignments.len(); + idx += branch.commits.len(); + ranges.push((start, idx)); + } + if !stack.branches.is_empty() { + idx += 1; // Blank line between stacks + } + } + + ranges + } + + pub(super) fn select_next_branch(&mut self) -> bool { + self.select_branch(true) + } + + pub(super) fn select_prev_branch(&mut self) -> bool { + self.select_branch(false) + } + + fn select_branch(&mut self, forward: bool) -> bool { + let ranges = self.branch_ranges(); + if ranges.is_empty() { + return false; + } + + let selected_idx = self.status_state.selected().unwrap_or(ranges[0].0); + let current_branch_idx = ranges + .iter() + .position(|(start, end)| selected_idx >= *start && selected_idx < *end) + .unwrap_or_else(|| if forward { 0 } else { ranges.len() - 1 }); + + let target_idx = if forward { + (current_branch_idx + 1) % ranges.len() + } else if current_branch_idx == 0 { + ranges.len() - 1 + } else { + current_branch_idx - 1 + }; + + self.status_state.select(Some(ranges[target_idx].0)); + true + } + + pub(super) fn get_selected_file(&self) -> Option<&FileAssignment> { + let idx = self.status_state.selected()?; + let mut current_idx = 0; + + // Check unassigned files + if !self.unassigned_files.is_empty() { + current_idx += 1; // Header + for file in &self.unassigned_files { + if current_idx == idx { + return Some(file); + } + current_idx += 1; + } + current_idx += 1; // Blank line + } + + // Check stacks + for stack in &self.stacks { + for branch in &stack.branches { + current_idx += 1; // Branch header + + // Check assigned files + for file in &branch.assignments { + if current_idx == idx { + return Some(file); + } + current_idx += 1; + } + + // Skip commits + current_idx += branch.commits.len(); + } + current_idx += 1; // Blank line + } + + None + } + + pub(super) fn is_unassigned_header_selected(&self) -> bool { + if self.unassigned_files.is_empty() { + return false; + } + matches!(self.status_state.selected(), Some(0)) + } + + pub(super) fn summarize_unassigned_files(&self) -> (AbsorbSummary, Vec) { + let mut summary = AbsorbSummary::default(); + let mut stats = Vec::new(); + + for file in &self.unassigned_files { + summary.file_count += 1; + let mut file_additions = 0; + let mut file_removals = 0; + let mut file_hunks = 0; + + for assignment in &file.assignments { + file_hunks += 1; + if let Some(added) = &assignment.inner.line_nums_added { + let count = added.len(); + summary.total_additions += count; + file_additions += count; + } + if let Some(removed) = &assignment.inner.line_nums_removed { + let count = removed.len(); + summary.total_removals += count; + file_removals += count; + } + } + + summary.hunk_count += file_hunks; + stats.push(UnassignedFileStat { + path: file.path.to_string(), + additions: file_additions, + removals: file_removals, + }); + } + + (summary, stats) + } + + pub(super) fn get_selected_branch(&self) -> Option<&BranchInfo> { + let idx = self.status_state.selected()?; + let mut current_idx = 0; + + // Skip unassigned files + if !self.unassigned_files.is_empty() { + current_idx += 1; // Header + current_idx += self.unassigned_files.len(); + current_idx += 1; // Blank line + } + + // Check stacks + for stack in &self.stacks { + for branch in &stack.branches { + if current_idx == idx { + return Some(branch); + } + current_idx += 1; // Branch header + current_idx += branch.assignments.len(); // Skip assigned files + current_idx += branch.commits.len(); // Skip commits + } + current_idx += 1; // Blank line + } + + None + } + + pub(super) fn get_selected_commit(&self) -> Option<&CommitInfo> { + let idx = self.status_state.selected()?; + let mut current_idx = 0; + + // Skip unassigned files + if !self.unassigned_files.is_empty() { + current_idx += 1; // Header + current_idx += self.unassigned_files.len(); + current_idx += 1; // Blank line + } + + // Check stacks + for stack in &self.stacks { + for branch in &stack.branches { + current_idx += 1; // Branch header + current_idx += branch.assignments.len(); // Skip assigned files + + // Check commits + for commit in &branch.commits { + if current_idx == idx { + return Some(commit); + } + current_idx += 1; + } + } + current_idx += 1; // Blank line + } + + None + } + + pub(super) fn get_selected_oplog_entry(&self) -> Option<&OplogEntry> { + let idx = self.oplog_state.selected()?; + self.oplog_entries.get(idx) + } + + pub(super) fn get_selected_branch_context( + &self, + ) -> Option<(Option, &BranchInfo)> { + if let Some(branch) = self.get_selected_branch() { + if let Some(context) = + self.find_branch_context(|candidate| std::ptr::eq(candidate, branch)) + { + return Some(context); + } + } + + if let Some(commit) = self.get_selected_commit() { + if let Some(context) = self.find_branch_context(|branch| { + branch + .commits + .iter() + .any(|candidate| std::ptr::eq(candidate, commit)) + }) { + return Some(context); + } + } + + if let Some(file) = self.get_selected_file() { + if let Some(context) = self.find_branch_context(|branch| { + branch + .assignments + .iter() + .any(|candidate| std::ptr::eq(candidate, file)) + }) { + return Some(context); + } + } + + None + } + + pub(super) fn find_branch_context( + &self, + mut predicate: impl FnMut(&BranchInfo) -> bool, + ) -> Option<(Option, &BranchInfo)> { + for stack in &self.stacks { + for branch in &stack.branches { + if predicate(branch) { + return Some((stack.id, branch)); + } + } + } + None + } + + pub(super) fn get_commit_file_changes( + &self, + commit_sha: &str, + ) -> Result<(Vec, but_core::ui::TreeStats)> { + let oid = gix::ObjectId::from_hex(commit_sha.as_bytes())?; + let commit_id = HexHash::from(oid); + let commit_details = but_api::legacy::diff::commit_details(self.project_id, commit_id)?; + Ok((commit_details.changes.changes, commit_details.changes.stats)) + } + + fn status_letter(status: &but_core::ui::TreeStatus) -> char { + match status { + but_core::ui::TreeStatus::Addition { .. } => 'A', + but_core::ui::TreeStatus::Deletion { .. } => 'D', + but_core::ui::TreeStatus::Modification { .. } => 'M', + but_core::ui::TreeStatus::Rename { .. } => 'R', + } + } + + fn status_colors(status: &but_core::ui::TreeStatus) -> (Color, Color) { + // Returns (path_color, status_letter_color) + match status { + but_core::ui::TreeStatus::Addition { .. } => (Color::Green, Color::Green), + but_core::ui::TreeStatus::Deletion { .. } => (Color::Red, Color::Red), + but_core::ui::TreeStatus::Modification { .. } => (Color::Yellow, Color::Yellow), + but_core::ui::TreeStatus::Rename { .. } => (Color::Magenta, Color::Magenta), + } + } + + fn describe_branch_status(status: &UpstreamBranchStatus) -> (&'static str, String, Color) { + match status { + UpstreamBranchStatus::SaflyUpdatable => { + ("✓", "Will rebase cleanly".to_string(), Color::Green) + } + UpstreamBranchStatus::Integrated => { + ("↺", "Already integrated".to_string(), Color::Blue) + } + UpstreamBranchStatus::Conflicted { rebasable } => { + if *rebasable { + ( + "⚠", + "Conflicts expected (rebasable)".to_string(), + Color::Yellow, + ) + } else { + ( + "✖", + "Will conflict (manual merge required)".to_string(), + Color::Red, + ) + } + } + UpstreamBranchStatus::Empty => { + ("•", "No changes to apply".to_string(), Color::DarkGray) + } + } + } + + pub(super) fn update_main_view(&mut self) { + self.main_view_content.clear(); + + match self.active_panel { + Panel::Status => { + // Check if a branch is selected first + if let Some(branch) = self.get_selected_branch().cloned() { + // Branch name header + self.main_view_content.push(Line::from(vec![ + Span::styled("Branch: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + branch.name.clone(), + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD), + ), + ])); + self.main_view_content.push(Line::from("")); + + // Collect unique authors from commits + let mut authors: std::collections::HashSet = + std::collections::HashSet::new(); + for commit in &branch.commits { + authors.insert(commit.author.clone()); + } + + // Display authors section + if !authors.is_empty() { + self.main_view_content.push(Line::from(vec![Span::styled( + "Authors:", + Style::default().add_modifier(Modifier::BOLD), + )])); + let mut authors_vec: Vec<_> = authors.into_iter().collect(); + authors_vec.sort(); + for author in authors_vec { + self.main_view_content.push(Line::from(vec![ + Span::raw(" • "), + Span::styled(author, Style::default().fg(Color::Yellow)), + ])); + } + self.main_view_content.push(Line::from("")); + } + + // Display commits section + if !branch.commits.is_empty() { + self.main_view_content.push(Line::from(vec![Span::styled( + "Commits:", + Style::default().add_modifier(Modifier::BOLD), + )])); + self.main_view_content.push(Line::from("")); + + for commit in &branch.commits { + // Commit header with SHA + let (dot_symbol, dot_color) = match &commit.state { + but_workspace::ui::CommitState::LocalOnly => ("●", Color::White), + but_workspace::ui::CommitState::LocalAndRemote(object_id) => { + if object_id.to_string() == commit.full_id { + ("●", Color::Green) + } else { + ("◐", Color::Green) + } + } + but_workspace::ui::CommitState::Integrated => ("●", Color::Magenta), + }; + + self.main_view_content.push(Line::from(vec![ + Span::raw(" "), + Span::styled(dot_symbol, Style::default().fg(dot_color)), + Span::raw(" "), + Span::styled(commit.id.clone(), Style::default().fg(Color::Green)), + Span::raw(" "), + Span::styled( + commit.author.clone(), + Style::default().fg(Color::Cyan), + ), + Span::raw(" "), + Span::styled( + commit.author_date.clone(), + Style::default().fg(Color::DarkGray), + ), + ])); + + // Commit message (indented) + let message_first_line = + commit.message.lines().next().unwrap_or("").to_string(); + self.main_view_content.push(Line::from(vec![ + Span::raw(" "), + Span::raw(message_first_line), + ])); + + // Get file changes for this commit + if let Ok((changes, _)) = self.get_commit_file_changes(&commit.full_id) + { + // Show files modified (indented further) + for change in changes.iter().take(5) { + let status_char = Self::status_letter(&change.status); + let (path_color, status_color) = + Self::status_colors(&change.status); + + self.main_view_content.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("{} ", status_char), + Style::default() + .fg(status_color) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + change.path.to_string(), + Style::default().fg(path_color), + ), + ])); + } + if changes.len() > 5 { + self.main_view_content.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("... {} more files", changes.len() - 5), + Style::default().fg(Color::DarkGray), + ), + ])); + } + } + + self.main_view_content.push(Line::from("")); + } + } else { + self.main_view_content.push(Line::from(vec![Span::styled( + "No commits in this branch", + Style::default().fg(Color::DarkGray), + )])); + } + } else if let Some(commit) = self.get_selected_commit().cloned() { + // Commit SHA with bold label and green SHA + self.main_view_content.push(Line::from(vec![ + Span::styled("Commit: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(commit.full_id.clone(), Style::default().fg(Color::Green)), + ])); + self.main_view_content.push(Line::from("")); + + // Author with bold label, yellow name, purple email + self.main_view_content.push(Line::from(vec![ + Span::styled("Author: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(commit.author.clone(), Style::default().fg(Color::Yellow)), + Span::raw(" <"), + Span::styled( + commit.author_email.clone(), + Style::default().fg(Color::Magenta), + ), + Span::raw(">"), + ])); + + // Author date with bold label and blue date + self.main_view_content.push(Line::from(vec![ + Span::styled("Date: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(commit.author_date.clone(), Style::default().fg(Color::Blue)), + ])); + self.main_view_content.push(Line::from("")); + + // Committer with bold label, yellow name, purple email + self.main_view_content.push(Line::from(vec![ + Span::styled("Committer: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(commit.committer.clone(), Style::default().fg(Color::Yellow)), + Span::raw(" <"), + Span::styled( + commit.committer_email.clone(), + Style::default().fg(Color::Magenta), + ), + Span::raw(">"), + ])); + + // Committer date with bold label and blue date + self.main_view_content.push(Line::from(vec![ + Span::styled("Date: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + commit.committer_date.clone(), + Style::default().fg(Color::Blue), + ), + ])); + self.main_view_content.push(Line::from("")); + + // Message with bold label + self.main_view_content.push(Line::from(vec![Span::styled( + "Message:", + Style::default().add_modifier(Modifier::BOLD), + )])); + for line in commit.message.lines() { + self.main_view_content + .push(Line::from(format!(" {}", line))); + } + self.main_view_content.push(Line::from("")); + + // Get commit details to show file changes + self.command_log.push(format!( + "but_api::legacy::diff::commit_details({:?})", + commit.full_id + )); + + match self.get_commit_file_changes(&commit.full_id) { + Ok((changes, stats)) => { + // Statistics header + self.main_view_content.push(Line::from(vec![ + Span::styled( + "Changes: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{} files changed", stats.files_changed), + Style::default().fg(Color::Cyan), + ), + Span::raw(", "), + Span::styled( + format!("+{}", stats.lines_added), + Style::default().fg(Color::Green), + ), + Span::raw(", "), + Span::styled( + format!("-{}", stats.lines_removed), + Style::default().fg(Color::Red), + ), + ])); + self.main_view_content.push(Line::from("")); + + // File list with status + self.main_view_content.push(Line::from(vec![Span::styled( + "Files:", + Style::default().add_modifier(Modifier::BOLD), + )])); + + for change in changes { + let status_char = Self::status_letter(&change.status); + let (path_color, status_color) = + Self::status_colors(&change.status); + + self.main_view_content.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("{} ", status_char), + Style::default() + .fg(status_color) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + change.path.to_string(), + Style::default().fg(path_color), + ), + ])); + } + } + Err(e) => { + self.main_view_content.push(Line::from(vec![ + Span::styled( + "Error loading file changes: ", + Style::default().fg(Color::Red), + ), + Span::raw(e.to_string()), + ])); + } + } + } else if self.is_unassigned_header_selected() { + let (summary, stats) = self.summarize_unassigned_files(); + + if summary.file_count == 0 { + self.main_view_content.push(Line::from(vec![Span::styled( + "No unassigned files", + Style::default().fg(Color::DarkGray), + )])); + } else { + self.main_view_content.push(Line::from(vec![Span::styled( + "Unassigned Files", + Style::default().add_modifier(Modifier::BOLD), + )])); + self.main_view_content.push(Line::from(vec![ + Span::styled( + format!("{} files", summary.file_count), + Style::default().fg(Color::Cyan), + ), + Span::raw(" • "), + Span::styled( + format!("{} hunks", summary.hunk_count), + Style::default().fg(Color::Yellow), + ), + Span::raw(" • "), + Span::styled( + format!("+{}", summary.total_additions), + Style::default().fg(Color::Green), + ), + Span::raw(" "), + Span::styled( + format!("-{}", summary.total_removals), + Style::default().fg(Color::Red), + ), + ])); + self.main_view_content.push(Line::from("")); + + for stat in stats { + self.main_view_content.push(Line::from(vec![ + Span::styled( + format!("+{}", stat.additions), + Style::default().fg(Color::Green), + ), + Span::raw(" "), + Span::styled( + format!("-{}", stat.removals), + Style::default().fg(Color::Red), + ), + Span::raw(" "), + Span::styled(stat.path.clone(), Style::default().fg(Color::Yellow)), + ])); + } + } + } else if let Some(file) = self.get_selected_file().cloned() { + // Show file diff with bold file path + self.main_view_content.push(Line::from(vec![ + Span::raw("File: "), + Span::styled( + file.path.to_string(), + Style::default().add_modifier(Modifier::BOLD), + ), + ])); + self.main_view_content.push(Line::from("")); + + for assignment in &file.assignments { + let hunk_header = assignment + .inner + .hunk_header + .as_ref() + .map(|h| { + format!( + "@@ -{},{} +{},{} @@", + h.old_start, h.old_lines, h.new_start, h.new_lines + ) + }) + .unwrap_or_else(|| "(no hunk info)".to_string()); + + // Style hunk header in cyan + self.main_view_content.push(Line::from(vec![Span::styled( + hunk_header, + Style::default().fg(Color::Cyan), + )])); + + // Show the diff lines with syntax highlighting + if let Some(diff) = &assignment.inner.diff { + for line in diff.lines() { + let line_str = String::from_utf8_lossy(line); + + // Color diff lines based on their prefix + let styled_line = if line_str.starts_with('+') { + Line::from(vec![Span::styled( + line_str.to_string(), + Style::default().fg(Color::Green), + )]) + } else if line_str.starts_with('-') { + Line::from(vec![Span::styled( + line_str.to_string(), + Style::default().fg(Color::Red), + )]) + } else if line_str.starts_with("@@") { + Line::from(vec![Span::styled( + line_str.to_string(), + Style::default().fg(Color::Cyan), + )]) + } else { + Line::from(line_str.to_string()) + }; + + self.main_view_content.push(styled_line); + } + } + self.main_view_content.push(Line::from("")); + } + } else { + self.main_view_content + .push(Line::from("Select a file or commit to view details")); + } + } + Panel::Upstream => { + if let Some(upstream) = &self.upstream_info { + self.main_view_content.push(Line::from(vec![Span::styled( + "Upstream Commits", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )])); + self.main_view_content.push(Line::from("")); + + for commit in &upstream.commits { + self.main_view_content.push(Line::from(vec![ + Span::styled("●", Style::default().fg(Color::Yellow)), + Span::raw(" "), + Span::styled(commit.id.clone(), Style::default().fg(Color::Yellow)), + Span::raw(" "), + Span::styled(commit.author.clone(), Style::default().fg(Color::Cyan)), + Span::raw(" "), + Span::styled( + commit.created_at.clone(), + Style::default().fg(Color::DarkGray), + ), + ])); + self.main_view_content.push(Line::from(vec![ + Span::raw(" "), + Span::raw(commit.message.clone()), + ])); + self.main_view_content.push(Line::from("")); + } + } + + if let Some(statuses) = &self.upstream_integration_status { + self.main_view_content.push(Line::from(vec![Span::styled( + "Local Branch Status", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )])); + self.main_view_content.push(Line::from("")); + + match statuses { + StackStatuses::UpToDate => { + self.main_view_content.push(Line::from(vec![Span::styled( + "All applied branches are up to date", + Style::default().fg(Color::Green), + )])); + } + StackStatuses::UpdatesRequired { + worktree_conflicts, + statuses, + } => { + if !worktree_conflicts.is_empty() { + self.main_view_content.push(Line::from(vec![Span::styled( + "Uncommitted worktree changes may conflict with updates", + Style::default().fg(Color::Red), + )])); + self.main_view_content.push(Line::from("")); + } + + if statuses.is_empty() { + self.main_view_content.push(Line::from(vec![Span::styled( + "No active branches require updates", + Style::default().fg(Color::DarkGray), + )])); + } + + for (maybe_stack_id, stack_status) in statuses { + let stack_name = maybe_stack_id + .and_then(|id| { + self.stacks + .iter() + .find(|stack| stack.id == Some(id)) + .map(|stack| stack.name.clone()) + }) + .unwrap_or_else(|| "Workspace".to_string()); + + self.main_view_content.push(Line::from(vec![Span::styled( + format!("Stack: {}", stack_name), + Style::default().add_modifier(Modifier::BOLD), + )])); + + for branch_status in &stack_status.branch_statuses { + let (icon, label, color) = + Self::describe_branch_status(&branch_status.status); + self.main_view_content.push(Line::from(vec![ + Span::raw(" "), + Span::styled(icon, Style::default().fg(color)), + Span::raw(" "), + Span::styled( + branch_status.name.clone(), + Style::default().fg(Color::White), + ), + Span::raw(": "), + Span::styled(label, Style::default().fg(color)), + ])); + } + + self.main_view_content.push(Line::from("")); + } + } + } + } + } + Panel::Oplog => { + if let Some(idx) = self.oplog_state.selected() { + if let Some(entry) = self.oplog_entries.get(idx) { + self.main_view_content + .push(Line::from(format!("Oplog Entry: {}", entry.id))); + self.main_view_content.push(Line::from("")); + self.main_view_content + .push(Line::from(format!("Operation: {}", entry.operation))); + self.main_view_content + .push(Line::from(format!("Title: {}", entry.title))); + self.main_view_content + .push(Line::from(format!("Time: {}", entry.time))); + self.main_view_content.push(Line::from("")); + self.main_view_content.push(Line::from(vec![ + Span::styled( + "Press 'r'", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw( + " to restore your workspace to this snapshot. This will overwrite", + ), + ])); + self.main_view_content.push(Line::from( + "current worktree changes, so make sure everything important is saved.", + )); + } + } + } + } + } + + pub(super) fn get_details_title(&self) -> String { + match self.active_panel { + Panel::Status => { + if self.get_selected_branch().is_some() { + "Branch Details".to_string() + } else if self.get_selected_commit().is_some() { + "Commit Details".to_string() + } else if self.is_unassigned_header_selected() { + "Unassigned Files".to_string() + } else if self.get_selected_file().is_some() { + "File Changes".to_string() + } else { + "Details".to_string() + } + } + Panel::Upstream => "Upstream Commits".to_string(), + Panel::Oplog => "Oplog Entry".to_string(), + } + } + + fn open_command_context(project: &Project) -> Option { + let settings = AppSettings::load_from_default_path_creating().ok()?; + CommandContext::open(project, settings).ok() + } + + fn refresh_upstream_statuses(&mut self, ctx: &CommandContext) { + let review_map: HashMap = HashMap::new(); + match upstream_integration_statuses(ctx, None, &review_map) { + Ok(statuses) => { + self.upstream_integration_status = Some(statuses); + } + Err(e) => { + self.command_log + .push(format!("Failed to compute upstream status: {}", e)); + self.upstream_integration_status = None; + } + } + } + + pub(super) fn perform_upstream_update(&mut self) -> Result<()> { + let project = gitbutler_project::get(self.project_id)?; + let ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + let review_map: HashMap = HashMap::new(); + let status = upstream_integration_statuses(&ctx, None, &review_map)?; + + match status { + StackStatuses::UpToDate => { + self.command_log + .push("Branches are already up to date".to_string()); + } + StackStatuses::UpdatesRequired { + worktree_conflicts, + statuses, + } => { + if !worktree_conflicts.is_empty() { + self.command_log.push( + "Cannot update: uncommitted worktree changes would conflict".to_string(), + ); + return Err(anyhow!("Worktree conflicts prevent update")); + } + + let mut resolutions = Vec::new(); + for (maybe_stack_id, stack_status) in statuses { + let Some(stack_id) = maybe_stack_id else { + self.command_log + .push("Skipping stack without identifier during update".to_string()); + continue; + }; + let all_integrated = stack_status + .branch_statuses + .iter() + .all(|s| matches!(s.status, UpstreamBranchStatus::Integrated)); + let approach = if all_integrated + && stack_status.tree_status != gitbutler_branch_actions::upstream_integration::TreeStatus::Conflicted + { + ResolutionApproach::Delete + } else { + ResolutionApproach::Rebase + }; + resolutions.push(Resolution { + stack_id, + approach, + delete_integrated_branches: true, + }); + } + + if resolutions.is_empty() { + self.command_log + .push("No branches require updating".to_string()); + return Ok(()); + } + + integrate_upstream(&ctx, &resolutions, None, &review_map)?; + self.command_log + .push("Updated applied branches from upstream".to_string()); + + self.load_data_with_project(&project)?; + } + } + + Ok(()) + } + + pub(super) fn open_upstream_update_modal(&mut self) { + if !matches!(self.active_panel, Panel::Upstream) { + self.command_log + .push("Switch to the Upstream panel to update branches".to_string()); + return; + } + + self.show_update_modal = true; + self.command_log + .push("Preparing to rebase applied branches onto upstream".to_string()); + } +} + +pub fn run(project: &Project) -> Result<()> { + // Setup terminal + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + // Create app + let mut app = LazyApp::new(project)?; + + // Run main loop + let res = run_app(&mut terminal, &mut app); + + // Restore terminal + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = res { + eprintln!("Error: {:?}", err); + } + + Ok(()) +} + +fn run_app( + terminal: &mut Terminal, + app: &mut LazyApp, +) -> Result<()> { + loop { + terminal.draw(|f| ui(f, app))?; + + if app.should_quit { + break; + } + + // Check if we need to auto-refresh (every 10 seconds) + if app.last_refresh.elapsed() >= Duration::from_secs(10) { + if let Ok(project) = gitbutler_project::get(app.project_id) { + let _ = app.load_data_with_project(&project); + app.update_main_view(); + app.last_refresh = std::time::Instant::now(); + } + } + + // Check if we need to auto-fetch (every 5 minutes) + if app.last_fetch.elapsed() >= Duration::from_secs(300) { + app.command_log + .push("but_api::legacy::virtual_branches::fetch_from_remotes() [auto]".to_string()); + match but_api::legacy::virtual_branches::fetch_from_remotes( + app.project_id, + Some("auto-refresh".to_string()), + ) { + Ok(base_branch) => { + if base_branch.behind > 0 { + app.command_log.push(format!( + "Auto-fetch completed: {} new commits", + base_branch.behind + )); + } else { + app.command_log + .push("Auto-fetch completed: up to date".to_string()); + } + // Reload data after fetch to show new upstream commits + if let Ok(project) = gitbutler_project::get(app.project_id) { + let _ = app.load_data_with_project(&project); + app.update_main_view(); + } + } + Err(e) => { + app.command_log.push(format!("Auto-fetch failed: {}", e)); + } + } + app.last_fetch = std::time::Instant::now(); + } + + if event::poll(Duration::from_millis(100))? { + match event::read()? { + Event::Key(key) => { + app.handle_input(key.code, key.modifiers); + } + Event::Mouse(mouse) => { + app.handle_mouse(mouse); + } + _ => {} + } + } + } + + Ok(()) +} diff --git a/crates/but/src/lazy/branch_rename.rs b/crates/but/src/lazy/branch_rename.rs new file mode 100644 index 0000000000..359da88127 --- /dev/null +++ b/crates/but/src/lazy/branch_rename.rs @@ -0,0 +1,88 @@ +use but_api::legacy::stack; + +use super::app::{BranchRenameTarget, LazyApp, Panel}; + +impl LazyApp { + pub(super) fn open_branch_rename_modal(&mut self) { + if !matches!(self.active_panel, Panel::Status) { + self.command_log + .push("Select a branch in the Status panel to rename".to_string()); + return; + } + + let Some((Some(stack_id), branch_name)) = self + .get_selected_branch_context() + .map(|(stack_id, branch)| (stack_id, branch.name.clone())) + else { + self.command_log + .push("No branch selected to rename".to_string()); + return; + }; + + self.branch_rename_input = branch_name.clone(); + self.branch_rename_target = Some(BranchRenameTarget { + stack_id, + current_name: branch_name.clone(), + }); + self.show_branch_rename_modal = true; + self.command_log + .push(format!("Renaming branch '{}'", branch_name)); + } + + pub(super) fn cancel_branch_rename_modal(&mut self) { + self.reset_branch_rename_modal_state(); + self.command_log.push("Branch rename canceled".to_string()); + } + + pub(super) fn submit_branch_rename_modal(&mut self) { + match self.perform_branch_rename() { + Ok(true) => self.reset_branch_rename_modal_state(), + Ok(false) => {} + Err(e) => { + self.command_log + .push(format!("Branch rename failed: {}", e)); + } + } + } + + fn perform_branch_rename(&mut self) -> anyhow::Result { + let Some(target) = self.branch_rename_target.as_ref() else { + return Ok(false); + }; + + let new_name = self.branch_rename_input.trim(); + if new_name.is_empty() { + self.command_log + .push("Branch name cannot be empty".to_string()); + return Ok(false); + } + + if new_name == target.current_name { + self.command_log.push("Branch name unchanged".to_string()); + return Ok(false); + } + + stack::update_branch_name( + self.project_id, + target.stack_id, + target.current_name.clone(), + new_name.to_string(), + )?; + + self.command_log.push(format!( + "Renamed branch '{}' to '{}'", + target.current_name, new_name + )); + + let project = gitbutler_project::get(self.project_id)?; + self.load_data_with_project(&project)?; + self.update_main_view(); + Ok(true) + } + + fn reset_branch_rename_modal_state(&mut self) { + self.show_branch_rename_modal = false; + self.branch_rename_input.clear(); + self.branch_rename_target = None; + } +} diff --git a/crates/but/src/lazy/commit.rs b/crates/but/src/lazy/commit.rs new file mode 100644 index 0000000000..111444d906 --- /dev/null +++ b/crates/but/src/lazy/commit.rs @@ -0,0 +1,398 @@ +use std::collections::{BTreeMap, HashSet}; + +use anyhow::{Result, anyhow, bail}; +use bstr::{BString, ByteSlice}; +use but_api::{ + json::HexHash, + legacy::{diff, stack, workspace}, +}; +use but_core::{DiffSpec, HunkHeader, ref_metadata::StackId}; +use but_hunk_assignment::HunkAssignment; +use but_workspace::ui::StackDetails; +use gitbutler_project::Project; + +use crate::status::assignment::FileAssignment; + +use super::app::{CommitBranchOption, CommitFileOption, CommitModalFocus, LazyApp}; + +impl LazyApp { + pub(super) fn open_commit_modal(&mut self) { + self.reset_commit_modal_state(); + + for stack in &self.stacks { + if let Some(stack_id) = stack.id { + for branch in &stack.branches { + self.commit_branch_options.push(CommitBranchOption { + stack_id: Some(stack_id), + branch_name: branch.name.clone(), + is_new_branch: false, + }); + } + } + } + + let canned_name = but_api::legacy::workspace::canned_branch_name(self.project_id) + .unwrap_or_else(|_| "new-branch".to_string()); + + self.commit_branch_options.push(CommitBranchOption { + stack_id: None, + branch_name: format!("New: {}", canned_name), + is_new_branch: true, + }); + + self.commit_new_branch_name = canned_name; + + if let Some((stack_id, selected_branch)) = self.get_selected_branch_context() { + let stack_id_ref = stack_id.as_ref(); + let mut preferred_idx = None; + + if stack_id_ref.is_some() { + preferred_idx = self.commit_branch_options.iter().position(|opt| { + !opt.is_new_branch + && opt.stack_id.as_ref() == stack_id_ref + && opt.branch_name == selected_branch.name + }); + + if preferred_idx.is_none() { + preferred_idx = self.commit_branch_options.iter().position(|opt| { + !opt.is_new_branch && opt.stack_id.as_ref() == stack_id_ref + }); + } + } + + if preferred_idx.is_none() { + preferred_idx = self + .commit_branch_options + .iter() + .position(|opt| !opt.is_new_branch && opt.branch_name == selected_branch.name); + } + + if let Some(idx) = preferred_idx { + self.commit_selected_branch_idx = idx; + } + } + + self.show_commit_modal = true; + self.commit_modal_focus = CommitModalFocus::BranchSelect; + self.rebuild_commit_file_list(); + self.command_log.push("Opened commit modal".to_string()); + } + + pub(super) fn reset_commit_modal_state(&mut self) { + self.commit_subject.clear(); + self.commit_message.clear(); + self.commit_new_branch_name.clear(); + self.commit_only_mode = false; + self.commit_branch_options.clear(); + self.commit_selected_branch_idx = 0; + self.commit_modal_focus = CommitModalFocus::BranchSelect; + self.commit_files.clear(); + self.commit_selected_file_idx = 0; + self.commit_selected_file_paths.clear(); + } + + pub(super) fn dismiss_commit_modal(&mut self) { + self.reset_commit_modal_state(); + self.show_commit_modal = false; + } + + pub(super) fn cancel_commit_modal(&mut self) { + self.dismiss_commit_modal(); + self.command_log.push("Canceled commit".to_string()); + } + + fn perform_commit(&mut self) -> Result<()> { + let full_message = if self.commit_message.is_empty() { + self.commit_subject.clone() + } else { + format!("{}\n\n{}", self.commit_subject, self.commit_message) + }; + + if full_message.trim().is_empty() { + self.command_log + .push("Commit failed: empty message".to_string()); + return Ok(()); + } + + if self.commit_selected_file_paths.is_empty() { + self.command_log + .push("Commit failed: no files selected".to_string()); + return Err(anyhow!("No files selected")); + } + + let selected = &self.commit_branch_options[self.commit_selected_branch_idx]; + let branch_name = if selected.is_new_branch { + self.commit_new_branch_name.clone() + } else { + selected.branch_name.clone() + }; + + self.command_log.push(format!( + "workspace commit: branch={}, only_mode={}, new_branch={}", + branch_name, self.commit_only_mode, selected.is_new_branch + )); + + let project = gitbutler_project::get(self.project_id)?; + match self.execute_lazy_commit( + &project, + full_message, + branch_name, + selected.stack_id, + selected.is_new_branch, + ) { + Ok(_) => Ok(()), + Err(e) => { + self.command_log.push(format!("Commit error: {}", e)); + Err(e) + } + } + } + + pub(super) fn submit_commit_modal(&mut self) { + if let Err(e) = self.perform_commit() { + self.command_log.push(format!("Commit failed: {}", e)); + } else { + self.dismiss_commit_modal(); + } + } + + fn execute_lazy_commit( + &mut self, + project: &Project, + commit_message: String, + branch_name: String, + stack_id_hint: Option, + create_branch: bool, + ) -> Result<()> { + let (target_stack_id, stack_details) = + self.resolve_target_stack(project, stack_id_hint, &branch_name, create_branch)?; + + let target_branch = stack_details + .branch_details + .iter() + .find(|branch| branch.name.to_str_lossy() == branch_name) + .ok_or_else(|| anyhow!("Branch '{}' not found in stack", branch_name))?; + + let files_to_commit = self.collect_files_to_commit(target_stack_id)?; + + if files_to_commit.is_empty() { + bail!("No changes to commit"); + } + + let diff_specs = Self::files_to_diff_specs(&files_to_commit); + + self.command_log.push(format!( + "workspace::create_commit_from_worktree_changes(stack: {}, branch: {})", + target_stack_id, branch_name + )); + + workspace::create_commit_from_worktree_changes( + self.project_id, + target_stack_id, + Some(HexHash::from(target_branch.tip)), + diff_specs, + commit_message, + branch_name.clone(), + )?; + + self.command_log + .push(format!("Commit created successfully on '{}'", branch_name)); + + self.load_data_with_project(project)?; + self.update_main_view(); + Ok(()) + } + + fn resolve_target_stack( + &mut self, + project: &Project, + stack_id_hint: Option, + branch_name: &str, + create_branch: bool, + ) -> Result<(StackId, StackDetails)> { + if create_branch { + self.command_log.push(format!( + "but_api::legacy::stack::create_reference(new_name={})", + branch_name + )); + let (new_stack_id_opt, _) = stack::create_reference( + project.id, + stack::create_reference::Request { + new_name: branch_name.to_string(), + anchor: None, + }, + )?; + let new_stack_id = new_stack_id_opt + .ok_or_else(|| anyhow!("Failed to create new branch '{}'", branch_name))?; + self.command_log.push(format!( + "but_api::legacy::workspace::stack_details({:?})", + new_stack_id + )); + let details = workspace::stack_details(project.id, Some(new_stack_id))?; + return Ok((new_stack_id, details)); + } + + let stack_id = stack_id_hint + .ok_or_else(|| anyhow!("Missing stack ID for branch '{}'", branch_name))?; + self.command_log.push(format!( + "but_api::legacy::workspace::stack_details({:?})", + stack_id + )); + let details = workspace::stack_details(project.id, Some(stack_id))?; + Ok((stack_id, details)) + } + + fn collect_files_to_commit(&mut self, stack_id: StackId) -> Result> { + self.command_log + .push("but_api::legacy::diff::changes_in_worktree()".to_string()); + let worktree_changes = diff::changes_in_worktree(self.project_id)?; + let assignments_by_file = Self::group_assignments_by_file(worktree_changes.assignments); + + let mut files_to_commit = Vec::new(); + if !self.commit_only_mode { + let unassigned = + crate::status::assignment::filter_by_stack_id(assignments_by_file.values(), &None); + files_to_commit.extend(unassigned); + } + + let stack_assignments = crate::status::assignment::filter_by_stack_id( + assignments_by_file.values(), + &Some(stack_id), + ); + files_to_commit.extend(stack_assignments); + + if !self.commit_selected_file_paths.is_empty() { + files_to_commit.retain(|file| { + let key = Self::file_key_from_path(&file.path); + self.commit_selected_file_paths.contains(&key) + }); + } + + Ok(files_to_commit) + } + + fn group_assignments_by_file( + assignments: Vec, + ) -> BTreeMap { + let mut by_file: BTreeMap> = BTreeMap::new(); + for assignment in assignments { + by_file + .entry(assignment.path_bytes.clone()) + .or_default() + .push(assignment); + } + + let mut assignments_by_file = BTreeMap::new(); + for (path, grouped) in by_file { + assignments_by_file.insert( + path.clone(), + FileAssignment::from_assignments(&path, &grouped), + ); + } + + assignments_by_file + } + + pub(super) fn rebuild_commit_file_list(&mut self) { + let stack_id = self + .commit_branch_options + .get(self.commit_selected_branch_idx) + .and_then(|opt| opt.stack_id); + let files = self.gather_commit_candidate_files(stack_id); + + let mut retained = HashSet::new(); + for file in &files { + let key = Self::file_key_from_path(&file.path); + if self.commit_selected_file_paths.contains(&key) { + retained.insert(key); + } + } + + if retained.is_empty() { + for file in &files { + retained.insert(Self::file_key_from_path(&file.path)); + } + } + + self.commit_selected_file_paths = retained; + self.commit_files = files + .into_iter() + .map(|file| CommitFileOption { file }) + .collect(); + + if self.commit_selected_file_idx >= self.commit_files.len() { + self.commit_selected_file_idx = self.commit_files.len().saturating_sub(1); + } + } + + fn gather_commit_candidate_files(&self, stack_id: Option) -> Vec { + let mut files = Vec::new(); + if !self.commit_only_mode { + files.extend(self.unassigned_files.iter().cloned()); + } + + if let Some(stack_id) = stack_id { + if let Some(stack) = self.stacks.iter().find(|s| s.id == Some(stack_id)) { + for branch in &stack.branches { + files.extend(branch.assignments.iter().cloned()); + } + } + } + + files + } + + pub(super) fn move_commit_file_cursor(&mut self, direction: i32) { + if self.commit_files.is_empty() { + return; + } + if direction > 0 { + if self.commit_selected_file_idx + 1 >= self.commit_files.len() { + self.commit_selected_file_idx = 0; + } else { + self.commit_selected_file_idx += 1; + } + } else if self.commit_selected_file_idx == 0 { + self.commit_selected_file_idx = self.commit_files.len() - 1; + } else { + self.commit_selected_file_idx -= 1; + } + } + + pub(super) fn toggle_current_commit_file(&mut self) { + if let Some(entry) = self.commit_files.get(self.commit_selected_file_idx) { + let key = Self::file_key_from_path(&entry.file.path); + if self.commit_selected_file_paths.contains(&key) { + self.commit_selected_file_paths.remove(&key); + if self.commit_selected_file_paths.is_empty() { + self.commit_selected_file_paths.insert(key); + } + } else { + self.commit_selected_file_paths.insert(key); + } + } + } + + fn file_key_from_path(path: &BString) -> String { + path.to_str_lossy().into_owned() + } + + fn files_to_diff_specs(files: &[FileAssignment]) -> Vec { + files + .iter() + .map(|fa| { + let hunk_headers: Vec = fa + .assignments + .iter() + .filter_map(|assignment| assignment.inner.hunk_header) + .collect(); + + DiffSpec { + previous_path: None, + path: fa.path.clone(), + hunk_headers, + } + }) + .collect() + } +} diff --git a/crates/but/src/lazy/diff.rs b/crates/but/src/lazy/diff.rs new file mode 100644 index 0000000000..106c707bb2 --- /dev/null +++ b/crates/but/src/lazy/diff.rs @@ -0,0 +1,216 @@ +use bstr::ByteSlice; + +use super::app::{CommitDiffFile, CommitDiffLine, DiffLineKind, LazyApp, Panel}; + +impl LazyApp { + pub(super) fn open_diff_modal(&mut self) { + if !matches!(self.active_panel, Panel::Status) { + self.command_log + .push("Select a commit in the Status panel to view its diff".to_string()); + return; + } + + let Some(commit) = self.get_selected_commit().cloned() else { + self.command_log + .push("No commit selected to diff".to_string()); + return; + }; + + let (changes, _) = match self.get_commit_file_changes(&commit.full_id) { + Ok(result) => result, + Err(e) => { + self.command_log + .push(format!("Failed to load commit changes: {}", e)); + return; + } + }; + + let mut files = Vec::new(); + for change in changes { + let path = change.path.to_string(); + let status = change.status.clone(); + let diff_result = + but_api::legacy::diff::tree_change_diffs(self.project_id, change.clone()); + + let lines = match diff_result { + Ok(patch) => Self::lines_from_patch(patch), + Err(e) => { + self.command_log + .push(format!("Failed to load diff for {}: {}", path, e)); + vec![CommitDiffLine { + text: format!("Error loading diff: {}", e), + kind: DiffLineKind::Info, + }] + } + }; + + files.push(CommitDiffFile { + path, + status, + lines, + }); + } + + if files.is_empty() { + self.command_log + .push("Commit has no file changes".to_string()); + return; + } + + self.diff_modal_files = files; + self.diff_modal_selected_file = 0; + self.diff_modal_scroll = 0; + self.show_diff_modal = true; + self.command_log + .push(format!("Viewing diff for commit {}", commit.id)); + } + + pub(super) fn close_diff_modal(&mut self) { + self.show_diff_modal = false; + self.diff_modal_files.clear(); + self.diff_modal_selected_file = 0; + self.diff_modal_scroll = 0; + } + + pub(super) fn scroll_diff_modal(&mut self, delta: i16) { + if delta > 0 { + self.diff_modal_scroll = self.diff_modal_scroll.saturating_add(delta as u16); + } else if delta < 0 { + self.diff_modal_scroll = self.diff_modal_scroll.saturating_sub((-delta) as u16); + } + self.clamp_diff_scroll(); + } + + pub(super) fn select_next_diff_file(&mut self) { + if self.diff_modal_files.is_empty() { + return; + } + self.diff_modal_selected_file = + (self.diff_modal_selected_file + 1) % self.diff_modal_files.len(); + self.diff_modal_scroll = 0; + } + + pub(super) fn select_prev_diff_file(&mut self) { + if self.diff_modal_files.is_empty() { + return; + } + + if self.diff_modal_selected_file == 0 { + self.diff_modal_selected_file = self.diff_modal_files.len() - 1; + } else { + self.diff_modal_selected_file -= 1; + } + self.diff_modal_scroll = 0; + } + + pub(super) fn jump_diff_hunk_forward(&mut self) { + self.jump_diff_hunk(true); + } + + pub(super) fn jump_diff_hunk_backward(&mut self) { + self.jump_diff_hunk(false); + } + + fn jump_diff_hunk(&mut self, forward: bool) { + if self.diff_modal_files.is_empty() { + return; + } + + let selected_idx = self + .diff_modal_selected_file + .min(self.diff_modal_files.len().saturating_sub(1)); + let Some(file) = self.diff_modal_files.get(selected_idx) else { + return; + }; + + let headers: Vec = file + .lines + .iter() + .enumerate() + .filter_map(|(idx, line)| { + if matches!(line.kind, DiffLineKind::Header) { + Some(idx) + } else { + None + } + }) + .collect(); + + if headers.is_empty() { + return; + } + + let current = self.diff_modal_scroll as usize; + let target = if forward { + headers + .iter() + .copied() + .find(|idx| *idx > current) + .unwrap_or(headers[0]) + } else { + headers + .iter() + .rev() + .copied() + .find(|idx| *idx < current) + .unwrap_or(*headers.last().unwrap()) + }; + + self.diff_modal_scroll = target as u16; + self.clamp_diff_scroll(); + } + + fn lines_from_patch(patch: Option) -> Vec { + match patch { + Some(but_core::UnifiedPatch::Patch { hunks, .. }) => { + let mut lines = Vec::new(); + for hunk in hunks { + for diff_line in hunk.diff.lines() { + let text = String::from_utf8_lossy(diff_line).to_string(); + let kind = if text.starts_with("@@") { + DiffLineKind::Header + } else if text.starts_with('+') { + DiffLineKind::Added + } else if text.starts_with('-') { + DiffLineKind::Removed + } else { + DiffLineKind::Context + }; + lines.push(CommitDiffLine { text, kind }); + } + lines.push(CommitDiffLine { + text: String::new(), + kind: DiffLineKind::Context, + }); + } + lines + } + Some(but_core::UnifiedPatch::Binary) => vec![CommitDiffLine { + text: "Binary file (diff unavailable)".to_string(), + kind: DiffLineKind::Info, + }], + Some(but_core::UnifiedPatch::TooLarge { size_in_bytes }) => vec![CommitDiffLine { + text: format!("File too large to diff ({} bytes)", size_in_bytes), + kind: DiffLineKind::Info, + }], + None => vec![CommitDiffLine { + text: "Diff not available".to_string(), + kind: DiffLineKind::Info, + }], + } + } + + fn clamp_diff_scroll(&mut self) { + let Some(file) = self.diff_modal_files.get( + self.diff_modal_selected_file + .min(self.diff_modal_files.len().saturating_sub(1)), + ) else { + self.diff_modal_scroll = 0; + return; + }; + let max_scroll = file.lines.len().saturating_sub(1) as u16; + if self.diff_modal_scroll > max_scroll { + self.diff_modal_scroll = max_scroll; + } + } +} diff --git a/crates/but/src/lazy/input.rs b/crates/but/src/lazy/input.rs new file mode 100644 index 0000000000..6a628633e9 --- /dev/null +++ b/crates/but/src/lazy/input.rs @@ -0,0 +1,484 @@ +use crossterm::event::{KeyCode, KeyModifiers, MouseEvent, MouseEventKind}; + +use super::app::{CommitModalFocus, LazyApp, Panel, RewordModalFocus}; + +impl LazyApp { + pub(super) fn handle_input(&mut self, key: KeyCode, modifiers: KeyModifiers) { + if self.show_absorb_modal { + self.handle_absorb_modal_input(key, modifiers); + return; + } + + if self.show_squash_modal { + self.handle_squash_modal_input(key, modifiers); + return; + } + + if self.show_branch_rename_modal { + self.handle_branch_rename_modal_input(key, modifiers); + return; + } + + if self.show_diff_modal { + self.handle_diff_modal_input(key, modifiers); + return; + } + + if self.show_uncommit_modal { + self.handle_uncommit_modal_input(key, modifiers); + return; + } + + if self.show_reword_modal { + self.handle_reword_modal_input(key, modifiers); + return; + } + + if self.show_commit_modal { + self.handle_commit_modal_input(key, modifiers); + return; + } + + if self.show_update_modal { + self.handle_update_modal_input(key); + return; + } + + if self.show_restore_modal { + self.handle_restore_modal_input(key); + return; + } + + if self.show_help { + match key { + KeyCode::Char('?') | KeyCode::Esc | KeyCode::Char('q') => { + self.show_help = false; + self.help_scroll = 0; + self.command_log.push("Closed help".to_string()); + } + KeyCode::Down | KeyCode::Char('j') => { + self.help_scroll = self.help_scroll.saturating_add(1); + } + KeyCode::Up | KeyCode::Char('k') => { + self.help_scroll = self.help_scroll.saturating_sub(1); + } + _ => {} + } + return; + } + + match key { + KeyCode::Char('q') | KeyCode::Esc => { + self.should_quit = true; + self.command_log.push("Quit requested".to_string()); + } + KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => { + self.should_quit = true; + self.command_log.push("Interrupted (Ctrl+C)".to_string()); + } + KeyCode::Tab => { + self.next_panel(); + self.command_log + .push(format!("Switched to {:?}", self.active_panel_name())); + } + KeyCode::BackTab => { + self.prev_panel(); + self.command_log + .push(format!("Switched to {:?}", self.active_panel_name())); + } + KeyCode::Down | KeyCode::Char('j') => { + self.select_next(); + self.update_main_view(); + } + KeyCode::Up | KeyCode::Char('k') => { + self.select_prev(); + self.update_main_view(); + } + KeyCode::Char('1') => { + if self.upstream_info.is_some() { + self.active_panel = Panel::Upstream; + self.command_log.push("Switched to Upstream".to_string()); + self.update_main_view(); + } + } + KeyCode::Char('2') => { + self.active_panel = Panel::Status; + self.command_log.push("Switched to Status".to_string()); + self.update_main_view(); + } + KeyCode::Char('3') => { + self.active_panel = Panel::Oplog; + self.command_log.push("Switched to Oplog".to_string()); + self.update_main_view(); + } + KeyCode::Char('?') => { + self.show_help = true; + self.help_scroll = 0; + self.command_log.push("Opened help".to_string()); + } + KeyCode::Char('r') => { + if matches!(self.active_panel, Panel::Oplog) + && self.oplog_state.selected().is_some() + { + self.open_restore_modal(); + } else if let Err(e) = self.refresh() { + self.command_log.push(format!("Refresh failed: {}", e)); + } + } + KeyCode::Char('@') => { + self.command_log_visible = !self.command_log_visible; + if self.command_log_visible { + self.command_log.push("Command log shown".to_string()); + } else { + self.command_log.push("Command log hidden".to_string()); + } + } + KeyCode::Char('f') => { + if matches!(self.active_panel, Panel::Upstream) { + if let Err(e) = self.fetch_upstream() { + self.command_log.push(format!("Fetch failed: {}", e)); + } + } else if matches!(self.active_panel, Panel::Status) { + self.open_diff_modal(); + } + } + KeyCode::Char('c') => { + if matches!(self.active_panel, Panel::Status) { + self.open_commit_modal(); + } + } + KeyCode::Char('e') => { + if matches!(self.active_panel, Panel::Status) { + if self.get_selected_commit().is_some() { + self.open_reword_modal(); + } else if self.get_selected_branch().is_some() { + self.open_branch_rename_modal(); + } + } + } + KeyCode::Char('a') => { + if matches!(self.active_panel, Panel::Status) { + self.open_absorb_modal(); + } + } + KeyCode::Char('s') => { + if matches!(self.active_panel, Panel::Status) { + self.open_squash_modal(); + } + } + KeyCode::Char('u') => { + if matches!(self.active_panel, Panel::Status) { + self.open_uncommit_modal(); + } else if matches!(self.active_panel, Panel::Upstream) { + self.open_upstream_update_modal(); + } + } + KeyCode::Char('d') => { + self.details_selected = !self.details_selected; + if self.details_selected { + if !matches!(self.active_panel, Panel::Status) { + self.active_panel = Panel::Status; + self.command_log.push("Switched to Status".to_string()); + } + self.command_log.push("Details pane selected".to_string()); + } else { + if !matches!(self.active_panel, Panel::Status) { + self.active_panel = Panel::Status; + self.command_log.push("Switched to Status".to_string()); + } + self.command_log.push("Details pane deselected".to_string()); + } + } + KeyCode::Char('l') => { + if self.details_selected { + self.details_scroll = self.details_scroll.saturating_add(1); + } else if matches!(self.active_panel, Panel::Status) { + if self.select_prev_branch() { + self.update_main_view(); + } + } + } + KeyCode::Char('h') => { + if self.details_selected { + self.details_scroll = self.details_scroll.saturating_sub(1); + } else if matches!(self.active_panel, Panel::Status) { + if self.select_next_branch() { + self.update_main_view(); + } + } + } + _ => {} + } + } + + pub(super) fn handle_mouse(&mut self, mouse: MouseEvent) { + if mouse.kind != MouseEventKind::Down(crossterm::event::MouseButton::Left) { + return; + } + + let col = mouse.column; + let row = mouse.row; + + if let Some(area) = self.status_area { + if col >= area.x + && col < area.x + area.width + && row >= area.y + && row < area.y + area.height + { + self.active_panel = Panel::Status; + + let relative_row = row.saturating_sub(area.y + 1); + let total_items = self.count_status_items(); + + if (relative_row as usize) < total_items { + self.status_state.select(Some(relative_row as usize)); + self.update_main_view(); + } + return; + } + } + + if let Some(area) = self.upstream_area { + if col >= area.x + && col < area.x + area.width + && row >= area.y + && row < area.y + area.height + { + self.active_panel = Panel::Upstream; + self.update_main_view(); + return; + } + } + + if let Some(area) = self.oplog_area { + if col >= area.x + && col < area.x + area.width + && row >= area.y + && row < area.y + area.height + { + self.active_panel = Panel::Oplog; + + let relative_row = row.saturating_sub(area.y + 1); + + if (relative_row as usize) < self.oplog_entries.len() { + self.oplog_state.select(Some(relative_row as usize)); + self.update_main_view(); + } + return; + } + } + + if let Some(area) = self.details_area { + if col >= area.x + && col < area.x + area.width + && row >= area.y + && row < area.y + area.height + { + self.details_selected = true; + return; + } + } + } + + fn handle_commit_modal_input(&mut self, key: KeyCode, modifiers: KeyModifiers) { + match key { + KeyCode::Esc => { + self.cancel_commit_modal(); + } + KeyCode::Char('[') if modifiers.contains(KeyModifiers::CONTROL) => { + self.cancel_commit_modal(); + } + KeyCode::Tab => { + self.commit_modal_focus = match self.commit_modal_focus { + CommitModalFocus::BranchSelect => CommitModalFocus::Files, + CommitModalFocus::Files => CommitModalFocus::Subject, + CommitModalFocus::NewBranchName => CommitModalFocus::Subject, + CommitModalFocus::Subject => CommitModalFocus::Message, + CommitModalFocus::Message => CommitModalFocus::BranchSelect, + }; + } + KeyCode::Up | KeyCode::Char('k') + if matches!(self.commit_modal_focus, CommitModalFocus::BranchSelect) => + { + if self.commit_selected_branch_idx > 0 { + self.commit_selected_branch_idx -= 1; + self.rebuild_commit_file_list(); + self.commit_selected_file_idx = 0; + } + } + KeyCode::Down | KeyCode::Char('j') + if matches!(self.commit_modal_focus, CommitModalFocus::BranchSelect) => + { + if self.commit_selected_branch_idx < self.commit_branch_options.len() - 1 { + self.commit_selected_branch_idx += 1; + self.rebuild_commit_file_list(); + self.commit_selected_file_idx = 0; + } + } + KeyCode::Up | KeyCode::Char('k') + if matches!(self.commit_modal_focus, CommitModalFocus::Files) => + { + self.move_commit_file_cursor(-1); + } + KeyCode::Down | KeyCode::Char('j') + if matches!(self.commit_modal_focus, CommitModalFocus::Files) => + { + self.move_commit_file_cursor(1); + } + KeyCode::Char(' ') if matches!(self.commit_modal_focus, CommitModalFocus::Files) => { + self.toggle_current_commit_file(); + } + KeyCode::Char('o') if modifiers.contains(KeyModifiers::CONTROL) => { + self.commit_only_mode = !self.commit_only_mode; + self.rebuild_commit_file_list(); + self.commit_selected_file_idx = 0; + } + KeyCode::Char('m') | KeyCode::Char('M') + if modifiers.contains(KeyModifiers::CONTROL) => + { + self.submit_commit_modal(); + } + KeyCode::Char(c) => match self.commit_modal_focus { + CommitModalFocus::Subject => self.commit_subject.push(c), + CommitModalFocus::Message => self.commit_message.push(c), + _ => {} + }, + KeyCode::Backspace => match self.commit_modal_focus { + CommitModalFocus::Subject => { + self.commit_subject.pop(); + } + CommitModalFocus::Message => { + self.commit_message.pop(); + } + _ => {} + }, + KeyCode::Enter => { + if modifiers.contains(KeyModifiers::CONTROL) { + self.submit_commit_modal(); + } else if matches!(self.commit_modal_focus, CommitModalFocus::Message) { + self.commit_message.push('\n'); + } + } + _ => {} + } + } + + fn handle_update_modal_input(&mut self, key: KeyCode) { + match key { + KeyCode::Esc | KeyCode::Char('n') => { + self.show_update_modal = false; + self.command_log + .push("Canceled upstream update".to_string()); + } + KeyCode::Enter | KeyCode::Char('y') => { + if let Err(e) = self.perform_upstream_update() { + self.command_log.push(format!("Update failed: {}", e)); + } + self.show_update_modal = false; + } + _ => {} + } + } + + fn handle_restore_modal_input(&mut self, key: KeyCode) { + match key { + KeyCode::Esc | KeyCode::Char('n') => self.cancel_restore_modal(), + KeyCode::Enter | KeyCode::Char('y') => self.confirm_restore_modal(), + _ => {} + } + } + + fn handle_reword_modal_input(&mut self, key: KeyCode, modifiers: KeyModifiers) { + match key { + KeyCode::Esc => self.cancel_reword_modal(), + KeyCode::Char('[') if modifiers.contains(KeyModifiers::CONTROL) => { + self.cancel_reword_modal(); + } + KeyCode::Tab => { + self.reword_modal_focus = match self.reword_modal_focus { + RewordModalFocus::Subject => RewordModalFocus::Message, + RewordModalFocus::Message => RewordModalFocus::Subject, + }; + } + KeyCode::Char('m') | KeyCode::Char('M') + if modifiers.contains(KeyModifiers::CONTROL) => + { + self.submit_reword_modal(); + } + KeyCode::Char(c) => match self.reword_modal_focus { + RewordModalFocus::Subject => self.reword_subject.push(c), + RewordModalFocus::Message => self.reword_message.push(c), + }, + KeyCode::Backspace => match self.reword_modal_focus { + RewordModalFocus::Subject => { + self.reword_subject.pop(); + } + RewordModalFocus::Message => { + self.reword_message.pop(); + } + }, + KeyCode::Enter => { + if modifiers.contains(KeyModifiers::CONTROL) { + self.submit_reword_modal(); + } else if matches!(self.reword_modal_focus, RewordModalFocus::Message) { + self.reword_message.push('\n'); + } + } + _ => {} + } + } + + fn handle_uncommit_modal_input(&mut self, key: KeyCode, _modifiers: KeyModifiers) { + match key { + KeyCode::Esc | KeyCode::Char('n') => self.cancel_uncommit_modal(), + KeyCode::Enter | KeyCode::Char('y') => self.confirm_uncommit_modal(), + _ => {} + } + } + + fn handle_diff_modal_input(&mut self, key: KeyCode, _modifiers: KeyModifiers) { + match key { + KeyCode::Esc | KeyCode::Char('q') => self.close_diff_modal(), + KeyCode::Char('j') | KeyCode::Down => self.scroll_diff_modal(1), + KeyCode::Char('k') | KeyCode::Up => self.scroll_diff_modal(-1), + KeyCode::Char('h') | KeyCode::Left => self.select_prev_diff_file(), + KeyCode::Char('l') | KeyCode::Right => self.select_next_diff_file(), + KeyCode::Char(']') => self.jump_diff_hunk_forward(), + KeyCode::Char('[') => self.jump_diff_hunk_backward(), + _ => {} + } + } + + fn handle_branch_rename_modal_input(&mut self, key: KeyCode, modifiers: KeyModifiers) { + match key { + KeyCode::Esc => self.cancel_branch_rename_modal(), + KeyCode::Enter => self.submit_branch_rename_modal(), + KeyCode::Char('m') | KeyCode::Char('M') + if modifiers.contains(KeyModifiers::CONTROL) => + { + self.submit_branch_rename_modal(); + } + KeyCode::Char(c) => self.branch_rename_input.push(c), + KeyCode::Backspace => { + self.branch_rename_input.pop(); + } + _ => {} + } + } + + fn handle_squash_modal_input(&mut self, key: KeyCode, _modifiers: KeyModifiers) { + match key { + KeyCode::Esc | KeyCode::Char('n') => self.cancel_squash_modal(), + KeyCode::Enter | KeyCode::Char('y') => self.confirm_squash_modal(), + _ => {} + } + } + + fn handle_absorb_modal_input(&mut self, key: KeyCode, _modifiers: KeyModifiers) { + match key { + KeyCode::Esc | KeyCode::Char('n') => self.cancel_absorb_modal(), + KeyCode::Enter | KeyCode::Char('y') => self.confirm_absorb_modal(), + _ => {} + } + } +} diff --git a/crates/but/src/lazy/mod.rs b/crates/but/src/lazy/mod.rs new file mode 100644 index 0000000000..df0955c5c3 --- /dev/null +++ b/crates/but/src/lazy/mod.rs @@ -0,0 +1,13 @@ +mod absorb; +mod app; +mod branch_rename; +mod commit; +mod diff; +mod input; +mod render; +mod restore; +mod reword; +mod squash; +mod uncommit; + +pub use app::run; diff --git a/crates/but/src/lazy/render.rs b/crates/but/src/lazy/render.rs new file mode 100644 index 0000000000..e3492e4c20 --- /dev/null +++ b/crates/but/src/lazy/render.rs @@ -0,0 +1,1829 @@ +use bstr::ByteSlice; +use gitbutler_branch_actions::upstream_integration::{ + BranchStatus as UpstreamBranchStatus, StackStatuses, +}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}, +}; +use std::{cmp, collections::BTreeSet}; + +use super::app::{CommitModalFocus, DiffLineKind, LazyApp, Panel, RewordModalFocus}; +use crate::status::assignment::FileAssignment; + +pub(super) fn ui(f: &mut Frame, app: &mut LazyApp) { + let size = f.area(); + + // Create outer layout: main content area and status line at bottom + let outer_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(1)]) + .split(size); + + // Create main layout: left side (panels) and right side (main view + command log) + let main_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(outer_chunks[0]); + + // Left side: split into three panels (Upstream, Status, Oplog) + let left_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(if app.upstream_info.is_some() { 3 } else { 0 }), + Constraint::Min(10), + Constraint::Percentage(25), + ]) + .split(main_chunks[0]); + + // Right side: main view (top) and command log (bottom) when visible + let right_chunks = if app.command_log_visible { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(80), Constraint::Percentage(20)]) + .split(main_chunks[1]) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(100)]) + .split(main_chunks[1]) + }; + + // Store panel areas for mouse click detection + if app.upstream_info.is_some() { + app.upstream_area = Some(left_chunks[0]); + render_upstream(f, app, left_chunks[0]); + } else { + app.upstream_area = None; + } + + app.status_area = Some(left_chunks[1]); + app.oplog_area = Some(left_chunks[2]); + + // Render status panel (combined unassigned files and branches) + render_status(f, app, left_chunks[1]); + + // Render oplog panel + render_oplog(f, app, left_chunks[2]); + + // Store details area for mouse click detection + app.details_area = Some(right_chunks[0]); + + // Render main view + render_main_view(f, app, right_chunks[0]); + + // Render command log if visible + if app.command_log_visible { + render_command_log(f, app, right_chunks[1]); + } + + // Render status line at the bottom + render_status_line(f, app, outer_chunks[1]); + + // Render help modal if shown + if app.show_help { + render_help_modal(f, app, size); + } + + // Render commit modal if shown + if app.show_commit_modal { + render_commit_modal(f, app, size); + } + + if app.show_reword_modal { + render_reword_modal(f, app, size); + } + + if app.show_uncommit_modal { + render_uncommit_modal(f, app, size); + } + + if app.show_absorb_modal { + render_absorb_modal(f, app, size); + } + + if app.show_squash_modal { + render_squash_modal(f, app, size); + } + + if app.show_diff_modal { + render_diff_modal(f, app, size); + } + + if app.show_branch_rename_modal { + render_branch_rename_modal(f, app, size); + } + + if app.show_update_modal { + render_update_modal(f, app, size); + } + + if app.show_restore_modal { + render_restore_modal(f, app, size); + } +} + +fn render_upstream(f: &mut Frame, app: &mut LazyApp, area: Rect) { + let is_active = matches!(app.active_panel, Panel::Upstream); + let border_style = if is_active { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + if let Some(upstream) = &app.upstream_info { + // Calculate relative time for last fetched + let last_fetched_text = upstream + .last_fetched_ms + .map(|ms| { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis(); + let elapsed_ms = now_ms.saturating_sub(ms); + let elapsed_secs = elapsed_ms / 1000; + + if elapsed_secs < 60 { + format!("{}s ago", elapsed_secs) + } else if elapsed_secs < 3600 { + let minutes = elapsed_secs / 60; + format!("{}m ago", minutes) + } else if elapsed_secs < 86400 { + let hours = elapsed_secs / 3600; + format!("{}h ago", hours) + } else { + let days = elapsed_secs / 86400; + format!("{}d ago", days) + } + }) + .unwrap_or_else(|| "unknown".to_string()); + + let mut content = vec![Line::from(vec![ + Span::styled("⏫ ", Style::default().fg(Color::Yellow)), + Span::styled( + format!("{} new commits", upstream.behind_count), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" • "), + Span::styled("fetched ", Style::default().fg(Color::DarkGray)), + Span::styled(last_fetched_text, Style::default().fg(Color::Cyan)), + ])]; + + if let Some(statuses) = &app.upstream_integration_status { + content.push(Line::from("")); + content.push(Line::from(vec![Span::styled( + "Applied Branch Status", + Style::default().add_modifier(Modifier::BOLD), + )])); + match statuses { + StackStatuses::UpToDate => { + content.push(Line::from(vec![Span::styled( + "✅ All applied branches are up to date", + Style::default().fg(Color::Green), + )])); + } + StackStatuses::UpdatesRequired { + worktree_conflicts, + statuses, + } => { + if !worktree_conflicts.is_empty() { + content.push(Line::from(vec![Span::styled( + "❗️ Uncommitted changes may conflict with an update", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )])); + for conflict in worktree_conflicts.iter().take(3) { + content.push(Line::from(vec![Span::raw(format!( + " • {}", + conflict.to_string() + ))])); + } + if worktree_conflicts.len() > 3 { + content.push(Line::from(" • ...")); + } + } + + for (stack_id, stack_status) in statuses { + if let Some(name) = stack_id + .and_then(|id| app.stacks.iter().find(|s| s.id == Some(id))) + .map(|s| s.name.clone()) + { + content.push(Line::from(vec![Span::styled( + format!("Stack: {name}"), + Style::default().fg(Color::Cyan), + )])); + } + for branch in &stack_status.branch_statuses { + let (icon, label, style) = branch_status_summary(&branch.status); + content.push(Line::from(vec![ + Span::raw(" "), + Span::raw(icon.to_string()), + Span::raw(" "), + Span::styled( + branch.name.clone(), + Style::default().fg(Color::White), + ), + Span::raw(": "), + Span::styled(label.to_string(), style), + ])); + } + } + } + } + + content.push(Line::from("")); + content.push(Line::from(vec![Span::styled( + "Press 'u' to update applied branches", + Style::default().fg(Color::Yellow), + )])); + } + + let paragraph = Paragraph::new(content).block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title("Upstream [1]"), + ); + + f.render_widget(paragraph, area); + } +} + +fn render_status(f: &mut Frame, app: &mut LazyApp, area: Rect) { + let is_active = matches!(app.active_panel, Panel::Status); + let border_style = if is_active { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let mut items: Vec = Vec::new(); + + let selected_lock_ids = selected_lock_ids(app); + + // Add unassigned files section + if !app.unassigned_files.is_empty() { + items.push(ListItem::new(Line::from(vec![Span::styled( + "Unassigned Files", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )]))); + + for file in &app.unassigned_files { + let path = file.path.to_string(); + let mut spans = vec![ + Span::raw(" "), + Span::styled(path, Style::default().fg(Color::Yellow)), + ]; + if let Some(lock_spans) = file_lock_spans(file) { + spans.push(Span::raw(" ")); + spans.extend(lock_spans); + } + items.push(ListItem::new(Line::from(spans))); + } + } + + if !app.unassigned_files.is_empty() { + items.push(ListItem::new(Line::from(""))); // Blank line after unassigned files + } + + // Add stacks section + for stack in &app.stacks { + let branch_count = stack.branches.len(); + + // Add branches in stack + for (branch_idx, branch) in stack.branches.iter().enumerate() { + let is_last_branch = branch_idx == branch_count - 1; + + // Determine branch line prefix based on position in stack + let branch_prefix = if branch_count > 1 { + if branch_idx == 0 { + "╭─ " // First branch + } else if is_last_branch { + "╰─ " // Last branch + } else { + "├─ " // Middle branch + } + } else { + "" // Single branch, no connection line + }; + + let mut branch_line = vec![ + Span::raw(branch_prefix), + Span::styled( + &branch.name, + Style::default() + .fg(Color::Blue) + .add_modifier(Modifier::BOLD), + ), + ]; + + // Add "no commits" indicator if branch has no commits + if branch.commits.is_empty() { + branch_line.push(Span::raw(" ")); + branch_line.push(Span::styled( + "(no commits)", + Style::default().fg(Color::DarkGray), + )); + } + + items.push(ListItem::new(Line::from(branch_line))); + + // Add assigned files in branch + for file in &branch.assignments { + let file_prefix = if branch_count > 1 && !is_last_branch { + "│ " // Show vertical line if not the last branch + } else if branch_count > 1 { + " " // Last branch, no vertical line + } else { + " " // Single branch + }; + + let path = file.path.to_string(); + let mut spans = vec![ + Span::raw(file_prefix), + Span::styled(path, Style::default().fg(Color::Yellow)), + ]; + if let Some(lock_spans) = file_lock_spans(file) { + spans.push(Span::raw(" ")); + spans.extend(lock_spans); + } + items.push(ListItem::new(Line::from(spans))); + } + + // Add commits in branch + for commit in &branch.commits { + let prefix_no_dot = if branch_count > 1 && !is_last_branch { + "│ " // Show vertical line if not the last branch + } else if branch_count > 1 { + " " // Last branch, no vertical line + } else { + " " // Single branch + }; + + // Determine dot symbol and color based on commit state + let (dot_symbol, dot_color) = match &commit.state { + but_workspace::ui::CommitState::LocalOnly => ("●", Color::White), + but_workspace::ui::CommitState::LocalAndRemote(object_id) => { + if object_id.to_string() == commit.full_id { + ("●", Color::Green) + } else { + ("◐", Color::Green) + } + } + but_workspace::ui::CommitState::Integrated => ("●", Color::Magenta), + }; + + let first_line = commit.message.lines().next().unwrap_or("").trim_end(); + + let highlight = selected_lock_ids + .as_ref() + .map_or(false, |ids| ids.contains(&commit.full_id)); + let mut spans = vec![ + Span::raw(prefix_no_dot.to_string()), + Span::styled(dot_symbol, Style::default().fg(dot_color)), + Span::raw(" ".to_string()), + Span::styled(commit.id.clone(), Style::default().fg(Color::Green)), + Span::raw(" ".to_string()), + Span::styled(first_line.to_string(), Style::default()), + ]; + + if highlight { + for span in &mut spans { + span.style = span.style.bg(Color::Yellow).add_modifier(Modifier::BOLD); + } + } + + items.push(ListItem::new(Line::from(spans))); + } + } + + // Add blank line between stacks + if !stack.branches.is_empty() { + items.push(ListItem::new(Line::from(""))); + } + } + + let total_items = app.count_status_items(); + let panel_num = if app.upstream_info.is_some() { 2 } else { 1 }; + let title = format!("Status ({} items) [{}]", total_items, panel_num); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(border_style), + ) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + + f.render_stateful_widget(list, area, &mut app.status_state); +} + +fn render_oplog(f: &mut Frame, app: &mut LazyApp, area: Rect) { + let is_active = matches!(app.active_panel, Panel::Oplog); + let border_style = if is_active { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let items: Vec = app + .oplog_entries + .iter() + .map(|entry| { + let op_color = match entry.operation.as_str() { + "CREATE" => Color::Green, + "AMEND" | "REWORD" => Color::Yellow, + "UNDO" | "RESTORE" => Color::Red, + _ => Color::White, + }; + + ListItem::new(Line::from(vec![ + Span::styled(&entry.id, Style::default().fg(Color::Cyan)), + Span::raw(" "), + Span::styled(&entry.operation, Style::default().fg(op_color)), + Span::raw(" "), + Span::raw(&entry.title), + ])) + }) + .collect(); + + let panel_num = if app.upstream_info.is_some() { 3 } else { 2 }; + let title = format!("Oplog ({}) [{}]", app.oplog_entries.len(), panel_num); + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(border_style), + ) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("▶ "); + + f.render_stateful_widget(list, area, &mut app.oplog_state); +} + +fn render_main_view(f: &mut Frame, app: &LazyApp, area: Rect) { + let title = app.get_details_title(); + + // Apply border style based on selection + let border_style = if app.details_selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let paragraph = Paragraph::new(app.main_view_content.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(border_style), + ) + .wrap(Wrap { trim: true }) + .scroll((app.details_scroll, 0)); + + f.render_widget(paragraph, area); +} + +fn render_command_log(f: &mut Frame, app: &LazyApp, area: Rect) { + let log_lines: Vec = app + .command_log + .iter() + .rev() + .take(5) + .rev() + .map(|line| Line::from(line.clone())) + .collect(); + + let paragraph = Paragraph::new(log_lines) + .block(Block::default().borders(Borders::ALL).title("Command Log")) + .wrap(Wrap { trim: true }); + + f.render_widget(paragraph, area); +} + +fn render_status_line(f: &mut Frame, app: &LazyApp, area: Rect) { + let width = area.width as usize; + let brand = "GitButler"; + let brand_len = brand.len(); + + // Create the status line content with hints on the left and brand on the right + let hints = if app.details_selected { + "[h/l] scroll • [d] deselect • [@] toggle log • Press ? for help".to_string() + } else if matches!(app.active_panel, Panel::Status) { + let mut parts = vec![ + "[c] commit", + "[e] edit", + "[s] squash", + "[u] uncommit", + "[f] diff", + ]; + if app.is_unassigned_header_selected() { + parts.push("[a] absorb"); + } + parts.push("[d] details"); + parts.push("[@] toggle log"); + parts.push("Press ? for help"); + parts.join(" • ") + } else if matches!(app.active_panel, Panel::Oplog) { + if app.oplog_state.selected().is_some() { + "[r] restore snapshot • [d] details • [@] toggle log • Press ? for help" + .to_string() + } else { + "[d] select details • [@] toggle log • Press ? for help".to_string() + } + } else { + "[d] select details • [@] toggle log • Press ? for help".to_string() + }; + let hints_len = hints.len(); + + // Calculate spacing to push brand to the right + let spaces_needed = if width > hints_len + brand_len { + width.saturating_sub(hints_len + brand_len) + } else { + 0 + }; + + let status_line = Line::from(vec![ + Span::styled(hints.clone(), Style::default().fg(Color::DarkGray)), + Span::raw(" ".repeat(spaces_needed)), + Span::styled(brand, Style::default().fg(Color::Cyan)), + ]); + + let paragraph = Paragraph::new(status_line).style(Style::default().bg(Color::Black)); + + f.render_widget(paragraph, area); +} + +fn render_help_modal(f: &mut Frame, app: &LazyApp, area: Rect) { + // Calculate centered modal area + let modal_width = 60; + let modal_height = 26; + + let modal_area = Rect { + x: (area.width.saturating_sub(modal_width)) / 2, + y: (area.height.saturating_sub(modal_height)) / 2, + width: modal_width.min(area.width), + height: modal_height.min(area.height), + }; + + // Clear the area + f.render_widget(Clear, modal_area); + + // Create help text + let help_text = vec![ + Line::from(""), + Line::from(vec![Span::styled( + "GitButler Lazy TUI - Help", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(vec![Span::styled( + "Navigation", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )]), + Line::from(" Tab / Shift+Tab Switch between panels"), + Line::from(" 1 / 2 Jump to specific panel"), + Line::from(" j / ↓ Move down in list"), + Line::from(" k / ↑ Move up in list"), + Line::from(""), + Line::from(vec![Span::styled( + "Panels", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )]), + Line::from(" 1: Upstream Upstream commits"), + Line::from(" 2: Status But Status"), + Line::from(" 3: Oplog Operation history"), + Line::from(""), + Line::from(vec![Span::styled( + "General", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )]), + Line::from(" c Open commit modal (Status panel)"), + Line::from(" e Edit commit message (Status panel)"), + Line::from(" u Uncommit selected commit (Status panel)"), + Line::from(" f View commit diff (Status panel)"), + Line::from(" r Refresh data"), + Line::from(" r (Oplog) Restore to selected snapshot"), + Line::from(" @ Hide/show command log"), + Line::from(" f Fetch from remotes (Upstream panel)"), + Line::from(" ? Toggle this help"), + Line::from(" q / Esc Quit (or close this help)"), + Line::from(" Ctrl+C Force quit"), + ]; + + let help_block = Paragraph::new(help_text) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Help (press ? or Esc to close) ") + .title_alignment(Alignment::Center) + .border_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + ) + .wrap(Wrap { trim: false }) + .scroll((app.help_scroll, 0)) + .style(Style::default().bg(Color::Black)); + + f.render_widget(help_block, modal_area); +} + +fn render_commit_modal(f: &mut Frame, app: &LazyApp, area: Rect) { + // Calculate centered modal area - larger to fit all fields + let modal_width = 100; + let modal_height = 35; + + let modal_area = Rect { + x: (area.width.saturating_sub(modal_width)) / 2, + y: (area.height.saturating_sub(modal_height)) / 2, + width: modal_width.min(area.width), + height: modal_height.min(area.height), + }; + + // Clear the area + f.render_widget(Clear, modal_area); + + // Split into left (1/3) and right (2/3) sections + let main_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(33), Constraint::Percentage(67)]) + .split(modal_area); + + // Left side: branch selection and files + let left_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(8), + Constraint::Min(5), + Constraint::Length(3), + ]) + .split(main_chunks[0]); + + // Branch selection + let branch_border = if matches!(app.commit_modal_focus, CommitModalFocus::BranchSelect) { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + + let branch_items: Vec = app + .commit_branch_options + .iter() + .enumerate() + .map(|(idx, opt)| { + let symbol = if idx == app.commit_selected_branch_idx { + "▶ " + } else { + " " + }; + let style = if idx == app.commit_selected_branch_idx { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else if opt.is_new_branch { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + ListItem::new(Line::from(vec![ + Span::styled(symbol, style), + Span::styled(&opt.branch_name, style), + ])) + }) + .collect(); + + let branch_list = List::new(branch_items).block( + Block::default() + .borders(Borders::ALL) + .border_style(branch_border) + .title(" Branch "), + ); + + f.render_widget(branch_list, left_chunks[0]); + + // Files to commit list + let files_to_commit = render_files_to_commit_list(app); + let files_border = if matches!(app.commit_modal_focus, CommitModalFocus::Files) { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + + let files_list = List::new(files_to_commit).block( + Block::default() + .borders(Borders::ALL) + .border_style(files_border) + .title(format!( + " Files to Commit {} ", + if app.commit_only_mode { + "[Only Mode]" + } else { + "[All]" + } + )), + ); + + f.render_widget(files_list, left_chunks[1]); + + // Only mode toggle hint + let only_hint = vec![Line::from(vec![ + Span::styled( + "Ctrl+O", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": toggle only mode"), + ])]; + let only_block = Paragraph::new(only_hint) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().bg(Color::Black)); + f.render_widget(only_block, left_chunks[2]); + + // Right side: subject, message, and hints + let right_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(3), + ]) + .split(main_chunks[1]); + + // Subject field + let subject_border = if matches!(app.commit_modal_focus, CommitModalFocus::Subject) { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + + let subject_block = Paragraph::new(app.commit_subject.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(subject_border) + .title(" Subject "), + ) + .style(Style::default().bg(Color::Black)); + + f.render_widget(subject_block, right_chunks[0]); + + // Message field + let message_border = if matches!(app.commit_modal_focus, CommitModalFocus::Message) { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + + let message_block = Paragraph::new(app.commit_message.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(message_border) + .title(" Message (optional) "), + ) + .wrap(Wrap { trim: false }) + .style(Style::default().bg(Color::Black)); + + f.render_widget(message_block, right_chunks[1]); + + // Hints at bottom + let hints = vec![Line::from(vec![ + Span::styled( + "Tab", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": next field • "), + Span::styled( + "j/k", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": move • "), + Span::styled( + "Space", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": toggle file • "), + Span::styled( + "Ctrl+M", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": commit • "), + Span::styled( + "Esc", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": cancel"), + ])]; + + let hints_block = Paragraph::new(hints) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ) + .style(Style::default().bg(Color::Black)); + + f.render_widget(hints_block, right_chunks[2]); +} + +fn render_update_modal(f: &mut Frame, app: &LazyApp, area: Rect) { + let modal_width = 60; + let modal_height = 8; + let modal_area = Rect { + x: (area.width.saturating_sub(modal_width)) / 2, + y: (area.height.saturating_sub(modal_height)) / 2, + width: modal_width.min(area.width), + height: modal_height.min(area.height), + }; + + f.render_widget(Clear, modal_area); + + let status_line = match &app.upstream_integration_status { + Some(StackStatuses::UpToDate) => "All applied branches are already up to date.".to_string(), + Some(StackStatuses::UpdatesRequired { statuses, .. }) => { + let branch_count: usize = statuses + .iter() + .map(|(_, stack)| stack.branch_statuses.len()) + .sum(); + if branch_count == 0 { + "No active branches require updates.".to_string() + } else { + format!("{} branch(es) will be rebased onto upstream.", branch_count) + } + } + None => "Branch status unknown; attempting rebase.".to_string(), + }; + + let content = vec![ + Line::from(vec![Span::styled( + "Rebase applied branches onto upstream?", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(status_line), + Line::from(""), + Line::from("Press Enter/‘y’ to confirm or Esc/‘n’ to cancel."), + ]; + + let block = Block::default() + .borders(Borders::ALL) + .title("Confirm Update") + .style(Style::default().bg(Color::Black)); + + f.render_widget( + Paragraph::new(content) + .block(block) + .alignment(Alignment::Center), + modal_area, + ); +} + +fn render_restore_modal(f: &mut Frame, app: &LazyApp, area: Rect) { + let modal_width = 70; + let modal_height = 9; + let modal_area = Rect { + x: (area.width.saturating_sub(modal_width)) / 2, + y: (area.height.saturating_sub(modal_height)) / 2, + width: modal_width.min(area.width), + height: modal_height.min(area.height), + }; + + f.render_widget(Clear, modal_area); + + let mut content = vec![Line::from(vec![Span::styled( + "Restore workspace to selected snapshot?", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )])]; + + if let Some(target) = &app.restore_target { + content.push(Line::from("")); + content.push(Line::from(vec![ + Span::styled( + format!("Snapshot {}", target.id), + Style::default().fg(Color::Cyan), + ), + Span::raw(": "), + Span::raw(&target.title), + ])); + content.push(Line::from(vec![ + Span::styled("Time: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(&target.time), + ])); + } + + content.push(Line::from("")); + content.push(Line::from(vec![Span::styled( + "This will overwrite any uncommitted work in your workspace.", + Style::default().fg(Color::Red), + )])); + content.push(Line::from( + "Press Enter/‘y’ to confirm or Esc/‘n’ to cancel.", + )); + + let block = Block::default() + .borders(Borders::ALL) + .title("Confirm Restore") + .style(Style::default().bg(Color::Black)); + + f.render_widget( + Paragraph::new(content) + .block(block) + .alignment(Alignment::Left), + modal_area, + ); +} + +fn render_reword_modal(f: &mut Frame, app: &LazyApp, area: Rect) { + let modal_width = 90; + let modal_height = 25; + + let modal_area = Rect { + x: (area.width.saturating_sub(modal_width)) / 2, + y: (area.height.saturating_sub(modal_height)) / 2, + width: modal_width.min(area.width), + height: modal_height.min(area.height), + }; + + f.render_widget(Clear, modal_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(3), + ]) + .split(modal_area); + + let mut info_lines = Vec::new(); + if let Some(target) = &app.reword_target { + info_lines.push(Line::from(vec![ + Span::styled( + "Editing commit ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled( + target.commit_short_id.clone(), + Style::default().fg(Color::Green), + ), + Span::raw(" on "), + Span::styled(target.branch_name.clone(), Style::default().fg(Color::Blue)), + ])); + info_lines.push(Line::from(vec![ + Span::styled("Stack:", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!(" {:?}", target.stack_id)), + ])); + } else { + info_lines.push(Line::from("No commit selected")); + } + let info_block = Paragraph::new(info_lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Edit Commit Message ") + .border_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + ) + .wrap(Wrap { trim: true }) + .style(Style::default().bg(Color::Black)); + f.render_widget(info_block, chunks[0]); + + let subject_border = if matches!(app.reword_modal_focus, RewordModalFocus::Subject) { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + let subject_block = Paragraph::new(app.reword_subject.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(subject_border) + .title(" Subject "), + ) + .style(Style::default().bg(Color::Black)); + f.render_widget(subject_block, chunks[1]); + + let message_border = if matches!(app.reword_modal_focus, RewordModalFocus::Message) { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + let message_block = Paragraph::new(app.reword_message.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(message_border) + .title(" Body "), + ) + .wrap(Wrap { trim: false }) + .style(Style::default().bg(Color::Black)); + f.render_widget(message_block, chunks[2]); + + let hints = vec![Line::from(vec![ + Span::styled( + "Tab", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": next field • "), + Span::styled( + "Ctrl+M", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": save & rebase • "), + Span::styled( + "Esc", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": cancel"), + ])]; + let hints_block = Paragraph::new(hints) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().bg(Color::Black)); + f.render_widget(hints_block, chunks[3]); +} + +fn render_uncommit_modal(f: &mut Frame, app: &LazyApp, area: Rect) { + let modal_width = 70; + let modal_height = 12; + + let modal_area = Rect { + x: (area.width.saturating_sub(modal_width)) / 2, + y: (area.height.saturating_sub(modal_height)) / 2, + width: modal_width.min(area.width), + height: modal_height.min(area.height), + }; + + f.render_widget(Clear, modal_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Min(3), + Constraint::Length(3), + ]) + .split(modal_area); + + let mut lines = Vec::new(); + if let Some(target) = &app.uncommit_target { + lines.push(Line::from(vec![ + Span::styled( + "Uncommit commit ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::styled( + target.commit_short_id.clone(), + Style::default().fg(Color::Green), + ), + Span::raw(" from "), + Span::styled(target.branch_name.clone(), Style::default().fg(Color::Blue)), + Span::raw("?"), + ])); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + target.commit_message.lines().next().unwrap_or(""), + Style::default().fg(Color::Yellow), + )])); + } else { + lines.push(Line::from("No commit selected")); + } + + let info_block = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Confirm Uncommit ") + .border_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + ) + .wrap(Wrap { trim: true }) + .style(Style::default().bg(Color::Black)); + f.render_widget(info_block, chunks[0]); + + let warning = Paragraph::new(vec![Line::from( + "This moves the commit's changes back to the worktree.", + )]) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: true }) + .style(Style::default().bg(Color::Black)); + f.render_widget(warning, chunks[1]); + + let hints = vec![Line::from(vec![ + Span::styled( + "Enter/Y", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": uncommit • "), + Span::styled( + "Esc/N", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": cancel"), + ])]; + let hints_block = Paragraph::new(hints) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().bg(Color::Black)); + f.render_widget(hints_block, chunks[2]); +} + +fn render_absorb_modal(f: &mut Frame, app: &LazyApp, area: Rect) { + let modal_width = 70; + let modal_height = 12; + + let modal_area = Rect { + x: (area.width.saturating_sub(modal_width)) / 2, + y: (area.height.saturating_sub(modal_height)) / 2, + width: modal_width.min(area.width), + height: modal_height.min(area.height), + }; + + f.render_widget(Clear, modal_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Min(3), + Constraint::Length(3), + ]) + .split(modal_area); + + let mut lines = Vec::new(); + if let Some(summary) = &app.absorb_summary { + lines.push(Line::from(vec![Span::styled( + format!("Absorb {} unassigned file(s)?", summary.file_count), + Style::default().add_modifier(Modifier::BOLD), + )])); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + format!("Hunks: {}", summary.hunk_count), + Style::default().fg(Color::Cyan), + )])); + lines.push(Line::from(vec![ + Span::styled( + format!("+{}", summary.total_additions), + Style::default().fg(Color::Green), + ), + Span::raw(" "), + Span::styled( + format!("-{}", summary.total_removals), + Style::default().fg(Color::Red), + ), + ])); + } else { + lines.push(Line::from("No unassigned files available")); + } + + let info_block = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Confirm Absorb ") + .border_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ) + .wrap(Wrap { trim: true }) + .style(Style::default().bg(Color::Black)); + f.render_widget(info_block, chunks[0]); + + let warning = Paragraph::new(vec![Line::from( + "Absorb amends these changes into their target commits.", + )]) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: true }) + .style(Style::default().bg(Color::Black)); + f.render_widget(warning, chunks[1]); + + let hints = vec![Line::from(vec![ + Span::styled( + "Enter/Y", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": absorb • "), + Span::styled( + "Esc/N", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": cancel"), + ])]; + let hints_block = Paragraph::new(hints) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().bg(Color::Black)); + f.render_widget(hints_block, chunks[2]); +} + +fn render_squash_modal(f: &mut Frame, app: &LazyApp, area: Rect) { + let modal_width = 80; + let modal_height = 14; + + let modal_area = Rect { + x: (area.width.saturating_sub(modal_width)) / 2, + y: (area.height.saturating_sub(modal_height)) / 2, + width: modal_width.min(area.width), + height: modal_height.min(area.height), + }; + + f.render_widget(Clear, modal_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(6), + Constraint::Min(3), + Constraint::Length(3), + ]) + .split(modal_area); + + let mut lines = Vec::new(); + if let Some(target) = &app.squash_target { + lines.push(Line::from(vec![ + Span::styled("Squash ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + target.source_short_id.clone(), + Style::default().fg(Color::Green), + ), + Span::raw(" into "), + Span::styled( + target.destination_short_id.clone(), + Style::default().fg(Color::Yellow), + ), + Span::raw(" on "), + Span::styled(target.branch_name.clone(), Style::default().fg(Color::Blue)), + Span::raw("?"), + ])); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("Source:", Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled( + target.source_message.lines().next().unwrap_or(""), + Style::default().fg(Color::Green), + ), + ])); + lines.push(Line::from(vec![ + Span::styled("Into:", Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled( + target.destination_message.lines().next().unwrap_or(""), + Style::default().fg(Color::Yellow), + ), + ])); + } else { + lines.push(Line::from("No commits selected")); + } + + let info_block = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Confirm Squash ") + .border_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ) + .wrap(Wrap { trim: true }) + .style(Style::default().bg(Color::Black)); + f.render_widget(info_block, chunks[0]); + + let warning = Paragraph::new(vec![Line::from( + "Combines both commits and rewrites stack history.", + )]) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: true }) + .style(Style::default().bg(Color::Black)); + f.render_widget(warning, chunks[1]); + + let hints = vec![Line::from(vec![ + Span::styled( + "Enter/Y", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": squash • "), + Span::styled( + "Esc/N", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": cancel"), + ])]; + let hints_block = Paragraph::new(hints) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().bg(Color::Black)); + f.render_widget(hints_block, chunks[2]); +} + +fn render_diff_modal(f: &mut Frame, app: &LazyApp, area: Rect) { + let modal_width = 110; + let modal_height = 38; + + let modal_area = Rect { + x: (area.width.saturating_sub(modal_width)) / 2, + y: (area.height.saturating_sub(modal_height)) / 2, + width: modal_width.min(area.width), + height: modal_height.min(area.height), + }; + + f.render_widget(Clear, modal_area); + + let vertical_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(5), Constraint::Length(3)]) + .split(modal_area); + + let content_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(30), Constraint::Min(10)]) + .split(vertical_chunks[0]); + + // Files sidebar + let selected_idx = if app.diff_modal_files.is_empty() { + 0 + } else { + app.diff_modal_selected_file + .min(app.diff_modal_files.len().saturating_sub(1)) + }; + + let file_items: Vec = if app.diff_modal_files.is_empty() { + vec![ListItem::new(Line::from("No files"))] + } else { + app.diff_modal_files + .iter() + .enumerate() + .map(|(idx, file)| { + let is_selected = idx == selected_idx; + let symbol = if is_selected { "▶" } else { " " }; + let style = if is_selected { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let status_char = match &file.status { + but_core::ui::TreeStatus::Addition { .. } => 'A', + but_core::ui::TreeStatus::Deletion { .. } => 'D', + but_core::ui::TreeStatus::Modification { .. } => 'M', + but_core::ui::TreeStatus::Rename { .. } => 'R', + }; + ListItem::new(Line::from(vec![ + Span::styled(format!("{} {} ", symbol, status_char), style), + Span::styled(file.path.clone(), style), + ])) + }) + .collect() + }; + + let files_list = List::new(file_items).block( + Block::default() + .borders(Borders::ALL) + .title(" Files ") + .border_style(Style::default().fg(Color::DarkGray)), + ); + f.render_widget(files_list, content_chunks[0]); + + // Diff viewer + let diff_lines = if app.diff_modal_files.is_empty() { + vec![Line::from("Select a file to view its diff")] + } else { + render_diff_lines(&app.diff_modal_files[selected_idx]) + }; + + let diff_title = if let Some(file) = app.diff_modal_files.get(selected_idx) { + format!(" Diff: {} ", file.path) + } else { + " Diff ".to_string() + }; + + let diff_split = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(5), Constraint::Length(2)]) + .split(content_chunks[1]); + + let viewport_height = diff_split[0].height.saturating_sub(2) as usize; + let total_lines = diff_lines.len(); + let max_scroll = total_lines.saturating_sub(viewport_height.max(1)); + let scroll = app.diff_modal_scroll.min(max_scroll as u16); + + let diff_block = Paragraph::new(diff_lines.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .title(diff_title) + .border_style(Style::default().fg(Color::DarkGray)), + ) + .wrap(Wrap { trim: false }) + .scroll((scroll, 0)); + f.render_widget(diff_block, diff_split[0]); + + render_scroll_indicator( + f, + diff_split[1], + total_lines, + viewport_height, + scroll as usize, + max_scroll, + ); + + // Hints area + let hints = vec![Line::from(vec![ + Span::styled( + "j/k", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": scroll • "), + Span::styled( + "h/l", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": change file • "), + Span::styled( + "[/]", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": prev/next hunk • "), + Span::styled( + "Esc", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": close"), + ])]; + + let hints_block = Paragraph::new(hints) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().bg(Color::Black)); + f.render_widget(hints_block, vertical_chunks[1]); +} + +fn render_branch_rename_modal(f: &mut Frame, app: &LazyApp, area: Rect) { + let modal_width = 70; + let modal_height = 10; + + let modal_area = Rect { + x: (area.width.saturating_sub(modal_width)) / 2, + y: (area.height.saturating_sub(modal_height)) / 2, + width: modal_width.min(area.width), + height: modal_height.min(area.height), + }; + + f.render_widget(Clear, modal_area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + ]) + .split(modal_area); + + let title = if let Some(target) = &app.branch_rename_target { + format!("Rename branch '{}'", target.current_name) + } else { + "Rename Branch".to_string() + }; + + let input_block = Paragraph::new(app.branch_rename_input.clone()) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(Style::default().fg(Color::Cyan)), + ) + .style(Style::default().bg(Color::Black)); + f.render_widget(input_block, chunks[0]); + + let instructions = Paragraph::new(vec![Line::from( + "Enter a new branch name. Existing branch history will be preserved.", + )]) + .block(Block::default().borders(Borders::ALL)) + .wrap(Wrap { trim: true }) + .style(Style::default().bg(Color::Black)); + f.render_widget(instructions, chunks[1]); + + let hints = vec![Line::from(vec![ + Span::styled( + "Enter", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": rename • "), + Span::styled( + "Ctrl+M", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + Span::raw(": rename • "), + Span::styled( + "Esc", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::raw(": cancel"), + ])]; + + let hints_block = Paragraph::new(hints) + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().bg(Color::Black)); + f.render_widget(hints_block, chunks[2]); +} + +fn render_scroll_indicator( + f: &mut Frame, + area: Rect, + total_lines: usize, + viewport_height: usize, + scroll: usize, + max_scroll: usize, +) { + if area.width == 0 || area.height == 0 { + return; + } + + if total_lines == 0 || viewport_height == 0 || total_lines <= viewport_height { + let block = + Paragraph::new(vec![Line::from(" ")]).block(Block::default().borders(Borders::NONE)); + f.render_widget(block, area); + return; + } + + let slider_style = Style::default().fg(Color::DarkGray); + let height = area.height as usize; + + let track_height = height.saturating_sub(2); + let thumb_height = cmp::max( + 1, + ((viewport_height as f32 / total_lines as f32) * track_height.max(1) as f32).round() + as usize, + ); + let max_thumb_pos = track_height.saturating_sub(thumb_height); + let thumb_pos = if max_scroll == 0 { + 0 + } else { + ((scroll as f32 / max_scroll as f32) * max_thumb_pos as f32) + .round() + .clamp(0.0, max_thumb_pos as f32) as usize + }; + + let mut lines = Vec::with_capacity(height); + if height > 0 { + lines.push(Line::from(vec![Span::styled("^", slider_style)])); + } + + for i in 0..track_height { + let symbol = if i >= thumb_pos && i < thumb_pos + thumb_height { + "#" + } else { + "|" + }; + lines.push(Line::from(vec![Span::styled(symbol, slider_style)])); + } + + if height > 1 { + lines.push(Line::from(vec![Span::styled("v", slider_style)])); + } + + let indicator = Paragraph::new(lines) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::NONE)); + f.render_widget(indicator, area); +} + +fn render_diff_lines(file: &super::app::CommitDiffFile) -> Vec> { + if file.lines.is_empty() { + return vec![Line::from("No diff available")]; + } + + file.lines + .iter() + .map(|line| { + let style = match line.kind { + DiffLineKind::Header => Style::default().fg(Color::Cyan), + DiffLineKind::Added => Style::default().fg(Color::Green), + DiffLineKind::Removed => Style::default().fg(Color::Red), + DiffLineKind::Info => Style::default().fg(Color::Yellow), + DiffLineKind::Context => Style::default(), + }; + Line::from(vec![Span::styled(line.text.clone(), style)]) + }) + .collect() +} + +fn render_files_to_commit_list(app: &LazyApp) -> Vec> { + if app.commit_files.is_empty() { + return vec![ListItem::new(Line::from(vec![Span::styled( + " No files to commit", + Style::default().fg(Color::DarkGray), + )]))]; + } + + app.commit_files + .iter() + .enumerate() + .map(|(idx, entry)| { + let key = entry.file.path.to_str_lossy().into_owned(); + let is_selected = app.commit_selected_file_paths.contains(&key); + let checkbox = if is_selected { "[x]" } else { "[ ]" }; + let is_cursor = matches!(app.commit_modal_focus, CommitModalFocus::Files) + && idx == app.commit_selected_file_idx; + + let mut spans = vec![ + Span::styled( + if is_cursor { "›" } else { " " }, + Style::default().fg(Color::Cyan), + ), + Span::raw(" "), + Span::styled(checkbox, Style::default()), + Span::raw(" "), + Span::styled(key.clone(), Style::default().fg(Color::Yellow)), + ]; + + if let Some(lock_spans) = file_lock_spans(&entry.file) { + spans.push(Span::raw(" ")); + spans.extend(lock_spans); + } + + if is_cursor { + spans = spans + .into_iter() + .map(|mut span| { + span.style = span + .style + .bg(Color::Blue) + .fg(Color::Black) + .add_modifier(Modifier::BOLD); + span + }) + .collect(); + } + + ListItem::new(Line::from(spans)) + }) + .collect() +} + +fn file_lock_spans(file: &FileAssignment) -> Option>> { + let locks = collect_lock_ids(file); + if locks.is_empty() { + return None; + } + + let mut spans = Vec::new(); + spans.push(Span::styled( + "🔒", + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::raw(" ")); + + let mut first = true; + for commit_id in locks { + if commit_id.len() < 7 { + continue; + } + if !first { + spans.push(Span::raw(", ")); + } + first = false; + let (prefix, rest) = commit_id.split_at(2); + let short_rest = &rest[..5.min(rest.len())]; + spans.push(Span::styled( + prefix.to_string(), + Style::default() + .fg(Color::LightBlue) + .add_modifier(Modifier::UNDERLINED), + )); + spans.push(Span::styled( + short_rest.to_string(), + Style::default().fg(Color::LightBlue), + )); + } + + if spans.len() <= 2 { None } else { Some(spans) } +} + +fn collect_lock_ids(file: &FileAssignment) -> BTreeSet { + let mut locks = BTreeSet::new(); + for assignment in &file.assignments { + if let Some(hunk_locks) = &assignment.inner.hunk_locks { + for lock in hunk_locks { + locks.insert(lock.commit_id.to_string()); + } + } + } + locks +} + +fn selected_lock_ids(app: &LazyApp) -> Option> { + app.get_selected_file() + .map(collect_lock_ids) + .filter(|locks| !locks.is_empty()) +} + +fn branch_status_summary(status: &UpstreamBranchStatus) -> (&'static str, &'static str, Style) { + match status { + UpstreamBranchStatus::SaflyUpdatable => { + ("✅", "Updatable", Style::default().fg(Color::Green)) + } + UpstreamBranchStatus::Integrated => ("🔄", "Integrated", Style::default().fg(Color::Blue)), + UpstreamBranchStatus::Conflicted { rebasable } => { + if *rebasable { + ( + "⚠️", + "Conflicted (rebasable)", + Style::default().fg(Color::Yellow), + ) + } else { + ( + "❗️", + "Conflicted", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ) + } + } + UpstreamBranchStatus::Empty => { + ("⬜", "Nothing to do", Style::default().fg(Color::DarkGray)) + } + } +} diff --git a/crates/but/src/lazy/restore.rs b/crates/but/src/lazy/restore.rs new file mode 100644 index 0000000000..3ab7fd6d4c --- /dev/null +++ b/crates/but/src/lazy/restore.rs @@ -0,0 +1,50 @@ +use anyhow::Result; + +use super::app::{LazyApp, OplogEntry}; + +impl LazyApp { + pub(super) fn open_restore_modal(&mut self) { + if let Some(entry) = self.get_selected_oplog_entry().cloned() { + self.restore_target = Some(entry); + self.show_restore_modal = true; + } else { + self.command_log + .push("Select an oplog entry before attempting to restore".to_string()); + } + } + + pub(super) fn cancel_restore_modal(&mut self) { + self.show_restore_modal = false; + self.restore_target = None; + self.command_log.push("Canceled oplog restore".to_string()); + } + + pub(super) fn confirm_restore_modal(&mut self) { + if let Some(entry) = self.restore_target.clone() { + if let Err(err) = self.restore_workspace_to(&entry) { + self.command_log.push(format!("Restore failed: {}", err)); + } + } + self.restore_target = None; + self.show_restore_modal = false; + } + + fn restore_workspace_to(&mut self, entry: &OplogEntry) -> Result<()> { + self.command_log.push(format!( + "Restoring workspace to snapshot {} ({})", + entry.id, entry.title + )); + + self.command_log + .push("but_api::legacy::oplog::restore_snapshot()".to_string()); + but_api::legacy::oplog::restore_snapshot(self.project_id, entry.full_id.clone())?; + + let project = gitbutler_project::get(self.project_id)?; + self.load_data_with_project(&project)?; + self.update_main_view(); + + self.command_log + .push(format!("Workspace restored to snapshot {}", entry.id)); + Ok(()) + } +} diff --git a/crates/but/src/lazy/reword.rs b/crates/but/src/lazy/reword.rs new file mode 100644 index 0000000000..becd476412 --- /dev/null +++ b/crates/but/src/lazy/reword.rs @@ -0,0 +1,145 @@ +use anyhow::Result; +use but_oxidize::{ObjectIdExt, OidExt}; +use but_settings::AppSettings; +use gitbutler_command_context::CommandContext; + +use super::app::{LazyApp, Panel, RewordModalFocus, RewordTargetInfo}; + +impl LazyApp { + pub(super) fn open_reword_modal(&mut self) { + if !matches!(self.active_panel, Panel::Status) { + self.command_log + .push("Select a commit in the Status panel to edit".to_string()); + return; + } + + let Some(commit) = self.get_selected_commit().cloned() else { + self.command_log + .push("No commit selected to edit".to_string()); + return; + }; + + let Some((Some(stack_id), branch)) = self.find_branch_context(|candidate| { + candidate + .commits + .iter() + .any(|c| c.full_id == commit.full_id) + }) else { + self.command_log + .push("Unable to resolve stack for selected commit".to_string()); + return; + }; + + let branch_name = branch.name.clone(); + + self.reset_reword_modal_state(); + + let (subject, body) = Self::split_commit_message(&commit.message); + self.reword_subject = subject; + self.reword_message = body; + self.reword_modal_focus = RewordModalFocus::Subject; + self.reword_target = Some(RewordTargetInfo { + stack_id, + branch_name, + commit_short_id: commit.id.clone(), + commit_full_id: commit.full_id.clone(), + original_message: commit.message.clone(), + }); + self.show_reword_modal = true; + self.command_log + .push(format!("Editing commit message for {}", commit.id)); + } + + fn reset_reword_modal_state(&mut self) { + self.show_reword_modal = false; + self.reword_subject.clear(); + self.reword_message.clear(); + self.reword_modal_focus = RewordModalFocus::Subject; + self.reword_target = None; + } + + pub(super) fn cancel_reword_modal(&mut self) { + self.reset_reword_modal_state(); + self.command_log + .push("Canceled commit message editing".to_string()); + } + + pub(super) fn submit_reword_modal(&mut self) { + match self.perform_reword_commit() { + Ok(true) => self.reset_reword_modal_state(), + Ok(false) => {} + Err(e) => { + self.command_log + .push(format!("Failed to update commit message: {}", e)); + } + } + } + + fn perform_reword_commit(&mut self) -> Result { + let Some(target) = self.reword_target.as_ref() else { + return Ok(false); + }; + + let new_message = Self::compose_reword_message(&self.reword_subject, &self.reword_message); + + if new_message.trim().is_empty() { + self.command_log + .push("Commit message cannot be empty".to_string()); + return Ok(false); + } + + if new_message.trim() == target.original_message.trim() { + self.command_log + .push("Commit message unchanged".to_string()); + return Ok(false); + } + + let project = gitbutler_project::get(self.project_id)?; + let ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + + let oid = gix::ObjectId::from_hex(target.commit_full_id.as_bytes())?; + let git2_oid = oid.to_git2(); + + self.command_log.push(format!( + "gitbutler_branch_actions::update_commit_message(stack={:?}, commit={})", + target.stack_id, target.commit_short_id + )); + + let new_commit_oid = gitbutler_branch_actions::update_commit_message( + &ctx, + target.stack_id, + git2_oid, + &new_message, + )?; + + self.command_log.push(format!( + "Rebased stack '{}' with updated commit {}", + target.branch_name, + new_commit_oid.to_gix().to_hex_with_len(7) + )); + + self.load_data_with_project(&project)?; + self.update_main_view(); + Ok(true) + } + + fn split_commit_message(message: &str) -> (String, String) { + if let Some((subject, rest)) = message.split_once("\n\n") { + (subject.to_string(), rest.to_string()) + } else { + let mut lines = message.lines(); + let subject = lines.next().unwrap_or("").to_string(); + let body = lines.collect::>().join("\n"); + (subject, body) + } + } + + fn compose_reword_message(subject: &str, body: &str) -> String { + let subject = subject.trim_end(); + if body.trim().is_empty() { + subject.to_string() + } else { + format!("{}\n\n{}", subject, body) + } + } +} diff --git a/crates/but/src/lazy/squash.rs b/crates/but/src/lazy/squash.rs new file mode 100644 index 0000000000..265412a7d1 --- /dev/null +++ b/crates/but/src/lazy/squash.rs @@ -0,0 +1,127 @@ +use anyhow::Result; +use but_oxidize::ObjectIdExt; +use but_settings::AppSettings; +use gitbutler_command_context::CommandContext; + +use super::app::{LazyApp, Panel, SquashTargetInfo}; + +impl LazyApp { + pub(super) fn open_squash_modal(&mut self) { + if !matches!(self.active_panel, Panel::Status) { + self.command_log + .push("Select a commit in the Status panel to squash".to_string()); + return; + } + + let Some(commit) = self.get_selected_commit().cloned() else { + self.command_log + .push("No commit selected to squash".to_string()); + return; + }; + + let (stack_id, branch_name, destination) = { + let Some((Some(stack_id), branch)) = self.find_branch_context(|candidate| { + candidate + .commits + .iter() + .any(|c| c.full_id == commit.full_id) + }) else { + self.command_log + .push("Unable to resolve stack for selected commit".to_string()); + return; + }; + + let Some(commit_index) = branch + .commits + .iter() + .position(|c| c.full_id == commit.full_id) + else { + self.command_log + .push("Unable to locate commit position".to_string()); + return; + }; + + if commit_index + 1 >= branch.commits.len() { + self.command_log + .push("Cannot squash the last commit in a branch".to_string()); + return; + } + + ( + stack_id, + branch.name.clone(), + branch.commits[commit_index + 1].clone(), + ) + }; + + self.reset_squash_modal_state(); + + self.squash_target = Some(SquashTargetInfo { + stack_id, + branch_name, + source_short_id: commit.id.clone(), + source_full_id: commit.full_id.clone(), + source_message: commit.message.clone(), + destination_short_id: destination.id.clone(), + destination_full_id: destination.full_id.clone(), + destination_message: destination.message.clone(), + }); + self.show_squash_modal = true; + self.command_log.push(format!( + "Preparing to squash {} into {}", + commit.id, destination.id + )); + } + + pub(super) fn cancel_squash_modal(&mut self) { + self.reset_squash_modal_state(); + self.command_log.push("Canceled squash".to_string()); + } + + pub(super) fn confirm_squash_modal(&mut self) { + match self.perform_squash() { + Ok(true) => self.reset_squash_modal_state(), + Ok(false) => {} + Err(e) => self.command_log.push(format!("Failed to squash: {}", e)), + } + } + + fn perform_squash(&mut self) -> Result { + let Some(target) = self.squash_target.as_ref() else { + return Ok(false); + }; + + let project = gitbutler_project::get(self.project_id)?; + let ctx = + &mut CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + + let source_oid = gix::ObjectId::from_hex(target.source_full_id.as_bytes())?; + let destination_oid = gix::ObjectId::from_hex(target.destination_full_id.as_bytes())?; + + self.command_log.push(format!( + "gitbutler_branch_actions::squash_commits(stack={:?}, source={}, destination={})", + target.stack_id, target.source_short_id, target.destination_short_id + )); + + gitbutler_branch_actions::squash_commits( + ctx, + target.stack_id, + vec![source_oid.to_git2()], + destination_oid.to_git2(), + )?; + + self.command_log.push(format!( + "Squashed {} into {}", + target.source_short_id, target.destination_short_id + )); + + self.load_data_with_project(&project)?; + self.update_main_view(); + Ok(true) + } + + fn reset_squash_modal_state(&mut self) { + self.show_squash_modal = false; + self.squash_target = None; + } +} diff --git a/crates/but/src/lazy/uncommit.rs b/crates/but/src/lazy/uncommit.rs new file mode 100644 index 0000000000..0a18f468e5 --- /dev/null +++ b/crates/but/src/lazy/uncommit.rs @@ -0,0 +1,96 @@ +use anyhow::Result; +use but_oxidize::ObjectIdExt; +use but_settings::AppSettings; +use gitbutler_command_context::CommandContext; + +use super::app::{LazyApp, Panel, UncommitTargetInfo}; + +impl LazyApp { + pub(super) fn open_uncommit_modal(&mut self) { + if !matches!(self.active_panel, Panel::Status) { + self.command_log + .push("Select a commit in the Status panel to uncommit".to_string()); + return; + } + + let Some(commit) = self.get_selected_commit().cloned() else { + self.command_log + .push("No commit selected to uncommit".to_string()); + return; + }; + + let Some((Some(stack_id), branch)) = self.find_branch_context(|candidate| { + candidate + .commits + .iter() + .any(|c| c.full_id == commit.full_id) + }) else { + self.command_log + .push("Unable to resolve stack for selected commit".to_string()); + return; + }; + + let branch_name = branch.name.clone(); + + self.reset_uncommit_modal_state(); + + self.uncommit_target = Some(UncommitTargetInfo { + stack_id, + branch_name, + commit_short_id: commit.id.clone(), + commit_full_id: commit.full_id.clone(), + commit_message: commit.message.clone(), + }); + self.show_uncommit_modal = true; + self.command_log + .push(format!("Preparing to uncommit {}", commit.id)); + } + + pub(super) fn cancel_uncommit_modal(&mut self) { + self.reset_uncommit_modal_state(); + self.command_log.push("Canceled uncommit".to_string()); + } + + pub(super) fn confirm_uncommit_modal(&mut self) { + match self.perform_uncommit() { + Ok(true) => self.reset_uncommit_modal_state(), + Ok(false) => {} + Err(e) => { + self.command_log.push(format!("Failed to uncommit: {}", e)); + } + } + } + + fn perform_uncommit(&mut self) -> Result { + let Some(target) = self.uncommit_target.as_ref() else { + return Ok(false); + }; + + let project = gitbutler_project::get(self.project_id)?; + let ctx = + &mut CommandContext::open(&project, AppSettings::load_from_default_path_creating()?)?; + + let oid = gix::ObjectId::from_hex(target.commit_full_id.as_bytes())?; + + self.command_log.push(format!( + "gitbutler_branch_actions::undo_commit(stack={:?}, commit={})", + target.stack_id, target.commit_short_id + )); + + gitbutler_branch_actions::undo_commit(ctx, target.stack_id, oid.to_git2())?; + + self.command_log.push(format!( + "Uncommitted {} from branch {}", + target.commit_short_id, target.branch_name + )); + + self.load_data_with_project(&project)?; + self.update_main_view(); + Ok(true) + } + + fn reset_uncommit_modal_state(&mut self) { + self.show_uncommit_modal = false; + self.uncommit_target = None; + } +} diff --git a/crates/but/src/lib.rs b/crates/but/src/lib.rs index 0ccffaeac6..b0685ac285 100644 --- a/crates/but/src/lib.rs +++ b/crates/but/src/lib.rs @@ -32,6 +32,7 @@ mod forge; mod gui; mod id; mod init; +mod lazy; mod mark; mod mcp; mod mcp_internal; @@ -346,6 +347,10 @@ async fn match_subcommand( let project = get_or_init_legacy_non_bare_project(&args)?; absorb::handle(&project, out, source.as_deref()).emit_metrics(metrics_ctx) } + Subcommands::Lazy => { + let project = get_or_init_legacy_non_bare_project(&args)?; + lazy::run(&project).emit_metrics(metrics_ctx) + } Subcommands::Init { repo } => init::repo(&args.current_dir, out, repo) .context("Failed to initialize GitButler project.") .emit_metrics(metrics_ctx), diff --git a/crates/but/src/metrics.rs b/crates/but/src/metrics.rs index aaca576d7e..9af7e5ab04 100644 --- a/crates/but/src/metrics.rs +++ b/crates/but/src/metrics.rs @@ -87,6 +87,7 @@ mod subcommands_impl { }, Subcommands::Completions { .. } => Completions, Subcommands::Absorb { .. } => Absorb, + Subcommands::Lazy => Lazy, Subcommands::Init { .. } => Init, Subcommands::Metrics { .. } | Subcommands::Actions(_) | Subcommands::Mcp { .. } => { Unknown @@ -170,6 +171,7 @@ impl Subcommands { }, Subcommands::Completions { .. } => Completions, Subcommands::Absorb { .. } => Absorb, + Subcommands::Lazy => Lazy, Subcommands::Metrics { .. } | Subcommands::Actions(_) | Subcommands::Mcp { .. } @@ -217,6 +219,7 @@ pub enum CommandName { PublishReview, ReviewTemplate, Completions, + Lazy, #[default] Unknown, }