T40: sessions post system notifications through gpui's: each terminal posts its own in the order its output asks, when its row turns to waiting, done or error, when a command ends, and when a program asks through OSC 9, 777 or 99, only while its window is inactive or it is hidden, one every 3 seconds, titled with the session's name; a press goes to the window whose session posted it and shows it; OSC 99 hears untracked closes, alive lists what lives, in order before a later DA1, a close and w take one back where gpui can, a closed session's too, and the query says what holds where
CI / platforms (windows-latest) (push) Successful in 1m51s
CI / platforms (ubuntu-24.04) (push) Successful in 2m2s
CI / macos (push) Successful in 12m36s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-17 15:36:28 -04:00
1 parent 53b8978dff
commit 31de5e44ef
18 files changed
+512 -122

No files matched your search

+17
View File
@@ -50,6 +50,23 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
workspace::bind_keys(cx);
settings::window::bind_keys(cx);
menus::init(cx);
cx.set_app_identity(APP_ID, TITLE);
// A press on a notification goes to the window whose session posted it.
cx.on_system_notification_response(|response, cx| {
let tag = response.tag;
let pressed = cx
.windows()
.into_iter()
.filter_map(|window| window.downcast::<Workspace>())
.any(|workspace| {
workspace
.update(cx, |this, window, cx| this.pressed(&tag, window, cx))
.unwrap_or(false)
});
if !pressed {
log::info!("app: notification {tag} pressed, but no session holds it");
}
});
// Settings alone hold no terminal, so they go with the last one.
cx.on_window_closed(|cx, _| {
let windows = cx.windows();
+23 -17
View File
@@ -121,7 +121,7 @@ fn mark(terminal: &Entity<TerminalView>, cx: &App) -> Option<AnyElement> {
let (pid, records) = (view.pid() as usize, view.records());
let icon = |name: IconName, color: Hsla| Icon::new(name).color(color).into_any_element();
let failed = view.ended().filter(|&code| code != 0);
let mark = match (records.shown(), failed) {
let mark = match (records.shown().map(|(_, record)| record), failed) {
(
Some(Record {
state: State::Blocked,
@@ -215,23 +215,13 @@ fn mark(terminal: &Entity<TerminalView>, cx: &App) -> Option<AnyElement> {
)
}
/// What hover shows: a line for each record, ten at most, with its state, app, title and message, then a failed command's status.
/// What hover shows: a line for each record, ten at most, then a failed command's status.
pub fn hover(records: &Records, failed: Option<i32>) -> Vec<SharedString> {
let line = |(id, record): (&str, &Record)| {
let parts = [
records.app(id),
record.title.as_deref(),
record.msg.as_deref(),
];
let said: Vec<String> = parts.into_iter().flatten().map(plain).collect();
let state = label(record);
SharedString::from(if said.is_empty() {
state
} else {
format!("{state}: {}", said.join(" · "))
})
};
let mut lines: Vec<SharedString> = records.iter().take(LISTED).map(line).collect();
let mut lines: Vec<SharedString> = records
.iter()
.take(LISTED)
.map(|(id, record)| line(records, id, record).into())
.collect();
let more = records.iter().count().saturating_sub(LISTED);
if more > 0 {
lines.push(format!("{more} more").into());
@@ -242,6 +232,22 @@ pub fn hover(records: &Records, failed: Option<i32>) -> Vec<SharedString> {
lines
}
/// A record as hover and notifications say it: its state, then its app, title and message.
pub fn line(records: &Records, id: &str, record: &Record) -> String {
let parts = [
records.app(id),
record.title.as_deref(),
record.msg.as_deref(),
];
let said: Vec<String> = parts.into_iter().flatten().map(plain).collect();
let state = label(record);
if said.is_empty() {
state
} else {
format!("{state}: {}", said.join(" · "))
}
}
/// A record's state, as hover names it.
fn label(record: &Record) -> String {
match (record.state, record.kind, record.progress) {
+4 -4
View File
@@ -77,8 +77,8 @@ pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
);
states.clear();
let gone = view.update(cx, |view, cx| {
if view.records.herdr(&[]) {
view.restate(cx);
if view.records.herdr(&[]) && view.restate(cx) {
view.tell_state(cx);
}
});
if gone.is_err() {
@@ -127,8 +127,8 @@ pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
let Ok(still) = view.update(cx, |view, cx| {
let still = view.reports == reports;
if still {
if view.records.herdr(&agents) {
view.restate(cx);
if view.records.herdr(&agents) && view.restate(cx) {
view.tell_state(cx);
}
if let Some(folder) = report.clone() {
cx.emit(TerminalEvent::Folder(folder));
+2 -2
View File
@@ -77,8 +77,8 @@ impl TerminalView {
}
drop(term);
self.pty.write(bytes);
if self.records.seen() {
self.restate(cx);
if self.records.seen() && self.restate(cx) {
self.tell_state(cx);
}
cx.notify();
}
+1
View File
@@ -15,6 +15,7 @@ mod paint;
mod pointer;
mod prompts;
mod pty;
mod reports;
pub mod shell;
mod status;
mod tap;
+21 -23
View File
@@ -2,15 +2,6 @@ use base64::Engine;
use super::status::BASE64;
/// When a notification may show: OSC 99's `o`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Occasion {
#[default]
Always,
Unfocused,
Invisible,
}
/// A notification a program asks for: OSC 9, 777 or 99.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Notice {
@@ -18,7 +9,6 @@ pub struct Notice {
pub id: Option<String>,
pub title: String,
pub body: String,
pub occasion: Occasion,
/// A press shows the window and session (`a=focus`, the default), and tells the program (`a=report`).
pub focus: bool,
pub report: bool,
@@ -121,7 +111,6 @@ fn plain(title: String, body: String) -> Notice {
id: None,
title,
body,
occasion: Occasion::Always,
focus: true,
report: false,
close: false,
@@ -141,14 +130,30 @@ fn safe(bytes: &[u8], what: &str) -> Option<String> {
Some(text)
}
/// The answer to OSC 99's query, naming `id`: what this terminal reads of notifications.
/// gpui closes no notification on Linux, nor replaces one, so close and expiry hold elsewhere alone.
pub(super) const CLOSES: bool = !cfg!(target_os = "linux");
/// The answer to OSC 99's query, naming `id`: a press focuses and reports, a notification posts while unseen, its payloads, and close and expiry where gpui closes one.
pub fn answer(id: &str) -> String {
format!("\x1b]99;i={id}:p=?;p=title,body,?,alive:o=always\x1b\\")
let (close, expire) = if CLOSES { ("close,", ":w=1") } else { ("", "") };
format!(
"\x1b]99;i={id}:p=?;a=focus,report:o=invisible:p=title,body,{close}?,alive{expire}\x1b\\"
)
}
/// The answer to OSC 99's `p=alive`, naming `id`: none, as no notification shows yet.
pub fn alive(id: &str) -> String {
format!("\x1b]99;i={id}:p=alive;\x1b\\")
/// The answer to OSC 99's `p=alive`, naming `id`: the ids of notifications still alive.
pub fn alive(id: &str, alive: &[&str]) -> String {
format!("\x1b]99;i={id}:p=alive;{}\x1b\\", alive.join(","))
}
/// What `c=1` hears of notification `id`: its close goes untracked, as gpui hears of none.
pub fn untracked(id: &str) -> String {
format!("\x1b]99;i={id}:p=close;untracked\x1b\\")
}
/// What `a=report` hears when notification `id` is pressed.
pub fn activated(id: &str) -> String {
format!("\x1b]99;i={id};\x1b\\")
}
/// An OSC 99 payload's most bytes: plain, and base64; and its metadata's.
@@ -335,13 +340,6 @@ impl Kitty {
return Err(format!("a notification over {GATHERED} bytes"));
}
let notice = &mut gathering.notice;
if let Some(occasion) = get(b'o') {
notice.occasion = match occasion {
b"unfocused" => Occasion::Unfocused,
b"invisible" => Occasion::Invisible,
_ => Occasion::Always,
};
}
if let Some(actions) = get(b'a') {
for action in actions.split(|&byte| byte == b',') {
let (on, name) = match action.strip_prefix(b"-") {
+13 -10
View File
@@ -87,14 +87,9 @@ fn osc_99_gathers_chunks_by_id_and_reads_its_options() {
(Some("a"), "Hel", "World")
);
assert_eq!(
(
notice.occasion,
notice.focus,
notice.report,
notice.close,
notice.expire
),
(Occasion::Unfocused, false, true, true, Some(5000))
(notice.focus, notice.report, notice.close, notice.expire),
(false, true, true, Some(5000)),
"o passes by"
);
let other = shown(kitty.read(b"i=b:p=body;!"));
assert_eq!(
@@ -224,9 +219,17 @@ fn osc_99_drops_what_breaks_its_rules() {
#[test]
fn the_answers_name_the_asker_s_id() {
let offered = if CLOSES {
"p=title,body,close,?,alive:w=1"
} else {
"p=title,body,?,alive"
};
assert_eq!(
answer("q"),
"\x1b]99;i=q:p=?;p=title,body,?,alive:o=always\x1b\\"
format!("\x1b]99;i=q:p=?;a=focus,report:o=invisible:{offered}\x1b\\")
);
assert_eq!(alive("0"), "\x1b]99;i=0:p=alive;\x1b\\");
assert_eq!(alive("0", &[]), "\x1b]99;i=0:p=alive;\x1b\\");
assert_eq!(alive("z", &["a", "b"]), "\x1b]99;i=z:p=alive;a,b\x1b\\");
assert_eq!(untracked("n1"), "\x1b]99;i=n1:p=close;untracked\x1b\\");
assert_eq!(activated("0"), "\x1b]99;i=0;\x1b\\");
}
+268
View File
@@ -0,0 +1,268 @@
use std::time::{Duration, Instant};
use gpui::{Context, SharedString, SystemNotification};
use super::{
notify::{self, Ask, CLOSES, Notice},
status::{self, State, Status},
view::{TerminalEvent, TerminalView},
};
use crate::{
sessions::{line, plain},
workspace::TITLE,
};
/// One notification a session every 3 seconds at most.
const GAP: Duration = Duration::from_secs(3);
/// The notifications a session keeps for presses and answers; past them the oldest goes.
const POSTED: usize = 64;
/// The system notifications a session posted and has not seen go.
#[derive(Default)]
pub struct Notices {
posted: Vec<Posted>,
/// When it last posted.
last: Option<Instant>,
/// Posts so far: they tell apart notifications without an id, and an expiry finds its own post.
count: u64,
}
/// A notification posted.
#[derive(Clone, Debug, PartialEq)]
struct Posted {
tag: SharedString,
/// The OSC 99 id its program gave; none for the session's own and for OSC 9 and 777.
id: Option<String>,
focus: bool,
report: bool,
count: u64,
}
impl Notices {
/// Program `pid`'s turn to post at `now`, kept as posted; none within 3 seconds of its last.
fn turn(&mut self, pid: u32, asked: Option<&Notice>, now: Instant) -> Option<Posted> {
if self.last.is_some_and(|last| now.duration_since(last) < GAP) {
return None;
}
self.last = Some(now);
self.count += 1;
let id = asked.and_then(|notice| notice.id.clone());
// The session's own share a tag, so the last takes its place; an OSC 99 id shares its own.
let tag = match (asked, &id) {
(None, _) => format!("{pid}"),
(Some(_), Some(id)) => format!("{pid}:{id}"),
(Some(_), None) => format!("{pid}#{}", self.count),
};
let posted = Posted {
tag: tag.into(),
id,
focus: asked.is_none_or(|notice| notice.focus),
report: asked.is_some_and(|notice| notice.report),
count: self.count,
};
self.posted.retain(|old| old.tag != posted.tag);
if self.posted.len() == POSTED {
let gone = self.posted.remove(0);
log::debug!(
"pty {pid}: notification {} goes from the {POSTED} kept",
gone.tag
);
}
self.posted.push(posted.clone());
Some(posted)
}
/// Takes notification `tag` out; with `count`, only that post of it.
fn take(&mut self, tag: &str, count: Option<u64>) -> Option<Posted> {
let at = self.posted.iter().position(|posted| {
posted.tag == tag && count.is_none_or(|count| posted.count == count)
})?;
Some(self.posted.remove(at))
}
/// Whether a post after post `count` holds `tag`.
fn replaced(&self, tag: &str, count: u64) -> bool {
self.posted
.iter()
.any(|posted| posted.tag == tag && posted.count > count)
}
/// The ids of the notifications still alive: posted, and not pressed, closed or expired.
fn alive(&self) -> Vec<&str> {
self.posted
.iter()
.filter_map(|posted| posted.id.as_deref())
.collect()
}
}
impl TerminalView {
/// Keeps a program's report, drops what it clears, and answers its query.
pub(super) fn status(&mut self, status: Status) {
match status {
Status::Report(id, record) => self.records.report(id, record),
Status::Clear(id) => self.records.clear(id.as_deref()),
Status::Query => self.pty.write(status::REPLY.as_bytes().to_vec()),
}
}
/// Maps progress, answers the query and alive in order, and posts and closes notifications.
pub(super) fn ask(&mut self, ask: Ask, cx: &mut Context<Self>) {
match ask {
Ask::Progress(progress) => self.records.progress(progress),
Ask::Query(id) => self.pty.write(notify::answer(&id).into_bytes()),
Ask::Alive(id) => {
let alive = self.notices.alive();
log::debug!("pty {}: notifications alive: {alive:?}", self.pty.pid);
self.pty.write(notify::alive(&id, &alive).into_bytes());
}
Ask::Show(notice) => self.post(said(&notice), Some(&notice), cx),
Ask::Close(id) => self.close(&id, cx),
}
}
/// Notes the state its row shows, and tells the owner of a change; true when it changed.
pub(super) fn restate(&mut self, cx: &mut Context<Self>) -> bool {
let changed = self.records.restate();
if changed {
let state = self.records.shows();
log::info!("terminal {}: its row shows {state:?}", self.pty.pid);
cx.emit(TerminalEvent::Shows(state));
}
changed
}
/// Posts the state its row shows when it turned to waiting, done or error.
pub(super) fn tell_state(&mut self, cx: &mut Context<Self>) {
let body = match self.records.shown() {
Some((id, record))
if matches!(record.state, State::Blocked | State::Done | State::Error) =>
{
line(&self.records, id, record)
}
_ => return,
};
self.post(body, None, cx);
}
/// A command the shell ran ended with `status`, which a notification tells.
pub(super) fn finished(&mut self, status: Option<i32>, cx: &mut Context<Self>) {
if self.ended != status {
let ended = status.map_or("with no status".into(), |code| format!("with {code}"));
log::info!("terminal {}: its last command ended {ended}", self.pty.pid);
}
self.ended = status;
self.post(finished(status), None, cx);
}
/// Posts `body` while the window is not active or the session hidden, one every 3 seconds at most.
fn post(&mut self, body: String, asked: Option<&Notice>, cx: &mut Context<Self>) {
let pid = self.pty.pid;
if self.front && self.active {
log::debug!("terminal {pid}: in view, so it posts no {body:?}");
return;
}
let Some(posted) = self.notices.turn(pid, asked, Instant::now()) else {
log::info!("terminal {pid}: posted within 3 s, so it posts no {body:?}");
return;
};
// Its title names the session, so no program speaks as another.
let title = plain(if self.title.is_empty() {
TITLE
} else {
&self.title
});
log::info!("terminal {pid}: posts {}: {title:?}, {body:?}", posted.tag);
cx.show_system_notification(SystemNotification {
tag: posted.tag.clone(),
title: title.into(),
body: body.into(),
actions: Vec::new(),
});
let Some(notice) = asked else {
return;
};
// gpui hears of no close, so a program that asks hears that none is tracked.
if notice.close {
let id = posted.id.as_deref().unwrap_or("0");
self.pty.write(notify::untracked(id).into_bytes());
}
match notice.expire {
Some(ms) if CLOSES => self.expire(posted, ms, cx),
Some(_) => log::debug!(
"terminal {pid}: gpui closes none on Linux, so {} stays",
posted.tag
),
None => {}
}
}
/// Takes `posted` back once `ms` run out, though its session be gone, unless a later post holds its tag.
fn expire(&mut self, posted: Posted, ms: u32, cx: &mut Context<Self>) {
let (tag, count) = (posted.tag, posted.count);
cx.spawn(async move |view, cx| {
cx.background_executor()
.timer(Duration::from_millis(u64::from(ms)))
.await;
let held = view.update(cx, |view, cx| {
if view.notices.replaced(&tag, count) {
return;
}
if view.notices.take(&tag, Some(count)).is_some() {
log::info!("terminal {}: notification {tag} expires", view.pty.pid);
}
cx.dismiss_system_notification(&tag);
});
// The system keeps what a closed session posted, so its expiry still takes it.
if held.is_err() {
log::info!("app: notification {tag} expires after its session");
cx.update(|cx| cx.dismiss_system_notification(&tag));
}
})
.detach();
}
/// A program's close of notification `id`, where gpui can close one.
fn close(&mut self, id: &str, cx: &mut Context<Self>) {
let tag = format!("{}:{id}", self.pty.pid);
if !CLOSES {
log::debug!(
"terminal {}: gpui closes none on Linux, so {tag} stays",
self.pty.pid
);
} else if self.notices.take(&tag, None).is_some() {
log::info!("terminal {}: closes notification {tag}", self.pty.pid);
cx.dismiss_system_notification(&tag);
}
}
/// A press on notification `tag`, if this session posted it: a program that asked hears of it; whether the session shows.
pub fn pressed(&mut self, tag: &str) -> Option<bool> {
let posted = self.notices.take(tag, None)?;
log::info!("terminal {}: notification {tag} pressed", self.pty.pid);
if posted.report {
let id = posted.id.as_deref().unwrap_or("0");
self.pty.write(notify::activated(id).into_bytes());
}
Some(posted.focus)
}
}
/// What a command's end says: it finished, or the status it failed with.
fn finished(status: Option<i32>) -> String {
match status {
Some(code) if code != 0 => format!("Command exited with {code}"),
_ => "Command finished".into(),
}
}
/// A program's notification as posted: its title, then its body, each line shown as text.
fn said(notice: &Notice) -> String {
let lines = notice.title.lines().chain(notice.body.lines());
let shown: Vec<String> = lines.map(plain).filter(|line| !line.is_empty()).collect();
shown.join("\n")
}
#[cfg(test)]
mod tests;
+83
View File
@@ -0,0 +1,83 @@
use super::*;
fn notice(id: Option<&str>, title: &str, body: &str) -> Notice {
Notice {
id: id.map(String::from),
title: title.into(),
body: body.into(),
focus: true,
report: false,
close: false,
expire: None,
}
}
#[test]
fn a_session_posts_once_in_3_seconds_its_own_under_one_tag() {
let mut notices = Notices::default();
let start = Instant::now();
let first = notices.turn(7, None, start).expect("the first post");
assert_eq!((&*first.tag, first.focus, first.report), ("7", true, false));
assert_eq!(notices.turn(7, None, start + Duration::from_secs(2)), None);
let again = notices.turn(7, None, start + GAP).expect("past the gap");
assert_eq!(&*again.tag, "7");
assert_eq!(notices.posted.len(), 1, "the last takes its place");
}
#[test]
fn a_program_s_notifications_go_by_their_id_and_live_until_taken() {
let mut notices = Notices::default();
let start = Instant::now();
let mut asked = notice(Some("build"), "T", "");
asked.report = true;
asked.focus = false;
let posted = notices.turn(7, Some(&asked), start).expect("a post");
assert_eq!(
(&*posted.tag, posted.focus, posted.report),
("7:build", false, true)
);
let untagged = notices
.turn(7, Some(&notice(None, "T", "")), start + GAP)
.expect("a post");
assert!(untagged.tag.starts_with("7#"), "{}", untagged.tag);
assert_eq!(notices.alive(), ["build"]);
let update = notices
.turn(7, Some(&asked), start + GAP * 2)
.expect("an update");
assert!(
notices.replaced(&posted.tag, posted.count),
"a later post holds the tag"
);
assert!(!notices.replaced(&update.tag, update.count));
assert_eq!(
notices.take("7:build", Some(posted.count)),
None,
"an expiry takes its own post only"
);
assert_eq!(notices.take("7:build", Some(update.count)), Some(update));
assert!(notices.alive().is_empty());
}
#[test]
fn a_session_keeps_its_last_64_notifications() {
let mut notices = Notices::default();
let start = Instant::now();
for at in 0..=POSTED as u32 {
let asked = notice(Some(format!("n{at}").as_str()), "T", "");
notices
.turn(7, Some(&asked), start + GAP * at)
.expect("a post");
}
assert_eq!(notices.posted.len(), POSTED);
assert!(!notices.alive().contains(&"n0"), "the oldest went");
assert!(notices.alive().contains(&"n64"));
}
#[test]
fn a_notification_says_its_text_as_text() {
let asked = notice(None, "Build\u{202e} done", "Line 1\n\nLine 2\u{200b}");
assert_eq!(said(&asked), "Build done\nLine 1\nLine 2");
assert_eq!(finished(Some(0)), "Command finished");
assert_eq!(finished(None), "Command finished");
assert_eq!(finished(Some(2)), "Command exited with 2");
}
+4 -4
View File
@@ -300,8 +300,8 @@ impl Records {
self.list.len() != before
}
/// The record a row shows: blocked, else error, working, done or idle, the one reported last in its state.
pub fn shown(&self) -> Option<&Record> {
/// The record a row shows, with its id: blocked, else error, working, done or idle, the one reported last in its state.
pub fn shown(&self) -> Option<(&str, &Record)> {
[
State::Blocked,
State::Error,
@@ -315,13 +315,13 @@ impl Records {
.iter()
.filter(|(_, record, _)| record.state == state)
.max_by_key(|(.., at)| *at)
.map(|(_, record, _)| record)
.map(|(id, record, _)| (id.as_str(), record))
})
}
/// Notes the state a row shows now; true when it changed since the last note.
pub fn restate(&mut self) -> bool {
let now = self.shown().map(|record| record.state);
let now = self.shown().map(|(_, record)| record.state);
let changed = now != self.shows;
self.shows = now;
changed
+5 -1
View File
@@ -303,7 +303,11 @@ fn osc_9_4_maps_to_the_root_record_until_a_program_reports() {
fn a_row_shows_the_most_pressing_record_the_last_reported_in_its_state() {
let mut records = Records::default();
assert_eq!(records.shown(), None);
let shown = |records: &Records| records.shown().map(|record| (record.state, record.kind));
let shown = |records: &Records| {
records
.shown()
.map(|(_, record)| (record.state, record.kind))
};
records.report("a".into(), record(State::Idle, None));
assert_eq!(shown(&records), Some((State::Idle, None)));
records.report("b".into(), record(State::Done, None));
+36 -49
View File
@@ -24,11 +24,11 @@ use super::{
input::{self, Find, NextPrompt, PreviousPrompt},
marks::Mark,
mouse::MouseMode,
notify::{self, Ask},
paint::{self, Look},
pointer::Pointer,
pty::{Events, Pty, PtyEvent, config, fit},
status::{self, Records, State, Status},
reports::Notices,
status::{Records, State},
zoom,
};
use crate::{
@@ -51,10 +51,6 @@ pub enum TerminalEvent {
Title(String),
Folder(PathBuf),
Bell,
/// A command the shell ran ended, with the status it gave.
Finished(Option<i32>),
/// A notification to show or close, OSC 9, 777 or 99.
Notice(Ask),
/// The state its row shows changed, to this one.
Shows(Option<State>),
Exited(ExitStatus),
@@ -80,7 +76,15 @@ pub struct TerminalView {
/// What programs say they are doing, OSC 7501.
pub(super) records: Records,
/// The status its shell's last command ended with; none before one, or when it gave none.
ended: Option<i32>,
pub(super) ended: Option<i32>,
/// The system notifications it posted.
pub(super) notices: Notices,
/// The program's title; empty while it names none.
pub(super) title: String,
/// It is the session shown.
pub(super) front: bool,
/// Its window is active.
pub(super) active: bool,
cell: (u16, u16),
/// Focused in an active window, as the program last heard.
focused: bool,
@@ -130,6 +134,10 @@ impl TerminalView {
reports: 0,
records: Records::default(),
ended: None,
notices: Notices::default(),
title: String::new(),
front: true,
active: false,
cell: (8, 16),
focused: false,
blinking: false,
@@ -163,23 +171,9 @@ impl TerminalView {
self.ended
}
/// Notes the state its row shows, and tells the owner of each change.
pub(super) fn restate(&mut self, cx: &mut Context<Self>) {
if self.records.restate() {
let state = self.records.shows();
log::info!("terminal {}: its row shows {state:?}", self.pty.pid);
cx.emit(TerminalEvent::Shows(state));
}
}
/// A command the shell ran ended with `status`.
fn finished(&mut self, status: Option<i32>, cx: &mut Context<Self>) {
if self.ended != status {
let ended = status.map_or("with no status".into(), |code| format!("with {code}"));
log::info!("terminal {}: its last command ended {ended}", self.pty.pid);
}
self.ended = status;
cx.emit(TerminalEvent::Finished(status));
/// Whether it is the session shown: one shown in an active window posts no notification.
pub fn set_front(&mut self, front: bool) {
self.front = front;
}
fn pump(mut events: Events, cx: &mut Context<Self>) -> Task<()> {
@@ -229,6 +223,7 @@ impl TerminalView {
/// Tells a program that asked, by mode 1004, when the terminal gains or loses focus.
fn refocus(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.active = window.is_window_active();
let focused = self.focus.is_focused(window) && window.is_window_active();
if focused != self.focused {
self.focused = focused;
@@ -254,6 +249,7 @@ impl TerminalView {
let drawn = events
.iter()
.any(|event| matches!(event, PtyEvent::Term(Event::Wakeup)));
let mut restated = false;
for event in events {
match event {
PtyEvent::Folder(folder) => {
@@ -266,23 +262,27 @@ impl TerminalView {
// A new prompt ends what was working, waiting or idle; done and error stay.
PtyEvent::Mark(Mark::Prompt) => {
self.records.prompt();
self.restate(cx);
restated |= self.restate(cx);
}
PtyEvent::Mark(_) => {}
PtyEvent::Status(status) => {
self.status(status);
self.restate(cx);
restated |= self.restate(cx);
}
PtyEvent::Ask(ask) => {
self.ask(ask, cx);
self.restate(cx);
restated |= self.restate(cx);
}
PtyEvent::Reset => {
self.records.reset();
self.restate(cx);
restated |= self.restate(cx);
}
}
}
// A state a later change in the same output passed by posts nothing.
if restated {
self.tell_state(cx);
}
// ponytail: a full search per output batch while find is open; search only new lines if it lags.
if drawn && self.finder.is_some() {
self.search(false, cx);
@@ -290,25 +290,6 @@ impl TerminalView {
cx.notify();
}
/// Keeps a program's report, drops what it clears, and answers its query.
fn status(&mut self, status: Status) {
match status {
Status::Report(id, record) => self.records.report(id, record),
Status::Clear(id) => self.records.clear(id.as_deref()),
Status::Query => self.pty.write(status::REPLY.as_bytes().to_vec()),
}
}
/// Maps progress, answers queries, and hands notifications on.
fn ask(&mut self, ask: Ask, cx: &mut Context<Self>) {
match ask {
Ask::Progress(progress) => self.records.progress(progress),
Ask::Query(id) => self.pty.write(notify::answer(&id).into_bytes()),
Ask::Alive(id) => self.pty.write(notify::alive(&id).into_bytes()),
ask @ (Ask::Show(_) | Ask::Close(_)) => cx.emit(TerminalEvent::Notice(ask)),
}
}
fn term_event(&mut self, event: Event, cx: &mut Context<Self>) {
match event {
Event::Wakeup => self.shown = true,
@@ -318,8 +299,14 @@ impl TerminalView {
let settings = config(SettingsStore::get(cx));
self.pty.term.lock().set_options(settings);
}
Event::Title(title) => cx.emit(TerminalEvent::Title(title)),
Event::ResetTitle => cx.emit(TerminalEvent::Title(String::new())),
Event::Title(title) => {
self.title.clone_from(&title);
cx.emit(TerminalEvent::Title(title));
}
Event::ResetTitle => {
self.title.clear();
cx.emit(TerminalEvent::Title(String::new()));
}
Event::Bell => {
log::debug!("terminal {}: a bell, kept silent", self.pty.pid);
cx.emit(TerminalEvent::Bell);
+5 -1
View File
@@ -192,11 +192,15 @@ impl Workspace {
if index != self.active
&& let Some(hidden) = self.sessions.get(self.active)
{
hidden.terminal.update(cx, |view, cx| view.let_go(cx));
hidden.terminal.update(cx, |view, cx| {
view.let_go(cx);
view.set_front(false);
});
}
let session = &mut self.sessions[index];
session.bell = false;
session.waiting = false;
session.terminal.update(cx, |view, _| view.set_front(true));
window.focus(&session.terminal.focus_handle(cx), cx);
let folder = session.folder.clone();
self.files.update(cx, |files, cx| files.show(&folder, cx));
+21 -5
View File
@@ -37,7 +37,6 @@ impl Workspace {
session.bell = true;
}
TerminalEvent::Bell => {}
TerminalEvent::Finished(_) => {}
TerminalEvent::Shows(state) => {
let now = session.terminal.read(cx).records().shows();
// A hidden session that starts waiting, and still waits, marks its row until it is shown.
@@ -46,10 +45,6 @@ impl Workspace {
session.waiting = true;
}
}
TerminalEvent::Notice(ask) => {
let pid = session.terminal.read(cx).pid();
log::info!("app: session {pid} asks {ask:?}");
}
TerminalEvent::Exited(status) => {
let pid = session.terminal.read(cx).pid();
log::info!("app: session {pid}'s program ended, {status}");
@@ -62,4 +57,25 @@ impl Workspace {
}
cx.notify();
}
/// A press on notification `tag`: its session hears of it and shows, unless it asked not to; false when no session here posted it.
pub fn pressed(&mut self, tag: &str, window: &mut Window, cx: &mut Context<Self>) -> bool {
let found = self
.sessions
.iter()
.enumerate()
.find_map(|(index, session)| {
let focus = session.terminal.update(cx, |view, _| view.pressed(tag))?;
Some((index, focus))
});
let Some((index, focus)) = found else {
return false;
};
if focus {
cx.activate(true);
window.activate_window();
self.select(index, window, cx);
}
true
}
}