use std::cell::Cell;
use std::collections::VecDeque;
use crate::jj::JjExecutor;
use crate::model::{Change, CommandHistory, DiffContent, Notification};
use crate::ui::components::Dialog;
use crate::ui::views::{
BlameView, BookmarkView, CommandHistoryView, DiffView, EvologView, LogView, OperationView,
ResolveView, StatusView, TagView,
};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct DirtyFlags {
pub log: bool,
pub status: bool,
pub op_log: bool,
pub bookmarks: bool,
}
impl DirtyFlags {
pub fn log() -> Self {
Self {
log: true,
op_log: true,
..Default::default()
}
}
pub fn log_and_status() -> Self {
Self {
log: true,
status: true,
op_log: true,
..Default::default()
}
}
pub fn log_and_bookmarks() -> Self {
Self {
log: true,
bookmarks: true,
op_log: true,
..Default::default()
}
}
pub fn all() -> Self {
Self {
log: true,
status: true,
op_log: true,
bookmarks: true,
}
}
}
const PREVIEW_CACHE_CAPACITY: usize = 8;
#[derive(Debug)]
pub(crate) struct PreviewCacheEntry {
pub change_id: String,
pub commit_id: String,
pub content: DiffContent,
pub bookmarks: Vec<String>,
}
#[derive(Debug)]
pub(crate) struct PreviewCache {
entries: VecDeque<PreviewCacheEntry>,
capacity: usize,
}
impl PreviewCache {
pub fn new() -> Self {
Self {
entries: VecDeque::new(),
capacity: PREVIEW_CACHE_CAPACITY,
}
}
pub fn peek(&self, change_id: &str) -> Option<&PreviewCacheEntry> {
self.entries.iter().find(|e| e.change_id == change_id)
}
pub fn touch(&mut self, change_id: &str) {
if let Some(pos) = self.entries.iter().position(|e| e.change_id == change_id) {
let entry = self.entries.remove(pos).unwrap();
self.entries.push_back(entry);
}
}
pub fn insert(&mut self, entry: PreviewCacheEntry) {
self.entries.retain(|e| e.change_id != entry.change_id);
if self.entries.len() >= self.capacity {
self.entries.pop_front();
}
self.entries.push_back(entry);
}
pub fn remove(&mut self, change_id: &str) {
self.entries.retain(|e| e.change_id != change_id);
}
pub fn validate(&mut self, changes: &[Change]) {
self.entries.retain_mut(|entry| {
if let Some(change) = changes
.iter()
.filter(|c| !c.is_graph_only)
.find(|c| c.change_id == entry.change_id)
{
if change.commit_id == entry.commit_id {
entry.bookmarks = change.bookmarks.clone();
true
} else {
false
}
} else {
false
}
});
}
pub fn clear(&mut self) {
self.entries.clear();
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.entries.len()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum View {
#[default]
Log,
Diff,
Status,
Operation,
Blame,
Resolve,
Bookmark,
Tag,
Evolog,
CommandHistory,
Help,
}
#[derive(Debug)]
pub struct App {
pub running: bool,
pub current_view: View,
pub(crate) previous_view: Option<View>,
pub log_view: LogView,
pub diff_view: Option<DiffView>,
pub blame_view: Option<BlameView>,
pub resolve_view: Option<ResolveView>,
pub evolog_view: Option<EvologView>,
pub bookmark_view: BookmarkView,
pub tag_view: TagView,
pub command_history_view: CommandHistoryView,
pub status_view: StatusView,
pub operation_view: OperationView,
pub jj: JjExecutor,
pub error_message: Option<String>,
pub notification: Option<Notification>,
pub(crate) last_frame_height: Cell<u16>,
pub active_dialog: Option<Dialog>,
pub(crate) pending_push_bookmarks: Vec<String>,
pub(crate) pending_forget_bookmark: Option<String>,
pub(crate) pending_jump_change_id: Option<String>,
pub preview_enabled: bool,
pub(crate) preview_auto_disabled: bool,
pub(crate) preview_cache: PreviewCache,
pub(crate) preview_pending_id: Option<String>,
pub(crate) push_target_remote: Option<String>,
pub(crate) help_scroll: u16,
pub(crate) help_search_query: Option<String>,
pub(crate) help_search_input: bool,
pub(crate) help_input_buffer: String,
pub(crate) dirty: DirtyFlags,
pub(crate) command_history: CommandHistory,
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
impl App {
fn init() -> Self {
Self {
running: true,
current_view: View::Log,
previous_view: None,
log_view: LogView::new(),
diff_view: None,
blame_view: None,
resolve_view: None,
evolog_view: None,
bookmark_view: BookmarkView::new(),
tag_view: TagView::new(),
command_history_view: CommandHistoryView::new(),
status_view: StatusView::new(),
operation_view: OperationView::new(),
jj: JjExecutor::new(),
error_message: None,
notification: None,
last_frame_height: Cell::new(24), active_dialog: None,
pending_push_bookmarks: Vec::new(),
pending_forget_bookmark: None,
pending_jump_change_id: None,
preview_enabled: true,
preview_auto_disabled: false,
preview_cache: PreviewCache::new(),
preview_pending_id: None,
push_target_remote: None,
help_scroll: 0,
help_search_query: None,
help_search_input: false,
help_input_buffer: String::new(),
dirty: DirtyFlags {
log: false, status: true,
op_log: true,
bookmarks: true,
},
command_history: CommandHistory::new(),
}
}
pub fn new() -> Self {
let mut app = Self::init();
app.refresh_log(None);
app.update_preview_if_needed();
app.resolve_pending_preview();
app
}
#[cfg(test)]
pub fn new_for_test() -> Self {
Self::init()
}
pub(crate) fn next_view(&mut self) {
let next = match self.current_view {
View::Log => View::Status,
View::Status => View::Log,
View::Diff => View::Log,
View::Operation => View::Log,
View::Blame => View::Log,
View::Resolve => View::Log,
View::Bookmark => View::Log,
View::Evolog => View::Log,
View::Tag => View::Log,
View::CommandHistory => View::Log,
View::Help => View::Log,
};
self.go_to_view(next);
}
pub(crate) fn go_to_view(&mut self, view: View) {
if self.current_view != view {
if self.current_view == View::Log {
self.preview_pending_id = None;
}
self.previous_view = Some(self.current_view);
self.current_view = view;
match view {
View::Log if self.dirty.log => {
let revset = self.log_view.current_revset.clone();
self.refresh_log(revset.as_deref());
self.dirty.log = false;
}
View::Status if self.dirty.status => {
self.refresh_status();
self.dirty.status = false;
}
View::Operation if self.dirty.op_log => {
self.refresh_operation_log();
self.dirty.op_log = false;
}
View::Bookmark if self.dirty.bookmarks => {
self.refresh_bookmark_view();
self.dirty.bookmarks = false;
}
View::Help => {
self.help_scroll = 0;
self.help_search_query = None;
self.help_search_input = false;
self.help_input_buffer.clear();
}
_ => {}
}
}
}
pub(crate) fn go_back(&mut self) {
let target = self.previous_view.take().unwrap_or(View::Log);
self.go_to_view(target);
}
pub(crate) fn quit(&mut self) {
self.running = false;
}
pub(crate) fn clear_expired_notification(&mut self) {
if let Some(ref notification) = self.notification
&& notification.is_expired()
{
self.notification = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dirty_flags_log_includes_op_log() {
let flags = DirtyFlags::log();
assert!(flags.log);
assert!(flags.op_log);
assert!(!flags.status);
assert!(!flags.bookmarks);
}
#[test]
fn dirty_flags_log_and_status_includes_op_log() {
let flags = DirtyFlags::log_and_status();
assert!(flags.log);
assert!(flags.status);
assert!(flags.op_log);
assert!(!flags.bookmarks);
}
#[test]
fn dirty_flags_log_and_bookmarks_includes_op_log() {
let flags = DirtyFlags::log_and_bookmarks();
assert!(flags.log);
assert!(!flags.status);
assert!(flags.op_log);
assert!(flags.bookmarks);
}
#[test]
fn dirty_flags_all_sets_everything() {
let flags = DirtyFlags::all();
assert!(flags.log);
assert!(flags.status);
assert!(flags.op_log);
assert!(flags.bookmarks);
}
#[test]
fn dirty_flags_default_is_all_false() {
let flags = DirtyFlags::default();
assert!(!flags.log);
assert!(!flags.status);
assert!(!flags.op_log);
assert!(!flags.bookmarks);
}
#[test]
fn go_to_view_status_skips_refresh_when_not_dirty() {
let mut app = App::new_for_test();
app.dirty.status = false;
app.go_to_view(View::Status);
assert_eq!(app.current_view, View::Status);
}
#[test]
fn go_to_view_operation_skips_refresh_when_not_dirty() {
let mut app = App::new_for_test();
app.dirty.op_log = false;
app.go_to_view(View::Operation);
assert_eq!(app.current_view, View::Operation);
}
#[test]
fn go_back_sets_previous_view() {
let mut app = App::new_for_test();
app.go_to_view(View::Help);
assert_eq!(app.current_view, View::Help);
assert_eq!(app.previous_view, Some(View::Log));
app.go_back();
assert_eq!(app.current_view, View::Log);
assert_eq!(app.previous_view, Some(View::Help));
}
#[test]
fn go_back_defaults_to_log_when_no_previous() {
let mut app = App::new_for_test();
app.current_view = View::Diff;
app.previous_view = None;
app.go_back();
assert_eq!(app.current_view, View::Log);
}
#[test]
fn init_dirty_flags() {
let app = App::new_for_test();
assert!(!app.dirty.log);
assert!(app.dirty.status);
assert!(app.dirty.op_log);
assert!(app.dirty.bookmarks);
}
fn make_entry(change_id: &str, commit_id: &str) -> PreviewCacheEntry {
PreviewCacheEntry {
change_id: change_id.to_string(),
commit_id: commit_id.to_string(),
content: crate::model::DiffContent::default(),
bookmarks: vec![],
}
}
#[test]
fn preview_cache_insert_and_peek() {
let mut cache = PreviewCache::new();
assert_eq!(cache.len(), 0);
cache.insert(make_entry("aaa", "c1"));
assert_eq!(cache.len(), 1);
assert!(cache.peek("aaa").is_some());
assert!(cache.peek("bbb").is_none());
}
#[test]
fn preview_cache_insert_replaces_same_change_id() {
let mut cache = PreviewCache::new();
cache.insert(make_entry("aaa", "c1"));
cache.insert(make_entry("aaa", "c2"));
assert_eq!(cache.len(), 1);
assert_eq!(cache.peek("aaa").unwrap().commit_id, "c2");
}
#[test]
fn preview_cache_evicts_lru_at_capacity() {
let mut cache = PreviewCache::new();
for i in 0..8 {
cache.insert(make_entry(&format!("id{}", i), &format!("c{}", i)));
}
assert_eq!(cache.len(), 8);
cache.insert(make_entry("id8", "c8"));
assert_eq!(cache.len(), 8);
assert!(cache.peek("id0").is_none());
assert!(cache.peek("id8").is_some());
}
#[test]
fn preview_cache_touch_promotes_to_mru() {
let mut cache = PreviewCache::new();
for i in 0..8 {
cache.insert(make_entry(&format!("id{}", i), &format!("c{}", i)));
}
cache.touch("id0");
cache.insert(make_entry("id8", "c8"));
assert_eq!(cache.len(), 8);
assert!(cache.peek("id0").is_some()); assert!(cache.peek("id1").is_none()); }
#[test]
fn preview_cache_remove() {
let mut cache = PreviewCache::new();
cache.insert(make_entry("aaa", "c1"));
cache.insert(make_entry("bbb", "c2"));
assert_eq!(cache.len(), 2);
cache.remove("aaa");
assert_eq!(cache.len(), 1);
assert!(cache.peek("aaa").is_none());
assert!(cache.peek("bbb").is_some());
}
#[test]
fn preview_cache_clear() {
let mut cache = PreviewCache::new();
cache.insert(make_entry("aaa", "c1"));
cache.insert(make_entry("bbb", "c2"));
cache.clear();
assert_eq!(cache.len(), 0);
}
#[test]
fn preview_cache_validate_keeps_matching() {
let mut cache = PreviewCache::new();
cache.insert(PreviewCacheEntry {
change_id: "aaa".to_string(),
commit_id: "c1".to_string(),
content: crate::model::DiffContent::default(),
bookmarks: vec!["old-bm".to_string()],
});
let changes = vec![Change {
change_id: crate::model::ChangeId::new("aaa".to_string()),
commit_id: crate::model::CommitId::new("c1".to_string()),
bookmarks: vec!["new-bm".to_string()],
..Change::default()
}];
cache.validate(&changes);
assert_eq!(cache.len(), 1);
assert_eq!(
cache.peek("aaa").unwrap().bookmarks,
vec!["new-bm".to_string()]
);
}
#[test]
fn preview_cache_validate_evicts_stale_commit() {
let mut cache = PreviewCache::new();
cache.insert(make_entry("aaa", "c1"));
let changes = vec![Change {
change_id: crate::model::ChangeId::new("aaa".to_string()),
commit_id: crate::model::CommitId::new("c2".to_string()), ..Change::default()
}];
cache.validate(&changes);
assert_eq!(cache.len(), 0);
}
#[test]
fn preview_cache_validate_evicts_absent() {
let mut cache = PreviewCache::new();
cache.insert(make_entry("aaa", "c1"));
cache.validate(&[]);
assert_eq!(cache.len(), 0);
}
#[test]
fn preview_cache_validate_skips_graph_only() {
let mut cache = PreviewCache::new();
cache.insert(make_entry("aaa", "c1"));
let changes = vec![Change {
change_id: crate::model::ChangeId::new("aaa".to_string()),
commit_id: crate::model::CommitId::new("c1".to_string()),
is_graph_only: true,
..Change::default()
}];
cache.validate(&changes);
assert_eq!(cache.len(), 0);
}
#[test]
fn update_preview_schedules_pending_on_cache_miss() {
let mut app = App::new_for_test();
app.log_view.set_changes(vec![Change {
change_id: crate::model::ChangeId::new("aaa".to_string()),
commit_id: crate::model::CommitId::new("c1".to_string()),
..Change::default()
}]);
app.preview_enabled = true;
assert!(app.preview_pending_id.is_none());
app.update_preview_if_needed();
assert_eq!(app.preview_pending_id.as_deref(), Some("aaa"));
}
#[test]
fn update_preview_skips_when_cache_hit() {
let mut app = App::new_for_test();
app.log_view.set_changes(vec![Change {
change_id: crate::model::ChangeId::new("aaa".to_string()),
commit_id: crate::model::CommitId::new("c1".to_string()),
..Change::default()
}]);
app.preview_enabled = true;
app.preview_cache.insert(make_entry("aaa", "c1"));
app.update_preview_if_needed();
assert!(app.preview_pending_id.is_none());
}
#[test]
fn update_preview_schedules_on_stale_commit_id() {
let mut app = App::new_for_test();
app.log_view.set_changes(vec![Change {
change_id: crate::model::ChangeId::new("aaa".to_string()),
commit_id: crate::model::CommitId::new("c2".to_string()), ..Change::default()
}]);
app.preview_enabled = true;
app.preview_cache.insert(make_entry("aaa", "c1"));
app.update_preview_if_needed();
assert_eq!(app.preview_pending_id.as_deref(), Some("aaa"));
}
#[test]
fn update_preview_noop_when_disabled() {
let mut app = App::new_for_test();
app.log_view.set_changes(vec![Change {
change_id: crate::model::ChangeId::new("aaa".to_string()),
commit_id: crate::model::CommitId::new("c1".to_string()),
..Change::default()
}]);
app.preview_enabled = false;
app.update_preview_if_needed();
assert!(app.preview_pending_id.is_none());
}
}