Merge pull request #1603 from zed-industries/terminal-polishing

Mikayla Maki created

Terminal Polishing

Change summary

crates/gpui/src/app.rs                         |  21 +
crates/gpui/src/platform/event.rs              |  11 
crates/gpui/src/platform/mac/event.rs          |  13 
crates/terminal/src/mappings/keys.rs           |   1 
crates/terminal/src/modal.rs                   |  29 -
crates/terminal/src/terminal.rs                | 283 ++++++++-----------
crates/terminal/src/terminal_container_view.rs |  92 ++++--
crates/terminal/src/terminal_view.rs           |   2 
crates/util/src/lib.rs                         |  17 +
crates/workspace/src/programs.rs               |  77 +++++
crates/workspace/src/workspace.rs              |   5 
11 files changed, 334 insertions(+), 217 deletions(-)

Detailed changes

crates/gpui/src/app.rs 🔗

@@ -5083,6 +5083,7 @@ impl Drop for AnyModelHandle {
     }
 }
 
+#[derive(Hash, PartialEq, Eq, Debug)]
 pub struct AnyWeakModelHandle {
     model_id: usize,
     model_type: TypeId,
@@ -5092,6 +5093,26 @@ impl AnyWeakModelHandle {
     pub fn upgrade(&self, cx: &impl UpgradeModelHandle) -> Option<AnyModelHandle> {
         cx.upgrade_any_model_handle(self)
     }
+    pub fn model_type(&self) -> TypeId {
+        self.model_type
+    }
+
+    fn is<T: 'static>(&self) -> bool {
+        TypeId::of::<T>() == self.model_type
+    }
+
+    pub fn downcast<T: Entity>(&self) -> Option<WeakModelHandle<T>> {
+        if self.is::<T>() {
+            let result = Some(WeakModelHandle {
+                model_id: self.model_id,
+                model_type: PhantomData,
+            });
+
+            result
+        } else {
+            None
+        }
+    }
 }
 
 impl<T: Entity> From<WeakModelHandle<T>> for AnyWeakModelHandle {

crates/gpui/src/platform/event.rs 🔗

@@ -19,6 +19,15 @@ pub struct ModifiersChangedEvent {
     pub cmd: bool,
 }
 
+/// The phase of a touch motion event.
+/// Based on the winit enum of the same name,
+#[derive(Clone, Copy, Debug)]
+pub enum TouchPhase {
+    Started,
+    Moved,
+    Ended,
+}
+
 #[derive(Clone, Copy, Debug, Default)]
 pub struct ScrollWheelEvent {
     pub position: Vector2F,
@@ -28,6 +37,8 @@ pub struct ScrollWheelEvent {
     pub alt: bool,
     pub shift: bool,
     pub cmd: bool,
+    /// If the platform supports returning the phase of a scroll wheel event, it will be stored here
+    pub phase: Option<TouchPhase>,
 }
 
 #[derive(Hash, PartialEq, Eq, Copy, Clone, Debug)]

crates/gpui/src/platform/mac/event.rs 🔗

@@ -3,10 +3,10 @@ use crate::{
     keymap::Keystroke,
     platform::{Event, NavigationDirection},
     KeyDownEvent, KeyUpEvent, ModifiersChangedEvent, MouseButton, MouseButtonEvent,
-    MouseMovedEvent, ScrollWheelEvent,
+    MouseMovedEvent, ScrollWheelEvent, TouchPhase,
 };
 use cocoa::{
-    appkit::{NSEvent, NSEventModifierFlags, NSEventType},
+    appkit::{NSEvent, NSEventModifierFlags, NSEventPhase, NSEventType},
     base::{id, YES},
     foundation::NSString as _,
 };
@@ -150,6 +150,14 @@ impl Event {
             NSEventType::NSScrollWheel => window_height.map(|window_height| {
                 let modifiers = native_event.modifierFlags();
 
+                let phase = match native_event.phase() {
+                    NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => {
+                        Some(TouchPhase::Started)
+                    }
+                    NSEventPhase::NSEventPhaseEnded => Some(TouchPhase::Ended),
+                    _ => Some(TouchPhase::Moved),
+                };
+
                 Self::ScrollWheel(ScrollWheelEvent {
                     position: vec2f(
                         native_event.locationInWindow().x as f32,
@@ -159,6 +167,7 @@ impl Event {
                         native_event.scrollingDeltaX() as f32,
                         native_event.scrollingDeltaY() as f32,
                     ),
+                    phase,
                     precise: native_event.hasPreciseScrollingDeltas() == YES,
                     ctrl: modifiers.contains(NSEventModifierFlags::NSControlKeyMask),
                     alt: modifiers.contains(NSEventModifierFlags::NSAlternateKeyMask),

crates/terminal/src/mappings/keys.rs 🔗

@@ -57,6 +57,7 @@ pub fn to_esc_str(keystroke: &Keystroke, mode: &TermMode) -> Option<String> {
         ("tab", Modifiers::None) => Some("\x09".to_string()),
         ("escape", Modifiers::None) => Some("\x1b".to_string()),
         ("enter", Modifiers::None) => Some("\x0d".to_string()),
+        ("enter", Modifiers::Shift) => Some("\x0d".to_string()),
         ("backspace", Modifiers::None) => Some("\x7f".to_string()),
         //Interesting escape codes
         ("tab", Modifiers::Shift) => Some("\x1b[Z".to_string()),

crates/terminal/src/modal.rs 🔗

@@ -1,6 +1,6 @@
 use gpui::{ModelHandle, ViewContext};
 use settings::{Settings, WorkingDirectory};
-use workspace::Workspace;
+use workspace::{programs::ProgramManager, Workspace};
 
 use crate::{
     terminal_container_view::{
@@ -9,24 +9,20 @@ use crate::{
     Event, Terminal,
 };
 
-#[derive(Debug)]
-struct StoredTerminal(ModelHandle<Terminal>);
-
 pub fn deploy_modal(workspace: &mut Workspace, _: &DeployModal, cx: &mut ViewContext<Workspace>) {
+    let window = cx.window_id();
+
     // Pull the terminal connection out of the global if it has been stored
-    let possible_terminal =
-        cx.update_default_global::<Option<StoredTerminal>, _, _>(|possible_connection, _| {
-            possible_connection.take()
-        });
+    let possible_terminal = ProgramManager::remove::<Terminal, _>(window, cx);
 
-    if let Some(StoredTerminal(stored_terminal)) = possible_terminal {
+    if let Some(terminal_handle) = possible_terminal {
         workspace.toggle_modal(cx, |_, cx| {
             // Create a view from the stored connection if the terminal modal is not already shown
-            cx.add_view(|cx| TerminalContainer::from_terminal(stored_terminal.clone(), true, cx))
+            cx.add_view(|cx| TerminalContainer::from_terminal(terminal_handle.clone(), true, cx))
         });
         // Toggle Modal will dismiss the terminal modal if it is currently shown, so we must
         // store the terminal back in the global
-        cx.set_global::<Option<StoredTerminal>>(Some(StoredTerminal(stored_terminal.clone())));
+        ProgramManager::insert_or_replace::<Terminal, _>(window, terminal_handle, cx);
     } else {
         // No connection was stored, create a new terminal
         if let Some(closed_terminal_handle) = workspace.toggle_modal(cx, |workspace, cx| {
@@ -47,21 +43,19 @@ pub fn deploy_modal(workspace: &mut Workspace, _: &DeployModal, cx: &mut ViewCon
                 cx.subscribe(&terminal_handle, on_event).detach();
                 // Set the global immediately if terminal construction was successful,
                 // in case the user opens the command palette
-                cx.set_global::<Option<StoredTerminal>>(Some(StoredTerminal(
-                    terminal_handle.clone(),
-                )));
+                ProgramManager::insert_or_replace::<Terminal, _>(window, terminal_handle, cx);
             }
 
             this
         }) {
-            // Terminal modal was dismissed. Store terminal if the terminal view is connected
+            // Terminal modal was dismissed and the terminal view is connected, store the terminal
             if let TerminalContainerContent::Connected(connected) =
                 &closed_terminal_handle.read(cx).content
             {
                 let terminal_handle = connected.read(cx).handle();
                 // Set the global immediately if terminal construction was successful,
                 // in case the user opens the command palette
-                cx.set_global::<Option<StoredTerminal>>(Some(StoredTerminal(terminal_handle)));
+                ProgramManager::insert_or_replace::<Terminal, _>(window, terminal_handle, cx);
             }
         }
     }
@@ -75,7 +69,8 @@ pub fn on_event(
 ) {
     // Dismiss the modal if the terminal quit
     if let Event::CloseTerminal = event {
-        cx.set_global::<Option<StoredTerminal>>(None);
+        ProgramManager::remove::<Terminal, _>(cx.window_id(), cx);
+
         if workspace.modal::<TerminalContainer>().is_some() {
             workspace.dismiss_modal(cx)
         }

crates/terminal/src/terminal.rs 🔗

@@ -72,8 +72,8 @@ pub fn init(cx: &mut MutableAppContext) {
 ///Scrolling is unbearably sluggish by default. Alacritty supports a configurable
 ///Scroll multiplier that is set to 3 by default. This will be removed when I
 ///Implement scroll bars.
-const ALACRITTY_SCROLL_MULTIPLIER: f32 = 3.;
-const MAX_SEARCH_LINES: usize = 100;
+const SCROLL_MULTIPLIER: f32 = 4.;
+// const MAX_SEARCH_LINES: usize = 100;
 const DEBUG_TERMINAL_WIDTH: f32 = 500.;
 const DEBUG_TERMINAL_HEIGHT: f32 = 30.;
 const DEBUG_CELL_WIDTH: f32 = 5.;
@@ -237,28 +237,12 @@ impl TerminalError {
         self.shell
             .clone()
             .map(|shell| match shell {
-                Shell::System => {
-                    let mut buf = [0; 1024];
-                    let pw = alacritty_unix::get_pw_entry(&mut buf).ok();
+                Shell::System => "<system defined shell>".to_string(),
 
-                    match pw {
-                        Some(pw) => format!("<system defined shell> {}", pw.shell),
-                        None => "<could not access the password file>".to_string(),
-                    }
-                }
                 Shell::Program(s) => s,
                 Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
             })
-            .unwrap_or_else(|| {
-                let mut buf = [0; 1024];
-                let pw = alacritty_unix::get_pw_entry(&mut buf).ok();
-                match pw {
-                    Some(pw) => {
-                        format!("<none specified, using system defined shell> {}", pw.shell)
-                    }
-                    None => "<none specified, could not access the password file> {}".to_string(),
-                }
-            })
+            .unwrap_or_else(|| "<none specified, using system defined shell>".to_string())
     }
 }
 
@@ -381,6 +365,7 @@ impl TerminalBuilder {
             shell_pid,
             foreground_process_info: None,
             breadcrumb_text: String::new(),
+            scroll_px: 0.,
         };
 
         Ok(TerminalBuilder {
@@ -500,6 +485,7 @@ pub struct Terminal {
     shell_pid: u32,
     shell_fd: u32,
     foreground_process_info: Option<LocalProcessInfo>,
+    scroll_px: f32,
 }
 
 impl Terminal {
@@ -535,16 +521,42 @@ impl Terminal {
             }
             AlacTermEvent::Wakeup => {
                 cx.emit(Event::Wakeup);
-                cx.notify();
+
+                if self.update_process_info() {
+                    cx.emit(Event::TitleChanged)
+                }
             }
             AlacTermEvent::ColorRequest(idx, fun_ptr) => {
                 self.events
                     .push_back(InternalEvent::ColorRequest(*idx, fun_ptr.clone()));
-                cx.notify(); //Immediately schedule a render to respond to the color request
             }
         }
     }
 
+    /// Update the cached process info, returns whether the Zed-relevant info has changed
+    fn update_process_info(&mut self) -> bool {
+        let mut pid = unsafe { libc::tcgetpgrp(self.shell_fd as i32) };
+        if pid < 0 {
+            pid = self.shell_pid as i32;
+        }
+
+        if let Some(process_info) = LocalProcessInfo::with_root_pid(pid as u32) {
+            let res = self
+                .foreground_process_info
+                .as_ref()
+                .map(|old_info| {
+                    process_info.cwd != old_info.cwd || process_info.name != old_info.name
+                })
+                .unwrap_or(true);
+
+            self.foreground_process_info = Some(process_info.clone());
+
+            res
+        } else {
+            false
+        }
+    }
+
     ///Takes events from Alacritty and translates them to behavior on this view
     fn process_terminal_event(
         &mut self,
@@ -678,7 +690,7 @@ impl Terminal {
         let mut terminal = if let Some(term) = term.try_lock_unfair() {
             term
         } else if self.last_synced.elapsed().as_secs_f32() > 0.25 {
-            term.lock_unfair()
+            term.lock_unfair() //It's been too long, force block
         } else if let None = self.sync_task {
             //Skip this frame
             let delay = cx.background().timer(Duration::from_millis(16));
@@ -699,24 +711,15 @@ impl Terminal {
             return;
         };
 
+        if self.update_process_info() {
+            cx.emit(Event::TitleChanged);
+        }
+
         //Note that the ordering of events matters for event processing
         while let Some(e) = self.events.pop_front() {
             self.process_terminal_event(&e, &mut terminal, cx)
         }
 
-        if let Some(process_info) = self.compute_process_info() {
-            let should_emit_title_changed = self
-                .foreground_process_info
-                .as_ref()
-                .map(|old_info| {
-                    process_info.cwd != old_info.cwd || process_info.name != old_info.name
-                })
-                .unwrap_or(true);
-            if should_emit_title_changed {
-                cx.emit(Event::TitleChanged)
-            }
-            self.foreground_process_info = Some(process_info.clone());
-        }
         self.last_content = Self::make_content(&terminal);
         self.last_synced = Instant::now();
     }
@@ -893,44 +896,66 @@ impl Terminal {
 
     ///Scroll the terminal
     pub fn scroll_wheel(&mut self, e: &ScrollWheelEvent, origin: Vector2F) {
-        if self.mouse_mode(e.shift) {
-            //TODO: Currently this only sends the current scroll reports as they come in. Alacritty
-            //Sends the *entire* scroll delta on *every* scroll event, only resetting it when
-            //The scroll enters 'TouchPhase::Started'. Do I need to replicate this?
-            //This would be consistent with a scroll model based on 'distance from origin'...
-            let scroll_lines = (e.delta.y() / self.cur_size.line_height) as i32;
-            let point = mouse_point(
-                e.position.sub(origin),
-                self.cur_size,
-                self.last_content.display_offset,
-            );
-
-            if let Some(scrolls) =
-                scroll_report(point, scroll_lines as i32, e, self.last_content.mode)
+        let mouse_mode = self.mouse_mode(e.shift);
+
+        if let Some(scroll_lines) = self.determine_scroll_lines(e, mouse_mode) {
+            if mouse_mode {
+                let point = mouse_point(
+                    e.position.sub(origin),
+                    self.cur_size,
+                    self.last_content.display_offset,
+                );
+
+                if let Some(scrolls) =
+                    scroll_report(point, scroll_lines as i32, e, self.last_content.mode)
+                {
+                    for scroll in scrolls {
+                        self.pty_tx.notify(scroll);
+                    }
+                };
+            } else if self
+                .last_content
+                .mode
+                .contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL)
+                && !e.shift
             {
-                for scroll in scrolls {
-                    self.pty_tx.notify(scroll);
+                self.pty_tx.notify(alt_scroll(scroll_lines))
+            } else {
+                if scroll_lines != 0 {
+                    let scroll = AlacScroll::Delta(scroll_lines);
+
+                    self.events.push_back(InternalEvent::Scroll(scroll));
                 }
-            };
-        } else if self
-            .last_content
-            .mode
-            .contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL)
-            && !e.shift
-        {
-            //TODO: See above TODO, also applies here.
-            let scroll_lines =
-                ((e.delta.y() * ALACRITTY_SCROLL_MULTIPLIER) / self.cur_size.line_height) as i32;
-
-            self.pty_tx.notify(alt_scroll(scroll_lines))
-        } else {
-            let scroll_lines =
-                ((e.delta.y() * ALACRITTY_SCROLL_MULTIPLIER) / self.cur_size.line_height) as i32;
-            if scroll_lines != 0 {
-                let scroll = AlacScroll::Delta(scroll_lines);
+            }
+        }
+    }
+
+    fn determine_scroll_lines(&mut self, e: &ScrollWheelEvent, mouse_mode: bool) -> Option<i32> {
+        let scroll_multiplier = if mouse_mode { 1. } else { SCROLL_MULTIPLIER };
+
+        match e.phase {
+            /* Reset scroll state on started */
+            Some(gpui::TouchPhase::Started) => {
+                self.scroll_px = 0.;
+                None
+            }
+            /* Calculate the appropriate scroll lines */
+            Some(gpui::TouchPhase::Moved) => {
+                let old_offset = (self.scroll_px / self.cur_size.line_height) as i32;
 
-                self.events.push_back(InternalEvent::Scroll(scroll));
+                self.scroll_px += e.delta.y() * scroll_multiplier;
+
+                let new_offset = (self.scroll_px / self.cur_size.line_height) as i32;
+
+                // Whenever we hit the edges, reset our stored scroll to 0
+                // so we can respond to changes in direction quickly
+                self.scroll_px %= self.cur_size.height;
+
+                Some(new_offset - old_offset)
             }
+            /* Fall back to delta / line_height */
+            None => Some(((e.delta.y() * scroll_multiplier) / self.cur_size.line_height) as i32),
+            _ => None,
         }
     }
 
@@ -957,17 +982,9 @@ impl Terminal {
 
             let term = term.lock();
 
-            make_search_matches(&term, &searcher).collect()
+            all_search_matches(&term, &searcher).collect()
         })
     }
-
-    fn compute_process_info(&self) -> Option<LocalProcessInfo> {
-        let mut pid = unsafe { libc::tcgetpgrp(self.shell_fd as i32) };
-        if pid < 0 {
-            pid = self.shell_pid as i32;
-        }
-        LocalProcessInfo::with_root_pid(pid as u32)
-    }
 }
 
 impl Drop for Terminal {
@@ -988,102 +1005,32 @@ fn make_selection(range: &RangeInclusive<Point>) -> Selection {
 
 /// Copied from alacritty/src/display/hint.rs HintMatches::visible_regex_matches()
 /// Iterate over all visible regex matches.
-fn make_search_matches<'a, T>(
+// fn visible_search_matches<'a, T>(
+//     term: &'a Term<T>,
+//     regex: &'a RegexSearch,
+// ) -> impl Iterator<Item = Match> + 'a {
+//     let viewport_start = Line(-(term.grid().display_offset() as i32));
+//     let viewport_end = viewport_start + term.bottommost_line();
+//     let mut start = term.line_search_left(Point::new(viewport_start, Column(0)));
+//     let mut end = term.line_search_right(Point::new(viewport_end, Column(0)));
+//     start.line = start.line.max(viewport_start - MAX_SEARCH_LINES);
+//     end.line = end.line.min(viewport_end + MAX_SEARCH_LINES);
+
+//     RegexIter::new(start, end, AlacDirection::Right, term, regex)
+//         .skip_while(move |rm| rm.end().line < viewport_start)
+//         .take_while(move |rm| rm.start().line <= viewport_end)
+// }
+
+fn all_search_matches<'a, T>(
     term: &'a Term<T>,
     regex: &'a RegexSearch,
 ) -> impl Iterator<Item = Match> + 'a {
-    let viewport_start = Line(-(term.grid().display_offset() as i32));
-    let viewport_end = viewport_start + term.bottommost_line();
-    let mut start = term.line_search_left(Point::new(viewport_start, Column(0)));
-    let mut end = term.line_search_right(Point::new(viewport_end, Column(0)));
-    start.line = start.line.max(viewport_start - MAX_SEARCH_LINES);
-    end.line = end.line.min(viewport_end + MAX_SEARCH_LINES);
-
+    let start = Point::new(term.grid().topmost_line(), Column(0));
+    let end = Point::new(term.grid().bottommost_line(), term.grid().last_column());
     RegexIter::new(start, end, AlacDirection::Right, term, regex)
-        .skip_while(move |rm| rm.end().line < viewport_start)
-        .take_while(move |rm| rm.start().line <= viewport_end)
 }
 
 #[cfg(test)]
 mod tests {
     pub mod terminal_test_context;
 }
-
-//TODO Move this around and clean up the code
-mod alacritty_unix {
-    use alacritty_terminal::config::Program;
-    use gpui::anyhow::{bail, Result};
-
-    use std::ffi::CStr;
-    use std::mem::MaybeUninit;
-    use std::ptr;
-
-    #[derive(Debug)]
-    pub struct Passwd<'a> {
-        _name: &'a str,
-        _dir: &'a str,
-        pub shell: &'a str,
-    }
-
-    /// Return a Passwd struct with pointers into the provided buf.
-    ///
-    /// # Unsafety
-    ///
-    /// If `buf` is changed while `Passwd` is alive, bad thing will almost certainly happen.
-    pub fn get_pw_entry(buf: &mut [i8; 1024]) -> Result<Passwd<'_>> {
-        // Create zeroed passwd struct.
-        let mut entry: MaybeUninit<libc::passwd> = MaybeUninit::uninit();
-
-        let mut res: *mut libc::passwd = ptr::null_mut();
-
-        // Try and read the pw file.
-        let uid = unsafe { libc::getuid() };
-        let status = unsafe {
-            libc::getpwuid_r(
-                uid,
-                entry.as_mut_ptr(),
-                buf.as_mut_ptr() as *mut _,
-                buf.len(),
-                &mut res,
-            )
-        };
-        let entry = unsafe { entry.assume_init() };
-
-        if status < 0 {
-            bail!("getpwuid_r failed");
-        }
-
-        if res.is_null() {
-            bail!("pw not found");
-        }
-
-        // Sanity check.
-        assert_eq!(entry.pw_uid, uid);
-
-        // Build a borrowed Passwd struct.
-        Ok(Passwd {
-            _name: unsafe { CStr::from_ptr(entry.pw_name).to_str().unwrap() },
-            _dir: unsafe { CStr::from_ptr(entry.pw_dir).to_str().unwrap() },
-            shell: unsafe { CStr::from_ptr(entry.pw_shell).to_str().unwrap() },
-        })
-    }
-
-    #[cfg(target_os = "macos")]
-    pub fn _default_shell(pw: &Passwd<'_>) -> Program {
-        let shell_name = pw.shell.rsplit('/').next().unwrap();
-        let argv = vec![
-            String::from("-c"),
-            format!("exec -a -{} {}", shell_name, pw.shell),
-        ];
-
-        Program::WithArgs {
-            program: "/bin/bash".to_owned(),
-            args: argv,
-        }
-    }
-
-    #[cfg(not(target_os = "macos"))]
-    pub fn default_shell(pw: &Passwd<'_>) -> Program {
-        Program::Just(env::var("SHELL").unwrap_or_else(|_| pw.shell.to_owned()))
-    }
-}

crates/terminal/src/terminal_container_view.rs 🔗

@@ -7,6 +7,7 @@ use gpui::{
     actions, elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, MutableAppContext, Task,
     View, ViewContext, ViewHandle,
 };
+use util::truncate_and_trailoff;
 use workspace::searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle};
 use workspace::{Item, Workspace};
 
@@ -149,6 +150,13 @@ impl TerminalContainer {
             associated_directory: None,
         }
     }
+
+    fn connected(&self) -> Option<ViewHandle<TerminalView>> {
+        match &self.content {
+            TerminalContainerContent::Connected(vh) => Some(vh.clone()),
+            TerminalContainerContent::Error(_) => None,
+        }
+    }
 }
 
 impl View for TerminalContainer {
@@ -246,12 +254,28 @@ impl Item for TerminalContainer {
                 .as_ref()
                 .map(|fpi| {
                     format!(
-                        "{} - {}",
-                        fpi.cwd
-                            .file_name()
-                            .map(|name| name.to_string_lossy().to_string())
-                            .unwrap_or_default(),
-                        fpi.name,
+                        "{} — {}",
+                        truncate_and_trailoff(
+                            &fpi.cwd
+                                .file_name()
+                                .map(|name| name.to_string_lossy().to_string())
+                                .unwrap_or_default(),
+                            25
+                        ),
+                        truncate_and_trailoff(
+                            &{
+                                format!(
+                                    "{}{}",
+                                    fpi.name,
+                                    if fpi.argv.len() >= 1 {
+                                        format!(" {}", (&fpi.argv[1..]).join(" "))
+                                    } else {
+                                        "".to_string()
+                                    }
+                                )
+                            },
+                            25
+                        )
                     )
                 })
                 .unwrap_or_else(|| "Terminal".to_string()),
@@ -324,18 +348,14 @@ impl Item for TerminalContainer {
 
     fn is_dirty(&self, cx: &gpui::AppContext) -> bool {
         if let TerminalContainerContent::Connected(connected) = &self.content {
-            connected.read(cx).has_new_content()
+            connected.read(cx).has_bell()
         } else {
             false
         }
     }
 
-    fn has_conflict(&self, cx: &AppContext) -> bool {
-        if let TerminalContainerContent::Connected(connected) = &self.content {
-            connected.read(cx).has_bell()
-        } else {
-            false
-        }
+    fn has_conflict(&self, _cx: &AppContext) -> bool {
+        false
     }
 
     fn should_update_tab_on_event(event: &Self::Event) -> bool {
@@ -431,28 +451,42 @@ impl SearchableItem for TerminalContainer {
         matches: Vec<Self::Match>,
         cx: &mut ViewContext<Self>,
     ) -> Option<usize> {
-        if let TerminalContainerContent::Connected(connected) = &self.content {
-            if let Some(selection_head) = connected.read(cx).terminal().read(cx).selection_head {
+        let connected = self.connected();
+        // Selection head might have a value if there's a selection that isn't
+        // associated with a match. Therefore, if there are no matches, we should
+        // report None, no matter the state of the terminal
+        let res = if matches.len() > 0 && connected.is_some() {
+            if let Some(selection_head) = connected
+                .unwrap()
+                .read(cx)
+                .terminal()
+                .read(cx)
+                .selection_head
+            {
                 // If selection head is contained in a match. Return that match
-                for (ix, search_match) in matches.iter().enumerate() {
-                    if search_match.contains(&selection_head) {
-                        return Some(ix);
-                    }
-
-                    // If not contained, return the next match after the selection head
-                    if search_match.start() > &selection_head {
-                        return Some(ix);
-                    }
+                if let Some(ix) = matches
+                    .iter()
+                    .enumerate()
+                    .find(|(_, search_match)| {
+                        search_match.contains(&selection_head)
+                            || search_match.start() > &selection_head
+                    })
+                    .map(|(ix, _)| ix)
+                {
+                    Some(ix)
+                } else {
+                    // If no selection after selection head, return the last match
+                    Some(matches.len().saturating_sub(1))
                 }
-
-                // If no selection after selection head, return the last match
-                return Some(matches.len().saturating_sub(1));
             } else {
-                Some(0)
+                // Matches found but no active selection, return the first last one (closest to cursor)
+                Some(matches.len().saturating_sub(1))
             }
         } else {
             None
-        }
+        };
+
+        res
     }
 }
 

crates/terminal/src/terminal_view.rs 🔗

@@ -91,8 +91,8 @@ impl TerminalView {
                 if !cx.is_self_focused() {
                     this.has_new_content = true;
                     cx.notify();
-                    cx.emit(Event::Wakeup);
                 }
+                cx.emit(Event::Wakeup);
             }
             Event::Bell => {
                 this.has_bell = true;

crates/util/src/lib.rs 🔗

@@ -9,6 +9,23 @@ use std::{
     task::{Context, Poll},
 };
 
+pub fn truncate(s: &str, max_chars: usize) -> &str {
+    match s.char_indices().nth(max_chars) {
+        None => s,
+        Some((idx, _)) => &s[..idx],
+    }
+}
+
+pub fn truncate_and_trailoff(s: &str, max_chars: usize) -> String {
+    debug_assert!(max_chars >= 5);
+
+    if s.len() > max_chars {
+        format!("{}…", truncate(&s, max_chars.saturating_sub(3)))
+    } else {
+        s.to_string()
+    }
+}
+
 pub fn post_inc<T: From<u8> + AddAssign<T> + Copy>(value: &mut T) -> T {
     let prev = *value;
     *value += T::from(1);

crates/workspace/src/programs.rs 🔗

@@ -0,0 +1,77 @@
+// TODO: Need to put this basic structure in workspace, and make 'program handles'
+// based off of the 'searchable item' pattern except with models. This way, the workspace's clients
+// can register their models as programs with a specific identity and capable of notifying the workspace
+// Programs are:
+//  - Kept alive by the program manager, they need to emit an event to get dropped from it
+//  - Can be interacted with directly, (closed, activated, etc.) by the program manager, bypassing
+//    associated view(s)
+//  - Have special rendering methods that the program manager requires them to implement to fill out
+//    the status bar
+//  - Can emit events for the program manager which:
+//    - Add a jewel (notification, change, etc.)
+//    - Drop the program
+//    - ???
+//  - Program Manager is kept in a global, listens for window drop so it can drop all it's program handles
+
+use collections::HashMap;
+use gpui::{AnyModelHandle, Entity, ModelHandle, View, ViewContext};
+
+/// This struct is going to be the starting point for the 'program manager' feature that will
+/// eventually be implemented to provide a collaborative way of engaging with identity-having
+/// features like the terminal.
+pub struct ProgramManager {
+    // TODO: Make this a hashset or something
+    modals: HashMap<usize, AnyModelHandle>,
+}
+
+impl ProgramManager {
+    pub fn insert_or_replace<T: Entity, V: View>(
+        window: usize,
+        program: ModelHandle<T>,
+        cx: &mut ViewContext<V>,
+    ) -> Option<AnyModelHandle> {
+        cx.update_global::<ProgramManager, _, _>(|pm, _| {
+            pm.insert_or_replace_internal::<T>(window, program)
+        })
+    }
+
+    pub fn remove<T: Entity, V: View>(
+        window: usize,
+        cx: &mut ViewContext<V>,
+    ) -> Option<ModelHandle<T>> {
+        cx.update_global::<ProgramManager, _, _>(|pm, _| pm.remove_internal::<T>(window))
+    }
+
+    pub fn new() -> Self {
+        Self {
+            modals: Default::default(),
+        }
+    }
+
+    /// Inserts or replaces the model at the given location.
+    fn insert_or_replace_internal<T: Entity>(
+        &mut self,
+        window: usize,
+        program: ModelHandle<T>,
+    ) -> Option<AnyModelHandle> {
+        self.modals.insert(window, AnyModelHandle::from(program))
+    }
+
+    /// Remove the program associated with this window, if it's of the given type
+    fn remove_internal<T: Entity>(&mut self, window: usize) -> Option<ModelHandle<T>> {
+        let program = self.modals.remove(&window);
+        if let Some(program) = program {
+            if program.is::<T>() {
+                // Guaranteed to be some, but leave it in the option
+                // anyway for the API
+                program.downcast()
+            } else {
+                // Model is of the incorrect type, put it back
+                self.modals.insert(window, program);
+                None
+            }
+        } else {
+            None
+        }
+    }
+}

crates/workspace/src/workspace.rs 🔗

@@ -5,6 +5,7 @@
 /// specific locations.
 pub mod pane;
 pub mod pane_group;
+pub mod programs;
 pub mod searchable;
 pub mod sidebar;
 mod status_bar;
@@ -36,6 +37,7 @@ use log::error;
 pub use pane::*;
 pub use pane_group::*;
 use postage::prelude::Stream;
+use programs::ProgramManager;
 use project::{fs, Fs, Project, ProjectEntryId, ProjectPath, ProjectStore, Worktree, WorktreeId};
 use searchable::SearchableItemHandle;
 use serde::Deserialize;
@@ -144,6 +146,9 @@ impl_internal_actions!(
 impl_actions!(workspace, [ToggleProjectOnline, ActivatePane]);
 
 pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
+    // Initialize the program manager immediately
+    cx.set_global(ProgramManager::new());
+
     pane::init(cx);
 
     cx.add_global_action(open);