use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crossterm::event::{MouseEvent, MouseEventKind};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::Span;
use super::{App, Mode, PaneFocus, Refresh};
use crate::git::GitStatus;
use crate::scanner::{self, ScannedFile};
use crate::store::{FileEntry, Store};
use crate::sync;
use crate::ui::tree;
const DATALESS_PREVIEW: &str = "· Online-only file (stored in the cloud, not downloaded).\n\n\
Open it (e / o) to download and edit it.";
fn status_for_file(abs_path: &std::path::Path, entry: Option<&FileEntry>) -> sync::SyncStatus {
if entry.is_none() {
return sync::SyncStatus::NotGisted;
}
if crate::fsutil::is_dataless(abs_path) {
return sync::SyncStatus::Synced;
}
let content = std::fs::read_to_string(abs_path).unwrap_or_default();
sync::local_status(&content, entry)
}
fn compute_status_cache(
files: &[ScannedFile],
store: &Store,
root: &Path,
) -> HashMap<String, sync::SyncStatus> {
let mut cache = HashMap::with_capacity(files.len());
for f in files {
let entry = store.get(root, &f.rel_path);
cache.insert(f.rel_path.clone(), status_for_file(&f.abs_path, entry));
}
cache
}
fn compute_git_status(root: &Path) -> (Option<PathBuf>, HashMap<String, GitStatus>) {
let Some(repo) = crate::git::find_repo_root(root) else {
return (None, HashMap::new());
};
let raw = crate::git::status(&repo);
let prefix = root.strip_prefix(&repo).ok().map(|p| {
let mut s = p.to_string_lossy().to_string();
if !s.is_empty() && !s.ends_with('/') {
s.push('/');
}
s
});
let mut map = HashMap::new();
for (path, st) in raw {
let rel = match &prefix {
Some(p) if !p.is_empty() => match path.strip_prefix(p.as_str()) {
Some(r) => r.to_string(),
None => continue,
},
_ => path,
};
map.insert(rel, st);
}
(Some(repo), map)
}
#[derive(Debug, Default, Clone, Copy)]
struct StatusCounts {
synced: usize,
local_newer: usize,
remote_newer: usize,
conflict: usize,
not_gisted: usize,
}
impl App {
pub(crate) fn rebuild_tree(&mut self) {
self.apply_sort();
let built = tree::build_tree(&self.files, &self.status_cache, &self.git_statuses);
self.tree_items = built.items;
self.tree_identifiers = built.identifiers;
self.tree_file_ids = built.file_ids;
}
fn apply_sort(&mut self) {
use crate::config::SortMode;
match self.config.sort.mode {
SortMode::MtimeDesc => {
self.files.sort_by_key(|f| std::cmp::Reverse(f.modified));
}
SortMode::MtimeAsc => {
self.files.sort_by_key(|f| f.modified);
}
SortMode::AlphaAsc => {
self.files.sort_by(|a, b| a.rel_path.cmp(&b.rel_path));
}
SortMode::AlphaDesc => {
self.files.sort_by(|a, b| b.rel_path.cmp(&a.rel_path));
}
SortMode::Status => {
let cache = &self.status_cache;
self.files.sort_by(|a, b| {
let sa = status_rank_cached(cache.get(&a.rel_path).copied());
let sb = status_rank_cached(cache.get(&b.rel_path).copied());
sa.cmp(&sb).then_with(|| b.modified.cmp(&a.modified))
});
}
}
}
pub fn refresh_files(&mut self) -> color_eyre::Result<()> {
if let Some(entry) = self.current_root_entry().cloned() {
if !entry.path.exists() {
self.status_message =
format!("Root missing: {} (check config.toml)", entry.path.display());
self.files.clear();
self.status_cache.clear();
self.git_statuses.clear();
self.git_repo_root = None;
self.rebuild_tree();
return Ok(());
}
let ignore = scanner::build_globset(&entry.ignore);
self.files = scanner::scan_directory(&entry.path, &ignore)?;
} else {
self.files.clear();
}
self.refresh_status_cache();
self.refresh_git_status();
self.rebuild_tree();
self.update_preview();
Ok(())
}
pub(crate) fn refresh_status_cache(&mut self) {
self.status_cache.clear();
let Some(root) = self.current_root().cloned() else {
return;
};
self.status_cache.reserve(self.files.len());
for f in &self.files {
let entry = self.store.get(&root, &f.rel_path);
let status = status_for_file(&f.abs_path, entry);
self.status_cache.insert(f.rel_path.clone(), status);
}
}
pub(crate) fn refresh_status_for(&mut self, rel_path: &str) {
let Some(root) = self.current_root().cloned() else {
return;
};
let entry = self.store.get(&root, rel_path);
let status = status_for_file(&self.abs_path(rel_path), entry);
self.status_cache.insert(rel_path.to_string(), status);
}
pub fn cached_status(&self, rel_path: &str) -> sync::SyncStatus {
self.status_cache
.get(rel_path)
.copied()
.unwrap_or(sync::SyncStatus::NotGisted)
}
pub(crate) fn refresh_git_status(&mut self) {
let (repo, statuses) = match self.current_root().cloned() {
Some(root) => compute_git_status(&root),
None => (None, HashMap::new()),
};
self.git_repo_root = repo;
self.git_statuses = statuses;
}
pub(crate) fn start_refresh(&mut self, kind: Refresh) {
let (select, only_if_changed, progress, done_message) = match kind {
Refresh::Initial => (None, false, true, None),
Refresh::Sweep => (None, true, false, None),
Refresh::User {
select,
done_message,
} => (select, false, false, done_message),
};
let Some(entry) = self.current_root_entry().cloned() else {
self.files.clear();
self.status_cache.clear();
self.git_statuses.clear();
self.git_repo_root = None;
self.scanning = false;
self.rebuild_tree();
return;
};
if !entry.path.exists() {
self.status_message =
format!("Root missing: {} (check config.toml)", entry.path.display());
self.files.clear();
self.status_cache.clear();
self.git_statuses.clear();
self.git_repo_root = None;
self.scanning = false;
self.rebuild_tree();
return;
}
self.refresh_generation += 1;
let generation = self.refresh_generation;
let root = entry.path.clone();
let ignore_patterns = entry.ignore.clone();
let store = std::sync::Arc::clone(&self.store);
let prev_fingerprint = super::files_fingerprint(&self.files);
let tx = self.async_tx.clone();
let progress_tx = tx.clone();
self.spawn_tracked(async move {
let computed = tokio::task::spawn_blocking(move || {
let ignore = scanner::build_globset(&ignore_patterns);
let files = if progress {
scanner::scan_directory_with_progress(&root, &ignore, &mut |count| {
let _ = progress_tx
.send(crate::event::AsyncEvent::ScanProgress { generation, count });
})
.unwrap_or_default()
} else {
scanner::scan_directory(&root, &ignore).unwrap_or_default()
};
if only_if_changed && super::files_fingerprint(&files) == prev_fingerprint {
return None;
}
let status_cache = compute_status_cache(&files, &store, &root);
let (git_repo_root, git_statuses) = compute_git_status(&root);
Some((root, files, status_cache, git_repo_root, git_statuses))
})
.await;
if let Ok(Some((root, files, status_cache, git_repo_root, git_statuses))) = computed {
let _ = tx.send(crate::event::AsyncEvent::RefreshDone {
generation,
root,
files,
status_cache,
git_repo_root,
git_statuses,
select,
only_if_changed,
done_message,
});
}
});
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn apply_refresh(
&mut self,
generation: u64,
root: PathBuf,
files: Vec<ScannedFile>,
status_cache: HashMap<String, sync::SyncStatus>,
git_repo_root: Option<PathBuf>,
git_statuses: HashMap<String, GitStatus>,
select: Option<String>,
only_if_changed: bool,
done_message: Option<String>,
) {
if generation != self.refresh_generation {
return;
}
if self.current_root().is_none_or(|r| r != &root) {
return;
}
if only_if_changed && !super::files_differ(&self.files, &files) {
return;
}
self.scanning = false;
self.scan_count = 0;
self.files = files;
self.status_cache = status_cache;
self.git_repo_root = git_repo_root;
self.git_statuses = git_statuses;
self.rebuild_tree();
match select {
Some(sel) => self.jump_to(&sel),
None => {
self.update_preview();
if !only_if_changed {
self.update_status();
}
}
}
if let Some(msg) = done_message {
self.status_message = msg;
}
}
pub(crate) fn switch_root(&mut self, index: usize) {
if index < self.config.roots.len() {
self.active_root = index;
self.files.clear();
self.status_cache.clear();
self.git_statuses.clear();
self.git_repo_root = None;
self.rebuild_tree();
self.update_status();
self.start_refresh(super::Refresh::User {
select: None,
done_message: None,
});
}
}
pub fn update_preview(&mut self) {
if let Some(ref rel) = self.selected_file() {
let path = self.abs_path(rel);
self.preview_content = if crate::fsutil::is_dataless(&path) {
DATALESS_PREVIEW.to_string()
} else {
std::fs::read_to_string(&path).unwrap_or_default()
};
} else {
self.preview_content.clear();
}
self.preview_scroll = 0;
}
pub fn handle_mouse(&mut self, event: MouseEvent) {
let over_tree = rect_contains(&self.tree_pane_rect, event.column, event.row);
let over_right = rect_contains(&self.right_pane_rect, event.column, event.row);
match event.kind {
MouseEventKind::ScrollDown => {
if over_tree {
self.tree_state.scroll_down(3);
} else {
self.scroll_right_pane(3, true);
}
}
MouseEventKind::ScrollUp => {
if over_tree {
self.tree_state.scroll_up(3);
} else {
self.scroll_right_pane(3, false);
}
}
MouseEventKind::Down(crossterm::event::MouseButton::Left) => {
if over_tree {
let pos = ratatui::layout::Position {
x: event.column,
y: event.row,
};
if self.tree_state.click_at(pos) {
self.focused_pane = PaneFocus::Tree;
self.update_preview();
self.update_status();
}
} else if over_right {
self.focused_pane = PaneFocus::Right;
}
}
_ => {}
}
}
pub(crate) fn scroll_right_pane(&mut self, lines: u16, down: bool) {
let target = if matches!(self.mode, Mode::Diff { .. }) {
&mut self.diff_scroll
} else {
&mut self.preview_scroll
};
if down {
*target = target.saturating_add(lines);
} else {
*target = target.saturating_sub(lines);
}
}
pub(crate) fn nav_down(&mut self) {
match self.focused_pane {
PaneFocus::Tree => {
self.tree_state.key_down();
self.update_preview();
self.update_status();
}
PaneFocus::Right => self.scroll_right_pane(1, true),
}
}
pub(crate) fn nav_up(&mut self) {
match self.focused_pane {
PaneFocus::Tree => {
self.tree_state.key_up();
self.update_preview();
self.update_status();
}
PaneFocus::Right => self.scroll_right_pane(1, false),
}
}
pub fn update_status(&mut self) {
let current_root = self.current_root().cloned();
let g = crate::glyphs::glyphs();
if let Some(ref rel) = self.selected_file() {
let entry = current_root
.as_ref()
.and_then(|r| self.store.get(r, rel))
.cloned();
let status = self.cached_status(rel);
self.status_color = status.color();
let url = entry.as_ref().map(|e| e.url.as_str()).unwrap_or("no gist");
self.status_message = format!("{} {} | {url}", status.icon(), rel);
let status_color = status.color();
let mut spans = vec![
Span::styled(
format!("{} ", status.icon()),
Style::default().fg(status_color),
),
Span::styled(
rel.to_string(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(" │ ", Style::default().fg(Color::DarkGray)),
];
if entry.is_some() {
spans.push(Span::styled(
url.to_string(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::UNDERLINED),
));
} else {
spans.push(Span::styled(
"no gist".to_string(),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
));
}
self.status_spans = spans;
} else {
let counts = self.status_counts();
self.status_color = if counts.conflict > 0 {
Color::Red
} else if counts.local_newer > 0 {
Color::Yellow
} else if counts.remote_newer > 0 {
Color::Blue
} else if counts.not_gisted > 0 {
Color::DarkGray
} else {
Color::Green
};
let root_label = current_root
.map(|r| r.display().to_string())
.unwrap_or_else(|| "(no root)".into());
self.status_message = format!(
"{} {root_label} | {} {} {} {} {} {} {} {} {} {}",
g.root,
g.status_synced,
counts.synced,
g.status_local_newer,
counts.local_newer,
g.status_remote_newer,
counts.remote_newer,
g.status_conflict,
counts.conflict,
g.status_not_gisted,
counts.not_gisted,
);
let mut spans: Vec<Span<'static>> = Vec::with_capacity(24);
spans.push(Span::styled(
format!("{} ", g.root),
Style::default().fg(Color::Magenta),
));
spans.push(Span::styled(
root_label,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(" │ ", Style::default().fg(Color::DarkGray)));
let cells = [
(
g.status_synced,
sync::SyncStatus::Synced.color(),
counts.synced,
),
(
g.status_local_newer,
sync::SyncStatus::LocalNewer.color(),
counts.local_newer,
),
(
g.status_remote_newer,
sync::SyncStatus::RemoteNewer.color(),
counts.remote_newer,
),
(
g.status_conflict,
sync::SyncStatus::Conflict.color(),
counts.conflict,
),
(
g.status_not_gisted,
sync::SyncStatus::NotGisted.color(),
counts.not_gisted,
),
];
for (i, (icon, color, count)) in cells.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
if *count == 0 {
spans.push(Span::styled(
format!("{icon} {count}"),
Style::default().fg(Color::DarkGray),
));
} else {
spans.push(Span::styled(
format!("{icon} "),
Style::default().fg(*color),
));
spans.push(Span::styled(
count.to_string(),
Style::default().fg(*color).add_modifier(Modifier::BOLD),
));
}
}
self.status_spans = spans;
}
}
fn status_counts(&self) -> StatusCounts {
let mut c = StatusCounts::default();
if self.current_root().is_none() {
c.not_gisted = self.files.len();
return c;
}
for file in &self.files {
match self.cached_status(&file.rel_path) {
sync::SyncStatus::Synced => c.synced += 1,
sync::SyncStatus::LocalNewer => c.local_newer += 1,
sync::SyncStatus::RemoteNewer => c.remote_newer += 1,
sync::SyncStatus::Conflict => c.conflict += 1,
sync::SyncStatus::NotGisted => c.not_gisted += 1,
}
}
c
}
pub(crate) fn jump_to(&mut self, rel_path: &str) {
let mut full_path: Vec<String> = Vec::new();
let mut acc = String::new();
for part in rel_path.split('/') {
if !acc.is_empty() {
acc.push('/');
}
acc.push_str(part);
full_path.push(acc.clone());
}
for depth in 1..full_path.len() {
self.tree_state.open(full_path[..depth].to_vec());
}
self.tree_state.select(full_path);
self.tree_state.scroll_selected_into_view();
self.focused_pane = PaneFocus::Tree;
self.update_preview();
self.update_status();
}
pub(crate) fn jump_to_next_dirty(&mut self, forward: bool) {
if self.current_root().is_none() {
return;
}
if self.files.is_empty() {
return;
}
let current_id = self
.tree_state
.selected()
.last()
.cloned()
.unwrap_or_default();
let cur_idx = self
.files
.iter()
.position(|f| f.rel_path == current_id)
.map(|i| i as isize)
.unwrap_or(-1);
let n = self.files.len() as isize;
let mut next = None;
for step in 1..=n {
let probe = if forward {
((cur_idx + step).rem_euclid(n)) as usize
} else {
((cur_idx - step).rem_euclid(n)) as usize
};
let file = &self.files[probe];
if !matches!(self.cached_status(&file.rel_path), sync::SyncStatus::Synced) {
next = Some(file.rel_path.clone());
break;
}
}
let Some(rel_path) = next else {
self.status_message = "No dirty files.".into();
return;
};
self.jump_to(&rel_path);
}
}
fn status_rank_cached(status: Option<sync::SyncStatus>) -> u8 {
match status.unwrap_or(sync::SyncStatus::NotGisted) {
sync::SyncStatus::Conflict => 0,
sync::SyncStatus::LocalNewer => 1,
sync::SyncStatus::RemoteNewer => 2,
sync::SyncStatus::NotGisted => 3,
sync::SyncStatus::Synced => 4,
}
}
fn rect_contains(rect: &Rect, x: u16, y: u16) -> bool {
rect.width > 0
&& rect.height > 0
&& x >= rect.x
&& x < rect.x + rect.width
&& y >= rect.y
&& y < rect.y + rect.height
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::test_support::{new_for_test, select, write_file};
use crate::config::SortMode;
use crate::event::async_channel;
use crate::scanner::ScannedFile;
use crate::store::{FileEntry, GIST_BACKEND};
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent};
use std::path::PathBuf;
use std::time::{Duration, UNIX_EPOCH};
fn app3() -> (tempfile::TempDir, App) {
let dir = tempfile::tempdir().unwrap();
write_file(dir.path(), "a.md", "alpha");
write_file(dir.path(), "b.md", "beta");
write_file(dir.path(), "sub/c.md", "gamma");
let (tx, rx) = async_channel();
std::mem::forget(rx);
let app = new_for_test(dir.path(), tx);
(dir, app)
}
fn sf(rel: &str, secs: u64) -> ScannedFile {
ScannedFile {
rel_path: rel.to_string(),
abs_path: PathBuf::from(format!("/x/{rel}")),
modified: UNIX_EPOCH + Duration::from_secs(secs),
}
}
fn synced_entry(content: &str) -> FileEntry {
let h = sync::sha256_hex(content);
FileEntry {
backend: GIST_BACKEND.into(),
remote_id: "g".into(),
url: "u".into(),
local_sha256: h.clone(),
remote_sha256: h,
last_synced: chrono::Utc::now(),
remote_updated_at: None,
}
}
#[test]
fn apply_sort_mtime_desc_and_asc() {
let (_d, mut app) = app3();
app.files = vec![sf("old.md", 1), sf("new.md", 9), sf("mid.md", 5)];
app.config.sort.mode = SortMode::MtimeDesc;
app.apply_sort();
let order: Vec<_> = app.files.iter().map(|f| f.rel_path.as_str()).collect();
assert_eq!(order, vec!["new.md", "mid.md", "old.md"]);
app.config.sort.mode = SortMode::MtimeAsc;
app.apply_sort();
let order: Vec<_> = app.files.iter().map(|f| f.rel_path.as_str()).collect();
assert_eq!(order, vec!["old.md", "mid.md", "new.md"]);
}
#[test]
fn apply_sort_alpha_asc_and_desc() {
let (_d, mut app) = app3();
app.files = vec![sf("b.md", 1), sf("a.md", 2), sf("c.md", 3)];
app.config.sort.mode = SortMode::AlphaAsc;
app.apply_sort();
let order: Vec<_> = app.files.iter().map(|f| f.rel_path.as_str()).collect();
assert_eq!(order, vec!["a.md", "b.md", "c.md"]);
app.config.sort.mode = SortMode::AlphaDesc;
app.apply_sort();
let order: Vec<_> = app.files.iter().map(|f| f.rel_path.as_str()).collect();
assert_eq!(order, vec!["c.md", "b.md", "a.md"]);
}
#[test]
fn apply_sort_status_orders_by_rank() {
let (_d, mut app) = app3();
app.files = vec![
sf("synced.md", 1),
sf("conflict.md", 2),
sf("remote.md", 3),
sf("local.md", 4),
];
app.status_cache.clear();
app.status_cache
.insert("synced.md".into(), sync::SyncStatus::Synced);
app.status_cache
.insert("conflict.md".into(), sync::SyncStatus::Conflict);
app.status_cache
.insert("remote.md".into(), sync::SyncStatus::RemoteNewer);
app.status_cache
.insert("local.md".into(), sync::SyncStatus::LocalNewer);
app.config.sort.mode = SortMode::Status;
app.apply_sort();
let order: Vec<_> = app.files.iter().map(|f| f.rel_path.as_str()).collect();
assert_eq!(
order,
vec!["conflict.md", "local.md", "remote.md", "synced.md"]
);
}
#[test]
fn refresh_status_cache_defaults_to_not_gisted() {
let (_d, app) = app3();
assert_eq!(app.cached_status("a.md"), sync::SyncStatus::NotGisted);
assert_eq!(app.cached_status("unknown.md"), sync::SyncStatus::NotGisted);
}
#[test]
fn refresh_status_cache_reflects_store() {
let (_d, mut app) = app3();
let root = app.active_root_path().unwrap();
app.store_mut()
.insert(&root, "a.md".into(), synced_entry("alpha"));
app.store_mut()
.insert(&root, "b.md".into(), synced_entry("stale-content"));
app.refresh_status_cache();
assert_eq!(app.cached_status("a.md"), sync::SyncStatus::Synced);
assert_eq!(app.cached_status("b.md"), sync::SyncStatus::LocalNewer);
assert_eq!(app.cached_status("sub/c.md"), sync::SyncStatus::NotGisted);
}
#[test]
fn refresh_status_for_updates_single_entry() {
let (_d, mut app) = app3();
let root = app.active_root_path().unwrap();
app.store_mut()
.insert(&root, "a.md".into(), synced_entry("alpha"));
app.refresh_status_for("a.md");
assert_eq!(app.cached_status("a.md"), sync::SyncStatus::Synced);
}
#[test]
fn status_counts_tallies_all_buckets() {
let (_d, mut app) = app3();
app.status_cache.clear();
app.status_cache
.insert("a.md".into(), sync::SyncStatus::Synced);
app.status_cache
.insert("b.md".into(), sync::SyncStatus::Conflict);
let c = app.status_counts();
assert_eq!(c.synced, 1);
assert_eq!(c.conflict, 1);
assert_eq!(c.not_gisted, 1);
}
#[test]
fn update_status_dashboard_when_nothing_selected() {
let (_d, mut app) = app3();
app.update_status();
assert!(!app.status_spans.is_empty());
assert_eq!(app.status_color, Color::DarkGray);
}
#[test]
fn update_status_conflict_colors_red() {
let (_d, mut app) = app3();
app.status_cache
.insert("a.md".into(), sync::SyncStatus::Conflict);
app.update_status();
assert_eq!(app.status_color, Color::Red);
}
#[test]
fn update_status_selected_file_shows_path() {
let (_d, mut app) = app3();
select(&mut app, "a.md");
app.update_status();
assert!(app.status_message.contains("a.md"));
assert!(app.status_message.contains("no gist"));
}
#[test]
fn jump_to_expands_ancestors_and_selects_leaf() {
let (_d, mut app) = app3();
app.focused_pane = PaneFocus::Right;
app.jump_to("sub/c.md");
assert_eq!(app.selected_file().as_deref(), Some("sub/c.md"));
assert_eq!(app.focused_pane, PaneFocus::Tree);
}
#[test]
fn jump_to_next_dirty_finds_dirty_file() {
let (_d, mut app) = app3();
app.jump_to_next_dirty(true);
assert!(app.selected_file().is_some());
}
#[test]
fn jump_to_next_dirty_no_dirty_reports() {
let (_d, mut app) = app3();
for f in ["a.md", "b.md", "sub/c.md"] {
app.status_cache.insert(f.into(), sync::SyncStatus::Synced);
}
app.jump_to_next_dirty(true);
assert_eq!(app.status_message, "No dirty files.");
}
#[test]
fn jump_to_next_dirty_wraps_backward() {
let (_d, mut app) = app3();
for f in ["a.md", "b.md", "sub/c.md"] {
app.status_cache.insert(f.into(), sync::SyncStatus::Synced);
}
app.config.sort.mode = SortMode::AlphaAsc;
app.rebuild_tree();
app.status_cache
.insert("b.md".into(), sync::SyncStatus::LocalNewer);
app.jump_to_next_dirty(false);
assert_eq!(app.selected_file().as_deref(), Some("b.md"));
}
fn mouse(kind: MouseEventKind, col: u16, row: u16) -> MouseEvent {
MouseEvent {
kind,
column: col,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn mouse_scroll_over_right_pane_scrolls_preview() {
let (_d, mut app) = app3();
app.tree_pane_rect = Rect::new(0, 0, 10, 20);
app.right_pane_rect = Rect::new(10, 0, 30, 20);
app.handle_mouse(mouse(MouseEventKind::ScrollDown, 20, 5));
assert_eq!(app.preview_scroll, 3);
app.handle_mouse(mouse(MouseEventKind::ScrollUp, 20, 5));
assert_eq!(app.preview_scroll, 0);
}
#[test]
fn mouse_left_click_right_pane_focuses_right() {
let (_d, mut app) = app3();
app.tree_pane_rect = Rect::new(0, 0, 10, 20);
app.right_pane_rect = Rect::new(10, 0, 30, 20);
app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 20, 5));
assert_eq!(app.focused_pane, PaneFocus::Right);
}
#[test]
fn mouse_scroll_over_tree_scrolls_tree_not_preview() {
let (_d, mut app) = app3();
app.tree_pane_rect = Rect::new(0, 0, 10, 20);
app.right_pane_rect = Rect::new(10, 0, 30, 20);
app.handle_mouse(mouse(MouseEventKind::ScrollDown, 5, 5));
assert_eq!(app.preview_scroll, 0);
}
#[test]
fn status_rank_cached_orders_states() {
assert_eq!(status_rank_cached(Some(sync::SyncStatus::Conflict)), 0);
assert_eq!(status_rank_cached(Some(sync::SyncStatus::LocalNewer)), 1);
assert_eq!(status_rank_cached(Some(sync::SyncStatus::RemoteNewer)), 2);
assert_eq!(status_rank_cached(Some(sync::SyncStatus::NotGisted)), 3);
assert_eq!(status_rank_cached(Some(sync::SyncStatus::Synced)), 4);
assert_eq!(status_rank_cached(None), 3);
}
#[test]
fn rect_contains_boundaries() {
let r = Rect::new(2, 3, 4, 5); assert!(rect_contains(&r, 2, 3));
assert!(rect_contains(&r, 5, 7));
assert!(!rect_contains(&r, 6, 3)); assert!(!rect_contains(&r, 2, 8));
assert!(!rect_contains(&r, 1, 3));
assert!(!rect_contains(&Rect::new(0, 0, 0, 0), 0, 0));
}
#[test]
fn scroll_right_pane_saturates_at_zero() {
let (_d, mut app) = app3();
app.preview_scroll = 2;
app.scroll_right_pane(5, false);
assert_eq!(app.preview_scroll, 0);
app.scroll_right_pane(4, true);
assert_eq!(app.preview_scroll, 4);
}
}