use std::env;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use ratatui::layout::{Alignment, Constraint, Flex, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, BorderType, Borders, Cell, Clear, List, ListItem, ListState, Padding, Paragraph, Row,
Table, TableState,
};
use ratatui::{DefaultTerminal, Frame};
use ratatui_notifications::Notifications;
use super::config::Config;
use super::git::{
self, AddExistingBranch, AddNewBranch, RepoContext, WorktreeEntry,
};
use super::name;
use super::placement::{self, Placement};
use crate::theme;
use crate::widgets::{
input_paragraph, key_hint_line, list_cursor_style, render_toasts, tick_toasts,
toast_error, toast_warn, wrapping_index, LIST_CURSOR,
};
const POLL_INTERVAL: Duration = Duration::from_millis(120);
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const LIST_HINTS: &[(&str, &str)] = &[
("↑/↓", "移动"),
("Enter", "进入"),
("o", "cursor"),
("m", "打开 MR"),
("v", "Diff"),
("n", "新建"),
("d", "Remove"),
("p", "Purge"),
("r", "刷新"),
("q", "退出"),
];
enum Mode {
List,
Diff {
sha: String,
scroll: usize,
},
CreateKind,
CreateNew,
CreateExisting,
AfterCreate {
path: PathBuf,
},
ConfirmRemove {
path: PathBuf,
dirty: bool,
},
ConfirmPurge {
path: PathBuf,
branch: String,
dirty: bool,
unmerged: bool,
},
Working {
label: &'static str,
},
}
struct WorkOutcome {
entries: Result<Vec<WorktreeEntry>, String>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum NewField {
Branch,
Base,
Placement,
Name,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ExistingField {
BranchList,
Placement,
Name,
}
pub struct App {
ctx: RepoContext,
entries: Vec<WorktreeEntry>,
state: TableState,
mode: Mode,
toasts: Notifications,
quit: bool,
new_branch: String,
new_base: String,
new_placement: Placement,
new_name: String,
new_field: NewField,
branch_choices: Vec<BranchChoice>,
show_remote: bool,
branch_filter: String,
branch_state: ListState,
existing_field: ExistingField,
existing_placement: Placement,
existing_name: String,
existing_local: String,
existing_start: Option<String>,
name_error: Option<String>,
work_rx: Option<Receiver<WorkOutcome>>,
status_rx: Option<Receiver<Vec<WorktreeEntry>>>,
commit_rx: Option<Receiver<Vec<WorktreeEntry>>>,
mr_rx: Option<Receiver<std::collections::HashMap<String, super::mr::MrRef>>>,
diff_cache: std::collections::HashMap<String, String>,
diff_rx: Option<(String, Receiver<Result<String, String>>)>,
patch_cache: std::collections::HashMap<String, Vec<Line<'static>>>,
patch_rx: Option<(String, Receiver<Result<String, String>>)>,
spinner: usize,
}
#[derive(Clone)]
struct BranchChoice {
display: String,
local_name: String,
start_point: Option<String>,
}
impl App {
pub fn new(ctx: RepoContext, entries: Vec<WorktreeEntry>) -> Self {
let mut app = Self {
ctx,
entries,
state: TableState::default(),
mode: Mode::List,
toasts: Notifications::new(),
quit: false,
new_branch: String::new(),
new_base: String::new(),
new_placement: Placement::InRepo,
new_name: String::new(),
new_field: NewField::Branch,
branch_choices: Vec::new(),
show_remote: false,
branch_filter: String::new(),
branch_state: ListState::default(),
existing_field: ExistingField::BranchList,
existing_placement: Placement::InRepo,
existing_name: String::new(),
existing_local: String::new(),
existing_start: None,
name_error: None,
work_rx: None,
status_rx: None,
commit_rx: None,
mr_rx: None,
diff_cache: std::collections::HashMap::new(),
diff_rx: None,
patch_cache: std::collections::HashMap::new(),
patch_rx: None,
spinner: 0,
};
if !app.entries.is_empty() {
app.state.select(Some(0));
}
app
}
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
while !self.quit {
self.poll_work();
self.poll_enrich();
self.poll_diff();
self.poll_patch();
self.ensure_commit_diff();
if let Mode::Diff { sha, .. } = &self.mode {
let sha = sha.clone();
self.ensure_commit_patch(&sha);
}
tick_toasts(&mut self.toasts, POLL_INTERVAL);
terminal.draw(|frame| render(frame, self))?;
if event::poll(POLL_INTERVAL)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
self.on_key(terminal, key)?;
}
}
}
if matches!(self.mode, Mode::Working { .. } | Mode::Diff { .. })
|| self.status_rx.is_some()
|| self.commit_rx.is_some()
|| self.mr_rx.is_some()
|| self.diff_rx.is_some()
|| self.patch_rx.is_some()
|| self.toasts.has_notification()
{
self.spinner = self.spinner.wrapping_add(1);
}
}
Ok(())
}
fn poll_work(&mut self) {
if !matches!(self.mode, Mode::Working { .. }) {
return;
}
let Some(outcome) = self.work_rx.as_ref().and_then(|rx| rx.try_recv().ok()) else {
return;
};
self.work_rx = None;
match outcome.entries {
Ok(entries) => {
self.apply_entries(entries);
self.mode = Mode::List;
self.start_enrich();
}
Err(err) => {
toast_error(&mut self.toasts, err);
self.mode = Mode::List;
}
}
}
fn poll_enrich(&mut self) {
if matches!(self.mode, Mode::Working { .. }) {
if self
.status_rx
.as_ref()
.and_then(|rx| rx.try_recv().ok())
.is_some()
{
self.status_rx = None;
}
if self
.commit_rx
.as_ref()
.and_then(|rx| rx.try_recv().ok())
.is_some()
{
self.commit_rx = None;
}
if self
.mr_rx
.as_ref()
.and_then(|rx| rx.try_recv().ok())
.is_some()
{
self.mr_rx = None;
}
return;
}
if let Some(updated) = self
.status_rx
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
self.status_rx = None;
self.merge_status(updated);
}
if let Some(updated) = self
.commit_rx
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
self.commit_rx = None;
self.merge_commits(updated);
}
if let Some(mrs) = self.mr_rx.as_ref().and_then(|rx| rx.try_recv().ok()) {
self.mr_rx = None;
self.merge_mrs(mrs);
}
}
fn poll_diff(&mut self) {
if matches!(self.mode, Mode::Working { .. }) {
if self
.diff_rx
.as_ref()
.and_then(|(_, rx)| rx.try_recv().ok())
.is_some()
{
self.diff_rx = None;
}
return;
}
let Some((sha, rx)) = self.diff_rx.as_ref() else {
return;
};
let sha = sha.clone();
let Some(result) = rx.try_recv().ok() else {
return;
};
self.diff_rx = None;
let text = match result {
Ok(s) => s,
Err(err) => format!("(无法加载: {err})"),
};
self.diff_cache.insert(sha, text);
}
fn ensure_commit_diff(&mut self) {
if matches!(self.mode, Mode::Working { .. } | Mode::Diff { .. }) {
return;
}
let Some(entry) = self.selected_entry() else {
return;
};
let sha = entry.head.clone();
if sha.is_empty() || self.diff_cache.contains_key(&sha) {
return;
}
if let Some((loading, _)) = &self.diff_rx {
if loading == &sha {
return;
}
}
let main = self.ctx.main_path.clone();
let (tx, rx) = mpsc::channel();
let sha_job = sha.clone();
thread::spawn(move || {
let result = git::commit_stat(&main, &sha_job).map_err(|e| e.to_string());
let _ = tx.send(result);
});
self.diff_rx = Some((sha, rx));
}
fn poll_patch(&mut self) {
if matches!(self.mode, Mode::Working { .. }) {
if self
.patch_rx
.as_ref()
.and_then(|(_, rx)| rx.try_recv().ok())
.is_some()
{
self.patch_rx = None;
}
return;
}
let Some((sha, rx)) = self.patch_rx.as_ref() else {
return;
};
let sha = sha.clone();
let Some(result) = rx.try_recv().ok() else {
return;
};
self.patch_rx = None;
let lines = match result {
Ok(s) => super::diff_hl::highlight_unified_diff(&s),
Err(err) => vec![Line::from(Span::styled(
format!("(无法加载: {err})"),
Style::default().fg(theme::muted()),
))],
};
self.patch_cache.insert(sha, lines);
}
fn ensure_commit_patch(&mut self, sha: &str) {
if sha.is_empty() || self.patch_cache.contains_key(sha) {
return;
}
if let Some((loading, _)) = &self.patch_rx {
if loading == sha {
return;
}
}
let main = self.ctx.main_path.clone();
let (tx, rx) = mpsc::channel();
let sha_job = sha.to_string();
thread::spawn(move || {
let result = git::commit_patch(&main, &sha_job).map_err(|e| e.to_string());
let _ = tx.send(result);
});
self.patch_rx = Some((sha.to_string(), rx));
}
fn merge_status(&mut self, updated: Vec<WorktreeEntry>) {
for u in updated {
if let Some(e) = self.entries.iter_mut().find(|e| e.path == u.path) {
e.dirty = u.dirty;
e.ahead = u.ahead;
e.behind = u.behind;
}
}
}
fn merge_commits(&mut self, updated: Vec<WorktreeEntry>) {
for u in updated {
if let Some(e) = self.entries.iter_mut().find(|e| e.path == u.path) {
e.commit = u.commit;
}
}
}
fn merge_mrs(&mut self, mrs: std::collections::HashMap<String, super::mr::MrRef>) {
for e in &mut self.entries {
e.mr = e.branch.as_ref().and_then(|b| mrs.get(b).cloned());
}
}
fn apply_entries(&mut self, entries: Vec<WorktreeEntry>) {
let selected_path = self.selected_entry().map(|e| e.path.clone());
self.entries = entries;
if self.entries.is_empty() {
self.state.select(None);
return;
}
if let Some(path) = selected_path {
if let Some(i) = self.entries.iter().position(|e| e.path == path) {
self.state.select(Some(i));
return;
}
}
let cur = self.state.selected().unwrap_or(0).min(self.entries.len() - 1);
self.state.select(Some(cur));
}
fn start_enrich(&mut self) {
let ctx = self.ctx.clone();
let mut for_status = self.entries.clone();
let (stx, srx) = mpsc::channel();
thread::spawn(move || {
ctx.enrich_status(&mut for_status);
let _ = stx.send(for_status);
});
let ctx = self.ctx.clone();
let mut for_commit = self.entries.clone();
let (ctx_tx, ctx_rx) = mpsc::channel();
thread::spawn(move || {
ctx.enrich_commits(&mut for_commit);
let _ = ctx_tx.send(for_commit);
});
let main = self.ctx.main_path.clone();
let (mtx, mrx) = mpsc::channel();
thread::spawn(move || {
let mrs = super::mr::index_by_source_branch(&main);
let _ = mtx.send(mrs);
});
self.status_rx = Some(srx);
self.commit_rx = Some(ctx_rx);
self.mr_rx = Some(mrx);
}
fn refresh(&mut self) -> Result<()> {
self.entries = self.ctx.list_managed_fast()?;
let len = self.entries.len();
if len == 0 {
self.state.select(None);
} else {
let cur = self.state.selected().unwrap_or(0).min(len - 1);
self.state.select(Some(cur));
}
self.start_enrich();
Ok(())
}
fn on_key(&mut self, terminal: &mut DefaultTerminal, key: KeyEvent) -> Result<()> {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && matches!(key.code, KeyCode::Char('c')) {
self.quit = true;
return Ok(());
}
match &self.mode {
Mode::Working { .. } => Ok(()),
Mode::List => self.on_list_key(terminal, key),
Mode::Diff { .. } => {
self.on_diff_key(key);
Ok(())
}
Mode::CreateKind => {
self.on_create_kind_key(key);
Ok(())
}
Mode::CreateNew => {
self.on_create_new_key(key);
Ok(())
}
Mode::CreateExisting => {
self.on_create_existing_key(key);
Ok(())
}
Mode::AfterCreate { path } => {
let path = path.clone();
self.on_after_create_key(terminal, key, &path)
}
Mode::ConfirmRemove { path, dirty } => {
let path = path.clone();
let dirty = *dirty;
self.on_confirm_remove_key(key, &path, dirty);
Ok(())
}
Mode::ConfirmPurge {
path,
branch,
dirty,
unmerged,
} => {
let path = path.clone();
let branch = branch.clone();
let dirty = *dirty;
let unmerged = *unmerged;
self.on_confirm_purge_key(key, &path, &branch, dirty, unmerged);
Ok(())
}
}
}
fn on_list_key(&mut self, terminal: &mut DefaultTerminal, key: KeyEvent) -> Result<()> {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.quit = true,
KeyCode::Up => self.move_list(-1),
KeyCode::Down => self.move_list(1),
KeyCode::Char('r' | 'R') => {
if let Err(err) = self.refresh() {
toast_error(&mut self.toasts, err.to_string());
}
}
KeyCode::Char('n' | 'N') => self.begin_create(),
KeyCode::Char('o' | 'O') => {
if let Some(entry) = self.selected_entry() {
let path = entry.path.clone();
if let Err(err) = open_cursor(terminal, &path) {
toast_error(&mut self.toasts, err.to_string());
}
}
}
KeyCode::Char('m' | 'M') => {
if let Err(err) = self.open_selected_mr(terminal) {
toast_error(&mut self.toasts, err.to_string());
}
}
KeyCode::Enter => {
if let Some(entry) = self.selected_entry() {
let path = entry.path.clone();
if let Ok(here) = env::current_dir() {
let _ = super::state::save_previous(&self.ctx.main_path, &here);
}
match super::switch::enter_from_tui(&path) {
Ok(true) => {
self.quit = true;
}
Ok(false) => {
if let Err(err) = open_subshell(terminal, &path) {
toast_error(&mut self.toasts, err.to_string());
}
}
Err(err) => toast_error(&mut self.toasts, err.to_string()),
}
}
}
KeyCode::Char('v' | 'V') => self.begin_diff(),
KeyCode::Char('d' | 'D') => self.begin_remove(),
KeyCode::Char('p' | 'P') => self.begin_purge(),
_ => {}
}
Ok(())
}
fn begin_diff(&mut self) {
let Some(entry) = self.selected_entry() else {
return;
};
if entry.head.is_empty() {
toast_warn(&mut self.toasts, "无 commit 可查看");
return;
}
let sha = entry.head.clone();
self.mode = Mode::Diff {
sha: sha.clone(),
scroll: 0,
};
self.ensure_commit_patch(&sha);
}
fn on_diff_key(&mut self, key: KeyEvent) {
let Mode::Diff { sha, scroll } = &self.mode else {
return;
};
let sha = sha.clone();
let scroll = *scroll;
let page = 10usize;
let next = match key.code {
KeyCode::Esc | KeyCode::Char('q' | 'Q') => {
self.mode = Mode::List;
return;
}
KeyCode::Up | KeyCode::Char('k') => scroll.saturating_sub(1),
KeyCode::Down | KeyCode::Char('j') => scroll.saturating_add(1),
KeyCode::PageDown | KeyCode::Char(' ') => scroll.saturating_add(page),
KeyCode::PageUp | KeyCode::Char('b' | 'B') => scroll.saturating_sub(page),
KeyCode::Home | KeyCode::Char('g') => 0,
KeyCode::End | KeyCode::Char('G') => self
.patch_cache
.get(&sha)
.map(|p| p.len())
.unwrap_or(0),
_ => return,
};
if let Mode::Diff { scroll, .. } = &mut self.mode {
*scroll = next;
}
}
fn open_selected_mr(&self, terminal: &mut DefaultTerminal) -> Result<()> {
let entry = self
.selected_entry()
.ok_or_else(|| anyhow!("未选中行"))?;
let label = entry
.mr
.as_ref()
.ok_or_else(|| anyhow!("当前行没有关联 MR"))?;
open_mr(terminal, &self.ctx.main_path, label.iid)
}
fn move_list(&mut self, delta: isize) {
if let Some(next) = wrapping_index(
self.state.selected().unwrap_or(0),
delta,
self.entries.len(),
) {
self.state.select(Some(next));
}
}
fn selected_entry(&self) -> Option<&WorktreeEntry> {
self.entries.get(self.state.selected()?)
}
fn begin_create(&mut self) {
self.mode = Mode::CreateKind;
self.name_error = None;
}
fn on_create_kind_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.mode = Mode::List,
KeyCode::Char('1') | KeyCode::Enter => self.start_create_new(),
KeyCode::Char('2') => self.start_create_existing(),
_ => {}
}
}
fn start_create_new(&mut self) {
self.new_branch.clear();
self.new_name.clear();
self.new_placement = Placement::InRepo;
self.new_field = NewField::Branch;
self.name_error = None;
self.new_base = git::default_base_branch(&self.ctx.main_path).unwrap_or_else(|_| "main".into());
self.mode = Mode::CreateNew;
}
fn start_create_existing(&mut self) {
self.show_remote = false;
self.branch_filter.clear();
self.existing_placement = Placement::InRepo;
self.existing_name.clear();
self.existing_start = None;
self.existing_field = ExistingField::BranchList;
self.name_error = None;
self.reload_branch_pool();
self.mode = Mode::CreateExisting;
}
fn reload_branch_pool(&mut self) {
let used: std::collections::HashSet<_> = self
.entries
.iter()
.filter_map(|e| e.branch.clone())
.collect();
let mut choices: Vec<BranchChoice> = Vec::new();
let local = git::list_local_branches(&self.ctx.main_path).unwrap_or_default();
for b in local {
if used.contains(&b) {
continue;
}
choices.push(BranchChoice {
display: b.clone(),
local_name: b,
start_point: None,
});
}
if self.show_remote {
let remote = git::list_remote_branches(&self.ctx.main_path).unwrap_or_default();
for r in remote {
let local_name = r
.split_once('/')
.map(|(_, rest)| rest.to_string())
.unwrap_or_else(|| r.clone());
if used.contains(&local_name)
|| choices.iter().any(|c| c.local_name == local_name)
{
continue;
}
choices.push(BranchChoice {
display: r.clone(),
local_name,
start_point: Some(r),
});
}
}
choices.sort_by(|a, b| a.display.cmp(&b.display));
self.branch_choices = choices;
self.branch_state.select(if self.branch_choices.is_empty() {
None
} else {
Some(0)
});
}
fn on_create_new_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
self.mode = Mode::List;
}
KeyCode::Tab | KeyCode::Down => {
self.new_field = match self.new_field {
NewField::Branch => NewField::Base,
NewField::Base => NewField::Placement,
NewField::Placement => NewField::Name,
NewField::Name => NewField::Branch,
};
}
KeyCode::BackTab | KeyCode::Up => {
self.new_field = match self.new_field {
NewField::Branch => NewField::Name,
NewField::Base => NewField::Branch,
NewField::Placement => NewField::Base,
NewField::Name => NewField::Placement,
};
}
KeyCode::Char('p' | 'P') if self.new_field == NewField::Placement => {
self.new_placement = match self.new_placement {
Placement::InRepo => Placement::Global,
Placement::Global => Placement::InRepo,
};
}
KeyCode::Enter => {
if self.new_field != NewField::Name {
self.new_field = match self.new_field {
NewField::Branch => {
if self.new_name.is_empty() {
self.new_name = name::from_branch(&self.new_branch);
}
NewField::Base
}
NewField::Base => NewField::Placement,
NewField::Placement => NewField::Name,
NewField::Name => NewField::Name,
};
} else {
self.submit_create_new();
}
}
KeyCode::Char(c) => match self.new_field {
NewField::Branch => {
self.new_branch.push(c);
self.new_name = name::from_branch(&self.new_branch);
self.name_error = None;
}
NewField::Base => self.new_base.push(c),
NewField::Name => {
self.new_name.push(c);
self.name_error = None;
}
NewField::Placement => {
if c == ' ' || c == 'p' || c == 'P' {
self.new_placement = match self.new_placement {
Placement::InRepo => Placement::Global,
Placement::Global => Placement::InRepo,
};
}
}
},
KeyCode::Backspace => match self.new_field {
NewField::Branch => {
self.new_branch.pop();
self.new_name = name::from_branch(&self.new_branch);
}
NewField::Base => {
self.new_base.pop();
}
NewField::Name => {
self.new_name.pop();
self.name_error = None;
}
NewField::Placement => {}
},
_ => {}
}
}
fn submit_create_new(&mut self) {
if self.new_branch.trim().is_empty() {
self.name_error = Some("分支名不能为空".into());
self.new_field = NewField::Branch;
return;
}
if self.new_name.trim().is_empty() {
self.name_error = Some("Worktree Name 不能为空".into());
self.new_field = NewField::Name;
return;
}
let path = placement::worktree_path(
self.new_placement,
&self.ctx.main_path,
&self.ctx.global_root,
&self.ctx.repo_name,
self.new_name.trim(),
);
if path.exists() || self.entries.iter().any(|e| e.path == path) {
self.name_error = Some("名称碰撞:目录已存在,请改名".into());
self.new_field = NewField::Name;
return;
}
match git::add_new_branch(
&self.ctx.main_path,
&AddNewBranch {
path: path.clone(),
branch: self.new_branch.trim().to_string(),
base: self.new_base.trim().to_string(),
},
) {
Ok(()) => {
let _ = self.refresh();
self.mode = Mode::AfterCreate { path };
}
Err(err) => {
self.name_error = Some(err.to_string());
self.new_field = NewField::Name;
}
}
}
fn on_create_existing_key(&mut self, key: KeyEvent) {
match self.existing_field {
ExistingField::BranchList => match key.code {
KeyCode::Esc => self.mode = Mode::List,
KeyCode::Char('t' | 'T') => {
self.show_remote = !self.show_remote;
self.reload_branch_pool();
}
KeyCode::Up => {
let len = self.filtered_branches().len();
if let Some(i) = self.branch_state.selected() {
if let Some(n) = wrapping_index(i, -1, len) {
self.branch_state.select(Some(n));
}
}
}
KeyCode::Down => {
let len = self.filtered_branches().len();
if let Some(i) = self.branch_state.selected() {
if let Some(n) = wrapping_index(i, 1, len) {
self.branch_state.select(Some(n));
}
}
}
KeyCode::Enter => {
let Some((local_name, start)) = self.selected_branch_choice().map(|c| {
(c.local_name.clone(), c.start_point.clone())
}) else {
return;
};
self.existing_local = local_name.clone();
self.existing_start = start;
self.existing_name = name::from_branch(&local_name);
self.existing_field = ExistingField::Placement;
}
KeyCode::Char(c) => {
self.branch_filter.push(c);
self.reselect_filtered_branch();
}
KeyCode::Backspace => {
self.branch_filter.pop();
self.reselect_filtered_branch();
}
_ => {}
},
ExistingField::Placement => match key.code {
KeyCode::Esc => self.existing_field = ExistingField::BranchList,
KeyCode::Char(' ') | KeyCode::Char('p' | 'P') | KeyCode::Tab => {
self.existing_placement = match self.existing_placement {
Placement::InRepo => Placement::Global,
Placement::Global => Placement::InRepo,
};
}
KeyCode::Enter => self.existing_field = ExistingField::Name,
_ => {}
},
ExistingField::Name => match key.code {
KeyCode::Esc => self.existing_field = ExistingField::Placement,
KeyCode::Enter => self.submit_create_existing(),
KeyCode::Char(c) => {
self.existing_name.push(c);
self.name_error = None;
}
KeyCode::Backspace => {
self.existing_name.pop();
self.name_error = None;
}
_ => {}
},
}
}
fn filtered_branches(&self) -> Vec<&BranchChoice> {
let q = self.branch_filter.to_lowercase();
self.branch_choices
.iter()
.filter(|b| q.is_empty() || b.display.to_lowercase().contains(&q))
.collect()
}
fn selected_branch_choice(&self) -> Option<&BranchChoice> {
let view = self.filtered_branches();
let i = self.branch_state.selected()?;
view.get(i).copied()
}
fn reselect_filtered_branch(&mut self) {
let view = self.filtered_branches();
self.branch_state.select(if view.is_empty() { None } else { Some(0) });
}
fn submit_create_existing(&mut self) {
if self.existing_name.trim().is_empty() {
self.name_error = Some("Worktree Name 不能为空".into());
return;
}
let path = placement::worktree_path(
self.existing_placement,
&self.ctx.main_path,
&self.ctx.global_root,
&self.ctx.repo_name,
self.existing_name.trim(),
);
if path.exists() || self.entries.iter().any(|e| e.path == path) {
self.name_error = Some("名称碰撞:目录已存在,请改名".into());
return;
}
let branch = self.existing_local.clone();
match git::add_existing_branch(
&self.ctx.main_path,
&AddExistingBranch {
path: path.clone(),
branch,
start_point: self.existing_start.clone(),
},
) {
Ok(()) => {
let _ = self.refresh();
self.mode = Mode::AfterCreate { path };
}
Err(err) => self.name_error = Some(err.to_string()),
}
}
fn on_after_create_key(
&mut self,
terminal: &mut DefaultTerminal,
key: KeyEvent,
path: &Path,
) -> Result<()> {
match key.code {
KeyCode::Enter => {
open_subshell(terminal, path)?;
self.mode = Mode::List;
}
KeyCode::Char('o' | 'O') => {
open_cursor(terminal, path)?;
self.mode = Mode::List;
}
KeyCode::Esc | KeyCode::Char('q') => {
self.mode = Mode::List;
}
_ => {}
}
Ok(())
}
fn begin_remove(&mut self) {
let Some(entry) = self.selected_entry().cloned() else {
return;
};
if entry.is_main {
toast_warn(&mut self.toasts, "不能 Remove Main Worktree");
return;
}
self.mode = Mode::ConfirmRemove {
path: entry.path,
dirty: entry.dirty,
};
}
fn begin_purge(&mut self) {
let Some(entry) = self.selected_entry().cloned() else {
return;
};
if entry.is_main {
toast_warn(&mut self.toasts, "不能 Purge Main Worktree");
return;
}
let Some(branch) = entry.branch.clone() else {
toast_warn(
&mut self.toasts,
"detached HEAD 无法 Purge 分支,请改用 Remove",
);
return;
};
let base = git::default_base_branch(&self.ctx.main_path).unwrap_or_else(|_| "main".into());
let unmerged = !git::is_merged_into(&self.ctx.main_path, &branch, &base).unwrap_or(true);
self.mode = Mode::ConfirmPurge {
path: entry.path,
branch,
dirty: entry.dirty,
unmerged,
};
}
fn on_confirm_remove_key(&mut self, key: KeyEvent, path: &Path, dirty: bool) {
match key.code {
KeyCode::Esc | KeyCode::Char('n' | 'N' | 'q') => self.mode = Mode::List,
KeyCode::Char('y' | 'Y') | KeyCode::Enter => {
self.start_remove(path.to_path_buf(), dirty);
}
_ => {}
}
}
fn on_confirm_purge_key(
&mut self,
key: KeyEvent,
path: &Path,
branch: &str,
dirty: bool,
unmerged: bool,
) {
match key.code {
KeyCode::Esc | KeyCode::Char('n' | 'N' | 'q') => self.mode = Mode::List,
KeyCode::Char('y' | 'Y') | KeyCode::Enter => {
self.start_purge(path.to_path_buf(), branch.to_string(), dirty, unmerged);
}
_ => {}
}
}
fn start_remove(&mut self, path: PathBuf, force: bool) {
let ctx = self.ctx.clone();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = git::remove_worktree(&ctx.main_path, &path, force)
.and_then(|()| ctx.list_managed_fast())
.map_err(|e| e.to_string());
let _ = tx.send(WorkOutcome { entries: result });
});
self.work_rx = Some(rx);
self.spinner = 0;
self.mode = Mode::Working { label: "Remove" };
}
fn start_purge(&mut self, path: PathBuf, branch: String, force_wt: bool, force_branch: bool) {
let ctx = self.ctx.clone();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let result = (|| {
git::remove_worktree(&ctx.main_path, &path, force_wt)?;
git::delete_branch(&ctx.main_path, &branch, force_branch)?;
ctx.list_managed_fast()
})()
.map_err(|e| e.to_string());
let _ = tx.send(WorkOutcome { entries: result });
});
self.work_rx = Some(rx);
self.spinner = 0;
self.mode = Mode::Working { label: "Purge" };
}
}
fn open_subshell(terminal: &mut DefaultTerminal, path: &Path) -> Result<()> {
with_suspended_tui(terminal, || {
println!("→ {}", path.display());
let shell = env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".into());
let status = Command::new(&shell)
.current_dir(path)
.status()
.with_context(|| format!("启动 shell 失败:{shell}"))?;
if !status.success() {
eprintln!("shell 退出码:{status}");
}
Ok(())
})
}
fn open_cursor(terminal: &mut DefaultTerminal, path: &Path) -> Result<()> {
with_suspended_tui(terminal, || {
let status = Command::new("cursor")
.arg(path)
.status()
.context("启动 cursor 失败(是否已安装 CLI?)")?;
if !status.success() {
return Err(anyhow!("cursor 退出码:{status}"));
}
Ok(())
})
}
fn open_mr(terminal: &mut DefaultTerminal, cwd: &Path, iid: u64) -> Result<()> {
with_suspended_tui(terminal, || {
let status = Command::new("glab")
.args(["mr", "view", &iid.to_string(), "--web"])
.current_dir(cwd)
.status()
.context("打开 MR 失败(需要 glab)")?;
if !status.success() {
return Err(anyhow!("glab mr view 退出码:{status}"));
}
Ok(())
})
}
fn with_suspended_tui<F>(terminal: &mut DefaultTerminal, f: F) -> Result<()>
where
F: FnOnce() -> Result<()>,
{
let mut stdout = io::stdout();
execute!(stdout, LeaveAlternateScreen)?;
disable_raw_mode()?;
stdout.flush().ok();
let result = f();
enable_raw_mode()?;
execute!(stdout, EnterAlternateScreen)?;
terminal.clear()?;
result
}
fn render(frame: &mut Frame, app: &mut App) {
let area = frame.area();
let [main, footer] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(area);
match &app.mode {
Mode::List
| Mode::ConfirmRemove { .. }
| Mode::ConfirmPurge { .. }
| Mode::Working { .. } => {
let [table, card] =
Layout::vertical([Constraint::Min(0), Constraint::Length(12)]).areas(main);
render_table(frame, app, table);
render_card(frame, app, card);
}
Mode::Diff { .. } => render_diff(frame, app, main),
Mode::CreateKind => render_create_kind(frame, main),
Mode::CreateNew => render_create_new(frame, app, main),
Mode::CreateExisting => render_create_existing(frame, app, main),
Mode::AfterCreate { path } => render_after_create(frame, path, main),
}
if let Mode::ConfirmRemove { path, dirty } = &app.mode {
render_confirm_remove_popup(frame, path, *dirty, area);
} else if let Mode::ConfirmPurge {
path,
branch,
dirty,
unmerged,
} = &app.mode
{
render_confirm_purge_popup(frame, path, branch, *dirty, *unmerged, area);
} else if let Mode::Working { label } = &app.mode {
render_working_popup(frame, label, app.spinner, area);
}
let hints = match &app.mode {
Mode::List => LIST_HINTS,
Mode::Diff { .. } => &[
("↑/↓", "滚动"),
("Space", "下翻"),
("b", "上翻"),
("g/G", "顶/底"),
("Esc", "返回"),
][..],
Mode::CreateKind => &[("1", "新分支"), ("2", "已有分支"), ("Esc", "返回")][..],
Mode::CreateNew => &[
("Tab", "下一项"),
("p", "切换 Placement"),
("Enter", "下一步/创建"),
("Esc", "取消"),
][..],
Mode::CreateExisting => match app.existing_field {
ExistingField::BranchList => &[
("↑/↓", "选择"),
("输入", "过滤"),
("t", "远程"),
("Enter", "下一步"),
("Esc", "取消"),
][..],
ExistingField::Placement => &[("p/Space", "切换"), ("Enter", "下一步"), ("Esc", "返回")][..],
ExistingField::Name => &[("Enter", "创建"), ("Esc", "返回")][..],
},
Mode::AfterCreate { .. } => &[("Enter", "子 shell"), ("o", "cursor"), ("Esc", "回列表")][..],
Mode::ConfirmRemove { .. } | Mode::ConfirmPurge { .. } => {
&[("Enter/Y", "确认"), ("n", "取消"), ("Esc", "返回")][..]
}
Mode::Working { .. } => &[("…", "处理中")][..],
};
frame.render_widget(
Paragraph::new(key_hint_line(hints)).alignment(Alignment::Center),
footer,
);
render_toasts(&mut app.toasts, frame, area);
}
fn panel(title: &str) -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(theme::muted()))
.title(Span::styled(
format!(" {title} "),
Style::default()
.fg(theme::accent())
.add_modifier(Modifier::BOLD),
))
}
fn render_table(frame: &mut Frame, app: &mut App, area: Rect) {
let title = format!("Worktrees ({})", app.entries.len());
let block = panel(&title);
if app.entries.is_empty() {
frame.render_widget(
Paragraph::new("没有可管理的 Worktree")
.style(Style::default().fg(theme::muted()))
.alignment(Alignment::Center)
.block(block),
area,
);
return;
}
let inner = area.width.saturating_sub(2);
let fixed = 2u16 + 8 + 10 + 10 + 4;
let flexible = inner.saturating_sub(fixed) as usize;
let longest_branch = app
.entries
.iter()
.map(|e| e.branch.as_deref().unwrap_or("(detached)").chars().count())
.max()
.unwrap_or(6)
.max("BRANCH".len());
let branch_width = longest_branch.clamp(6, flexible.saturating_sub(8).max(6));
let path_width = flexible.saturating_sub(branch_width).max(4);
let spin = SPINNER[app.spinner % SPINNER.len()];
let loading = Style::default()
.fg(theme::warning())
.add_modifier(Modifier::BOLD);
let idle = Style::default()
.fg(theme::muted())
.add_modifier(Modifier::BOLD);
let status_header = if app.status_rx.is_some() {
Cell::from(format!("STATUS {spin}")).style(loading)
} else {
Cell::from("STATUS").style(idle)
};
let mr_header = if app.mr_rx.is_some() {
Cell::from(format!("MR {spin}")).style(loading)
} else {
Cell::from("MR").style(idle)
};
let rows = app.entries.iter().map(|e| {
let branch = e.branch.as_deref().unwrap_or("(detached)");
let path = crate::ports::lsof::truncate_text(&display_path(&e.path, &app.ctx), path_width);
let where_ = where_label(e);
Row::new(vec![
Cell::from(branch.to_string()),
Cell::from(path),
Cell::from(where_),
Cell::from(status_line(e)),
Cell::from(mr_line(e)),
])
});
let header = Row::new(vec![
Cell::from("BRANCH").style(idle),
Cell::from("PATH").style(idle),
Cell::from("WHERE").style(idle),
status_header,
mr_header,
]);
let table = Table::new(
rows,
[
Constraint::Length(branch_width as u16),
Constraint::Min(path_width as u16),
Constraint::Length(8),
Constraint::Length(10),
Constraint::Length(10),
],
)
.header(header)
.block(block)
.highlight_symbol(LIST_CURSOR)
.row_highlight_style(
Style::default()
.bg(theme::surface())
.add_modifier(Modifier::BOLD),
);
frame.render_stateful_widget(table, area, &mut app.state);
}
fn render_diff(frame: &mut Frame, app: &mut App, area: Rect) {
let Mode::Diff { sha, scroll } = &app.mode else {
return;
};
let sha = sha.clone();
let scroll = *scroll;
let entry = app.entries.iter().find(|e| e.head == sha);
let title = match entry.and_then(|e| e.commit.as_ref()) {
Some(c) => {
let msg = crate::ports::lsof::truncate_text(&c.subject, 48);
format!("Diff · {} · {}", c.short, msg)
}
None => {
let short = if sha.len() >= 7 { &sha[..7] } else { &sha };
format!("Diff · {short}")
}
};
let block = panel(&title).padding(Padding::horizontal(1));
let inner = block.inner(area);
frame.render_widget(block, area);
let width = inner.width as usize;
let height = inner.height as usize;
let loading = app.patch_rx.as_ref().is_some_and(|(s, _)| s == &sha);
let lines: Vec<Line<'static>> = match app.patch_cache.get(&sha) {
Some(all) if all.is_empty() => {
vec![Line::from(Span::styled(
"(无文件变更)".to_string(),
Style::default().fg(theme::muted()),
))]
}
Some(all) => {
let max_scroll = all.len().saturating_sub(height.max(1));
let scroll = scroll.min(max_scroll);
if let Mode::Diff { scroll: s, .. } = &mut app.mode {
*s = scroll;
}
all.iter()
.skip(scroll)
.take(height)
.cloned()
.map(|line| super::diff_hl::truncate_line(line, width))
.collect()
}
None if loading => {
let spin = SPINNER[app.spinner % SPINNER.len()];
vec![Line::from(Span::styled(
format!("{spin} 加载中…"),
Style::default().fg(theme::warning()),
))]
}
None => vec![Line::from(Span::styled(
"—".to_string(),
Style::default().fg(theme::muted()),
))],
};
frame.render_widget(Paragraph::new(lines), inner);
}
fn render_card(frame: &mut Frame, app: &App, area: Rect) {
let block = panel("详情").padding(Padding::horizontal(1));
let inner = block.inner(area);
frame.render_widget(block, area);
let label = |text: &str| Span::styled(text.to_string(), Style::default().fg(theme::muted()));
let value = |text: String| Span::styled(text, Style::default().fg(theme::text()));
let lines = match app.selected_entry() {
Some(entry) => {
let budget = (inner.width as usize).saturating_sub(6);
let branch = entry
.branch
.clone()
.unwrap_or_else(|| "(detached)".into());
let path = crate::ports::lsof::truncate_text(
&display_path(&entry.path, &app.ctx),
budget,
);
let commit_line = match &entry.commit {
Some(c) => {
let msg =
crate::ports::lsof::truncate_text(&c.subject, budget.saturating_sub(12));
Line::from(vec![
label("提交 "),
Span::styled(
c.short.clone(),
Style::default()
.fg(theme::accent())
.add_modifier(Modifier::BOLD),
),
label(" · "),
value(c.age_label()),
label(" · "),
value(msg),
])
}
None if app.commit_rx.is_some() => {
let spin = SPINNER[app.spinner % SPINNER.len()];
Line::from(vec![
label("提交 "),
Span::styled(
format!("{spin} 加载中…"),
Style::default().fg(theme::warning()),
),
])
}
None => Line::from(vec![label("提交 "), value("—".into())]),
};
let mut lines = vec![
Line::from(vec![
label("分支 "),
Span::styled(
branch,
Style::default()
.fg(theme::accent())
.add_modifier(Modifier::BOLD),
),
label(" "),
label("位置 "),
value(where_label(entry).into()),
]),
Line::from(vec![label("路径 "), value(path)]),
commit_line,
Line::from(""),
];
let sha = &entry.head;
let loading_this = app
.diff_rx
.as_ref()
.is_some_and(|(s, _)| s == sha);
match app.diff_cache.get(sha) {
Some(stat) if !stat.is_empty() => {
let room = (inner.height as usize).saturating_sub(lines.len());
lines.extend(stat_preview_lines(stat, budget, room));
}
Some(_) => lines.push(Line::from(vec![label("变更 "), value("—".into())])),
None if loading_this => {
let spin = SPINNER[app.spinner % SPINNER.len()];
lines.push(Line::from(vec![
label("变更 "),
Span::styled(
format!("{spin} 加载中…"),
Style::default().fg(theme::warning()),
),
]));
}
None => lines.push(Line::from(vec![label("变更 "), value("—".into())])),
}
lines
}
None => vec![Line::from(Span::styled(
"未选中",
Style::default().fg(theme::muted()),
))],
};
frame.render_widget(Paragraph::new(lines), inner);
}
fn stat_preview_lines(stat: &str, width: usize, max_lines: usize) -> Vec<Line<'static>> {
if max_lines == 0 {
return Vec::new();
}
let raw: Vec<&str> = stat.lines().filter(|l| !l.is_empty()).collect();
if raw.is_empty() {
return vec![Line::from(Span::styled(
"—".to_string(),
Style::default().fg(theme::muted()),
))];
}
let mut out = Vec::new();
let show = raw.len().min(max_lines);
let truncated = raw.len() > show;
let take = if truncated { show.saturating_sub(1) } else { show };
for line in raw.iter().take(take) {
let clipped = crate::ports::lsof::truncate_text(line, width);
out.push(style_stat_line(&clipped));
}
if truncated {
let more = raw.len() - take;
out.push(Line::from(Span::styled(
format!("… 另有 {more} 行"),
Style::default().fg(theme::muted()),
)));
}
out
}
fn style_stat_line(line: &str) -> Line<'static> {
if let Some((left, right)) = line.rsplit_once('|') {
let mut spans = vec![
Span::styled(left.to_string(), Style::default().fg(theme::text())),
Span::styled("|".to_string(), Style::default().fg(theme::muted())),
];
for ch in right.chars() {
let style = match ch {
'+' => Style::default().fg(theme::success()),
'-' => Style::default().fg(theme::danger()),
_ => Style::default().fg(theme::muted()),
};
spans.push(Span::styled(ch.to_string(), style));
}
return Line::from(spans);
}
if line.contains("changed") {
let mut spans = Vec::new();
let mut rest = line;
loop {
let next_ins = rest.find("insertion");
let next_del = rest.find("deletion");
let (kind, i) = match (next_ins, next_del) {
(Some(a), Some(b)) if a <= b => ("ins", a),
(Some(a), None) => ("ins", a),
(None, Some(b)) => ("del", b),
(Some(_), Some(b)) => ("del", b),
(None, None) => {
if !rest.is_empty() {
spans.push(Span::styled(
rest.to_string(),
Style::default().fg(theme::text()),
));
}
break;
}
};
let mut start = i;
while start > 0
&& rest.as_bytes()[start - 1].is_ascii_digit()
{
start -= 1;
}
if start > 0 && rest.as_bytes()[start - 1] == b' ' {
}
spans.push(Span::styled(
rest[..start].to_string(),
Style::default().fg(theme::text()),
));
let end = rest[i..]
.find([',', '.'])
.map(|j| i + j)
.unwrap_or(rest.len());
let color = if kind == "ins" {
theme::success()
} else {
theme::danger()
};
spans.push(Span::styled(
rest[start..end].to_string(),
Style::default().fg(color),
));
rest = &rest[end..];
}
return Line::from(spans);
}
Line::from(Span::styled(
line.to_string(),
Style::default().fg(theme::muted()),
))
}
fn where_label(e: &WorktreeEntry) -> &'static str {
if e.is_main {
return "Main";
}
match e.placement {
Some(Placement::InRepo) => "In-repo",
Some(Placement::Global) => "Global",
None => "—",
}
}
fn status_line(e: &WorktreeEntry) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
if e.dirty {
spans.push(Span::styled(
"*".to_string(),
Style::default().fg(theme::warning()),
));
}
match (e.ahead, e.behind) {
(Some(0), Some(0)) => {
spans.push(Span::styled(
"=".to_string(),
Style::default().fg(theme::success()),
));
}
(Some(a), Some(b)) => {
if a > 0 {
spans.push(Span::styled(
format!("↑{a}"),
Style::default().fg(theme::success()),
));
}
if b > 0 {
spans.push(Span::styled(
format!("↓{b}"),
Style::default().fg(theme::danger()),
));
}
}
_ => {}
}
if spans.is_empty() {
spans.push(Span::styled(
"·".to_string(),
Style::default().fg(theme::muted()),
));
}
Line::from(spans)
}
fn mr_line(e: &WorktreeEntry) -> Line<'static> {
let Some(mr) = &e.mr else {
return Line::from(Span::styled(
"—".to_string(),
Style::default().fg(theme::muted()),
));
};
let color = match mr.state {
crate::worktree::mr::MrState::Opened => theme::accent(),
crate::worktree::mr::MrState::Merged => theme::success(),
crate::worktree::mr::MrState::Closed => theme::muted(),
};
Line::from(Span::styled(mr.label(), Style::default().fg(color)))
}
fn display_path(path: &Path, ctx: &RepoContext) -> String {
if let Ok(rel) = path.strip_prefix(&ctx.main_path) {
return rel.display().to_string();
}
if let Some(home) = dirs::home_dir() {
if let Ok(rel) = path.strip_prefix(&home) {
return format!("~/{}", rel.display());
}
}
path.display().to_string()
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let [h] = Layout::horizontal([Constraint::Length(width.min(area.width))])
.flex(Flex::Center)
.areas(area);
let [v] = Layout::vertical([Constraint::Length(height.min(area.height))])
.flex(Flex::Center)
.areas(h);
v
}
fn render_create_kind(frame: &mut Frame, area: Rect) {
let lines = vec![
Line::from(""),
Line::from(Span::styled(
"1 新建分支 + Worktree",
Style::default().fg(theme::text()),
)),
Line::from(Span::styled(
"2 挂已有分支",
Style::default().fg(theme::text()),
)),
];
frame.render_widget(Paragraph::new(lines).block(panel("新建 Worktree")), area);
}
fn render_create_new(frame: &mut Frame, app: &App, area: Rect) {
let [b, base, place, name, err] = Layout::vertical([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(1),
])
.areas(area);
frame.render_widget(
input_paragraph(
&app.new_branch,
"feat/my-feature",
app.new_field == NewField::Branch,
" 分支名 ",
),
b,
);
frame.render_widget(
input_paragraph(
&app.new_base,
"main",
app.new_field == NewField::Base,
" Base ",
),
base,
);
let place_label = match app.new_placement {
Placement::InRepo => "In-repo (.worktrees/)",
Placement::Global => "Global (~/worktrees/...)",
};
frame.render_widget(
input_paragraph(
place_label,
"",
app.new_field == NewField::Placement,
" Placement (p 切换) ",
),
place,
);
frame.render_widget(
input_paragraph(
&app.new_name,
"worktree-name",
app.new_field == NewField::Name,
" Worktree Name ",
),
name,
);
if let Some(err_msg) = &app.name_error {
frame.render_widget(
Paragraph::new(err_msg.clone()).style(Style::default().fg(theme::warning())),
err,
);
}
}
fn render_create_existing(frame: &mut Frame, app: &mut App, area: Rect) {
match app.existing_field {
ExistingField::BranchList => {
let [filter_a, list_a] =
Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(area);
let title = if app.show_remote {
" 过滤(含远程,t 切换) "
} else {
" 过滤(本地,t 显示远程) "
};
frame.render_widget(
input_paragraph(&app.branch_filter, "过滤分支…", true, title),
filter_a,
);
let view = app.filtered_branches();
let items: Vec<ListItem> = view
.iter()
.map(|b| {
ListItem::new(Span::styled(
b.display.clone(),
Style::default().fg(theme::text()),
))
})
.collect();
let list = List::new(items)
.block(panel("选择分支"))
.highlight_symbol(LIST_CURSOR)
.highlight_style(list_cursor_style());
frame.render_stateful_widget(list, list_a, &mut app.branch_state);
}
ExistingField::Placement => {
let label = match app.existing_placement {
Placement::InRepo => "In-repo (.worktrees/)",
Placement::Global => "Global (~/worktrees/...)",
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("Placement: ", Style::default().fg(theme::muted())),
Span::styled(label, Style::default().fg(theme::text())),
]))
.block(panel("Placement")),
area,
);
}
ExistingField::Name => {
let [name_a, err_a] =
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(area);
frame.render_widget(
input_paragraph(&app.existing_name, "worktree-name", true, " Worktree Name "),
name_a,
);
if let Some(err_msg) = &app.name_error {
frame.render_widget(
Paragraph::new(err_msg.clone()).style(Style::default().fg(theme::warning())),
err_a,
);
}
}
}
}
fn render_after_create(frame: &mut Frame, path: &Path, area: Rect) {
let lines = vec![
Line::from(""),
Line::from(Span::styled(
"创建成功",
Style::default()
.fg(theme::success())
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
path.display().to_string(),
Style::default().fg(theme::muted()),
)),
Line::from(""),
Line::from("Enter 打开子 shell · o 用 cursor 打开 · Esc 回列表"),
];
frame.render_widget(Paragraph::new(lines).block(panel("下一步")), area);
}
fn render_working_popup(frame: &mut Frame, label: &str, spinner: usize, area: Rect) {
let width = 36.min(area.width.saturating_sub(4));
let popup = centered(area, width, 5);
frame.render_widget(Clear, popup);
let block = panel("请稍候").padding(Padding::uniform(1));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let spin = SPINNER[spinner % SPINNER.len()];
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!("{spin} "),
Style::default().fg(theme::warning()),
),
Span::styled(
format!("正在 {label}…"),
Style::default().fg(theme::text()),
),
]))
.alignment(Alignment::Center),
inner,
);
}
fn render_confirm_remove_popup(frame: &mut Frame, path: &Path, dirty: bool, area: Rect) {
let width = 56.min(area.width.saturating_sub(4));
let height = if dirty { 9 } else { 8 };
let popup = centered(area, width, height);
frame.render_widget(Clear, popup);
let block = panel("确认 Remove").padding(Padding::uniform(1));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let [title_a, path_a, warn_a, _gap, hints_a] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(if dirty { 1 } else { 0 }),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(inner);
frame.render_widget(
Paragraph::new(Span::styled(
"Remove Worktree(保留分支)",
Style::default()
.fg(theme::warning())
.add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Center),
title_a,
);
frame.render_widget(
Paragraph::new(path.display().to_string())
.style(Style::default().fg(theme::muted()))
.alignment(Alignment::Center),
path_a,
);
if dirty {
frame.render_widget(
Paragraph::new("目录不干净:确认将强制删除未提交改动")
.style(Style::default().fg(theme::warning()))
.alignment(Alignment::Center),
warn_a,
);
}
frame.render_widget(
Paragraph::new(key_hint_line(&[("Enter/Y", "确认"), ("n", "取消"), ("Esc", "返回")]))
.alignment(Alignment::Center),
hints_a,
);
}
fn render_confirm_purge_popup(
frame: &mut Frame,
path: &Path,
branch: &str,
dirty: bool,
unmerged: bool,
area: Rect,
) {
let warnings = u16::from(dirty) + u16::from(unmerged);
let width = 60.min(area.width.saturating_sub(4));
let height = 9 + warnings;
let popup = centered(area, width, height);
frame.render_widget(Clear, popup);
let block = panel("确认 Purge").padding(Padding::uniform(1));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let [title_a, path_a, branch_a, warn_a, _gap, hints_a] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(warnings),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(inner);
frame.render_widget(
Paragraph::new(Span::styled(
"Purge:Remove + 删除分支",
Style::default()
.fg(theme::warning())
.add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Center),
title_a,
);
frame.render_widget(
Paragraph::new(format!("path {}", path.display()))
.style(Style::default().fg(theme::muted()))
.alignment(Alignment::Center),
path_a,
);
frame.render_widget(
Paragraph::new(format!("branch {branch}"))
.style(Style::default().fg(theme::text()))
.alignment(Alignment::Center),
branch_a,
);
let mut warn_lines: Vec<Line> = Vec::new();
if dirty {
warn_lines.push(Line::from(Span::styled(
"目录不干净",
Style::default().fg(theme::warning()),
)));
}
if unmerged {
warn_lines.push(Line::from(Span::styled(
"分支可能尚未合并进主分支",
Style::default().fg(theme::warning()),
)));
}
if !warn_lines.is_empty() {
frame.render_widget(
Paragraph::new(warn_lines).alignment(Alignment::Center),
warn_a,
);
}
frame.render_widget(
Paragraph::new(key_hint_line(&[("Enter/Y", "确认"), ("n", "取消"), ("Esc", "返回")]))
.alignment(Alignment::Center),
hints_a,
);
}
pub fn load_app(cwd: &Path, config: Config) -> Result<App> {
let ctx = RepoContext::discover(cwd, config.global_root)?;
let entries = ctx.list_managed_fast()?;
let mut app = App::new(ctx, entries);
app.start_enrich();
Ok(app)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn sample_entries() -> Vec<WorktreeEntry> {
vec![
WorktreeEntry {
path: PathBuf::from("/proj/repo"),
head: "abcdef012345".into(),
branch: Some("main".into()),
is_main: true,
placement: None,
dirty: false,
ahead: Some(0),
behind: Some(0),
mr: None,
commit: Some(git::CommitInfo {
short: "abcdef0".into(),
subject: "init".into(),
committed_at: 1_700_000_000,
}),
},
WorktreeEntry {
path: PathBuf::from("/proj/repo/.worktrees/feat-x"),
head: "bbb111122223333".into(),
branch: Some("feat/x".into()),
is_main: false,
placement: Some(Placement::InRepo),
dirty: true,
ahead: Some(2),
behind: Some(1),
mr: Some(crate::worktree::mr::MrRef {
iid: 12,
source_branch: "feat/x".into(),
title: "feat".into(),
state: crate::worktree::mr::MrState::Opened,
}),
commit: Some(git::CommitInfo {
short: "bbb1111".into(),
subject: "Add oauth".into(),
committed_at: 1_700_100_000,
}),
},
WorktreeEntry {
path: PathBuf::from("/Users/me/worktrees/repo/other"),
head: "ccc".into(),
branch: Some("other".into()),
is_main: false,
placement: Some(Placement::Global),
dirty: false,
ahead: None,
behind: None,
mr: None,
commit: None,
},
]
}
#[test]
fn status_and_where_labels() {
let entries = sample_entries();
assert_eq!(where_label(&entries[0]), "Main");
assert_eq!(where_label(&entries[1]), "In-repo");
assert_eq!(where_label(&entries[2]), "Global");
let plain = |e: &WorktreeEntry| -> String {
status_line(e)
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
};
assert_eq!(plain(&entries[0]), "=");
assert_eq!(plain(&entries[1]), "*↑2↓1");
}
#[test]
fn preview_table_smoke() {
let ctx = RepoContext {
main_path: PathBuf::from("/proj/repo"),
repo_name: "repo".into(),
global_root: PathBuf::from("/Users/me/worktrees"),
};
let mut app = App::new(ctx, sample_entries());
app.diff_cache.insert(
"abcdef012345".into(),
" src/main.rs | 2 +-\n 1 file changed, 1 insertion(+), 1 deletion(-)".into(),
);
let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap();
terminal.draw(|frame| render(frame, &mut app)).unwrap();
let buffer = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
out.push_str(buffer[(x, y)].symbol());
}
out.push('\n');
}
assert!(out.contains("Worktrees"));
assert!(out.contains("BRANCH"));
assert!(out.contains("PATH"));
assert!(out.contains("init"));
assert!(out.contains("main.rs"));
assert!(!out.contains("HEAD"));
assert!(out.contains("MR"));
assert!(out.contains("Main"));
assert!(out.contains("In-repo"));
assert!(out.contains("!12"));
assert!(out.contains("feat/x"));
assert!(out.contains('❯'));
}
#[test]
fn styles_stat_plus_minus() {
let line = style_stat_line(" src/a.rs | 4 ++--");
let plain: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(plain, " src/a.rs | 4 ++--");
let plus = line.spans.iter().any(|s| {
s.content.as_ref() == "+" && s.style.fg == Some(theme::success())
});
let minus = line.spans.iter().any(|s| {
s.content.as_ref() == "-" && s.style.fg == Some(theme::danger())
});
assert!(plus && minus);
}
#[test]
fn preview_diff_view_smoke() {
let ctx = RepoContext {
main_path: PathBuf::from("/proj/repo"),
repo_name: "repo".into(),
global_root: PathBuf::from("/Users/me/worktrees"),
};
let mut app = App::new(ctx, sample_entries());
app.mode = Mode::Diff {
sha: "abcdef012345".into(),
scroll: 0,
};
let patch = "\
diff --git a/src/a.rs b/src/a.rs
--- a/src/a.rs
+++ b/src/a.rs
@@ -1 +1 @@
-old
+new
";
app.patch_cache.insert(
"abcdef012345".into(),
crate::worktree::diff_hl::highlight_unified_diff(patch),
);
let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap();
terminal.draw(|frame| render(frame, &mut app)).unwrap();
let buffer = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buffer.area.height {
for x in 0..buffer.area.width {
out.push_str(buffer[(x, y)].symbol());
}
out.push('\n');
}
assert!(out.contains("Diff"));
assert!(out.contains("abcdef0"));
assert!(out.contains("init"));
assert!(out.contains("+new") || out.contains("new"));
assert!(out.contains("-old") || out.contains("old"));
assert!(!out.contains("Worktrees"));
}
}