use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use anyhow::Result;
use crate::app::{App, ViewMode, ViewState};
use crate::diff::{DiffLine, FileDiff, LineSource};
use crate::gitignore::GitignoreFilter;
use crate::image_diff::ImageCache;
use crate::vcs::{ComparisonContext, RefreshResult, StackPosition, VcsBackend, VcsEventType, VcsWatchPaths};
pub struct TestAppBuilder {
lines: Vec<DiffLine>,
files: Vec<FileDiff>,
viewport_height: usize,
view_mode: ViewMode,
scroll_offset: usize,
base_branch: String,
current_branch: Option<String>,
stack_position: Option<StackPosition>,
vcs_backend: VcsBackend,
}
impl Default for TestAppBuilder {
fn default() -> Self {
Self::new()
}
}
impl TestAppBuilder {
pub fn new() -> Self {
Self {
lines: Vec::new(),
files: Vec::new(),
viewport_height: 10,
view_mode: ViewMode::Full,
scroll_offset: 0,
base_branch: "main".to_string(),
current_branch: Some("feature".to_string()),
stack_position: None,
vcs_backend: VcsBackend::Git,
}
}
pub fn with_lines(mut self, lines: Vec<DiffLine>) -> Self {
self.lines = lines;
self
}
pub fn with_files(mut self, files: Vec<FileDiff>) -> Self {
self.lines = files.iter().flat_map(|f| f.lines.clone()).collect();
self.files = files;
self
}
pub fn with_viewport_height(mut self, height: usize) -> Self {
self.viewport_height = height;
self
}
pub fn with_view_mode(mut self, mode: ViewMode) -> Self {
self.view_mode = mode;
self
}
pub fn with_scroll_offset(mut self, offset: usize) -> Self {
self.scroll_offset = offset;
self
}
pub fn with_base_branch(mut self, branch: &str) -> Self {
self.base_branch = branch.to_string();
self
}
pub fn with_current_branch(mut self, branch: Option<&str>) -> Self {
self.current_branch = branch.map(|s| s.to_string());
self
}
pub fn with_stack_position(mut self, pos: StackPosition) -> Self {
self.stack_position = Some(pos);
self
}
pub fn with_vcs_backend(mut self, backend: VcsBackend) -> Self {
self.vcs_backend = backend;
self
}
pub fn build(self) -> App {
let repo_path = PathBuf::from("/tmp/test");
let to_label = self.current_branch.unwrap_or_else(|| "HEAD".to_string());
App {
gitignore_filter: GitignoreFilter::new(&repo_path),
repo_path,
comparison: ComparisonContext {
from_label: self.base_branch,
to_label,
stack_position: self.stack_position,
vcs_backend: self.vcs_backend,
bookmark_name: None,
divergence: None,
},
base_identifier: "abc123".to_string(),
files: self.files,
lines: self.lines,
error: None,
conflict_warning: None,
performance_warning: None,
file_links: HashMap::new(),
image_cache: ImageCache::new(),
image_picker: None,
font_size: (crate::image_diff::FONT_WIDTH_PX as u16, crate::image_diff::FONT_HEIGHT_PX as u16),
search: None,
diff_base: crate::vcs::DiffBase::default(),
view: ViewState {
scroll_offset: self.scroll_offset,
viewport_height: self.viewport_height,
view_mode: self.view_mode,
content_offset: (1, 1),
line_num_width: 0,
content_width: 80,
panel_width: 80,
show_help: false,
selection: None,
word_selection_anchor: None,
line_selection_anchor: None,
row_map: Vec::new(),
collapsed_files: Default::default(),
manually_toggled: Default::default(),
reviewed_files: Default::default(),
reviewed_flash: None,
needs_inline_spans: true,
path_copied_at: None,
last_click: None,
pending_copy: None,
status_bar_lines: Vec::new(),
status_bar_screen_y: 0,
},
}
}
}
pub fn base_line(content: &str) -> DiffLine {
DiffLine::new(LineSource::Base, content.to_string(), ' ', None)
}
pub fn change_line(content: &str) -> DiffLine {
DiffLine::new(LineSource::Committed, content.to_string(), '+', None)
}
pub fn staged_line(content: &str) -> DiffLine {
DiffLine::new(LineSource::Staged, content.to_string(), '+', None)
}
pub fn unstaged_line(content: &str) -> DiffLine {
DiffLine::new(LineSource::Unstaged, content.to_string(), '+', None)
}
pub fn deletion_line(content: &str) -> DiffLine {
DiffLine::new(LineSource::DeletedBase, content.to_string(), '-', None)
}
pub fn base_lines(count: usize) -> Vec<DiffLine> {
(0..count).map(|i| base_line(&format!("line{}", i))).collect()
}
pub struct StubVcs {
repo_path: PathBuf,
}
impl StubVcs {
pub fn new(repo_path: PathBuf) -> Self {
Self { repo_path }
}
}
impl crate::vcs::Vcs for StubVcs {
fn repo_path(&self) -> &Path { &self.repo_path }
fn comparison_context(&self) -> Result<ComparisonContext> { unimplemented!() }
fn refresh(&self, _: &Arc<AtomicBool>) -> Result<RefreshResult> { unimplemented!() }
fn single_file_diff(&self, _: &str) -> Option<FileDiff> { unimplemented!() }
fn base_identifier(&self) -> Result<String> { unimplemented!() }
fn base_file_bytes(&self, _: &str) -> Result<Option<Vec<u8>>> { Ok(None) }
fn working_file_bytes(&self, _: &str) -> Result<Option<Vec<u8>>> { Ok(None) }
fn binary_files(&self) -> HashSet<String> { HashSet::new() }
fn fetch(&self) -> Result<()> { unimplemented!() }
fn has_conflicts(&self) -> Result<bool> { unimplemented!() }
fn is_locked(&self) -> bool {
self.repo_path.join(".git/index.lock").exists()
}
fn watch_paths(&self) -> VcsWatchPaths {
VcsWatchPaths { files: vec![], recursive_dirs: vec![] }
}
fn classify_event(&self, path: &Path) -> VcsEventType {
let relative = path.strip_prefix(&self.repo_path).unwrap_or(path);
let is_vcs_path = relative.components().next()
.is_some_and(|c| c.as_os_str() == ".git");
if !is_vcs_path { return VcsEventType::Source; }
if relative.extension().is_some_and(|ext| ext == "lock") {
return VcsEventType::Lock;
}
if relative == Path::new(".git/HEAD") {
return VcsEventType::RevisionChange;
}
let path_str = relative.to_string_lossy();
if path_str.contains("refs/") {
VcsEventType::RevisionChange
} else {
VcsEventType::Internal
}
}
fn backend(&self) -> VcsBackend { VcsBackend::Git }
fn current_revision_id(&self) -> Result<String> { Ok("stub_revision".to_string()) }
}