use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime};
use chrono::Utc;
use ratatui::style::Color;
use crate::comment_vim::CommentVimEditor;
use crate::config::{CommentTypeConfig, ExportConfig};
use crate::editor::EditorTarget;
use crate::error::{Result, TuicrError};
use crate::forge::context::{ContextProvider, ForgeContextProvider, VcsContextProvider};
use crate::forge::selector::PullRequestsTab;
use crate::forge::traits::{ForgeBackend, ForgeRepository};
use crate::model::review::FileReview;
use crate::model::{
ClearScope, Comment, CommentType, DiffFile, DiffHunk, DiffLine, FileStatus, LineOrigin,
LineRange, LineSide, ReviewSession, SessionDiffSource,
};
use crate::persistence::load_latest_session_for_context;
use crate::review_store::{AddCommentRequest, CommentTarget, add_comment_to_session};
use crate::syntax::SyntaxHighlighter;
use crate::theme::Theme;
use crate::update::UpdateInfo;
use crate::vcs::git::calculate_gap;
use crate::vcs::traits::VcsType;
use crate::vcs::{
ChangeKind, CommitInfo, DiffWhitespaceMode, FileBackend, GitBackendPreference, PrNoopVcs,
ResolvedRevisionRange, RevisionDiffTarget, VcsBackend, VcsChangeStatus, VcsInfo, detect_vcs,
};
const VISIBLE_COMMIT_COUNT: usize = 10;
const COMMIT_PAGE_SIZE: usize = 10;
pub const DEFAULT_REVIEW_WATCH_INTERVAL_MS: u64 = 1000;
pub const STAGED_SELECTION_ID: &str = "__tuicr_staged__";
pub const UNSTAGED_SELECTION_ID: &str = "__tuicr_unstaged__";
pub const GAP_EXPAND_BATCH: usize = 20;
fn create_forge_backend(
repo: &ForgeRepository,
local_checkout: Option<PathBuf>,
) -> Box<dyn ForgeBackend> {
use crate::forge::traits::ForgeKind;
match repo.kind {
ForgeKind::GitHub => {
use crate::forge::github::gh::GitHubGhBackend;
Box::new(GitHubGhBackend::new(Some(repo.clone())).with_local_checkout(local_checkout))
}
ForgeKind::GitLab => {
use crate::forge::gitlab::GitLabGlabBackend;
Box::new(GitLabGlabBackend::new(Some(repo.clone())).with_local_checkout(local_checkout))
}
}
}
fn char_slice(s: &str, lo_char: usize, hi_char: Option<usize>) -> &str {
let mut indices = s.char_indices();
let lo_byte = indices
.by_ref()
.nth(lo_char)
.map(|(b, _)| b)
.unwrap_or(s.len());
let hi_byte = match hi_char {
None => s.len(),
Some(hi) if hi <= lo_char => return "",
Some(hi) => indices
.nth(hi - lo_char - 1)
.map(|(b, _)| b)
.unwrap_or(s.len()),
};
&s[lo_byte..hi_byte]
}
fn gap_annotation_line_count(
is_top_of_file: bool,
is_end_of_file: bool,
remaining: usize,
) -> usize {
if remaining == 0 {
0
} else if is_top_of_file {
if remaining > GAP_EXPAND_BATCH { 2 } else { 1 }
} else if is_end_of_file {
if remaining > GAP_EXPAND_BATCH { 2 } else { 1 }
} else {
if remaining >= GAP_EXPAND_BATCH { 3 } else { 1 }
}
}
fn profile_diff_result(result: &Result<Vec<DiffFile>>) -> String {
match result {
Ok(files) => format!("files={}", files.len()),
Err(e) => format!("error={e}"),
}
}
fn profile_commit_result(result: &Result<Vec<CommitInfo>>) -> String {
match result {
Ok(commits) => format!("commits={}", commits.len()),
Err(e) => format!("error={e}"),
}
}
fn profile_unit_result(result: &Result<()>) -> String {
match result {
Ok(()) => "result=ok".to_string(),
Err(e) => format!("error={e}"),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileTreeItem {
Directory {
path: String,
depth: usize,
expanded: bool,
},
File {
file_idx: usize,
depth: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct GapId {
pub file_idx: usize,
pub hunk_idx: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ExpandDirection {
Down,
Up,
Both,
}
const MIN_LINENO_WIDTH: usize = 4;
pub fn lineno_width(max_lineno: u32) -> usize {
if max_lineno == 0 {
return MIN_LINENO_WIDTH;
}
let mut digits = 0;
let mut n = max_lineno;
while n > 0 {
digits += 1;
n /= 10;
}
digits.max(MIN_LINENO_WIDTH)
}
pub fn unified_gutter(w: usize) -> u16 {
(w + 4) as u16
}
pub fn sbs_left_gutter(w: usize) -> u16 {
(w + 3) as u16
}
pub fn sbs_overhead(w: usize) -> u16 {
(2 * w + 8) as u16
}
#[derive(Debug, Clone, Copy)]
pub struct PaneGeom {
pub content_x_start: u16,
pub content_x_end: u16,
pub content_width: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SelPoint {
pub annotation_idx: usize,
pub char_offset: usize,
pub side: LineSide,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VisualSelection {
pub anchor: SelPoint,
pub head: SelPoint,
}
impl VisualSelection {
pub fn collapsed(point: SelPoint) -> Self {
Self {
anchor: point,
head: point,
}
}
pub fn ordered(&self) -> (SelPoint, SelPoint) {
if (self.anchor.annotation_idx, self.anchor.char_offset)
<= (self.head.annotation_idx, self.head.char_offset)
{
(self.anchor, self.head)
} else {
(self.head, self.anchor)
}
}
pub fn char_range(&self, ann_idx: usize, total_chars: usize) -> (usize, usize) {
let (start, end) = self.ordered();
let lo = if ann_idx == start.annotation_idx {
start.char_offset.min(total_chars)
} else {
0
};
let hi = if ann_idx == end.annotation_idx {
end.char_offset.min(total_chars)
} else {
total_chars
};
(lo, hi)
}
}
pub enum GapCursorHit {
Expander(GapId, ExpandDirection),
HiddenLines(GapId),
ExpandedContent(GapId),
}
#[derive(Debug, Clone)]
pub enum AnnotatedLine {
PrInfoLine { line_idx: usize },
IssueCommentsHeader,
IssueComment { comment_idx: usize },
ReviewCommentsHeader,
ReviewComment { comment_idx: usize },
RemoteReviewSummaryLine { summary_idx: usize },
FileHeader { file_idx: usize },
FileComment { file_idx: usize, comment_idx: usize },
Expander {
gap_id: GapId,
direction: ExpandDirection,
},
HiddenLines { gap_id: GapId, count: usize },
ExpandedContext { gap_id: GapId, line_idx: usize },
HunkHeader { file_idx: usize, hunk_idx: usize },
DiffLine {
file_idx: usize,
hunk_idx: usize,
line_idx: usize,
old_lineno: Option<u32>,
new_lineno: Option<u32>,
},
SideBySideLine {
file_idx: usize,
hunk_idx: usize,
del_line_idx: Option<usize>,
add_line_idx: Option<usize>,
old_lineno: Option<u32>,
new_lineno: Option<u32>,
},
LineComment {
file_idx: usize,
line: u32,
side: LineSide,
comment_idx: usize,
},
RemoteThreadLine { thread_idx: usize },
BinaryOrEmpty { file_idx: usize },
Spacing,
}
#[derive(Debug, Default, Clone)]
pub struct RemoteThreadIndex {
pub by_file:
std::collections::HashMap<String, std::collections::HashMap<(u32, LineSide), Vec<usize>>>,
}
impl RemoteThreadIndex {
#[allow(dead_code)]
pub fn threads_at(
&self,
path: &std::path::Path,
line: u32,
side: LineSide,
) -> Option<&Vec<usize>> {
self.by_file
.get(path.to_string_lossy().as_ref())
.and_then(|m| m.get(&(line, side)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FindSourceLineResult {
Exact(usize),
Nearest(usize),
NotFound,
}
pub fn annotation_side_default(annotation: &AnnotatedLine) -> LineSide {
match annotation {
AnnotatedLine::SideBySideLine {
new_lineno: None,
old_lineno: Some(_),
..
} => LineSide::Old,
AnnotatedLine::DiffLine {
new_lineno: None,
old_lineno: Some(_),
..
} => LineSide::Old,
_ => LineSide::New,
}
}
pub fn pr_commit_to_commit_info(commit: &crate::forge::traits::PullRequestCommit) -> CommitInfo {
CommitInfo {
id: commit.oid.clone(),
short_id: commit.short_oid.clone(),
branch_name: None,
summary: commit.summary.clone(),
body: None,
author: commit.author.clone(),
time: commit.timestamp.unwrap_or_else(chrono::Utc::now),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SinceLastReviewSelection {
range: Option<(usize, usize)>,
reviewed_index: usize,
message: String,
}
fn commits_since_last_review_selection(
commits_newest_first: &[crate::forge::traits::PullRequestCommit],
review_metadata: &crate::forge::traits::PullRequestReviewMetadata,
) -> Option<SinceLastReviewSelection> {
let viewer = review_metadata.viewer_login.as_deref()?;
let last_review = review_metadata
.reviews
.iter()
.filter(|review| {
review
.author
.as_deref()
.is_some_and(|author| author.eq_ignore_ascii_case(viewer))
})
.filter(|review| review.submitted_at.is_some() && review.commit_oid.is_some())
.max_by(|a, b| a.submitted_at.cmp(&b.submitted_at))?;
let reviewed_commit = last_review.commit_oid.as_deref()?;
let reviewed_index = commits_newest_first
.iter()
.position(|commit| commit.oid == reviewed_commit)?;
if reviewed_index == 0 {
return Some(SinceLastReviewSelection {
range: None,
reviewed_index,
message: "No commits since your last review".to_string(),
});
}
let count = reviewed_index;
let noun = if count == 1 { "commit" } else { "commits" };
Some(SinceLastReviewSelection {
range: Some((0, reviewed_index - 1)),
reviewed_index,
message: format!("Showing {count} {noun} since your last review — press Enter to see all"),
})
}
pub fn annotation_file_idx(annotation: &AnnotatedLine) -> Option<usize> {
match annotation {
AnnotatedLine::FileHeader { file_idx }
| AnnotatedLine::FileComment { file_idx, .. }
| AnnotatedLine::HunkHeader { file_idx, .. }
| AnnotatedLine::DiffLine { file_idx, .. }
| AnnotatedLine::SideBySideLine { file_idx, .. }
| AnnotatedLine::LineComment { file_idx, .. }
| AnnotatedLine::BinaryOrEmpty { file_idx } => Some(*file_idx),
AnnotatedLine::PrInfoLine { .. }
| AnnotatedLine::IssueCommentsHeader
| AnnotatedLine::IssueComment { .. }
| AnnotatedLine::ReviewCommentsHeader
| AnnotatedLine::ReviewComment { .. }
| AnnotatedLine::RemoteReviewSummaryLine { .. }
| AnnotatedLine::Expander { .. }
| AnnotatedLine::HiddenLines { .. }
| AnnotatedLine::ExpandedContext { .. }
| AnnotatedLine::RemoteThreadLine { .. }
| AnnotatedLine::Spacing => None,
}
}
#[cfg(test)]
pub fn find_source_line(
annotations: &[AnnotatedLine],
current_file: usize,
target_lineno: u32,
side: LineSide,
) -> FindSourceLineResult {
let mut best: Option<(usize, u32)> = None;
for (idx, annotation) in annotations.iter().enumerate() {
let (file_idx, old_lineno, new_lineno) = match annotation {
AnnotatedLine::DiffLine {
file_idx,
old_lineno,
new_lineno,
..
} => (*file_idx, *old_lineno, *new_lineno),
AnnotatedLine::SideBySideLine {
file_idx,
old_lineno,
new_lineno,
..
} => (*file_idx, *old_lineno, *new_lineno),
_ => continue,
};
if file_idx != current_file {
continue;
}
let candidate = match side {
LineSide::New => new_lineno,
LineSide::Old => old_lineno,
};
if let Some(ln) = candidate {
let dist = ln.abs_diff(target_lineno);
if dist == 0 {
return FindSourceLineResult::Exact(idx);
}
if best.is_none() || dist < best.unwrap().1 {
best = Some((idx, dist));
}
}
}
match best {
Some((idx, _)) => FindSourceLineResult::Nearest(idx),
None => FindSourceLineResult::NotFound,
}
}
fn is_decoration(annotation: &AnnotatedLine) -> bool {
matches!(
annotation,
AnnotatedLine::Spacing | AnnotatedLine::FileHeader { .. }
)
}
fn skip_decoration_forward(annotations: &[AnnotatedLine], start: usize, max_line: usize) -> usize {
let mut line = start;
while line < max_line && annotations.get(line).is_some_and(is_decoration) {
line += 1;
}
line
}
fn skip_decoration_backward(annotations: &[AnnotatedLine], start: usize) -> usize {
let mut line = start;
while line > 0 && annotations.get(line).is_some_and(is_decoration) {
line -= 1;
}
line
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
Normal,
Comment,
Command,
Search,
Help,
Confirm,
CommitSelect,
VisualSelect,
SubmitResolver,
SubmitConfirm,
SubmitActionPicker,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CommandCompletionState {
pub(crate) prefix: String,
pub(crate) matches: Vec<&'static str>,
pub(crate) selected: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffSource {
WorkingTree,
Staged,
Unstaged,
StagedAndUnstaged,
CommitRange(Vec<String>),
StagedUnstagedAndCommits(Vec<String>),
PullRequest(Box<PullRequestDiffSource>),
}
impl DiffSource {
pub fn includes_worktree_changes(&self) -> bool {
matches!(
self,
Self::WorkingTree
| Self::Unstaged
| Self::StagedAndUnstaged
| Self::StagedUnstagedAndCommits(_)
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullRequestDiffSource {
pub key: crate::forge::traits::PrSessionKey,
pub base_sha: String,
pub title: String,
pub url: String,
pub head_ref_name: String,
pub base_ref_name: String,
pub state: String,
pub closed: bool,
pub merged: bool,
}
impl PullRequestDiffSource {
pub fn from_details(details: &crate::forge::traits::PullRequestDetails) -> Self {
Self {
key: crate::forge::traits::PrSessionKey::from_details(details),
base_sha: details.base_sha.clone(),
title: details.title.clone(),
url: details.url.clone(),
head_ref_name: details.head_ref_name.clone(),
base_ref_name: details.base_ref_name.clone(),
state: details.state.clone(),
closed: details.closed,
merged: details.merged_at.is_some(),
}
}
pub fn read_only_reason(&self) -> Option<&'static str> {
if self.merged {
Some("merged")
} else if self.closed {
Some("closed")
} else {
None
}
}
pub fn is_read_only(&self) -> bool {
self.read_only_reason().is_some()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmAction {
CopyAndQuit,
}
fn bucket_mapping(
mapped: crate::forge::submit::MappedComment,
mappable: &mut Vec<crate::forge::submit::InlineComment>,
unmappable: &mut Vec<crate::forge::submit::UnmappableItem>,
) {
use crate::forge::submit::{MappedComment, UnmappableItem};
match mapped {
MappedComment::Inline(inline) => mappable.push(inline),
MappedComment::Unmappable {
comment,
file,
reason,
} => unmappable.push(UnmappableItem {
comment,
file,
reason,
}),
}
}
#[derive(Debug, Clone)]
pub struct SubmitState {
pub event: crate::forge::submit::SubmitEvent,
pub mappable: Vec<crate::forge::submit::InlineComment>,
pub unmappable: Vec<crate::forge::submit::UnmappableItem>,
pub resolver_choices: Vec<crate::forge::submit::ResolverAction>,
pub resolver_cursor: usize,
pub commit_id: String,
pub skip_confirm: bool,
}
pub const SUBMIT_PICKER_EVENTS: &[(&str, crate::forge::submit::SubmitEvent)] = &[
("Comment", crate::forge::submit::SubmitEvent::Comment),
("Approve", crate::forge::submit::SubmitEvent::Approve),
(
"Request changes",
crate::forge::submit::SubmitEvent::RequestChanges,
),
("Draft", crate::forge::submit::SubmitEvent::Draft),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusedPanel {
FileList,
Comments,
Diff,
CommitSelector,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetTab {
Local,
PullRequests,
}
#[derive(Debug)]
pub enum PrLoadEvent {
Initial {
canonical: crate::forge::traits::ForgeRepository,
result: std::result::Result<(Vec<crate::forge::traits::PullRequestSummary>, bool), String>,
},
LoadMore(std::result::Result<(Vec<crate::forge::traits::PullRequestSummary>, bool), String>),
}
#[derive(Debug, Clone)]
pub struct PrOpenRequest {
pub repository: crate::forge::traits::ForgeRepository,
pub pr_number: u64,
pub started_at: Instant,
}
impl PrOpenRequest {
pub fn matches(&self, repo: &crate::forge::traits::ForgeRepository, number: u64) -> bool {
self.pr_number == number && &self.repository == repo
}
}
#[derive(Debug)]
pub enum PrOpenEvent {
Done {
request: PrOpenRequest,
result: std::result::Result<
(
crate::forge::traits::PullRequestDetails,
String,
Vec<crate::forge::traits::PullRequestCommit>,
crate::forge::traits::PullRequestReviewMetadata,
crate::forge::traits::PullRequestInfo,
),
String,
>,
},
}
#[derive(Debug, Clone)]
pub struct PrCursorAnchor {
pub path: std::path::PathBuf,
pub new_lineno: Option<u32>,
pub old_lineno: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct PrReloadRequest {
pub repository: crate::forge::traits::ForgeRepository,
pub pr_number: u64,
pub head_sha: String,
pub started_at: Instant,
pub anchor: Option<PrCursorAnchor>,
pub restore_overview_cursor: Option<usize>,
}
#[derive(Debug)]
pub enum PrReloadEvent {
Done {
request: PrReloadRequest,
result: std::result::Result<
(
crate::forge::traits::PullRequestDetails,
String,
Vec<crate::forge::traits::PullRequestCommit>,
crate::forge::traits::PullRequestReviewMetadata,
crate::forge::traits::PullRequestInfo,
),
String,
>,
},
}
#[derive(Debug, Clone)]
pub struct PrRangeReloadRequest {
pub repository: crate::forge::traits::ForgeRepository,
pub pr_number: u64,
pub head_sha: String,
pub start_sha: String,
pub end_sha: String,
pub range: (usize, usize),
pub started_at: Instant,
pub anchor: Option<PrCursorAnchor>,
}
#[derive(Debug)]
pub enum PrRangeReloadEvent {
Done {
request: PrRangeReloadRequest,
result: std::result::Result<String, String>,
},
}
#[derive(Debug, Clone)]
pub struct SubmitInFlightState {
pub event: crate::forge::submit::SubmitEvent,
pub mappable: Vec<crate::forge::submit::InlineComment>,
pub summary_comment_ids: Vec<String>,
pub review_comment_ids: Vec<String>,
pub moved_to_summary_count: usize,
pub head_sha_snapshot: String,
pub repository: crate::forge::traits::ForgeRepository,
pub pr_number: u64,
pub started_at: Instant,
}
#[derive(Debug)]
pub enum PrSubmitEvent {
Done {
repository: crate::forge::traits::ForgeRepository,
pr_number: u64,
head_sha: String,
result: std::result::Result<crate::forge::traits::GhCreateReviewResponse, String>,
},
}
#[derive(Debug)]
pub enum PrThreadsEvent {
Done {
repository: crate::forge::traits::ForgeRepository,
pr_number: u64,
head_sha: String,
threads:
std::result::Result<Vec<crate::forge::remote_comments::RemoteReviewThread>, String>,
summaries:
std::result::Result<Vec<crate::forge::remote_comments::RemoteReviewSummary>, String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffViewMode {
Unified,
SideBySide,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CommitOrder {
#[default]
Descending,
Ascending,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CommitSelectionStart {
#[default]
All,
Oldest,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MessageType {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Message {
pub content: String,
pub message_type: MessageType,
pub expires_at: Option<Instant>,
}
const MESSAGE_TTL_INFO: Duration = Duration::from_secs(3);
const MESSAGE_TTL_WARNING: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct SessionFileState {
modified: Option<SystemTime>,
len: u64,
}
impl SessionFileState {
fn from_path(path: &Path) -> Result<Self> {
let metadata = std::fs::metadata(path)?;
Ok(Self {
modified: metadata.modified().ok(),
len: metadata.len(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StoredComment {
location: StoredCommentLocation,
comment: Comment,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum StoredCommentLocation {
Review,
File { path: PathBuf },
Line { path: PathBuf, line: u32 },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CommentVimPending {
#[default]
None,
Save,
Cancel,
}
pub struct App {
pub theme: Theme,
pub vcs: Box<dyn VcsBackend>,
pub vcs_info: VcsInfo,
pub session: ReviewSession,
pub(crate) persisted_session_snapshot: ReviewSession,
pub(crate) session_path: Option<PathBuf>,
pub(crate) session_file_state: Option<SessionFileState>,
pub review_watch_interval: Option<Duration>,
pub next_review_watch_at: Instant,
pub(crate) ephemeral_session_paths: HashSet<PathBuf>,
pub diff_files: Vec<DiffFile>,
pub diff_source: DiffSource,
pub pending_editor_target: Option<EditorTarget>,
pub input_mode: InputMode,
pub focused_panel: FocusedPanel,
pub diff_view_mode: DiffViewMode,
pub relative_line_numbers: bool,
pub file_list_state: FileListState,
pub comment_navigator_state: CommentNavigatorState,
pub diff_state: DiffState,
pub help_state: HelpState,
pub command_buffer: String,
pub(crate) command_completion: Option<CommandCompletionState>,
pub search_buffer: String,
pub last_search_pattern: Option<String>,
pub(crate) search_return_mode: InputMode,
pub comment_buffer: String,
pub comment_cursor: usize,
pub comment_vim_enabled: bool,
pub comment_tab_width: usize,
pub comment_vim_editor: Option<CommentVimEditor>,
pub comment_vim_command: Option<String>,
pub comment_vim_pending: CommentVimPending,
pub comment_type: CommentType,
pub comment_types: Vec<CommentTypeDefinition>,
pub comment_is_review_level: bool,
pub comment_is_file_level: bool,
pub comment_line: Option<(u32, LineSide)>,
pub editing_comment_id: Option<String>,
pub visual_selection: Option<VisualSelection>,
pub mouse_drag_active: bool,
pub comment_line_range: Option<(LineRange, LineSide)>,
pub commit_list: Vec<CommitInfo>,
pub commit_list_cursor: usize,
pub commit_list_scroll_offset: usize,
pub commit_list_viewport_height: usize,
pub commit_selection_range: Option<(usize, usize)>,
pub visible_commit_count: usize,
pub commit_page_size: usize,
pub has_more_commit: bool,
pub target_tab: TargetTab,
pub forge_repository: Option<ForgeRepository>,
pub repo_url_override: Option<ForgeRepository>,
pub canonical_resolved: bool,
pub pr_tab: PullRequestsTab,
pub pr_list_viewport_height: usize,
pub pr_list_inner_area: Option<ratatui::layout::Rect>,
pub pr_filter_draft: Option<String>,
pub pr_load_rx: Option<std::sync::mpsc::Receiver<PrLoadEvent>>,
pub pr_open_state: Option<PrOpenRequest>,
pub pr_open_rx: Option<std::sync::mpsc::Receiver<PrOpenEvent>>,
pub pr_reload_state: Option<PrReloadRequest>,
pub pr_reload_rx: Option<std::sync::mpsc::Receiver<PrReloadEvent>>,
pub forge_backend: Option<Box<dyn ForgeBackend>>,
pub forge_review_threads: Vec<crate::forge::remote_comments::RemoteReviewThread>,
pub forge_review_summaries: Vec<crate::forge::remote_comments::RemoteReviewSummary>,
pub forge_review_threads_loading: bool,
pub pr_threads_rx: Option<std::sync::mpsc::Receiver<PrThreadsEvent>>,
pub forge_config: crate::config::ForgeConfig,
pub username: String,
pub submit_state: Option<SubmitState>,
pub submit_picker_cursor: usize,
pub pr_submit_state: Option<SubmitInFlightState>,
pub pr_submit_rx: Option<std::sync::mpsc::Receiver<PrSubmitEvent>>,
pub current_pr_head: Option<String>,
pub pr_info: Option<crate::forge::traits::PullRequestInfo>,
pub should_quit: bool,
pub dirty: bool,
pub quit_warned: bool,
pub message: Option<Message>,
pub pending_confirm: Option<ConfirmAction>,
pub supports_keyboard_enhancement: bool,
pub show_file_list: bool,
pub is_pristine_mode: bool,
pub is_single_file_view: bool,
pub primed_walk_next: bool,
pub primed_walk_prev: bool,
pub down_released_since_arm: bool,
pub up_released_since_arm: bool,
pub cursor_line_highlight: bool,
pub leader_key: char,
pub scroll_offset: usize,
pub file_list_area: Option<ratatui::layout::Rect>,
pub comment_navigator_area: Option<ratatui::layout::Rect>,
pub diff_area: Option<ratatui::layout::Rect>,
pub file_list_inner_area: Option<ratatui::layout::Rect>,
pub comment_navigator_inner_area: Option<ratatui::layout::Rect>,
pub diff_inner_area: Option<ratatui::layout::Rect>,
pub commit_list_inner_area: Option<ratatui::layout::Rect>,
pub diff_row_to_annotation: Vec<usize>,
pub expanded_dirs: HashSet<String>,
pub expanded_top: HashMap<GapId, Vec<DiffLine>>,
pub expanded_bottom: HashMap<GapId, Vec<DiffLine>>,
pub file_line_count_cache: HashMap<usize, u32>,
pub line_annotations: Vec<AnnotatedLine>,
pub output_to_stdout: bool,
pub pending_stdout_output: Option<String>,
pub comment_cursor_screen_pos: Option<(u16, u16)>,
pub comment_input_annotation_offset: Option<(usize, usize, usize)>,
pub update_info: Option<UpdateInfo>,
pub pending_count: Option<usize>,
pub review_commits: Vec<CommitInfo>,
pub pr_commits: Vec<crate::forge::traits::PullRequestCommit>,
pub pr_last_reviewed_commit_index: Option<usize>,
pub pr_range_reload_state: Option<PrRangeReloadRequest>,
pub pr_range_reload_rx: Option<std::sync::mpsc::Receiver<PrRangeReloadEvent>>,
pub show_commit_selector: bool,
pub commit_order: CommitOrder,
pub commit_selection_start: CommitSelectionStart,
pub commit_diff_cache: HashMap<(usize, usize), Vec<DiffFile>>,
pub range_diff_files: Option<Vec<DiffFile>>,
pub saved_inline_selection: Option<(usize, usize)>,
pub path_filter: Option<String>,
pub export: ExportConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommentTypeDefinition {
pub id: String,
pub label: String,
pub definition: Option<String>,
pub color: Option<Color>,
}
#[derive(Default)]
pub struct FileListState {
pub list_state: ratatui::widgets::ListState,
pub scroll_x: usize,
pub viewport_width: usize, pub viewport_height: usize, pub max_content_width: usize, }
impl FileListState {
pub fn selected(&self) -> usize {
self.list_state.selected().unwrap_or(0)
}
pub fn select(&mut self, index: usize) {
self.list_state.select(Some(index));
}
pub fn scroll_left(&mut self, cols: usize) {
self.scroll_x = self.scroll_x.saturating_sub(cols);
}
pub fn scroll_right(&mut self, cols: usize) {
let max_scroll_x = self.max_content_width.saturating_sub(self.viewport_width);
self.scroll_x = (self.scroll_x.saturating_add(cols)).min(max_scroll_x);
}
}
#[derive(Default)]
pub struct CommentNavigatorState {
pub list_state: ratatui::widgets::ListState,
pub scroll_x: usize,
pub viewport_width: usize, pub viewport_height: usize, pub max_content_width: usize, }
impl CommentNavigatorState {
pub fn selected(&self) -> usize {
self.list_state.selected().unwrap_or(0)
}
pub fn select(&mut self, index: usize) {
self.list_state.select(Some(index));
}
pub fn scroll_left(&mut self, cols: usize) {
self.scroll_x = self.scroll_x.saturating_sub(cols);
}
pub fn scroll_right(&mut self, cols: usize) {
let max_scroll_x = self.max_content_width.saturating_sub(self.viewport_width);
self.scroll_x = (self.scroll_x.saturating_add(cols)).min(max_scroll_x);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommentNavigatorKey {
Review {
comment_idx: usize,
},
File {
file_idx: usize,
comment_idx: usize,
},
Line {
file_idx: usize,
line: u32,
side: LineSide,
comment_idx: usize,
},
Remote {
thread_idx: usize,
},
RemoteReview {
summary_idx: usize,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommentNavigatorKind {
Local(CommentType),
Remote { muted: bool },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommentNavigatorItem {
pub key: CommentNavigatorKey,
pub kind: CommentNavigatorKind,
pub target_annotation: usize,
pub path: Option<String>,
pub line: Option<u32>,
pub side: Option<LineSide>,
pub author: Option<String>,
}
#[derive(Debug)]
pub struct DiffState {
pub scroll_offset: usize,
pub scroll_x: usize,
pub cursor_line: usize,
pub current_file_idx: usize,
pub viewport_height: usize,
pub viewport_width: usize,
pub max_content_width: usize,
pub wrap_lines: bool,
pub visible_line_count: usize,
}
impl DiffState {
pub fn effective_visible_lines(&self) -> usize {
if self.visible_line_count > 0 {
self.visible_line_count
} else {
self.viewport_height.max(1)
}
}
pub fn effective_scroll_margin(&self, scroll_offset: usize) -> usize {
scroll_offset.min((self.effective_visible_lines() / 2).saturating_sub(1))
}
}
impl Default for DiffState {
fn default() -> Self {
Self {
scroll_offset: 0,
scroll_x: 0,
cursor_line: 0,
current_file_idx: 0,
viewport_height: 0,
viewport_width: 0,
max_content_width: 0,
wrap_lines: true,
visible_line_count: 0,
}
}
}
#[derive(Debug, Default)]
pub struct HelpState {
pub scroll_offset: usize,
pub viewport_height: usize,
pub total_lines: usize, pub(crate) searchable_lines: Vec<String>,
pub(crate) last_search_pattern: Option<String>,
pub(crate) current_match_line: Option<usize>,
}
enum CommentLocation {
Review {
index: usize,
},
File {
path: std::path::PathBuf,
index: usize,
},
Line {
path: std::path::PathBuf,
line: u32,
side: LineSide,
index: usize,
},
}
pub struct AppStartupOptions<'a> {
pub revisions: Option<&'a str>,
pub working_tree: bool,
pub path_filter: Option<&'a str>,
pub file_path: Option<&'a str>,
pub all_files: bool,
pub git_backend_preference: GitBackendPreference,
pub diff_whitespace_mode: DiffWhitespaceMode,
pub commit_selection: CommitSelectionStart,
pub pr_target: Option<&'a str>,
pub repo_url_override: Option<ForgeRepository>,
}
mod annotations;
mod comment_vim;
mod comments;
mod commits;
mod diff_load;
mod gaps;
mod init;
mod modes;
mod navigation;
mod pr;
mod reviewed;
mod search;
mod session;
mod submit;
mod tree;
mod visual;
#[cfg(test)]
mod tests;