use super::diff_widget::{self, ADDED_COLOR, DiffBufferWidget, DiffFileBuffer, REMOVED_COLOR};
use super::highlight::{FileHighlights, HighlightLanguage, parse_file_highlights};
use super::text_rendering::MAX_HIGHLIGHT_SIZE;
use crate::diff_parse::{
CommitInfo, DiffFileStatus, DiffLineKind, git_has_commits, git_is_installed, is_git_repo,
make_untracked_diff_file, parse_git_diff, run_git_command, run_git_commit, run_git_diff,
run_git_show, run_git_status,
};
use iced::widget::Id;
use iced::widget::{Space, button, column, container, row, scrollable, text, text_input};
use iced::{Alignment, Color, Element, Length, Task, keyboard};
use iced_fonts::lucide;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::time::Duration;
use super::context_menu::ContextMenu;
use super::theme;
use super::widgets::{self, FileTree};
const MAX_DIFF_LINES: usize = 5000;
const MAX_HUNKS: usize = 100;
const MAX_UNTRACKED_SIZE: u64 = 1024 * 1024;
const FILE_HEADER_COLOR: Color = theme::STATUS_WARNING;
const RENAME_COLOR: Color = theme::ACCENT_LIGHT;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiscardTarget {
File,
Directory,
}
#[derive(Debug, Clone)]
pub enum DiffMessage {
WorkspaceSelected(String, Option<String>),
DiffLoaded(u64, Result<Vec<DiffFile>, String>),
Tick,
Escape,
ToggleDir(String),
SelectFile(String),
CommitMessageChanged(String),
CommitClicked,
CommitResult(Result<CommitInfo, String>),
Toast(super::ToastMessage),
TreeFocusToggled,
TreeNavUp,
TreeNavDown,
TreeNavEnter,
TreeNavLeft,
TreeNavRight,
NavigateToCommit(String, String),
BackToWorkingTree,
DiscardPath(String, DiscardTarget),
DiscardResult(Result<(), String>),
}
#[derive(Debug, Clone)]
pub struct DiffFile {
pub dfile: crate::diff_parse::DiffFile,
pub old_highlights: Option<FileHighlights>,
pub new_highlights: Option<FileHighlights>,
pub add_count: usize,
pub remove_count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CachedIcon {
ArrowRight,
FilePlus,
FileMinus,
FileText,
}
impl CachedIcon {
fn to_text<'a>(self) -> iced::widget::Text<'a, iced::Theme, iced::Renderer> {
match self {
CachedIcon::ArrowRight => lucide::arrow_right(),
CachedIcon::FilePlus => lucide::file_plus(),
CachedIcon::FileMinus => lucide::file_minus(),
CachedIcon::FileText => lucide::file_text(),
}
}
}
pub struct DiffState {
error: Option<String>,
selected_workspace_name: Option<String>,
personal_workspace_path: Option<String>,
generation: u64,
diff_files: Vec<DiffFile>,
diff_empty: bool,
diff_loading: bool,
diff_has_loaded: bool,
status_message: Option<String>,
file_tree: FileTree,
selected_file: Option<String>,
file_buffers: Vec<DiffFileBuffer>,
buffers_generation: u64,
commit_message: String,
committing: bool,
current_commit_ref: Option<String>,
tree_auto_expand_pending: bool,
}
impl DiffState {
pub fn new() -> Self {
Self {
error: None,
selected_workspace_name: None,
personal_workspace_path: None,
generation: 0,
diff_files: Vec::new(),
diff_empty: true,
diff_loading: false,
diff_has_loaded: false,
status_message: None,
file_tree: FileTree::new(Id::new("diff_tree_panel")),
selected_file: None,
file_buffers: Vec::new(),
buffers_generation: 0,
commit_message: String::new(),
committing: false,
current_commit_ref: None,
tree_auto_expand_pending: false,
}
}
pub fn subscription(&self) -> iced::Subscription<DiffMessage> {
let mut subs: Vec<iced::Subscription<DiffMessage>> = Vec::new();
if self.selected_workspace_name.is_some() || self.personal_workspace_path.is_some() {
subs.push(iced::time::every(Duration::from_secs(5)).map(|_| DiffMessage::Tick));
}
subs.push(keyboard::listen().filter_map(|event| {
use keyboard::{Event, Key};
let Event::KeyPressed {
key,
modifiers,
physical_key,
..
} = event
else {
return None;
};
let is_cmd = modifiers.command();
#[cfg(not(target_os = "macos"))]
let altgr_active = modifiers.alt() && modifiers.control();
#[cfg(target_os = "macos")]
let altgr_active = false;
if !altgr_active && is_cmd && key.to_latin(physical_key) == Some('b') {
return Some(DiffMessage::TreeFocusToggled);
}
match &key {
Key::Named(named) => match named {
keyboard::key::Named::ArrowUp => Some(DiffMessage::TreeNavUp),
keyboard::key::Named::ArrowDown => Some(DiffMessage::TreeNavDown),
keyboard::key::Named::ArrowLeft => Some(DiffMessage::TreeNavLeft),
keyboard::key::Named::ArrowRight => Some(DiffMessage::TreeNavRight),
keyboard::key::Named::Enter => Some(DiffMessage::TreeNavEnter),
_ => None,
},
_ => None,
}
}));
iced::Subscription::batch(subs)
}
pub fn update(&mut self, msg: DiffMessage) -> Task<DiffMessage> {
match msg {
DiffMessage::WorkspaceSelected(name, path_override) => {
if name.is_empty() && path_override.is_none() {
self.selected_workspace_name = None;
self.personal_workspace_path = None;
self.diff_files = Vec::new();
self.diff_empty = true;
self.file_tree.nodes = Vec::new();
self.file_tree.expanded_dirs.clear();
self.selected_file = None;
self.error = None;
self.status_message = None;
self.commit_message.clear();
self.committing = false;
return Task::none();
}
self.file_tree.expanded_dirs.clear();
self.tree_auto_expand_pending = true;
self.commit_message.clear();
self.committing = false;
self.selected_workspace_name = Some(name.clone());
self.personal_workspace_path.clone_from(&path_override);
self.current_commit_ref = None;
self.generation = self.generation.wrapping_add(1);
let generation_num = self.generation;
let workspace_name = name.clone();
let ws_path = path_override.clone();
self.diff_loading = true;
self.selected_file = None;
Task::perform(load_diff(workspace_name, ws_path, None), move |r| {
DiffMessage::DiffLoaded(generation_num, r)
})
}
DiffMessage::DiffLoaded(generation_num, result) => {
if generation_num != self.generation {
return Task::none();
}
self.diff_loading = false;
self.diff_has_loaded = true;
self.error = None;
self.status_message = None;
match result {
Ok(files) => {
self.diff_empty = files.is_empty();
if self.diff_empty {
self.status_message = if self.current_commit_ref.is_some() {
Some("No files changed in this commit.".to_string())
} else {
Some("Working tree clean.".to_string())
};
}
self.file_tree.nodes = build_tree(&files);
if self.tree_auto_expand_pending {
collect_dir_paths(
&self.file_tree.nodes,
&mut self.file_tree.expanded_dirs,
);
self.tree_auto_expand_pending = false;
}
if let Some(ref sel) = self.selected_file {
if !files.iter().any(|f| f.dfile.path == *sel) {
self.selected_file = None;
}
}
self.diff_files = files;
self.rebuild_file_buffers();
self.file_tree.rebuild_visible();
}
Err(e) => {
self.error = Some(e);
self.diff_files = Vec::new();
self.diff_empty = true;
self.file_tree.nodes = Vec::new();
self.selected_file = None;
self.file_tree.rebuild_visible();
}
}
Task::none()
}
DiffMessage::Tick => {
if self.current_commit_ref.is_some() {
return Task::none();
}
if let Some(ref ws_name) = self.selected_workspace_name.clone() {
if !self.diff_loading && !self.committing {
self.diff_loading = true;
self.generation = self.generation.wrapping_add(1);
let generation_num = self.generation;
let workspace_name = ws_name.clone();
let ws_path = self.personal_workspace_path.clone();
Task::perform(load_diff(workspace_name, ws_path, None), move |r| {
DiffMessage::DiffLoaded(generation_num, r)
})
} else {
Task::none()
}
} else {
Task::none()
}
}
DiffMessage::NavigateToCommit(ws_name, hash) => {
self.selected_workspace_name = Some(ws_name.clone());
self.personal_workspace_path = None;
self.error = None;
self.file_tree.expanded_dirs.clear();
self.tree_auto_expand_pending = true;
self.commit_message.clear();
self.committing = false;
self.selected_file = None;
self.file_buffers.clear();
self.current_commit_ref = Some(hash.clone());
self.diff_loading = true;
self.diff_has_loaded = false;
self.generation = self.generation.wrapping_add(1);
let generation_num = self.generation;
Task::perform(load_diff(ws_name, None, Some(hash)), move |result| {
DiffMessage::DiffLoaded(generation_num, result)
})
}
DiffMessage::BackToWorkingTree => {
let ws_name = match &self.selected_workspace_name {
Some(n) => n.clone(),
None => return Task::none(),
};
self.diff_loading = true;
self.current_commit_ref = None;
self.file_tree.expanded_dirs.clear();
self.tree_auto_expand_pending = true;
self.generation = self.generation.wrapping_add(1);
let generation_num = self.generation;
let ws_path = self.personal_workspace_path.clone();
Task::perform(load_diff(ws_name, ws_path, None), move |result| {
DiffMessage::DiffLoaded(generation_num, result)
})
}
DiffMessage::ToggleDir(path) => {
self.file_tree.tree_focused = true;
let path_clone = path.clone();
if self.file_tree.expanded_dirs.contains(&path_clone) {
self.file_tree.expanded_dirs.remove(&path_clone);
} else {
self.file_tree.expanded_dirs.insert(path_clone);
}
self.file_tree.nodes = build_tree(&self.diff_files);
self.file_tree.rebuild_visible();
self.file_tree.focus_path(&path);
Task::none()
}
DiffMessage::SelectFile(path) => {
self.file_tree.focus_path(&path);
if self.selected_file.as_deref() == Some(&path) {
self.selected_file = None;
} else {
self.selected_file = Some(path);
}
self.rebuild_file_buffers();
Task::none()
}
DiffMessage::CommitMessageChanged(msg) => {
self.commit_message = msg;
self.file_tree.tree_focused = false;
Task::none()
}
DiffMessage::CommitClicked => {
let trimmed = self.commit_message.trim().to_string();
if trimmed.is_empty() || self.committing {
return Task::none();
}
self.committing = true;
let ws_name = self
.selected_workspace_name
.as_deref()
.unwrap_or_default()
.to_string();
let ws_path_for_commit = self.personal_workspace_path.clone();
Task::perform(
async move {
let ws_path_buf =
resolve_workspace_path(&ws_name, ws_path_for_commit).await?;
run_git_commit(&ws_path_buf, &trimmed).await
},
DiffMessage::CommitResult,
)
}
DiffMessage::CommitResult(result) => {
self.committing = false;
match result {
Ok(info) => {
self.commit_message.clear();
self.error = None;
let toast_msg = match (info.lines_added, info.lines_removed) {
(0, 0) => {
format!("Committed {}", &info.hash.get(..7).unwrap_or(&info.hash))
}
(a, 0) => format!(
"Committed {} (+{a})",
&info.hash.get(..7).unwrap_or(&info.hash)
),
(0, r) => format!(
"Committed {} (-{r})",
&info.hash.get(..7).unwrap_or(&info.hash)
),
(a, r) => format!(
"Committed {} (+{a}/-{r})",
&info.hash.get(..7).unwrap_or(&info.hash)
),
};
if let Some(ref ws_name) = self.selected_workspace_name.clone() {
self.diff_loading = true;
self.generation = self.generation.wrapping_add(1);
let generation_num = self.generation;
let workspace_name = ws_name.clone();
let ws_path = self.personal_workspace_path.clone();
return Task::batch([
Task::done(DiffMessage::Toast(super::ToastMessage::SuccessMsg(
toast_msg,
))),
Task::perform(load_diff(workspace_name, ws_path, None), move |r| {
DiffMessage::DiffLoaded(generation_num, r)
}),
]);
}
Task::done(DiffMessage::Toast(super::ToastMessage::SuccessMsg(
toast_msg,
)))
}
Err(e) => {
self.error = Some(e);
Task::none()
}
}
}
DiffMessage::Toast(_) => Task::none(),
DiffMessage::DiscardPath(path, target) => {
if self.current_commit_ref.is_some() {
return Task::none();
}
let Some(ws_name) = self.selected_workspace_name.clone() else {
return Task::none();
};
let ws_path = self.personal_workspace_path.clone();
self.diff_loading = true;
Task::perform(
async move {
let ws_path_buf = resolve_workspace_path(&ws_name, ws_path).await?;
let _ =
run_git_command(&ws_path_buf, &["checkout", "HEAD", "--", &path]).await;
let _ =
run_git_command(&ws_path_buf, &["reset", "HEAD", "--", &path]).await;
let clean_args: &[&str] = match target {
DiscardTarget::Directory => &["clean", "-fd", "--", &path],
DiscardTarget::File => &["clean", "-f", "--", &path],
};
let _ = run_git_command(&ws_path_buf, clean_args).await;
match run_git_command(&ws_path_buf, &["status", "--porcelain", "--", &path])
.await
{
Ok(status) if status.trim().is_empty() => Ok(()),
Ok(status) => {
Err(format!("Changes remain after discard:\n{}", status.trim()))
}
Err(e) => Err(format!("Discard ran but verification failed: {e}")),
}
},
DiffMessage::DiscardResult,
)
}
DiffMessage::DiscardResult(result) => {
match result {
Ok(()) => {
if let Some(ref ws_name) = self.selected_workspace_name.clone() {
self.diff_loading = true;
self.generation = self.generation.wrapping_add(1);
let generation_num = self.generation;
let workspace_name = ws_name.clone();
let ws_path = self.personal_workspace_path.clone();
return Task::batch([
Task::done(DiffMessage::Toast(super::ToastMessage::SuccessMsg(
"Changes discarded.".to_string(),
))),
Task::perform(load_diff(workspace_name, ws_path, None), move |r| {
DiffMessage::DiffLoaded(generation_num, r)
}),
]);
}
self.diff_loading = false;
Task::done(DiffMessage::Toast(super::ToastMessage::SuccessMsg(
"Changes discarded.".to_string(),
)))
}
Err(e) => {
self.diff_loading = false;
Task::done(DiffMessage::Toast(super::ToastMessage::Error(e)))
}
}
}
DiffMessage::Escape => {
if self.file_tree.tree_focused {
self.file_tree.tree_focused = false;
}
Task::none()
}
DiffMessage::TreeFocusToggled => {
self.file_tree.tree_focused = !self.file_tree.tree_focused;
if self.file_tree.tree_focused && self.file_tree.visible_tree_nodes.is_empty() {
self.file_tree.rebuild_visible();
}
if !self.file_tree.tree_focused || self.file_tree.visible_tree_nodes.is_empty() {
self.file_tree.tree_focused = false;
}
Task::none()
}
DiffMessage::TreeNavUp => {
if self.file_tree.tree_focused && self.file_tree.tree_focus_index > 0 {
self.file_tree.tree_focus_index -= 1;
return widgets::scroll_to_tree_focus(&self.file_tree);
}
Task::none()
}
DiffMessage::TreeNavDown => {
if self.file_tree.tree_focused
&& self.file_tree.tree_focus_index + 1 < self.file_tree.visible_tree_nodes.len()
{
self.file_tree.tree_focus_index += 1;
return widgets::scroll_to_tree_focus(&self.file_tree);
}
Task::none()
}
DiffMessage::TreeNavEnter => {
if !self.file_tree.tree_focused || self.file_tree.visible_tree_nodes.is_empty() {
return Task::none();
}
let idx = self
.file_tree
.tree_focus_index
.min(self.file_tree.visible_tree_nodes.len() - 1);
let (ref path, is_dir) = self.file_tree.visible_tree_nodes[idx];
if is_dir {
if self.file_tree.expanded_dirs.contains(path) {
self.file_tree.expanded_dirs.remove(path);
self.file_tree.nodes = build_tree(&self.diff_files);
self.file_tree.rebuild_visible();
return Task::none();
}
self.file_tree.expanded_dirs.insert(path.clone());
self.file_tree.nodes = build_tree(&self.diff_files);
self.file_tree.rebuild_visible();
if idx + 1 < self.file_tree.visible_tree_nodes.len() {
self.file_tree.tree_focus_index = idx + 1;
return widgets::scroll_to_tree_focus(&self.file_tree);
}
Task::none()
} else {
Task::done(DiffMessage::SelectFile(path.clone()))
}
}
DiffMessage::TreeNavLeft => {
if !self.file_tree.tree_focused || self.file_tree.visible_tree_nodes.is_empty() {
return Task::none();
}
let idx = self
.file_tree
.tree_focus_index
.min(self.file_tree.visible_tree_nodes.len() - 1);
let path = self.file_tree.visible_tree_nodes[idx].0.clone();
let is_dir = self.file_tree.visible_tree_nodes[idx].1;
if is_dir && self.file_tree.expanded_dirs.contains(&path) {
self.file_tree.expanded_dirs.remove(&path);
self.file_tree.nodes = build_tree(&self.diff_files);
self.file_tree.rebuild_visible();
if self.file_tree.focus_path(&path).is_some() {
return widgets::scroll_to_tree_focus(&self.file_tree);
}
return Task::none();
}
let parent = Path::new(&path)
.parent()
.map(|p| p.to_string_lossy().to_string());
match parent {
Some(ref p) if !p.is_empty() && self.file_tree.focus_path(p).is_some() => {
return widgets::scroll_to_tree_focus(&self.file_tree);
}
_ => {} }
Task::none()
}
DiffMessage::TreeNavRight => {
if !self.file_tree.tree_focused || self.file_tree.visible_tree_nodes.is_empty() {
return Task::none();
}
let idx = self
.file_tree
.tree_focus_index
.min(self.file_tree.visible_tree_nodes.len() - 1);
let path = self.file_tree.visible_tree_nodes[idx].0.clone();
let is_dir = self.file_tree.visible_tree_nodes[idx].1;
if !is_dir {
return Task::none();
}
if !self.file_tree.expanded_dirs.contains(&path) {
self.file_tree.expanded_dirs.insert(path.clone());
self.file_tree.nodes = build_tree(&self.diff_files);
self.file_tree.rebuild_visible();
if let Some(dir_idx) = self.file_tree.focus_path(&path) {
if dir_idx + 1 < self.file_tree.visible_tree_nodes.len() {
self.file_tree.tree_focus_index = dir_idx + 1;
return widgets::scroll_to_tree_focus(&self.file_tree);
}
}
return Task::none();
}
if idx + 1 < self.file_tree.visible_tree_nodes.len() {
self.file_tree.tree_focus_index = idx + 1;
return widgets::scroll_to_tree_focus(&self.file_tree);
}
Task::none()
}
}
}
pub fn view(&self) -> Element<'_, DiffMessage> {
let has_changes = !self.diff_files.is_empty();
let show_commit_bar =
self.current_commit_ref.is_none() && has_changes && self.error.is_none();
let header = if let Some(ref hash) = self.current_commit_ref {
let short_hash: String = hash.chars().take(7).collect();
let back_btn = button("Back to working tree")
.on_press(DiffMessage::BackToWorkingTree)
.style(theme::button_secondary);
container(
row![
text(format!("Viewing commit {short_hash}"))
.size(12)
.color(theme::TEXT_SECONDARY),
Space::new().width(Length::Fill),
back_btn,
]
.align_y(Alignment::Center),
)
.width(Length::Fill)
.padding([8, 12])
} else if show_commit_bar {
let commit_input = text_input("Commit message…", &self.commit_message)
.on_input(DiffMessage::CommitMessageChanged)
.on_submit(DiffMessage::CommitClicked)
.width(Length::Fill)
.size(12);
let msg_empty = self.commit_message.trim().is_empty();
let commit_disabled = msg_empty || self.committing;
let commit_btn = button(if self.committing {
text("Committing…").size(12)
} else {
text("Commit").size(12)
})
.on_press_maybe(if commit_disabled {
None
} else {
Some(DiffMessage::CommitClicked)
})
.style(if commit_disabled {
theme::button_secondary
} else {
theme::button_primary
});
container(
row![commit_input, Space::new().width(8), commit_btn].align_y(Alignment::Center),
)
.width(Length::Fill)
.padding([8, 12])
} else {
container(Space::new().width(Length::Fill).height(0))
};
let status: Element<'_, DiffMessage> = if let Some(ref err) = self.error {
widgets::error_banner(err)
} else if let Some(ref s) = self.status_message {
container(text(s).size(13).color(theme::TEXT_SECONDARY))
.padding([8, 12])
.width(Length::Fill)
.height(Length::Fill)
.style(|_t: &iced::Theme| container::Style {
background: Some(iced::Background::Color(theme::BG_BASE)),
..Default::default()
})
.into()
} else if self.diff_loading && !self.diff_has_loaded {
container(text("Loading diff…").size(12).color(theme::TEXT_MUTED))
.padding([8, 12])
.width(Length::Fill)
.height(Length::Fill)
.style(|_t: &iced::Theme| container::Style {
background: Some(iced::Background::Color(theme::BG_BASE)),
..Default::default()
})
.into()
} else if self.selected_workspace_name.is_none() {
container(
text("Select a workspace to view its diff.")
.size(13)
.color(theme::TEXT_MUTED),
)
.padding([8, 12])
.width(Length::Fill)
.height(Length::Fill)
.style(|_t: &iced::Theme| container::Style {
background: Some(iced::Background::Color(theme::BG_BASE)),
..Default::default()
})
.into()
} else if self.diff_empty {
container(
column![
lucide::check::<iced::Theme, iced::Renderer>()
.size(32)
.color(theme::STATUS_SUCCESS),
Space::new().height(8),
text("Working tree clean.")
.size(14)
.color(theme::TEXT_SECONDARY),
]
.align_x(Alignment::Center)
.spacing(4),
)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(|_t: &iced::Theme| container::Style {
background: Some(iced::Background::Color(theme::BG_BASE)),
..Default::default()
})
.into()
} else {
self.build_split_view()
};
column![header, status]
.spacing(0)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn build_split_view(&self) -> Element<'_, DiffMessage> {
let tree_panel = self.build_tree_panel();
let diff_panel = self.build_diff_content();
row![tree_panel, diff_panel]
.spacing(0)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn build_tree_panel(&self) -> Element<'_, DiffMessage> {
let nodes = &self.file_tree.nodes;
let count = nodes.len();
let elements: Vec<Element<'_, DiffMessage>> = nodes
.iter()
.enumerate()
.map(|(i, n)| self.render_tree_node(n, 0, 0, i == count - 1))
.collect();
widgets::build_tree_panel(&self.file_tree, elements)
}
fn render_tree_node<'a>(
&'a self,
node: &'a widgets::TreeNode,
depth: usize,
ancestor_mask: u64,
is_last: bool,
) -> Element<'a, DiffMessage> {
if node.is_dir {
self.render_dir_node(node, depth, ancestor_mask, is_last)
} else {
self.render_file_node(node, depth, ancestor_mask, is_last)
}
}
fn render_dir_node<'a>(
&'a self,
node: &'a widgets::TreeNode,
depth: usize,
ancestor_mask: u64,
is_last: bool,
) -> Element<'a, DiffMessage> {
let is_expanded = self.file_tree.expanded_dirs.contains(&node.full_path);
let icon: iced::widget::Text<'static, iced::Theme, iced::Renderer> = if is_expanded {
lucide::folder_open()
} else {
lucide::folder()
};
let icon_color = if is_expanded {
theme::ACCENT_LIGHT
} else {
theme::TEXT_MUTED
};
let guide = widgets::tree_guide_prefix(ancestor_mask, depth, is_last);
let guide_text: Element<'_, DiffMessage> = text(guide)
.size(widgets::TREE_FONT_SIZE)
.color(theme::TEXT_MUTED)
.into();
let header_row = row![
guide_text,
icon.size(widgets::TREE_ICON_SIZE).color(icon_color),
Space::new().width(4),
text(&node.name)
.size(widgets::TREE_FONT_SIZE)
.color(theme::TEXT_SECONDARY),
Space::new().width(Length::Fill),
]
.align_y(Alignment::Center)
.padding([0, 8]);
let full_path = node.full_path.clone();
let is_focused = widgets::tree_node_focused(&self.file_tree, &node.full_path);
let header_btn = widgets::tree_node_button(
header_row,
is_focused,
Some(DiffMessage::ToggleDir(full_path.clone())),
);
let header_element: Element<'_, DiffMessage> = if self.current_commit_ref.is_some() {
header_btn
} else {
ContextMenu::new(
header_btn,
vec![(
"Discard changes".into(),
DiffMessage::DiscardPath(full_path, DiscardTarget::Directory),
)],
)
.into()
};
let mut col = column![header_element].spacing(0);
if is_expanded {
for elem in widgets::render_tree_children(
&node.children,
depth,
ancestor_mask,
is_last,
|child, d, mask, last| self.render_tree_node(child, d, mask, last),
) {
col = col.push(elem);
}
}
col.into()
}
fn render_file_node<'a>(
&'a self,
node: &'a widgets::TreeNode,
depth: usize,
ancestor_mask: u64,
is_last: bool,
) -> Element<'a, DiffMessage> {
let file = self
.diff_files
.iter()
.find(|f| f.dfile.path == node.full_path);
let is_selected = self.selected_file.as_deref() == Some(&node.full_path);
let guide = widgets::tree_guide_prefix(ancestor_mask, depth, is_last);
let guide_text: Element<'_, DiffMessage> = text(guide)
.size(widgets::TREE_FONT_SIZE)
.color(theme::TEXT_MUTED)
.into();
let (icon, icon_color) = if let Some(f) = file {
if f.dfile.status == DiffFileStatus::Renamed {
(lucide::arrow_right(), RENAME_COLOR)
} else if matches!(
f.dfile.status,
DiffFileStatus::Added | DiffFileStatus::Untracked
) {
(lucide::file_plus(), FILE_HEADER_COLOR)
} else if f.dfile.status == DiffFileStatus::Deleted {
(lucide::file_minus(), FILE_HEADER_COLOR)
} else if f.dfile.is_binary {
(lucide::file(), theme::TEXT_MUTED)
} else {
(lucide::file_text(), FILE_HEADER_COLOR)
}
} else {
(lucide::file_text(), FILE_HEADER_COLOR)
};
let counts: Element<'_, DiffMessage> = if let Some(f) = file {
if f.dfile.is_binary {
text("binary").size(10).color(theme::TEXT_MUTED).into()
} else if f.add_count > 0 || f.remove_count > 0 {
let mut parts: Vec<Element<'_, DiffMessage>> = Vec::new();
if f.add_count > 0 {
parts.push(
text(format!("+{}", f.add_count))
.size(10)
.color(ADDED_COLOR)
.into(),
);
}
if f.add_count > 0 && f.remove_count > 0 {
parts.push(text(" ").size(10).into());
}
if f.remove_count > 0 {
parts.push(
text(format!("-{}", f.remove_count))
.size(10)
.color(REMOVED_COLOR)
.into(),
);
}
row(parts).spacing(0).into()
} else {
text("").size(10).into()
}
} else {
text("").size(10).into()
};
let name_color = if is_selected {
theme::TEXT_PRIMARY
} else {
theme::TEXT_SECONDARY
};
let name_weight = if is_selected {
iced::font::Weight::Bold
} else {
iced::font::Weight::Normal
};
let btn_row = row![
guide_text,
icon.size(widgets::TREE_FONT_SIZE).color(icon_color),
Space::new().width(4),
text(&node.name)
.size(widgets::TREE_FONT_SIZE)
.color(name_color)
.font(iced::Font {
weight: name_weight,
..theme::FONT_REGULAR
}),
Space::new().width(Length::Fill),
counts,
Space::new().width(6),
]
.align_y(Alignment::Center)
.padding([0, 8]);
let full_path = node.full_path.clone();
let is_focused = widgets::tree_node_focused(&self.file_tree, &node.full_path);
let file_btn = widgets::tree_node_button(
btn_row,
is_selected || is_focused,
Some(DiffMessage::SelectFile(full_path.clone())),
);
if self.current_commit_ref.is_some() {
file_btn
} else {
ContextMenu::new(
file_btn,
vec![(
"Discard changes".into(),
DiffMessage::DiscardPath(full_path, DiscardTarget::File),
)],
)
.into()
}
}
fn build_diff_content(&self) -> Element<'_, DiffMessage> {
if self.diff_files.is_empty() {
return container(
scrollable(column![])
.width(Length::Fill)
.height(Length::Fill)
.direction(scrollable::Direction::Vertical(theme::thin_scrollbar()))
.style(theme::scrollbar_style),
)
.width(Length::Fill)
.height(Length::Fill)
.style(|_t: &iced::Theme| container::Style {
background: Some(iced::Background::Color(theme::BG_BASE)),
..Default::default()
})
.into();
}
let mut rows: Vec<Element<'_, DiffMessage>> = Vec::new();
let mut total_hunks: usize = 0;
let mut total_lines: usize = 0;
let mut truncated = false;
let mut buffer_idx = 0usize;
for file in &self.diff_files {
if let Some(ref sel) = self.selected_file {
if file.dfile.path != *sel {
continue;
}
}
if truncated {
break;
}
total_hunks += file.dfile.hunks.len();
if total_hunks > MAX_HUNKS || total_lines > MAX_DIFF_LINES {
truncated = true;
break;
}
let (header_label, header_icon) = if file.dfile.status == DiffFileStatus::Renamed {
(
format!(
"Rename: {} \u{2192} {}",
file.dfile.old_path.as_deref().unwrap_or("?"),
file.dfile.path
),
CachedIcon::ArrowRight,
)
} else if file.dfile.status == DiffFileStatus::Added {
(
format!("New file: {}", file.dfile.path),
CachedIcon::FilePlus,
)
} else if file.dfile.status == DiffFileStatus::Deleted {
(
format!("Deleted: {}", file.dfile.path),
CachedIcon::FileMinus,
)
} else if file.dfile.status == DiffFileStatus::Untracked {
(
format!("Untracked: {}", file.dfile.path),
CachedIcon::FilePlus,
)
} else {
(file.dfile.path.clone(), CachedIcon::FileText)
};
rows.push(
container(
row![
header_icon.to_text().size(12).color(FILE_HEADER_COLOR),
Space::new().width(6),
text(header_label).size(12).color(FILE_HEADER_COLOR),
]
.align_y(Alignment::Center),
)
.width(Length::Fill)
.padding([6, 12])
.style(move |_t: &iced::Theme| iced::widget::container::Style {
background: Some(iced::Background::Color(iced::Color::from_rgba(
1.0, 0.667, 0.0, 0.06,
))),
..Default::default()
})
.into(),
);
if file.dfile.is_binary {
rows.push(
container(
text(format!("Binary file: {}", file.dfile.path))
.size(13)
.color(theme::TEXT_MUTED),
)
.padding([2, 12])
.into(),
);
continue;
}
if let Some(sz) = file.dfile.too_large_size {
rows.push(
container(
text(format!("File too large: {}, {sz} bytes", file.dfile.path))
.size(13)
.color(theme::TEXT_MUTED),
)
.padding([2, 12])
.into(),
);
continue;
}
if let Some(buf) = self.file_buffers.get(buffer_idx) {
buffer_idx += 1;
for hunk in &file.dfile.hunks {
total_lines += hunk.lines.len();
}
rows.push(iced::Element::new(DiffBufferWidget::new(buf)));
}
}
if truncated {
rows.push(
container(
text("\u{26a0} Diff truncated (max 5000 lines / 100 hunks)")
.size(12)
.color(theme::STATUS_WARNING),
)
.padding([8, 12])
.into(),
);
}
container(
scrollable(column(rows).spacing(0).width(Length::Fill))
.width(Length::Fill)
.height(Length::Fill)
.direction(scrollable::Direction::Vertical(theme::thin_scrollbar()))
.style(theme::scrollbar_style),
)
.width(Length::Fill)
.height(Length::Fill)
.style(|_t: &iced::Theme| container::Style {
background: Some(iced::Background::Color(theme::BG_BASE)),
..Default::default()
})
.into()
}
fn rebuild_file_buffers(&mut self) {
if self.diff_files.is_empty() {
self.file_buffers.clear();
return;
}
self.file_buffers = diff_widget::build_file_buffers(
&self.diff_files,
self.selected_file.as_deref(),
Some((MAX_HUNKS, MAX_DIFF_LINES)),
);
self.buffers_generation = self.generation;
}
}
async fn resolve_workspace_path(
ws_name: &str,
ws_path_override: Option<String>,
) -> Result<std::path::PathBuf, String> {
if let Some(p) = ws_path_override {
Ok(std::path::PathBuf::from(p))
} else {
let store = crate::workspace::store();
let ws = store
.get_by_name(ws_name)
.await
.map_err(|e| format!("Failed to look up workspace: {e}"))?
.ok_or_else(|| "Workspace not found.".to_string())?;
Ok(ws.as_path().to_path_buf())
}
}
async fn load_diff(
ws_name: String,
ws_path_override: Option<String>,
commit_ref: Option<String>,
) -> Result<Vec<DiffFile>, String> {
let ws_path = resolve_workspace_path(&ws_name, ws_path_override).await?;
if !git_is_installed().await {
return Err("Git is not installed.".to_string());
}
if !is_git_repo(&ws_path).await {
return Err("This workspace is not a git repository.".to_string());
}
if !git_has_commits(&ws_path)
.await
.map_err(|e| format!("Git error: {e}"))?
{
return Err("No commits yet \u{2014} nothing to diff against.".to_string());
}
let diff_output = run_git_diff(&ws_path, commit_ref.as_deref())
.await
.map_err(|e| format!("Failed to run git diff: {e}"))?;
let mut parsed = parse_git_diff(&diff_output);
if commit_ref.is_none() {
let status_output = run_git_status(&ws_path)
.await
.map_err(|e| format!("Failed to run git status: {e}"))?;
let untracked: Vec<String> = status_output
.lines()
.filter(|l| l.starts_with("?? "))
.map(|l| l[3..].trim().to_string())
.collect();
for path in &untracked {
let full = ws_path.join(path);
if !full.is_file() {
continue;
}
let meta = match tokio::fs::metadata(&full).await {
Ok(m) => m,
Err(_) => continue,
};
if meta.len() > MAX_UNTRACKED_SIZE {
parsed.push(crate::diff_parse::DiffFile::placeholder(
path.clone(),
false,
Some(meta.len()),
));
continue;
}
let content = match tokio::fs::read(&full).await {
Ok(c) => c,
Err(_) => continue,
};
if content.contains(&0) {
parsed.push(crate::diff_parse::DiffFile::placeholder(
path.clone(),
true,
None,
));
continue;
}
match String::from_utf8(content) {
Ok(text) => {
parsed.push(make_untracked_diff_file(path, &text));
}
Err(_) => {
parsed.push(crate::diff_parse::DiffFile::placeholder(
path.clone(),
true,
None,
));
}
}
}
}
let mut enhanced: Vec<DiffFile> = Vec::with_capacity(parsed.len());
for dfile in parsed {
let (add_count, remove_count) = count_lines(&dfile);
let (old_hl, new_hl) = if dfile.is_binary || dfile.too_large_size.is_some() {
(None, None)
} else {
compute_highlights(&dfile, &ws_path, commit_ref.as_deref()).await
};
enhanced.push(DiffFile {
dfile,
old_highlights: old_hl,
new_highlights: new_hl,
add_count,
remove_count,
});
}
Ok(enhanced)
}
fn count_lines(dfile: &crate::diff_parse::DiffFile) -> (usize, usize) {
let mut add = 0;
let mut remove = 0;
for hunk in &dfile.hunks {
for line in &hunk.lines {
match line.kind {
DiffLineKind::Added => add += 1,
DiffLineKind::Removed => remove += 1,
DiffLineKind::Context => {}
}
}
}
(add, remove)
}
async fn compute_highlights(
dfile: &crate::diff_parse::DiffFile,
ws_path: &Path,
commit_ref: Option<&str>,
) -> (Option<FileHighlights>, Option<FileHighlights>) {
let lang = HighlightLanguage::from_path(&dfile.path);
let Some(lang) = lang else {
return (None, None);
};
let old_hl = compute_old_highlights(dfile, ws_path, lang, commit_ref).await;
let new_hl = compute_new_highlights(dfile, ws_path, lang, commit_ref).await;
(old_hl, new_hl)
}
async fn compute_old_highlights(
dfile: &crate::diff_parse::DiffFile,
ws_path: &Path,
lang: HighlightLanguage,
commit_ref: Option<&str>,
) -> Option<FileHighlights> {
if matches!(
dfile.status,
DiffFileStatus::Added | DiffFileStatus::Untracked
) {
return None;
}
let old_path = dfile.old_path.as_deref().unwrap_or(&dfile.path);
let show_ref = commit_ref.map(|hash| format!("{hash}~1"));
let content = run_git_show(ws_path, old_path, show_ref.as_deref())
.await
.ok()
.flatten()?;
if content.len() > MAX_HIGHLIGHT_SIZE {
return None;
}
let mut parser = make_parser(lang);
Some(parse_file_highlights(&mut parser, &content, lang))
}
async fn compute_new_highlights(
dfile: &crate::diff_parse::DiffFile,
ws_path: &Path,
lang: HighlightLanguage,
commit_ref: Option<&str>,
) -> Option<FileHighlights> {
if dfile.status == DiffFileStatus::Deleted {
return None;
}
let content = if let Some(hash) = commit_ref {
run_git_show(ws_path, &dfile.path, Some(hash))
.await
.ok()
.flatten()?
} else {
let full_path = ws_path.join(&dfile.path);
tokio::fs::read_to_string(&full_path).await.ok()?
};
if content.len() > MAX_HIGHLIGHT_SIZE {
return None;
}
let mut parser = make_parser(lang);
Some(parse_file_highlights(&mut parser, &content, lang))
}
fn make_parser(lang: HighlightLanguage) -> tree_sitter::Parser {
let mut parser = tree_sitter::Parser::new();
let ts_lang = lang.tree_sitter_language();
let _ = parser.set_language(&ts_lang);
parser
}
fn build_tree(files: &[DiffFile]) -> Vec<widgets::TreeNode> {
let mut roots: HashMap<String, widgets::TreeNode> = HashMap::new();
for file in files {
let path = &file.dfile.path;
let components: Vec<&str> = path.split('/').collect();
if components.is_empty() {
continue;
}
let root_name = components[0].to_string();
let root_full = root_name.clone();
let root_node = roots
.entry(root_full.clone())
.or_insert_with(|| widgets::TreeNode {
name: root_name,
full_path: root_full,
is_dir: components.len() > 1,
children: Vec::new(),
error: None,
});
if components.len() == 1 {
if root_node.is_dir {
root_node.children.push(widgets::TreeNode {
name: components[0].to_string(),
full_path: path.clone(),
is_dir: false,
children: Vec::new(),
error: None,
});
} else {
root_node.full_path.clone_from(path);
root_node.is_dir = false;
}
} else {
root_node.is_dir = true;
let mut current = root_node;
for (i, comp) in components.iter().enumerate().skip(1) {
let full = components[..=i].join("/");
let is_last = i == components.len() - 1;
let child = current.children.iter_mut().find(|c| c.name == *comp);
match child {
Some(existing) => {
if is_last {
existing.is_dir = false;
existing.full_path.clone_from(path);
}
}
None => {
current.children.push(widgets::TreeNode {
name: (*comp).to_string(),
full_path: if is_last { path.clone() } else { full },
is_dir: !is_last,
children: Vec::new(),
error: None,
});
}
}
if let Some(idx) = current.children.iter().position(|c| c.name == *comp) {
current = &mut current.children[idx];
} else {
break;
}
}
}
}
let mut sorted: Vec<widgets::TreeNode> = roots.into_values().collect();
FileTree::sort_nodes(&mut sorted);
sorted
}
fn collect_dir_paths(nodes: &[widgets::TreeNode], paths: &mut HashSet<String>) {
for node in nodes {
if node.is_dir {
paths.insert(node.full_path.clone());
collect_dir_paths(&node.children, paths);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_test_file(path: &str, add: usize, remove: usize) -> DiffFile {
DiffFile {
dfile: crate::diff_parse::DiffFile {
path: path.to_string(),
old_path: None,
hunks: Vec::new(),
status: crate::diff_parse::DiffFileStatus::Modified,
is_binary: false,
too_large_size: None,
},
old_highlights: None,
new_highlights: None,
add_count: add,
remove_count: remove,
}
}
#[test]
fn test_build_tree_single_file() {
let files = vec![make_test_file("src/main.rs", 3, 1)];
let tree = build_tree(&files);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].name, "src");
assert!(tree[0].is_dir);
assert_eq!(tree[0].children.len(), 1);
assert_eq!(tree[0].children[0].name, "main.rs");
assert!(!tree[0].children[0].is_dir);
}
#[test]
fn test_build_tree_multiple_files_same_dir() {
let files = vec![
make_test_file("src/lib.rs", 5, 2),
make_test_file("src/main.rs", 3, 1),
];
let tree = build_tree(&files);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].name, "src");
assert!(tree[0].is_dir);
assert_eq!(tree[0].children.len(), 2);
assert_eq!(tree[0].children[0].name, "lib.rs");
assert_eq!(tree[0].children[1].name, "main.rs");
}
#[test]
fn test_build_tree_root_file() {
let files = vec![make_test_file("README.md", 0, 0)];
let tree = build_tree(&files);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].name, "README.md");
assert!(!tree[0].is_dir);
}
#[test]
fn test_build_tree_nested() {
let files = vec![make_test_file("src/gui/diff.rs", 10, 5)];
let tree = build_tree(&files);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].name, "src");
assert!(tree[0].is_dir);
assert_eq!(tree[0].children.len(), 1);
assert_eq!(tree[0].children[0].name, "gui");
assert!(tree[0].children[0].is_dir);
assert_eq!(tree[0].children[0].children.len(), 1);
assert_eq!(tree[0].children[0].children[0].name, "diff.rs");
assert!(!tree[0].children[0].children[0].is_dir);
}
fn make_diff_with_tree() -> DiffState {
let mut state = DiffState::new();
state.diff_files = vec![
DiffFile {
dfile: crate::diff_parse::DiffFile {
path: "src/main.rs".to_owned(),
old_path: None,
hunks: Vec::new(),
status: crate::diff_parse::DiffFileStatus::Modified,
is_binary: false,
too_large_size: None,
},
old_highlights: None,
new_highlights: None,
add_count: 0,
remove_count: 0,
},
DiffFile {
dfile: crate::diff_parse::DiffFile {
path: "src/lib.rs".to_owned(),
old_path: None,
hunks: Vec::new(),
status: crate::diff_parse::DiffFileStatus::Modified,
is_binary: false,
too_large_size: None,
},
old_highlights: None,
new_highlights: None,
add_count: 0,
remove_count: 0,
},
];
state.file_tree.nodes = build_tree(&state.diff_files);
state.file_tree.rebuild_visible();
state
}
#[test]
fn test_diff_rebuild_visible_tree() {
let mut state = make_diff_with_tree();
assert!(!state.file_tree.visible_tree_nodes.is_empty());
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes = build_tree(&state.diff_files);
state.file_tree.rebuild_visible();
assert_eq!(
state.file_tree.visible_tree_nodes.len(),
3,
"expected 3 nodes: src, src/main.rs, src/lib.rs"
);
}
#[test]
fn test_diff_tree_focus_toggled_sets_focus() {
let mut state = make_diff_with_tree();
assert!(!state.file_tree.tree_focused);
let _ = state.update(DiffMessage::TreeFocusToggled);
assert!(state.file_tree.tree_focused);
let _ = state.update(DiffMessage::TreeFocusToggled);
assert!(!state.file_tree.tree_focused);
}
#[test]
fn test_diff_tree_focus_toggled_empty_tree_stays_off() {
let mut state = DiffState::new();
let _ = state.update(DiffMessage::TreeFocusToggled);
assert!(!state.file_tree.tree_focused);
}
#[test]
fn test_diff_tree_nav_up_clamped() {
let mut state = make_diff_with_tree();
state.file_tree.tree_focused = true;
state.file_tree.tree_focus_index = 0;
let _ = state.update(DiffMessage::TreeNavUp);
assert_eq!(state.file_tree.tree_focus_index, 0);
}
#[test]
fn test_diff_tree_nav_down_clamped() {
let mut state = make_diff_with_tree();
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes = build_tree(&state.diff_files);
state.file_tree.rebuild_visible();
state.file_tree.tree_focused = true;
let last = state.file_tree.visible_tree_nodes.len() - 1;
state.file_tree.tree_focus_index = last;
let _ = state.update(DiffMessage::TreeNavDown);
assert_eq!(state.file_tree.tree_focus_index, last);
}
#[test]
fn test_diff_tree_nav_up_down_moves_focus() {
let mut state = make_diff_with_tree();
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes = build_tree(&state.diff_files);
state.file_tree.rebuild_visible();
state.file_tree.tree_focused = true;
state.file_tree.tree_focus_index = 1;
let _ = state.update(DiffMessage::TreeNavUp);
assert_eq!(state.file_tree.tree_focus_index, 0);
let _ = state.update(DiffMessage::TreeNavDown);
assert_eq!(state.file_tree.tree_focus_index, 1);
}
#[test]
fn test_diff_escape_clears_tree_focus() {
let mut state = make_diff_with_tree();
state.file_tree.tree_focused = true;
let _ = state.update(DiffMessage::Escape);
assert!(!state.file_tree.tree_focused);
}
#[test]
fn test_diff_toggle_dir_sets_tree_focus() {
let mut state = make_diff_with_tree();
if let Some((dir_path, _)) = state
.file_tree
.visible_tree_nodes
.iter()
.find(|(_, is_dir)| *is_dir)
.cloned()
{
let _ = state.update(DiffMessage::ToggleDir(dir_path));
assert!(state.file_tree.tree_focused);
}
}
#[test]
fn test_diff_select_file_keeps_tree_focus() {
let mut state = make_diff_with_tree();
state.file_tree.tree_focused = true;
let _ = state.update(DiffMessage::SelectFile("src/main.rs".to_owned()));
assert!(state.file_tree.tree_focused);
}
#[test]
fn test_diff_visible_tree_clamps_focus() {
let mut state = make_diff_with_tree();
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes = build_tree(&state.diff_files);
state.file_tree.rebuild_visible();
state.file_tree.tree_focus_index = 999;
state.file_tree.rebuild_visible();
assert_eq!(
state.file_tree.tree_focus_index,
state.file_tree.visible_tree_nodes.len() - 1
);
}
#[test]
fn test_diff_tree_nav_left_on_expanded_dir_collapses() {
let mut state = make_diff_with_tree();
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes = build_tree(&state.diff_files);
state.file_tree.rebuild_visible();
state.file_tree.tree_focused = true;
state.file_tree.tree_focus_index = 0; assert!(state.file_tree.expanded_dirs.contains("src"));
let _ = state.update(DiffMessage::TreeNavLeft);
assert!(!state.file_tree.expanded_dirs.contains("src"));
assert_eq!(state.file_tree.tree_focus_index, 0);
}
#[test]
fn test_diff_tree_nav_left_on_file_navigates_to_parent() {
let mut state = make_diff_with_tree();
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes = build_tree(&state.diff_files);
state.file_tree.rebuild_visible();
state.file_tree.tree_focused = true;
state.file_tree.tree_focus_index = 1;
let _ = state.update(DiffMessage::TreeNavLeft);
assert_eq!(state.file_tree.tree_focus_index, 0);
assert_eq!(state.file_tree.visible_tree_nodes[0].0, "src");
}
#[test]
fn test_diff_tree_nav_left_on_root_item_noop() {
let mut state = make_diff_with_tree();
state.file_tree.tree_focused = true;
state.file_tree.tree_focus_index = 0;
let _ = state.update(DiffMessage::TreeNavLeft);
assert_eq!(state.file_tree.tree_focus_index, 0);
}
#[test]
fn test_diff_tree_nav_right_on_collapsed_dir_expands_and_advances() {
let mut state = make_diff_with_tree();
state.file_tree.tree_focused = true;
state.file_tree.tree_focus_index = 0;
let _ = state.update(DiffMessage::TreeNavRight);
assert!(state.file_tree.expanded_dirs.contains("src"));
assert_eq!(state.file_tree.tree_focus_index, 1);
assert_eq!(state.file_tree.visible_tree_nodes[1].0, "src/lib.rs");
}
#[test]
fn test_diff_tree_nav_right_on_expanded_dir_moves_to_first_child() {
let mut state = make_diff_with_tree();
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes = build_tree(&state.diff_files);
state.file_tree.rebuild_visible();
state.file_tree.tree_focused = true;
state.file_tree.tree_focus_index = 0;
let _ = state.update(DiffMessage::TreeNavRight);
assert_eq!(state.file_tree.tree_focus_index, 1);
assert_eq!(state.file_tree.visible_tree_nodes[1].0, "src/lib.rs");
}
#[test]
fn test_diff_tree_nav_right_on_file_noop() {
let mut state = make_diff_with_tree();
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes = build_tree(&state.diff_files);
state.file_tree.rebuild_visible();
state.file_tree.tree_focused = true;
state.file_tree.tree_focus_index = 1;
let _ = state.update(DiffMessage::TreeNavRight);
assert_eq!(state.file_tree.tree_focus_index, 1);
}
#[test]
fn test_diff_toggle_dir_sets_tree_focus_index() {
let mut state = make_diff_with_tree();
let _ = state.update(DiffMessage::ToggleDir("src".to_owned()));
assert!(state.file_tree.tree_focused);
assert_eq!(state.file_tree.tree_focus_index, 0);
assert_eq!(state.file_tree.visible_tree_nodes[0].0, "src");
}
#[test]
fn test_diff_select_file_sets_tree_focus_index() {
let mut state = make_diff_with_tree();
state.file_tree.expanded_dirs.insert("src".to_string());
state.file_tree.nodes = build_tree(&state.diff_files);
state.file_tree.rebuild_visible();
state.file_tree.tree_focused = true;
let _ = state.update(DiffMessage::SelectFile("src/main.rs".to_owned()));
assert!(state.file_tree.tree_focused);
assert_eq!(
state.file_tree.visible_tree_nodes[state.file_tree.tree_focus_index].0,
"src/main.rs"
);
}
#[test]
fn test_discard_path_noop_in_commit_view() {
let mut state = make_diff_with_tree();
state.current_commit_ref = Some("abc1234".to_owned());
assert!(!state.diff_loading);
let _ = state.update(DiffMessage::DiscardPath(
"src/main.rs".to_owned(),
DiscardTarget::File,
));
assert!(!state.diff_loading);
}
#[test]
fn test_discard_path_noop_without_workspace() {
let mut state = make_diff_with_tree();
state.selected_workspace_name = None;
assert!(!state.diff_loading);
let _ = state.update(DiffMessage::DiscardPath(
"src/main.rs".to_owned(),
DiscardTarget::File,
));
assert!(!state.diff_loading);
}
#[test]
fn test_discard_path_sets_loading() {
let mut state = make_diff_with_tree();
state.selected_workspace_name = Some("test-ws".to_owned());
assert!(!state.diff_loading);
let _ = state.update(DiffMessage::DiscardPath(
"src/main.rs".to_owned(),
DiscardTarget::File,
));
assert!(state.diff_loading);
}
#[test]
fn test_discard_result_success_no_workspace_resets_loading() {
let mut state = make_diff_with_tree();
state.selected_workspace_name = None;
state.diff_loading = true;
let _ = state.update(DiffMessage::DiscardResult(Ok(())));
assert!(!state.diff_loading);
}
#[test]
fn test_discard_result_success_with_workspace_keeps_loading_for_refresh() {
let mut state = make_diff_with_tree();
state.selected_workspace_name = Some("test-ws".to_owned());
state.diff_loading = true;
let _ = state.update(DiffMessage::DiscardResult(Ok(())));
assert!(state.diff_loading);
}
#[test]
fn test_discard_result_error_resets_loading() {
let mut state = make_diff_with_tree();
state.selected_workspace_name = Some("test-ws".to_owned());
state.diff_loading = true;
let _ = state.update(DiffMessage::DiscardResult(Err(
"something went wrong".to_owned()
)));
assert!(!state.diff_loading);
}
#[test]
fn test_discard_path_file_target_vs_dir_target() {
let mut state_file = make_diff_with_tree();
state_file.selected_workspace_name = Some("ws".to_owned());
let _task_file = state_file.update(DiffMessage::DiscardPath(
"src/main.rs".to_owned(),
DiscardTarget::File,
));
assert!(state_file.diff_loading);
let mut state_dir = make_diff_with_tree();
state_dir.selected_workspace_name = Some("ws".to_owned());
let _task_dir = state_dir.update(DiffMessage::DiscardPath(
"src".to_owned(),
DiscardTarget::Directory,
));
assert!(state_dir.diff_loading);
}
fn make_diff_loaded(files: Vec<DiffFile>) -> DiffState {
let mut state = DiffState::new();
state.tree_auto_expand_pending = true;
let _ = state.update(DiffMessage::DiffLoaded(state.generation, Ok(files)));
state
}
#[test]
fn test_tree_auto_expand_nested_on_first_load() {
let files = vec![
make_test_file("src/gui/diff.rs", 1, 0),
make_test_file("src/lib.rs", 0, 1),
];
let state = make_diff_loaded(files);
assert!(state.file_tree.expanded_dirs.contains("src"));
assert!(state.file_tree.expanded_dirs.contains("src/gui"));
assert!(!state.tree_auto_expand_pending);
}
#[test]
fn test_collapsed_tree_preserved_on_refresh() {
let files = vec![make_test_file("src/main.rs", 1, 0)];
let mut state = make_diff_loaded(files);
state.file_tree.expanded_dirs.clear();
state.tree_auto_expand_pending = false;
let _ = state.update(DiffMessage::DiffLoaded(
state.generation,
Ok(vec![make_test_file("src/main.rs", 2, 0)]),
));
assert!(state.file_tree.expanded_dirs.is_empty());
}
#[test]
fn test_workspace_switch_resets_auto_expand() {
let mut state = DiffState::new();
state.tree_auto_expand_pending = false;
let _ = state.update(DiffMessage::WorkspaceSelected(
"ws".to_owned(),
Some("/tmp/ws".to_owned()),
));
assert!(state.tree_auto_expand_pending);
assert!(state.file_tree.expanded_dirs.is_empty());
}
#[test]
fn test_commit_message_clears_tree_focus() {
let mut state = make_diff_with_tree();
state.file_tree.tree_focused = true;
let _ = state.update(DiffMessage::CommitMessageChanged("fix bug".to_owned()));
assert!(!state.file_tree.tree_focused);
}
}