use std::collections::HashSet;
use std::path::PathBuf;
use gitkraft_core::*;
use iced::{Color, Point, Task};
use crate::message::Message;
use crate::theme::ThemeColors;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DragTarget {
SidebarRight,
CommitLogRight,
DiffFileListRight,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DragTargetH {
StagingTop,
}
#[derive(Debug, Clone)]
pub enum ContextMenu {
Branch {
name: String,
is_current: bool,
local_index: usize,
},
RemoteBranch { name: String },
Commit { index: usize, oid: String },
Stash { index: usize },
UnstagedFile { path: String },
StagedFile { path: String },
CommitFile { oid: String, file_path: String },
}
pub struct RepoTab {
pub repo_path: Option<PathBuf>,
pub repo_info: Option<RepoInfo>,
pub branches: Vec<BranchInfo>,
pub current_branch: Option<String>,
pub commits: Vec<CommitInfo>,
pub selected_commit: Option<usize>,
pub anchor_commit_index: Option<usize>,
pub selected_commits: Vec<usize>,
pub graph_rows: Vec<gitkraft_core::GraphRow>,
pub unstaged_changes: Vec<DiffInfo>,
pub staged_changes: Vec<DiffInfo>,
pub commit_files: Vec<gitkraft_core::DiffFileEntry>,
pub selected_commit_oid: Option<String>,
pub selected_file_index: Option<usize>,
pub is_loading_file_diff: bool,
pub anchor_file_index: Option<usize>,
pub selected_commit_file_indices: Vec<usize>,
pub multi_file_diffs: Vec<gitkraft_core::DiffInfo>,
pub commit_range_diffs: Vec<gitkraft_core::DiffInfo>,
pub selected_diff: Option<DiffInfo>,
pub commit_message: String,
pub stashes: Vec<StashEntry>,
pub remotes: Vec<RemoteInfo>,
pub show_commit_detail: bool,
pub new_branch_name: String,
pub show_branch_create: bool,
pub local_branches_expanded: bool,
pub remote_branches_expanded: bool,
pub stash_message: String,
pub selected_unstaged: std::collections::HashSet<String>,
pub selected_staged: std::collections::HashSet<String>,
pub pending_discard: Option<String>,
pub status_message: Option<String>,
pub error_message: Option<String>,
pub is_loading: bool,
pub context_menu_pos: (f32, f32),
pub context_menu: Option<ContextMenu>,
pub rename_branch_target: Option<String>,
pub rename_branch_input: String,
pub create_tag_target_oid: Option<String>,
pub create_tag_annotated: bool,
pub create_tag_name: String,
pub create_tag_message: String,
pub create_branch_at_oid: Option<String>,
pub commit_scroll_offset: f32,
pub diff_scroll_offset: f32,
pub commit_display: Vec<(String, String, String)>,
pub has_more_commits: bool,
pub is_loading_more_commits: bool,
pub file_history_path: Option<String>,
pub file_history_commits: Vec<gitkraft_core::CommitInfo>,
pub file_history_scroll: f32,
pub blame_path: Option<String>,
pub blame_lines: Vec<gitkraft_core::BlameLine>,
pub blame_scroll: f32,
pub pending_delete_file: Option<String>,
}
impl RepoTab {
pub fn new_empty() -> Self {
Self {
repo_path: None,
repo_info: None,
branches: Vec::new(),
current_branch: None,
commits: Vec::new(),
selected_commit: None,
anchor_commit_index: None,
selected_commits: Vec::new(),
graph_rows: Vec::new(),
unstaged_changes: Vec::new(),
staged_changes: Vec::new(),
commit_files: Vec::new(),
selected_commit_oid: None,
selected_file_index: None,
is_loading_file_diff: false,
anchor_file_index: None,
selected_commit_file_indices: Vec::new(),
multi_file_diffs: Vec::new(),
commit_range_diffs: Vec::new(),
selected_diff: None,
commit_message: String::new(),
stashes: Vec::new(),
remotes: Vec::new(),
show_commit_detail: false,
new_branch_name: String::new(),
show_branch_create: false,
local_branches_expanded: true,
remote_branches_expanded: true,
stash_message: String::new(),
selected_unstaged: std::collections::HashSet::new(),
selected_staged: std::collections::HashSet::new(),
pending_discard: None,
status_message: None,
error_message: None,
is_loading: false,
context_menu: None,
context_menu_pos: (0.0, 0.0),
rename_branch_target: None,
rename_branch_input: String::new(),
create_tag_target_oid: None,
create_tag_annotated: false,
create_tag_name: String::new(),
create_tag_message: String::new(),
create_branch_at_oid: None,
commit_scroll_offset: 0.0,
diff_scroll_offset: 0.0,
commit_display: Vec::new(),
has_more_commits: true,
is_loading_more_commits: false,
file_history_path: None,
file_history_commits: Vec::new(),
file_history_scroll: 0.0,
blame_path: None,
blame_lines: Vec::new(),
blame_scroll: 0.0,
pending_delete_file: None,
}
}
pub fn has_repo(&self) -> bool {
self.repo_path.is_some()
}
pub fn display_name(&self) -> &str {
self.repo_path
.as_ref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("New Tab")
}
pub fn apply_payload(
&mut self,
payload: crate::message::RepoPayload,
path: std::path::PathBuf,
) {
let prev_oid = self.selected_commit_oid.clone();
let prev_anchor_oid = self
.anchor_commit_index
.and_then(|i| self.commits.get(i).map(|c| c.oid.clone()));
let prev_selected_oids: Vec<String> = self
.selected_commits
.iter()
.filter_map(|&i| self.commits.get(i).map(|c| c.oid.clone()))
.collect();
self.current_branch = payload.info.head_branch.clone();
self.repo_path = Some(path);
self.repo_info = Some(payload.info);
self.branches = payload.branches;
self.commits = payload.commits;
self.graph_rows = payload.graph_rows;
self.unstaged_changes = payload.unstaged;
self.staged_changes = payload.staged;
self.stashes = payload.stashes;
self.remotes = payload.remotes;
self.selected_commit = None;
self.anchor_commit_index = None;
self.selected_commits.clear();
self.selected_commit_oid = None;
self.commit_message.clear();
self.error_message = None;
self.status_message = Some("Repository loaded.".into());
self.commit_scroll_offset = 0.0;
self.has_more_commits = true;
self.is_loading_more_commits = false;
self.selected_unstaged.clear();
self.selected_staged.clear();
self.anchor_file_index = None;
self.selected_commit_file_indices.clear();
self.multi_file_diffs.clear();
self.commit_range_diffs.clear();
if let Some(oid) = prev_oid {
if let Some(new_idx) = self.commits.iter().position(|c| c.oid == oid) {
self.selected_commit = Some(new_idx);
self.selected_commit_oid = Some(oid);
} else {
self.selected_diff = None;
self.commit_files.clear();
self.selected_file_index = None;
self.is_loading_file_diff = false;
self.diff_scroll_offset = 0.0;
}
} else {
self.selected_diff = None;
self.commit_files.clear();
self.selected_file_index = None;
self.is_loading_file_diff = false;
self.diff_scroll_offset = 0.0;
}
if let Some(anchor_oid) = prev_anchor_oid {
if let Some(new_anchor) = self.commits.iter().position(|c| c.oid == anchor_oid) {
self.anchor_commit_index = Some(new_anchor);
}
}
if !prev_selected_oids.is_empty() {
let restored: Vec<usize> = prev_selected_oids
.iter()
.filter_map(|oid| self.commits.iter().position(|c| &c.oid == oid))
.collect();
if !restored.is_empty() {
self.selected_commits = restored;
self.selected_commits.sort_unstable();
}
}
}
}
pub struct GitKraft {
pub tabs: Vec<RepoTab>,
pub active_tab: usize,
pub sidebar_expanded: bool,
pub sidebar_width: f32,
pub commit_log_width: f32,
pub staging_height: f32,
pub diff_file_list_width: f32,
pub ui_scale: f32,
pub dragging: Option<DragTarget>,
pub dragging_h: Option<DragTargetH>,
pub drag_start_x: f32,
pub drag_start_y: f32,
pub drag_initialized: bool,
pub drag_initialized_h: bool,
pub cursor_pos: Point,
pub current_theme_index: usize,
pub recent_repos: Vec<gitkraft_core::RepoHistoryEntry>,
pub search_visible: bool,
pub search_query: String,
pub search_results: Vec<gitkraft_core::CommitInfo>,
pub search_selected: Option<usize>,
pub search_diff_files: Vec<gitkraft_core::DiffFileEntry>,
pub search_diff_selected: HashSet<usize>,
pub search_diff_content: Vec<gitkraft_core::DiffInfo>,
pub search_diff_oid: Option<String>,
pub editor: gitkraft_core::Editor,
pub keyboard_modifiers: iced::keyboard::Modifiers,
pub animation_tick: u64,
pub window_width: f32,
pub window_height: f32,
pub window_x: f32,
pub window_y: f32,
}
impl Default for GitKraft {
fn default() -> Self {
Self::new()
}
}
impl GitKraft {
fn from_settings(settings: gitkraft_core::AppSettings) -> Self {
let current_theme_index = settings
.theme_name
.as_deref()
.map(gitkraft_core::theme_index_by_name)
.unwrap_or(0);
let recent_repos = settings.recent_repos;
let (
sidebar_width,
commit_log_width,
staging_height,
diff_file_list_width,
sidebar_expanded,
ui_scale,
) = if let Some(ref layout) = settings.layout {
(
layout.sidebar_width.unwrap_or(220.0),
layout.commit_log_width.unwrap_or(500.0),
layout.staging_height.unwrap_or(200.0),
layout.diff_file_list_width.unwrap_or(180.0),
layout.sidebar_expanded.unwrap_or(true),
layout.ui_scale.unwrap_or(1.0),
)
} else {
(220.0, 500.0, 200.0, 180.0, true, 1.0)
};
Self {
tabs: vec![RepoTab::new_empty()],
active_tab: 0,
sidebar_expanded,
sidebar_width,
commit_log_width,
staging_height,
diff_file_list_width,
ui_scale,
dragging: None,
dragging_h: None,
drag_start_x: 0.0,
drag_start_y: 0.0,
drag_initialized: false,
drag_initialized_h: false,
cursor_pos: Point::ORIGIN,
current_theme_index,
recent_repos,
search_visible: false,
search_query: String::new(),
search_results: Vec::new(),
search_selected: None,
search_diff_files: Vec::new(),
search_diff_selected: HashSet::new(),
search_diff_content: Vec::new(),
search_diff_oid: None,
keyboard_modifiers: iced::keyboard::Modifiers::default(),
animation_tick: 0,
window_width: settings
.layout
.as_ref()
.and_then(|l| l.window_width)
.unwrap_or(1400.0),
window_height: settings
.layout
.as_ref()
.and_then(|l| l.window_height)
.unwrap_or(800.0),
window_x: settings
.layout
.as_ref()
.and_then(|l| l.window_x)
.unwrap_or(0.0),
window_y: settings
.layout
.as_ref()
.and_then(|l| l.window_y)
.unwrap_or(0.0),
editor: settings
.editor_name
.as_deref()
.map(|name| {
gitkraft_core::EDITOR_NAMES
.iter()
.position(|n| n.eq_ignore_ascii_case(name))
.map(gitkraft_core::Editor::from_index)
.unwrap_or_else(|| {
if name.eq_ignore_ascii_case("none") {
gitkraft_core::Editor::None
} else {
gitkraft_core::Editor::Custom(name.to_string())
}
})
})
.unwrap_or_else(detect_system_editor),
}
}
pub fn new() -> Self {
Self::from_settings(
gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default(),
)
}
pub fn new_with_session_paths() -> (Self, Vec<PathBuf>) {
let settings =
gitkraft_core::features::persistence::ops::load_settings().unwrap_or_default();
let open_tabs = settings.open_tabs.clone();
let active_tab_index = settings.active_tab_index;
let mut state = Self::from_settings(settings);
if !open_tabs.is_empty() {
state.tabs = open_tabs
.iter()
.map(|path| {
let mut tab = RepoTab::new_empty();
tab.repo_path = Some(path.clone());
if path.exists() {
tab.is_loading = true;
tab.status_message = Some(format!(
"Loading {}…",
path.file_name().unwrap_or_default().to_string_lossy()
));
} else {
tab.error_message =
Some(format!("Repository not found: {}", path.display()));
}
tab
})
.collect();
state.active_tab = active_tab_index.min(state.tabs.len().saturating_sub(1));
}
(state, open_tabs)
}
pub fn open_tab_paths(&self) -> Vec<PathBuf> {
self.tabs
.iter()
.filter(|t| t.repo_info.is_some())
.filter_map(|t| t.repo_path.clone())
.collect()
}
pub fn active_tab(&self) -> &RepoTab {
&self.tabs[self.active_tab]
}
pub fn active_tab_mut(&mut self) -> &mut RepoTab {
&mut self.tabs[self.active_tab]
}
pub fn has_repo(&self) -> bool {
self.active_tab().has_repo()
}
pub fn repo_display_name(&self) -> &str {
self.active_tab().display_name()
}
pub fn colors(&self) -> ThemeColors {
ThemeColors::from_core(&gitkraft_core::theme_by_index(self.current_theme_index))
}
pub fn iced_theme(&self) -> iced::Theme {
let core = gitkraft_core::theme_by_index(self.current_theme_index);
let name = self.current_theme_name().to_string();
let palette = iced::theme::Palette {
background: rgb_to_iced(core.background),
text: rgb_to_iced(core.text_primary),
primary: rgb_to_iced(core.accent),
success: rgb_to_iced(core.success),
warning: rgb_to_iced(core.warning),
danger: rgb_to_iced(core.error),
};
iced::Theme::custom(name, palette)
}
pub fn current_theme_name(&self) -> &'static str {
gitkraft_core::THEME_NAMES
.get(self.current_theme_index)
.copied()
.unwrap_or("Default")
}
pub fn refresh_active_tab(&mut self) -> Task<Message> {
match self.active_tab().repo_path.clone() {
Some(path) => crate::features::repo::commands::refresh_repo(path),
None => Task::none(),
}
}
pub fn on_ok_refresh(
&mut self,
result: Result<(), String>,
ok_msg: &str,
err_prefix: &str,
) -> Task<Message> {
match result {
Ok(()) => {
{
let tab = self.active_tab_mut();
tab.is_loading = false;
tab.status_message = Some(ok_msg.to_string());
}
self.refresh_active_tab()
}
Err(e) => {
let tab = self.active_tab_mut();
tab.is_loading = false;
tab.error_message = Some(format!("{err_prefix}: {e}"));
tab.status_message = None;
Task::none()
}
}
}
pub fn current_layout(&self) -> gitkraft_core::LayoutSettings {
gitkraft_core::LayoutSettings {
sidebar_width: Some(self.sidebar_width),
commit_log_width: Some(self.commit_log_width),
staging_height: Some(self.staging_height),
diff_file_list_width: Some(self.diff_file_list_width),
sidebar_expanded: Some(self.sidebar_expanded),
ui_scale: Some(self.ui_scale),
window_width: Some(self.window_width),
window_height: Some(self.window_height),
window_x: Some(self.window_x),
window_y: Some(self.window_y),
window_maximized: None, }
}
}
fn rgb_to_iced(rgb: gitkraft_core::Rgb) -> Color {
Color::from_rgb8(rgb.r, rgb.g, rgb.b)
}
fn detect_system_editor() -> gitkraft_core::Editor {
for var in ["VISUAL", "EDITOR"] {
if let Ok(val) = std::env::var(var) {
let bin = val.split('/').next_back().unwrap_or(&val).trim();
return match bin {
"nvim" | "neovim" => gitkraft_core::Editor::Neovim,
"vim" => gitkraft_core::Editor::Vim,
"hx" | "helix" => gitkraft_core::Editor::Helix,
"nano" => gitkraft_core::Editor::Nano,
"micro" => gitkraft_core::Editor::Micro,
"emacs" => gitkraft_core::Editor::Emacs,
"code" => gitkraft_core::Editor::VSCode,
"zed" => gitkraft_core::Editor::Zed,
"subl" => gitkraft_core::Editor::Sublime,
_ => gitkraft_core::Editor::Custom(val),
};
}
}
gitkraft_core::Editor::None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_defaults() {
let state = GitKraft::new();
assert!(state.active_tab().repo_path.is_none());
assert!(!state.has_repo());
assert_eq!(state.repo_display_name(), "New Tab");
assert!(state.active_tab().commits.is_empty());
assert!(state.sidebar_expanded);
assert!(state.current_theme_index < gitkraft_core::THEME_COUNT);
assert!(state.sidebar_width > 0.0);
assert!(state.commit_log_width > 0.0);
assert!(state.staging_height > 0.0);
assert!(state.dragging.is_none());
assert!(state.dragging_h.is_none());
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.active_tab, 0);
}
#[test]
fn repo_display_name_extracts_basename() {
let mut state = GitKraft::new();
state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/home/user/my-project"));
assert_eq!(state.repo_display_name(), "my-project");
}
#[test]
fn colors_returns_theme_colors() {
let state = GitKraft::new();
let c = state.colors();
assert!(c.bg.r < 0.5);
}
#[test]
fn iced_theme_is_custom_with_correct_palette() {
let mut state = GitKraft::new();
state.current_theme_index = 0;
let iced_t = state.iced_theme();
let pal = iced_t.palette();
assert!(pal.background.r < 0.5, "Default theme bg should be dark");
assert_eq!(iced_t.to_string(), "Default");
state.current_theme_index = 11;
let iced_t = state.iced_theme();
let pal = iced_t.palette();
assert!(pal.background.r > 0.5, "Solarized Light bg should be light");
assert_eq!(iced_t.to_string(), "Solarized Light");
state.current_theme_index = 12;
let iced_t = state.iced_theme();
let pal = iced_t.palette();
let core = gitkraft_core::theme_by_index(12);
let expected_accent = rgb_to_iced(core.accent);
assert!(
(pal.primary.r - expected_accent.r).abs() < 0.01
&& (pal.primary.g - expected_accent.g).abs() < 0.01
&& (pal.primary.b - expected_accent.b).abs() < 0.01,
"Gruvbox Dark accent should match core accent"
);
}
#[test]
fn iced_theme_name_round_trips_through_core() {
for i in 0..gitkraft_core::THEME_COUNT {
let mut state = GitKraft::new();
state.current_theme_index = i;
let iced_t = state.iced_theme();
let name = iced_t.to_string();
let resolved = gitkraft_core::theme_index_by_name(&name);
assert_eq!(
resolved,
i,
"theme index {i} ({}) did not round-trip through iced_theme name",
gitkraft_core::THEME_NAMES[i]
);
}
}
#[test]
fn current_theme_name_round_trips() {
let mut state = GitKraft::new();
state.current_theme_index = 8;
assert_eq!(state.current_theme_name(), "Dracula");
state.current_theme_index = 0;
assert_eq!(state.current_theme_name(), "Default");
}
#[test]
fn repo_tab_new_empty() {
let tab = RepoTab::new_empty();
assert!(tab.repo_path.is_none());
assert!(!tab.has_repo());
assert_eq!(tab.display_name(), "New Tab");
assert!(tab.commits.is_empty());
assert!(tab.branches.is_empty());
assert!(!tab.is_loading);
}
#[test]
fn repo_tab_display_name_with_path() {
let mut tab = RepoTab::new_empty();
tab.repo_path = Some(std::path::PathBuf::from("/some/path/cool-repo"));
assert!(tab.has_repo());
assert_eq!(tab.display_name(), "cool-repo");
}
#[test]
fn search_defaults() {
let state = GitKraft::new();
assert!(!state.search_visible);
assert!(state.search_query.is_empty());
assert!(state.search_results.is_empty());
assert!(state.search_selected.is_none());
}
#[test]
fn context_menu_variants_exist() {
use crate::state::ContextMenu;
let _branch = ContextMenu::Branch {
name: "main".to_string(),
is_current: true,
local_index: 0,
};
let _remote = ContextMenu::RemoteBranch {
name: "origin/main".to_string(),
};
let _commit = ContextMenu::Commit {
index: 0,
oid: "abc1234".to_string(),
};
let _stash = ContextMenu::Stash { index: 0 };
let _unstaged = ContextMenu::UnstagedFile {
path: "src/main.rs".to_string(),
};
let _staged = ContextMenu::StagedFile {
path: "src/lib.rs".to_string(),
};
}
#[test]
fn repo_tab_context_menu_defaults_to_none() {
let tab = crate::state::RepoTab::new_empty();
assert!(tab.context_menu.is_none());
}
#[test]
fn context_menu_variants_constructable() {
use crate::state::ContextMenu;
let _ = ContextMenu::Stash { index: 0 };
let _ = ContextMenu::UnstagedFile {
path: "a.rs".into(),
};
let _ = ContextMenu::StagedFile {
path: "b.rs".into(),
};
}
#[test]
fn selected_unstaged_defaults_empty() {
let tab = crate::state::RepoTab::new_empty();
assert!(tab.selected_unstaged.is_empty());
assert!(tab.selected_staged.is_empty());
}
#[test]
fn selected_unstaged_toggle() {
let mut tab = crate::state::RepoTab::new_empty();
tab.selected_unstaged.insert("a.rs".to_string());
tab.selected_unstaged.insert("b.rs".to_string());
assert_eq!(tab.selected_unstaged.len(), 2);
assert!(tab.selected_unstaged.contains("a.rs"));
tab.selected_unstaged.remove("a.rs");
assert_eq!(tab.selected_unstaged.len(), 1);
assert!(!tab.selected_unstaged.contains("a.rs"));
}
#[test]
fn detect_system_editor_returns_valid() {
let editor = super::detect_system_editor();
let _ = editor.display_name();
}
#[test]
fn selected_commit_file_indices_defaults_to_empty_vec() {
let tab = RepoTab::new_empty();
assert!(tab.selected_commit_file_indices.is_empty());
let v: &Vec<usize> = &tab.selected_commit_file_indices;
assert_eq!(v.len(), 0);
}
#[test]
fn multi_file_diffs_defaults_empty() {
let tab = RepoTab::new_empty();
assert!(tab.multi_file_diffs.is_empty());
}
#[test]
fn keyboard_modifiers_default_has_no_shift() {
let state = GitKraft::new();
assert!(!state.keyboard_modifiers.shift());
}
#[test]
fn selected_commit_file_indices_preserves_insertion_order() {
let mut tab = RepoTab::new_empty();
tab.selected_commit_file_indices.push(5);
tab.selected_commit_file_indices.push(2);
tab.selected_commit_file_indices.push(8);
assert_eq!(tab.selected_commit_file_indices, vec![5, 2, 8]);
}
#[test]
fn selected_commit_file_indices_cleared_on_reset() {
let mut tab = RepoTab::new_empty();
tab.selected_commit_file_indices.push(0);
tab.selected_commit_file_indices.push(1);
tab.selected_commit_file_indices.clear();
assert!(tab.selected_commit_file_indices.is_empty());
}
#[test]
fn multi_file_diffs_cleared_on_reset() {
let mut tab = RepoTab::new_empty();
tab.multi_file_diffs.push(gitkraft_core::DiffInfo {
old_file: String::new(),
new_file: "a.rs".to_string(),
status: gitkraft_core::FileStatus::Modified,
hunks: vec![],
});
tab.multi_file_diffs.clear();
assert!(tab.multi_file_diffs.is_empty());
}
#[test]
fn commit_range_diffs_defaults_empty() {
let tab = RepoTab::new_empty();
assert!(tab.commit_range_diffs.is_empty());
}
#[test]
fn commit_range_diffs_cleared_on_apply_payload() {
let mut tab = RepoTab::new_empty();
tab.commit_range_diffs.push(gitkraft_core::DiffInfo {
old_file: String::new(),
new_file: "x.rs".to_string(),
status: gitkraft_core::FileStatus::Modified,
hunks: vec![],
});
tab.commit_range_diffs.clear();
assert!(tab.commit_range_diffs.is_empty());
}
#[test]
fn modifiers_changed_sets_shift_state() {
use crate::message::Message;
let mut state = GitKraft::new();
assert!(!state.keyboard_modifiers.shift());
let _ = state.update(Message::ModifiersChanged(iced::keyboard::Modifiers::SHIFT));
assert!(state.keyboard_modifiers.shift());
let _ = state.update(Message::ModifiersChanged(
iced::keyboard::Modifiers::default(),
));
assert!(!state.keyboard_modifiers.shift());
}
fn make_commit_files(names: &[&str]) -> Vec<gitkraft_core::DiffFileEntry> {
names
.iter()
.map(|name| gitkraft_core::DiffFileEntry {
old_file: String::new(),
new_file: name.to_string(),
status: gitkraft_core::FileStatus::Modified,
})
.collect()
}
#[test]
fn select_diff_by_index_regular_click_clears_multi_selection() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
state.active_tab_mut().selected_commit_file_indices = vec![0, 1];
let _ = state.update(Message::SelectDiffByIndex(0));
assert!(state.active_tab().selected_commit_file_indices.is_empty());
assert!(state.active_tab().multi_file_diffs.is_empty());
assert_eq!(state.active_tab().selected_file_index, Some(0));
}
#[test]
fn select_diff_by_index_shift_click_adds_both_files_to_selection() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
state.active_tab_mut().selected_file_index = Some(0);
state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
let _ = state.update(Message::SelectDiffByIndex(1));
let sel = &state.active_tab().selected_commit_file_indices;
assert!(sel.contains(&0), "anchor file 0 should be selected");
assert!(sel.contains(&1), "newly clicked file 1 should be selected");
assert_eq!(sel.len(), 2);
}
#[test]
fn anchor_file_index_defaults_to_none() {
let tab = RepoTab::new_empty();
assert!(tab.anchor_file_index.is_none());
}
#[test]
fn regular_click_sets_anchor() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
let _ = state.update(Message::SelectDiffByIndex(2));
assert_eq!(
state.active_tab().anchor_file_index,
Some(2),
"regular click must set anchor to the clicked index"
);
}
#[test]
fn shift_click_selects_range_downward_from_anchor() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
state.active_tab_mut().commit_files =
make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
state.active_tab_mut().anchor_file_index = Some(1);
state.active_tab_mut().selected_file_index = Some(1);
state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
let _ = state.update(Message::SelectDiffByIndex(4));
let sel = &state.active_tab().selected_commit_file_indices;
assert_eq!(
sel,
&vec![1, 2, 3, 4],
"range must be contiguous from anchor to click"
);
}
#[test]
fn shift_click_selects_range_upward_from_anchor() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
state.active_tab_mut().commit_files =
make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
state.active_tab_mut().anchor_file_index = Some(4);
state.active_tab_mut().selected_file_index = Some(4);
state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
let _ = state.update(Message::SelectDiffByIndex(1));
let sel = &state.active_tab().selected_commit_file_indices;
assert_eq!(
sel,
&vec![1, 2, 3, 4],
"range must be stored ascending regardless of click direction"
);
}
#[test]
fn shift_click_anchor_fixed_on_subsequent_clicks() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
state.active_tab_mut().commit_files =
make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs", "e.rs"]);
state.active_tab_mut().anchor_file_index = Some(2);
state.active_tab_mut().selected_file_index = Some(2);
state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
let _ = state.update(Message::SelectDiffByIndex(4));
assert_eq!(
state.active_tab().selected_commit_file_indices,
vec![2, 3, 4]
);
let _ = state.update(Message::SelectDiffByIndex(3));
assert_eq!(
state.active_tab().selected_commit_file_indices,
vec![2, 3],
"anchor must stay fixed; second Shift+Click shrinks the range"
);
let _ = state.update(Message::SelectDiffByIndex(0));
assert_eq!(
state.active_tab().selected_commit_file_indices,
vec![0, 1, 2],
"anchor must stay fixed; can extend range in either direction"
);
}
#[test]
fn shift_click_on_anchor_itself_gives_single_item_range() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
state.active_tab_mut().anchor_file_index = Some(1);
state.active_tab_mut().selected_file_index = Some(1);
state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
let _ = state.update(Message::SelectDiffByIndex(1));
assert_eq!(state.active_tab().selected_commit_file_indices, vec![1]);
assert!(
state.active_tab().multi_file_diffs.is_empty(),
"single-item range must not populate multi_file_diffs"
);
}
#[test]
fn shift_click_range_is_always_ascending() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().selected_commit_oid = Some("abc123".to_string());
state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs", "d.rs"]);
state.active_tab_mut().anchor_file_index = Some(3);
state.active_tab_mut().selected_file_index = Some(3);
state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
let _ = state.update(Message::SelectDiffByIndex(0));
let sel = &state.active_tab().selected_commit_file_indices;
let is_sorted = sel.windows(2).all(|w| w[0] < w[1]);
assert!(
is_sorted,
"selection must always be stored in ascending order"
);
assert_eq!(sel, &vec![0, 1, 2, 3]);
}
#[test]
fn checkout_file_at_commit_message_variants_exist() {
use crate::message::Message;
let _single =
Message::CheckoutFileAtCommit("abc123".to_string(), "src/main.rs".to_string());
let _multi = Message::CheckoutMultiFilesAtCommit(
"abc123".to_string(),
vec!["a.rs".to_string(), "b.rs".to_string()],
);
}
#[test]
fn checkout_file_at_commit_closes_context_menu() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
oid: "abc123".to_string(),
file_path: "src/main.rs".to_string(),
});
let _ = state.update(Message::CheckoutFileAtCommit(
"abc123".to_string(),
"src/main.rs".to_string(),
));
assert!(state.active_tab().context_menu.is_none());
}
#[test]
fn checkout_multi_files_at_commit_closes_context_menu() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::CommitFile {
oid: "abc123".to_string(),
file_path: "src/main.rs".to_string(),
});
let _ = state.update(Message::CheckoutMultiFilesAtCommit(
"abc123".to_string(),
vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
));
assert!(state.active_tab().context_menu.is_none());
}
fn make_test_commits(count: usize) -> Vec<gitkraft_core::CommitInfo> {
(0..count)
.map(|i| gitkraft_core::CommitInfo {
oid: i.to_string(),
short_oid: i.to_string(),
summary: String::new(),
message: String::new(),
author_name: String::new(),
author_email: String::new(),
time: Default::default(),
parent_ids: Vec::new(),
})
.collect()
}
#[test]
fn selected_commits_defaults_empty() {
let tab = RepoTab::new_empty();
assert!(tab.selected_commits.is_empty());
assert!(tab.anchor_commit_index.is_none());
}
#[test]
fn regular_click_commit_sets_anchor_and_clears_range() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/fake"));
state.active_tab_mut().commits = make_test_commits(3);
state.active_tab_mut().selected_commits = vec![0, 1, 2];
let _ = state.update(Message::SelectCommit(1));
assert_eq!(state.active_tab().anchor_commit_index, Some(1));
assert!(state.active_tab().selected_commits.is_empty());
assert_eq!(state.active_tab().selected_commit, Some(1));
}
#[test]
fn shift_click_commit_selects_range_from_anchor() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().commits = make_test_commits(5);
state.active_tab_mut().anchor_commit_index = Some(1);
state.active_tab_mut().selected_commit = Some(1);
state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
let _ = state.update(Message::SelectCommit(4));
assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3, 4]);
}
#[test]
fn shift_click_commit_range_is_ascending_when_clicking_above_anchor() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().commits = make_test_commits(5);
state.active_tab_mut().anchor_commit_index = Some(3);
state.active_tab_mut().selected_commit = Some(3);
state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
let _ = state.update(Message::SelectCommit(1));
assert_eq!(state.active_tab().selected_commits, vec![1, 2, 3]);
}
#[test]
fn execute_commit_action_closes_context_menu() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().context_menu = Some(crate::state::ContextMenu::Commit {
index: 0,
oid: "abc123".to_string(),
});
let _ = state.update(Message::ExecuteCommitAction(
"abc123".to_string(),
gitkraft_core::CommitAction::CherryPick,
));
assert!(state.active_tab().context_menu.is_none());
}
#[test]
fn execute_commit_action_sets_loading_when_repo_open() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
let _ = state.update(Message::ExecuteCommitAction(
"abc123".to_string(),
gitkraft_core::CommitAction::ResetHard,
));
assert!(state.active_tab().is_loading);
}
#[test]
fn execute_commit_action_no_repo_does_not_set_loading() {
use crate::message::Message;
let mut state = GitKraft::new();
let _ = state.update(Message::ExecuteCommitAction(
"abc123".to_string(),
gitkraft_core::CommitAction::CherryPick,
));
assert!(!state.active_tab().is_loading);
}
#[test]
fn execute_commit_action_sets_status_message_from_action_label() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
let _ = state.update(Message::ExecuteCommitAction(
"abc123".to_string(),
gitkraft_core::CommitAction::Revert,
));
let status = state.active_tab().status_message.as_deref().unwrap_or("");
assert!(
status.contains("Revert commit"),
"expected status to contain 'Revert commit', got: {status:?}"
);
}
#[test]
fn file_history_defaults_empty() {
let tab = RepoTab::new_empty();
assert!(tab.file_history_path.is_none());
assert!(tab.file_history_commits.is_empty());
assert_eq!(tab.file_history_scroll, 0.0);
}
#[test]
fn blame_defaults_empty() {
let tab = RepoTab::new_empty();
assert!(tab.blame_path.is_none());
assert!(tab.blame_lines.is_empty());
assert_eq!(tab.blame_scroll, 0.0);
}
#[test]
fn pending_delete_file_defaults_none() {
let tab = RepoTab::new_empty();
assert!(tab.pending_delete_file.is_none());
}
#[test]
fn view_file_history_sets_path_and_clears_blame() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().blame_path = Some("some/file.rs".to_string());
let _ = state.update(Message::ViewFileHistory("src/main.rs".to_string()));
assert_eq!(
state.active_tab().file_history_path.as_deref(),
Some("src/main.rs")
);
assert!(state.active_tab().blame_path.is_none());
}
#[test]
fn close_file_history_clears_state() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().file_history_path = Some("src/lib.rs".to_string());
state.active_tab_mut().file_history_commits = vec![gitkraft_core::CommitInfo {
oid: "abc".to_string(),
short_oid: "abc".to_string(),
summary: "s".to_string(),
message: "s".to_string(),
author_name: "a".to_string(),
author_email: "a@b.com".to_string(),
time: Default::default(),
parent_ids: vec![],
}];
let _ = state.update(Message::CloseFileHistory);
assert!(state.active_tab().file_history_path.is_none());
assert!(state.active_tab().file_history_commits.is_empty());
}
#[test]
fn view_file_blame_sets_path_and_clears_history() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().file_history_path = Some("some/file.rs".to_string());
let _ = state.update(Message::ViewFileBlame("src/lib.rs".to_string()));
assert_eq!(state.active_tab().blame_path.as_deref(), Some("src/lib.rs"));
assert!(state.active_tab().file_history_path.is_none());
}
#[test]
fn selecting_new_commit_closes_blame_overlay() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().commits = vec![
gitkraft_core::CommitInfo {
oid: "abc1".into(),
short_oid: "abc1".into(),
summary: "first".into(),
message: "first".into(),
author_name: "A".into(),
author_email: "a@a.com".into(),
time: Default::default(),
parent_ids: Vec::new(),
},
gitkraft_core::CommitInfo {
oid: "abc2".into(),
short_oid: "abc2".into(),
summary: "second".into(),
message: "second".into(),
author_name: "A".into(),
author_email: "a@a.com".into(),
time: Default::default(),
parent_ids: Vec::new(),
},
];
state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
state.active_tab_mut().blame_lines = vec![gitkraft_core::BlameLine {
line_number: 1,
content: "fn main() {}".into(),
short_oid: "abc1".into(),
oid: "abc1".into(),
author_name: "A".into(),
time: Default::default(),
}];
let _ = state.update(Message::SelectCommit(1));
assert!(
state.active_tab().blame_path.is_none(),
"blame_path must be cleared when a new commit is selected"
);
assert!(
state.active_tab().blame_lines.is_empty(),
"blame_lines must be cleared when a new commit is selected"
);
}
#[test]
fn close_file_blame_clears_state() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().blame_path = Some("src/lib.rs".to_string());
let _ = state.update(Message::CloseFileBlame);
assert!(state.active_tab().blame_path.is_none());
assert!(state.active_tab().blame_lines.is_empty());
}
#[test]
fn delete_file_sets_pending() {
use crate::message::Message;
let mut state = GitKraft::new();
let _ = state.update(Message::DeleteFile("src/old.rs".to_string()));
assert_eq!(
state.active_tab().pending_delete_file.as_deref(),
Some("src/old.rs")
);
assert!(state.active_tab().context_menu.is_none());
}
#[test]
fn cancel_delete_file_clears_pending() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
let _ = state.update(Message::CancelDeleteFile);
assert!(state.active_tab().pending_delete_file.is_none());
}
#[test]
fn confirm_delete_file_no_repo_is_noop() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().pending_delete_file = Some("src/old.rs".to_string());
let _ = state.update(Message::ConfirmDeleteFile);
assert!(!state.active_tab().is_loading);
}
#[test]
fn shift_arrow_down_extends_file_list_selection_when_files_loaded() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
state.active_tab_mut().commit_files = make_commit_files(&["a.rs", "b.rs", "c.rs"]);
state.active_tab_mut().selected_file_index = Some(0);
state.active_tab_mut().anchor_file_index = Some(0);
state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
let _ = state.update(Message::ShiftArrowDown);
assert_eq!(state.active_tab().selected_file_index, Some(1));
assert!(state.active_tab().selected_commit_file_indices.contains(&0));
assert!(state.active_tab().selected_commit_file_indices.contains(&1));
}
#[test]
fn shift_arrow_down_falls_through_to_commit_log_when_no_files() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().commits = make_test_commits(5);
state.active_tab_mut().selected_commit = Some(1);
state.active_tab_mut().anchor_commit_index = Some(1);
state.keyboard_modifiers = iced::keyboard::Modifiers::SHIFT;
let _ = state.update(Message::ShiftArrowDown);
assert_eq!(state.active_tab().selected_commit, Some(2));
assert!(state.active_tab().selected_commits.contains(&1));
assert!(state.active_tab().selected_commits.contains(&2));
}
#[test]
fn file_system_changed_triggers_full_refresh() {
use crate::message::Message;
let mut state = GitKraft::new();
state.active_tab_mut().repo_path =
Some(std::path::PathBuf::from("/tmp/fake-repo-for-test"));
let _task = state.update(Message::FileSystemChanged);
assert!(
state.active_tab().error_message.is_none(),
"FileSystemChanged must not set an error message"
);
}
fn fake_payload(workdir: &str) -> crate::message::RepoPayload {
gitkraft_core::RepoSnapshot {
info: gitkraft_core::RepoInfo {
path: std::path::PathBuf::from(format!("{workdir}/.git")),
workdir: Some(std::path::PathBuf::from(workdir)),
head_branch: Some("main".into()),
is_bare: false,
state: gitkraft_core::RepoState::Clean,
},
branches: Vec::new(),
commits: Vec::new(),
graph_rows: Vec::new(),
unstaged: Vec::new(),
staged: Vec::new(),
stashes: Vec::new(),
remotes: Vec::new(),
}
}
fn setup_loaded_tab(tab: &mut RepoTab, path: &str) {
tab.repo_path = Some(std::path::PathBuf::from(path));
tab.repo_info = Some(gitkraft_core::RepoInfo {
path: std::path::PathBuf::from(format!("{path}/.git")),
workdir: Some(std::path::PathBuf::from(path)),
head_branch: Some("main".into()),
is_bare: false,
state: gitkraft_core::RepoState::Clean,
});
}
#[test]
fn open_repo_creates_new_tab_when_repo_already_open() {
use crate::message::Message;
let mut state = GitKraft::new();
setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.active_tab, 0);
let _task = state.update(Message::OpenRepo);
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab, 1);
assert!(state.tabs[1].is_loading);
assert_eq!(
state.tabs[0].repo_path.as_deref(),
Some(std::path::Path::new("/home/user/repo-a"))
);
}
#[test]
fn open_repo_reuses_empty_tab() {
use crate::message::Message;
let mut state = GitKraft::new();
assert!(!state.active_tab().has_repo());
let _task = state.update(Message::OpenRepo);
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.active_tab, 0);
assert!(state.tabs[0].is_loading);
}
#[test]
fn repo_selected_deduplicates_already_open_repo() {
use crate::message::Message;
let mut state = GitKraft::new();
setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
state.tabs.push(RepoTab::new_empty());
state.active_tab = 1;
let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
"/home/user/repo-a",
))));
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.active_tab, 0);
assert_eq!(
state.tabs[0].repo_path.as_deref(),
Some(std::path::Path::new("/home/user/repo-a"))
);
}
#[test]
fn repo_selected_opens_new_repo_in_empty_tab() {
use crate::message::Message;
let mut state = GitKraft::new();
setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
state.tabs.push(RepoTab::new_empty());
state.active_tab = 1;
let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
"/home/user/repo-b",
))));
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab, 1);
assert!(state.tabs[1]
.status_message
.as_deref()
.unwrap_or("")
.contains("repo-b"));
}
#[test]
fn repo_selected_cancel_removes_empty_tab() {
use crate::message::Message;
let mut state = GitKraft::new();
setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
state.tabs.push(RepoTab::new_empty());
state.active_tab = 1;
let _task = state.update(Message::RepoSelected(None));
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.active_tab, 0);
assert_eq!(
state.tabs[0].repo_path.as_deref(),
Some(std::path::Path::new("/home/user/repo-a"))
);
}
#[test]
fn repo_selected_cancel_keeps_tab_if_only_one() {
use crate::message::Message;
let mut state = GitKraft::new();
assert_eq!(state.tabs.len(), 1);
assert!(!state.active_tab().has_repo());
let _task = state.update(Message::RepoSelected(None));
assert_eq!(state.tabs.len(), 1);
assert!(!state.active_tab().is_loading);
}
#[test]
fn open_recent_repo_deduplicates() {
use crate::message::Message;
let mut state = GitKraft::new();
setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
state.tabs.push(RepoTab::new_empty());
state.active_tab = 1;
let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
"/home/user/repo-a",
)));
assert_eq!(state.active_tab, 0);
}
#[test]
fn open_recent_repo_creates_new_tab_when_current_has_repo() {
use crate::message::Message;
let mut state = GitKraft::new();
setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
"/home/user/repo-b",
)));
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab, 1);
assert!(state.tabs[1].is_loading);
}
#[test]
fn open_recent_repo_uses_empty_tab() {
use crate::message::Message;
let mut state = GitKraft::new();
assert!(!state.active_tab().has_repo());
let _task = state.update(Message::OpenRecentRepo(std::path::PathBuf::from(
"/home/user/repo-b",
)));
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.active_tab, 0);
assert!(state.tabs[0].is_loading);
}
#[test]
fn repo_refreshed_targets_correct_tab_after_tab_switch() {
use crate::message::Message;
let mut state = GitKraft::new();
setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
state.tabs.push(RepoTab::new_empty());
state.active_tab = 1;
let payload = fake_payload("/home/user/repo-a");
let _task = state.update(Message::RepoRefreshed(Ok(payload)));
assert!(
state.tabs[0].repo_info.is_some(),
"tab 0 should still have repo info after refresh"
);
assert_eq!(
state.tabs[0].current_branch.as_deref(),
Some("main"),
"tab 0 should have updated branch from payload"
);
assert!(
state.tabs[1].repo_info.is_none(),
"tab 1 (empty) must NOT receive the refresh payload"
);
assert!(
state.tabs[1].repo_path.is_none(),
"tab 1 should still have no repo path"
);
}
#[test]
fn repo_refreshed_targets_active_tab_for_new_open() {
use crate::message::Message;
let mut state = GitKraft::new();
assert_eq!(state.tabs.len(), 1);
assert!(!state.active_tab().has_repo());
let payload = fake_payload("/home/user/new-repo");
let _task = state.update(Message::RepoOpened(Ok(payload)));
assert_eq!(
state.tabs[0].repo_path.as_deref(),
Some(std::path::Path::new("/home/user/new-repo"))
);
assert!(state.tabs[0].repo_info.is_some());
}
#[test]
fn repo_refreshed_does_not_duplicate_into_new_tab() {
use crate::message::Message;
let mut state = GitKraft::new();
setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
let _task = state.update(Message::NewTab);
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab, 1);
let payload = fake_payload("/home/user/repo-a");
let _task = state.update(Message::RepoRefreshed(Ok(payload)));
assert!(
state.tabs[1].repo_path.is_none(),
"new empty tab must not receive repo-a refresh"
);
assert!(
state.tabs[1].repo_info.is_none(),
"new empty tab must not have repo_info"
);
assert_eq!(
state.tabs[0].repo_path.as_deref(),
Some(std::path::Path::new("/home/user/repo-a"))
);
}
#[test]
fn git_operation_result_targets_correct_tab() {
use crate::message::Message;
let mut state = GitKraft::new();
setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
state.tabs.push(RepoTab::new_empty());
setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-b");
state.active_tab = 1;
let payload = fake_payload("/home/user/repo-a");
let _task = state.update(Message::GitOperationResult(Ok(payload)));
assert_eq!(state.tabs[0].current_branch.as_deref(), Some("main"));
assert_eq!(
state.tabs[1].repo_path.as_deref(),
Some(std::path::Path::new("/home/user/repo-b"))
);
}
#[test]
fn multiple_new_tabs_dont_get_polluted_by_refresh() {
use crate::message::Message;
let mut state = GitKraft::new();
setup_loaded_tab(state.active_tab_mut(), "/home/user/repo-a");
let _task = state.update(Message::NewTab);
let _task = state.update(Message::NewTab);
assert_eq!(state.tabs.len(), 3);
assert_eq!(state.active_tab, 2);
let payload = fake_payload("/home/user/repo-a");
let _task = state.update(Message::RepoRefreshed(Ok(payload)));
assert!(state.tabs[0].repo_info.is_some());
assert!(state.tabs[1].repo_info.is_none());
assert!(state.tabs[2].repo_info.is_none());
assert!(state.tabs[1].repo_path.is_none());
assert!(state.tabs[2].repo_path.is_none());
}
#[test]
fn repo_selected_dedup_adjusts_index_when_existing_is_after_active() {
use crate::message::Message;
let mut state = GitKraft::new();
state.tabs.push(RepoTab::new_empty());
setup_loaded_tab(&mut state.tabs[1], "/home/user/repo-a");
state.active_tab = 0;
let _task = state.update(Message::RepoSelected(Some(std::path::PathBuf::from(
"/home/user/repo-a",
))));
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.active_tab, 0);
assert_eq!(
state.tabs[0].repo_path.as_deref(),
Some(std::path::Path::new("/home/user/repo-a"))
);
}
#[test]
fn apply_payload_preserves_multi_selection_by_oid() {
let mut tab = RepoTab::new_empty();
tab.commits = make_test_commits(5);
tab.anchor_commit_index = Some(1);
tab.selected_commits = vec![1, 2, 3];
tab.selected_commit = Some(3);
tab.selected_commit_oid = Some(tab.commits[3].oid.clone());
let mut payload = fake_payload("/tmp/repo");
payload.commits = make_test_commits(5);
tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
assert_eq!(tab.anchor_commit_index, Some(1));
assert_eq!(tab.selected_commits, vec![1, 2, 3]);
assert_eq!(tab.selected_commit, Some(3));
}
#[test]
fn apply_payload_preserves_anchor_even_without_range() {
let mut tab = RepoTab::new_empty();
tab.commits = make_test_commits(5);
tab.anchor_commit_index = Some(2);
tab.selected_commit = Some(2);
tab.selected_commit_oid = Some(tab.commits[2].oid.clone());
let mut payload = fake_payload("/tmp/repo");
payload.commits = make_test_commits(5);
tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
assert_eq!(tab.anchor_commit_index, Some(2));
assert_eq!(tab.selected_commit, Some(2));
}
#[test]
fn apply_payload_clears_selection_when_commits_disappear() {
let mut tab = RepoTab::new_empty();
tab.commits = make_test_commits(5);
tab.anchor_commit_index = Some(2);
tab.selected_commits = vec![2, 3, 4];
tab.selected_commit = Some(4);
tab.selected_commit_oid = Some(tab.commits[4].oid.clone());
let mut payload = fake_payload("/tmp/repo");
payload.commits = (0..3)
.map(|i| gitkraft_core::CommitInfo {
oid: format!("new_oid_{i}"),
short_oid: format!("new_{i}"),
summary: format!("new commit {i}"),
message: String::new(),
author_name: "Author".into(),
author_email: "a@b.c".into(),
time: Default::default(),
parent_ids: Vec::new(),
})
.collect();
tab.apply_payload(payload, std::path::PathBuf::from("/tmp/repo"));
assert!(tab.selected_commits.is_empty());
assert!(tab.anchor_commit_index.is_none());
assert!(tab.selected_commit.is_none());
}
}