use std::fmt::Write as _;
use ag_forge::ReviewCommentSnapshot;
use super::help_action::{
self, HelpAction, ViewActionAvailability, ViewHelpState, ViewSessionState,
};
use super::prompt::{
PromptAtMentionState, PromptAttachmentState, PromptHistoryState, PromptSlashState,
};
use crate::domain::input::InputState;
use crate::domain::question::QuestionItem;
use crate::domain::session::{
PublishBranchAction, Session, SessionId, Status, can_reply_to_session_in_stack,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DiffLineSide {
New,
Old,
}
impl DiffLineSide {
pub(crate) fn prompt_label(self) -> &'static str {
match self {
Self::New => "new",
Self::Old => "old",
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiffLineCommentAnchor {
pub(crate) content: String,
pub(crate) line: u32,
pub(crate) path: String,
pub(crate) side: DiffLineSide,
}
impl DiffLineCommentAnchor {
pub(crate) fn prompt_line(&self, comment: &str) -> String {
match self.side {
DiffLineSide::New => format!(
"- {}:{} [{}]: {}",
self.path,
self.line,
self.side.prompt_label(),
comment.trim(),
),
DiffLineSide::Old => format!(
"- {}:{} [{}, source={:?}]: {}",
self.path,
self.line,
self.side.prompt_label(),
self.content,
comment.trim(),
),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiffLineCommentTarget {
first_anchor: DiffLineCommentAnchor,
remaining_anchors: Vec<DiffLineCommentAnchor>,
}
impl DiffLineCommentTarget {
pub(crate) fn single(anchor: DiffLineCommentAnchor) -> Self {
Self {
first_anchor: anchor,
remaining_anchors: Vec::new(),
}
}
pub(crate) fn from_anchors(anchors: Vec<DiffLineCommentAnchor>) -> Option<Self> {
let mut anchors = anchors.into_iter();
let first_anchor = anchors.next()?;
Some(Self {
first_anchor,
remaining_anchors: anchors.collect(),
})
}
pub(crate) fn first_anchor(&self) -> &DiffLineCommentAnchor {
&self.first_anchor
}
pub(crate) fn last_anchor(&self) -> &DiffLineCommentAnchor {
self.remaining_anchors.last().unwrap_or(&self.first_anchor)
}
fn anchors(&self) -> impl Iterator<Item = &DiffLineCommentAnchor> {
std::iter::once(&self.first_anchor).chain(self.remaining_anchors.iter())
}
fn prompt_line(&self, comment: &str) -> String {
let first_anchor = &self.first_anchor;
if self.remaining_anchors.is_empty() {
return first_anchor.prompt_line(comment);
}
let last_anchor = self.last_anchor();
let mut location =
if first_anchor.path == last_anchor.path && first_anchor.side == last_anchor.side {
format!(
"{}:{}-{} [{}]",
first_anchor.path,
first_anchor.line,
last_anchor.line,
first_anchor.side.prompt_label(),
)
} else {
format!(
"{}:{} [{}]..{}:{} [{}]",
first_anchor.path,
first_anchor.line,
first_anchor.side.prompt_label(),
last_anchor.path,
last_anchor.line,
last_anchor.side.prompt_label(),
)
};
let deleted_source = self
.anchors()
.filter(|anchor| anchor.side == DiffLineSide::Old)
.map(|anchor| anchor.content.as_str())
.collect::<Vec<_>>();
if !deleted_source.is_empty() {
let _ = write!(location, ", deleted source={deleted_source:?}");
}
format!("- {location}: {}", comment.trim())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiffLineComment {
pub(crate) input: InputState,
pub(crate) target: DiffLineCommentTarget,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct DiffLineComments {
pub(crate) comments: Vec<DiffLineComment>,
pub(crate) editing_index: Option<usize>,
pub(crate) selection_anchor_index: Option<usize>,
}
impl DiffLineComments {
pub(crate) fn start_editing_target(&mut self, target: DiffLineCommentTarget) -> usize {
let editing_index = self
.comments
.iter()
.position(|comment| comment.target == target)
.unwrap_or_else(|| {
self.comments.push(DiffLineComment {
input: InputState::default(),
target,
});
self.comments.len().saturating_sub(1)
});
self.editing_index = Some(editing_index);
editing_index
}
pub(crate) fn start_selection(&mut self, selected_index: usize) {
self.selection_anchor_index.get_or_insert(selected_index);
}
pub(crate) fn cancel_selection(&mut self) {
self.selection_anchor_index = None;
}
pub(crate) fn is_selecting(&self) -> bool {
self.selection_anchor_index.is_some()
}
pub(crate) fn selected_row_bounds(&self, selected_index: usize) -> (usize, usize) {
let anchor_index = self.selection_anchor_index.unwrap_or(selected_index);
(
anchor_index.min(selected_index),
anchor_index.max(selected_index),
)
}
pub(crate) fn editing_input_mut(&mut self) -> Option<&mut InputState> {
self.editing_index
.and_then(|editing_index| self.comments.get_mut(editing_index))
.map(|comment| &mut comment.input)
}
pub(crate) fn finish_editing(&mut self) {
let Some(editing_index) = self.editing_index.take() else {
return;
};
self.selection_anchor_index = None;
if self
.comments
.get(editing_index)
.is_some_and(|comment| comment.input.text().trim().is_empty())
{
self.comments.remove(editing_index);
}
}
pub(crate) fn is_editing(&self) -> bool {
self.editing_index.is_some()
}
pub(crate) fn prompt_text(&self) -> String {
let comments = self
.comments
.iter()
.filter(|comment| !comment.input.text().trim().is_empty())
.map(|comment| comment.target.prompt_line(comment.input.text()))
.collect::<Vec<_>>()
.join("\n");
if comments.is_empty() {
return comments;
}
format!("Line comments:\n{comments}")
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReviewCommentSelection {
pub(crate) thread_id: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DiffReviewComments {
pub selected_comments: Vec<ReviewCommentSelection>,
pub comment_error: Option<String>,
pub comment_snapshot: Option<ReviewCommentSnapshot>,
pub is_loading_comments: bool,
pub request_id: u64,
pub selected_comment_index: usize,
pub sidebar_focus: DiffSidebarFocus,
}
impl DiffReviewComments {
pub fn loading(request_id: u64) -> Self {
Self {
selected_comments: Vec::new(),
comment_error: None,
comment_snapshot: None,
is_loading_comments: true,
request_id,
selected_comment_index: 0,
sidebar_focus: DiffSidebarFocus::Files,
}
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum DiffSidebarFocus {
#[default]
Files,
Comments,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum DiffFocus {
#[default]
Files,
Content,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ConfirmationIntent {
Quit,
CancelSession,
ContinueSession,
ForkSession,
MergeSession,
RegenerateReview,
DetachManagedSession,
OpenManagedWorktree,
ChooseIntegrationApproach,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ConfirmationViewMode {
pub scroll_offset: Option<u16>,
pub session_id: SessionId,
}
impl ConfirmationViewMode {
#[must_use]
pub fn into_view_mode(self) -> AppMode {
AppMode::View {
session_id: self.session_id,
scroll_offset: self.scroll_offset,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DiffScrollCache {
pub content_area: ViewportRect,
pub file_explorer_selected_index: usize,
pub max_scroll_offset: u16,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DiffPreview {
Off {
request_id: u64,
},
Unsupported {
request_id: u64,
},
Loading {
path: String,
request_id: u64,
},
Ready {
content: String,
path: String,
request_id: u64,
},
Unavailable {
path: String,
reason: DiffPreviewUnavailableReason,
request_id: u64,
},
}
impl DiffPreview {
#[must_use]
pub fn is_enabled(&self) -> bool {
!matches!(self, Self::Off { .. })
}
#[must_use]
pub fn request_id(&self) -> u64 {
match self {
Self::Off { request_id }
| Self::Unsupported { request_id }
| Self::Loading { request_id, .. }
| Self::Ready { request_id, .. }
| Self::Unavailable { request_id, .. } => *request_id,
}
}
#[must_use]
pub fn path(&self) -> Option<&str> {
match self {
Self::Loading { path, .. }
| Self::Ready { path, .. }
| Self::Unavailable { path, .. } => Some(path),
Self::Off { .. } | Self::Unsupported { .. } => None,
}
}
#[must_use]
pub fn next_request_id(&self) -> u64 {
let next_request_id = self.request_id().wrapping_add(1);
next_request_id.max(1)
}
#[must_use]
pub fn disabled(&self) -> Self {
Self::Off {
request_id: self.next_request_id(),
}
}
}
impl Default for DiffPreview {
fn default() -> Self {
Self::Off { request_id: 0 }
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DiffPreviewUnavailableReason {
Deleted,
Binary,
TooLarge,
LoadFailed(String),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ViewportRect {
pub height: u16,
pub width: u16,
pub x: u16,
pub y: u16,
}
pub struct QuestionModeSnapshot {
pub at_mention_state: Option<PromptAtMentionState>,
pub current_index: usize,
pub input: InputState,
pub questions: Vec<QuestionItem>,
pub responses: Vec<String>,
pub scroll_offset: Option<u16>,
pub selected_option_index: Option<usize>,
pub session_id: SessionId,
}
impl QuestionModeSnapshot {
#[must_use]
pub fn into_question_mode(self) -> AppMode {
AppMode::Question {
at_mention_state: self.at_mention_state,
current_index: self.current_index,
focus: ChatFocus::Input,
input: self.input,
questions: self.questions,
responses: self.responses,
scroll_offset: self.scroll_offset,
selected_option_index: self.selected_option_index,
session_id: self.session_id,
}
}
}
#[derive(Clone)]
pub struct PromptModeSnapshot {
pub at_mention_state: Option<PromptAtMentionState>,
pub attachment_state: PromptAttachmentState,
pub history_state: PromptHistoryState,
pub input: InputState,
pub scroll_offset: Option<u16>,
pub session_id: SessionId,
pub slash_state: PromptSlashState,
}
impl PromptModeSnapshot {
#[must_use]
pub fn into_prompt_mode(self) -> AppMode {
AppMode::Prompt {
at_mention_state: self.at_mention_state,
attachment_state: self.attachment_state,
focus: ChatFocus::Input,
history_state: self.history_state,
slash_state: self.slash_state,
session_id: self.session_id,
input: self.input,
scroll_offset: self.scroll_offset,
}
}
}
pub enum DiffRestoreTarget {
Prompt(PromptModeSnapshot),
Question(QuestionModeSnapshot),
}
impl DiffRestoreTarget {
#[must_use]
pub fn into_mode(self) -> AppMode {
match self {
DiffRestoreTarget::Prompt(snapshot) => snapshot.into_prompt_mode(),
DiffRestoreTarget::Question(snapshot) => snapshot.into_question_mode(),
}
}
}
pub(crate) fn allows_diff_line_comment_reply(
session: &Session,
sessions: &[Session],
restore: Option<&DiffRestoreTarget>,
) -> bool {
!matches!(restore, Some(DiffRestoreTarget::Question(_)))
&& session.status.allows_chat_composer()
&& session.accepts_user_turns()
&& (session.status == Status::Draft
|| can_reply_to_session_in_stack(sessions, session.id.as_str()))
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum ChatFocus {
#[default]
Input,
Chat,
}
pub enum AppMode {
List,
SessionCreation {
selected_option_index: usize,
},
PreCommitHookWarning {
message: String,
},
ProjectSwitcher {
selected_option_index: usize,
},
Confirmation {
confirmation_intent: ConfirmationIntent,
confirmation_message: String,
confirmation_title: String,
restore_view: Option<ConfirmationViewMode>,
session_id: Option<SessionId>,
selected_confirmation_index: usize,
},
SyncBlockedPopup {
project_name: Option<String>,
default_branch: Option<String>,
is_loading: bool,
message: String,
title: String,
},
ViewInfoPopup {
is_loading: bool,
loading_label: String,
message: String,
restore_view: ConfirmationViewMode,
title: String,
},
LaunchConfigurationSelector {
commands: Vec<String>,
restore_view: ConfirmationViewMode,
selected_command_index: usize,
},
PublishBranchInput {
default_branch_name: String,
input: InputState,
locked_upstream_ref: Option<String>,
publish_branch_action: PublishBranchAction,
restore_view: ConfirmationViewMode,
},
Prompt {
at_mention_state: Option<PromptAtMentionState>,
attachment_state: PromptAttachmentState,
focus: ChatFocus,
history_state: PromptHistoryState,
slash_state: PromptSlashState,
session_id: SessionId,
input: InputState,
scroll_offset: Option<u16>,
},
View {
session_id: SessionId,
scroll_offset: Option<u16>,
},
DiffLoading {
fallback_view_scroll_offset: Option<u16>,
request_id: u64,
restore: Option<Box<DiffRestoreTarget>>,
session_id: SessionId,
sidebar_focus: DiffSidebarFocus,
},
Diff {
diff: String,
file_explorer_selected_index: usize,
focus: DiffFocus,
line_comments: DiffLineComments,
preview: DiffPreview,
review_comments: Option<DiffReviewComments>,
restore: Option<Box<DiffRestoreTarget>>,
scroll_cache: Option<DiffScrollCache>,
scroll_offset: u16,
selected_diff_line_index: usize,
session_id: SessionId,
},
Question {
at_mention_state: Option<PromptAtMentionState>,
session_id: SessionId,
questions: Vec<QuestionItem>,
responses: Vec<String>,
current_index: usize,
focus: ChatFocus,
input: InputState,
scroll_offset: Option<u16>,
selected_option_index: Option<usize>,
},
Help {
context: HelpContext,
scroll_offset: u16,
},
}
pub enum HelpContext {
List {
keybindings: Vec<HelpAction>,
},
View {
can_fork_session: bool,
can_merge_session_branch: bool,
can_mutate_session_branch: bool,
can_open_worktree: bool,
can_rebase_session_branch: bool,
can_show_diff: bool,
can_reply_to_session: bool,
can_start_staged_session: bool,
can_view_review_comments: bool,
publish_pull_request_action: Option<PublishBranchAction>,
session_id: SessionId,
session_state: ViewSessionState,
scroll_offset: Option<u16>,
},
Diff {
can_comment: bool,
diff: String,
file_explorer_selected_index: usize,
focus: DiffFocus,
line_comments: DiffLineComments,
preview: DiffPreview,
review_comments: Option<Box<DiffReviewComments>>,
restore: Option<Box<DiffRestoreTarget>>,
session_id: SessionId,
scroll_offset: u16,
selected_diff_line_index: usize,
},
}
impl HelpContext {
pub fn keybindings(&self) -> Vec<HelpAction> {
match self {
HelpContext::View {
can_fork_session,
can_merge_session_branch,
can_mutate_session_branch,
can_open_worktree,
can_rebase_session_branch,
can_reply_to_session,
can_show_diff,
can_start_staged_session,
can_view_review_comments,
publish_pull_request_action,
session_state,
..
} => help_action::view_actions_with_review_comments(
ViewHelpState {
can_fork_session: ViewActionAvailability::from_bool(*can_fork_session),
can_merge_session_branch: ViewActionAvailability::from_bool(
*can_merge_session_branch,
),
can_mutate_session_branch: ViewActionAvailability::from_bool(
*can_mutate_session_branch,
),
can_open_worktree: ViewActionAvailability::from_bool(*can_open_worktree),
can_rebase_session_branch: ViewActionAvailability::from_bool(
*can_rebase_session_branch,
),
can_show_diff: ViewActionAvailability::from_bool(*can_show_diff),
reply_to_session: ViewActionAvailability::from_bool(*can_reply_to_session),
can_start_staged_session: ViewActionAvailability::from_bool(
*can_start_staged_session,
),
publish_pull_request_action: *publish_pull_request_action,
session_state: *session_state,
},
*can_view_review_comments,
),
HelpContext::List { keybindings } => keybindings.clone(),
HelpContext::Diff { can_comment, .. } => help_action::diff_actions(*can_comment),
}
}
pub fn restore_mode(self) -> AppMode {
match self {
HelpContext::List { .. } => AppMode::List,
HelpContext::View {
publish_pull_request_action: _,
session_id,
scroll_offset,
..
} => AppMode::View {
session_id,
scroll_offset,
},
HelpContext::Diff {
can_comment: _,
diff,
file_explorer_selected_index,
focus,
line_comments,
preview,
review_comments,
restore,
selected_diff_line_index,
session_id,
scroll_offset,
} => AppMode::Diff {
diff,
file_explorer_selected_index,
focus,
line_comments,
preview,
review_comments: review_comments.map(|review_comments| *review_comments),
restore,
scroll_cache: None,
selected_diff_line_index,
session_id,
scroll_offset,
},
}
}
pub fn title(&self) -> &'static str {
"Keybindings"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::session::PublishBranchAction;
#[test]
fn test_diff_line_comments_edit_and_build_compact_prompt() {
let anchor = DiffLineCommentAnchor {
content: "println!(\"review\");".to_string(),
line: 12,
path: "src/main.rs".to_string(),
side: DiffLineSide::New,
};
let mut line_comments = DiffLineComments::default();
line_comments.start_editing_target(DiffLineCommentTarget::single(anchor.clone()));
line_comments
.editing_input_mut()
.expect("new comment should be editable")
.insert_text("Please explain this change.");
line_comments.finish_editing();
let prompt = line_comments.prompt_text();
assert_eq!(
prompt,
"Line comments:\n- src/main.rs:12 [new]: Please explain this change."
);
assert!(!line_comments.is_editing());
let editing_index =
line_comments.start_editing_target(DiffLineCommentTarget::single(anchor));
assert_eq!(editing_index, 0);
assert_eq!(line_comments.comments.len(), 1);
}
#[test]
fn test_deleted_diff_line_prompt_includes_captured_source() {
let anchor = DiffLineCommentAnchor {
content: "let message = \"old\";".to_string(),
line: 7,
path: "src/old.rs".to_string(),
side: DiffLineSide::Old,
};
let prompt_line = anchor.prompt_line("Keep this behavior.");
assert_eq!(
prompt_line,
"- src/old.rs:7 [old, source=\"let message = \\\"old\\\";\"]: Keep this behavior."
);
}
#[test]
fn test_diff_line_comment_target_formats_new_and_mixed_row_ranges() {
let new_target = DiffLineCommentTarget::from_anchors(vec![
DiffLineCommentAnchor {
content: "first();".to_string(),
line: 4,
path: "src/main.rs".to_string(),
side: DiffLineSide::New,
},
DiffLineCommentAnchor {
content: "second();".to_string(),
line: 5,
path: "src/main.rs".to_string(),
side: DiffLineSide::New,
},
])
.expect("new-line range should create a target");
let mixed_target = DiffLineCommentTarget::from_anchors(vec![
DiffLineCommentAnchor {
content: "old();".to_string(),
line: 7,
path: "src/old.rs".to_string(),
side: DiffLineSide::Old,
},
DiffLineCommentAnchor {
content: "new();".to_string(),
line: 8,
path: "src/new.rs".to_string(),
side: DiffLineSide::New,
},
])
.expect("mixed range should create a target");
let new_prompt = new_target.prompt_line("Explain this range.");
let mixed_prompt = mixed_target.prompt_line("Preserve the behavior.");
let last_anchor = mixed_target.last_anchor();
let empty_target = DiffLineCommentTarget::from_anchors(Vec::new());
assert_eq!(new_prompt, "- src/main.rs:4-5 [new]: Explain this range.");
assert_eq!(
mixed_prompt,
"- src/old.rs:7 [old]..src/new.rs:8 [new], deleted source=[\"old();\"]: Preserve the \
behavior."
);
assert_eq!(last_anchor.content, "new();");
assert_eq!(empty_target, None);
}
#[test]
fn test_diff_line_comments_tracks_visual_row_selection() {
let mut line_comments = DiffLineComments::default();
let target = DiffLineCommentTarget::single(DiffLineCommentAnchor {
content: "selected();".to_string(),
line: 4,
path: "src/lib.rs".to_string(),
side: DiffLineSide::New,
});
line_comments.start_selection(3);
line_comments.start_selection(9);
let upward_bounds = line_comments.selected_row_bounds(1);
let downward_bounds = line_comments.selected_row_bounds(5);
line_comments.start_editing_target(target);
assert!(line_comments.is_selecting());
assert!(line_comments.is_editing());
assert_eq!(upward_bounds, (1, 3));
assert_eq!(downward_bounds, (3, 5));
line_comments.finish_editing();
assert!(!line_comments.is_selecting());
assert_eq!(line_comments.selected_row_bounds(5), (5, 5));
line_comments.start_selection(5);
line_comments.cancel_selection();
assert!(!line_comments.is_selecting());
}
#[test]
fn test_diff_line_comments_remove_blank_editor() {
let mut line_comments = DiffLineComments::default();
line_comments.start_editing_target(DiffLineCommentTarget::single(DiffLineCommentAnchor {
content: "removed".to_string(),
line: 3,
path: "src/lib.rs".to_string(),
side: DiffLineSide::Old,
}));
line_comments.finish_editing();
line_comments.finish_editing();
assert!(line_comments.comments.is_empty());
assert!(line_comments.editing_input_mut().is_none());
assert!(line_comments.prompt_text().is_empty());
}
#[test]
fn test_confirmation_view_mode_into_view_mode_restores_view_identity() {
let confirmation_view_mode = ConfirmationViewMode {
scroll_offset: Some(7),
session_id: "session-id".into(),
};
let mode = confirmation_view_mode.into_view_mode();
assert!(matches!(
mode,
AppMode::View {
ref session_id,
scroll_offset: Some(7),
} if session_id == "session-id"
));
}
#[test]
fn test_help_context_view_keybindings_for_in_progress_show_sync_and_hide_edit_actions() {
let context = HelpContext::View {
can_fork_session: true,
can_merge_session_branch: true,
can_mutate_session_branch: true,
can_open_worktree: true,
can_rebase_session_branch: true,
can_show_diff: true,
can_reply_to_session: true,
can_start_staged_session: false,
can_view_review_comments: false,
publish_pull_request_action: None,
session_id: "session-id".into(),
session_state: ViewSessionState::InProgress,
scroll_offset: Some(2),
};
let bindings = context.keybindings();
assert!(bindings.iter().any(|binding| binding.key == "q"));
assert!(bindings.iter().any(|binding| binding.key == "j/k"));
assert!(bindings.iter().any(|binding| binding.key == "?"));
assert!(bindings.iter().any(|binding| binding.key == "Ctrl+c"));
assert!(bindings.iter().any(|binding| binding.key == "r"));
assert!(!bindings.iter().any(|binding| binding.key == "Enter"));
assert!(!bindings.iter().any(|binding| binding.key == "d"));
assert!(!bindings.iter().any(|binding| binding.key == "m"));
assert!(!bindings.iter().any(|binding| binding.key == "S-Tab"));
}
#[test]
fn test_help_context_restore_mode_ignores_help_only_view_fields() {
let context = HelpContext::View {
can_fork_session: true,
can_merge_session_branch: true,
can_mutate_session_branch: true,
can_open_worktree: true,
can_rebase_session_branch: true,
can_show_diff: true,
can_reply_to_session: true,
can_start_staged_session: false,
can_view_review_comments: false,
publish_pull_request_action: Some(PublishBranchAction::PublishPullRequest),
session_id: "session-id".into(),
session_state: ViewSessionState::InProgress,
scroll_offset: Some(4),
};
let mode = context.restore_mode();
assert!(matches!(
mode,
AppMode::View {
ref session_id,
scroll_offset: Some(4),
..
} if session_id == "session-id"
));
}
#[test]
fn test_help_context_view_keybindings_include_publish_pull_request_action() {
let context = HelpContext::View {
can_fork_session: true,
can_merge_session_branch: true,
can_mutate_session_branch: true,
can_open_worktree: true,
can_rebase_session_branch: true,
can_show_diff: true,
can_reply_to_session: true,
can_start_staged_session: false,
can_view_review_comments: false,
publish_pull_request_action: Some(PublishBranchAction::PublishPullRequest),
session_id: "session-id".into(),
session_state: ViewSessionState::Interactive,
scroll_offset: None,
};
let bindings = context.keybindings();
assert!(bindings.iter().any(|binding| binding.key == "p"));
}
#[test]
fn test_help_context_list_keybindings_return_stored_actions() {
let keybindings = vec![
HelpAction::new("quit", "q", "Quit"),
HelpAction::new("help", "?", "Help"),
];
let context = HelpContext::List { keybindings };
let bindings = context.keybindings();
assert_eq!(bindings.len(), 2);
assert!(bindings.iter().any(|binding| binding.key == "q"));
assert!(bindings.iter().any(|binding| binding.key == "?"));
}
#[test]
fn test_diff_preview_tracks_enabled_state_and_request_generation() {
let states = [
DiffPreview::Off { request_id: 0 },
DiffPreview::Unsupported { request_id: 1 },
DiffPreview::Loading {
path: "README.md".to_string(),
request_id: 2,
},
DiffPreview::Ready {
content: "# Ready".to_string(),
path: "README.md".to_string(),
request_id: 3,
},
DiffPreview::Unavailable {
path: "README.md".to_string(),
reason: DiffPreviewUnavailableReason::Deleted,
request_id: 4,
},
];
let enabled = states
.iter()
.map(DiffPreview::is_enabled)
.collect::<Vec<_>>();
let request_ids = states
.iter()
.map(DiffPreview::request_id)
.collect::<Vec<_>>();
let paths = states.iter().map(DiffPreview::path).collect::<Vec<_>>();
let next_request_id = states[4].next_request_id();
let disabled = states[3].disabled();
assert_eq!(enabled, [false, true, true, true, true]);
assert_eq!(request_ids, [0, 1, 2, 3, 4]);
assert_eq!(
paths,
[
None,
None,
Some("README.md"),
Some("README.md"),
Some("README.md")
]
);
assert_eq!(next_request_id, 5);
assert_eq!(disabled, DiffPreview::Off { request_id: 4 });
}
}