use crate::cache::CiCache;
use crate::engine::Stack;
use crate::git::GitRepo;
use anyhow::Result;
use std::collections::HashMap;
use std::time::Instant;
#[derive(Debug, Clone)]
pub struct DiffLine {
pub content: String,
pub line_type: DiffLineType,
}
#[derive(Debug, Clone, PartialEq)]
pub enum DiffLineType {
Header,
Addition,
Deletion,
Context,
Hunk,
}
#[derive(Debug, Clone)]
pub struct DiffStatLine {
pub file: String,
pub additions: usize,
pub deletions: usize,
}
#[derive(Debug, Clone)]
struct CachedDiff {
stat: Vec<DiffStatLine>,
lines: Vec<DiffLine>,
}
#[derive(Debug, Clone)]
pub struct BranchDisplay {
pub name: String,
pub parent: Option<String>,
pub column: usize,
pub is_current: bool,
pub is_trunk: bool,
pub ahead: usize, pub behind: usize, pub needs_restack: bool,
pub has_remote: bool,
pub unpushed: usize, pub unpulled: usize, pub pr_number: Option<u64>,
pub pr_state: Option<String>,
pub ci_state: Option<String>,
pub commits: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub enum FocusedPane {
#[default]
Stack,
Diff,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Mode {
Normal,
Search,
Help,
Confirm(ConfirmAction),
Input(InputAction),
Reorder,
}
#[derive(Debug, Clone, PartialEq)]
pub enum InputAction {
Rename,
NewBranch,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ConfirmAction {
Delete(String),
Restack(String),
RestackAll,
ApplyReorder,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConflictInfo {
pub file: String,
pub branches_involved: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ReorderPreview {
pub commits_to_rebase: Vec<(String, Vec<String>)>,
pub potential_conflicts: Vec<ConflictInfo>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct StackChainEntry {
pub name: String,
pub parent: String,
}
#[derive(Debug, Clone)]
pub struct ReorderState {
pub original_chain: Vec<StackChainEntry>,
pub pending_chain: Vec<StackChainEntry>,
pub moving_index: usize,
pub preview: ReorderPreview,
}
#[derive(Debug, Clone)]
pub struct PendingCommand {
pub commands: Vec<Vec<String>>,
pub success_message: String,
pub preferred_selection: Option<String>,
}
pub struct App {
pub stack: Stack,
pub cache: CiCache,
pub repo: GitRepo,
pub current_branch: String,
pub selected_index: usize,
pub branches: Vec<BranchDisplay>,
pub mode: Mode,
pub search_query: String,
pub filtered_indices: Vec<usize>,
pub input_buffer: String,
pub input_cursor: usize,
pub selected_diff: Vec<DiffLine>,
pub diff_scroll: usize,
pub focused_pane: FocusedPane,
pub diff_stat: Vec<DiffStatLine>,
pub status_message: Option<String>,
pub status_set_at: Option<Instant>,
pub should_quit: bool,
pub pending_command: Option<PendingCommand>,
pub needs_refresh: bool,
pub reorder_state: Option<ReorderState>,
diff_cache: HashMap<String, CachedDiff>,
}
impl App {
pub fn new(
initial_status: Option<String>,
preferred_selection: Option<String>,
) -> Result<Self> {
let repo = GitRepo::open()?;
let stack = Stack::load(&repo)?;
let current_branch = repo.current_branch()?;
let git_dir = repo.git_dir()?;
let cache = CiCache::load(git_dir);
let status_set_at = initial_status.as_ref().map(|_| Instant::now());
let mut app = Self {
stack,
cache,
repo,
current_branch,
selected_index: 0,
branches: Vec::new(),
mode: Mode::Normal,
search_query: String::new(),
filtered_indices: Vec::new(),
input_buffer: String::new(),
input_cursor: 0,
selected_diff: Vec::new(),
diff_scroll: 0,
focused_pane: FocusedPane::Stack,
diff_stat: Vec::new(),
status_message: initial_status,
status_set_at,
should_quit: false,
pending_command: None,
needs_refresh: true,
reorder_state: None,
diff_cache: HashMap::new(),
};
app.refresh_branches()?;
if let Some(branch) = preferred_selection {
app.select_branch(&branch);
} else {
app.select_current_branch();
}
app.update_diff();
Ok(app)
}
pub fn refresh_branches(&mut self) -> Result<()> {
self.stack = Stack::load(&self.repo)?;
self.current_branch = self.repo.current_branch()?;
self.branches = self.build_branch_list()?;
self.diff_cache.clear();
self.needs_refresh = false;
self.update_diff();
Ok(())
}
fn build_branch_list(&self) -> Result<Vec<BranchDisplay>> {
let mut branches = Vec::new();
let trunk = &self.stack.trunk;
let trunk_info = self.stack.branches.get(trunk);
let trunk_children: Vec<String> =
trunk_info.map(|b| b.children.clone()).unwrap_or_default();
if trunk_children.is_empty() {
branches.push(self.create_branch_display(trunk, 0, true)?);
return Ok(branches);
}
let mut max_column = 0;
let mut sorted_trunk_children = trunk_children;
sorted_trunk_children.sort();
for (i, root) in sorted_trunk_children.iter().enumerate() {
self.collect_branches(&mut branches, root, i, &mut max_column)?;
}
branches.push(self.create_branch_display(trunk, 0, true)?);
Ok(branches)
}
fn collect_branches(
&self,
result: &mut Vec<BranchDisplay>,
branch: &str,
base_column: usize,
max_column: &mut usize,
) -> Result<()> {
#[derive(Clone)]
struct Frame {
branch: String,
column: usize,
expanded: bool,
}
let mut stack_frames = vec![Frame {
branch: branch.to_string(),
column: base_column,
expanded: false,
}];
let mut visiting = std::collections::HashSet::new();
let mut emitted = std::collections::HashSet::new();
while let Some(frame) = stack_frames.pop() {
if frame.expanded {
visiting.remove(&frame.branch);
if emitted.insert(frame.branch.clone()) {
result.push(self.create_branch_display(&frame.branch, frame.column, false)?);
}
continue;
}
if emitted.contains(&frame.branch) || !visiting.insert(frame.branch.clone()) {
continue;
}
*max_column = (*max_column).max(frame.column);
stack_frames.push(Frame {
branch: frame.branch.clone(),
column: frame.column,
expanded: true,
});
if let Some(info) = self.stack.branches.get(&frame.branch) {
let mut children: Vec<&String> = info.children.iter().collect();
children.sort();
for (i, child) in children.into_iter().enumerate().rev() {
if emitted.contains(child) || visiting.contains(child) {
continue;
}
stack_frames.push(Frame {
branch: child.clone(),
column: frame.column + i,
expanded: false,
});
}
}
}
Ok(())
}
fn create_branch_display(
&self,
branch: &str,
column: usize,
is_trunk: bool,
) -> Result<BranchDisplay> {
let is_current = branch == self.current_branch;
let info = self.stack.branches.get(branch);
let (ahead, behind) = if let Some(info) = info {
if let Some(parent) = &info.parent {
self.repo
.commits_ahead_behind(parent, branch)
.unwrap_or((0, 0))
} else {
(0, 0)
}
} else {
(0, 0)
};
let needs_restack = info.map(|i| i.needs_restack).unwrap_or(false);
let has_remote = self.repo.has_remote(branch);
let (unpushed, unpulled) = self.repo.commits_vs_remote(branch).unwrap_or((0, 0));
let pr_number = info.and_then(|i| i.pr_number);
let pr_state = info.and_then(|i| i.pr_state.clone());
let parent = info.and_then(|i| i.parent.clone());
let commits = if let Some(parent) = &parent {
self.repo
.commits_between(parent, branch)
.unwrap_or_default()
.into_iter()
.take(10)
.collect()
} else {
Vec::new()
};
let ci_state = self.cache.get_ci_state(branch);
Ok(BranchDisplay {
name: branch.to_string(),
parent,
column,
is_current,
is_trunk,
ahead,
behind,
needs_restack,
has_remote,
unpushed,
unpulled,
pr_number,
pr_state,
ci_state,
commits,
})
}
pub fn select_current_branch(&mut self) {
if let Some(idx) = self.branches.iter().position(|b| b.is_current) {
self.selected_index = idx;
}
}
pub fn select_branch(&mut self, branch: &str) {
if let Some(idx) = self.branches.iter().position(|b| b.name == branch) {
self.selected_index = idx;
self.update_diff();
return;
}
self.select_current_branch();
self.update_diff();
}
pub fn selected_branch(&self) -> Option<&BranchDisplay> {
if self.mode == Mode::Search {
self.filtered_indices
.get(self.selected_index)
.and_then(|&idx| self.branches.get(idx))
} else {
self.branches.get(self.selected_index)
}
}
pub fn select_previous(&mut self) {
let len = if self.mode == Mode::Search {
self.filtered_indices.len()
} else {
self.branches.len()
};
if len > 0 && self.selected_index > 0 {
self.selected_index -= 1;
self.update_diff();
}
}
pub fn select_next(&mut self) {
let len = if self.mode == Mode::Search {
self.filtered_indices.len()
} else {
self.branches.len()
};
if len > 0 && self.selected_index < len - 1 {
self.selected_index += 1;
self.update_diff();
}
}
pub fn update_search(&mut self) {
let query = self.search_query.to_lowercase();
self.filtered_indices = self
.branches
.iter()
.enumerate()
.filter(|(_, b)| b.name.to_lowercase().contains(&query))
.map(|(i, _)| i)
.collect();
self.selected_index = 0;
}
pub fn update_diff(&mut self) {
self.selected_diff.clear();
self.diff_stat.clear();
self.diff_scroll = 0;
let (branch_name, parent_name) = match self.selected_branch() {
Some(branch) => match &branch.parent {
Some(parent) => (branch.name.clone(), parent.clone()),
None => return,
},
None => return,
};
let cache_key = format!("{}...{}", parent_name, branch_name);
if let Some(cached) = self.diff_cache.get(&cache_key) {
self.diff_stat = cached.stat.clone();
self.selected_diff = cached.lines.clone();
return;
}
if let Ok(stats) = self.repo.diff_stat(&branch_name, &parent_name) {
self.diff_stat = stats
.into_iter()
.map(|(file, additions, deletions)| DiffStatLine {
file,
additions,
deletions,
})
.collect();
}
if let Ok(lines) = self.repo.diff_against_parent(&branch_name, &parent_name) {
self.selected_diff = lines
.into_iter()
.map(|line| {
let line_type = if line.starts_with("+++") || line.starts_with("---") {
DiffLineType::Header
} else if line.starts_with('+') {
DiffLineType::Addition
} else if line.starts_with('-') {
DiffLineType::Deletion
} else if line.starts_with("@@") {
DiffLineType::Hunk
} else if line.starts_with("diff ") || line.starts_with("index ") {
DiffLineType::Header
} else {
DiffLineType::Context
};
DiffLine {
content: line,
line_type,
}
})
.collect();
}
self.diff_cache.insert(
cache_key,
CachedDiff {
stat: self.diff_stat.clone(),
lines: self.selected_diff.clone(),
},
);
}
pub fn total_diff_lines(&self) -> usize {
self.selected_diff.len()
}
pub fn set_status(&mut self, msg: impl Into<String>) {
self.status_message = Some(msg.into());
self.status_set_at = Some(Instant::now());
}
pub fn queue_command(
&mut self,
commands: Vec<Vec<String>>,
success_message: impl Into<String>,
preferred_selection: Option<String>,
) {
self.pending_command = Some(PendingCommand {
commands,
success_message: success_message.into(),
preferred_selection,
});
self.should_quit = true;
}
pub fn clear_stale_status(&mut self) {
if let Some(set_at) = self.status_set_at {
if set_at.elapsed().as_secs() >= 2 {
self.status_message = None;
self.status_set_at = None;
}
}
}
pub fn init_reorder_state(&mut self) -> bool {
let branch = match self.selected_branch() {
Some(b) => b.clone(),
None => return false,
};
if branch.is_trunk {
self.set_status("Cannot reorder trunk branch");
return false;
}
let chain = self.build_stack_chain(&branch.name);
if chain.len() < 2 {
self.set_status("Stack too small to reorder");
return false;
}
let moving_index = match chain.iter().position(|e| e.name == branch.name) {
Some(idx) => idx,
None => {
self.set_status("Branch not found in stack chain");
return false;
}
};
self.reorder_state = Some(ReorderState {
original_chain: chain.clone(),
pending_chain: chain,
moving_index,
preview: ReorderPreview::default(),
});
self.update_reorder_preview();
true
}
fn build_stack_chain(&self, branch_name: &str) -> Vec<StackChainEntry> {
let mut ancestors = vec![branch_name.to_string()];
let mut current = branch_name.to_string();
while let Some(info) = self.stack.branches.get(¤t) {
if let Some(parent) = &info.parent {
if *parent == self.stack.trunk {
break; }
ancestors.push(parent.clone());
current = parent.clone();
} else {
break;
}
}
ancestors.reverse();
let mut chain = Vec::new();
let mut prev_parent = self.stack.trunk.clone();
for ancestor in &ancestors {
chain.push(StackChainEntry {
name: ancestor.clone(),
parent: prev_parent.clone(),
});
prev_parent = ancestor.clone();
}
let mut current = branch_name.to_string();
while let Some(info) = self.stack.branches.get(¤t) {
if info.children.len() == 1 {
let child = &info.children[0];
chain.push(StackChainEntry {
name: child.clone(),
parent: current.clone(),
});
current = child.clone();
} else {
break; }
}
chain
}
pub fn reorder_move_up(&mut self) {
if let Some(ref mut state) = self.reorder_state {
if state.moving_index > 0 {
let i = state.moving_index;
let new_parent = state.pending_chain[i - 1].parent.clone();
let moving_branch = state.pending_chain[i].name.clone();
let displaced_branch = state.pending_chain[i - 1].name.clone();
state.pending_chain[i - 1].parent = moving_branch.clone();
state.pending_chain[i].parent = new_parent;
if i + 1 < state.pending_chain.len() {
state.pending_chain[i + 1].parent = displaced_branch.clone();
}
state.pending_chain.swap(i, i - 1);
state.moving_index -= 1;
self.update_reorder_preview();
}
}
}
pub fn reorder_move_down(&mut self) {
if let Some(ref mut state) = self.reorder_state {
if state.moving_index < state.pending_chain.len() - 1 {
let i = state.moving_index;
let moving_branch = state.pending_chain[i].name.clone();
let displaced_branch = state.pending_chain[i + 1].name.clone();
let moving_parent = state.pending_chain[i].parent.clone();
state.pending_chain[i].parent = displaced_branch.clone();
state.pending_chain[i + 1].parent = moving_parent;
if i + 2 < state.pending_chain.len() {
state.pending_chain[i + 2].parent = moving_branch.clone();
}
state.pending_chain.swap(i, i + 1);
state.moving_index += 1;
self.update_reorder_preview();
}
}
}
pub fn reorder_has_changes(&self) -> bool {
self.reorder_state
.as_ref()
.map(|s| s.original_chain != s.pending_chain)
.unwrap_or(false)
}
pub fn get_reparent_operations(&self) -> Vec<(String, String)> {
let state = match &self.reorder_state {
Some(s) => s,
None => return Vec::new(),
};
let mut ops = Vec::new();
for pending in &state.pending_chain {
if let Some(original) = state.original_chain.iter().find(|e| e.name == pending.name) {
if original.parent != pending.parent {
ops.push((pending.name.clone(), pending.parent.clone()));
}
}
}
ops
}
pub fn update_reorder_preview(&mut self) {
let state = match &self.reorder_state {
Some(s) => s.clone(),
None => return,
};
let mut commits_to_rebase = Vec::new();
let mut potential_conflicts = Vec::new();
for entry in &state.pending_chain {
let original_parent = state
.original_chain
.iter()
.find(|e| e.name == entry.name)
.map(|e| e.parent.clone());
if original_parent.as_ref() != Some(&entry.parent) {
if let Some(orig_parent) = &original_parent {
let commits = self
.repo
.commits_between(orig_parent, &entry.name)
.unwrap_or_default();
if !commits.is_empty() {
commits_to_rebase.push((entry.name.clone(), commits));
}
if let Ok(conflict_files) =
self.repo.check_rebase_conflicts(&entry.name, &entry.parent)
{
for file in conflict_files {
potential_conflicts.push(ConflictInfo {
file,
branches_involved: vec![entry.name.clone(), entry.parent.clone()],
});
}
}
}
}
}
if let Some(ref mut reorder_state) = self.reorder_state {
reorder_state.preview = ReorderPreview {
commits_to_rebase,
potential_conflicts,
};
}
}
pub fn clear_reorder_state(&mut self) {
self.reorder_state = None;
}
}