use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender, channel};
use std::thread::JoinHandle;
use std::time::Duration;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
use ratatui::{
Frame,
layout::{Position, Rect},
style::Modifier,
text::{Line, Span},
widgets::ListItem,
};
use crate::core::graph::{self, Section};
use crate::core::hunk_select::{self, HunkArgs};
use crate::core::repo::{self, BranchInfo, CommitInfo, FileChange, RemoteStatus, RepoInfo};
use crate::core::shortid::IdAllocator;
use crate::core::staging;
use crate::core::transaction;
use crate::core::ui::{self, Answer, Cancelled, Level, Request};
use crate::git;
use crate::tui::hunk_selector::{FileEntry, run_hunk_selector_nested};
use crate::tui::shell::{KeyResult, PaneId, Shell, ShellApp, ShellConfig, Tick};
use crate::tui::status_tree::{
self, LOCAL_CHANGES_KEY, PENDING_COMMIT_OID, Row, RowKind, RowMark, SelectionClass, branch_key,
commit_file_key, working_file_key,
};
use crate::tui::theme::TuiTheme;
use crate::tui::widgets::common::{colorize_diff, pane_block};
use crate::tui::widgets::diff_pane::DiffPane;
use crate::tui::widgets::list_pane::ListPane;
use crate::tui::widgets::popup::{self, LogEntry, Notice, Prompt, PromptOutcome, TextField};
use crate::{absorb, branch, commit, drop, fold, reword, split};
struct Snapshot {
workdir: PathBuf,
git_dir: PathBuf,
cwd_prefix: String,
info: RepoInfo,
ids: IdAllocator,
}
const NEW_BRANCH_NAME: &str = ".new";
#[derive(Debug, Clone, PartialEq, Eq)]
enum CommitDest {
Integration,
Branch(String),
}
enum Preview {
Branch(BranchInfo),
Commit { dest: CommitDest, file_count: usize },
Move { oid: git2::Oid, slot: MoveSlot },
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum MoveSlot {
Stay,
Above(git2::Oid),
Below(git2::Oid),
Branch(String),
}
impl Snapshot {
fn sections(&self, preview: Option<Preview>) -> Vec<Section> {
let mut info = self.info.clone();
let pinned = match preview {
Some(Preview::Branch(pending)) => {
info.branches.push(pending);
false
}
Some(Preview::Commit { dest, file_count }) => {
place_pending_commit(&mut info, &dest, file_count);
true
}
Some(Preview::Move { oid, slot }) => {
place_moved_commit(&mut info, oid, &slot);
true
}
None => false,
};
let sections = graph::build_sections(info);
if pinned {
keep_branch_places(&graph::build_sections(self.info.clone()), sections)
} else {
sections
}
}
fn is_all_changes(&self, files: &[String]) -> bool {
files.iter().any(|f| f == self.ids.get_unstaged())
}
fn staged_count(&self) -> usize {
self.info
.working_changes
.iter()
.filter(|c| is_staged(c))
.count()
}
}
fn keep_branch_places(base: &[Section], mut preview: Vec<Section>) -> Vec<Section> {
let place: HashMap<&str, usize> = base
.iter()
.enumerate()
.flat_map(|(i, s)| match s {
Section::Branch { names, .. } => names.iter().map(|(n, _)| (n.as_str(), i)).collect(),
_ => Vec::new(),
})
.collect();
let is_branch = |s: &Section| matches!(s, Section::Branch { .. });
let Some(start) = preview.iter().position(is_branch) else {
return preview;
};
let end = start + preview[start..].iter().take_while(|s| is_branch(s)).count();
let taken: Vec<Section> = preview.drain(start..end).collect();
let rank_of = |name: &str| place.get(name).copied();
let mut rank = 0;
let mut run: Vec<(usize, Section)> = Vec::new();
for section in taken {
let Section::Branch { names, commits } = section else {
unreachable!("a run of branch sections");
};
if !commits.is_empty() {
rank = names
.iter()
.filter_map(|(n, _)| rank_of(n))
.min()
.unwrap_or(rank);
run.push((rank, Section::Branch { names, commits }));
continue;
}
for (name, remote) in names {
rank = rank_of(&name).unwrap_or(rank);
match run.last_mut() {
Some((r, Section::Branch { names, commits }))
if *r == rank && commits.is_empty() =>
{
names.push((name, remote))
}
_ => run.push((
rank,
Section::Branch {
names: vec![(name, remote)],
commits: Vec::new(),
},
)),
}
}
}
run.sort_by_key(|(rank, _)| *rank);
preview.splice(start..start, run.into_iter().map(|(_, s)| s));
preview
}
fn place_pending_commit(info: &mut RepoInfo, dest: &CommitDest, file_count: usize) {
let oid = PENDING_COMMIT_OID;
let mut pending = CommitInfo {
oid,
short_id: String::new(),
message: format!(
"new commit ({} file{})",
file_count,
if file_count == 1 { "" } else { "s" }
),
parent_oid: None,
change_id: None,
files: Vec::new(),
};
match dest {
CommitDest::Integration => {
pending.parent_oid = Some(
info.commits
.first()
.map_or(info.upstream.merge_base_oid, |c| c.oid),
);
info.commits.insert(0, pending);
}
CommitDest::Branch(name) => push_onto_branch(info, pending, name),
}
}
fn push_onto_branch(info: &mut RepoInfo, mut commit: CommitInfo, name: &str) {
let Some(branch) = info.branches.iter_mut().find(|b| b.name == name) else {
return;
};
let oid = commit.oid;
let tip = branch.tip_oid;
branch.tip_oid = oid;
commit.parent_oid = Some(tip);
let at = info.commits.iter().position(|c| c.oid == tip);
if at.is_some() {
reparent_children(info, tip, oid);
}
info.commits
.insert(at.unwrap_or(info.commits.len()), commit);
}
fn reparent_children(info: &mut RepoInfo, from: git2::Oid, to: git2::Oid) {
for commit in &mut info.commits {
if commit.parent_oid == Some(from) {
commit.parent_oid = Some(to);
}
}
}
fn place_moved_commit(info: &mut RepoInfo, oid: git2::Oid, slot: &MoveSlot) {
let anchor_known = |x: &git2::Oid| *x != oid && info.commits.iter().any(|c| c.oid == *x);
let known = match slot {
MoveSlot::Stay => false,
MoveSlot::Above(x) | MoveSlot::Below(x) => anchor_known(x),
MoveSlot::Branch(name) => info.branches.iter().any(|b| b.name == *name),
};
let Some(from) = info
.commits
.iter()
.position(|c| c.oid == oid)
.filter(|_| known)
else {
return;
};
let mut moved = info.commits.remove(from);
if let Some(parent) = moved.parent_oid {
reparent_children(info, oid, parent);
for branch in &mut info.branches {
if branch.tip_oid == oid {
branch.tip_oid = parent;
}
}
}
let position = |info: &RepoInfo, x: git2::Oid| {
info.commits
.iter()
.position(|c| c.oid == x)
.expect("checked above")
};
match slot {
MoveSlot::Stay => {}
MoveSlot::Above(x) => {
let at = position(info, *x);
reparent_children(info, *x, oid);
for branch in &mut info.branches {
if branch.tip_oid == *x {
branch.tip_oid = oid;
}
}
moved.parent_oid = Some(*x);
info.commits.insert(at, moved);
}
MoveSlot::Below(x) => {
let at = position(info, *x);
moved.parent_oid = info.commits[at].parent_oid;
info.commits[at].parent_oid = Some(oid);
info.commits.insert(at + 1, moved);
}
MoveSlot::Branch(name) => push_onto_branch(info, moved, name),
}
}
#[derive(Debug, PartialEq, Eq)]
enum CommitSource {
Files(Vec<String>),
Index,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FoldEffect {
Amend,
Noop,
Uncommit,
MoveFile,
}
impl FoldEffect {
fn tag(self) -> &'static str {
match self {
FoldEffect::Amend => "[AMEND]",
FoldEffect::Noop => "[NOOP]",
FoldEffect::Uncommit => "[UNCOMMIT]",
FoldEffect::MoveFile => "[MOVE]",
}
}
}
#[derive(Debug, Clone)]
struct FoldTarget {
key: String,
arg: String,
effect: FoldEffect,
}
#[derive(Debug, PartialEq, Eq)]
enum Action {
Commit {
source: CommitSource,
dest: CommitDest,
},
Fold {
sources: Vec<String>,
target: String,
hunks: Option<HunkArgs>,
},
FoldHunks {
sources: Vec<String>,
picked: Vec<FileEntry>,
stamp: Stamp,
target: String,
},
Move { commit: git2::Oid, slot: MoveSlot },
Split {
commit: String,
files: Vec<String>,
hunks: Option<HunkArgs>,
},
NewBranch {
name: String,
target: Option<String>,
},
Drop { targets: Vec<String> },
Absorb { files: Vec<String> },
Reword {
target: String,
name: Option<String>,
},
}
enum Outcome {
Quit,
}
enum Mode {
Normal,
FoldTarget {
sources: Vec<String>,
source_rows: Sources,
targets: Vec<FoldTarget>,
index: usize,
origin: String,
hunks: Option<FoldHunks>,
},
CommitTarget {
source: CommitSource,
source_rows: Sources,
dests: Vec<CommitDest>,
index: usize,
origin: String,
},
MoveTarget {
commit: git2::Oid,
slots: Vec<MoveSlot>,
index: usize,
},
RenameBranch {
branch: String,
field: TextField,
},
NewBranch {
target: Option<String>,
tip: git2::Oid,
origin: String,
field: TextField,
},
}
#[derive(Default)]
struct Sources {
named: HashSet<String>,
covered: HashSet<String>,
}
fn is_staged(change: &FileChange) -> bool {
matches!(change.index, 'A' | 'M' | 'D' | 'R')
}
enum PickTick {
Nothing,
Redraw,
Ready {
first: bool,
},
}
enum PickFor {
Commit,
Fold {
sources: Vec<String>,
targets: Vec<FoldTarget>,
commit: Option<git2::Oid>,
},
Split { commit: git2::Oid },
}
impl PickFor {
fn label(&self) -> &'static str {
match self {
PickFor::Commit => "commit",
PickFor::Fold { .. } => "fold",
PickFor::Split { .. } => "split",
}
}
}
enum Pick {
Collecting {
handle: JoinHandle<Result<(Vec<FileEntry>, Stamp)>>,
origin: String,
purpose: PickFor,
ticks: usize,
cancelled: bool,
},
Ready {
entries: Vec<FileEntry>,
stamp: Stamp,
origin: String,
purpose: PickFor,
},
}
type Stamp = Vec<Option<git2::Oid>>;
enum FoldHunks {
Worktree {
picked: Vec<FileEntry>,
stamp: Stamp,
},
Commit(Vec<FileEntry>),
}
struct Running {
handle: JoinHandle<Result<()>>,
command: String,
spinner: Option<String>,
ticks: usize,
}
enum Popup {
Prompt {
prompt: Prompt,
reply: Sender<Option<Answer>>,
},
Notice {
notice: Notice,
then: AfterNotice,
},
Log {
scroll: DiffPane,
},
Help {
scroll: DiffPane,
},
}
const HELP: &[popup::HelpSection] = &[
(
"Navigation",
&[
(
"↑/k ↓/j",
"move the cursor; scroll the diff when it has focus",
),
(
"←/h →/l",
"close / open a row; ← on a file walks up to its commit",
),
("Enter", "toggle a row open or closed"),
("PgUp/PgDn", "scroll the diff by a page"),
("Tab", "switch pane"),
("Ctrl-←/→", "narrow / widen the left pane"),
("+/-", "one more / one fewer context commit before the base"),
("Space", "select / unselect a row"),
("Esc", "cancel a pick or a mode, else clear the selection"),
],
),
(
"Actions (on the selection, else the cursor row)",
&[
("c / C", "commit files / commit picked hunks"),
("f / F", "fold into a commit / fold picked hunks"),
("m", "move a commit above or below another"),
("s / S", "split a commit by file / by hunk"),
("b", "new branch at the cursor"),
("d", "drop"),
("a", "absorb into the commits that touched the same lines"),
("r", "reword a commit, or rename a branch"),
],
),
(
"Other",
&[
("L", "action log"),
("R / F5", "reload the tree"),
("?", "this help"),
("q / Ctrl-C", "quit"),
],
),
];
enum AfterNotice {
Nothing,
Reload,
Quit,
}
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
pub fn run(theme: graph::Theme) -> Result<()> {
if crate::core::agent_mode::enabled() {
anyhow::bail!(
"the TUI is interactive and unavailable in agent mode\n\
Use `loom status` instead"
);
}
let tui_theme = TuiTheme::from_graph_theme(&theme);
let context = crate::status::resolve_context(&repo::open_repo()?, None);
let snapshot = load_snapshot(context)?;
let git_dir = snapshot.git_dir.clone();
let mut expanded: HashSet<String> = HashSet::new();
expanded.insert(LOCAL_CHANGES_KEY.to_string());
let app = App::new(snapshot, &tui_theme, theme, expanded, context);
let (_, Outcome::Quit) = Shell::new(app).run()?;
if let Ok(Some(state)) = transaction::load(&git_dir) {
println!("{}", paused_message(&state.command));
}
Ok(())
}
fn paused_message(command: &str) -> String {
format!(
"A `loom {}` is paused due to conflicts.\n\
Resolve them, then run `loom continue` to resume, or `loom abort` to cancel.",
command
)
}
fn load_snapshot(context: usize) -> Result<Snapshot> {
let repo = repo::open_repo()?;
let workdir = repo::require_workdir(&repo, "display status")?.to_path_buf();
let git_dir = repo.path().to_path_buf();
let cwd_prefix = repo::cwd_relative_to_repo(&repo).unwrap_or_default();
let mut info = repo::gather_repo_info(&repo, true, context)?;
let ids = IdAllocator::new(info.collect_entities());
crate::status::apply_hidden_branches(&repo, &mut info);
Ok(Snapshot {
workdir,
git_dir,
cwd_prefix,
info,
ids,
})
}
fn execute_action(
action: Action,
command: &str,
git_dir: &std::path::Path,
theme: &graph::Theme,
) -> Result<()> {
crate::trace::init(git_dir, &format!("loom tui: {}", command));
let result = match action {
Action::Commit { source, dest } => {
let (branch, integration) = match dest {
CommitDest::Integration => (None, true),
CommitDest::Branch(name) => (Some(name), false),
};
let files = match source {
CommitSource::Files(files) => files,
CommitSource::Index => vec![],
};
commit::run(branch, integration, None, None, files, vec![], theme)
}
Action::Fold {
sources,
target,
hunks,
} => {
let mut args = sources;
args.push(target);
let patch = hunks.is_some();
let hunks = hunks.unwrap_or_default();
fold::run(false, patch, None, hunks, args, vec![], theme)
}
Action::FoldHunks {
picked,
stamp,
target,
..
} => fold::run_picked(&picked, &stamp, &target),
Action::Move { commit, slot } => {
let (anchor, mut args) = match slot {
MoveSlot::Above(x) => (Some(fold::Anchor::Above(x.to_string())), vec![]),
MoveSlot::Below(x) => (Some(fold::Anchor::Below(x.to_string())), vec![]),
MoveSlot::Branch(name) => (None, vec![name]),
MoveSlot::Stay => anyhow::bail!("Nothing to move"),
};
args.insert(0, commit.to_string());
fold::run(
false,
false,
anchor,
HunkArgs::default(),
args,
vec![],
theme,
)
}
Action::Split {
commit,
files,
hunks,
} => {
let patch = hunks.is_some();
let hunks = hunks.unwrap_or_default();
split::run(commit, None, patch, hunks, files, theme)
}
Action::NewBranch { name, target } => branch::new::run(Some(name), target),
Action::Drop { targets } => drop::run(targets, false),
Action::Absorb { files } => absorb::run(false, files),
Action::Reword { target, name } => reword::run(target, name),
};
crate::trace::finalize();
result
}
struct App<'a> {
snapshot: Snapshot,
theme: &'a TuiTheme,
graph_theme: graph::Theme,
rows: Vec<Row>,
tree: ListPane,
selected: HashSet<String>,
selected_class: Option<SelectionClass>,
expanded: HashSet<String>,
context: usize,
mode: Mode,
diff: DiffPane,
diff_cache: HashMap<String, Vec<Line<'static>>>,
next_cursor: Option<String>,
fallback_cursor: Option<String>,
pick: Option<Pick>,
quit_after_pick: bool,
notice: Option<String>,
outcome: Option<Outcome>,
requests: Receiver<Request>,
request_tx: Sender<Request>,
running: Option<Running>,
popup: Option<Popup>,
log: Vec<LogEntry>,
}
impl<'a> App<'a> {
fn new(
snapshot: Snapshot,
theme: &'a TuiTheme,
graph_theme: graph::Theme,
expanded: HashSet<String>,
context: usize,
) -> Self {
let (request_tx, requests) = channel();
let mut app = App {
snapshot,
theme,
graph_theme,
rows: Vec::new(),
tree: ListPane::new(0),
selected: HashSet::new(),
selected_class: None,
expanded,
context,
mode: Mode::Normal,
diff: DiffPane::new(),
diff_cache: HashMap::new(),
next_cursor: None,
fallback_cursor: None,
pick: None,
quit_after_pick: false,
notice: None,
outcome: None,
requests,
request_tx,
running: None,
popup: None,
log: Vec::new(),
};
app.rows = app.build_rows();
let cursor = app.rows.iter().position(|r| r.focusable).unwrap_or(0);
app.tree.set_cursor(cursor);
app.ensure_diff_cached();
app
}
fn build_rows(&self) -> Vec<Row> {
let preview = match &self.mode {
Mode::NewBranch { tip, .. } => Some(Preview::Branch(BranchInfo {
name: NEW_BRANCH_NAME.to_string(),
tip_oid: *tip,
remote: None,
})),
Mode::CommitTarget {
source,
dests,
index,
..
} => Some(Preview::Commit {
dest: dests[*index].clone(),
file_count: match source {
CommitSource::Files(files) => self.commit_file_count(files),
CommitSource::Index => self.snapshot.staged_count(),
},
}),
Mode::MoveTarget {
commit,
slots,
index,
} => Some(Preview::Move {
oid: *commit,
slot: slots[*index].clone(),
}),
_ => None,
};
let sections = self.snapshot.sections(preview);
status_tree::build_rows(§ions, &self.snapshot.ids, &self.expanded)
}
fn commit_file_count(&self, files: &[String]) -> usize {
if self.snapshot.is_all_changes(files) {
self.snapshot.info.working_changes.len()
} else {
files.len()
}
}
fn current_row(&self) -> Option<&Row> {
self.rows.get(self.tree.cursor())
}
fn apply_snapshot(&mut self, snapshot: Snapshot) {
let key = self
.next_cursor
.take()
.or_else(|| self.current_row().map(|r| r.key.clone()));
let previous = self.tree.cursor();
self.snapshot = snapshot;
self.clear_selection();
self.diff_cache.clear();
self.rows = self.build_rows();
let cursor = key
.and_then(|key| self.rows.iter().position(|r| r.focusable && r.key == key))
.or_else(|| nearest_focusable(&self.rows, previous))
.unwrap_or(0);
self.tree.set_cursor(cursor);
self.diff.reset();
self.ensure_diff_cached();
}
fn reload(&mut self) -> bool {
match load_snapshot(self.context) {
Ok(snapshot) => {
self.apply_snapshot(snapshot);
true
}
Err(e) => {
self.next_cursor = None;
self.show_error(&e.to_string(), AfterNotice::Nothing);
false
}
}
}
fn change_context(&mut self, delta: isize) {
let depth = self.context.saturating_add_signed(delta).max(1);
if depth == self.context {
self.notice = Some(format!("context: {}", depth));
return;
}
let previous = self.context;
self.context = depth;
if self.reload() {
self.context = depth.min(self.snapshot.info.context_commits.len() + 1);
self.notice = Some(format!("context: {}", self.context));
} else {
self.context = previous;
}
}
fn show_error(&mut self, text: &str, then: AfterNotice) {
self.popup = Some(Popup::Notice {
notice: Notice::new("Error", Level::Error, text),
then,
});
}
fn sid_of(&self, target: &str) -> String {
self.rows
.iter()
.find(|r| r.target.as_deref() == Some(target) && !r.sid.is_empty())
.map(|r| r.sid.clone())
.unwrap_or_else(|| target.to_string())
}
fn title_sources(
&self,
targets: &[String],
width: u16,
title: impl Fn(&str) -> String,
) -> String {
let joined = targets
.iter()
.map(|t| self.sid_of(t))
.collect::<Vec<_>>()
.join(" ");
let full = title(&joined);
if full.chars().count() + 2 <= width as usize {
full
} else {
title(&format!("{} item(s)", targets.len()))
}
}
fn command_line(&self, action: &Action) -> String {
let sid = |target: &str| self.sid_of(target);
let mut words = vec!["loom".to_string()];
match action {
Action::Commit { source, dest } => {
words.push("commit".into());
match dest {
CommitDest::Integration => words.push("-i".into()),
CommitDest::Branch(name) => words.extend(["-b".into(), sid(name)]),
}
if let CommitSource::Files(files) = source {
words.extend(files.iter().map(|f| sid(f)));
}
}
Action::Fold {
sources,
target,
hunks,
} => {
words.push("fold".into());
if hunks.is_some() {
words.push("-p".into());
}
words.extend(sources.iter().map(|s| sid(s)));
words.push(sid(target));
if let Some(hunks) = hunks {
for id in &hunks.ids {
words.extend(["--hunks".into(), hunk_select::quoted(id)]);
}
if let Some(from) = &hunks.from {
words.extend(["--hunks-from".into(), from.clone()]);
}
}
}
Action::FoldHunks {
sources, target, ..
} => {
words.extend(["fold".into(), "-p".into()]);
words.extend(sources.iter().map(|s| sid(s)));
words.push(sid(target));
}
Action::Move { commit, slot } => {
words.extend(["fold".into(), sid(&commit.to_string())]);
match slot {
MoveSlot::Above(x) => words.extend(["--above".into(), sid(&x.to_string())]),
MoveSlot::Below(x) => words.extend(["--below".into(), sid(&x.to_string())]),
MoveSlot::Branch(name) => words.push(sid(name)),
MoveSlot::Stay => {}
}
}
Action::Split {
commit,
files,
hunks,
} => {
words.push("split".into());
if hunks.is_some() {
words.push("-p".into());
}
words.push(sid(commit));
words.extend(files.iter().map(|f| hunk_select::quoted(f)));
if let Some(hunks) = hunks {
for id in &hunks.ids {
words.extend(["--hunks".into(), hunk_select::quoted(id)]);
}
if let Some(from) = &hunks.from {
words.extend(["--hunks-from".into(), from.clone()]);
}
}
}
Action::NewBranch { name, target } => {
words.extend(["branch".into(), "new".into(), name.clone()]);
if let Some(target) = target {
words.extend(["-t".into(), sid(target)]);
}
}
Action::Drop { targets } => {
words.push("drop".into());
words.extend(targets.iter().map(|t| sid(t)));
}
Action::Absorb { files } => {
words.push("absorb".into());
words.extend(files.iter().map(|f| sid(f)));
}
Action::Reword { target, name } => {
words.extend(["reword".into(), sid(target)]);
if let Some(name) = name {
words.extend(["-m".into(), name.clone()]);
}
}
}
words.join(" ")
}
fn start_action(&mut self, action: Action) {
let command = self.command_line(&action);
self.log.push(LogEntry {
command: command.clone(),
lines: Vec::new(),
});
self.clear_selection();
self.mode = Mode::Normal;
self.notice = None;
let tx = self.request_tx.clone();
let git_dir = self.snapshot.git_dir.clone();
let theme = self.graph_theme.clone();
let trace_name = command.clone();
let handle = std::thread::spawn(move || {
ui::install(tx);
let result = execute_action(action, &trace_name, &git_dir, &theme);
ui::uninstall();
result
});
self.running = Some(Running {
handle,
command,
spinner: None,
ticks: 0,
});
}
fn handle_request(&mut self, request: Request) {
match request {
Request::Prompt {
kind,
prompt,
error,
reply,
} => {
let command = self.running.as_ref().map_or("", |r| r.command.as_str());
self.popup = Some(Popup::Prompt {
prompt: Prompt::new(kind, prompt, error, command),
reply,
});
}
Request::Message { level, text } => {
if let Some(entry) = self.log.last_mut() {
entry.lines.push((level, text));
}
}
Request::Spinner(text) => {
if let Some(running) = &mut self.running {
running.spinner = text;
}
}
Request::Suspend(_) | Request::Resume => {}
}
}
fn finish_action(&mut self, result: Result<()>) -> bool {
if result.is_err() {
self.next_cursor = self.fallback_cursor.take();
}
self.fallback_cursor = None;
match result {
Ok(()) => {
let successes: Vec<&str> = self.log.last().map_or_else(Vec::new, |entry| {
entry
.lines
.iter()
.filter(|(level, _)| *level == Level::Success)
.filter_map(|(_, text)| text.lines().next())
.collect()
});
self.notice = Some(match successes.as_slice() {
[] => "✓ done".to_string(),
[one] => format!("✓ {}", one),
[.., last] => {
format!("✓ {} (+{} more, L: log)", last, successes.len() - 1)
}
});
true
}
Err(e) if e.downcast_ref::<Cancelled>().is_some() => {
self.log_line(Level::Warn, "Cancelled");
self.notice = Some("cancelled".to_string());
false
}
Err(e) => {
let text = e.to_string();
self.log_line(Level::Error, &text);
if matches!(self.popup, Some(Popup::Log { .. })) {
return true;
}
self.show_error(&text, AfterNotice::Reload);
false
}
}
}
fn log_line(&mut self, level: Level, text: &str) {
if let Some(entry) = self.log.last_mut() {
entry.lines.push((level, text.to_string()));
}
}
fn after_action(&mut self) {
match transaction::load(&self.snapshot.git_dir) {
Ok(Some(state)) => {
self.popup = Some(Popup::Notice {
notice: Notice::new("Paused", Level::Warn, &paused_message(&state.command)),
then: AfterNotice::Quit,
});
}
_ => {
self.reload();
}
}
}
fn dismiss_notice(&mut self, then: AfterNotice) {
match then {
AfterNotice::Nothing => {}
AfterNotice::Reload => self.after_action(),
AfterNotice::Quit => self.outcome = Some(Outcome::Quit),
}
}
fn handle_popup_key(&mut self, code: KeyCode, modifiers: KeyModifiers) {
let Some(popup) = self.popup.take() else {
return;
};
match popup {
Popup::Prompt { mut prompt, reply } => match prompt.handle_key(code, modifiers) {
PromptOutcome::Pending => self.popup = Some(Popup::Prompt { prompt, reply }),
PromptOutcome::Answer(answer) => {
let _ = reply.send(Some(answer));
}
PromptOutcome::Cancel => {
let _ = reply.send(None);
}
},
Popup::Notice { notice, then } => {
if Notice::dismisses(code) {
self.dismiss_notice(then);
} else {
self.popup = Some(Popup::Notice { notice, then });
}
}
Popup::Log { mut scroll } => match code {
KeyCode::Esc | KeyCode::Char('q' | 'L') => {}
_ => {
scroll_popup(&mut scroll, code);
self.popup = Some(Popup::Log { scroll });
}
},
Popup::Help { mut scroll } => match code {
KeyCode::Esc | KeyCode::Char('q' | '?') => {}
_ => {
scroll_popup(&mut scroll, code);
self.popup = Some(Popup::Help { scroll });
}
},
}
}
fn open_help(&mut self) {
self.popup = Some(Popup::Help {
scroll: DiffPane::new(),
});
}
fn open_log(&mut self) {
let mut scroll = DiffPane::new();
scroll.set_scroll(u16::MAX);
self.popup = Some(Popup::Log { scroll });
}
fn rebuild_rows(&mut self, key: &str) {
self.rows = self.build_rows();
let visible: HashSet<&str> = self.rows.iter().map(|r| r.key.as_str()).collect();
self.selected.retain(|k| visible.contains(k.as_str()));
if self.selected.is_empty() {
self.selected_class = None;
}
if let Some(pos) = self.rows.iter().position(|r| r.key == key) {
self.tree.set_cursor(pos);
} else if self.tree.cursor() >= self.rows.len() {
self.tree.set_cursor(self.rows.len().saturating_sub(1));
}
}
fn handle_tree_key(&mut self, focused: PaneId, code: KeyCode) {
let action = match code {
KeyCode::Esc => {
self.handle_escape();
None
}
KeyCode::Up | KeyCode::Char('k') => {
match focused {
PaneId::Left => self.move_cursor(-1),
PaneId::Right => self.diff.scroll_by(-1),
}
None
}
KeyCode::Down | KeyCode::Char('j') => {
match focused {
PaneId::Left => self.move_cursor(1),
PaneId::Right => self.diff.scroll_by(1),
}
None
}
KeyCode::PageUp => {
self.diff.scroll_page(-1);
None
}
KeyCode::PageDown => {
self.diff.scroll_page(1);
None
}
KeyCode::Left | KeyCode::Right | KeyCode::Char('h' | 'l')
if self.placing().is_some() =>
{
let what = self.placing().unwrap_or_default();
self.notice = Some(format!("{}: Enter to confirm, Esc to cancel", what));
None
}
KeyCode::Right | KeyCode::Char('l') => {
self.expand_current();
None
}
KeyCode::Left | KeyCode::Char('h') => {
self.collapse_current();
None
}
KeyCode::Enter => match &self.mode {
Mode::FoldTarget { .. } => self.confirm_fold_target(),
Mode::CommitTarget { .. } => self.confirm_commit_target(),
Mode::MoveTarget { .. } => self.confirm_move_target(),
_ => {
self.toggle_current();
None
}
},
KeyCode::Char('L') => {
self.open_log();
None
}
KeyCode::Char('?') => {
self.open_help();
None
}
KeyCode::Char(
' ' | 'c' | 'C' | 'f' | 'F' | 'm' | 's' | 'S' | 'b' | 'd' | 'a' | 'r' | 'R' | '+'
| '=' | '-',
)
| KeyCode::F(5)
if self.placing().is_some() =>
{
let what = self.placing().unwrap_or_default();
self.notice = Some(format!("{}: Enter to confirm, Esc to cancel", what));
None
}
KeyCode::Char(' ') => {
self.toggle_selection();
None
}
KeyCode::Char('c') => {
self.action_commit_start();
None
}
KeyCode::Char('C') => {
self.action_commit_patch_start();
None
}
KeyCode::Char('f') => {
self.action_fold_start();
None
}
KeyCode::Char('F') => {
self.action_fold_patch_start();
None
}
KeyCode::Char('m') => {
self.action_move_start();
None
}
KeyCode::Char('s') => self.action_split(),
KeyCode::Char('S') => {
self.action_split_patch_start();
None
}
KeyCode::Char('b') => {
self.action_new_branch();
None
}
KeyCode::Char('d') => self.action_drop(),
KeyCode::Char('a') => self.action_absorb(),
KeyCode::Char('r') => self.action_reword(),
KeyCode::Char('R') | KeyCode::F(5) => {
self.reload();
None
}
KeyCode::Char('+' | '=') => {
self.change_context(1);
None
}
KeyCode::Char('-') => {
self.change_context(-1);
None
}
_ => None,
};
if let Some(action) = action {
self.start_action(action);
}
}
fn placing(&self) -> Option<&'static str> {
match self.mode {
Mode::CommitTarget { .. } => Some("commit"),
Mode::FoldTarget { .. } => Some("fold"),
Mode::MoveTarget { .. } => Some("move"),
_ => None,
}
}
fn handle_escape(&mut self) {
if matches!(self.mode, Mode::FoldTarget { .. }) {
self.cancel_fold_target();
} else if matches!(self.mode, Mode::CommitTarget { .. }) {
self.cancel_commit_target();
} else if matches!(self.mode, Mode::MoveTarget { .. }) {
self.cancel_move_target();
} else if !self.selected.is_empty() {
self.clear_selection();
}
}
fn move_cursor(&mut self, dir: isize) {
match self.mode {
Mode::CommitTarget { .. } => return self.move_commit_dest(dir),
Mode::FoldTarget { .. } => return self.move_fold_target(dir),
Mode::MoveTarget { .. } => return self.move_move_target(dir),
_ => {}
}
if self
.tree
.move_cursor(dir, self.rows.len(), |i| self.rows[i].focusable)
{
self.diff.reset();
}
}
fn expand_current(&mut self) {
let Some(row) = self.current_row() else {
return;
};
if row.expandable && !row.expanded {
let key = row.key.clone();
self.expanded.insert(key.clone());
self.rebuild_rows(&key);
}
}
fn collapse_current(&mut self) {
let Some(row) = self.current_row() else {
return;
};
if row.expandable && row.expanded {
let key = row.key.clone();
self.expanded.remove(&key);
self.rebuild_rows(&key);
return;
}
if matches!(
row.kind,
RowKind::WorkingFile { .. } | RowKind::CommitFile { .. }
) {
let mut pos = self.tree.cursor();
while pos > 0 {
pos -= 1;
if self.rows[pos].expandable {
let key = self.rows[pos].key.clone();
self.expanded.remove(&key);
self.rebuild_rows(&key);
return;
}
}
}
}
fn toggle_current(&mut self) {
let Some(row) = self.current_row() else {
return;
};
if !row.expandable {
return;
}
if row.expanded {
self.collapse_current();
} else {
self.expand_current();
}
}
fn toggle_selection(&mut self) {
let Some(row) = self.current_row() else {
return;
};
let Some(class) = row.selection_class() else {
return;
};
let key = row.key.clone();
if self.selected.remove(&key) {
if self.selected.is_empty() {
self.selected_class = None;
}
} else {
if let Some(current) = self.selected_class
&& current != class
{
self.notice = Some(format!(
"selection holds {}; Esc clears it",
current.label()
));
return;
}
self.selected.insert(key);
self.selected_class = Some(class);
}
self.move_cursor(1);
}
fn clear_selection(&mut self) {
self.selected.clear();
self.selected_class = None;
}
fn picked_rows(&self) -> Vec<&Row> {
if self.selected.is_empty() {
self.current_row().into_iter().collect()
} else {
self.rows
.iter()
.filter(|r| self.selected.contains(&r.key))
.collect()
}
}
fn mark_sources(&self, rows: &[&Row]) -> Sources {
let covers_all = rows
.iter()
.any(|r| matches!(r.kind, RowKind::LocalChanges { .. }));
Sources {
named: rows.iter().map(|r| r.key.clone()).collect(),
covered: if covers_all {
self.working_file_keys(|_| true)
} else {
HashSet::new()
},
}
}
fn working_file_keys(&self, keep: impl Fn(&FileChange) -> bool) -> HashSet<String> {
self.snapshot
.info
.working_changes
.iter()
.filter(|c| keep(c))
.map(|c| working_file_key(&c.path))
.collect()
}
fn index_sources(&self) -> Sources {
let mut covered = self.working_file_keys(is_staged);
covered.insert(LOCAL_CHANGES_KEY.to_string());
Sources {
named: HashSet::new(),
covered,
}
}
fn row_mark(&self, row: &Row) -> RowMark {
let source_rows = match &self.mode {
Mode::CommitTarget { source_rows, .. } | Mode::FoldTarget { source_rows, .. } => {
Some(source_rows)
}
_ => None,
};
if let Some(sources) = source_rows {
if sources.named.contains(&row.key) {
return RowMark::Source;
}
if sources.covered.contains(&row.key) {
return RowMark::Covered;
}
}
if self.selected.contains(&row.key) {
return RowMark::Selected;
}
RowMark::None
}
fn action_commit_start(&mut self) {
let Some((files, source_rows, origin)) = self.commit_sources() else {
return;
};
self.enter_commit_target(CommitSource::Files(files), source_rows, origin);
}
fn action_commit_patch_start(&mut self) {
if self.snapshot.info.working_changes.is_empty() {
self.notice = Some("commit: no local changes".to_string());
return;
}
let Some(origin) = self.current_row().map(|r| r.key.clone()) else {
return;
};
let files = vec![self.snapshot.ids.get_unstaged().to_string()];
let command = format!("loom add -p {}", files.join(" "));
self.start_pick(command, origin, PickFor::Commit, move |workdir| {
let repo = git2::Repository::open(workdir)?;
let filter = staging::filter_paths(&repo, &files)?;
let entries = staging::collect_file_entries(&repo, workdir, filter.as_deref())?;
Ok((entries, Vec::new()))
});
}
fn action_fold_patch_start(&mut self) {
let rows = self.picked_rows();
let Some(origin) = self.current_row().map(|r| r.key.clone()) else {
return;
};
let mut sources: Vec<String> = rows.iter().filter_map(|r| r.target.clone()).collect();
if sources.is_empty() || sources.len() != rows.len() {
self.notice = Some("fold: select source rows first".to_string());
return;
}
let commit = match rows[0].kind {
RowKind::Commit { oid, .. } => Some(oid),
RowKind::LocalChanges { .. } | RowKind::WorkingFile { .. } => None,
_ => {
self.notice = Some("fold: move to a file, local changes, or a commit".to_string());
return;
}
};
let targets = match self.fold_targets(&rows, true) {
Ok(targets) => targets,
Err(notice) => {
self.notice = Some(format!("fold: {}", notice));
return;
}
};
if commit.is_none() {
sources = vec![self.snapshot.ids.get_unstaged().to_string()];
}
let shown: Vec<String> = sources.iter().map(|s| self.sid_of(s)).collect();
let command = format!("loom fold -p {}", shown.join(" "));
let purpose = PickFor::Fold {
sources: sources.clone(),
targets,
commit,
};
match commit {
Some(oid) => self.start_pick(command, origin, purpose, move |workdir| {
let entries = staging::collect_commit_hunks(workdir, &oid.to_string(), &[])?;
Ok((entries, Vec::new()))
}),
None => self.start_pick(command, origin, purpose, move |workdir| {
let repo = git2::Repository::open(workdir)?;
let entries = staging::collect_file_entries(&repo, workdir, None)?;
let stamp = staging::binary_stamp(workdir, &entries);
Ok((entries, stamp))
}),
}
}
fn start_pick(
&mut self,
command: String,
origin: String,
purpose: PickFor,
read: impl FnOnce(&std::path::Path) -> Result<(Vec<FileEntry>, Stamp)> + Send + 'static,
) {
self.log.push(LogEntry {
command: command.clone(),
lines: Vec::new(),
});
let workdir = self.snapshot.workdir.clone();
let git_dir = self.snapshot.git_dir.clone();
let tx = self.request_tx.clone();
let handle = std::thread::spawn(move || {
ui::install(tx);
crate::trace::init(&git_dir, &format!("loom tui: {command}"));
let result = read(&workdir);
crate::trace::finalize();
ui::uninstall();
result
});
self.pick = Some(Pick::Collecting {
handle,
origin,
purpose,
ticks: 0,
cancelled: false,
});
}
fn commit_sources(&mut self) -> Option<(Vec<String>, Sources, String)> {
let rows = self.picked_rows();
if rows.is_empty()
|| !rows.iter().all(|r| {
matches!(
r.kind,
RowKind::WorkingFile { .. } | RowKind::LocalChanges { .. }
)
})
{
self.notice = Some("commit: move to local changes or select files".to_string());
return None;
}
if rows
.iter()
.any(|r| matches!(r.kind, RowKind::LocalChanges { count: 0 }))
{
self.notice = Some("commit: no local changes".to_string());
return None;
}
let Some(files) = rows
.iter()
.map(|r| r.target.clone().filter(|t| !t.is_empty()))
.collect::<Option<Vec<String>>>()
else {
self.notice = Some("commit: this row has nothing to commit".to_string());
return None;
};
let source_rows = self.mark_sources(&rows);
let origin = self.current_row().map(|r| r.key.clone())?;
Some((files, source_rows, origin))
}
fn enter_commit_target(&mut self, source: CommitSource, source_rows: Sources, origin: String) {
let mut dests = vec![CommitDest::Integration];
dests.extend(self.rows.iter().filter_map(|r| match &r.kind {
RowKind::BranchName { name, .. } => Some(CommitDest::Branch(name.clone())),
_ => None,
}));
self.mode = Mode::CommitTarget {
source,
source_rows,
dests,
index: 0,
origin,
};
self.diff_cache.remove(&PENDING_COMMIT_OID.to_string());
self.diff.reset();
self.show_pending_commit();
}
fn cancel_pick(&mut self, then_quit: bool) {
if let Some(Pick::Collecting { cancelled, .. }) = &mut self.pick {
*cancelled = true;
self.quit_after_pick |= then_quit;
}
}
fn advance_pick(&mut self) -> PickTick {
match self.pick.take() {
None => PickTick::Nothing,
Some(ready @ Pick::Ready { .. }) => {
self.pick = Some(ready);
PickTick::Ready { first: false }
}
Some(Pick::Collecting {
handle,
origin,
purpose,
ticks,
cancelled,
}) if !handle.is_finished() => {
self.pick = Some(Pick::Collecting {
handle,
origin,
purpose,
ticks: ticks + 1,
cancelled,
});
PickTick::Redraw
}
Some(Pick::Collecting {
handle,
origin,
purpose,
cancelled,
..
}) => {
let read = handle.join().unwrap_or_else(|_| {
Err(anyhow::anyhow!(
"reading the hunks crashed — run `loom trace` for what it did"
))
});
if cancelled {
self.log_line(Level::Warn, "Cancelled");
self.notice = Some(format!("{} cancelled", purpose.label()));
if self.quit_after_pick {
self.outcome = Some(Outcome::Quit);
}
return PickTick::Redraw;
}
match read {
Ok((entries, stamp)) => {
self.pick = Some(Pick::Ready {
entries,
stamp,
origin,
purpose,
});
PickTick::Ready { first: true }
}
Err(e) => {
let text = e.to_string();
self.log_line(Level::Error, &text);
if !matches!(self.popup, Some(Popup::Log { .. })) {
self.show_error(&text, AfterNotice::Nothing);
}
PickTick::Redraw
}
}
}
}
}
fn finish_pick(&mut self, staged: Result<bool>, origin: String) {
match staged {
Ok(true) => {
if self.reload() {
let source_rows = self.index_sources();
self.enter_commit_target(CommitSource::Index, source_rows, origin);
}
}
Ok(false) => {
self.log_line(Level::Warn, "Cancelled");
self.notice = Some("commit cancelled".to_string());
}
Err(e) => {
let text = e.to_string();
self.log_line(Level::Error, &text);
self.reload();
self.show_error(&text, AfterNotice::Nothing);
}
}
}
fn select_and_stage(
&mut self,
entries: Vec<FileEntry>,
terminal: &mut ratatui::DefaultTerminal,
) -> Result<bool> {
let picked = run_hunk_selector_nested(
entries,
TuiTheme::from_graph_theme(&self.graph_theme),
"COMMIT",
terminal,
)?;
let Some(picked) = picked else {
return Ok(false);
};
self.apply_pick(picked)
}
fn apply_pick(&mut self, picked: Vec<FileEntry>) -> Result<bool> {
if !picked
.iter()
.any(|file| file.hunks.iter().any(|hunk| hunk.selected))
{
return Ok(false);
}
ui::install(self.request_tx.clone());
let applied =
staging::apply_selections(&self.snapshot.workdir, &picked, staging::LeftOut::Unstaged);
ui::uninstall();
applied.map(|_| true)
}
fn show_pending_commit(&mut self) {
self.rebuild_rows(&PENDING_COMMIT_OID.to_string());
}
fn move_commit_dest(&mut self, dir: isize) {
let Mode::CommitTarget { dests, index, .. } = &mut self.mode else {
return;
};
let next = index.saturating_add_signed(dir).min(dests.len() - 1);
if next == *index {
return;
}
*index = next;
self.show_pending_commit();
}
fn confirm_commit_target(&mut self) -> Option<Action> {
let Mode::CommitTarget {
source,
dests,
index,
origin,
..
} = std::mem::replace(&mut self.mode, Mode::Normal)
else {
return None;
};
self.fallback_cursor = Some(origin);
Some(Action::Commit {
source,
dest: dests[index].clone(),
})
}
fn cancel_commit_target(&mut self) {
let Mode::CommitTarget { origin, .. } = std::mem::replace(&mut self.mode, Mode::Normal)
else {
return;
};
self.rebuild_rows(&origin);
self.diff.reset();
self.notice = Some("commit cancelled".to_string());
}
fn action_fold_start(&mut self) {
let rows = self.picked_rows();
let Some(origin) = self.current_row().map(|r| r.key.clone()) else {
return;
};
let sources: Vec<String> = rows.iter().filter_map(|r| r.target.clone()).collect();
if sources.is_empty() || sources.len() != rows.len() {
self.notice = Some("fold: select source rows first".to_string());
return;
}
let source_rows = self.mark_sources(&rows);
let targets = match self.fold_targets(&rows, false) {
Ok(targets) => targets,
Err(notice) => {
self.notice = Some(format!("fold: {}", notice));
return;
}
};
self.enter_fold_target(sources, source_rows, targets, origin, None);
}
fn enter_fold_target(
&mut self,
sources: Vec<String>,
source_rows: Sources,
targets: Vec<FoldTarget>,
origin: String,
hunks: Option<FoldHunks>,
) {
let index = nearest_target(&self.rows, &targets, self.tree.cursor());
self.mode = Mode::FoldTarget {
sources,
source_rows,
targets,
index,
origin,
hunks,
};
self.show_fold_target();
}
fn finish_fold_pick(
&mut self,
picked: Result<Option<Vec<FileEntry>>>,
stamp: Stamp,
sources: Vec<String>,
targets: Vec<FoldTarget>,
commit: Option<git2::Oid>,
origin: String,
) {
let picked = match picked {
Ok(Some(picked)) if picked.iter().any(|f| f.hunks.iter().any(|h| h.selected)) => picked,
Ok(_) => {
self.log_line(Level::Warn, "Cancelled");
self.notice = Some("fold cancelled".to_string());
return;
}
Err(e) => {
let text = e.to_string();
self.log_line(Level::Error, &text);
self.show_error(&text, AfterNotice::Nothing);
return;
}
};
let source_rows = self.hunk_sources(commit, &picked);
let hunks = match commit {
Some(_) => FoldHunks::Commit(picked),
None => FoldHunks::Worktree { picked, stamp },
};
self.enter_fold_target(sources, source_rows, targets, origin, Some(hunks));
}
fn hunk_sources(&self, commit: Option<git2::Oid>, picked: &[FileEntry]) -> Sources {
let paths: HashSet<&str> = picked
.iter()
.filter(|f| f.hunks.iter().any(|h| h.selected))
.map(|f| f.path.as_str())
.collect();
let Some(oid) = commit else {
return Sources {
named: paths.iter().map(|p| working_file_key(p)).collect(),
covered: HashSet::new(),
};
};
let files = self
.snapshot
.info
.commits
.iter()
.find(|c| c.oid == oid)
.map_or(&[][..], |c| &c.files);
Sources {
named: HashSet::from([oid.to_string()]),
covered: files
.iter()
.enumerate()
.filter(|(_, f)| paths.contains(f.path.as_str()))
.map(|(i, _)| commit_file_key(oid, i))
.collect(),
}
}
fn fold_targets(&self, rows: &[&Row], hunks: bool) -> Result<Vec<FoldTarget>, &'static str> {
let target = |row: &Row, effect| {
row.target.clone().map(|arg| FoldTarget {
key: row.key.clone(),
arg,
effect,
})
};
let targets: Vec<FoldTarget> = match rows[0].kind {
RowKind::LocalChanges { count: 0 } => return Err("no local changes"),
RowKind::LocalChanges { .. } | RowKind::WorkingFile { .. } => self
.rows
.iter()
.filter(|r| matches!(r.kind, RowKind::Commit { .. }))
.filter_map(|r| target(r, FoldEffect::Amend))
.collect(),
RowKind::Commit { oid: source, .. } => {
if rows.len() > 1 {
return Err("one commit at a time");
}
let repo = git2::Repository::open(&self.snapshot.workdir).ok();
let older = |oid: git2::Oid| {
repo.as_ref()
.is_none_or(|repo| repo.graph_descendant_of(source, oid).unwrap_or(true))
};
self.rows
.iter()
.filter_map(|r| match r.kind {
RowKind::LocalChanges { .. } => target(r, FoldEffect::Uncommit),
RowKind::Commit { oid, .. } if oid == source => target(r, FoldEffect::Noop),
RowKind::Commit { oid, .. } if older(oid) => target(
r,
if hunks {
FoldEffect::MoveFile
} else {
FoldEffect::Amend
},
),
_ => None,
})
.collect()
}
RowKind::CommitFile { oid: owner, .. } => {
if rows.len() > 1 {
return Err("one commit file at a time");
}
self.rows
.iter()
.filter_map(|r| match r.kind {
RowKind::LocalChanges { .. } => target(r, FoldEffect::Uncommit),
RowKind::Commit { oid, .. } if oid == owner => target(r, FoldEffect::Noop),
RowKind::Commit { .. } => target(r, FoldEffect::MoveFile),
_ => None,
})
.collect()
}
RowKind::BranchName { .. } => return Err("a branch cannot be folded"),
_ => return Err("move to a file, commit, or commit file"),
};
if targets.iter().all(|t| t.effect == FoldEffect::Noop) {
return Err("nothing to fold into");
}
Ok(targets)
}
fn show_fold_target(&mut self) {
let Mode::FoldTarget { targets, index, .. } = &self.mode else {
return;
};
let key = targets[*index].key.clone();
self.rebuild_rows(&key);
self.diff.reset();
}
fn move_fold_target(&mut self, dir: isize) {
let Mode::FoldTarget { targets, index, .. } = &mut self.mode else {
return;
};
let next = index.saturating_add_signed(dir).min(targets.len() - 1);
if next == *index {
return;
}
*index = next;
self.show_fold_target();
}
fn confirm_fold_target(&mut self) -> Option<Action> {
let Mode::FoldTarget { targets, index, .. } = &self.mode else {
return None;
};
let target = targets[*index].clone();
if target.effect == FoldEffect::Noop {
self.notice = Some("fold: nothing to do here, pick another target".to_string());
return None;
}
let Mode::FoldTarget { sources, hunks, .. } =
std::mem::replace(&mut self.mode, Mode::Normal)
else {
return None;
};
Some(match hunks {
None => Action::Fold {
sources,
target: target.arg,
hunks: None,
},
Some(FoldHunks::Worktree { picked, stamp }) => Action::FoldHunks {
sources,
picked,
stamp,
target: target.arg,
},
Some(FoldHunks::Commit(picked)) => {
let into = (target.effect != FoldEffect::Uncommit).then_some(target.arg.as_str());
let from = hunk_select::fingerprint(&sources[0], into, &picked);
let ids = hunk_select::picked_ids(&picked);
Action::Fold {
sources,
target: target.arg,
hunks: Some(HunkArgs::new(ids, Some(from))),
}
}
})
}
fn cancel_fold_target(&mut self) {
let Mode::FoldTarget { origin, .. } = std::mem::replace(&mut self.mode, Mode::Normal)
else {
return;
};
self.rebuild_rows(&origin);
self.diff.reset();
self.notice = Some("fold cancelled".to_string());
}
fn row_tag(&self, row: &Row) -> Option<&'static str> {
match &self.mode {
Mode::FoldTarget { targets, index, .. } => {
let target = &targets[*index];
(row.key == target.key).then(|| target.effect.tag())
}
Mode::MoveTarget { commit, .. } => (row.key == commit.to_string()).then_some("[MOVE]"),
_ => None,
}
}
fn action_move_start(&mut self) {
let rows = self.picked_rows();
let commit = match rows.as_slice() {
[row] => match row.kind {
RowKind::Commit { oid, .. } if row.target.is_some() => oid,
_ => {
self.notice = Some("move: move to a commit".to_string());
return;
}
},
[] => return,
_ => {
self.notice = Some("move: one commit at a time".to_string());
return;
}
};
let slots = self.move_slots(commit);
if slots.len() < 2 {
self.notice = Some("move: nowhere else to put it".to_string());
return;
}
let index = slots
.iter()
.position(|s| *s == MoveSlot::Stay)
.expect("always offered");
self.clear_selection();
self.mode = Mode::MoveTarget {
commit,
slots,
index,
};
self.show_move_target();
}
fn move_slots(&self, commit: git2::Oid) -> Vec<MoveSlot> {
let info = &self.snapshot.info;
let parent_of = |oid: git2::Oid| {
info.commits
.iter()
.find(|c| c.oid == oid)
.and_then(|c| c.parent_oid)
};
let parent = parent_of(commit);
let mut candidates: Vec<(usize, MoveSlot)> = Vec::new();
for (i, row) in self.rows.iter().enumerate() {
match &row.kind {
RowKind::Commit { oid, .. } if *oid == commit => {
candidates.push((3 * i + 1, MoveSlot::Stay));
}
RowKind::Commit { oid, .. } if row.target.is_some() => {
if parent != Some(*oid) {
candidates.push((3 * i, MoveSlot::Above(*oid)));
}
if parent_of(*oid) != Some(commit) {
candidates.push((3 * i + 2, MoveSlot::Below(*oid)));
}
}
RowKind::BranchName { name, .. }
if !info
.branches
.iter()
.any(|b| b.name == *name && b.tip_oid == commit) =>
{
candidates.push((3 * i + 3, MoveSlot::Branch(name.clone())));
}
_ => {}
}
}
candidates.sort_by_key(|(rank, slot)| (*rank, matches!(slot, MoveSlot::Branch(_))));
let mut seen = HashSet::from([self.move_signature(commit, &MoveSlot::Stay)]);
candidates
.into_iter()
.map(|(_, slot)| slot)
.filter(|slot| {
*slot == MoveSlot::Stay || seen.insert(self.move_signature(commit, slot))
})
.collect()
}
fn move_signature(&self, commit: git2::Oid, slot: &MoveSlot) -> Vec<String> {
let sections = self.snapshot.sections(Some(Preview::Move {
oid: commit,
slot: slot.clone(),
}));
status_tree::build_rows(§ions, &self.snapshot.ids, &self.expanded)
.into_iter()
.map(|r| match r.kind {
RowKind::Spacer(text) => text.to_string(),
_ => r.key,
})
.collect()
}
fn show_move_target(&mut self) {
let Mode::MoveTarget { commit, .. } = &self.mode else {
return;
};
let key = commit.to_string();
self.rebuild_rows(&key);
self.diff.reset();
}
fn move_move_target(&mut self, dir: isize) {
let Mode::MoveTarget { slots, index, .. } = &mut self.mode else {
return;
};
let next = index.saturating_add_signed(dir).min(slots.len() - 1);
if next == *index {
return;
}
*index = next;
self.show_move_target();
}
fn confirm_move_target(&mut self) -> Option<Action> {
let Mode::MoveTarget { slots, index, .. } = &self.mode else {
return None;
};
if slots[*index] == MoveSlot::Stay {
self.notice = Some("move: it is already here, pick another place".to_string());
return None;
}
let Mode::MoveTarget {
commit,
mut slots,
index,
} = std::mem::replace(&mut self.mode, Mode::Normal)
else {
return None;
};
Some(Action::Move {
commit,
slot: slots.swap_remove(index),
})
}
fn cancel_move_target(&mut self) {
let Mode::MoveTarget { commit, .. } = std::mem::replace(&mut self.mode, Mode::Normal)
else {
return;
};
self.rebuild_rows(&commit.to_string());
self.diff.reset();
self.notice = Some("move cancelled".to_string());
}
fn action_new_branch(&mut self) {
let Some(row) = self.current_row() else {
return;
};
let origin = row.key.clone();
let info = &self.snapshot.info;
let (target, tip) = match &row.kind {
RowKind::Commit { oid, .. } => (row.target.clone(), *oid),
RowKind::BranchName { name, .. } => {
let tip = info
.branches
.iter()
.find(|b| b.name == *name)
.map_or(info.upstream.merge_base_oid, |b| b.tip_oid);
(row.target.clone(), tip)
}
_ => (None, info.upstream.merge_base_oid),
};
self.mode = Mode::NewBranch {
target,
tip,
origin,
field: TextField::new(""),
};
self.rebuild_rows(&branch_key(NEW_BRANCH_NAME));
self.diff.reset();
}
fn editing_name(&self) -> bool {
matches!(
self.mode,
Mode::RenameBranch { .. } | Mode::NewBranch { .. }
)
}
fn handle_name_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
match self.mode {
Mode::RenameBranch { .. } => self.handle_rename_key(code, modifiers),
Mode::NewBranch { .. } => self.handle_new_branch_key(code, modifiers),
_ => None,
}
}
fn handle_new_branch_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
let Mode::NewBranch {
target,
origin,
field,
..
} = &mut self.mode
else {
return None;
};
let cancelled = code == KeyCode::Esc
|| (code == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL));
if !cancelled && code != KeyCode::Enter {
field.handle_key(code, modifiers);
return None;
}
let name = field.value().trim().to_string();
if cancelled || name.is_empty() {
let origin = origin.clone();
self.mode = Mode::Normal;
self.rebuild_rows(&origin);
self.diff.reset();
self.notice = Some("branch cancelled".to_string());
return None;
}
let target = target.clone();
self.fallback_cursor = Some(origin.clone());
self.mode = Mode::Normal;
let key = branch_key(NEW_BRANCH_NAME);
if let Some(row) = self.rows.iter_mut().find(|r| r.key == key)
&& let RowKind::BranchName { name: shown, .. } = &mut row.kind
{
*shown = name.clone();
}
self.next_cursor = Some(branch_key(&name));
Some(Action::NewBranch { name, target })
}
fn split_source(&self, rows: &[&Row]) -> Result<git2::Oid, &'static str> {
let mut commits = HashSet::new();
for row in rows {
match row.kind {
RowKind::Commit { oid, .. } | RowKind::CommitFile { oid, .. }
if row.target.is_some() =>
{
commits.insert(oid);
}
_ => return Err("move to a commit or commit file"),
}
}
match commits.into_iter().collect::<Vec<_>>()[..] {
[commit] => Ok(commit),
[] => Err("move to a commit or commit file"),
_ => Err("one commit at a time"),
}
}
fn action_split(&mut self) -> Option<Action> {
let rows = self.picked_rows();
let commit = match self.split_source(&rows) {
Ok(commit) => commit,
Err(notice) => {
self.notice = Some(format!("split: {}", notice));
return None;
}
};
let files: Vec<String> = rows
.iter()
.filter_map(|r| match &r.kind {
RowKind::CommitFile { path, .. } => {
Some(repo::cwd_relative_path(path, &self.snapshot.cwd_prefix))
}
_ => None,
})
.collect();
if files.is_empty() {
self.action_split_patch_start();
return None;
}
let total = self
.snapshot
.info
.commits
.iter()
.find(|c| c.oid == commit)
.map_or(0, |c| c.files.len());
if total < 2 {
self.notice = Some("split: one file only, split its hunks with S".to_string());
return None;
}
if files.len() >= total {
self.notice = Some("split: leave at least one file for the second commit".to_string());
return None;
}
Some(Action::Split {
commit: commit.to_string(),
files,
hunks: None,
})
}
fn action_split_patch_start(&mut self) {
let rows = self.picked_rows();
let Some(origin) = self.current_row().map(|r| r.key.clone()) else {
return;
};
let commit = match self.split_source(&rows) {
Ok(commit) => commit,
Err(notice) => {
self.notice = Some(format!("split: {}", notice));
return;
}
};
let command = format!("loom split -p {}", self.sid_of(&commit.to_string()));
self.start_pick(command, origin, PickFor::Split { commit }, move |workdir| {
let entries = staging::collect_commit_hunks(workdir, &commit.to_string(), &[])?;
Ok((entries, Vec::new()))
});
}
fn refuse_split_pick(&mut self, entries: &[FileEntry]) -> bool {
let (line, notice) = match entries.iter().map(|f| f.hunks.len()).sum::<usize>() {
0 => ("No changes to pick", "split: no hunks to pick"),
1 => ("Only one hunk", "split: one hunk only, nothing to split"),
_ => return false,
};
self.log_line(Level::Warn, line);
self.notice = Some(notice.to_string());
true
}
fn finish_split_pick(
&mut self,
picked: Result<Option<Vec<FileEntry>>>,
commit: git2::Oid,
) -> Option<Action> {
let picked = match picked {
Ok(Some(picked)) if picked.iter().any(|f| f.hunks.iter().any(|h| h.selected)) => picked,
Ok(_) => {
self.log_line(Level::Warn, "Cancelled");
self.notice = Some("split cancelled".to_string());
return None;
}
Err(e) => {
let text = e.to_string();
self.log_line(Level::Error, &text);
self.show_error(&text, AfterNotice::Nothing);
return None;
}
};
if picked.iter().all(|f| f.hunks.iter().all(|h| h.selected)) {
self.log_line(Level::Warn, "Nothing left for the second commit");
self.notice = Some("split: leave at least one hunk for the second commit".to_string());
return None;
}
let commit = commit.to_string();
let from = hunk_select::fingerprint(&commit, None, &picked);
let ids = hunk_select::picked_ids(&picked);
Some(Action::Split {
commit,
files: Vec::new(),
hunks: Some(HunkArgs::new(ids, Some(from))),
})
}
fn action_drop(&mut self) -> Option<Action> {
let rows = self.picked_rows();
let droppable = |row: &Row| {
matches!(
row.kind,
RowKind::Commit { .. }
| RowKind::BranchName { .. }
| RowKind::WorkingFile { .. }
| RowKind::LocalChanges { .. }
) && row.target.is_some()
};
if rows.is_empty() || !rows.iter().all(|r| droppable(r)) {
self.notice =
Some("drop: move to a commit, branch, file, or local changes".to_string());
return None;
}
if rows.len() > 1
&& !rows
.iter()
.all(|r| matches!(r.kind, RowKind::WorkingFile { .. }))
{
self.notice = Some("drop: only files can be dropped together".to_string());
return None;
}
let targets = rows.iter().filter_map(|r| r.target.clone()).collect();
Some(Action::Drop { targets })
}
fn action_absorb(&mut self) -> Option<Action> {
if self.snapshot.info.working_changes.is_empty() {
self.notice = Some("absorb: no local changes".to_string());
return None;
}
let rows = self.picked_rows();
let is_file = |r: &&Row| matches!(r.kind, RowKind::WorkingFile { .. });
if !self.selected.is_empty()
&& !rows
.iter()
.all(|r| is_file(r) || matches!(r.kind, RowKind::LocalChanges { .. }))
{
self.notice = Some("absorb: select files or local changes".to_string());
return None;
}
let files = if rows.iter().all(is_file) {
rows.iter().filter_map(|r| r.target.clone()).collect()
} else {
Vec::new()
};
Some(Action::Absorb { files })
}
fn action_reword(&mut self) -> Option<Action> {
let row = self.current_row()?;
let target = row.target.clone();
match (&row.kind, target) {
(RowKind::Commit { .. }, Some(target)) => Some(Action::Reword { target, name: None }),
(RowKind::BranchName { name, .. }, Some(target)) => {
let field = TextField::new(name);
self.mode = Mode::RenameBranch {
branch: target,
field,
};
None
}
_ => {
self.notice = Some("reword: move to a commit or branch".to_string());
None
}
}
}
fn handle_rename_key(&mut self, code: KeyCode, modifiers: KeyModifiers) -> Option<Action> {
let Mode::RenameBranch { branch, field } = &mut self.mode else {
return None;
};
if code == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL) {
self.mode = Mode::Normal;
self.notice = Some("rename cancelled".to_string());
return None;
}
match code {
KeyCode::Enter => {
let name = field.value().trim().to_string();
let branch = branch.clone();
if name.is_empty() {
self.notice = Some("rename: the name cannot be empty".to_string());
return None;
}
self.mode = Mode::Normal;
if name == branch {
self.notice = Some("rename: name unchanged".to_string());
return None;
}
self.next_cursor = Some(branch_key(&name));
Some(Action::Reword {
target: branch,
name: Some(name),
})
}
KeyCode::Esc => {
self.mode = Mode::Normal;
self.notice = Some("rename cancelled".to_string());
None
}
_ => {
field.handle_key(code, modifiers);
None
}
}
}
fn render_tree(&mut self, frame: &mut Frame, area: Rect, focused: bool) {
let cursor = self.tree.cursor();
let editing = match &self.mode {
Mode::RenameBranch { branch, field } => Some((branch_key(branch), field)),
Mode::NewBranch { field, .. } => Some((branch_key(NEW_BRANCH_NAME), field)),
_ => None,
};
let items: Vec<ListItem> = self
.rows
.iter()
.enumerate()
.map(|(i, row)| {
ListItem::new(row_line(
row,
self.theme,
&self.snapshot.cwd_prefix,
self.row_mark(row),
self.row_tag(row),
i == cursor,
editing
.as_ref()
.filter(|(key, _)| *key == row.key)
.map(|(_, f)| *f),
))
})
.collect();
let title = match &self.mode {
Mode::Normal => " Status ".to_string(),
Mode::FoldTarget { sources, hunks, .. } => {
let of = if hunks.is_some() { "hunks of " } else { "" };
self.title_sources(sources, area.width, |what| {
format!(" Fold {}{} into... ", of, what)
})
}
Mode::CommitTarget {
source,
dests,
index,
..
} => {
let dest = match &dests[*index] {
CommitDest::Integration => &self.snapshot.info.branch_name,
CommitDest::Branch(name) => name,
};
let title = |what: &str| format!(" Commit {} → [{}] ", what, dest);
match source {
CommitSource::Files(files) => self.title_sources(files, area.width, title),
CommitSource::Index => title("the index"),
}
}
Mode::MoveTarget { commit, .. } => {
format!(" Move {} ", self.sid_of(&commit.to_string()))
}
Mode::RenameBranch { .. } => " Rename branch ".to_string(),
Mode::NewBranch { .. } => " New branch ".to_string(),
};
let block = pane_block(&title, self.theme, focused);
self.tree
.render(frame, area, items, block, self.theme.file_selected);
}
fn render_diff(&mut self, frame: &mut Frame, area: Rect, focused: bool) {
let key = self.ensure_diff_cached();
let lines = borrowed_lines(&self.diff_cache[&key]);
let block = pane_block(" Diff ", self.theme, focused);
self.diff.render(frame, area, lines, block);
}
fn ensure_diff_cached(&mut self) -> String {
let key = match self.rows.get(self.tree.cursor()) {
Some(row) => row.key.clone(),
None => String::new(),
};
if !self.diff_cache.contains_key(&key) {
let lines = match self.rows.get(self.tree.cursor()) {
Some(row) => {
let text = match &self.mode {
Mode::CommitTarget { source, .. }
if row.key == PENDING_COMMIT_OID.to_string() =>
{
match source {
CommitSource::Files(files) => {
pending_commit_diff(&self.snapshot, files)
}
CommitSource::Index => staged_commit_diff(&self.snapshot),
}
}
_ => diff_text(&self.snapshot, row),
};
colorize_diff(&text, self.theme)
}
None => vec![Line::from("")],
};
self.diff_cache.insert(key.clone(), lines);
}
key
}
}
fn borrowed_lines<'a>(lines: &'a [Line<'static>]) -> Vec<Line<'a>> {
lines
.iter()
.map(|line| {
Line::from(
line.spans
.iter()
.map(|s| Span::styled(s.content.as_ref(), s.style))
.collect::<Vec<Span<'a>>>(),
)
.style(line.style)
})
.collect()
}
impl ShellApp for App<'_> {
type Exit = Outcome;
fn config(&self) -> ShellConfig {
ShellConfig { split: (45, 55) }
}
fn theme(&self) -> &TuiTheme {
self.theme
}
fn quit_exit(&mut self) -> Outcome {
Outcome::Quit
}
fn handle_key(
&mut self,
focused: PaneId,
code: KeyCode,
modifiers: KeyModifiers,
) -> KeyResult<Outcome> {
if self.popup.is_some() {
self.handle_popup_key(code, modifiers);
} else if self.editing_name() {
if let Some(action) = self.handle_name_key(code, modifiers) {
self.start_action(action);
}
} else if self.running.is_some() {
match code {
KeyCode::Char('L') => self.open_log(),
KeyCode::Char('?') => self.open_help(),
_ => self.notice = Some("an action is running…".to_string()),
}
} else if self.pick.is_some() {
match code {
KeyCode::Char('L') => self.open_log(),
KeyCode::Char('?') => self.open_help(),
KeyCode::Esc => self.cancel_pick(false),
KeyCode::Char('q') => self.cancel_pick(true),
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
self.cancel_pick(true)
}
_ => {}
}
} else {
self.handle_tree_key(focused, code);
}
match self.outcome.take() {
Some(outcome) => KeyResult::Exit(outcome),
None => {
self.ensure_diff_cached();
KeyResult::Handled
}
}
}
fn handle_mouse(&mut self, pane: PaneId, kind: MouseEventKind, pos: Position, area: Rect) {
match pane {
PaneId::Left => match kind {
MouseEventKind::Down(MouseButton::Left)
if !matches!(
self.mode,
Mode::CommitTarget { .. } | Mode::MoveTarget { .. }
) =>
{
let Some(clicked) = self.tree.hit_test(area, pos.y) else {
return;
};
let Some(key) = self
.rows
.get(clicked)
.filter(|r| r.focusable)
.map(|r| r.key.clone())
else {
return;
};
if let Mode::FoldTarget { targets, index, .. } = &mut self.mode {
if let Some(i) = targets.iter().position(|t| t.key == key) {
*index = i;
self.show_fold_target();
}
} else {
self.tree.set_cursor(clicked);
self.diff.reset();
}
}
MouseEventKind::ScrollUp => self.move_cursor(-1),
MouseEventKind::ScrollDown => self.move_cursor(1),
_ => {}
},
PaneId::Right => match kind {
MouseEventKind::ScrollUp => self.diff.scroll_by(-3),
MouseEventKind::ScrollDown => self.diff.scroll_by(3),
_ => {}
},
}
self.ensure_diff_cached();
}
fn render_pane(&mut self, frame: &mut Frame, pane: PaneId, area: Rect, focused: bool) {
match pane {
PaneId::Left => self.render_tree(frame, area, focused),
PaneId::Right => self.render_diff(frame, area, focused),
}
}
fn render_overlay(&mut self, frame: &mut Frame, area: Rect) {
match &mut self.popup {
Some(Popup::Prompt { prompt, .. }) => prompt.render(frame, area, self.theme),
Some(Popup::Notice { notice, .. }) => notice.render(frame, area, self.theme),
Some(Popup::Log { scroll }) => {
popup::render_log(frame, area, &self.log, scroll, self.theme)
}
Some(Popup::Help { scroll }) => {
popup::render_help(frame, area, HELP, scroll, self.theme)
}
None => {}
}
}
fn modal_active(&self) -> bool {
self.popup.is_some() || self.running.is_some() || self.editing_name() || self.pick.is_some()
}
fn poll_background(&mut self) -> Tick<Outcome> {
let mut changed = false;
loop {
match self.requests.try_recv() {
Ok(Request::Suspend(ack)) => return Tick::Suspend(ack),
Ok(request) => {
self.handle_request(request);
changed = true;
}
Err(_) => break,
}
}
match self.advance_pick() {
PickTick::Ready { .. } if self.popup.is_none() => return Tick::TakeOver,
PickTick::Ready { first } => changed |= first,
PickTick::Redraw => changed = true,
PickTick::Nothing => {}
}
if let Some(running) = &mut self.running {
running.ticks += 1;
if running.handle.is_finished() {
let running = self.running.take().expect("checked above");
let result = running.handle.join().unwrap_or_else(|_| {
Err(anyhow::anyhow!(
"the action crashed — run `loom trace` for what it did"
))
});
if self.finish_action(result) {
self.after_action();
}
}
changed = true;
}
if let Some(outcome) = self.outcome.take() {
return Tick::Exit(outcome);
}
if changed { Tick::Redraw } else { Tick::Idle }
}
fn take_over(&mut self, terminal: &mut ratatui::DefaultTerminal) {
let (entries, stamp, origin, purpose) = match self.pick.take() {
Some(Pick::Ready {
entries,
stamp,
origin,
purpose,
}) => (entries, stamp, origin, purpose),
other => {
self.pick = other;
return;
}
};
match purpose {
PickFor::Commit => {
let staged = self.select_and_stage(entries, terminal);
self.finish_pick(staged, origin);
}
PickFor::Split { commit } => {
if self.refuse_split_pick(&entries) {
return;
}
let picked = run_hunk_selector_nested(
entries,
TuiTheme::from_graph_theme(&self.graph_theme),
"SPLIT",
terminal,
);
if let Some(action) = self.finish_split_pick(picked, commit) {
self.start_action(action);
}
}
PickFor::Fold {
sources,
targets,
commit,
} => {
if entries.is_empty() {
self.log_line(Level::Warn, "No changes to pick");
self.notice = Some("fold: no hunks to pick".to_string());
return;
}
let picked = run_hunk_selector_nested(
entries,
TuiTheme::from_graph_theme(&self.graph_theme),
"FOLD",
terminal,
);
self.finish_fold_pick(picked, stamp, sources, targets, commit, origin);
}
}
}
fn wait_for_resume(&mut self) {
loop {
match self.requests.recv_timeout(Duration::from_millis(100)) {
Ok(Request::Resume) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
return;
}
Ok(request) => self.handle_request(request),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if self.running.as_ref().is_none_or(|r| r.handle.is_finished()) {
return;
}
}
}
}
}
fn notice(&self) -> Option<&str> {
self.notice.as_deref()
}
fn clear_notice(&mut self) {
self.notice = None;
}
fn mode_hint(&self) -> Option<String> {
if let Some(running) = &self.running {
let frame = SPINNER_FRAMES[(running.ticks / 2) % SPINNER_FRAMES.len()];
let detail = running
.spinner
.as_deref()
.map(|s| format!(" — {}", s))
.unwrap_or_default();
return Some(format!(" {} {}{}", frame, running.command, detail));
}
if let Some(Pick::Collecting {
ticks,
cancelled,
purpose,
..
}) = &self.pick
{
let frame = SPINNER_FRAMES[(ticks / 2) % SPINNER_FRAMES.len()];
let what = match purpose {
_ if *cancelled => "cancelling: waiting for the read to end",
PickFor::Fold {
commit: Some(_), ..
}
| PickFor::Split { .. } => "reading the commit — Esc to cancel",
_ => "reading the working tree — Esc to cancel",
};
return Some(format!(" {} {}: {}", frame, purpose.label(), what));
}
match &self.mode {
Mode::FoldTarget { .. } => {
Some(" fold: ↑/↓ choose the target, Enter to fold, Esc to cancel".to_string())
}
Mode::CommitTarget { .. } => Some(
" commit: ↑/↓ choose the destination, Enter to commit, Esc to cancel".to_string(),
),
Mode::MoveTarget { .. } => {
Some(" move: ↑/↓ choose the place, Enter to move, Esc to cancel".to_string())
}
Mode::RenameBranch { .. } => Some(
" rename: type the new branch name, Enter to confirm, Esc to cancel".to_string(),
),
Mode::NewBranch { .. } => Some(
" branch: type the new branch name, Enter to create, Esc to cancel".to_string(),
),
Mode::Normal => None,
}
}
fn status_hints(&self, _focused: PaneId) -> Vec<Cow<'static, str>> {
vec![
"Commit: c/C".into(),
"Fold: f/F".into(),
"Branch: b".into(),
"Drop: d".into(),
"Help: ?".into(),
"Quit: q".into(),
]
}
}
fn scroll_popup(scroll: &mut DiffPane, code: KeyCode) {
match code {
KeyCode::Up | KeyCode::Char('k') => scroll.scroll_by(-1),
KeyCode::Down | KeyCode::Char('j') => scroll.scroll_by(1),
KeyCode::PageUp => scroll.scroll_page(-1),
KeyCode::PageDown => scroll.scroll_page(1),
_ => {}
}
}
fn nearest_focusable(rows: &[Row], index: usize) -> Option<usize> {
if rows.is_empty() {
return None;
}
let start = index.min(rows.len() - 1);
rows[..=start]
.iter()
.rposition(|r| r.focusable)
.or_else(|| rows.iter().position(|r| r.focusable))
}
fn nearest_target(rows: &[Row], targets: &[FoldTarget], cursor: usize) -> usize {
(0..targets.len())
.min_by_key(|&i| {
let at = rows
.iter()
.position(|r| r.key == targets[i].key)
.unwrap_or(usize::MAX);
(
targets[i].effect != FoldEffect::Noop,
at.abs_diff(cursor),
at < cursor,
)
})
.unwrap_or(0)
}
fn row_line(
row: &Row,
theme: &TuiTheme,
cwd_prefix: &str,
mark: RowMark,
tag: Option<&'static str>,
is_cursor: bool,
editing: Option<&TextField>,
) -> Line<'static> {
let dim = if is_cursor {
theme.dim_selected
} else {
theme.dim
};
let mut spans: Vec<Span<'static>> = vec![match mark {
RowMark::Selected => Span::styled("✓ ", theme.selection),
RowMark::Source => Span::styled("▸ ", theme.source),
RowMark::Covered => Span::styled("▸ ", dim),
RowMark::None => Span::raw(" "),
}];
let display = |path: &str| crate::core::repo::cwd_relative_path(path, cwd_prefix);
match &row.kind {
RowKind::LocalChanges { count } => {
spans.push(Span::styled("╭─ ", theme.graph));
spans.push(Span::styled(row.sid.clone(), theme.shortid));
if let Some(tag) = tag {
spans.push(Span::raw(" "));
spans.push(Span::styled(tag, theme.pending_tag));
}
spans.push(Span::styled(" [", dim));
spans.push(Span::styled("local changes", theme.label));
spans.push(Span::styled("]", dim));
if *count == 0 {
spans.push(Span::styled(" no changes", dim));
} else if !row.expanded {
spans.push(Span::styled(format!(" ({} files)", count), dim));
}
}
RowKind::WorkingFile {
path,
index,
worktree,
} => {
spans.push(Span::styled("│ ", theme.graph));
spans.push(Span::styled(row.sid.clone(), theme.shortid));
let group = graph::file_group(*index, *worktree);
if group == graph::FileGroup::Untracked {
spans.push(Span::styled(" ⁕ ", theme.untracked));
} else if group == graph::FileGroup::Conflicted {
spans.push(Span::styled(
" !! ",
theme.unstaged_status.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::raw(" "));
spans.push(Span::styled(index.to_string(), theme.staged_status));
spans.push(Span::styled(worktree.to_string(), theme.unstaged_status));
spans.push(Span::raw(" "));
}
spans.push(Span::raw(display(path)));
}
RowKind::BranchName {
name,
remote,
connector,
..
} => {
spans.push(Span::styled(format!("{} ", connector), theme.graph));
if row.key == branch_key(NEW_BRANCH_NAME) {
spans.push(Span::styled("[CREATE BRANCH]", theme.pending_tag));
} else {
spans.push(Span::styled(row.sid.clone(), theme.shortid));
}
spans.push(Span::styled(" [", dim));
match editing {
Some(field) => spans.extend(field.spans(theme)),
None => spans.push(Span::styled(name.clone(), theme.branch)),
}
spans.push(Span::styled("]", dim));
match remote {
Some(RemoteStatus::Synced) => spans.push(Span::styled(" ✓", theme.remote_synced)),
Some(RemoteStatus::Different) => spans.push(Span::styled(" ↑", theme.remote_ahead)),
Some(RemoteStatus::Gone) => spans.push(Span::styled(" ✗", theme.remote_gone)),
None => {}
}
}
RowKind::Commit {
oid,
message,
dot_color,
file_count,
} => {
match dot_color {
Some(idx) => {
spans.push(Span::styled("│", theme.graph));
spans.push(Span::styled(
"●",
theme.branch_dots[idx % theme.branch_dots.len()],
));
spans.push(Span::raw(" "));
}
None => {
spans.push(Span::styled("●", theme.graph));
spans.push(Span::raw(" "));
}
}
if *oid == PENDING_COMMIT_OID {
spans.push(Span::styled("[CREATE COMMIT]", theme.pending_tag));
spans.push(Span::raw(" "));
spans.push(Span::styled(message.clone(), dim));
return Line::from(spans);
}
spans.push(Span::styled(row.sid.clone(), theme.shortid));
spans.push(Span::raw(graph::id_pad(&row.sid)));
if let Some(tag) = tag {
spans.push(Span::styled(tag, theme.pending_tag));
spans.push(Span::raw(" "));
}
spans.push(Span::styled(message.clone(), theme.message));
if row.expandable && !row.expanded {
spans.push(Span::styled(format!(" ({} files)", file_count), dim));
}
}
RowKind::CommitFile {
path,
index,
worktree,
on_branch,
..
} => {
let prefix = if *on_branch { "│┊ " } else { "┊ " };
spans.push(Span::styled(prefix, theme.graph));
spans.push(Span::styled(row.sid.clone(), theme.shortid));
spans.push(Span::raw(" "));
spans.push(Span::styled(index.to_string(), theme.staged_status));
spans.push(Span::styled(worktree.to_string(), theme.unstaged_status));
spans.push(Span::raw(" "));
spans.push(Span::raw(display(path)));
}
RowKind::Upstream {
label,
base_short_id,
base_message,
commits_ahead,
} => {
spans.push(Span::styled("● ", theme.graph));
spans.push(Span::styled(format!("{} ", base_short_id), dim));
spans.push(Span::styled("(upstream) ", theme.label));
spans.push(Span::styled("[", dim));
spans.push(Span::styled(label.clone(), theme.branch));
spans.push(Span::styled("] ", dim));
if *commits_ahead > 0 {
spans.push(Span::styled(
format!(
"⏫ {} new commit{} — ",
commits_ahead,
if *commits_ahead == 1 { "" } else { "s" }
),
theme.message,
));
}
spans.push(Span::styled(base_message.clone(), theme.message));
}
RowKind::Context {
short_hash,
date,
message,
} => {
spans.push(Span::styled("· ", dim));
spans.push(Span::styled(format!("{} {} ", short_hash, date), dim));
spans.push(Span::styled(message.clone(), theme.message));
}
RowKind::Spacer(text) => {
spans.push(Span::styled(*text, theme.graph));
}
}
Line::from(spans)
}
fn diff_text(snapshot: &Snapshot, row: &Row) -> String {
let workdir = &snapshot.workdir;
if row.key == branch_key(NEW_BRANCH_NAME) {
return "branch not created yet".to_string();
}
let result = match &row.kind {
RowKind::LocalChanges { count } => {
if *count == 0 {
return "no changes".to_string();
}
git::diff_head_display(workdir)
}
RowKind::WorkingFile {
path,
index,
worktree,
} => {
if graph::file_group(*index, *worktree) == graph::FileGroup::Untracked {
return untracked_file_text(workdir, path);
}
git::diff_head_file_display(workdir, path)
}
RowKind::BranchName { range, .. } => match range {
Some((base, tip)) => git::diff_range(workdir, base, tip),
None => return "branch has no commits of its own".to_string(),
},
RowKind::Commit { oid, .. } if *oid == PENDING_COMMIT_OID => {
return "commit not created yet".to_string();
}
RowKind::Commit { oid, .. } => git::show_commit_patch(workdir, &oid.to_string()),
RowKind::CommitFile { oid, path, .. } => {
git::show_commit_file(workdir, &oid.to_string(), path)
}
RowKind::Upstream {
label,
base_short_id,
base_message,
commits_ahead,
} => {
return format!(
"upstream: {}\ncommon base: {} {}\n{} new commit(s) on the remote",
label, base_short_id, base_message, commits_ahead
);
}
RowKind::Context { short_hash, .. } => git::show_commit_patch(workdir, short_hash),
RowKind::Spacer(_) => return String::new(),
};
match result {
Ok(text) if text.trim().is_empty() => "no changes".to_string(),
Ok(text) => text,
Err(e) => format!("error: {}", e),
}
}
fn staged_commit_diff(snapshot: &Snapshot) -> String {
match git::diff_cached_display(&snapshot.workdir) {
Ok(text) if text.trim().is_empty() => "no changes".to_string(),
Ok(text) => text,
Err(e) => format!("error: {}", e),
}
}
fn pending_commit_diff(snapshot: &Snapshot, files: &[String]) -> String {
let workdir = &snapshot.workdir;
let ids = &snapshot.ids;
let all = snapshot.is_all_changes(files);
let or_empty = |result: Result<String>| match result {
Ok(text) if text.trim().is_empty() => String::new(),
Ok(text) => text,
Err(e) => format!("error: {}", e),
};
let untracked = |c: &repo::FileChange| c.index == '?' && c.worktree == '?';
let wanted = |c: &repo::FileChange| all || files.iter().any(|f| f == ids.get_file(&c.path));
let mut out = String::new();
if all {
out.push_str(&or_empty(git::diff_head_display(workdir)));
} else {
let paths: Vec<&str> = snapshot
.info
.working_changes
.iter()
.filter(|c| wanted(c) && !untracked(c))
.map(|c| c.path.as_str())
.collect();
out.push_str(&or_empty(git::diff_head_files_display(workdir, &paths)));
}
for change in snapshot
.info
.working_changes
.iter()
.filter(|c| wanted(c) && untracked(c))
{
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&untracked_file_text(workdir, &change.path));
}
if out.trim().is_empty() {
return "no changes".to_string();
}
out
}
fn untracked_file_text(workdir: &std::path::Path, path: &str) -> String {
const MAX_LINES: usize = 2000;
const MAX_BYTES: u64 = 256 * 1024;
use std::io::Read;
let full = workdir.join(path);
let Ok(file) = std::fs::File::open(&full) else {
return "(unreadable file)".to_string();
};
let mut bytes = Vec::new();
if file.take(MAX_BYTES + 1).read_to_end(&mut bytes).is_err() {
return "(unreadable file)".to_string();
}
let mut truncated = bytes.len() as u64 > MAX_BYTES;
bytes.truncate(MAX_BYTES as usize);
if bytes.contains(&0) {
return "(binary file)".to_string();
}
let content = String::from_utf8_lossy(&bytes);
let mut out = format!("untracked file: {}\n", path);
for (i, line) in content.lines().enumerate() {
if i >= MAX_LINES {
truncated = true;
break;
}
out.push('+');
out.push_str(line);
out.push('\n');
}
if truncated {
out.push_str("...\n");
}
out
}
#[cfg(test)]
#[path = "app_test.rs"]
mod tests;