aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/api/mod.rs42
-rw-r--r--src/app/input.rs2
-rw-r--r--src/app/mod.rs319
-rw-r--r--src/main.rs2
-rw-r--r--src/pane.rs11
-rw-r--r--src/persist.rs31
-rw-r--r--src/workspace.rs40
7 files changed, 362 insertions, 85 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs
index e2c29490..bf95bf29 100644
--- a/src/api/mod.rs
+++ b/src/api/mod.rs
@@ -9,6 +9,7 @@ use std::time::Duration;
use tracing::{debug, error, info, warn};
use regex::Regex;
+use tokio::sync::mpsc;
use crate::api::schema::{
ErrorBody, ErrorResponse, Method, PaneAgentStateChangedEvent, PaneOutputMatchedEvent, Request,
@@ -23,6 +24,8 @@ pub struct ApiRequestMessage {
pub respond_to: std::sync::mpsc::Sender<String>,
}
+pub type ApiRequestSender = mpsc::UnboundedSender<ApiRequestMessage>;
+
#[derive(Clone, Default)]
pub struct EventHub {
inner: std::sync::Arc<std::sync::Mutex<EventHubState>>,
@@ -99,7 +102,7 @@ impl Drop for ServerHandle {
}
pub fn start_server(
- api_tx: std::sync::mpsc::Sender<ApiRequestMessage>,
+ api_tx: ApiRequestSender,
event_hub: EventHub,
) -> std::io::Result<ServerHandle> {
let path = socket_path();
@@ -151,7 +154,7 @@ fn prepare_socket_path(path: &Path) -> std::io::Result<()> {
fn handle_connection(
mut stream: UnixStream,
- api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>,
+ api_tx: &ApiRequestSender,
event_hub: &EventHub,
) -> std::io::Result<()> {
let mut line = String::new();
@@ -205,7 +208,7 @@ fn handle_connection(
}
}
-fn handle_request(request: Request, api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>) -> String {
+fn handle_request(request: Request, api_tx: &ApiRequestSender) -> String {
let request_id = request.id.clone();
match request.method {
Method::Ping(_) => serde_json::to_string(&SuccessResponse {
@@ -226,7 +229,7 @@ fn handle_request(request: Request, api_tx: &std::sync::mpsc::Sender<ApiRequestM
fn wait_for_output(
request_id: String,
params: crate::api::schema::PaneWaitForOutputParams,
- api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>,
+ api_tx: &ApiRequestSender,
) -> String {
let deadline = params
.timeout_ms
@@ -316,7 +319,7 @@ fn stream_subscriptions(
mut stream: UnixStream,
request_id: String,
params: crate::api::schema::EventsSubscribeParams,
- api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>,
+ api_tx: &ApiRequestSender,
event_hub: &EventHub,
) -> std::io::Result<()> {
let mut subscriptions = Vec::with_capacity(params.subscriptions.len());
@@ -411,7 +414,7 @@ impl ActiveSubscription {
subscription: Subscription,
request_id: &str,
index: usize,
- api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>,
+ api_tx: &ApiRequestSender,
_event_hub: &EventHub,
) -> Result<Self, ErrorResponse> {
match subscription {
@@ -514,7 +517,7 @@ impl ActiveSubscription {
fn poll(
&mut self,
- api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>,
+ api_tx: &ApiRequestSender,
event_hub: &EventHub,
) -> Option<serde_json::Value> {
match self {
@@ -542,10 +545,7 @@ impl ActiveEventSubscription {
}
impl ActiveOutputMatchedSubscription {
- fn poll(
- &mut self,
- api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>,
- ) -> Option<SubscriptionEventEnvelope> {
+ fn poll(&mut self, api_tx: &ApiRequestSender) -> Option<SubscriptionEventEnvelope> {
let read = pane_read(
format!("{}:read", self.request_prefix),
&self.pane_id,
@@ -581,10 +581,7 @@ impl ActiveOutputMatchedSubscription {
}
impl ActiveAgentStateChangedSubscription {
- fn poll(
- &mut self,
- api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>,
- ) -> Option<SubscriptionEventEnvelope> {
+ fn poll(&mut self, api_tx: &ApiRequestSender) -> Option<SubscriptionEventEnvelope> {
let pane = pane_get(
format!("{}:pane", self.request_prefix),
&self.pane_id,
@@ -621,7 +618,7 @@ fn pane_read(
source: crate::api::schema::ReadSource,
lines: Option<u32>,
strip_ansi: bool,
- api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>,
+ api_tx: &ApiRequestSender,
) -> Result<crate::api::schema::PaneReadResult, ErrorResponse> {
let response = dispatch_to_app(
Request {
@@ -663,7 +660,7 @@ fn pane_read(
fn pane_get(
request_id: String,
pane_id: &str,
- api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>,
+ api_tx: &ApiRequestSender,
) -> Result<crate::api::schema::PaneInfo, ErrorResponse> {
let response = dispatch_to_app(
Request {
@@ -699,10 +696,7 @@ fn pane_get(
})
}
-fn dispatch_to_app(
- request: Request,
- api_tx: &std::sync::mpsc::Sender<ApiRequestMessage>,
-) -> String {
+fn dispatch_to_app(request: Request, api_tx: &ApiRequestSender) -> String {
let (respond_to, response_rx) = std::sync::mpsc::channel();
if let Err(err) = api_tx.send(ApiRequestMessage {
request,
@@ -748,7 +742,7 @@ mod tests {
#[test]
fn ping_request_returns_pong() {
- let (tx, _rx) = std::sync::mpsc::channel();
+ let (tx, _rx) = mpsc::unbounded_channel();
let response = handle_request(
Request {
id: "req_1".into(),
@@ -764,7 +758,7 @@ mod tests {
#[test]
fn request_dispatches_to_app_channel() {
- let (tx, rx) = std::sync::mpsc::channel();
+ let (tx, mut rx) = mpsc::unbounded_channel();
let request = Request {
id: "req_2".into(),
method: Method::WorkspaceList(crate::api::schema::EmptyParams::default()),
@@ -773,7 +767,7 @@ mod tests {
let request_for_thread = request.clone();
let thread = std::thread::spawn(move || handle_request(request_for_thread, &tx));
- let msg = rx.recv().unwrap();
+ let msg = rx.blocking_recv().unwrap();
assert_eq!(msg.request.id, "req_2");
msg.respond_to
.send(
diff --git a/src/app/input.rs b/src/app/input.rs
index 56eda4f2..9827d4d2 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -1418,7 +1418,7 @@ mod tests {
}
fn app_for_mouse_test() -> App {
- let (_api_tx, api_rx) = std::sync::mpsc::channel();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&Config::default(),
true,
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 617db561..293a252b 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -8,17 +8,22 @@ mod actions;
mod input;
pub mod state;
+use std::future::pending;
use std::io;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
use std::time::{Duration, Instant};
-const RENDER_INTERVAL: Duration = Duration::from_millis(16);
+const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16);
+const ANIMATION_INTERVAL: Duration = Duration::from_millis(100);
+const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100);
const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500);
const SIDEBAR_DOUBLE_CLICK_WINDOW: Duration = Duration::from_millis(350);
use crossterm::terminal;
use ratatui::layout::Rect;
use ratatui::DefaultTerminal;
-use tokio::sync::mpsc;
+use tokio::sync::{mpsc, Notify};
use tracing::{error, info};
use crate::config::Config;
@@ -32,7 +37,7 @@ pub struct App {
pub state: AppState,
pub event_tx: mpsc::Sender<AppEvent>,
event_rx: mpsc::Receiver<AppEvent>,
- api_rx: std::sync::mpsc::Receiver<crate::api::ApiRequestMessage>,
+ api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>,
event_hub: crate::api::EventHub,
last_focus: Option<(usize, crate::layout::PaneId)>,
no_session: bool,
@@ -42,14 +47,50 @@ pub struct App {
toast_deadline: Option<Instant>,
last_git_remote_status_refresh: Instant,
last_sidebar_divider_click: Option<Instant>,
+ next_resize_poll: Instant,
+ next_animation_tick: Option<Instant>,
+ last_render_at: Option<Instant>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
}
enum LoopEvent {
- RenderTick,
+ Timer,
Internal(AppEvent),
+ Api(crate::api::ApiRequestMessage),
RawInput(crate::raw_input::RawInputEvent),
InputClosed,
- Idle,
+ RenderRequested,
+}
+
+async fn recv_raw_input_or_pending(
+ input_rx: Option<&mut mpsc::Receiver<crate::raw_input::RawInputEvent>>,
+) -> Option<crate::raw_input::RawInputEvent> {
+ match input_rx {
+ Some(rx) => rx.recv().await,
+ None => pending().await,
+ }
+}
+
+async fn sleep_until_or_pending(deadline: Option<Instant>) {
+ match deadline {
+ Some(deadline) => tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await,
+ None => pending().await,
+ }
+}
+
+fn api_request_changes_ui(request: &crate::api::schema::Request) -> bool {
+ use crate::api::schema::Method;
+
+ matches!(
+ &request.method,
+ Method::WorkspaceCreate(_)
+ | Method::WorkspaceFocus(_)
+ | Method::WorkspaceRename(_)
+ | Method::WorkspaceClose(_)
+ | Method::PaneSplit(_)
+ | Method::PaneClose(_)
+ )
}
/// Resolve the palette from config: base theme + optional custom overrides.
@@ -89,17 +130,26 @@ impl App {
config: &Config,
no_session: bool,
config_diagnostic: Option<String>,
- api_rx: std::sync::mpsc::Receiver<crate::api::ApiRequestMessage>,
+ api_rx: tokio::sync::mpsc::UnboundedReceiver<crate::api::ApiRequestMessage>,
event_hub: crate::api::EventHub,
) -> Self {
let (prefix_code, prefix_mods) = config.prefix_key();
let (event_tx, event_rx) = mpsc::channel::<AppEvent>(64);
+ let render_notify = Arc::new(Notify::new());
+ let render_dirty = Arc::new(AtomicBool::new(false));
// Try to restore previous session
let (workspaces, active, selected) = if no_session {
(Vec::new(), None, 0)
} else if let Some(snap) = crate::persist::load() {
- let ws = crate::persist::restore(&snap, 24, 80, event_tx.clone());
+ let ws = crate::persist::restore(
+ &snap,
+ 24,
+ 80,
+ event_tx.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ );
if ws.is_empty() {
info!("session file found but no workspaces restored");
(Vec::new(), None, 0)
@@ -199,12 +249,17 @@ impl App {
event_rx,
last_git_remote_status_refresh: Instant::now(),
last_sidebar_divider_click: None,
+ next_resize_poll: Instant::now() + RESIZE_POLL_INTERVAL,
+ next_animation_tick: None,
+ last_render_at: None,
api_rx,
event_hub,
last_focus,
no_session,
input_rx: None,
last_terminal_size: terminal::size().ok(),
+ render_notify,
+ render_dirty,
}
}
@@ -213,28 +268,25 @@ impl App {
self.input_rx = Some(crate::raw_input::spawn_input_reader());
}
- let mut render_tick = tokio::time::interval(RENDER_INTERVAL);
- render_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut needs_render = true;
while !self.state.should_quit {
- // Drain internal events first so API reads observe fresh pane state.
- let had_internal_events = self.drain_internal_events();
-
- let mut had_api_requests = false;
- while let Ok(msg) = self.api_rx.try_recv() {
- had_api_requests = true;
- let response = self.handle_api_request(msg.request);
- let _ = msg.respond_to.send(response);
+ if self.render_dirty.load(Ordering::Acquire) {
+ needs_render = true;
}
- if had_internal_events || had_api_requests {
+ // Drain internal events first so API reads observe fresh pane state.
+ if self.drain_internal_events() {
+ needs_render = true;
+ }
+ if self.drain_api_requests() {
needs_render = true;
}
self.sync_focus_events();
- self.handle_resize_poll();
- if self.handle_frame_timers() {
+
+ let now = Instant::now();
+ if self.handle_scheduled_tasks(now) {
needs_render = true;
}
@@ -250,52 +302,65 @@ impl App {
needs_render = true;
}
- if needs_render {
+ let now = Instant::now();
+ self.sync_animation_timer(now);
+
+ if needs_render && self.can_render_now(now) {
+ self.render_dirty.swap(false, Ordering::AcqRel);
terminal.draw(|frame| {
crate::ui::compute_view(&mut self.state, frame.area());
crate::ui::render(&self.state, frame);
})?;
+ self.last_render_at = Some(now);
needs_render = false;
continue;
}
+ let next_deadline = self.next_loop_deadline(now, needs_render);
let event = {
let input_rx = self.input_rx.as_mut();
tokio::select! {
- _ = render_tick.tick() => LoopEvent::RenderTick,
+ maybe_api = self.api_rx.recv() => match maybe_api {
+ Some(msg) => LoopEvent::Api(msg),
+ None => LoopEvent::Timer,
+ },
maybe_ev = self.event_rx.recv() => match maybe_ev {
Some(ev) => LoopEvent::Internal(ev),
- None => LoopEvent::Idle,
+ None => LoopEvent::Timer,
},
- maybe_input = async {
- match input_rx {
- Some(rx) => rx.recv().await,
- None => None,
- }
- } => match maybe_input {
+ maybe_input = recv_raw_input_or_pending(input_rx) => match maybe_input {
Some(input) => LoopEvent::RawInput(input),
None => LoopEvent::InputClosed,
},
+ _ = sleep_until_or_pending(next_deadline) => LoopEvent::Timer,
+ _ = self.render_notify.notified() => LoopEvent::RenderRequested,
}
};
match event {
- LoopEvent::RenderTick => {
- self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1);
- needs_render = true;
- }
+ LoopEvent::Timer => {}
LoopEvent::Internal(ev) => {
self.handle_internal_event(ev);
needs_render = true;
}
+ LoopEvent::Api(msg) => {
+ if self.handle_api_request_message(msg) {
+ needs_render = true;
+ }
+ }
LoopEvent::RawInput(input) => {
- self.handle_raw_input_event(input).await;
- needs_render = true;
+ if self.handle_raw_input_batch(input).await {
+ needs_render = true;
+ }
}
LoopEvent::InputClosed => {
self.input_rx = None;
}
- LoopEvent::Idle => {}
+ LoopEvent::RenderRequested => {
+ if self.render_dirty.load(Ordering::Acquire) {
+ needs_render = true;
+ }
+ }
}
}
@@ -312,58 +377,170 @@ impl App {
Ok(())
}
- async fn handle_raw_input_event(&mut self, event: crate::raw_input::RawInputEvent) {
+ fn drain_api_requests(&mut self) -> bool {
+ let mut changed = false;
+ while let Ok(msg) = self.api_rx.try_recv() {
+ changed |= self.handle_api_request_message(msg);
+ }
+ changed
+ }
+
+ fn handle_api_request_message(&mut self, msg: crate::api::ApiRequestMessage) -> bool {
+ let changed = api_request_changes_ui(&msg.request);
+ let response = self.handle_api_request(msg.request);
+ let _ = msg.respond_to.send(response);
+ changed
+ }
+
+ async fn handle_raw_input_batch(&mut self, first: crate::raw_input::RawInputEvent) -> bool {
+ let mut changed = self.handle_raw_input_event(first).await;
+
+ while let Some(rx) = self.input_rx.as_mut() {
+ match rx.try_recv() {
+ Ok(event) => changed |= self.handle_raw_input_event(event).await,
+ Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
+ Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
+ self.input_rx = None;
+ break;
+ }
+ }
+ }
+
+ changed
+ }
+
+ async fn handle_raw_input_event(&mut self, event: crate::raw_input::RawInputEvent) -> bool {
match event {
crate::raw_input::RawInputEvent::Key(key) => {
if key.kind == crossterm::event::KeyEventKind::Press {
self.handle_key(key).await;
+ true
+ } else {
+ false
}
}
- crate::raw_input::RawInputEvent::Paste(text) => self.handle_paste(text).await,
- crate::raw_input::RawInputEvent::Mouse(mouse) => self.handle_mouse(mouse),
- crate::raw_input::RawInputEvent::Unsupported => {}
+ crate::raw_input::RawInputEvent::Paste(text) => {
+ self.handle_paste(text).await;
+ true
+ }
+ crate::raw_input::RawInputEvent::Mouse(mouse) => {
+ self.handle_mouse(mouse);
+ true
+ }
+ crate::raw_input::RawInputEvent::Unsupported => false,
}
}
- fn handle_resize_poll(&mut self) {
- let Ok(size) = terminal::size() else { return };
+ fn handle_resize_poll(&mut self) -> bool {
+ let Ok(size) = terminal::size() else {
+ return false;
+ };
if self.last_terminal_size != Some(size) {
self.last_terminal_size = Some(size);
+ return true;
}
+ false
}
- fn handle_frame_timers(&mut self) -> bool {
+ fn handle_scheduled_tasks(&mut self, now: Instant) -> bool {
let mut changed = false;
+ self.sync_animation_timer(now);
+
+ if now >= self.next_resize_poll {
+ changed |= self.handle_resize_poll();
+ self.next_resize_poll = now + RESIZE_POLL_INTERVAL;
+ }
+
if self
.config_diagnostic_deadline
- .is_some_and(|deadline| Instant::now() >= deadline)
+ .is_some_and(|deadline| now >= deadline)
{
self.config_diagnostic_deadline = None;
self.state.config_diagnostic = None;
changed = true;
}
- if self
- .toast_deadline
- .is_some_and(|deadline| Instant::now() >= deadline)
- {
+ if self.toast_deadline.is_some_and(|deadline| now >= deadline) {
self.toast_deadline = None;
self.state.toast = None;
changed = true;
}
- if self.last_git_remote_status_refresh.elapsed() >= GIT_REMOTE_STATUS_REFRESH_INTERVAL {
+ if self
+ .next_animation_tick
+ .is_some_and(|deadline| now >= deadline)
+ {
+ self.state.spinner_tick = self.state.spinner_tick.wrapping_add(1);
+ self.next_animation_tick = Some(now + ANIMATION_INTERVAL);
+ changed = true;
+ }
+
+ if self
+ .git_refresh_deadline()
+ .is_some_and(|deadline| now >= deadline)
+ {
for ws in &mut self.state.workspaces {
ws.refresh_git_ahead_behind();
}
- self.last_git_remote_status_refresh = Instant::now();
+ self.last_git_remote_status_refresh = now;
changed = true;
}
+ self.sync_animation_timer(now);
changed
}
+ fn sync_animation_timer(&mut self, now: Instant) {
+ if self.active_workspace_has_animation() {
+ self.next_animation_tick
+ .get_or_insert(now + ANIMATION_INTERVAL);
+ } else {
+ self.next_animation_tick = None;
+ }
+ }
+
+ fn active_workspace_has_animation(&self) -> bool {
+ self.state
+ .active
+ .and_then(|idx| self.state.workspaces.get(idx))
+ .is_some_and(Workspace::has_working_pane)
+ }
+
+ fn can_render_now(&self, now: Instant) -> bool {
+ match self.last_render_at {
+ Some(last_render_at) => now.duration_since(last_render_at) >= MIN_RENDER_INTERVAL,
+ None => true,
+ }
+ }
+
+ fn git_refresh_deadline(&self) -> Option<Instant> {
+ (!self.state.workspaces.is_empty())
+ .then_some(self.last_git_remote_status_refresh + GIT_REMOTE_STATUS_REFRESH_INTERVAL)
+ }
+
+ fn next_loop_deadline(&self, now: Instant, needs_render: bool) -> Option<Instant> {
+ let render_deadline = if needs_render {
+ self.last_render_at
+ .map(|last_render_at| last_render_at + MIN_RENDER_INTERVAL)
+ .filter(|deadline| *deadline > now)
+ } else {
+ None
+ };
+
+ [
+ Some(self.next_resize_poll),
+ self.config_diagnostic_deadline,
+ self.toast_deadline,
+ self.next_animation_tick,
+ self.git_refresh_deadline(),
+ render_deadline,
+ ]
+ .into_iter()
+ .flatten()
+ .min()
+ }
+
fn drain_internal_events(&mut self) -> bool {
let mut had_event = false;
while let Ok(ev) = self.event_rx.try_recv() {
@@ -1096,7 +1273,14 @@ impl App {
focus: bool,
) -> std::io::Result<usize> {
let (rows, cols) = self.state.estimate_pane_size();
- let mut ws = Workspace::new(initial_cwd, rows, cols, self.event_tx.clone())?;
+ let mut ws = Workspace::new(
+ initial_cwd,
+ rows,
+ cols,
+ self.event_tx.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ )?;
ws.refresh_git_ahead_behind();
self.state.workspaces.push(ws);
let idx = self.state.workspaces.len() - 1;
@@ -1251,3 +1435,36 @@ fn agent_name(agent: crate::detect::Agent) -> String {
}
.to_string()
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn raw_input_waits_when_reader_is_gone() {
+ let result =
+ tokio::time::timeout(Duration::from_millis(20), recv_raw_input_or_pending(None)).await;
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn read_only_api_requests_do_not_force_rerender() {
+ let read_only = crate::api::schema::Request {
+ id: "req_1".into(),
+ method: crate::api::schema::Method::WorkspaceList(
+ crate::api::schema::EmptyParams::default(),
+ ),
+ };
+ let mutating = crate::api::schema::Request {
+ id: "req_2".into(),
+ method: crate::api::schema::Method::WorkspaceFocus(
+ crate::api::schema::WorkspaceTarget {
+ workspace_id: "w_1".into(),
+ },
+ ),
+ };
+
+ assert!(!api_request_changes_ui(&read_only));
+ assert!(api_request_changes_ui(&mutating));
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index 5756026b..60044981 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -249,7 +249,7 @@ fn main() -> io::Result<()> {
init_logging();
- let (api_tx, api_rx) = std::sync::mpsc::channel();
+ let (api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let event_hub = api::EventHub::default();
let _api_server = api::start_server(api_tx, event_hub.clone())?;
diff --git a/src/pane.rs b/src/pane.rs
index 9d62a0c9..7a5a75dd 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1,13 +1,13 @@
use std::cell::Cell;
use std::io::{BufWriter, Read, Write};
use std::sync::{
- atomic::{AtomicU16, AtomicU32, Ordering},
+ atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering},
Arc, RwLock,
};
use bytes::Bytes;
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
-use tokio::sync::mpsc;
+use tokio::sync::{mpsc, Notify};
use tracing::{debug, error, info, warn};
use crate::detect::{Agent, AgentState};
@@ -167,6 +167,8 @@ impl PaneRuntime {
cols: u16,
cwd: std::path::PathBuf,
events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
) -> std::io::Result<Self> {
let pty_system = native_pty_system();
let pair = pty_system
@@ -241,6 +243,8 @@ impl PaneRuntime {
let parser = parser.clone();
let screen_content = screen_content.clone();
let response_writer = input_tx.clone();
+ let render_notify = render_notify.clone();
+ let render_dirty = render_dirty.clone();
tokio::task::spawn_blocking(move || {
let mut buf = [0u8; 8192];
loop {
@@ -277,6 +281,9 @@ impl PaneRuntime {
warn!(pane = pane_id.raw(), err = %e, "dropped terminal query response");
}
}
+ if !render_dirty.swap(true, Ordering::AcqRel) {
+ render_notify.notify_one();
+ }
}
}
}
diff --git a/src/persist.rs b/src/persist.rs
index 9cc6205e..49a36141 100644
--- a/src/persist.rs
+++ b/src/persist.rs
@@ -4,12 +4,14 @@
use std::collections::HashMap;
use std::path::PathBuf;
+use std::sync::atomic::AtomicBool;
+use std::sync::Arc;
use ratatui::layout::Direction;
use serde::{Deserialize, Serialize};
use tracing::{error, info, warn};
-use tokio::sync::mpsc;
+use tokio::sync::{mpsc, Notify};
use crate::events::AppEvent;
use crate::layout::{Node, PaneId, TileLayout};
@@ -132,11 +134,22 @@ pub fn restore(
rows: u16,
cols: u16,
events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
) -> Vec<Workspace> {
snapshot
.workspaces
.iter()
- .filter_map(|ws_snap| restore_workspace(ws_snap, rows, cols, events.clone()))
+ .filter_map(|ws_snap| {
+ restore_workspace(
+ ws_snap,
+ rows,
+ cols,
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ )
+ })
.collect()
}
@@ -145,6 +158,8 @@ fn restore_workspace(
rows: u16,
cols: u16,
events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
) -> Option<Workspace> {
let (node, id_map) = restore_node_remapped(&snap.layout);
let pane_ids = collect_pane_ids(&node);
@@ -169,7 +184,15 @@ fn restore_workspace(
.map(|p| p.cwd.clone())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
- match PaneRuntime::spawn(*id, rows, cols, cwd, events.clone()) {
+ match PaneRuntime::spawn(
+ *id,
+ rows,
+ cols,
+ cwd,
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ ) {
Ok(runtime) => {
panes.insert(*id, PaneState::new());
runtimes.insert(*id, runtime);
@@ -205,6 +228,8 @@ fn restore_workspace(
runtimes,
zoomed: snap.zoomed,
events,
+ render_notify,
+ render_dirty,
})
}
diff --git a/src/workspace.rs b/src/workspace.rs
index 68b4e624..63e98912 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -1,8 +1,10 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
+use std::sync::atomic::AtomicBool;
+use std::sync::Arc;
use ratatui::layout::Direction;
-use tokio::sync::mpsc;
+use tokio::sync::{mpsc, Notify};
use tracing::info;
use crate::detect::{Agent, AgentState};
@@ -29,6 +31,8 @@ pub struct Workspace {
pub runtimes: HashMap<PaneId, PaneRuntime>,
pub zoomed: bool,
pub events: mpsc::Sender<AppEvent>,
+ pub(crate) render_notify: Arc<Notify>,
+ pub(crate) render_dirty: Arc<AtomicBool>,
}
impl Workspace {
@@ -37,9 +41,19 @@ impl Workspace {
rows: u16,
cols: u16,
events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
) -> std::io::Result<Self> {
let (layout, root_id) = TileLayout::new();
- let runtime = PaneRuntime::spawn(root_id, rows, cols, initial_cwd, events.clone())?;
+ let runtime = PaneRuntime::spawn(
+ root_id,
+ rows,
+ cols,
+ initial_cwd,
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ )?;
let mut panes = HashMap::new();
panes.insert(root_id, PaneState::new());
@@ -60,6 +74,8 @@ impl Workspace {
runtimes,
zoomed: false,
events,
+ render_notify,
+ render_dirty,
})
}
@@ -74,7 +90,15 @@ impl Workspace {
let new_id = self.layout.split_focused(direction);
let actual_cwd =
cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
- let runtime = PaneRuntime::spawn(new_id, rows, cols, actual_cwd, self.events.clone())?;
+ let runtime = PaneRuntime::spawn(
+ new_id,
+ rows,
+ cols,
+ actual_cwd,
+ self.events.clone(),
+ self.render_notify.clone(),
+ self.render_dirty.clone(),
+ )?;
self.panes.insert(new_id, PaneState::new());
self.public_pane_numbers
.insert(new_id, self.next_public_pane_number);
@@ -170,6 +194,12 @@ impl Workspace {
.unwrap_or((AgentState::Unknown, true))
}
+ pub fn has_working_pane(&self) -> bool {
+ self.panes
+ .values()
+ .any(|pane| pane.state == AgentState::Working)
+ }
+
/// Per-pane (state, seen) in BSP tree order (left-to-right, top-to-bottom).
#[cfg(test)]
pub fn pane_states(&self) -> Vec<(AgentState, bool)> {
@@ -346,6 +376,8 @@ impl Workspace {
pub fn test_new(name: &str) -> Self {
let (events, _) = mpsc::channel(64);
let (layout, root_id) = TileLayout::new();
+ let render_notify = Arc::new(Notify::new());
+ let render_dirty = Arc::new(AtomicBool::new(false));
let mut panes = HashMap::new();
panes.insert(root_id, PaneState::new());
let mut public_pane_numbers = HashMap::new();
@@ -361,6 +393,8 @@ impl Workspace {
runtimes: HashMap::new(),
zoomed: false,
events,
+ render_notify,
+ render_dirty,
}
}