mod cli;
mod config;
mod diff;
mod git;
mod icons;
mod parser;
mod persistence;
mod render;
mod theme;
mod tree;
use crate::cli::{Cli, OperationMode};
use crate::config::{Config, DiffCommandType};
use crate::git::GitExecutor;
use crate::parser::{DiffFileKey, DiffParser, FileDiff};
use crate::persistence::PersistenceManager;
use crate::render::{render_diff_content, render_file_list, render_search_box, render_status_line};
use crate::theme::Theme;
use crate::tree::{FileTreeBuilder, FileTreeItem};
use anyhow::Result;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{
Frame, Terminal,
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
widgets::ListState,
};
use std::io::{self, Read};
use std::process::{Command, Stdio};
const DEFAULT_TERMINAL_HEIGHT: &str = "50";
const DEFAULT_TERMINAL_TYPE: &str = "xterm-256color";
#[derive(Debug, Clone)]
struct TemplateValues {
width: u16,
column_width: u16,
diff_area_width: u16,
diff_column_width: u16,
}
struct App {
should_quit: bool,
config: Config,
theme: Theme,
diff_output: String,
file_tree_items: Vec<FileTreeItem>,
original_file_diffs: Vec<FileDiff>, selected_index: usize,
vertical_scroll: u16,
horizontal_scroll: u16,
collapsed_directories: std::collections::HashSet<String>, checked_files: std::collections::HashSet<String>, persistence_manager: PersistenceManager, git_executor: Option<GitExecutor>, operation_mode: OperationMode, search_mode: bool, search_input_mode: bool, search_query: String, filtered_file_tree_items: Vec<FileTreeItem>, file_list_state: ListState, }
impl App {
fn new(
config: Config,
file_diffs: Vec<FileDiff>,
operation_mode: OperationMode,
) -> Result<Self> {
let diff_output = if file_diffs.is_empty() {
String::from("No diff content available")
} else {
file_diffs[0].content.clone()
};
let file_tree_items = FileTreeBuilder::build_file_tree(&file_diffs);
let theme = config.theme.clone();
let persistence_manager = PersistenceManager::new()?;
let git_executor = if operation_mode.requires_git_repo() {
Some(GitExecutor::new())
} else {
None
};
let diff_keys: Vec<DiffFileKey> = file_diffs
.iter()
.filter_map(|fd| fd.diff_key.clone())
.collect();
let checked_files = persistence_manager
.load_checked_files(&diff_keys)
.unwrap_or_else(|_| std::collections::HashSet::new());
Ok(Self {
should_quit: false,
config,
theme,
diff_output,
file_tree_items: file_tree_items.clone(),
original_file_diffs: file_diffs,
selected_index: 0,
vertical_scroll: 0,
horizontal_scroll: 0,
collapsed_directories: std::collections::HashSet::new(),
checked_files,
persistence_manager,
git_executor,
operation_mode,
search_mode: false,
search_input_mode: false,
search_query: String::new(),
filtered_file_tree_items: file_tree_items,
file_list_state: {
let mut state = ListState::default();
state.select(Some(0));
state
},
})
}
fn select_next(&mut self) {
let current_items = self.get_current_file_tree_items();
if !current_items.is_empty() && self.selected_index < current_items.len() - 1 {
self.selected_index += 1;
self.file_list_state.select(Some(self.selected_index));
self.update_diff_content();
}
}
fn select_previous(&mut self) {
if self.selected_index > 0 {
self.selected_index -= 1;
self.file_list_state.select(Some(self.selected_index));
self.update_diff_content();
}
}
fn update_diff_content(&mut self) {
let current_items = self.get_current_file_tree_items();
if let Some(tree_item) = current_items.get(self.selected_index) {
if let Some(file_diff) = &tree_item.file_diff {
if let Some(ref git_executor) = self.git_executor {
match git_executor.get_file_diff(&self.operation_mode, &tree_item.full_path) {
Ok(fresh_diff) => {
self.diff_output = fresh_diff;
}
Err(_) => {
self.diff_output = file_diff.content.clone();
}
}
} else {
self.diff_output = file_diff.content.clone();
}
if let Ok((terminal_width, _)) = crossterm::terminal::size() {
self.apply_external_diff_tool_with_width(Some(terminal_width));
} else {
self.apply_external_diff_tool();
}
self.vertical_scroll = 0;
self.horizontal_scroll = 0;
} else {
self.diff_output = format!("Directory: {}", tree_item.full_path);
self.vertical_scroll = 0;
self.horizontal_scroll = 0;
}
}
}
fn apply_external_diff_tool(&mut self) {
self.apply_external_diff_tool_with_width(None);
}
fn apply_external_diff_tool_with_width(&mut self, width: Option<u16>) {
match self.config.get_diff_command_type() {
DiffCommandType::GitDefault => {
}
DiffCommandType::Pager(_) | DiffCommandType::External(_) => {
match self.execute_external_diff_tool_with_width(&self.diff_output, width) {
Ok(processed_output) => {
self.diff_output = processed_output;
}
Err(e) => {
eprintln!("Warning: Failed to process with diff tool: {e}");
}
}
}
}
}
#[allow(dead_code)]
fn execute_external_diff_tool(&self, diff_content: &str) -> Result<String> {
self.execute_external_diff_tool_with_width(diff_content, None)
}
fn execute_external_diff_tool_with_width(
&self,
diff_content: &str,
width: Option<u16>,
) -> Result<String> {
let diff_command_type = self.config.get_diff_command_type();
match diff_command_type {
DiffCommandType::GitDefault => {
Ok(diff_content.to_string()) }
DiffCommandType::Pager(ref cmd) => {
self.execute_pager_with_stdin_legacy(cmd, diff_content, width)
}
DiffCommandType::External(ref cmd) => {
if let Some(w) = width {
self.execute_external_diff_via_git(cmd, w.saturating_sub(2), w)
} else {
if let Ok((terminal_width, _)) = crossterm::terminal::size() {
self.execute_external_diff_via_git(
cmd,
terminal_width.saturating_sub(2),
terminal_width,
)
} else {
self.execute_external_diff_via_git(cmd, 78, 80)
}
}
}
}
}
fn execute_command_with_stdin(
&self,
command_str: &str,
input: &str,
env_vars: &[(&str, String)],
) -> Result<String> {
use std::io::Write;
let parts: Vec<&str> = command_str.split_whitespace().collect();
if parts.is_empty() {
return Err(anyhow::anyhow!("Empty command"));
}
let command_name = parts[0];
let mut cmd = Command::new(command_name);
if parts.len() > 1 {
cmd.args(&parts[1..]);
}
for (key, value) in env_vars {
cmd.env(key, value);
}
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| anyhow::anyhow!("Failed to spawn {}: {}", command_name, e))?;
if let Some(stdin) = child.stdin.take() {
let mut writer = std::io::BufWriter::new(stdin);
writer
.write_all(input.as_bytes())
.map_err(|e| anyhow::anyhow!("Failed to write to command: {}", e))?;
writer
.flush()
.map_err(|e| anyhow::anyhow!("Failed to flush command input: {}", e))?;
}
let output = child
.wait_with_output()
.map_err(|e| anyhow::anyhow!("Failed to read from command: {}", e))?;
if output.status.success() {
String::from_utf8(output.stdout)
.map_err(|e| anyhow::anyhow!("Command output is not valid UTF-8: {}", e))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow::anyhow!("Command failed: {}", stderr))
}
}
fn execute_pager_with_stdin_legacy(
&self,
command_str: &str,
diff_content: &str,
width: Option<u16>,
) -> Result<String> {
let final_command_str = if let Some(w) = width {
let content_width = w.saturating_sub(2);
self.resolve_template_variables(command_str, content_width)
} else {
command_str.to_string()
};
let mut env_vars = vec![
("TERM", DEFAULT_TERMINAL_TYPE.to_string()),
("LINES", DEFAULT_TERMINAL_HEIGHT.to_string()),
];
if let Some(w) = width {
env_vars.push(("COLUMNS", w.to_string()));
}
self.execute_command_with_stdin(&final_command_str, diff_content, &env_vars)
}
fn execute_external_diff_tool_with_area_width(
&self,
diff_content: &str,
area_width: u16,
terminal_width: u16,
) -> Result<String> {
let diff_command_type = self.config.get_diff_command_type();
match diff_command_type {
DiffCommandType::GitDefault => {
Ok(diff_content.to_string()) }
DiffCommandType::Pager(ref cmd) => {
self.execute_pager_with_stdin(cmd, diff_content, area_width, terminal_width)
}
DiffCommandType::External(ref cmd) => {
self.execute_external_diff_via_git(cmd, area_width, terminal_width)
}
}
}
fn execute_pager_with_stdin(
&self,
command_str: &str,
diff_content: &str,
area_width: u16,
terminal_width: u16,
) -> Result<String> {
let final_command_str = self.resolve_template_variables_with_area_width(
command_str,
area_width,
terminal_width,
);
let env_vars = vec![
("TERM", DEFAULT_TERMINAL_TYPE.to_string()),
("COLUMNS", terminal_width.to_string()),
("LINES", DEFAULT_TERMINAL_HEIGHT.to_string()),
];
self.execute_command_with_stdin(&final_command_str, diff_content, &env_vars)
}
fn setup_git_external_diff_env(
&self,
cmd: &mut Command,
_area_width: u16,
terminal_width: u16,
) {
cmd.env("TERM", DEFAULT_TERMINAL_TYPE);
cmd.env("COLUMNS", terminal_width.to_string());
cmd.env("LINES", DEFAULT_TERMINAL_HEIGHT);
}
fn execute_external_diff_via_git(
&self,
command_str: &str,
area_width: u16,
terminal_width: u16,
) -> Result<String> {
use std::process::{Command, Stdio};
let final_command_str = self.resolve_template_variables_with_area_width(
command_str,
area_width,
terminal_width,
);
let current_items = self.get_current_file_tree_items();
let file_path = if let Some(tree_item) = current_items.get(self.selected_index) {
if !tree_item.is_directory {
Some(&tree_item.full_path)
} else {
None
}
} else {
None
};
if file_path.is_none() {
return Err(anyhow::anyhow!("No file selected for external diff"));
}
let mut cmd = Command::new("git");
let external_diff_config = format!("diff.external={final_command_str}");
cmd.args([
"-c",
&external_diff_config,
"-c",
"diff.noprefix=false",
"diff",
"--ext-diff",
"--color=always",
]);
match &self.operation_mode {
OperationMode::GitWorkingDirectory => {
}
OperationMode::GitCached => {
cmd.arg("--cached");
}
OperationMode::Compare { target1, target2 } => {
cmd.arg(target1);
cmd.arg(target2);
}
OperationMode::GitDiff { target } => {
cmd.arg(target);
}
_ => {
return Err(anyhow::anyhow!(
"External diff not supported for this operation mode"
));
}
}
cmd.arg("--");
cmd.arg(file_path.unwrap());
self.setup_git_external_diff_env(&mut cmd, area_width, terminal_width);
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let output = cmd
.output()
.map_err(|e| anyhow::anyhow!("Failed to execute git with external diff: {}", e))?;
if output.status.success() {
String::from_utf8(output.stdout)
.map_err(|e| anyhow::anyhow!("Git external diff output is not valid UTF-8: {}", e))
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow::anyhow!("Git external diff failed: {}", stderr))
}
}
fn scroll_up(&mut self, amount: u16) {
self.vertical_scroll = self.vertical_scroll.saturating_sub(amount);
}
fn scroll_down(&mut self, amount: u16) {
self.vertical_scroll = self.vertical_scroll.saturating_add(amount);
}
fn scroll_left(&mut self, amount: u16) {
self.horizontal_scroll = self.horizontal_scroll.saturating_sub(amount);
}
fn scroll_right(&mut self, amount: u16) {
self.horizontal_scroll = self.horizontal_scroll.saturating_add(amount);
}
fn jump_to_top(&mut self) {
self.selected_index = 0;
self.file_list_state.select(Some(self.selected_index));
self.update_diff_content();
}
fn jump_to_bottom(&mut self) {
let current_items = self.get_current_file_tree_items();
if !current_items.is_empty() {
self.selected_index = current_items.len() - 1;
self.file_list_state.select(Some(self.selected_index));
self.update_diff_content();
}
}
fn toggle_file_checked(&mut self) {
let current_items = if self.search_mode {
&self.filtered_file_tree_items
} else {
&self.file_tree_items
};
if let Some(tree_item) = current_items.get(self.selected_index) {
if !tree_item.is_directory {
let file_path = tree_item.full_path.clone();
let was_checked = self.checked_files.contains(&file_path);
if was_checked {
self.checked_files.remove(&file_path);
} else {
self.checked_files.insert(file_path.clone());
}
if let Some(file_diff) = tree_item.file_diff.as_ref() {
if let Some(diff_key) = &file_diff.diff_key {
let is_now_checked = !was_checked;
if let Err(e) = self
.persistence_manager
.save_check_state(diff_key, is_now_checked)
{
eprintln!("Warning: Failed to save check state: {e}");
}
}
}
}
}
}
fn get_current_file_tree_items(&self) -> &Vec<FileTreeItem> {
if self.search_mode {
&self.filtered_file_tree_items
} else {
&self.file_tree_items
}
}
fn enter_search_mode(&mut self) {
if self.search_mode {
self.search_query.clear();
self.search_input_mode = true;
self.selected_index = 0;
self.file_list_state.select(Some(self.selected_index));
self.update_search_filter();
} else {
self.search_mode = true;
self.search_input_mode = true;
self.search_query.clear();
self.selected_index = 0;
self.file_list_state.select(Some(self.selected_index));
self.update_search_filter();
}
}
fn exit_search_mode(&mut self) {
self.search_mode = false;
self.search_input_mode = false;
self.search_query.clear();
self.selected_index = 0;
self.file_list_state.select(Some(self.selected_index));
self.update_diff_content();
}
fn confirm_search(&mut self) {
self.search_input_mode = false;
}
fn add_search_char(&mut self, c: char) {
if self.search_input_mode {
self.search_query.push(c);
self.update_search_filter();
}
}
fn remove_search_char(&mut self) {
if self.search_input_mode && !self.search_query.is_empty() {
self.search_query.pop();
self.update_search_filter();
}
}
fn update_search_filter(&mut self) {
if self.search_query.is_empty() {
self.filtered_file_tree_items = self.file_tree_items.clone();
} else {
self.filtered_file_tree_items = self
.file_tree_items
.iter()
.filter(|item| self.fuzzy_match(&item.full_path, &self.search_query))
.cloned()
.collect();
}
self.selected_index = 0;
self.file_list_state.select(Some(self.selected_index));
self.update_diff_content();
}
fn fuzzy_match(&self, text: &str, pattern: &str) -> bool {
text.to_lowercase().contains(&pattern.to_lowercase())
}
fn toggle_directory(&mut self) {
if let Some(tree_item) = self.file_tree_items.get(self.selected_index) {
if tree_item.is_directory {
let path = tree_item.full_path.clone();
if self.collapsed_directories.contains(&path) {
self.collapsed_directories.remove(&path);
} else {
self.collapsed_directories.insert(path);
}
self.rebuild_file_tree();
}
}
}
fn rebuild_file_tree(&mut self) {
self.file_tree_items = FileTreeBuilder::build_file_tree_with_collapsed(
&self.original_file_diffs,
&self.collapsed_directories,
);
if self.selected_index >= self.file_tree_items.len() {
self.selected_index = self.file_tree_items.len().saturating_sub(1);
self.file_list_state.select(Some(self.selected_index));
}
}
fn refresh_diff_with_width(&mut self, width: u16) {
match self.config.get_diff_command_type() {
DiffCommandType::GitDefault => {
}
DiffCommandType::Pager(_) | DiffCommandType::External(_) => {
let current_items = self.get_current_file_tree_items();
if let Some(tree_item) = current_items.get(self.selected_index) {
if let Some(file_diff) = &tree_item.file_diff {
let base_diff = if let Some(ref git_executor) = self.git_executor {
match git_executor
.get_file_diff(&self.operation_mode, &tree_item.full_path)
{
Ok(fresh_diff) => fresh_diff,
Err(_) => file_diff.content.clone(),
}
} else {
file_diff.content.clone()
};
match self.execute_external_diff_tool_with_width(&base_diff, Some(width)) {
Ok(processed_output) => {
self.diff_output = processed_output;
}
Err(e) => {
eprintln!("Warning: Failed to refresh diff with width: {e}");
}
}
}
}
}
}
}
fn refresh_diff_with_area_width(&mut self, area_width: u16, terminal_width: u16) {
match self.config.get_diff_command_type() {
DiffCommandType::GitDefault => {
}
DiffCommandType::Pager(_) | DiffCommandType::External(_) => {
let current_items = self.get_current_file_tree_items();
if let Some(tree_item) = current_items.get(self.selected_index) {
if let Some(file_diff) = &tree_item.file_diff {
let base_diff = if let Some(ref git_executor) = self.git_executor {
match git_executor
.get_file_diff(&self.operation_mode, &tree_item.full_path)
{
Ok(fresh_diff) => fresh_diff,
Err(_) => file_diff.content.clone(),
}
} else {
file_diff.content.clone()
};
match self.execute_external_diff_tool_with_area_width(
&base_diff,
area_width,
terminal_width,
) {
Ok(processed_output) => {
self.diff_output = processed_output;
}
Err(e) => {
eprintln!("Warning: Failed to refresh diff with area width: {e}");
}
}
}
}
}
}
}
fn clamp_scroll(&mut self, viewport_height: u16, viewport_width: u16) {
let content_height = self.diff_output.lines().count() as u16;
let max_line_width = self
.diff_output
.lines()
.map(|line| self.calculate_display_width(line))
.max()
.unwrap_or(0) as u16;
let available_height = viewport_height.saturating_sub(2);
let available_width = viewport_width.saturating_sub(2);
let max_vertical_scroll = content_height.saturating_sub(available_height);
let max_horizontal_scroll = max_line_width.saturating_sub(available_width);
self.vertical_scroll = self.vertical_scroll.min(max_vertical_scroll);
self.horizontal_scroll = self.horizontal_scroll.min(max_horizontal_scroll);
}
fn calculate_display_width(&self, line: &str) -> usize {
if self.contains_ansi_codes(line) {
let stripped = strip_ansi_escapes::strip(line);
match String::from_utf8(stripped) {
Ok(clean_line) => self.calculate_text_width(&clean_line),
Err(_) => line.len(), }
} else {
self.calculate_text_width(line)
}
}
fn calculate_text_width(&self, text: &str) -> usize {
text.chars()
.map(|ch| {
if ch == '\t' {
4 } else if ch.is_control() {
0 } else {
1 }
})
.sum()
}
pub fn contains_ansi_codes(&self, text: &str) -> bool {
text.contains('\x1b') || text.contains("\u{001b}")
}
fn calculate_template_values(&self, area_width: u16, terminal_width: u16) -> TemplateValues {
let diff_area_width = area_width.saturating_sub(2); let column_width = (terminal_width / 2).saturating_sub(6);
let diff_column_width = (diff_area_width / 2).saturating_sub(6);
TemplateValues {
width: terminal_width,
column_width,
diff_area_width,
diff_column_width,
}
}
fn apply_template_substitutions(&self, command_str: &str, values: &TemplateValues) -> String {
let mut result = command_str.to_string();
let substitutions = [
("{{width}}", values.width.to_string()),
("{{.width}}", values.width.to_string()),
("{{columnWidth}}", values.column_width.to_string()),
("{{.columnWidth}}", values.column_width.to_string()),
("{{diffAreaWidth}}", values.diff_area_width.to_string()),
("{{.diffAreaWidth}}", values.diff_area_width.to_string()),
("{{diffColumnWidth}}", values.diff_column_width.to_string()),
("{{.diffColumnWidth}}", values.diff_column_width.to_string()),
];
for (template, value) in &substitutions {
result = result.replace(template, value);
}
result
}
fn resolve_template_variables(&self, command_str: &str, width: u16) -> String {
let area_width = (width * 80 / 100).saturating_sub(2); let values = self.calculate_template_values(area_width, width);
self.apply_template_substitutions(command_str, &values)
}
fn resolve_template_variables_with_area_width(
&self,
command_str: &str,
area_width: u16,
terminal_width: u16,
) -> String {
let values = self.calculate_template_values(area_width, terminal_width);
self.apply_template_substitutions(command_str, &values)
}
}
fn main() -> Result<()> {
let cli = Cli::parse_args();
let operation_mode = cli.get_operation_mode();
match &operation_mode {
OperationMode::Completions { shell } => {
generate_completions(*shell);
return Ok(());
}
OperationMode::Invalid { reason } => {
eprintln!("Error: {reason}");
std::process::exit(1);
}
_ => {}
}
let config = if let Some(config_path) = cli.config {
Config::load_from_path(&config_path)?
} else {
Config::load()?
};
if operation_mode.requires_git_repo() && !GitExecutor::is_git_repo() {
return Err(anyhow::anyhow!("Not in a git repository"));
}
let is_stdin_terminal = io::IsTerminal::is_terminal(&io::stdin());
if cli.verbose {
eprintln!("Debug: stdin is terminal: {is_stdin_terminal}");
eprintln!("Debug: operation mode: {operation_mode:?}");
}
let file_diffs = if !is_stdin_terminal {
if cli.verbose {
eprintln!("Debug: Using stdin mode");
}
read_input_completely().unwrap_or_else(|_| {
if cli.verbose {
eprintln!("Debug: No stdin input, falling back to git executor");
}
get_diffs_from_git(&operation_mode).unwrap_or_default()
})
} else {
if cli.verbose {
eprintln!("Debug: Using git executor mode");
}
get_diffs_from_git(&operation_mode)?
};
if file_diffs.is_empty() {
println!("No differences found.");
return Ok(());
}
enable_raw_mode()
.map_err(|e| anyhow::anyhow!("Failed to initialize terminal raw mode: {}", e))?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let app = App::new(config, file_diffs, operation_mode)?;
let res = run_app(&mut terminal, app);
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
eprintln!("{err:?}")
}
Ok(())
}
fn generate_completions(shell: clap_complete::Shell) {
use clap::CommandFactory;
use clap_complete::{Generator, generate};
use std::io;
fn print_completions<G: Generator>(generator: G, cmd: &mut clap::Command) {
generate(
generator,
cmd,
cmd.get_name().to_string(),
&mut io::stdout(),
);
}
let mut cmd = Cli::command();
print_completions(shell, &mut cmd);
}
fn get_diffs_from_git(mode: &OperationMode) -> Result<Vec<FileDiff>> {
let git_executor = GitExecutor::new();
let diff_output = git_executor.get_diff(mode)?;
if diff_output.is_empty() {
return Ok(vec![]);
}
Ok(DiffParser::parse(&diff_output))
}
fn read_input_completely() -> Result<Vec<FileDiff>> {
let mut buffer = String::new();
io::stdin()
.read_to_string(&mut buffer)
.map_err(|e| anyhow::anyhow!("Failed to read from stdin: {}", e))?;
if buffer.trim().is_empty() {
anyhow::bail!("No input received from stdin");
}
Ok(DiffParser::parse(&buffer))
}
fn run_app<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>, mut app: App) -> Result<()> {
loop {
terminal.draw(|f| ui(f, &mut app))?;
if event::poll(std::time::Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
match key.code {
KeyCode::Char('q') => {
if app.search_mode {
app.exit_search_mode();
} else {
app.should_quit = true;
}
}
KeyCode::Esc => {
if app.search_mode {
app.exit_search_mode();
} else {
app.should_quit = true;
}
}
KeyCode::Char('/') if !app.search_input_mode => {
app.enter_search_mode();
}
KeyCode::Enter if app.search_input_mode => {
app.confirm_search();
}
KeyCode::Backspace => {
if app.search_input_mode {
app.remove_search_char();
}
}
KeyCode::Down | KeyCode::Char('j') if !app.search_input_mode => {
app.select_next()
}
KeyCode::Up | KeyCode::Char('k') if !app.search_input_mode => {
app.select_previous()
}
KeyCode::Char(c) if app.search_input_mode => {
app.add_search_char(c);
}
KeyCode::Enter => {
if let Some(tree_item) = app.file_tree_items.get(app.selected_index) {
if tree_item.is_directory {
app.toggle_directory();
} else {
app.update_diff_content();
}
}
}
KeyCode::Char('g') if !app.search_input_mode => app.jump_to_top(),
KeyCode::Char('G') if !app.search_input_mode => app.jump_to_bottom(),
KeyCode::Char('e') | KeyCode::Char('J') if !app.search_input_mode => {
app.scroll_down(1)
}
KeyCode::Char('y') | KeyCode::Char('K') if !app.search_input_mode => {
app.scroll_up(1)
}
KeyCode::Char('d') | KeyCode::PageDown if !app.search_input_mode => {
app.scroll_down(10)
}
KeyCode::Char('u') | KeyCode::PageUp if !app.search_input_mode => {
app.scroll_up(10)
}
KeyCode::Char('f') if !app.search_input_mode => app.scroll_down(20),
KeyCode::Char('b') if !app.search_input_mode => app.scroll_up(20),
KeyCode::Char('h') | KeyCode::Left if !app.search_input_mode => {
app.scroll_left(5)
}
KeyCode::Char('l') | KeyCode::Right if !app.search_input_mode => {
app.scroll_right(5)
}
KeyCode::Char('H') if !app.search_input_mode => app.scroll_left(20),
KeyCode::Char('L') if !app.search_input_mode => app.scroll_right(20),
KeyCode::Char(' ') if !app.search_input_mode => {
app.update_diff_content();
}
KeyCode::Tab => app.toggle_file_checked(),
_ => {}
}
}
}
if app.should_quit {
return Ok(());
}
}
}
fn ui(f: &mut Frame, app: &mut App) {
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(20), Constraint::Percentage(80)])
.split(f.area());
if app.search_mode {
let left_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(main_chunks[0]);
render_search_box(f, left_chunks[0], app);
render_file_list(f, left_chunks[1], app);
} else {
render_file_list(f, main_chunks[0], app);
}
let right_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(main_chunks[1]);
render_status_line(f, right_chunks[0], app);
render_diff_content(f, right_chunks[1], app);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::FileDiff;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
#[test]
fn test_app_new() {
let config = Config::default();
let app = App::new(config, vec![], OperationMode::GitWorkingDirectory).unwrap();
assert!(!app.should_quit);
assert_eq!(app.selected_index, 0);
assert_eq!(app.vertical_scroll, 0);
assert_eq!(app.horizontal_scroll, 0);
}
#[test]
fn test_ui_layout() {
let backend = TestBackend::new(100, 50);
let mut terminal = Terminal::new(backend).unwrap();
let config = Config::default();
let mut app = App::new(config, vec![], OperationMode::GitWorkingDirectory).unwrap();
terminal.draw(|f| ui(f, &mut app)).unwrap();
let buffer = terminal.backend().buffer();
assert!(buffer.area().width == 100);
assert!(buffer.area().height == 50);
}
#[test]
fn test_render_file_list() {
let backend = TestBackend::new(40, 20);
let mut terminal = Terminal::new(backend).unwrap();
let config = Config::default();
let file_diffs = vec![
FileDiff {
filename: "test1.rs".to_string(),
old_path: None,
new_path: None,
content: "test content".to_string(),
added_lines: 1,
removed_lines: 0,
diff_key: None,
},
FileDiff {
filename: "test2.rs".to_string(),
old_path: None,
new_path: None,
content: "test content 2".to_string(),
added_lines: 0,
removed_lines: 1,
diff_key: None,
},
];
let mut app = App::new(config, file_diffs, OperationMode::GitWorkingDirectory).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 40, 20);
render_file_list(f, area, &mut app);
})
.unwrap();
let buffer = terminal.backend().buffer();
let content = buffer_to_string(buffer);
assert!(content.contains("Files & Directories"));
assert!(content.contains("test1.rs"));
assert!(content.contains("test2.rs"));
}
#[test]
fn test_render_diff_content() {
let backend = TestBackend::new(60, 20);
let mut terminal = Terminal::new(backend).unwrap();
let config = Config::default();
let mut app = App::new(config, vec![], OperationMode::GitWorkingDirectory).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 60, 20);
render_diff_content(f, area, &mut app);
})
.unwrap();
let buffer = terminal.backend().buffer();
let content = buffer_to_string(buffer);
assert!(content.contains("Diff Content"));
assert!(content.contains("No diff content available"));
}
fn buffer_to_string(buffer: &Buffer) -> String {
let mut result = String::new();
for y in 0..buffer.area().height {
for x in 0..buffer.area().width {
let cell = buffer.cell((x, y)).unwrap();
result.push_str(cell.symbol());
}
result.push('\n');
}
result
}
}