mod detail;
mod embedded;
#[cfg(test)]
mod embedded_tests;
mod form;
#[cfg(test)]
mod identity_tests;
mod runs;
#[cfg(test)]
pub(crate) mod tests;
mod view;
pub(crate) use detail::{Handled, Hits};
pub(crate) use embedded::Control;
pub use form::{Field, Form, Input, template};
pub use runs::Runs;
use crate::config::ToolName;
use std::{
future::Future,
io::Read,
path::PathBuf,
pin::Pin,
time::{Duration, Instant},
};
use crossterm::event::{
Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use futures_util::StreamExt;
use ratatui::{layout::Position, widgets::TableState};
use serde_json::Value;
use time::OffsetDateTime;
use tokio_util::sync::CancellationToken;
use crate::{
config::HumanToolKind,
human,
store::{self, HumanClaim, Request, RequestView},
tools::human::{CommandLine, Content, ToolResult},
types::RequestId,
};
const REFRESH: Duration = Duration::from_secs(1);
const SPIN: Duration = Duration::from_millis(100);
const SCROLL_PAGE: u16 = 10;
const WHEEL_ROWS: i16 = 3;
#[derive(Debug, Clone, PartialEq)]
pub enum Mode {
Request,
Form(Form),
Leave,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
List,
Detail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Area {
Instruction,
Tools,
Fields,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Job {
Claim { id: RequestId },
Run {
id: RequestId,
tool: ToolName,
claim: bool,
},
Submit {
id: RequestId,
result: Value,
claim: bool,
},
Release { ids: Vec<RequestId>, quit: bool },
}
#[derive(Debug)]
pub enum Outcome {
Claimed {
result: Result<HumanClaim, String>,
},
SavedLocally {
request: Box<Request>,
error: String,
},
Ran {
tool: ToolName,
claimed: Option<HumanClaim>,
result: Result<ToolResult, String>,
},
Submitted {
id: RequestId,
claimed: Option<HumanClaim>,
result: Result<Box<Request>, String>,
},
Released {
ids: Vec<RequestId>,
quit: bool,
failed: Vec<(RequestId, String)>,
},
}
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
None,
Refresh,
Quit,
Start(Job),
Edit(String),
Capture(bool),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Tool {
pub name: ToolName,
pub kind: HumanToolKind,
pub description: String,
pub declared: Runs,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Output {
pub title: String,
pub text: String,
pub error: bool,
}
struct Busy {
label: String,
since: Instant,
cancel: CancellationToken,
}
impl Drop for Busy {
fn drop(&mut self) {
self.cancel.cancel();
}
}
type Commands = std::collections::BTreeMap<ToolName, Result<CommandLine, String>>;
pub struct Review {
state: PathBuf,
repo: Option<PathBuf>,
reviewer: crate::types::ReviewerId,
mode: Mode,
focus: Focus,
area: Area,
technical: bool,
standalone: bool,
waiting: Vec<RequestView>,
list: TableState,
open: Option<RequestId>,
request: Option<RequestView>,
tool: usize,
output: Option<Output>,
scroll: u16,
taken: Vec<RequestId>,
commands: Option<(RequestId, Commands)>,
busy: Option<Busy>,
notice: Option<(String, bool)>,
refreshed: Option<OffsetDateTime>,
error: Option<String>,
submitted: bool,
drafts: std::collections::BTreeMap<crate::runtime::Verdict, Form>,
field_scroll: u16,
instruction_scroll: u16,
mouse_capture: bool,
hits: view::Hits,
}
impl Review {
pub fn new(
state: PathBuf,
repo: Option<PathBuf>,
reviewer: crate::types::ReviewerId,
open: Option<RequestId>,
) -> Self {
Self {
state,
repo,
reviewer,
mode: Mode::Request,
focus: if open.is_some() {
Focus::Detail
} else {
Focus::List
},
area: Area::Fields,
technical: false,
standalone: true,
waiting: Vec::new(),
list: TableState::default(),
open,
request: None,
tool: 0,
output: None,
scroll: 0,
taken: Vec::new(),
commands: None,
busy: None,
notice: None,
refreshed: None,
error: None,
submitted: false,
drafts: std::collections::BTreeMap::new(),
field_scroll: 0,
instruction_scroll: 0,
mouse_capture: true,
hits: view::Hits::default(),
}
}
pub fn mode(&self) -> &Mode {
&self.mode
}
pub fn focus(&self) -> Focus {
self.focus
}
pub fn area(&self) -> Area {
self.area
}
pub fn busy(&self) -> bool {
self.busy.is_some()
}
pub async fn refresh(&mut self) -> Action {
let mut result = Ok(());
if self.standalone {
result = store::read_waiting_originals(&self.state, self.repo.as_deref())
.await
.map(|waiting| self.set_waiting(waiting));
}
if let (Ok(()), Some(id)) = (&result, self.open.clone()) {
result = match store::read_request(&self.state, &id).await {
Ok(view) if view.request.status() == crate::types::RequestStatus::WaitingHuman => {
store::read_original(&self.state, &view).await
}
other => other,
}
.map(|view| {
self.open = Some(view.request.id.clone());
self.show(view)
});
}
let now = OffsetDateTime::now_utc();
match result {
Ok(()) => {
self.refreshed = Some(now);
self.error = None;
}
Err(error) => {
self.error = Some(format!(
"read failed at {}: {error}",
crate::monitor::clock(now)
));
return Action::None;
}
}
self.resolve_commands().await;
self.tool = self.tool.min(self.tools().len().saturating_sub(1));
if self.submitted && self.open.is_none() {
self.submitted = false;
if self.waiting.is_empty() {
return Action::Quit;
}
}
Action::None
}
pub(crate) async fn resolve_commands(&mut self) {
let Some(view) = &self.request else {
return;
};
let id = view.request.id.clone();
if view.request.status() != crate::types::RequestStatus::WaitingHuman
|| view.request.human_definition.is_none()
|| self.commands.as_ref().is_some_and(|(seen, _)| seen == &id)
{
return;
}
let commands = async {
let (receipts, _) = human::open(&self.state, &id).await?;
human::tool_commands(&receipts, &id).await
}
.await;
self.commands = Some((id, commands.unwrap_or_default()));
}
pub(crate) fn command(&self, tool: &str) -> Option<&Result<CommandLine, String>> {
self.commands
.as_ref()
.and_then(|(_, commands)| commands.get(tool))
}
fn set_waiting(&mut self, waiting: Vec<RequestView>) {
let selected = self.selected().map(|view| view.request.id.clone());
let index = selected
.and_then(|id| waiting.iter().position(|view| view.request.id == id))
.or(self.list.selected());
self.waiting = waiting;
self.list.select(
index
.map(|index| index.min(self.waiting.len().saturating_sub(1)))
.or(Some(0))
.filter(|_| !self.waiting.is_empty()),
);
}
fn selected(&self) -> Option<&RequestView> {
self.list
.selected()
.and_then(|index| self.waiting.get(index))
}
pub fn tools(&self) -> Vec<Tool> {
self.request
.as_ref()
.map_or_else(Vec::new, |view| tools(&view.request))
}
fn mine(&self) -> bool {
let claim = self.request.as_ref().and_then(|view| view.claim.as_ref());
claim.is_some_and(|claim| claim.reviewer == self.reviewer)
}
fn actionable(&self) -> Result<(RequestId, bool), String> {
let view = self
.request
.as_ref()
.ok_or("The request is still loading.")?;
if view.request.status() != crate::types::RequestStatus::WaitingHuman {
return Err(format!(
"The request is {}; there is nothing to review.",
view.request.status()
));
}
match &view.claim {
Some(claim) if claim.reviewer != self.reviewer => Err(format!(
"Claimed by {}; read-only for {}.",
claim.reviewer, self.reviewer
)),
claim => Ok((view.request.id.clone(), claim.is_none())),
}
}
fn notify(&mut self, message: impl Into<String>, error: bool) -> Action {
self.notice = Some((message.into(), error));
Action::None
}
fn quit(&mut self) -> Action {
if self.taken.is_empty() {
return Action::Quit;
}
self.stop_editing();
self.mode = Mode::Leave;
Action::None
}
fn stop_editing(&mut self) {
if !matches!(self.mode, Mode::Form(_)) {
return;
}
if let Mode::Form(form) = std::mem::replace(&mut self.mode, Mode::Request) {
self.drafts.insert(form.verdict, form);
}
}
pub fn key(&mut self, key: KeyEvent) -> Action {
let interrupt =
key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c');
if key.code == KeyCode::F(2) {
self.mouse_capture = !self.mouse_capture;
return Action::Capture(self.mouse_capture);
}
if self.busy.is_some() {
return match self.key_detail(key) {
Handled::Action(action) => action,
Handled::Back | Handled::Quit | Handled::Pass => Action::None,
};
}
if interrupt {
return if self.mode == Mode::Leave {
Action::Quit
} else {
self.quit()
};
}
if !matches!(self.mode, Mode::Form(_)) {
self.notice = None;
}
if self.mode == Mode::Leave {
return match key.code {
KeyCode::Char('k' | 'n' | 'q') => Action::Quit,
KeyCode::Char('u' | 'r' | 'y') => Action::Start(Job::Release {
ids: self.taken.clone(),
quit: true,
}),
KeyCode::Esc => {
self.mode = Mode::Request;
Action::None
}
_ => Action::None,
};
}
match self.focus {
Focus::List => self.list_key(key),
Focus::Detail => match self.key_detail(key) {
Handled::Action(action) => action,
Handled::Back => {
self.focus = Focus::List;
Action::Refresh
}
Handled::Quit => self.quit(),
Handled::Pass => Action::None,
},
}
}
fn list_key(&mut self, key: KeyEvent) -> Action {
match key.code {
KeyCode::Char('q') => self.quit(),
KeyCode::Char('r') => Action::Refresh,
KeyCode::Down | KeyCode::Char('j') => {
let next = self.list.selected().map_or(0, |index| index + 1);
if next < self.waiting.len() {
self.list.select(Some(next));
}
Action::None
}
KeyCode::Up | KeyCode::Char('k') => {
self.list.select_previous();
Action::None
}
KeyCode::Enter | KeyCode::Right => match self.selected().cloned() {
Some(view) => self.open_view(view),
None => Action::None,
},
KeyCode::Tab => match self.selected().cloned() {
Some(view) => {
let action = self.open_view(view);
self.area = Area::Tools;
action
}
None => Action::None,
},
_ => Action::None,
}
}
pub fn mouse(&mut self, event: MouseEvent) -> Action {
if !self.mouse_capture || self.mode == Mode::Leave {
return Action::None;
}
let point = Position::new(event.column, event.row);
let hits = std::mem::take(&mut self.hits);
let action = self.mouse_at(&hits, event.kind, point);
self.hits = hits;
action
}
fn mouse_at(&mut self, hits: &view::Hits, kind: MouseEventKind, point: Position) -> Action {
let in_detail = hits.detail.contains(point);
match kind {
MouseEventKind::ScrollDown | MouseEventKind::ScrollUp => {
let down = kind == MouseEventKind::ScrollDown;
if in_detail {
let delta = if down { WHEEL_ROWS } else { -WHEEL_ROWS };
if hits.review.instruction.contains(point) {
self.scroll_instruction(delta);
} else if hits.review.fields.contains(point) {
self.scroll_single(delta, true);
} else if hits.review.tools.contains(point) {
self.scroll_single(delta, false);
}
} else if hits.list.contains(point) && self.focus == Focus::List {
return self.list_key(KeyEvent::from(if down {
KeyCode::Down
} else {
KeyCode::Up
}));
}
Action::None
}
MouseEventKind::Down(MouseButton::Left) => {
if in_detail {
if hits.review.close.contains(point) {
if self.busy() {
return self.control(Control::Cancel);
}
return match self.key_detail(KeyEvent::from(KeyCode::Esc)) {
Handled::Back => {
self.focus = Focus::List;
Action::Refresh
}
Handled::Action(action) => action,
Handled::Quit | Handled::Pass => Action::None,
};
}
return self.click(&hits.review, point);
}
if self.focus == Focus::Detail && (self.busy() || self.editing()) {
return Action::None;
}
if hits.preview.contains(point) {
return match self.selected().cloned() {
Some(view) => self.open_view(view),
None => Action::None,
};
}
let Some(&(_, index)) = hits.rows.iter().find(|(rect, _)| rect.contains(point))
else {
if hits.list.contains(point) {
self.focus = Focus::List;
}
return Action::None;
};
let open = self.focus == Focus::List && self.list.selected() == Some(index);
self.focus = Focus::List;
self.list.select(Some(index));
match (open, self.waiting.get(index).cloned()) {
(true, Some(view)) => self.open_view(view),
_ => Action::None,
}
}
_ => Action::None,
}
}
fn open_view(&mut self, view: RequestView) -> Action {
if self.open.as_ref() != Some(&view.request.id) {
self.mode = Mode::Request;
self.area = Area::Fields;
self.tool = 0;
self.output = None;
self.scroll = 0;
self.field_scroll = 0;
self.instruction_scroll = 0;
self.drafts.clear();
}
self.open = Some(view.request.id.clone());
self.request = Some(view);
self.focus = Focus::Detail;
Action::Refresh
}
fn move_tool(&mut self, down: bool) {
self.tool = if down {
(self.tool + 1).min(self.tools().len().saturating_sub(1))
} else {
self.tool.saturating_sub(1)
};
}
fn run_selected(&mut self) -> Action {
let Some(tool) = self.tools().into_iter().nth(self.tool) else {
return self.notify("This request declares no Human tools.", true);
};
self.run(tool.name)
}
fn owned_request(&self) -> Result<RequestId, String> {
match self.actionable()? {
(id, false) => Ok(id),
(_, true) => Err("Claim this request before reviewing it.".into()),
}
}
fn release(&mut self) -> Action {
match (self.actionable(), self.mine()) {
(Ok((id, _)), true) => Action::Start(Job::Release {
ids: vec![id],
quit: false,
}),
(Ok(_), false) => self.notify("The request is not claimed.", true),
(Err(error), _) => self.notify(error, true),
}
}
fn run(&mut self, tool: ToolName) -> Action {
match self.owned_request() {
Ok(id) => Action::Start(Job::Run {
id,
tool,
claim: false,
}),
Err(error) => self.notify(error, true),
}
}
fn submit(&mut self, result: Value) -> Action {
match self.owned_request() {
Ok(id) => Action::Start(Job::Submit {
id,
result,
claim: false,
}),
Err(error) => {
if let Mode::Form(form) = &mut self.mode {
form.error = Some(error);
}
Action::None
}
}
}
pub fn start(&mut self, job: Job) -> impl Future<Output = Outcome> + 'static {
let cancel = CancellationToken::new();
let label = match &job {
Job::Claim { .. } => "Claiming the review".into(),
Job::Run { tool, .. } => format!("Running {tool}"),
Job::Submit { result, .. } => {
format!(
"Submitting {}",
result["verdict"].as_str().unwrap_or_default()
)
}
Job::Release { .. } => "Releasing the claim".into(),
};
self.busy = Some(Busy {
label,
since: Instant::now(),
cancel: cancel.clone(),
});
let state = self.state.clone();
let reviewer = self.reviewer.clone();
async move { job.run(state, reviewer, cancel).await }
}
fn took(&mut self, claim: Option<HumanClaim>) {
if let (Some(_), Some(id)) = (claim, &self.open)
&& !self.taken.contains(id)
{
self.taken.push(id.clone());
}
}
pub fn finish(&mut self, outcome: Outcome) -> Action {
self.busy = None;
match outcome {
Outcome::SavedLocally { request, error } => {
self.taken.retain(|id| id != &request.id);
if let Some(view) = &mut self.request {
view.request = *request;
view.claim = None;
}
self.mode = Mode::Request;
self.notice = Some((error, true));
Action::Refresh
}
Outcome::Claimed { result } => {
match result {
Ok(claim) => {
self.took(Some(claim.clone()));
if let Some(view) = &mut self.request {
view.claim = Some(claim);
}
if !matches!(self.mode, Mode::Form(_)) {
self.mode = Mode::Request;
}
self.notice = Some((
"Claimed. Fill GREEN or RED fields, then Submit.".into(),
false,
));
}
Err(error) => self.notice = Some((error, true)),
}
Action::Refresh
}
Outcome::Ran {
tool,
claimed,
result,
} => {
self.took(claimed);
self.commands = None;
let launched = Content::Launch { launched: true };
self.notice = Some(match &result {
Ok(result) if result.is_error() => (
format!("{tool} failed; a tool error is not a verdict."),
true,
),
Ok(result) if result.content().contains(&launched) => {
(format!("{tool} launched."), false)
}
Ok(_) => (format!("{tool} finished."), false),
Err(_) => (format!("{tool} did not run."), true),
});
self.output = Some(output(&tool, result));
self.scroll = 0;
Action::Refresh
}
Outcome::Submitted {
id,
claimed,
result,
} => {
self.took(claimed);
match result {
Ok(request) => {
self.taken.retain(|taken| *taken != id);
self.notice = Some((
format!(
"Submitted {} for {} ({}).",
request.status(),
request.eval_id,
request.id
),
false,
));
self.open = None;
self.request = None;
self.output = None;
self.mode = Mode::Request;
self.focus = Focus::List;
self.submitted = true;
}
Err(error) => {
if let Mode::Form(form) = &mut self.mode {
form.error = Some(error);
} else {
self.notice = Some((error, true));
}
}
}
Action::Refresh
}
Outcome::Released { ids, quit, failed } => {
self.taken.retain(|taken| !ids.contains(taken));
if failed.is_empty() {
if quit {
return Action::Quit;
}
self.notice = Some(("Claim released.".into(), false));
} else {
let lines = failed.iter().map(|(id, error)| format!("{id}: {error}"));
self.notice = Some((lines.collect::<Vec<_>>().join("\n"), true));
self.taken.extend(failed.into_iter().map(|(id, _)| id));
}
Action::Refresh
}
}
}
pub fn paste(&mut self, text: &str) {
if self.focus == Focus::Detail && self.area == Area::Fields {
self.paste_single(text);
}
}
pub fn edited(&mut self, text: Result<String, String>) -> Action {
let Mode::Form(form) = &mut self.mode else {
return Action::None;
};
match text {
Err(error) => form.error = Some(error),
Ok(text) if text.trim().is_empty() => {
form.error = Some("The edited file was empty; nothing was submitted.".into())
}
Ok(text) => {
form.set_json(text);
match form.result() {
Ok(result) => return self.submit(result),
Err(error) => form.error = Some(error),
}
}
}
Action::None
}
}
impl Job {
async fn run(
self,
state: PathBuf,
reviewer: crate::types::ReviewerId,
cancel: CancellationToken,
) -> Outcome {
let mut claimed = None;
match self {
Job::Claim { id } => {
let result = async {
let (receipts, _) = human::open(&state, &id).await?;
human::claim(&receipts, &id, &reviewer).await
}
.await;
Outcome::Claimed { result }
}
Job::Run {
id,
tool,
claim: first,
} => {
let result = async {
let (receipts, _) = human::open(&state, &id).await?;
if first {
claimed = Some(human::claim(&receipts, &id, &reviewer).await?);
}
human::run_human_tool(&receipts, &id, &reviewer, &tool, cancel).await
}
.await;
Outcome::Ran {
tool,
claimed,
result,
}
}
Job::Submit {
id,
result,
claim: first,
} => {
let result = async {
if first {
let (receipts, _) = human::open(&state, &id).await?;
claimed = Some(human::claim(&receipts, &id, &reviewer).await?);
}
human::submit_and_publish(&state, &id, &reviewer, &result, cancel)
.await
.map(Box::new)
}
.await;
if let Err(error) = &result
&& let Ok(view) = store::read_request(&state, &id).await
&& matches!(
view.request.status(),
crate::types::RequestStatus::Green | crate::types::RequestStatus::Red
)
&& view.execution.as_ref().is_some_and(|execution| {
execution.reviewer.as_deref() == Some(reviewer.as_str())
})
{
return Outcome::SavedLocally {
request: Box::new(view.request),
error: error.clone(),
};
}
Outcome::Submitted {
id,
claimed,
result,
}
}
Job::Release { ids, quit } => {
let mut failed = Vec::new();
for id in &ids {
let release = async {
let view = store::read_request(&state, id).await?;
if view.claim.is_none_or(|claim| claim.reviewer != reviewer) {
return Ok(());
}
let (receipts, _) = human::open(&state, id).await?;
human::unclaim(&receipts, id, &reviewer).await.map(drop)
};
if let Err(error) = release.await {
failed.push((id.clone(), error));
}
}
Outcome::Released { ids, quit, failed }
}
}
}
}
pub fn tools(request: &Request) -> Vec<Tool> {
use crate::store::definitions::HumanTool;
let Some(artifacts) = request
.human_definition
.as_ref()
.and_then(|definition| definition.artifacts.value())
else {
return Vec::new();
};
let mut tools: Vec<_> = artifacts
.iter()
.flat_map(|(id, artifact)| {
artifact
.views
.value()
.and_then(|views| views.human_tools.value())
.into_iter()
.flatten()
.filter_map(move |(operation, tool)| {
let (kind, description) = match tool {
HumanTool::Command(tool) => (tool.kind, &tool.description),
HumanTool::Builtin(tool) => (tool.kind, &tool.description),
};
Some(Tool {
name: format!("{operation}_{id}").parse().ok()?,
kind,
description: description.replace("{artifactName}", id.as_str()),
declared: Runs::parse(artifacts, id, tool),
})
})
})
.collect();
tools.sort_by(|a, b| a.name.cmp(&b.name));
tools
}
pub fn shell<'a>(words: impl IntoIterator<Item = &'a str>) -> String {
let quote = |word: &str| {
let plain = !word.is_empty()
&& word
.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./=:,+@%{}".contains(c));
if plain {
word.to_owned()
} else {
format!("\"{}\"", word.replace('"', "\"\""))
}
};
words.into_iter().map(quote).collect::<Vec<_>>().join(" ")
}
fn output(tool: &str, result: Result<ToolResult, String>) -> Output {
match result {
Ok(result) => Output {
title: if result.is_error() {
format!("{tool} · tool error")
} else {
tool.to_owned()
},
text: result
.content()
.iter()
.map(|content| match content {
Content::Text { text } => text.trim_end().to_owned(),
Content::Launch { launched: true } => "launched".into(),
Content::Launch { launched: false } => "not launched".into(),
})
.collect::<Vec<_>>()
.join("\n"),
error: result.is_error(),
},
Err(error) => Output {
title: format!("{tool} · failed"),
text: error,
error: true,
},
}
}
async fn edit(text: String) -> Result<String, String> {
let file =
crate::platform::private_temp_file("artifactize-fields-").map_err(|e| e.to_string())?;
std::fs::write(file.path(), text).map_err(|e| e.to_string())?;
crate::platform::run_editor(&crate::platform::selected_editor(), file.path())
.await
.map_err(|e| format!("cannot run the editor: {e}"))?;
let mut bytes = Vec::new();
crate::platform::open_regular(file.path())
.and_then(|file| {
file.take(crate::human::FIELDS_READ_BYTES)
.read_to_end(&mut bytes)
})
.map_err(|e| e.to_string())?;
if bytes.len() > crate::human::MAX_RESULT_BYTES {
return Err(format!(
"Human fields exceed {} bytes.",
crate::human::MAX_RESULT_BYTES
));
}
String::from_utf8(bytes).map_err(|e| e.to_string())
}
pub async fn run(
state: PathBuf,
repo: Option<PathBuf>,
reviewer: crate::types::ReviewerId,
open: Option<RequestId>,
cancellation: CancellationToken,
) -> Result<(), String> {
let mut terminal = ratatui::try_init().map_err(|e| {
let _ = crossterm::terminal::disable_raw_mode();
format!("cannot start the review: {e}")
})?;
let guard = crate::monitor::InputGuard;
let result = async {
let review = Review::new(state, repo, reviewer, open);
crate::monitor::input::protocols(review.mouse_capture, true)?;
drive(&mut terminal, review, cancellation).await
}
.await;
drop(guard);
drop(terminal);
ratatui::try_restore().map_err(|e| e.to_string())?;
result
}
async fn drive(
terminal: &mut ratatui::DefaultTerminal,
mut review: Review,
cancellation: CancellationToken,
) -> Result<(), String> {
let mut changes = crate::changes::Subscription::new(&review.state).await;
let mut events = EventStream::new();
let mut tick = tokio::time::interval(REFRESH);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut spin = tokio::time::interval(SPIN);
let mut pending: Option<Pin<Box<dyn Future<Output = Outcome>>>> = None;
let mut action = Action::Refresh;
loop {
loop {
action = match action {
Action::None => break,
Action::Quit => return Ok(()),
Action::Refresh => {
tick.reset();
review.refresh().await
}
Action::Start(job) => {
pending = Some(Box::pin(review.start(job)));
Action::None
}
Action::Edit(text) => {
drop(events);
let edited = crate::monitor::suspend(terminal, edit(text)).await;
crate::monitor::input::protocols(review.mouse_capture, true)?;
events = EventStream::new();
review.edited(edited?)
}
Action::Capture(capture) => {
crate::monitor::input::protocols(capture, true)?;
Action::None
}
};
}
terminal
.draw(|frame| review.draw(frame))
.map_err(|e| e.to_string())?;
action = tokio::select! {
_ = cancellation.cancelled() => Action::Quit,
outcome = async { pending.as_mut().expect("pending job").await },
if pending.is_some() =>
{
pending = None;
if matches!(outcome, Outcome::Submitted { .. }) {
crate::monitor::repaint(terminal)?;
}
review.finish(outcome)
}
_ = spin.tick(), if review.busy() => Action::None,
_ = tick.tick() => Action::None,
change = changes.next() => match change {
crate::changes::Change::StateInvalidated | crate::changes::Change::Resync => {
Action::Refresh
}
crate::changes::Change::SessionInvalidated(_) => Action::None,
},
event = events.next() => match event {
Some(Ok(Event::Key(key))) if key.kind == KeyEventKind::Press => review.key(key),
Some(Ok(Event::Mouse(mouse))) => review.mouse(mouse),
Some(Ok(Event::Paste(text))) => {
review.paste(&text);
Action::None
}
Some(Ok(_)) => Action::None,
Some(Err(error)) => return Err(error.to_string()),
None => Action::Quit,
},
};
}
}