use lasso::{Rodeo, Spur};
use ratatui::style::Style;
use smallvec::SmallVec;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use tokio::sync::mpsc;
use crate::ai::orchestrator::RallyEvent;
use crate::ai::RallyState;
use crate::diff::LineType;
use crate::diff_store::{DiffCacheStore, DiffScrollState, ScrollMode, MAX_STORE_ENTRIES};
use crate::github::{
ChangedFile, CommitListPage, IssueComment, IssueDetail, IssueListPage, IssueStateFilter,
IssueSummary, LinkedPr, PrCommit, PullRequest,
};
use crate::loader::SingleFileDiffResult;
#[derive(Debug, Clone)]
pub struct CommentPosition {
pub diff_line_index: usize,
pub comment_index: usize,
}
#[derive(Debug, Clone)]
pub struct JumpLocation {
pub file_index: usize,
pub line_index: usize,
pub scroll_offset: usize,
}
#[derive(Debug, Clone)]
pub struct SymbolPopupState {
pub symbols: Vec<(String, usize, usize)>,
pub selected: usize,
}
#[derive(Clone)]
pub struct InternedSpan {
pub content: Spur,
pub style: Style,
}
pub type SpanVec = SmallVec<[InternedSpan; 8]>;
#[derive(Clone)]
pub struct CachedDiffLine {
pub spans: SpanVec,
pub line_type: LineType,
}
pub struct DiffCache {
pub file_index: usize,
pub patch_hash: u64,
pub lines: Vec<CachedDiffLine>,
pub interner: Rodeo,
pub highlighted: bool,
pub markdown_rich: bool,
}
impl DiffCache {
pub fn resolve(&self, spur: Spur) -> &str {
self.interner.resolve(&spur)
}
}
pub fn hash_string(s: &str) -> u64 {
let mut hasher = DefaultHasher::new();
s.hash(&mut hasher);
hasher.finish()
}
#[derive(Debug, Clone)]
pub struct MultilineSelection {
pub anchor_line: usize,
pub cursor_line: usize,
}
impl MultilineSelection {
pub fn start(&self) -> usize {
self.anchor_line.min(self.cursor_line)
}
pub fn end(&self) -> usize {
self.anchor_line.max(self.cursor_line)
}
}
#[derive(Debug, Clone)]
pub struct LineInputContext {
pub file_index: usize,
pub line_number: u32,
pub diff_position: u32,
pub start_line_number: Option<u32>,
}
#[derive(Debug, Clone)]
pub enum InputMode {
Comment(LineInputContext),
Suggestion {
context: LineInputContext,
original_code: String,
},
Reply {
comment_id: u64,
reply_to_user: String,
reply_to_body: String,
},
IssueComment {
issue_number: u32,
},
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AppState {
PullRequestList,
FileList,
DiffView,
TextInput,
CommentList,
Help,
AiRally,
SplitViewFileList,
SplitViewDiff,
PrDescription,
ChecksList,
IssueList,
IssueDetail,
IssueCommentList,
GitOpsSplitTree,
GitOpsSplitDiff,
Cockpit,
}
impl AppState {
pub fn is_data_state_independent(self) -> bool {
matches!(
self,
Self::PullRequestList
| Self::Help
| Self::PrDescription
| Self::ChecksList
| Self::IssueList
| Self::IssueDetail
| Self::IssueCommentList
| Self::TextInput
| Self::GitOpsSplitTree
| Self::GitOpsSplitDiff
| Self::Cockpit
)
}
pub fn is_issue(self) -> bool {
matches!(
self,
Self::IssueList | Self::IssueDetail | Self::IssueCommentList
)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) enum DiffViewVariant {
Fullscreen,
SplitPane,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogEventType {
Info,
Thinking,
ToolUse,
ToolResult,
Text,
Review,
Fix,
Error,
}
#[derive(Debug, Clone)]
pub struct LogEntry {
pub timestamp: String,
pub event_type: LogEventType,
pub message: String,
}
impl LogEntry {
pub fn new(event_type: LogEventType, message: String) -> Self {
let now = chrono::Local::now();
Self {
timestamp: now.format("%H:%M:%S").to_string(),
event_type,
message,
}
}
}
#[derive(Debug, Clone)]
pub struct PermissionInfo {
pub action: String,
pub reason: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PauseState {
Running,
PauseRequested,
Paused,
}
#[derive(Debug, Clone)]
pub struct AiRallyState {
pub iteration: u32,
pub max_iterations: u32,
pub state: RallyState,
pub history: Vec<RallyEvent>,
pub logs: Vec<LogEntry>,
pub log_scroll_offset: usize,
pub selected_log_index: Option<usize>,
pub showing_log_detail: bool,
pub pending_question: Option<String>,
pub pending_permission: Option<PermissionInfo>,
pub pending_review_post: Option<crate::ai::orchestrator::ReviewPostInfo>,
pub pending_fix_post: Option<crate::ai::orchestrator::FixPostInfo>,
pub last_visible_log_height: usize,
pub pending_config_warning: Option<Vec<(String, String)>>,
pub pause_state: PauseState,
}
impl AiRallyState {
pub fn push_log(&mut self, entry: LogEntry) {
let was_at_tail = self.is_selection_at_tail();
self.logs.push(entry);
if was_at_tail {
self.selected_log_index = Some(self.logs.len().saturating_sub(1));
self.log_scroll_offset = 0; }
}
fn is_selection_at_tail(&self) -> bool {
match self.selected_log_index {
None => true, Some(idx) => {
idx >= self.logs.len().saturating_sub(1)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ReviewAction {
Approve,
RequestChanges,
Comment,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PendingApproveChoice {
Ignore,
Submit,
Cancel,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum HelpTab {
#[default]
Keybindings,
Config,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum CommentTab {
#[default]
Review,
Discussion,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CockpitMenuItem {
PrList,
IssueList,
LocalDiff,
GitOps,
}
impl CockpitMenuItem {
pub const ALL: [Self; 4] = [Self::PrList, Self::IssueList, Self::LocalDiff, Self::GitOps];
pub fn index(self) -> usize {
self as usize
}
pub fn from_index(i: usize) -> Self {
Self::ALL[i.min(Self::ALL.len() - 1)]
}
pub fn next(self) -> Self {
Self::from_index(self.index().saturating_add(1))
}
pub fn prev(self) -> Self {
Self::from_index(self.index().saturating_sub(1))
}
pub fn label(self) -> &'static str {
match self {
Self::PrList => "PR List",
Self::IssueList => "Issue List",
Self::LocalDiff => "Local Diff",
Self::GitOps => "Git Ops",
}
}
pub fn description(self) -> &'static str {
match self {
Self::PrList => "Browse pull requests",
Self::IssueList => "Browse issues",
Self::LocalDiff => "View local git diff",
Self::GitOps => "Git operations (stage, commit, push)",
}
}
pub fn requires_repo(self) -> bool {
matches!(self, Self::PrList | Self::IssueList)
}
}
pub struct CockpitState {
pub selected_item: CockpitMenuItem,
pub mentioned_issues_count: LoadState<u32>,
pub review_prs_count: LoadState<u32>,
pub(crate) mentioned_receiver: Option<mpsc::Receiver<Result<u32, String>>>,
pub(crate) review_receiver: Option<mpsc::Receiver<Result<u32, String>>>,
pub repo_available: bool,
}
impl CockpitState {
pub fn new(repo_available: bool) -> Self {
Self {
selected_item: CockpitMenuItem::PrList,
mentioned_issues_count: if repo_available {
LoadState::Loading
} else {
LoadState::NotLoaded
},
review_prs_count: if repo_available {
LoadState::Loading
} else {
LoadState::NotLoaded
},
mentioned_receiver: None,
review_receiver: None,
repo_available,
}
}
}
#[derive(Debug, Clone)]
pub enum RefreshRequest {
PrRefresh { pr_number: u32 },
LocalRefresh,
}
#[derive(Debug, Clone)]
pub(super) enum MarkViewedResult {
Completed {
marked_paths: Vec<String>,
total_targets: usize,
error: Option<String>,
set_viewed: bool,
},
}
pub struct WatcherHandle {
pub(crate) active: Arc<AtomicBool>,
pub(crate) _thread: std::thread::JoinHandle<()>,
}
#[derive(Debug, Clone)]
pub enum DataState {
Loading,
Loaded {
pr: Box<PullRequest>,
files: Vec<ChangedFile>,
},
Error(String),
}
#[derive(Debug, Clone, Default)]
pub enum LoadState<T> {
#[default]
NotLoaded,
Loading,
LoadingMore(T),
Loaded(T),
Error(String),
}
impl<T> LoadState<T> {
pub fn as_loaded(&self) -> Option<&T> {
match self {
Self::Loaded(t) | Self::LoadingMore(t) => Some(t),
_ => None,
}
}
pub fn as_loaded_mut(&mut self) -> Option<&mut T> {
match self {
Self::Loaded(t) | Self::LoadingMore(t) => Some(t),
_ => None,
}
}
pub fn is_loading(&self) -> bool {
matches!(self, Self::Loading | Self::LoadingMore(_))
}
pub fn is_loaded(&self) -> bool {
matches!(self, Self::Loaded(_))
}
pub fn into_loaded(self) -> Option<T> {
match self {
Self::Loaded(t) | Self::LoadingMore(t) => Some(t),
_ => None,
}
}
pub fn recover_or(&mut self, fallback: T) {
let taken = std::mem::take(self);
match taken {
Self::LoadingMore(t) | Self::Loaded(t) => *self = Self::Loaded(t),
_ => *self = Self::Loaded(fallback),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileStatus {
Unmodified,
Modified,
Added,
Deleted,
Renamed,
Copied,
Untracked,
Ignored,
Unmerged,
}
impl FileStatus {
pub fn from_char(c: char) -> Self {
match c {
' ' | '.' => Self::Unmodified,
'M' => Self::Modified,
'A' => Self::Added,
'D' => Self::Deleted,
'R' => Self::Renamed,
'C' => Self::Copied,
'?' => Self::Untracked,
'!' => Self::Ignored,
'U' => Self::Unmerged,
_ => Self::Unmodified,
}
}
pub fn as_char(self) -> char {
match self {
Self::Unmodified => ' ',
Self::Modified => 'M',
Self::Added => 'A',
Self::Deleted => 'D',
Self::Renamed => 'R',
Self::Copied => 'C',
Self::Untracked => '?',
Self::Ignored => '!',
Self::Unmerged => 'U',
}
}
}
#[derive(Debug, Clone)]
pub struct GitStatusEntry {
pub path: String,
pub index_status: FileStatus,
pub worktree_status: FileStatus,
pub additions: u32,
pub deletions: u32,
pub staged_additions: u32,
pub staged_deletions: u32,
pub orig_path: Option<String>,
pub unmerged: bool,
}
impl GitStatusEntry {
pub fn is_staged(&self) -> bool {
!matches!(
self.index_status,
FileStatus::Unmodified | FileStatus::Untracked | FileStatus::Ignored
)
}
pub fn has_worktree_changes(&self) -> bool {
!matches!(
self.worktree_status,
FileStatus::Unmodified | FileStatus::Ignored
)
}
pub fn describe_discard_command(&self) -> String {
if self.worktree_status == FileStatus::Untracked
&& self.index_status == FileStatus::Untracked
{
format!("git clean -f -- {}", self.path)
} else if self.is_staged() && !self.has_worktree_changes() {
format!("git restore --staged --source=HEAD -- {}", self.path)
} else {
format!("git restore -- {}", self.path)
}
}
pub fn change_type_label(&self) -> &'static str {
if self.index_status == FileStatus::Untracked
|| self.worktree_status == FileStatus::Untracked
|| (self.index_status == FileStatus::Added
&& self.worktree_status == FileStatus::Unmodified)
{
return "??";
}
let kind = if self.index_status != FileStatus::Unmodified {
self.index_status
} else {
self.worktree_status
};
match kind {
FileStatus::Modified => "M ",
FileStatus::Added => "A ",
FileStatus::Deleted => "D ",
FileStatus::Renamed => "R ",
FileStatus::Copied => "C ",
FileStatus::Unmerged => "U ",
_ => " ",
}
}
}
#[derive(Debug, Clone)]
pub struct IndexEntry {
pub mode: String,
pub hash: String,
pub path: String,
}
pub enum UndoAction {
Commit,
Stage {
paths: Vec<String>,
previous_index_entries: Vec<IndexEntry>,
},
Unstage { paths: Vec<String> },
StageAll { tree_hash: Option<String> },
}
impl UndoAction {
pub fn describe_command(&self) -> String {
match self {
UndoAction::Commit => "git reset --soft HEAD~1".to_string(),
UndoAction::Stage { paths, .. } => {
format!("git update-index (restore {} file(s))", paths.len())
}
UndoAction::Unstage { paths } => {
if paths.len() == 1 {
format!("git add -- {}", paths[0])
} else {
format!("git add -- ({} files)", paths.len())
}
}
UndoAction::StageAll { tree_hash } => {
if let Some(hash) = tree_hash {
format!("git read-tree {}", &hash[..hash.len().min(7)])
} else {
"git reset".to_string()
}
}
}
}
pub fn to_destructive_op(&self) -> DestructiveOp {
match self {
UndoAction::Commit => DestructiveOp::UndoCommit,
UndoAction::Stage { paths, .. } => DestructiveOp::UndoStage {
paths: paths.clone(),
},
UndoAction::Unstage { paths } => DestructiveOp::UndoUnstage {
paths: paths.clone(),
},
UndoAction::StageAll { tree_hash } => DestructiveOp::UndoStageAll {
tree_hash: tree_hash.clone(),
},
}
}
}
#[derive(Debug, Clone)]
pub enum DestructiveOp {
Discard { path: String },
UndoStage { paths: Vec<String> },
UndoUnstage { paths: Vec<String> },
UndoStageAll { tree_hash: Option<String> },
UndoCommit,
ResetSoft { sha: String, head_offset: usize },
ForcePush { branch: String },
}
impl DestructiveOp {
pub fn to_gitfilm_args(&self) -> Vec<String> {
match self {
Self::Discard { path } => vec![format!("restore {}", path)],
Self::UndoStage { paths } => {
vec![format!("reset --mixed HEAD -- {}", paths.join(" "))]
}
Self::UndoUnstage { paths } => {
vec![format!("add {}", paths.join(" "))]
}
Self::UndoStageAll { tree_hash } => {
if let Some(hash) = tree_hash {
vec![format!("reset --mixed {}", hash)]
} else {
vec!["reset".into()]
}
}
Self::UndoCommit => vec!["reset --soft HEAD~1".into()],
Self::ResetSoft { head_offset, .. } => {
vec![format!("reset --soft HEAD~{}", head_offset)]
}
Self::ForcePush { .. } => vec![],
}
}
pub fn display_command(&self) -> String {
match self {
Self::Discard { path } => format!("git restore -- {}", path),
Self::UndoStage { paths } => format!("git reset -- {}", paths.join(" ")),
Self::UndoUnstage { paths } => format!("git add {}", paths.join(" ")),
Self::UndoStageAll { tree_hash } => {
if let Some(hash) = tree_hash {
format!("git read-tree {}", &hash[..hash.len().min(7)])
} else {
"git reset".to_string()
}
}
Self::UndoCommit => "git reset --soft HEAD~1".to_string(),
Self::ResetSoft { sha, .. } => format!("git reset --soft {}", &sha[..sha.len().min(7)]),
Self::ForcePush { branch } => format!("git push --force-with-lease origin {}", branch),
}
}
}
#[derive(Debug, Clone)]
pub struct SimulationPreview {
pub before: crate::gitfilm::GitfilmAreaSnapshot,
pub after: crate::gitfilm::GitfilmAreaSnapshot,
}
#[derive(Debug, Clone)]
pub enum SimulationResult {
Success(SimulationPreview),
Message(String),
}
#[derive(Debug, Clone)]
pub enum PendingGitOpsConfirm {
Simple { op: DestructiveOp },
Simulating { op: DestructiveOp, abort_id: u64 },
Previewing {
op: DestructiveOp,
result: SimulationResult,
scroll_offset: usize,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum LeftPaneFocus {
#[default]
Tree,
Commits,
}
pub struct CommitLogState {
pub commits: Vec<PrCommit>,
pub selected: usize,
pub scroll_offset: usize,
pub diff_store: DiffCacheStore<String>,
pub diff_scroll: DiffScrollState,
pub diff_loading: bool,
pub loading: bool,
pub has_more: bool,
pub page: u32,
pub error: Option<String>,
pub diff_error: Option<String>,
pub pending_diff_sha: Option<String>,
pub(crate) list_receiver: Option<mpsc::Receiver<Result<CommitListPage, String>>>,
pub(crate) diff_receiver: Option<mpsc::Receiver<Result<(String, String), String>>>,
pub initialized: bool,
}
impl Default for CommitLogState {
fn default() -> Self {
Self::new()
}
}
impl CommitLogState {
pub fn new() -> Self {
Self {
commits: Vec::new(),
selected: 0,
scroll_offset: 0,
diff_store: DiffCacheStore::new(MAX_STORE_ENTRIES),
diff_scroll: DiffScrollState::new(ScrollMode::Edge),
diff_loading: false,
loading: false,
has_more: false,
page: 0,
error: None,
diff_error: None,
pending_diff_sha: None,
list_receiver: None,
diff_receiver: None,
initialized: false,
}
}
}
pub struct GitOpsState {
pub entries: Vec<GitStatusEntry>,
pub tree: crate::app::file_tree::FileTreeState,
pub diff_store: DiffCacheStore<String>,
pub diff_scroll: DiffScrollState,
pub return_state: AppState,
pub(crate) status_receiver: Option<mpsc::Receiver<Result<Vec<GitStatusEntry>, String>>>,
pub(crate) diff_patch_receiver: Option<mpsc::Receiver<SingleFileDiffResult>>,
pub(crate) op_receiver: Option<mpsc::Receiver<Result<String, String>>>,
pub op_message: Option<(String, std::time::Instant)>,
pub undo_stack: Vec<UndoAction>,
pub pending_confirm: Option<PendingGitOpsConfirm>,
pub(crate) status_updated: bool,
pub pushing: bool,
pub ahead_count: u32,
pub(crate) ahead_receiver: Option<mpsc::Receiver<u32>>,
pub left_focus: LeftPaneFocus,
pub left_return_focus: LeftPaneFocus,
pub commit_log: CommitLogState,
pub gitfilm_path: Option<std::path::PathBuf>,
pub(crate) simulate_receiver: Option<(
u64,
mpsc::Receiver<Result<crate::gitfilm::GitfilmSimOutput, String>>,
)>,
}
#[derive(Debug, Clone)]
pub enum TreeRow {
Dir {
path: String,
depth: usize,
expanded: bool,
},
File { index: usize, depth: usize },
}
impl GitOpsState {
pub fn new(entries: Vec<GitStatusEntry>) -> Self {
Self {
entries,
tree: crate::app::file_tree::FileTreeState::new(),
diff_store: DiffCacheStore::new(MAX_STORE_ENTRIES),
diff_scroll: DiffScrollState::new(ScrollMode::Margin),
return_state: AppState::FileList,
status_receiver: None,
diff_patch_receiver: None,
op_receiver: None,
op_message: None,
undo_stack: Vec::new(),
pending_confirm: None,
pushing: false,
ahead_count: 0,
ahead_receiver: None,
status_updated: false,
left_focus: LeftPaneFocus::Tree,
left_return_focus: LeftPaneFocus::Tree,
commit_log: CommitLogState::new(),
gitfilm_path: crate::gitfilm::extract_gitfilm(),
simulate_receiver: None,
}
}
pub fn has_staged_files(&self) -> bool {
self.entries.iter().any(|e| e.is_staged())
}
pub fn has_unmerged_files(&self) -> bool {
self.entries.iter().any(|e| e.unmerged)
}
pub fn selected_path(&self) -> Option<&str> {
self.tree
.selected_file_index()
.and_then(|idx| self.entries.get(idx).map(|e| e.path.as_str()))
}
}
pub(crate) type IssueReceiver<T> = Option<(u32, mpsc::Receiver<Result<T, String>>)>;
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub enum IssueDetailFocus {
#[default]
Body,
LinkedPrs,
}
pub struct IssueState {
pub issues: LoadState<Vec<IssueSummary>>,
pub selected_issue: usize,
pub issue_list_scroll_offset: usize,
pub issue_list_has_more: bool,
pub issue_list_state_filter: IssueStateFilter,
pub issue_list_filter: Option<crate::filter::ListFilter>,
pub issue_detail: LoadState<IssueDetail>,
pub issue_detail_scroll_offset: usize,
pub issue_detail_cache: Option<DiffCache>,
pub selected_linked_pr: usize,
pub detail_focus: IssueDetailFocus,
pub issue_comments: Option<Vec<IssueComment>>,
pub selected_issue_comment: usize,
pub issue_comment_list_scroll_offset: usize,
pub issue_comment_detail_mode: bool,
pub issue_comment_detail_scroll: usize,
pub(crate) issue_comment_submit_receiver:
Option<(u32, mpsc::Receiver<Result<IssueComment, String>>)>,
pub(crate) issue_comment_submitting: bool,
pub linked_prs: LoadState<Vec<LinkedPr>>,
pub(crate) issue_list_receiver: Option<mpsc::Receiver<Result<IssueListPage, String>>>,
pub(crate) issue_detail_receiver: IssueReceiver<IssueDetail>,
pub(crate) linked_prs_receiver: IssueReceiver<Vec<LinkedPr>>,
}
impl Default for IssueState {
fn default() -> Self {
Self::new()
}
}
impl IssueState {
pub fn new() -> Self {
Self {
issues: LoadState::NotLoaded,
selected_issue: 0,
issue_list_scroll_offset: 0,
issue_list_has_more: false,
issue_list_state_filter: IssueStateFilter::default(),
issue_list_filter: None,
issue_detail: LoadState::NotLoaded,
issue_detail_scroll_offset: 0,
issue_detail_cache: None,
issue_comments: None,
selected_issue_comment: 0,
issue_comment_list_scroll_offset: 0,
issue_comment_detail_mode: false,
issue_comment_detail_scroll: 0,
issue_comment_submit_receiver: None,
issue_comment_submitting: false,
selected_linked_pr: 0,
detail_focus: IssueDetailFocus::default(),
linked_prs: LoadState::NotLoaded,
issue_list_receiver: None,
issue_detail_receiver: None,
linked_prs_receiver: None,
}
}
}
#[derive(Default)]
pub struct CommentState {
pub review_comments: Option<Vec<crate::github::comment::ReviewComment>>,
pub local_comment_meta: std::collections::HashMap<u64, crate::cache::LocalCommentMeta>,
pub selected_comment: usize,
pub comment_list_scroll_offset: usize,
pub comments_loading: bool,
pub file_comment_positions: Vec<CommentPosition>,
pub file_comment_lines: std::collections::HashSet<usize>,
pub comment_panel_open: bool,
pub comment_panel_scroll: u16,
pub comment_tab: CommentTab,
pub discussion_comments: Option<Vec<crate::github::comment::DiscussionComment>>,
pub selected_discussion_comment: usize,
pub discussion_comment_list_scroll_offset: usize,
pub discussion_comments_loading: bool,
pub discussion_comment_detail_mode: bool,
pub discussion_comment_detail_scroll: usize,
pub(crate) comment_receiver:
super::PrReceiver<Result<Vec<crate::github::comment::ReviewComment>, String>>,
pub(crate) discussion_comment_receiver:
super::PrReceiver<Result<Vec<crate::github::comment::DiscussionComment>, String>>,
pub(crate) comment_submit_receiver: super::PrReceiver<crate::loader::CommentSubmitResult>,
pub comment_submitting: bool,
pub submission_result: Option<(bool, String)>,
pub(crate) submission_result_time: Option<std::time::Instant>,
pub(crate) pending_approve_body: Option<String>,
pub selected_inline_comment: usize,
}
#[derive(Default)]
pub struct PrListState {
pub pr_list: LoadState<Vec<crate::github::PullRequestSummary>>,
pub selected_pr: usize,
pub pr_list_scroll_offset: usize,
pub pr_list_has_more: bool,
pub pr_list_state_filter: crate::github::PrStateFilter,
pub pr_list_filter: Option<crate::filter::ListFilter>,
pub(crate) pr_list_receiver:
Option<tokio::sync::mpsc::Receiver<Result<crate::github::PrListPage, String>>>,
}
pub struct ChecksState {
pub checks: Option<Vec<crate::github::CheckItem>>,
pub selected_check: usize,
pub checks_scroll_offset: usize,
pub checks_loading: bool,
pub checks_target_pr: Option<u32>,
pub checks_return_state: AppState,
pub ci_status: Option<crate::github::CiStatus>,
pub(crate) checks_receiver: super::PrReceiver<Result<Vec<crate::github::CheckItem>, String>>,
pub(crate) ci_status_receiver: Option<tokio::sync::mpsc::Receiver<crate::github::CiStatus>>,
}
impl Default for ChecksState {
fn default() -> Self {
Self {
checks: None,
selected_check: 0,
checks_scroll_offset: 0,
checks_loading: false,
checks_target_pr: None,
checks_return_state: AppState::FileList,
ci_status: None,
checks_receiver: None,
ci_status_receiver: None,
}
}
}
#[derive(Debug, Clone)]
pub struct RepoSymbolSearchResult {
pub file_path: String,
pub line_number: usize,
pub repo_root: String,
}
pub enum SymbolSearchUpdate {
Found(RepoSymbolSearchResult),
NotFound,
Failed(String),
}
pub enum SymbolSearchState {
Idle,
Searching {
receiver: mpsc::Receiver<SymbolSearchUpdate>,
origin_file_index: usize,
},
Ready(RepoSymbolSearchResult, usize),
}
impl SymbolSearchState {
pub fn is_searching(&self) -> bool {
matches!(self, Self::Searching { .. })
}
pub fn take_ready(&mut self) -> Option<RepoSymbolSearchResult> {
if matches!(self, Self::Ready(..)) {
let old = std::mem::replace(self, Self::Idle);
if let Self::Ready(result, _) = old {
return Some(result);
}
}
None
}
}
#[derive(Debug, Clone)]
pub struct ShellState {
pub input: String,
pub cursor: usize,
pub phase: ShellPhase,
pub scroll_offset: usize,
}
#[derive(Debug, Clone)]
pub enum ShellPhase {
Input,
Running,
Cancelling,
Done(ShellCommandResult),
}
#[derive(Debug, Clone)]
pub struct ShellCommandResult {
pub command: String,
pub stdout: String,
pub stderr: String,
pub exit_code: Option<i32>,
pub cached_lines: Vec<CachedShellLine>,
pub total_lines: usize,
}
#[derive(Debug, Clone)]
pub struct CachedShellLine {
pub text: String,
pub is_stderr: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cockpit_menu_item_next_clamps_at_last() {
assert_eq!(CockpitMenuItem::PrList.next(), CockpitMenuItem::IssueList);
assert_eq!(
CockpitMenuItem::IssueList.next(),
CockpitMenuItem::LocalDiff
);
assert_eq!(CockpitMenuItem::LocalDiff.next(), CockpitMenuItem::GitOps);
assert_eq!(CockpitMenuItem::GitOps.next(), CockpitMenuItem::GitOps);
}
#[test]
fn cockpit_menu_item_prev_clamps_at_first() {
assert_eq!(CockpitMenuItem::GitOps.prev(), CockpitMenuItem::LocalDiff);
assert_eq!(
CockpitMenuItem::LocalDiff.prev(),
CockpitMenuItem::IssueList
);
assert_eq!(CockpitMenuItem::IssueList.prev(), CockpitMenuItem::PrList);
assert_eq!(CockpitMenuItem::PrList.prev(), CockpitMenuItem::PrList);
}
#[test]
fn cockpit_menu_item_from_index_clamps_overflow() {
assert_eq!(CockpitMenuItem::from_index(0), CockpitMenuItem::PrList);
assert_eq!(CockpitMenuItem::from_index(3), CockpitMenuItem::GitOps);
assert_eq!(CockpitMenuItem::from_index(100), CockpitMenuItem::GitOps);
}
#[test]
fn cockpit_menu_item_requires_repo() {
assert!(CockpitMenuItem::PrList.requires_repo());
assert!(CockpitMenuItem::IssueList.requires_repo());
assert!(!CockpitMenuItem::LocalDiff.requires_repo());
assert!(!CockpitMenuItem::GitOps.requires_repo());
}
#[test]
fn cockpit_state_new_repo_available() {
let state = CockpitState::new(true);
assert!(state.repo_available);
assert!(state.mentioned_issues_count.is_loading());
assert!(state.review_prs_count.is_loading());
}
#[test]
fn cockpit_state_new_repo_unavailable() {
let state = CockpitState::new(false);
assert!(!state.repo_available);
assert!(!state.mentioned_issues_count.is_loading());
assert!(!state.review_prs_count.is_loading());
}
#[test]
fn cockpit_is_data_state_independent() {
assert!(AppState::Cockpit.is_data_state_independent());
}
}