fix: make app rendering event-driven

This commit is contained in:
Ogulcan Celik 2026-03-31 02:03:45 +03:00
parent 41a0269de8
commit ea57a65321
7 changed files with 365 additions and 88 deletions

View File

@ -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(

View File

@ -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,

View File

@ -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 {
for ws in &mut self.state.workspaces {
ws.refresh_git_ahead_behind();
}
self.last_git_remote_status_refresh = Instant::now();
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 = 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));
}
}

View File

@ -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())?;

View File

@ -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();
}
}
}
}

View File

@ -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,
})
}

View File

@ -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,
}
}