use super::{App, ViewMode};
use crate::app::menu::MenuHost;
use crate::app::utils::copy_to_clipboard;
use crate::config::{list_ui_themes, ThemeConfig};
use crate::syntax::list_syntax_themes;
use crate::toasts::ToastEvent;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PaletteAction {
ToggleMenuBar,
OpenMenu,
OpenCommandPalette,
NextStep,
PreviousStep,
SyncReview,
ToggleStepping,
ToggleViewMode,
SetViewMode(ViewMode),
ToggleLineWrap,
ToggleFoldContext,
ToggleSyntax,
ToggleHelp,
OpenSettings,
ToggleZen,
ToggleFilePanel,
ToggleFileReviewed,
ToggleReviewedFilesVisibility,
ToggleIncrementalReview,
ToggleAutoplay,
ToggleAutoplayReverse,
OpenDashboard,
OpenRemoteReview,
NavigateBack,
NavigateForward,
OpenFileSearch,
OpenReviewGrep,
OpenReviewGrepChanges,
OpenReviewGrepEverything,
OpenThemePicker,
OpenCommentPicker,
OpenPrComments,
OpenOutdatedComments,
CopyFilePath,
CopyFilePosition,
CopyAbsoluteFilePath,
CopyAbsoluteFilePosition,
CopyFileCursorPosition,
CopyAbsoluteFileCursorPosition,
CopySessionName,
RenameSession,
Quit,
RefreshCurrentFile,
RefreshAllFiles,
ReviewAction(usize),
}
#[derive(Clone, Debug)]
pub(crate) struct PaletteEntry {
pub label: String,
pub action: PaletteAction,
}
pub(crate) fn standard_command_entries() -> Vec<PaletteEntry> {
use PaletteAction::*;
let entries: Vec<_> = [
("Toggle menu bar", ToggleMenuBar),
("Open menu", OpenMenu),
("Command palette...", OpenCommandPalette),
("Toggle step mode", ToggleStepping),
("Step forward", NextStep),
("Step backward", PreviousStep),
("Cycle view modes", ToggleViewMode),
("View: Unified", SetViewMode(ViewMode::UnifiedPane)),
("View: Split", SetViewMode(ViewMode::Split)),
("View: Evolution", SetViewMode(ViewMode::Evolution)),
("View: Preview", SetViewMode(ViewMode::Preview)),
("Toggle line wrap", ToggleLineWrap),
("Cycle context folding", ToggleFoldContext),
("Toggle syntax highlight", ToggleSyntax),
("Open help", ToggleHelp),
("Open settings", OpenSettings),
("Toggle zen mode", ToggleZen),
("Toggle file panel", ToggleFilePanel),
("Refresh all files", RefreshAllFiles),
("Open remote review...", OpenRemoteReview),
("History...", OpenDashboard),
("Navigate back", NavigateBack),
("Navigate forward", NavigateForward),
("Files...", OpenFileSearch),
("Find in files\u{2026}", OpenReviewGrep),
("Find in files: changes\u{2026}", OpenReviewGrepChanges),
("Find in files: all\u{2026}", OpenReviewGrepEverything),
("Copy file path", CopyFilePath),
("Copy file path and position", CopyFilePosition),
("Copy absolute file path", CopyAbsoluteFilePath),
(
"Copy absolute file path and position",
CopyAbsoluteFilePosition,
),
("Copy file path and cursor position", CopyFileCursorPosition),
(
"Copy absolute file path and cursor position",
CopyAbsoluteFileCursorPosition,
),
("Themes...", OpenThemePicker),
("Refresh current file", RefreshCurrentFile),
("Copy session name", CopySessionName),
("Rename session", RenameSession),
("Toggle autoplay", ToggleAutoplay),
("Toggle autoplay (reverse)", ToggleAutoplayReverse),
("Comments...", OpenCommentPicker),
("Outdated comments", OpenOutdatedComments),
("Sync review", SyncReview),
("Quit", Quit),
]
.into_iter()
.map(|(label, action)| PaletteEntry {
label: label.to_string(),
action,
})
.collect();
entries
}
impl App {
pub(crate) fn start_history(&mut self) {
if !self.request_settings_leave(super::settings::SettingsLeaveTarget::OpenHistory) {
self.open_dashboard = true;
}
}
pub(crate) fn set_control_session_name(&mut self, name: Option<String>) {
self.control_session_name = name;
}
pub(crate) fn control_session_name(&self) -> Option<&str> {
self.control_session_name.as_deref()
}
pub(crate) fn copy_current_file_path(&mut self) {
let path = self.current_file_path();
if path.is_empty() {
self.notify(ToastEvent::CopyFailed);
} else if copy_to_clipboard(&path) {
self.notify(ToastEvent::CopiedPath);
} else {
self.notify(ToastEvent::CopyFailed);
}
}
pub(crate) fn copy_current_file_position(&mut self) {
let Some(location) = self.current_file_relative_position_label() else {
self.notify(ToastEvent::CopyFailed);
return;
};
if copy_to_clipboard(&location) {
self.notify(ToastEvent::CopiedPath);
} else {
self.notify(ToastEvent::CopyFailed);
}
}
pub(crate) fn copy_current_file_cursor_position(&mut self) {
let Some(location) = self.current_file_cursor_position_label() else {
self.notify(ToastEvent::CopyFailed);
return;
};
if copy_to_clipboard(&location) {
self.notify(ToastEvent::CopiedPath);
} else {
self.notify(ToastEvent::CopyFailed);
}
}
pub(crate) fn copy_current_absolute_file_path(&mut self) {
let Some(path) = self.current_file_absolute_path_label() else {
self.notify(ToastEvent::CopyFailed);
return;
};
if copy_to_clipboard(&path) {
self.notify(ToastEvent::CopiedPath);
} else {
self.notify(ToastEvent::CopyFailed);
}
}
pub(crate) fn copy_current_absolute_file_position(&mut self) {
let Some(location) = self.current_file_absolute_position_label() else {
self.notify(ToastEvent::CopyFailed);
return;
};
if copy_to_clipboard(&location) {
self.notify(ToastEvent::CopiedPath);
} else {
self.notify(ToastEvent::CopyFailed);
}
}
pub(crate) fn copy_current_absolute_file_cursor_position(&mut self) {
let Some(location) = self.current_file_absolute_cursor_position_label() else {
self.notify(ToastEvent::CopyFailed);
return;
};
if copy_to_clipboard(&location) {
self.notify(ToastEvent::CopiedPath);
} else {
self.notify(ToastEvent::CopyFailed);
}
}
pub(crate) fn copy_control_session_name(&mut self) {
let Some(name) = self.control_session_name.clone() else {
return;
};
if copy_to_clipboard(&name) {
self.notify(ToastEvent::CopiedSessionName);
} else {
self.notify(ToastEvent::CopyFailed);
}
}
pub(crate) fn start_session_rename(&mut self) {
self.session_rename_footer_hits.clear();
let Some(name) = self.control_session_name.clone() else {
return;
};
self.session_rename_active = true;
self.session_rename_query = name;
self.reset_picker_cursor();
self.file_filter_active = false;
self.clear_search();
self.clear_goto();
self.stop_file_search();
self.stop_comment_picker();
self.stop_theme_picker();
}
pub(crate) fn session_rename_active(&self) -> bool {
self.session_rename_active
}
pub(crate) fn session_rename_query(&self) -> &str {
&self.session_rename_query
}
pub(crate) fn cancel_session_rename(&mut self) {
self.session_rename_footer_hits.clear();
self.session_rename_active = false;
self.session_rename_query.clear();
}
pub(crate) fn clear_session_rename_text(&mut self) {
self.session_rename_query.clear();
self.reset_picker_cursor();
}
pub(crate) fn push_session_rename_char(&mut self, ch: char) {
self.session_rename_query.push(ch);
self.reset_picker_cursor();
}
pub(crate) fn pop_session_rename_char(&mut self) {
self.session_rename_query.pop();
self.reset_picker_cursor();
}
pub(crate) fn submit_session_rename(&mut self) {
self.session_rename_footer_hits.clear();
let name = self.session_rename_query.trim();
if name.is_empty() {
self.notify(ToastEvent::SelectionActionFailed(
"Session name cannot be empty".to_string(),
));
return;
}
self.pending_session_rename = Some(name.to_string());
self.session_rename_active = false;
}
pub(crate) fn apply_session_rename_action(
&mut self,
action: crate::keybindings::LineInputAction,
) {
use crate::keybindings::LineInputAction;
if !self.session_rename_active() {
return;
}
self.session_rename_footer_hits.clear();
match action {
LineInputAction::Cancel => self.cancel_session_rename(),
LineInputAction::Accept => self.submit_session_rename(),
LineInputAction::Backspace => self.pop_session_rename_char(),
LineInputAction::Clear => self.clear_session_rename_text(),
}
}
pub(crate) fn handle_session_rename_click(&mut self, column: u16, row: u16) {
if let Some(action) =
crate::footer::action_at(&self.session_rename_footer_hits, column, row)
{
self.keybindings.clear_sequence();
self.apply_session_rename_action(action);
}
}
pub(crate) fn take_pending_session_rename(&mut self) -> Option<String> {
self.pending_session_rename.take()
}
pub(crate) fn reset_picker_cursor(&mut self) {
self.file_filter_cursor_visible = true;
self.file_filter_cursor_last_blink = std::time::Instant::now();
}
pub fn start_command_palette(&mut self) {
self.command_palette_active = true;
self.command_palette_query.clear();
self.command_palette_selection = 0;
self.command_palette_rendered_actions.clear();
self.reset_picker_cursor();
self.file_filter_active = false;
self.clear_search();
self.clear_goto();
self.stop_file_search();
self.stop_review_grep();
self.stop_comment_picker();
self.stop_theme_picker();
}
pub fn stop_command_palette(&mut self) {
self.command_palette_active = false;
}
pub fn command_palette_active(&self) -> bool {
self.command_palette_active
}
pub fn command_palette_query(&self) -> &str {
&self.command_palette_query
}
pub fn command_palette_selection(&self) -> usize {
self.command_palette_selection
}
pub fn push_command_palette_char(&mut self, ch: char) {
self.command_palette_query.push(ch);
self.command_palette_selection = 0;
self.command_palette_rendered_actions.clear();
self.reset_picker_cursor();
}
pub fn pop_command_palette_char(&mut self) {
self.command_palette_query.pop();
self.command_palette_selection = 0;
self.command_palette_rendered_actions.clear();
self.reset_picker_cursor();
}
pub fn clear_command_palette_text(&mut self) {
self.command_palette_query.clear();
self.command_palette_selection = 0;
self.command_palette_rendered_actions.clear();
self.reset_picker_cursor();
}
pub fn move_command_palette_selection(&mut self, delta: isize) {
let entries = self.command_palette_filtered_entries();
let total = entries.len();
if total == 0 {
self.command_palette_selection = 0;
return;
}
let current = self.command_palette_selection.min(total.saturating_sub(1)) as isize;
let next = (current + delta).clamp(0, total.saturating_sub(1) as isize);
self.command_palette_selection = next as usize;
}
pub fn apply_command_palette_selection(&mut self) {
if self.command_palette_rendered_actions.is_empty() {
self.command_palette_filtered_entries();
}
let Some(action) = self
.command_palette_rendered_actions
.get(self.command_palette_selection)
.copied()
else {
return;
};
self.stop_command_palette();
self.execute_palette_action(action);
}
pub fn set_command_palette_list_area(
&mut self,
area: Option<(u16, u16, u16, u16)>,
start: usize,
count: usize,
item_height: u16,
) {
self.command_palette_list_area = area;
self.command_palette_list_start = start;
self.command_palette_list_count = count;
self.command_palette_item_height = item_height.max(1);
}
pub fn handle_command_palette_click(&mut self, column: u16, row: u16) -> bool {
let Some((x, y, width, height)) = self.command_palette_list_area else {
return false;
};
if row < y || row >= y.saturating_add(height) {
return false;
}
if column < x || column >= x.saturating_add(width) {
return false;
}
let item_height = self.command_palette_item_height.max(1);
let offset = row.saturating_sub(y) / item_height;
let offset = offset as usize;
if offset >= self.command_palette_list_count {
return false;
}
self.command_palette_selection = self.command_palette_list_start.saturating_add(offset);
self.apply_command_palette_selection();
true
}
pub(crate) fn command_palette_filtered_entries(&mut self) -> Vec<PaletteEntry> {
let selected = self
.command_palette_rendered_actions
.get(self.command_palette_selection)
.copied();
let mut entries = self.command_palette_entries();
let query = self.command_palette_query.trim().to_ascii_lowercase();
if !query.is_empty() {
entries.retain(|entry| entry.label.to_ascii_lowercase().contains(&query));
}
if let Some(action) = selected {
if let Some(index) = entries.iter().position(|entry| entry.action == action) {
self.command_palette_selection = index;
} else if let Some(mut entry) = self
.command_entries()
.into_iter()
.find(|entry| entry.action == action)
{
if !self.command_enabled(action) {
entry.label.push_str(" (unavailable)");
}
let index = self.command_palette_selection.min(entries.len());
entries.insert(index, entry);
self.command_palette_selection = index;
}
}
self.command_palette_rendered_actions = entries.iter().map(|entry| entry.action).collect();
if entries.is_empty() {
self.command_palette_selection = 0;
} else if self.command_palette_selection >= entries.len() {
self.command_palette_selection = entries.len().saturating_sub(1);
}
entries
}
pub(super) fn command_palette_entries(&self) -> Vec<PaletteEntry> {
self.command_entries()
.into_iter()
.filter(|entry| self.command_enabled(entry.action))
.collect()
}
pub(super) fn command_entries(&self) -> Vec<PaletteEntry> {
use PaletteAction::*;
let mut entries = standard_command_entries();
if self.blame_enabled {
entries.push(PaletteEntry {
label: "View: Blame".into(),
action: SetViewMode(ViewMode::Blame),
});
}
entries.push(PaletteEntry {
label: if self.file_is_reviewed(self.review_live_diff().selected_index) {
"Mark file as unreviewed"
} else {
"Mark file as reviewed"
}
.into(),
action: ToggleFileReviewed,
});
entries.push(PaletteEntry {
label: self
.incremental_review_toggle_label()
.unwrap_or("Enable incremental review")
.into(),
action: ToggleIncrementalReview,
});
entries.push(PaletteEntry {
label: self
.reviewed_files_visibility_label()
.unwrap_or_else(|| "Hide reviewed files".into()),
action: ToggleReviewedFilesVisibility,
});
entries.push(PaletteEntry {
label: format!(
"{} comments",
self.review_provider_kind().long_review_noun_title()
),
action: OpenPrComments,
});
for (idx, action) in self.review_actions.iter().enumerate() {
if action.show.is_empty() || action.show.iter().any(|item| item == "command_palette") {
let label = if action.label.trim().is_empty() {
&action.id
} else {
&action.label
};
if !label.trim().is_empty() {
entries.push(PaletteEntry {
label: format!("Review: {label}"),
action: ReviewAction(idx),
});
}
}
}
entries
}
pub(crate) fn command_enabled(&self, action: PaletteAction) -> bool {
use PaletteAction::*;
let files = self.multi_diff.file_count() > 0;
let diff = files
&& matches!(
self.active_topbar_content(),
Some(super::TopbarTabContent::File(_))
)
&& self.view_mode != ViewMode::Preview
&& !self.active_pr_comments_view()
&& !self.active_outdated_comments_view();
let review = self.review_mode
&& files
&& self.remote_manifest_complete()
&& self.outdated_diff_view.is_none();
match action {
ToggleMenuBar | OpenMenu | OpenCommandPalette | ToggleHelp | OpenSettings
| OpenDashboard | OpenRemoteReview | OpenThemePicker | ToggleZen | ToggleFilePanel
| Quit | RefreshAllFiles => true,
NavigateBack => self.view_history_cursor > 0,
NavigateForward => self.view_history_cursor + 1 < self.view_history.len(),
CopySessionName | RenameSession => self.control_session_name().is_some(),
CopyFilePath
| CopyFilePosition
| CopyAbsoluteFilePath
| CopyAbsoluteFilePosition
| CopyFileCursorPosition
| CopyAbsoluteFileCursorPosition => diff,
SetViewMode(ViewMode::Blame) => files && self.blame_enabled,
NextStep | PreviousStep | ToggleAutoplay | ToggleAutoplayReverse => {
files && self.stepping
}
ToggleFileReviewed => {
review
&& !self
.multi_diff
.file_is_sparse(self.multi_diff.selected_index)
}
ToggleIncrementalReview => review && self.incremental_review_toggle_label().is_some(),
ToggleReviewedFilesVisibility => {
review && self.reviewed_files_visibility_label().is_some()
}
OpenCommentPicker | OpenPrComments | OpenOutdatedComments => {
self.review_mode && self.remote_manifest_complete()
}
SyncReview => {
self.review_mode
&& self.remote_manifest_complete()
&& self.outdated_diff_view.is_none()
&& self.is_remote_session()
&& self.review_sync_status().is_none()
&& crate::remote_sync::RemoteReviewContext::from_app(self).is_ok()
}
ReviewAction(idx) => {
review
&& !self
.multi_diff
.file_is_sparse(self.multi_diff.selected_index)
&& idx < self.review_actions.len()
}
_ => files,
}
}
pub(super) fn execute_palette_action(&mut self, action: PaletteAction) {
if !self.command_enabled(action) {
return;
}
match action {
PaletteAction::ToggleMenuBar => {
self.menu_bar = !self.menu_bar;
}
PaletteAction::OpenMenu => self.open_menu(0),
PaletteAction::OpenCommandPalette => self.start_command_palette(),
PaletteAction::NextStep => self.next_step(),
PaletteAction::PreviousStep => self.prev_step(),
PaletteAction::SyncReview => self.run_comments_sidebar_sync(),
PaletteAction::ToggleStepping => self.toggle_stepping(),
PaletteAction::ToggleViewMode => self.toggle_view_mode(),
PaletteAction::SetViewMode(mode) => self.set_view_mode(mode),
PaletteAction::ToggleLineWrap => self.toggle_line_wrap(),
PaletteAction::ToggleFoldContext => self.toggle_fold_context(),
PaletteAction::ToggleSyntax => self.toggle_syntax(),
PaletteAction::ToggleHelp => self.open_help_tab(),
PaletteAction::OpenSettings => self.open_settings_tab(),
PaletteAction::ToggleZen => self.toggle_zen(),
PaletteAction::ToggleFilePanel => self.toggle_file_panel(),
PaletteAction::ToggleFileReviewed => {
self.toggle_current_file_reviewed();
}
PaletteAction::ToggleReviewedFilesVisibility => {
self.toggle_reviewed_files_visibility();
}
PaletteAction::ToggleIncrementalReview => {
self.toggle_incremental_review();
}
PaletteAction::ToggleAutoplay => self.toggle_autoplay(),
PaletteAction::ToggleAutoplayReverse => self.toggle_autoplay_reverse(),
PaletteAction::OpenRemoteReview => self.start_remote_open(),
PaletteAction::OpenDashboard => self.start_history(),
PaletteAction::NavigateBack => {
self.navigate_view_back();
}
PaletteAction::NavigateForward => {
self.navigate_view_forward();
}
PaletteAction::OpenFileSearch => self.start_file_search(),
PaletteAction::OpenReviewGrep => self.start_review_grep(),
PaletteAction::OpenReviewGrepChanges => {
self.open_review_grep_scope(super::grep::ReviewGrepScope::Changes)
}
PaletteAction::OpenReviewGrepEverything => {
self.open_review_grep_scope(super::grep::ReviewGrepScope::Everything)
}
PaletteAction::OpenThemePicker => self.start_theme_picker(),
PaletteAction::OpenCommentPicker => self.start_comment_picker(),
PaletteAction::OpenPrComments => self.open_pr_comments_in_current_tab(None),
PaletteAction::OpenOutdatedComments => self.open_outdated_comments_in_current_tab(None),
PaletteAction::CopyFilePath => self.copy_current_file_path(),
PaletteAction::CopyFilePosition => self.copy_current_file_position(),
PaletteAction::CopyAbsoluteFilePath => self.copy_current_absolute_file_path(),
PaletteAction::CopyAbsoluteFilePosition => self.copy_current_absolute_file_position(),
PaletteAction::CopyFileCursorPosition => self.copy_current_file_cursor_position(),
PaletteAction::CopyAbsoluteFileCursorPosition => {
self.copy_current_absolute_file_cursor_position()
}
PaletteAction::CopySessionName => self.copy_control_session_name(),
PaletteAction::RenameSession => self.start_session_rename(),
PaletteAction::Quit => self.request_quit(),
PaletteAction::RefreshCurrentFile => self.refresh_current_file(),
PaletteAction::RefreshAllFiles => self.refresh_all_files(),
PaletteAction::ReviewAction(idx) => self.run_review_action(idx),
}
}
pub fn start_file_search(&mut self) {
self.file_search_active = true;
self.file_search_query.clear();
self.file_search_selection = 0;
self.reset_picker_cursor();
self.file_filter_active = false;
self.clear_search();
self.clear_goto();
self.stop_command_palette();
self.stop_review_grep();
self.stop_comment_picker();
self.stop_theme_picker();
}
pub fn stop_file_search(&mut self) {
self.file_search_active = false;
}
pub fn file_search_active(&self) -> bool {
self.file_search_active
}
pub fn file_search_query(&self) -> &str {
&self.file_search_query
}
pub fn file_search_selection(&self) -> usize {
self.file_search_selection
}
pub fn push_file_search_char(&mut self, ch: char) {
self.file_search_query.push(ch);
self.file_search_selection = 0;
self.reset_picker_cursor();
}
pub fn pop_file_search_char(&mut self) {
self.file_search_query.pop();
self.file_search_selection = 0;
self.reset_picker_cursor();
}
pub fn clear_file_search_text(&mut self) {
self.file_search_query.clear();
self.file_search_selection = 0;
self.reset_picker_cursor();
}
pub fn move_file_search_selection(&mut self, delta: isize) {
let indices = self.file_search_filtered_indices();
let total = indices.len();
if total == 0 {
self.file_search_selection = 0;
return;
}
let current = self.file_search_selection.min(total.saturating_sub(1)) as isize;
let next = (current + delta).clamp(0, total.saturating_sub(1) as isize);
self.file_search_selection = next as usize;
}
pub fn apply_file_search_selection(&mut self) {
let indices = self.file_search_filtered_indices();
if indices.is_empty() {
return;
}
let idx = self
.file_search_selection
.min(indices.len().saturating_sub(1));
let file_idx = indices[idx];
self.select_file(file_idx);
self.file_list_focused = false;
self.stop_file_search();
}
pub fn set_file_search_list_area(
&mut self,
area: Option<(u16, u16, u16, u16)>,
start: usize,
count: usize,
item_height: u16,
) {
self.file_search_list_area = area;
self.file_search_list_start = start;
self.file_search_list_count = count;
self.file_search_item_height = item_height.max(1);
}
pub fn handle_file_search_click(&mut self, column: u16, row: u16) -> bool {
let Some((x, y, width, height)) = self.file_search_list_area else {
return false;
};
if row < y || row >= y.saturating_add(height) {
return false;
}
if column < x || column >= x.saturating_add(width) {
return false;
}
let item_height = self.file_search_item_height.max(1);
let offset = row.saturating_sub(y) / item_height;
let offset = offset as usize;
if offset >= self.file_search_list_count {
return false;
}
self.file_search_selection = self.file_search_list_start.saturating_add(offset);
self.apply_file_search_selection();
true
}
pub(crate) fn file_search_filtered_indices(&mut self) -> Vec<usize> {
let indices = self.file_indices_for_query(&self.file_search_query);
if indices.is_empty() {
self.file_search_selection = 0;
} else if self.file_search_selection >= indices.len() {
self.file_search_selection = indices.len().saturating_sub(1);
}
indices
}
pub fn start_comment_picker(&mut self) {
self.comment_picker_active = true;
self.comment_picker_query.clear();
self.comment_picker_selection = 0;
self.reset_picker_cursor();
self.file_filter_active = false;
self.clear_search();
self.clear_goto();
self.stop_command_palette();
self.stop_file_search();
self.stop_review_grep();
self.stop_theme_picker();
}
pub fn stop_comment_picker(&mut self) {
self.comment_picker_active = false;
}
pub fn comment_picker_active(&self) -> bool {
self.comment_picker_active
}
pub fn comment_picker_query(&self) -> &str {
&self.comment_picker_query
}
pub fn comment_picker_selection(&self) -> usize {
self.comment_picker_selection
}
pub fn push_comment_picker_char(&mut self, ch: char) {
self.comment_picker_query.push(ch);
self.comment_picker_selection = 0;
self.reset_picker_cursor();
}
pub fn pop_comment_picker_char(&mut self) {
self.comment_picker_query.pop();
self.comment_picker_selection = 0;
self.reset_picker_cursor();
}
pub fn clear_comment_picker_text(&mut self) {
self.comment_picker_query.clear();
self.comment_picker_selection = 0;
self.reset_picker_cursor();
}
pub fn move_comment_picker_selection(&mut self, delta: isize) {
let indices = self.comment_picker_filtered_indices();
let total = indices.len();
if total == 0 {
self.comment_picker_selection = 0;
return;
}
let current = self.comment_picker_selection.min(total.saturating_sub(1)) as isize;
let next = (current + delta).clamp(0, total.saturating_sub(1) as isize);
self.comment_picker_selection = next as usize;
}
pub fn apply_comment_picker_selection(&mut self) {
let indices = self.comment_picker_filtered_indices();
if indices.is_empty() {
return;
}
let idx = self
.comment_picker_selection
.min(indices.len().saturating_sub(1));
let comment_idx = indices[idx];
self.show_comments_sidebar();
let _ = self.open_review_comment(comment_idx);
self.stop_comment_picker();
}
pub fn set_comment_picker_list_area(
&mut self,
area: Option<(u16, u16, u16, u16)>,
start: usize,
count: usize,
item_height: u16,
) {
self.comment_picker_list_area = area;
self.comment_picker_list_start = start;
self.comment_picker_list_count = count;
self.comment_picker_item_height = item_height.max(1);
}
pub fn handle_comment_picker_click(&mut self, column: u16, row: u16) -> bool {
let Some((x, y, width, height)) = self.comment_picker_list_area else {
return false;
};
if row < y || row >= y.saturating_add(height) {
return false;
}
if column < x || column >= x.saturating_add(width) {
return false;
}
let item_height = self.comment_picker_item_height.max(1);
let offset = row.saturating_sub(y) / item_height;
let offset = offset as usize;
if offset >= self.comment_picker_list_count {
return false;
}
self.comment_picker_selection = self.comment_picker_list_start.saturating_add(offset);
self.apply_comment_picker_selection();
true
}
pub(crate) fn comment_picker_filtered_indices(&mut self) -> Vec<usize> {
let mut indices = self.review_comment_indices_for_query(&self.comment_picker_query);
indices.sort_by(|a, b| {
self.review_comment_sidebar_sort_key(*b)
.cmp(&self.review_comment_sidebar_sort_key(*a))
});
if indices.is_empty() {
self.comment_picker_selection = 0;
} else if self.comment_picker_selection >= indices.len() {
self.comment_picker_selection = indices.len().saturating_sub(1);
}
indices
}
pub fn start_theme_picker(&mut self) {
if !self.theme_picker_active {
self.theme_picker_restore = Some((self.theme.clone(), self.ui_theme_name.clone()));
}
self.theme_picker_syntax = false;
self.theme_picker_active = true;
self.theme_picker_query.clear();
self.reset_picker_cursor();
self.theme_picker_selection = self
.ui_theme_name
.as_ref()
.and_then(|current| list_ui_themes().iter().position(|name| name == current))
.unwrap_or(0);
self.stop_for_theme_picker();
}
pub(crate) fn start_syntax_theme_picker(&mut self) {
if !self.theme_picker_active {
self.syntax_theme_picker_restore = Some(self.syntax_theme.clone());
}
self.theme_picker_syntax = true;
self.theme_picker_active = true;
self.theme_picker_query.clear();
self.reset_picker_cursor();
self.theme_picker_selection = list_syntax_themes()
.iter()
.position(|name| name == &self.syntax_theme)
.unwrap_or(0);
self.stop_for_theme_picker();
}
fn stop_for_theme_picker(&mut self) {
self.file_filter_active = false;
self.clear_search();
self.clear_goto();
self.stop_command_palette();
self.stop_file_search();
self.stop_review_grep();
self.stop_comment_picker();
}
pub fn stop_theme_picker(&mut self) {
if self.theme_picker_syntax {
if let Some(theme) = self.syntax_theme_picker_restore.take() {
self.set_syntax_theme(theme);
}
} else if let Some((theme, name)) = self.theme_picker_restore.take() {
self.theme = theme;
self.ui_theme_name = name;
}
self.theme_picker_active = false;
self.theme_picker_syntax = false;
}
pub fn theme_picker_active(&self) -> bool {
self.theme_picker_active
}
pub fn theme_picker_query(&self) -> &str {
&self.theme_picker_query
}
pub fn theme_picker_selection(&self) -> usize {
self.theme_picker_selection
}
pub fn push_theme_picker_char(&mut self, ch: char) {
self.theme_picker_query.push(ch);
self.theme_picker_selection = 0;
self.reset_picker_cursor();
self.preview_theme_picker_selection();
}
pub fn pop_theme_picker_char(&mut self) {
self.theme_picker_query.pop();
self.theme_picker_selection = 0;
self.reset_picker_cursor();
self.preview_theme_picker_selection();
}
pub fn clear_theme_picker_text(&mut self) {
self.theme_picker_query.clear();
self.theme_picker_selection = 0;
self.reset_picker_cursor();
self.preview_theme_picker_selection();
}
pub fn move_theme_picker_selection(&mut self, delta: isize) {
let names = self.theme_picker_filtered_names();
let total = names.len();
if total == 0 {
self.theme_picker_selection = 0;
return;
}
let current = self.theme_picker_selection.min(total.saturating_sub(1)) as isize;
let next = (current + delta).clamp(0, total.saturating_sub(1) as isize);
self.theme_picker_selection = next as usize;
self.preview_theme_picker_selection();
}
pub fn apply_theme_picker_selection(&mut self) {
let names = self.theme_picker_filtered_names();
if names.is_empty() {
return;
}
let idx = self
.theme_picker_selection
.min(names.len().saturating_sub(1));
if self.theme_picker_syntax {
self.set_syntax_theme(names[idx].clone());
self.syntax_theme_picker_restore = None;
} else {
self.apply_ui_theme(&names[idx]);
self.theme_picker_restore = None;
}
self.stop_theme_picker();
}
pub fn set_theme_picker_list_area(
&mut self,
area: Option<(u16, u16, u16, u16)>,
start: usize,
count: usize,
item_height: u16,
) {
self.theme_picker_list_area = area;
self.theme_picker_list_start = start;
self.theme_picker_list_count = count;
self.theme_picker_item_height = item_height.max(1);
}
pub fn handle_theme_picker_click(&mut self, column: u16, row: u16) -> bool {
let Some((x, y, width, height)) = self.theme_picker_list_area else {
return false;
};
if row < y || row >= y.saturating_add(height) {
return false;
}
if column < x || column >= x.saturating_add(width) {
return false;
}
let item_height = self.theme_picker_item_height.max(1);
let offset = row.saturating_sub(y) / item_height;
let offset = offset as usize;
if offset >= self.theme_picker_list_count {
return false;
}
self.theme_picker_selection = self.theme_picker_list_start.saturating_add(offset);
self.apply_theme_picker_selection();
true
}
pub(crate) fn theme_picker_filtered_names(&mut self) -> Vec<String> {
let query = self.theme_picker_query.trim().to_ascii_lowercase();
let names = if self.theme_picker_syntax {
list_syntax_themes()
} else {
list_ui_themes()
}
.into_iter()
.filter(|name| query.is_empty() || name.to_ascii_lowercase().contains(&query))
.collect::<Vec<_>>();
if names.is_empty() {
self.theme_picker_selection = 0;
} else if self.theme_picker_selection >= names.len() {
self.theme_picker_selection = names.len().saturating_sub(1);
}
names
}
fn preview_theme_picker_selection(&mut self) {
let names = self.theme_picker_filtered_names();
let Some(name) = names.get(self.theme_picker_selection) else {
return;
};
if self.theme_picker_syntax {
self.set_syntax_theme(name.clone());
} else {
self.apply_ui_theme(name);
}
}
pub(crate) fn theme_picker_current_name(&self) -> Option<&str> {
if self.theme_picker_syntax {
Some(&self.syntax_theme)
} else {
self.ui_theme_name.as_deref()
}
}
pub(crate) fn theme_picker_placeholder(&self) -> &'static str {
if self.theme_picker_syntax {
"Search for syntax themes..."
} else {
"Search for themes..."
}
}
pub(crate) fn apply_ui_theme(&mut self, name: &str) {
self.set_ui_theme_name(Some(name.to_string()));
}
pub(crate) fn set_ui_theme_name(&mut self, name: Option<String>) {
self.theme = ThemeConfig {
name: name.clone(),
..ThemeConfig::default()
}
.resolve(self.theme_is_light);
self.ui_theme_name = name;
self.unified_render_cache = None;
self.blame_render_cache = None;
self.blame_bar_cache.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::review::{ReviewAnchor, ReviewRange, ReviewSide, ReviewTargetKind};
use oyo_core::MultiFileDiff;
use std::path::PathBuf;
fn app() -> App {
let diff = MultiFileDiff::from_file_pair(
PathBuf::from("src/lib.rs"),
PathBuf::from("src/lib.rs"),
"same\nold\nthree\nfour\nold five\n".to_string(),
"same\nnew\nthree\nfour\nnew five\n".to_string(),
);
let mut app = App::new(diff, ViewMode::UnifiedPane, 0, false, None);
app.set_review_workspace_root(Some(PathBuf::from("/tmp/oyo-palette")));
app.handle_file_enter();
app
}
#[test]
fn displayed_review_action_cannot_shift_after_remote_validation_is_lost() {
let mut app = app();
app.set_review_persist_enabled(false);
app.enable_review_mode();
app.start_command_palette();
for ch in "review".chars() {
app.push_command_palette_char(ch);
}
let entries = app.command_palette_filtered_entries();
app.command_palette_selection = entries
.iter()
.position(|entry| entry.action == PaletteAction::ToggleFileReviewed)
.unwrap();
app.multi_diff.remote_snapshot =
Some(serde_json::json!({"acquisition":"patch-v1", "streamState":"failed"}));
for _ in 0..2 {
let entries = app.command_palette_filtered_entries();
assert_eq!(
entries[app.command_palette_selection].action,
PaletteAction::ToggleFileReviewed
);
assert!(entries[app.command_palette_selection]
.label
.contains("unavailable"));
}
app.apply_command_palette_selection();
assert!(!app.file_is_reviewed(0));
assert!(app.remote_open.is_none());
}
#[test]
fn palette_toggles_current_file_reviewed() {
let mut app = app();
app.set_review_persist_enabled(false);
app.enable_review_mode();
assert!(app.command_palette_entries().iter().any(|entry| {
entry.label == "Mark file as reviewed"
&& matches!(entry.action, PaletteAction::ToggleFileReviewed)
}));
app.execute_palette_action(PaletteAction::ToggleFileReviewed);
assert!(app.file_is_reviewed(0));
assert!(app
.command_palette_entries()
.iter()
.any(|entry| entry.label == "Mark file as unreviewed"));
}
#[test]
fn palette_offers_current_file_path_and_position_in_diff_views() {
let mut app = app();
let entries = app.command_entries();
assert!(entries.iter().any(|entry| {
entry.label == "Find in files\u{2026}"
&& matches!(entry.action, PaletteAction::OpenReviewGrep)
}));
assert!(entries.iter().any(|entry| {
entry.label == "Find in files: changes\u{2026}"
&& matches!(entry.action, PaletteAction::OpenReviewGrepChanges)
}));
assert!(entries.iter().any(|entry| {
entry.label == "Find in files: all\u{2026}"
&& matches!(entry.action, PaletteAction::OpenReviewGrepEverything)
}));
assert!(entries.iter().any(|entry| {
entry.label == "Navigate back" && matches!(entry.action, PaletteAction::NavigateBack)
}));
assert!(entries.iter().any(|entry| {
entry.label == "Navigate forward"
&& matches!(entry.action, PaletteAction::NavigateForward)
}));
assert!(entries.iter().any(|entry| {
entry.label == "Copy file path" && matches!(entry.action, PaletteAction::CopyFilePath)
}));
assert!(entries.iter().any(|entry| {
entry.label == "Copy file path and position"
&& matches!(entry.action, PaletteAction::CopyFilePosition)
}));
assert!(entries.iter().any(|entry| {
entry.label == "Copy absolute file path"
&& matches!(entry.action, PaletteAction::CopyAbsoluteFilePath)
}));
assert!(entries.iter().any(|entry| {
entry.label == "Copy absolute file path and position"
&& matches!(entry.action, PaletteAction::CopyAbsoluteFilePosition)
}));
assert!(entries.iter().any(|entry| {
entry.label == "Copy file path and cursor position"
&& matches!(entry.action, PaletteAction::CopyFileCursorPosition)
}));
assert!(entries.iter().any(|entry| {
entry.label == "Copy absolute file path and cursor position"
&& matches!(entry.action, PaletteAction::CopyAbsoluteFileCursorPosition)
}));
assert_eq!(app.current_file_path(), "src/lib.rs");
assert_eq!(
app.current_file_relative_position_label().as_deref(),
Some("src/lib.rs:R2")
);
assert_eq!(
app.current_file_absolute_path_label().as_deref(),
Some("/tmp/oyo-palette/src/lib.rs")
);
assert_eq!(
app.current_file_absolute_position_label().as_deref(),
Some("/tmp/oyo-palette/src/lib.rs:R2")
);
app.last_hovered_review_anchor = Some((
app.diff_revision(),
ReviewAnchor {
file_index: 0,
file_path: "src/lib.rs".to_string(),
kind: ReviewTargetKind::Line,
side: Some(ReviewSide::New),
old_range: Some(ReviewRange { start: 5, end: 5 }),
new_range: Some(ReviewRange { start: 5, end: 5 }),
hunk_id: None,
display_idx_hint: Some(4),
anchor_key: "line|src/lib.rs|new|5".to_string(),
snapshot: None,
},
));
assert_eq!(
app.current_file_relative_position_label().as_deref(),
Some("src/lib.rs:R5")
);
assert_eq!(
app.current_file_cursor_position_label().as_deref(),
Some("src/lib.rs:R2")
);
assert_eq!(
app.current_file_absolute_position_label().as_deref(),
Some("/tmp/oyo-palette/src/lib.rs:R5")
);
assert_eq!(
app.current_file_absolute_cursor_position_label().as_deref(),
Some("/tmp/oyo-palette/src/lib.rs:R2")
);
app.mark_diff_changed();
assert_eq!(
app.current_file_relative_position_label().as_deref(),
Some("src/lib.rs:R2")
);
app.last_hovered_review_anchor = None;
app.next_step();
assert_eq!(
app.current_file_relative_position_label().as_deref(),
Some("src/lib.rs:R5")
);
app.view_mode = ViewMode::Preview;
assert!(!app.command_palette_entries().iter().any(|entry| matches!(
entry.action,
PaletteAction::CopyFilePath
| PaletteAction::CopyFilePosition
| PaletteAction::CopyAbsoluteFilePath
| PaletteAction::CopyAbsoluteFilePosition
| PaletteAction::CopyFileCursorPosition
| PaletteAction::CopyAbsoluteFileCursorPosition
)));
let mut scoped = self::app();
scoped.execute_palette_action(PaletteAction::OpenReviewGrepChanges);
assert!(scoped.review_grep_active());
assert_eq!(
scoped.review_grep_scope(),
super::super::grep::ReviewGrepScope::Changes
);
scoped.execute_palette_action(PaletteAction::OpenReviewGrepEverything);
assert_eq!(
scoped.review_grep_scope(),
super::super::grep::ReviewGrepScope::Everything
);
}
}