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
This commit is contained in:
18 files changed
+512
-122
No files matched your search
+17
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ mod paint;
|
||||
mod pointer;
|
||||
mod prompts;
|
||||
mod pty;
|
||||
mod reports;
|
||||
pub mod shell;
|
||||
mod status;
|
||||
mod tap;
|
||||
|
||||
+21
-23
@@ -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
@@ -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\\");
|
||||
}
|
||||
@@ -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(¬ice), Some(¬ice), 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;
|
||||
@@ -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(¬ice(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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user