diff --git a/Cargo.lock b/Cargo.lock
index 8844f710df..abd9ab1fb9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -165,7 +165,7 @@ dependencies = [
"anstyle-lossy",
"anstyle-parse",
"html-escape",
- "unicode-width",
+ "unicode-width 0.2.0",
]
[[package]]
@@ -738,6 +738,7 @@ dependencies = [
"cli-prompts",
"colored",
"command-group",
+ "crossterm 0.28.1",
"git2",
"gitbutler-branch",
"gitbutler-branch-actions",
@@ -748,23 +749,24 @@ dependencies = [
"gitbutler-stack",
"gix",
"insta",
- "itertools",
+ "itertools 0.14.0",
"minus",
"posthog-rs",
+ "ratatui",
"regex",
"rmcp",
"serde",
"serde_json",
"shell-words",
"snapbox",
- "strum",
+ "strum 0.27.2",
"tempfile",
"terminal_size",
"tokio",
"tracing",
"tracing-forest",
"tracing-subscriber",
- "unicode-width",
+ "unicode-width 0.2.0",
]
[[package]]
@@ -795,14 +797,14 @@ dependencies = [
"gitbutler-reference",
"gitbutler-stack",
"gix",
- "itertools",
+ "itertools 0.14.0",
"reqwest 0.12.24",
"rmcp",
"schemars 1.1.0",
"serde",
"serde-error",
"serde_json",
- "strum",
+ "strum 0.27.2",
"tokio",
"tracing",
"uuid",
@@ -951,7 +953,7 @@ dependencies = [
"serde",
"serde_json",
"serde_json_lenient",
- "strum",
+ "strum 0.27.2",
"tokio",
"tracing",
"url",
@@ -1142,7 +1144,7 @@ dependencies = [
"gix",
"gix-testtools",
"insta",
- "itertools",
+ "itertools 0.14.0",
"petgraph",
"tracing",
]
@@ -1158,7 +1160,7 @@ dependencies = [
"but-db",
"but-hunk-dependency",
"gitbutler-stack",
- "itertools",
+ "itertools 0.14.0",
"serde",
"serde-error",
"serde_json",
@@ -1184,7 +1186,7 @@ dependencies = [
"gix",
"gix-testtools",
"insta",
- "itertools",
+ "itertools 0.14.0",
"serde",
"serde_json",
]
@@ -1204,7 +1206,7 @@ dependencies = [
"gix",
"hex",
"insta",
- "itertools",
+ "itertools 0.14.0",
"md5",
"serde",
"toml 0.9.8",
@@ -1280,7 +1282,7 @@ dependencies = [
"gitbutler-branch-actions",
"gitbutler-stack",
"gix",
- "itertools",
+ "itertools 0.14.0",
"regex",
"serde",
"serde_json",
@@ -1387,7 +1389,7 @@ dependencies = [
"gitbutler-reference",
"gitbutler-stack",
"gix",
- "itertools",
+ "itertools 0.14.0",
"serde_json",
"tokio",
"tracing",
@@ -1467,7 +1469,7 @@ dependencies = [
"gitbutler-stack",
"gix",
"insta",
- "itertools",
+ "itertools 0.14.0",
"md5",
"serde",
"tempfile",
@@ -1640,6 +1642,21 @@ dependencies = [
"toml 0.9.8",
]
+[[package]]
+name = "cassowary"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
+
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "cbc"
version = "0.1.2"
@@ -1877,6 +1894,20 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "compact_str"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -2151,6 +2182,22 @@ dependencies = [
"winapi",
]
+[[package]]
+name = "crossterm"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
+dependencies = [
+ "bitflags 2.10.0",
+ "crossterm_winapi",
+ "mio 1.1.0",
+ "parking_lot",
+ "rustix 0.38.44",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
@@ -3438,7 +3485,7 @@ dependencies = [
"gitbutler-reference",
"gitbutler-stack",
"gix",
- "itertools",
+ "itertools 0.14.0",
"lazy_static",
"serde",
]
@@ -3483,7 +3530,7 @@ dependencies = [
"gitbutler-workspace",
"gix",
"glob",
- "itertools",
+ "itertools 0.14.0",
"md5",
"pretty_assertions",
"serde",
@@ -3627,7 +3674,7 @@ dependencies = [
"git2",
"gitbutler-diff",
"gitbutler-stack",
- "itertools",
+ "itertools 0.14.0",
"serde",
]
@@ -3686,10 +3733,10 @@ dependencies = [
"gitbutler-repo",
"gitbutler-stack",
"gix",
- "itertools",
+ "itertools 0.14.0",
"pretty_assertions",
"serde",
- "strum",
+ "strum 0.27.2",
"tempfile",
"toml 0.9.8",
"tracing",
@@ -3713,7 +3760,7 @@ dependencies = [
"resolve-path",
"serde",
"serde_json",
- "strum",
+ "strum 0.27.2",
"tempfile",
"tracing",
]
@@ -3759,7 +3806,7 @@ dependencies = [
"ignore",
"infer",
"insta",
- "itertools",
+ "itertools 0.14.0",
"resolve-path",
"scopeguard",
"serde",
@@ -3816,7 +3863,7 @@ dependencies = [
"gitbutler-testsupport",
"gitbutler-time",
"gix",
- "itertools",
+ "itertools 0.14.0",
"serde",
"tempfile",
"toml 0.9.8",
@@ -3840,7 +3887,7 @@ dependencies = [
"gitbutler-url",
"gitbutler-user",
"gix",
- "itertools",
+ "itertools 0.14.0",
"rand 0.9.2",
"tracing",
]
@@ -5441,6 +5488,8 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
+ "allocator-api2",
+ "equivalent",
"foldhash 0.1.5",
]
@@ -5936,6 +5985,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "indoc"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "infer"
version = "0.19.0"
@@ -5987,6 +6045,19 @@ dependencies = [
"similar",
]
+[[package]]
+name = "instability"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c"
+dependencies = [
+ "darling 0.20.11",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.111",
+]
+
[[package]]
name = "instant"
version = "0.1.13"
@@ -6053,6 +6124,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+[[package]]
+name = "itertools"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itertools"
version = "0.14.0"
@@ -6476,6 +6556,15 @@ dependencies = [
"value-bag",
]
+[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -8029,7 +8118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425"
dependencies = [
"anyhow",
- "itertools",
+ "itertools 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.111",
@@ -8310,6 +8399,27 @@ dependencies = [
"rand_core 0.5.1",
]
+[[package]]
+name = "ratatui"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
+dependencies = [
+ "bitflags 2.10.0",
+ "cassowary",
+ "compact_str",
+ "crossterm 0.28.1",
+ "indoc",
+ "instability",
+ "itertools 0.13.0",
+ "lru",
+ "paste",
+ "strum 0.26.3",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width 0.2.0",
+]
+
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -9393,6 +9503,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio 0.8.11",
+ "mio 1.1.0",
"signal-hook",
]
@@ -9622,13 +9733,35 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros 0.26.4",
+]
+
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
- "strum_macros",
+ "strum_macros 0.27.2",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn 2.0.111",
]
[[package]]
@@ -10399,7 +10532,7 @@ version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
- "unicode-width",
+ "unicode-width 0.2.0",
]
[[package]]
@@ -11072,6 +11205,23 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+[[package]]
+name = "unicode-truncate"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
+dependencies = [
+ "itertools 0.13.0",
+ "unicode-segmentation",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
[[package]]
name = "unicode-width"
version = "0.2.0"
diff --git a/README.md b/README.md
index e9fd2a443a..dbb86d4a8f 100644
--- a/README.md
+++ b/README.md
@@ -137,3 +137,4 @@ at the [DEVELOPMENT.md](DEVELOPMENT.md) file.
+test
diff --git a/crates/but/Cargo.toml b/crates/but/Cargo.toml
index c0ddc822aa..3fe887776d 100644
--- a/crates/but/Cargo.toml
+++ b/crates/but/Cargo.toml
@@ -37,6 +37,8 @@ legacy = [
"dep:gitbutler-branch",
"dep:gitbutler-reference",
"dep:gitbutler-oplog",
+ "dep:crossterm",
+ "dep:ratatui",
]
[dependencies]
@@ -102,6 +104,8 @@ gix.workspace = true
colored = "3.0.0"
serde_json.workspace = true
terminal_size = "0.4.3"
+crossterm = { version = "0.28", optional = true }
+ratatui = { version = "0.29", optional = true }
tracing.workspace = true
tracing-subscriber = { workspace = true, features = [
"env-filter",
diff --git a/crates/but/src/args/metrics.rs b/crates/but/src/args/metrics.rs
index 7c70c05082..b792a3c460 100644
--- a/crates/but/src/args/metrics.rs
+++ b/crates/but/src/args/metrics.rs
@@ -39,6 +39,7 @@ pub enum CommandName {
PublishReview,
ReviewTemplate,
Completions,
+ Lazy,
#[default]
Unknown,
}
diff --git a/crates/but/src/args/mod.rs b/crates/but/src/args/mod.rs
index f10be2a22f..5ce317f7c9 100644
--- a/crates/but/src/args/mod.rs
+++ b/crates/but/src/args/mod.rs
@@ -159,6 +159,25 @@ pub enum Subcommands {
target: String,
},
+ /// Launch the experimental Lazy TUI interface.
+ ///
+ /// The Lazy TUI provides an interactive terminal interface for managing your
+ /// GitButler workspace, similar to lazygit but integrated with GitButler's
+ /// virtual branch workflow.
+ ///
+ /// ## Features
+ ///
+ /// - View and manage commits across branches
+ /// - Interactive commit operations (squash, reword, uncommit)
+ /// - File assignment and hunks management
+ /// - Oplog history navigation
+ /// - Upstream integration status
+ ///
+ /// This is an experimental feature and may have rough edges.
+ #[cfg(feature = "legacy")]
+ #[clap(hide = true)]
+ Lazy,
+
/// Initializes a GitButler project from a git repository in the current directory.
///
/// If you have an existing Git repository and want to start using GitButler
diff --git a/crates/but/src/command/legacy/describe.rs b/crates/but/src/command/legacy/describe.rs
index 5177df146d..aaaa5396b7 100644
--- a/crates/but/src/command/legacy/describe.rs
+++ b/crates/but/src/command/legacy/describe.rs
@@ -7,6 +7,8 @@ use gix::prelude::ObjectIdExt;
use crate::{CliId, IdMap, tui, utils::OutputChannel};
+// new comment
+
pub(crate) fn describe_target(
project: &Project,
out: &mut OutputChannel,
diff --git a/crates/but/src/command/legacy/forge/review.rs b/crates/but/src/command/legacy/forge/review.rs
index 334d7b5a35..e84d796c16 100644
--- a/crates/but/src/command/legacy/forge/review.rs
+++ b/crates/but/src/command/legacy/forge/review.rs
@@ -218,6 +218,8 @@ fn prompt_for_branch_selection(
return Ok(vec![]);
}
+ // another comment
+
// Display branches with numbers
println!("\nAvailable branches to publish:\n");
for (idx, (name, commit_count, reviews)) in all_branches.iter().enumerate() {
diff --git a/crates/but/src/lazy/absorb.rs b/crates/but/src/lazy/absorb.rs
new file mode 100644
index 0000000000..9e8fa71727
--- /dev/null
+++ b/crates/but/src/lazy/absorb.rs
@@ -0,0 +1,90 @@
+use anyhow::Result;
+
+use super::app::{LazyApp, Panel};
+use crate::{
+ OutputFormat,
+ command::legacy::absorb,
+ utils::OutputChannel,
+};
+
+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..c4801ba41c
--- /dev/null
+++ b/crates/but/src/lazy/app.rs
@@ -0,0 +1,1932 @@
+use anyhow::Result;
+use bstr::ByteSlice;
+use but_oxidize::TimeExt;
+use crossterm::{
+ event::{self, DisableMouseCapture, EnableMouseCapture, Event},
+ execute,
+ terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
+};
+use gitbutler_project::{Project, ProjectId};
+use ratatui::{
+ Terminal,
+ backend::CrosstermBackend,
+ layout::Rect,
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::ListState,
+};
+use std::{
+ collections::{BTreeMap, HashSet},
+ io,
+ time::Duration,
+};
+
+use super::render::ui;
+use crate::command::legacy::status::assignment::FileAssignment;
+use but_hunk_assignment::HunkAssignment;
+use but_workspace::ui::StackDetails;
+use gitbutler_branch_actions::upstream_integration::{
+ BranchStatus as UpstreamBranchStatus, StackStatuses,
+ };
+
+#[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,
+}
+
+#[allow(dead_code)]
+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)]
+#[allow(dead_code)]
+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,
+}
+
+#[allow(dead_code)]
+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)?;
+
+ // NOTE: CommandContext no longer exists, upstream status disabled for now
+ // 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)
+ self.upstream_info = None; // TODO: Reimplement without CommandContext
+ // .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();
+ // TODO: Build proper IdMap from stacks, for now use empty one
+ let id_map = crate::IdMap::new_for_branches_and_commits(&[]).unwrap_or_else(|_| panic!("Failed to create IdMap"));
+ for (path, assignments) in &by_file {
+ assignments_by_file.insert(
+ path.clone(),
+ FileAssignment::from_assignments(&id_map, path, assignments),
+ );
+ }
+
+ // Get unassigned files
+ self.unassigned_files =
+ crate::command::legacy::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::command::legacy::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)> {
+ // TODO: Reimplement - needs Context instead of project_id
+ // For now return empty changes
+ Ok((
+ Vec::new(),
+ but_core::ui::TreeStats {
+ lines_added: 0,
+ lines_removed: 0,
+ files_changed: 0,
+ },
+ ))
+ }
+
+ 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(),
+ }
+ }
+
+ // NOTE: CommandContext has been removed from the codebase, these functions
+ // need to be refactored to work without it
+ // fn open_command_context(project: &Project) -> Option {
+ // let settings = AppSettings::load_from_default_path_creating_without_customization().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<()> {
+ // TODO: Reimplement without CommandContext
+ self.command_log.push("Upstream update not yet implemented".to_string());
+ Ok(())
+
+ // let project = gitbutler_project::get(self.project_id)?;
+ // let ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating_without_customization()?)?;
+ // 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..dab328773a
--- /dev/null
+++ b/crates/but/src/lazy/commit.rs
@@ -0,0 +1,400 @@
+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::command::legacy::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::command::legacy::status::assignment::filter_by_stack_id(assignments_by_file.values(), &None);
+ files_to_commit.extend(unassigned);
+ }
+
+ let stack_assignments = crate::command::legacy::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);
+ }
+
+ // TODO: Build proper IdMap from stacks, for now use empty one
+ let id_map = crate::IdMap::new_for_branches_and_commits(&[]).unwrap_or_else(|_| panic!("Failed to create IdMap"));
+ let mut assignments_by_file = BTreeMap::new();
+ for (path, grouped) in by_file {
+ assignments_by_file.insert(
+ path.clone(),
+ FileAssignment::from_assignments(&id_map, &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..5266b228a8
--- /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::command::legacy::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..8ec655ab3e
--- /dev/null
+++ b/crates/but/src/lazy/reword.rs
@@ -0,0 +1,146 @@
+use anyhow::{Result, anyhow};
+
+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);
+ }
+
+ // TODO: Reimplement without CommandContext
+ self.command_log.push("Reword not yet implemented - CommandContext removed".to_string());
+ return Err(anyhow!("Reword functionality needs to be reimplemented without CommandContext"));
+
+ // let project = gitbutler_project::get(self.project_id)?;
+ // let ctx = CommandContext::open(&project, AppSettings::load_from_default_path_creating_without_customization()?)?;
+
+ // 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..9ad2eedfec
--- /dev/null
+++ b/crates/but/src/lazy/squash.rs
@@ -0,0 +1,127 @@
+use anyhow::{Result, anyhow};
+
+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);
+ };
+
+ // TODO: Reimplement without CommandContext
+ self.command_log.push("Squash not yet implemented - CommandContext removed".to_string());
+ Err(anyhow!("Squash functionality needs to be reimplemented without CommandContext"))
+
+ // let project = gitbutler_project::get(self.project_id)?;
+ // let ctx =
+ // &mut CommandContext::open(&project, AppSettings::load_from_default_path_creating_without_customization()?)?;
+
+ // 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..198aae3edf
--- /dev/null
+++ b/crates/but/src/lazy/uncommit.rs
@@ -0,0 +1,96 @@
+use anyhow::{Result, anyhow};
+
+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);
+ };
+
+ // TODO: Reimplement without CommandContext
+ self.command_log.push("Uncommit not yet implemented - CommandContext removed".to_string());
+ Err(anyhow!("Uncommit functionality needs to be reimplemented without CommandContext"))
+
+ // let project = gitbutler_project::get(self.project_id)?;
+ // let ctx =
+ // &mut CommandContext::open(&project, AppSettings::load_from_default_path_creating_without_customization()?)?;
+
+ // 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 c18a70d439..df803f0abd 100644
--- a/crates/but/src/lib.rs
+++ b/crates/but/src/lib.rs
@@ -29,7 +29,8 @@ use cfg_if::cfg_if;
pub mod args;
use args::{
- Args, OutputFormat, Subcommands, actions, base, branch, claude, cursor, forge, metrics,
+ Args, OutputFormat, Subcommands, actions, base, branch, claude, cursor, forge,
+ metrics::CommandName,
worktree,
};
use but_settings::AppSettings;
@@ -46,6 +47,8 @@ pub use id::{CliId, IdMap};
/// A place for all command implementations.
pub(crate) mod command;
mod tui;
+#[cfg(feature = "legacy")]
+mod lazy;
const CLI_DATE: CustomFormat = gix::date::time::format::ISO8601;
@@ -119,7 +122,7 @@ pub async fn handle_args(args: impl Iterator- ) -> Result<()> {
.context("Rubbed the wrong way.")
.emit_metrics(OneshotMetricsContext::new_if_enabled(
&app_settings,
- metrics::CommandName::Rub,
+ CommandName::Rub,
))
.show_root_cause_error_then_exit_without_destructors(out)
}
@@ -424,6 +427,11 @@ async fn match_subcommand(
.context("Failed to initialize GitButler project.")
.emit_metrics(metrics_ctx),
#[cfg(feature = "legacy")]
+ Subcommands::Lazy => {
+ let project = legacy::get_or_init_non_bare_project(&args)?;
+ lazy::run(&project).emit_metrics(metrics_ctx)
+ }
+ #[cfg(feature = "legacy")]
Subcommands::Review(forge::review::Platform { cmd }) => match cmd {
forge::review::Subcommands::Publish {
branch,
diff --git a/crates/but/src/utils/metrics.rs b/crates/but/src/utils/metrics.rs
index d1e3b2c8f4..f865c62b87 100644
--- a/crates/but/src/utils/metrics.rs
+++ b/crates/but/src/utils/metrics.rs
@@ -127,6 +127,8 @@ impl Subcommands {
forge::review::Subcommands::Template { .. } => ReviewTemplate,
},
#[cfg(feature = "legacy")]
+ Subcommands::Lazy => Lazy,
+ #[cfg(feature = "legacy")]
Subcommands::Actions(_) | Subcommands::Mcp { .. } | Subcommands::Init { .. } => Unknown,
Subcommands::Forge(forge::integration::Platform { cmd }) => match cmd {
forge::integration::Subcommands::Auth => ForgeAuth,