use crate::diff::{self, FileDiff, Kind, Line as DLine};
use crate::gh::{self, Anchor, Pr, Thread};
use crate::highlight::{Colors, Highlighter};
use crate::theme::{Settings, Theme, Ui};
use crate::lsp::{self, Loc, Lsp};
use anyhow::{anyhow, bail, Context, Result};
use ratatui::{
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
layout::{Alignment, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Clear, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
DefaultTerminal, Frame,
};
use std::collections::{HashMap, HashSet};
use serde_json::{json, Value};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
mod agent;
mod amend;
mod rebase;
mod replacement;
mod seen;
pub(crate) use agent::{REPLACE_TOOLS, tools_for, answer_prompt, close_if_done, pane_screen, asking, working, claude_command, claude_wrote, fix_checks_prompt, review_prompt, turn, update_prompt, with_instructions};
mod build;
mod compose;
use compose::*;
use build::*;
mod files;
mod git;
mod github;
mod info;
mod links;
use info::*;
#[cfg(test)]
pub(crate) use info::info_lines_of;
mod keys;
mod layout;
mod mouse;
mod motion;
mod ra;
mod render;
mod review;
mod select;
mod term;
mod threads;
pub use term::{MOUSE, init_terminal, restore_terminal};
pub(crate) use term::{compose_with, in_zellij};
pub(crate) use github::prefetch_github;
pub(crate) use render::{chip, popup_frame};
use {agent::ClaudeState, git::with_reload, keys::*, layout::*, motion::*, ra::*, seen::Seen, threads::*};
use term::*;
#[cfg(test)]
pub(crate) mod tests;
const DEFAULT_CONTEXT: u32 = 3;
const CTRL_W: char = '\u{17}';
const INFO: usize = usize::MAX - 1;
pub(super) const MARK_STALE_LINE: u8 = 1;
pub(super) const MARK_LINE: u8 = 2;
pub(super) const MARK_STALE_DOT: u8 = 3;
pub(super) const MARK_CLAUDE_DOT: u8 = 4;
pub(super) const MARK_DOT: u8 = 5;
const SUMMARY: usize = usize::MAX;
const DRAFT: usize = usize::MAX - 2;
const FACTS: usize = usize::MAX - 3;
const MORE_CONVERSATION: usize = usize::MAX - 4;
const LATEST: usize = 3;
const DESCRIPTION: usize = 0;
const ON_SUMMARY: &str = "summary";
const CLAUDE_REVIEW: usize = usize::MAX;
const CLOSING_NOTE: usize = usize::MAX - 1;
const CLOSING_PR: usize = usize::MAX - 2;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Pos {
row: usize,
col: usize,
}
struct Row {
file: usize,
line: DLine,
thread: Option<usize>,
syn: Vec<Option<Style>>,
links: Vec<(usize, usize, String)>,
info: Option<usize>,
edge: bool,
nowrap: bool,
main: Option<usize>,
attached: bool,
}
impl Row {
fn new(file: usize, line: DLine) -> Row {
Row { file, line, thread: None, syn: vec![], links: vec![], info: None, edge: false, nowrap: false, main: None, attached: false }
}
fn text(file: usize, kind: Kind, text: String) -> Row {
Row::new(file, DLine { kind, old: None, new: None, text })
}
}
struct CursorKey {
line: Option<(String, Option<u32>, Option<u32>)>,
thread: Option<(u64, usize)>,
info: Option<(Option<String>, usize, usize)>,
section: Option<(usize, usize)>,
}
enum Job {
Refresh,
Pull,
Resolve(String, bool),
Viewed(String, bool),
React(String, &'static str, bool),
Discard(String),
DeleteComment(String),
Preview(String),
Reactions(String),
Rebase,
Retarget(String),
Rerun,
Labels,
SetField(gh::Field, String, bool),
Push(bool),
Post(Post),
}
impl Job {
fn doing(&self) -> String {
match self {
Job::Refresh => "refreshing…".into(),
Job::Pull => "updating the checkout…".into(),
Job::Resolve(_, on) => if *on { "resolving…" } else { "unresolving…" }.into(),
Job::Viewed(_, on) => if *on { "marking as viewed…" } else { "unmarking…" }.into(),
Job::React(..) => "reacting…".into(),
Job::Discard(_) => "discarding…".into(),
Job::DeleteComment(_) => "deleting…".into(),
Job::Preview(url) => format!("loading {url}…"),
Job::Reactions(_) => "asking GitHub for its reactions…".into(),
Job::Rebase => "rebasing…".into(),
Job::Retarget(base) => format!("the PR's base is {base} now: fetching it…"),
Job::Rerun => "rerunning the failed checks…".into(),
Job::Labels => "asking GitHub for labels, assignees, reviewers…".into(),
Job::SetField(..) => "editing the PR…".into(),
Job::Push(force) => if *force { "force-pushing…" } else { "pushing…" }.into(),
Job::Post(p) => match p {
Post::Thread { review: true, .. } | Post::Reply { review: true, .. } => "adding to your pending review…",
Post::Thread { .. } | Post::Reply { .. } | Post::Converse(_) => "posting…",
Post::Summary(_) => "adding to your review summary…",
Post::Edit { .. } => "updating the comment…",
Post::Submit(..) => "submitting the review…",
}
.into(),
}
}
}
enum Target {
Do(Action),
Run(Job),
Row(usize),
Loc(Loc),
Discard,
Comment(usize, usize),
Claude(agent::Ask),
Want(&'static str),
ClaudeNotes,
ClaudeQuestion,
ReplaceRemote(String, bool),
ClaudeFresh,
ClaudePane,
ClaudeCheck,
ClaudeStop,
GitView,
AmendMenu,
Amend,
AmendAsk,
AmendAll,
RebaseClaude,
RebaseAbort,
GitTakeTheirs(bool),
GitMerge,
ClaudeDismissOutdated(bool),
GitReady,
GitApproveRuns,
DescApprove(bool),
DescDiscard,
CloseDiscard,
Nothing,
}
#[derive(Clone, Copy, PartialEq, Debug, Default)]
pub(super) enum View {
#[default]
All,
Claude,
Pending,
}
struct Item {
label: String,
detail: Vec<String>,
target: Target,
}
impl Item {
fn new(label: impl Into<String>, target: Target) -> Item {
Item { label: label.into(), detail: vec![], target }
}
}
pub(super) fn commit_of(url: &str) -> Option<&str> {
url.rsplit_once("/commits/").map(|(_, s)| s).or_else(|| url.strip_prefix("commit:"))
}
impl App {
pub(super) fn commit_short(&self, sha: &str) -> String {
self.commits.iter().find(|c| c.sha == sha).map_or_else(|| sha.chars().take(7).collect(), |c| c.short.clone())
}
}
struct Picker {
title: &'static str,
items: Vec<Item>,
query: String,
sel: usize,
}
impl Picker {
fn new(title: &'static str, items: Vec<Item>) -> Self {
Picker { title, items, query: String::new(), sel: 0 }
}
}
#[derive(Default)]
pub struct App {
base: String,
full_base: String,
pub follow_base: Option<String>,
to: Option<String>,
commits: Vec<files::Commit>,
unsigned: Vec<String>,
awaiting: usize,
failing: usize,
commit_shown: Option<(usize, usize)>,
commit_cur: usize,
commit_anchor: Option<usize>,
in_commits: bool,
files: Vec<FileDiff>,
extra: HashMap<(String, usize), i64>,
view_extra: HashMap<(String, usize), i64>,
whole: HashSet<String>,
collapsed: HashSet<String>,
rows: Vec<Row>,
anchor: Pos,
head: Pos,
want_col: usize,
select: bool,
count: usize,
pending: Option<char>,
prompt: Option<String>,
picker: Option<Picker>,
wanting: Option<Action>,
github_base: Option<String>,
refresh_at: Option<std::time::Instant>,
dirty: files::Dirty,
rekeyed: Vec<String>,
search: String,
msg: String,
top: usize,
left: usize,
pin: bool,
bar_counts: bool,
code_at: Option<usize>,
code_left: usize,
height: usize,
width: u16,
action: Option<Action>,
job: Option<Job>,
review: Option<String>,
viewed: HashMap<String, String>,
split: bool,
right: bool,
margin_pref: Option<bool>,
margin_active: bool,
margin_w: usize,
margin_w_pref: Option<usize>,
margin_float_pref: Option<bool>,
files_pref: Option<bool>,
files_w_pref: Option<usize>,
files_w: usize,
files_top: usize,
list_hits: Vec<files::Hit>,
files_cur: Option<files::Spot>,
files_focus: bool,
files_sel: Option<files::Spot>,
pane_file: Option<PathBuf>,
margin_float: bool,
code_head: Pos,
margin_anchor: HashMap<usize, usize>,
marks: HashMap<(usize, bool), u8>,
geom: mouse::Geom,
drows: Vec<DRow>,
d_of: Vec<usize>,
emph: HashMap<usize, Vec<(usize, usize)>>,
settings: Settings,
quit: bool,
pub merge: bool,
pr: Option<Pr>,
threads: Vec<Thread>,
info: Vec<InfoLine>,
info_collapsed: bool,
sync: String,
sections_folded: HashSet<usize>,
commit_msgs: Vec<String>,
hl: Highlighter,
ui: Ui,
hl_cache: HashMap<(String, String), Colors>,
md_cache: std::cell::RefCell<HashMap<String, Vec<crate::md::MdLine>>>,
wrap_w: usize,
toggled: HashSet<u64>,
claude: ClaudeState,
theirs: bool,
view: View,
pr_state: Option<State>,
replacement: Option<crate::replace::Record>,
replacement_mtime: Option<std::time::SystemTime>,
summary_draft: String,
me: String,
seen: Seen,
hx: Option<HxPane>,
writing: Option<(Action, term::Writing)>,
quit_armed: Option<std::time::Instant>,
tree_checked: Option<std::time::Instant>,
tree_seen: Option<(Option<std::time::SystemTime>, u64)>,
msgs: Vec<(String, String)>,
root: PathBuf,
lsp: Option<Lsp>,
lsp_ready: bool,
inflight: HashMap<u64, Req>,
progress: HashMap<String, (String, String)>,
popup: Option<(String, Vec<Line<'static>>, u16)>,
help: Option<(String, bool)>,
jumps: Vec<Pos>,
alphabet: Vec<char>,
labels: Vec<(Pos, Pos, [char; 2])>,
label_first: Option<char>,
github: Option<std::sync::mpsc::Receiver<(u64, github::Fetched)>>,
github_gen: std::sync::Arc<std::sync::atomic::AtomicU64>,
github_poke: Option<std::sync::mpsc::Sender<()>>,
conversation_all: bool,
gone_at: usize,
revealed: HashSet<String>,
fields: Option<(gh::Choices, gh::Choices, gh::Choices)>,
fields_at: Option<(String, usize)>,
amend_then: bool,
hid_again: HashSet<String>,
base_said: String,
github_stale: bool,
}
impl App {
pub fn new(base: String, pr: Option<Pr>) -> Result<Self> {
crate::progress(Some("loading the diff"));
let files = diff::load(&base, None, DEFAULT_CONTEXT)?;
let mut app = Self::with(base, files);
app.load_commits();
let theme = Theme::load();
(app.hl, app.ui) = (Highlighter::new(&theme), theme.ui());
app.settings = crate::theme::settings();
app.load_pane();
app.claude.path = crate::claude::path(pr.as_ref().map(|p| p.number)).ok();
app.load_seen();
let session = app.claude.path.as_ref().and_then(|p| crate::claude::load_session(p, &app.root));
let other_skill = session.as_ref().is_some_and(|s| !s.1);
app.claude.session = session.filter(|s| s.1).map(|s| s.0);
if pr.is_some() {
crate::progress(Some("loading comments and PR info"));
}
app.pr = pr;
let n = app.pr.as_ref().map(|p| p.number);
let cached = n.is_some_and(|n| app.show_cached(n));
match cached {
true => app.load_claude(),
false => app.load_threads(),
}
app.rekey_commits(); if let Some(n) = n {
app.start_github_watch(n, cached);
}
if other_skill && (app.msg.is_empty() || app.github_stale) {
app.msg = "dv's Claude skill has changed since this PR's Claude session began: the next Claude run starts a new one".into();
}
app.refresh_sync();
app.build();
match Lsp::start(&app.root) {
Ok((l, id)) => {
app.lsp = Some(l);
app.inflight.insert(id, Req::Init);
}
Err(e) => app.msg = format!("{e:#}"),
}
Ok(app)
}
fn log_msg(&mut self) {
let m = self.msg.trim();
if m.is_empty() || m.ends_with('…') || self.msgs.last().is_some_and(|l| l.1 == m) {
return; }
let at = crate::claude::now().get(11..19).unwrap_or("").to_owned();
self.msgs.push((at, m.to_owned()));
if self.msgs.len() > 100 {
self.msgs.remove(0);
}
}
fn messages(&mut self) {
self.log_msg();
let mut md: String = self.msgs.iter().rev().map(|(at, m)| format!("- `{at}` {m}\n")).collect();
if md.is_empty() {
md = "_no messages yet_\n".into();
}
if let Some(log) = &self.claude.log {
md += &format!("\nThe last background Claude run's log: `{}` (its errors: `.err`)\n", log.display());
md += &format!("\nBackground Claude runs so far: {}, ${:.2} (runs in a pane aren't counted)\n", self.claude.runs, self.claude.spent);
}
self.popup = Some((" messages (UTC) · j/k scroll ".into(), self.hover_lines(&md), 0));
}
pub fn notice(&mut self, msg: String) {
self.msg = msg;
}
fn with(base: String, files: Vec<FileDiff>) -> Self {
let mut app = App {
full_base: base.clone(),
base,
files,
height: 1,
width: 80,
right: true,
margin_w: MARGIN_MIN,
wrap_w: 100,
root: std::env::current_dir().unwrap_or_default(),
alphabet: jump_alphabet(),
..Default::default()
};
app.shape();
app.build();
app
}
pub(super) fn hold_quit(&mut self) {
if !self.quit || self.writing.is_none() {
return;
}
let again = self.quit_armed.take().is_some_and(|t| (QUIT_AFTER..QUIT_WITHIN).contains(&t.elapsed()));
if !again {
(self.quit, self.merge, self.quit_armed) = (false, false, Some(std::time::Instant::now()));
self.msg = "you're writing in the editor's pane: save or :cq it first (again: leaves anyway)".into();
}
}
pub(super) fn press(&mut self, k: KeyEvent) {
self.key(k);
if !self.quit {
self.quit_armed = None;
}
}
pub fn run(&mut self, term: &mut DefaultTerminal) -> Result<()> {
while !self.quit {
self.log_msg();
self.watch_seen();
term.draw(|f| self.draw(f))?;
if let Some(job) = self.job.take() {
self.run_job(job);
continue;
}
if event::poll(Duration::from_millis(100))? {
match event::read()? {
Event::Key(k) if k.kind == KeyEventKind::Press => self.press(k),
Event::Mouse(m) => self.mouse(m),
_ => {}
}
}
self.hold_quit();
self.lsp_poll();
self.watch_tree();
self.watch_claude();
self.watch_github();
if self.refresh_at.is_some_and(|t| t <= std::time::Instant::now()) {
self.refresh_at = None;
if let Some(p) = &self.github_poke {
_ = p.send(());
}
}
self.watch_replacement();
self.poll_claude();
self.watch_claude_pane();
self.act(term);
}
self.seen.current.push(String::new()); self.watch_seen();
if let Some(pr) = &self.pr {
_ = crate::claude::set_looked(pr.number); }
Ok(())
}
fn repo_url(&self) -> Option<&str> {
self.pr.as_ref().and_then(|p| p.url.rsplit_once("/pull/")).map(|(r, _)| r)
}
fn file_path(&self) -> Option<&str> {
Some(&self.files.get(self.rows[self.head.row].file)?.path)
}
fn len(&self, r: usize) -> usize {
self.rows[r].line.text.chars().count()
}
fn chars(&self, r: usize) -> Vec<char> {
self.rows[r].line.text.chars().collect()
}
fn open_target(&self) -> Option<String> {
let row = &self.rows[self.head.row];
let f = self.files.get(row.file)?;
let line = self.rows[self.head.row..]
.iter()
.take_while(|r| r.file == row.file)
.find_map(|r| r.line.new)
.unwrap_or(1);
let col = if row.line.new.is_some() { self.head.col + 1 } else { 1 };
Some(format!("{}:{line}:{col}", f.path))
}
}
const QUIT_AFTER: std::time::Duration = std::time::Duration::from_millis(300);
const QUIT_WITHIN: std::time::Duration = std::time::Duration::from_secs(5);
fn wrap(text: &str, w: usize) -> Vec<String> {
let mut out = vec![];
for line in text.lines() {
if line.chars().count() <= w {
out.push(line.to_owned());
continue;
}
let body = line.trim_start();
let indent = &line[..line.len() - body.len()];
let mut cur = indent.to_owned();
for word in body.split(' ') {
if cur.len() > indent.len() && cur.chars().count() + 1 + word.chars().count() > w {
out.push(std::mem::replace(&mut cur, indent.to_owned()));
} else if cur.len() > indent.len() {
cur.push(' ');
}
cur.push_str(word);
}
out.push(cur);
}
if out.is_empty() {
out.push(String::new());
}
out
}
fn flip<T: std::hash::Hash + Eq>(s: &mut HashSet<T>, x: T) {
if !s.remove(&x) {
s.insert(x);
}
}
fn quote(text: &str) -> String {
if text.trim().is_empty() {
return String::new(); }
let q: Vec<String> = text.lines().map(|l| format!("> {l}").trim_end().to_owned()).collect();
format!("{}\n\n", q.join("\n"))
}
fn disk_line(cache: &mut HashMap<PathBuf, String>, p: &Path, n: u32) -> String {
let text = cache.entry(p.to_owned()).or_insert_with(|| std::fs::read_to_string(p).unwrap_or_default());
text.lines().nth(n as usize).unwrap_or("").to_owned()
}