Make the terminal usable: control keys, paste, scroll, restart, terminate
The largest defect was not the one the plan named. Every control character was being dropped before it reached the PTY — ctrl-c, ctrl-d, ctrl-a, ctrl-r, not just ctrl-k — because terminal_key_from_parts required a key_char and GPUI reports none for a control chord, since ctrl-k produces no printable character. Verified with `cat -v`, which now prints ^K^A^R; ctrl-c interrupts a sleep and ctrl-d ends a heredoc. The engine had always encoded these correctly; nothing ever handed them to it. The binding shadowing was real too. OpenPalette was on secondary-k, which is ctrl-k on Linux, and GPUI stops dispatching once a binding claims an event, so readline's kill-line was unreachable in every pane. Pane selection sat on alt-1..6, which readline reads as a digit argument, and focus movement on alt-arrows, which is word motion in most terminals. Bindings now live in keymap.rs with the rule written down and tested: no binding may be a bare control character or a bare Meta sequence, because those are what a terminal application actually receives. A leader chord was considered and rejected — GPUI parks a chord prefix for a second and drops it if focus moves. Also in this pass: - Paste on secondary-shift-v, through the engine's bracketed-paste path so a shell that asked for bracketed paste is told this is a paste. secondary-v would have been ctrl-v, which readline reads as quoted-insert. There is no matching copy: the engine has no selection yet, and a key that copied the whole screen would not be the same feature under the same name. - A scroll wheel on the terminal surface. Shift+PageUp was the only route to scrollback, which is not something anyone guesses. - Restart and Terminate. RuntimeRegistry::shutdown existed and was called only from its own crate's tests, so nothing in the application could ever stop a PTY. Terminate is the literal words with a confirmation naming the pid, per decision 0010, never a close icon; restart keeps the pane and replaces the process, per decision 0011. - A dead or faulted pane now says so over its stale screen instead of looking idle, and typing into a pane with no terminal explains where the keystroke went instead of silently discarding it. - The twelve reachable .expect panics on live-terminal state are gone. A pane can outlive its runtime — failed spawn, terminate, restored snapshot — and every one of those paths used to be a panic in the middle of a paint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1556b87f37
commit
d76da3babb
+363
-112
@@ -1,3 +1,4 @@
|
||||
mod keymap;
|
||||
mod panel_registry;
|
||||
mod theme;
|
||||
mod usage_feed;
|
||||
@@ -7,9 +8,8 @@ use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{
|
||||
App, Application, Bounds, Context, FocusHandle, FontWeight, KeyBinding, KeyDownEvent, Pixels,
|
||||
Rgba, Size, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb,
|
||||
size,
|
||||
App, Application, Bounds, Context, FocusHandle, FontWeight, KeyDownEvent, Pixels, Rgba, Size,
|
||||
Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, relative, rgb, size,
|
||||
};
|
||||
use lumbridge_core::UsageProvenance;
|
||||
use lumbridge_runtime::{
|
||||
@@ -56,6 +56,9 @@ actions!(
|
||||
FocusDown,
|
||||
OpenPalette,
|
||||
ClosePalette,
|
||||
RestartPane,
|
||||
TerminatePane,
|
||||
PasteIntoPane,
|
||||
SelectPane1,
|
||||
SelectPane2,
|
||||
SelectPane3,
|
||||
@@ -88,6 +91,10 @@ struct LumbridgeShell {
|
||||
/// inert by design: it prepares nothing and runs nothing.
|
||||
shelf_choice: BTreeMap<PanelId, usize>,
|
||||
add_panel_chooser_open: bool,
|
||||
/// The pane a terminate has been asked for but not yet confirmed.
|
||||
pending_terminate: Option<PanelId>,
|
||||
/// Why the last keystroke went nowhere, if it did.
|
||||
input_gap: Option<String>,
|
||||
root_focus: FocusHandle,
|
||||
}
|
||||
|
||||
@@ -703,6 +710,8 @@ impl LumbridgeShell {
|
||||
surfaces: BTreeMap::new(),
|
||||
shelf_choice: BTreeMap::new(),
|
||||
add_panel_chooser_open: false,
|
||||
pending_terminate: None,
|
||||
input_gap: None,
|
||||
root_focus,
|
||||
}
|
||||
}
|
||||
@@ -751,19 +760,17 @@ impl LumbridgeShell {
|
||||
Ok(Some(event)) => event,
|
||||
Ok(None) => break,
|
||||
Err(RuntimeRegistryError::Actor(RuntimeActorError::Disconnected)) => {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status =
|
||||
LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned());
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status =
|
||||
LiveRuntimeStatus::Fault("runtime actor disconnected".to_owned());
|
||||
}
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
Err(error) => {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
}
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
@@ -774,23 +781,21 @@ impl LumbridgeShell {
|
||||
session_id,
|
||||
process_id,
|
||||
} => {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status = LiveRuntimeStatus::Running {
|
||||
session_id: session_id.get(),
|
||||
process_id,
|
||||
};
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status = LiveRuntimeStatus::Running {
|
||||
session_id: session_id.get(),
|
||||
process_id,
|
||||
};
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
RuntimeEvent::Output {
|
||||
sequence, bytes, ..
|
||||
} => {
|
||||
let responses = {
|
||||
let terminal = self
|
||||
.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists");
|
||||
let Some(terminal) = self.live_terminals.get_mut(&pane) else {
|
||||
break;
|
||||
};
|
||||
if sequence <= terminal.last_runtime_sequence {
|
||||
terminal.status = LiveRuntimeStatus::Fault(format!(
|
||||
"non-monotonic PTY output sequence {sequence}"
|
||||
@@ -805,10 +810,9 @@ impl LumbridgeShell {
|
||||
if let Err(error) =
|
||||
self.send_runtime_command(pane, RuntimeCommand::Input(response))
|
||||
{
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -817,23 +821,22 @@ impl LumbridgeShell {
|
||||
}
|
||||
RuntimeEvent::InputClosed { .. } => {}
|
||||
RuntimeEvent::Exited { status, .. } => {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status = LiveRuntimeStatus::Exited(format!(
|
||||
"PTY exited with code {}",
|
||||
status.code
|
||||
));
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status = LiveRuntimeStatus::Exited(format!(
|
||||
"PTY exited with code {}",
|
||||
status.code
|
||||
));
|
||||
}
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
RuntimeEvent::Fault {
|
||||
operation, message, ..
|
||||
} => {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status = LiveRuntimeStatus::Fault(format!("{operation:?}: {message}"));
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status =
|
||||
LiveRuntimeStatus::Fault(format!("{operation:?}: {message}"));
|
||||
}
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
@@ -853,10 +856,9 @@ impl LumbridgeShell {
|
||||
|
||||
fn publish_terminal_snapshot(&mut self, pane: PanelId) {
|
||||
let lines = {
|
||||
let terminal = self
|
||||
.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists");
|
||||
let Some(terminal) = self.live_terminals.get_mut(&pane) else {
|
||||
return;
|
||||
};
|
||||
let snapshot = terminal.terminal.snapshot();
|
||||
let lines = snapshot.plain_rows();
|
||||
terminal.snapshot = snapshot;
|
||||
@@ -875,20 +877,18 @@ impl LumbridgeShell {
|
||||
fn resize_terminal(&mut self, pane: PanelId, rows: u16, columns: u16) -> bool {
|
||||
let dimensions = TerminalDimensions::new(rows, columns)
|
||||
.expect("resize actions always retain non-zero dimensions");
|
||||
let terminal = self
|
||||
.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists");
|
||||
let Some(terminal) = self.live_terminals.get_mut(&pane) else {
|
||||
return false;
|
||||
};
|
||||
if !terminal.terminal.resize(dimensions) {
|
||||
return false;
|
||||
}
|
||||
let pty_size = TerminalSize::new(rows, columns)
|
||||
.expect("terminal engine dimensions are valid PTY dimensions");
|
||||
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Resize(pty_size)) {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Resize(pty_size))
|
||||
&& let Some(terminal) = self.live_terminals.get_mut(&pane)
|
||||
{
|
||||
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
}
|
||||
self.publish_terminal_snapshot(pane);
|
||||
true
|
||||
@@ -908,13 +908,10 @@ impl LumbridgeShell {
|
||||
}
|
||||
|
||||
fn scroll_terminal(&mut self, pane: PanelId, scroll: TerminalScroll, cx: &mut Context<Self>) {
|
||||
if !self
|
||||
.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("live terminal state exists")
|
||||
.terminal
|
||||
.scroll_display(scroll)
|
||||
{
|
||||
let Some(terminal) = self.live_terminals.get_mut(&pane) else {
|
||||
return;
|
||||
};
|
||||
if !terminal.terminal.scroll_display(scroll) {
|
||||
return;
|
||||
}
|
||||
self.publish_terminal_snapshot(pane);
|
||||
@@ -1025,6 +1022,100 @@ impl LumbridgeShell {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Restarts the selected pane's process, keeping the pane.
|
||||
///
|
||||
/// The pane's identity is durable and its process is not, which is the whole
|
||||
/// point of decision 0011: a crashed shell should not cost you the pane, its
|
||||
/// position, or its place in the workspace.
|
||||
fn restart_pane(&mut self, _: &RestartPane, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let pane = self.panels.selected();
|
||||
if !self.live_terminals.contains_key(&pane) {
|
||||
return;
|
||||
}
|
||||
self.timing.mark_dispatch();
|
||||
// Stop the old one first. A failure here is reported rather than
|
||||
// swallowed: a restart that silently left the old process running would
|
||||
// leak a shell every time it was pressed.
|
||||
if let Err(error) = self.runtimes.shutdown(&pane) {
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
}
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
let dimensions =
|
||||
terminal_dimensions_for_window(window.bounds().size, self.panels.attached_count());
|
||||
let mut terminal = LiveTerminalState::new(dimensions);
|
||||
if let Err(error) = spawn_live_runtime(&mut self.runtimes, pane, None, dimensions) {
|
||||
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
}
|
||||
self.live_terminals.insert(pane, terminal);
|
||||
self.pending_terminate = None;
|
||||
window.focus(&self.root_focus);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Asks before terminating, then terminates on a second press.
|
||||
///
|
||||
/// Decision 0010 requires terminate to be a distinct, named, confirmed
|
||||
/// operation rather than a close icon, and the confirmation names the
|
||||
/// process being killed. Detaching a pane leaves its process alive; this is
|
||||
/// the only path in the application that ends one.
|
||||
fn terminate_pane(&mut self, _: &TerminatePane, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let pane = self.panels.selected();
|
||||
if !self.live_terminals.contains_key(&pane) {
|
||||
return;
|
||||
}
|
||||
self.timing.mark_dispatch();
|
||||
if self.pending_terminate != Some(pane) {
|
||||
self.pending_terminate = Some(pane);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
self.pending_terminate = None;
|
||||
let outcome = self.runtimes.shutdown(&pane);
|
||||
if let Some(terminal) = self.live_terminals.get_mut(&pane) {
|
||||
terminal.status = match outcome {
|
||||
Ok(()) => LiveRuntimeStatus::Exited("terminated by request".to_owned()),
|
||||
Err(error) => LiveRuntimeStatus::Fault(error.to_string()),
|
||||
};
|
||||
}
|
||||
window.focus(&self.root_focus);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Pastes the clipboard into the selected terminal.
|
||||
///
|
||||
/// Through the engine's bracketed-paste path rather than as synthetic
|
||||
/// keystrokes, so a shell or editor that asked for bracketed paste is told
|
||||
/// this is a paste and does not run every newline as a command.
|
||||
///
|
||||
/// There is no matching copy: the terminal engine has no selection yet, so
|
||||
/// there is nothing to copy from. Adding a key that copied the whole screen
|
||||
/// would not be the same feature under the same name.
|
||||
fn paste_into_pane(&mut self, _: &PasteIntoPane, _: &mut Window, cx: &mut Context<Self>) {
|
||||
let pane = self.panels.selected();
|
||||
let Some(terminal) = self.live_terminals.get(&pane) else {
|
||||
return;
|
||||
};
|
||||
if terminal.status.is_terminal() {
|
||||
return;
|
||||
}
|
||||
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
|
||||
return;
|
||||
};
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let bytes = terminal.terminal.encode_paste(&text);
|
||||
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Input(bytes))
|
||||
&& let Some(terminal) = self.live_terminals.get_mut(&pane)
|
||||
{
|
||||
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn add_panel_action(&mut self, _: &AddPanel, window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.dispatch(ShellAction::CloseCommandPalette);
|
||||
self.add_panel_chooser_open = !self.add_panel_chooser_open;
|
||||
@@ -1156,13 +1247,28 @@ impl LumbridgeShell {
|
||||
}
|
||||
if !self.model.command_palette().is_open() {
|
||||
let pane = self.panels.selected();
|
||||
if self
|
||||
.live_terminals
|
||||
.get(&pane)
|
||||
.is_none_or(|terminal| terminal.status.is_terminal())
|
||||
{
|
||||
return;
|
||||
// Typing into a pane with nothing to type into used to vanish
|
||||
// without a word: no beep, no message, no indication that the
|
||||
// keystroke had gone anywhere. Say where it went.
|
||||
match self.live_terminals.get(&pane) {
|
||||
None => {
|
||||
self.input_gap = Some(
|
||||
"This pane has no terminal. Select a terminal pane to type into it."
|
||||
.to_owned(),
|
||||
);
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
Some(terminal) if terminal.status.is_terminal() => {
|
||||
self.input_gap =
|
||||
Some("This pane's process has ended. ⌘⌥R restarts it.".to_owned());
|
||||
cx.notify();
|
||||
return;
|
||||
}
|
||||
Some(_) => self.input_gap = None,
|
||||
}
|
||||
// Any key that is not the confirmation cancels a pending terminate.
|
||||
self.pending_terminate = None;
|
||||
let modifiers = key_modifiers(event);
|
||||
if let Some(scroll) =
|
||||
terminal_scroll_from_parts(event.keystroke.key.as_str(), modifiers)
|
||||
@@ -1177,29 +1283,25 @@ impl LumbridgeShell {
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let bytes = self
|
||||
let Some(bytes) = self
|
||||
.live_terminals
|
||||
.get(&pane)
|
||||
.expect("selected live terminal exists")
|
||||
.terminal
|
||||
.encode_key(&event);
|
||||
.map(|terminal| terminal.terminal.encode_key(&event))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if bytes.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Input(bytes)) {
|
||||
self.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("selected live terminal exists")
|
||||
.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
if let Err(error) = self.send_runtime_command(pane, RuntimeCommand::Input(bytes))
|
||||
&& let Some(terminal) = self.live_terminals.get_mut(&pane)
|
||||
{
|
||||
terminal.status = LiveRuntimeStatus::Fault(error.to_string());
|
||||
}
|
||||
let moved_to_bottom = {
|
||||
let terminal = self
|
||||
.live_terminals
|
||||
.get_mut(&pane)
|
||||
.expect("selected live terminal exists");
|
||||
let moved_to_bottom = self.live_terminals.get_mut(&pane).is_some_and(|terminal| {
|
||||
terminal.terminal.display_offset() > 0
|
||||
&& terminal.terminal.scroll_display(TerminalScroll::Bottom)
|
||||
};
|
||||
});
|
||||
if moved_to_bottom {
|
||||
self.publish_terminal_snapshot(pane);
|
||||
}
|
||||
@@ -1339,12 +1441,23 @@ impl LumbridgeShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_view(&self, pane: PanelId) -> gpui::AnyElement {
|
||||
fn terminal_view(&self, pane: PanelId, cx: &mut Context<Self>) -> gpui::AnyElement {
|
||||
let theme = self.theme.colors;
|
||||
let terminal = self
|
||||
.live_terminals
|
||||
.get(&pane)
|
||||
.expect("external terminal pane has live state");
|
||||
// A pane can outlive its runtime state — a failed spawn, a terminate, a
|
||||
// restored snapshot whose process is gone. Saying so beats panicking in
|
||||
// the middle of a paint.
|
||||
let Some(terminal) = self.live_terminals.get(&pane) else {
|
||||
return div()
|
||||
.flex()
|
||||
.size_full()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.text_sm()
|
||||
.text_color(theme.muted)
|
||||
.child("No runtime is attached to this pane.")
|
||||
.into_any_element();
|
||||
};
|
||||
let scroll_pane = pane;
|
||||
let rows = terminal_paint_rows(&terminal.snapshot, theme)
|
||||
.into_iter()
|
||||
.map(|runs| {
|
||||
@@ -1363,6 +1476,33 @@ impl LumbridgeShell {
|
||||
.bg(theme.chrome)
|
||||
.font_family("monospace")
|
||||
.text_size(px(13.0))
|
||||
// Shift+PageUp was the only way into scrollback, which is not
|
||||
// something anyone guesses. A wheel is how people scroll.
|
||||
.on_scroll_wheel(
|
||||
cx.listener(move |shell, event: &gpui::ScrollWheelEvent, _, cx| {
|
||||
let delta = event.delta.pixel_delta(px(TERMINAL_CELL_HEIGHT));
|
||||
let lines = (f32::from(delta.y) / TERMINAL_CELL_HEIGHT).clamp(-64.0, 64.0);
|
||||
#[expect(
|
||||
clippy::cast_possible_truncation,
|
||||
reason = "clamped into -64..=64 on the line above"
|
||||
)]
|
||||
let rows = lines.trunc() as i32;
|
||||
// A wheel notch shorter than one row still scrolls one row,
|
||||
// otherwise a fine-grained trackpad does nothing at all.
|
||||
let rows = if rows == 0 {
|
||||
if lines > 0.0 {
|
||||
1
|
||||
} else if lines < 0.0 {
|
||||
-1
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
rows
|
||||
};
|
||||
shell.scroll_terminal(scroll_pane, TerminalScroll::Delta(rows), cx);
|
||||
}),
|
||||
)
|
||||
.children(rows)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1381,11 +1521,11 @@ impl LumbridgeShell {
|
||||
let pane_id = pane.id;
|
||||
let can_detach = self.panels.attached_count() > 1;
|
||||
let external = pane.output_source == OutputSource::External;
|
||||
let live_terminal = external.then(|| {
|
||||
self.live_terminals
|
||||
.get(&pane_id)
|
||||
.expect("external terminal pane has live state")
|
||||
});
|
||||
// A pane can be marked external and still have no runtime: a failed
|
||||
// spawn, a terminated session, a snapshot restored past its process.
|
||||
let live_terminal = external
|
||||
.then(|| self.live_terminals.get(&pane_id))
|
||||
.flatten();
|
||||
let status = live_terminal.map_or(pane.badge.as_str(), |terminal| terminal.status.badge());
|
||||
let detail =
|
||||
live_terminal.map_or_else(|| pane.target.clone(), |terminal| terminal.status.detail());
|
||||
@@ -1629,7 +1769,71 @@ impl LumbridgeShell {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn pane_work_surface(&self, pane: &PanelView) -> gpui::AnyElement {
|
||||
/// What a pane shows when its process has ended.
|
||||
///
|
||||
/// The stale screen stays, dimmed, rather than being cleared: what the
|
||||
/// process last printed is usually why it stopped. Decision 0010 wants a
|
||||
/// terminated session to be visibly terminated, not silently blank.
|
||||
fn exited_banner(status: &LiveRuntimeStatus, theme: ThemeColors) -> Option<gpui::AnyElement> {
|
||||
let (label, tone) = match status {
|
||||
LiveRuntimeStatus::Exited(detail) => (format!("Process ended · {detail}"), theme.muted),
|
||||
LiveRuntimeStatus::Fault(detail) => (format!("Runtime fault · {detail}"), theme.danger),
|
||||
LiveRuntimeStatus::Starting | LiveRuntimeStatus::Running { .. } => return None,
|
||||
};
|
||||
Some(
|
||||
div()
|
||||
.flex_none()
|
||||
.px_3()
|
||||
.py_1()
|
||||
.text_xs()
|
||||
.bg(theme.danger_container)
|
||||
.text_color(tone)
|
||||
.child(format!("{label} · ⌘⌥R restarts it"))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The confirmation decision 0010 requires before a process is ended.
|
||||
///
|
||||
/// It names the process and says what will happen, and it is a separate
|
||||
/// keystroke rather than a close icon, because detaching a pane and killing
|
||||
/// its process are different operations with different consequences.
|
||||
fn terminate_confirmation(
|
||||
&self,
|
||||
pane: PanelId,
|
||||
theme: ThemeColors,
|
||||
) -> Option<gpui::AnyElement> {
|
||||
if self.pending_terminate != Some(pane) {
|
||||
return None;
|
||||
}
|
||||
let process = self.live_terminals.get(&pane).map_or_else(
|
||||
|| "this pane".to_owned(),
|
||||
|terminal| match terminal.status {
|
||||
LiveRuntimeStatus::Running {
|
||||
process_id: Some(id),
|
||||
..
|
||||
} => format!("pid {id} on this machine"),
|
||||
_ => "this pane's process".to_owned(),
|
||||
},
|
||||
);
|
||||
Some(
|
||||
div()
|
||||
.flex_none()
|
||||
.px_3()
|
||||
.py_1()
|
||||
.text_xs()
|
||||
.bg(theme.danger_container)
|
||||
.border_1()
|
||||
.border_color(theme.danger)
|
||||
.text_color(theme.text)
|
||||
.child(format!(
|
||||
"Terminate session? {process} will be ended. ⌘⌥X again to confirm, any other key to cancel."
|
||||
))
|
||||
.into_any_element(),
|
||||
)
|
||||
}
|
||||
|
||||
fn pane_work_surface(&self, pane: &PanelView, cx: &mut Context<Self>) -> gpui::AnyElement {
|
||||
let theme = self.theme.colors;
|
||||
let shown = self.surface_for(pane.id, pane.kind);
|
||||
if shown != SurfaceTab::native_for(pane.kind) {
|
||||
@@ -1637,7 +1841,7 @@ impl LumbridgeShell {
|
||||
}
|
||||
let external = pane.output_source == OutputSource::External;
|
||||
let content = if external {
|
||||
self.terminal_view(pane.id)
|
||||
self.terminal_view(pane.id, cx)
|
||||
} else {
|
||||
let start = pane.lines.len().saturating_sub(18);
|
||||
div()
|
||||
@@ -1653,6 +1857,10 @@ impl LumbridgeShell {
|
||||
.into_any_element()
|
||||
};
|
||||
|
||||
let banner = self
|
||||
.live_terminals
|
||||
.get(&pane.id)
|
||||
.and_then(|terminal| Self::exited_banner(&terminal.status, theme));
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
@@ -1660,6 +1868,23 @@ impl LumbridgeShell {
|
||||
.min_w_0()
|
||||
.min_h_0()
|
||||
.overflow_hidden()
|
||||
.children(self.terminate_confirmation(pane.id, theme))
|
||||
.children(banner)
|
||||
.children(
|
||||
self.input_gap
|
||||
.as_ref()
|
||||
.filter(|_| pane.id == self.panels.selected())
|
||||
.map(|reason| {
|
||||
div()
|
||||
.flex_none()
|
||||
.px_3()
|
||||
.py_1()
|
||||
.text_xs()
|
||||
.bg(theme.surface_raised)
|
||||
.text_color(theme.attention)
|
||||
.child(reason.clone())
|
||||
}),
|
||||
)
|
||||
.bg(theme.chrome)
|
||||
.border_y_1()
|
||||
.border_color(theme.border)
|
||||
@@ -1826,7 +2051,7 @@ impl LumbridgeShell {
|
||||
.flex_none()
|
||||
.min_h_0()
|
||||
.overflow_hidden()
|
||||
.child(self.pane_work_surface(pane)),
|
||||
.child(self.pane_work_surface(pane, cx)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
@@ -2296,6 +2521,9 @@ impl LumbridgeShell {
|
||||
.on_action(cx.listener(Self::close_palette))
|
||||
.on_action(cx.listener(Self::add_panel_action))
|
||||
.on_action(cx.listener(Self::detach_selected_panel))
|
||||
.on_action(cx.listener(Self::restart_pane))
|
||||
.on_action(cx.listener(Self::terminate_pane))
|
||||
.on_action(cx.listener(Self::paste_into_pane))
|
||||
.on_action(cx.listener(Self::terminal_taller))
|
||||
.on_action(cx.listener(Self::terminal_shorter))
|
||||
.on_action(cx.listener(Self::terminal_wider))
|
||||
@@ -2901,7 +3129,20 @@ fn terminal_key_from_parts(
|
||||
let number = function.strip_prefix('f')?.parse::<u8>().ok()?;
|
||||
TerminalKey::Function(number)
|
||||
}
|
||||
_ if !modifiers.contains(KeyModifiers::PLATFORM) => TerminalKey::Text(key_char?.to_owned()),
|
||||
_ if !modifiers.contains(KeyModifiers::PLATFORM) => {
|
||||
// GPUI reports no `key_char` for a control chord, because ctrl-k
|
||||
// produces no printable character. Taking `key_char` alone meant
|
||||
// every control byte was dropped before it reached the PTY — not
|
||||
// just ctrl-k, but ctrl-c, ctrl-d, ctrl-a and ctrl-r as well, which
|
||||
// is most of what makes a terminal usable. Fall back to the key
|
||||
// name when it names a single character; the engine applies the
|
||||
// control transformation.
|
||||
let text = key_char
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(str::to_owned)
|
||||
.or_else(|| (key.chars().count() == 1).then(|| key.to_owned()))?;
|
||||
TerminalKey::Text(text)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(TerminalKeyEvent { key, modifiers })
|
||||
@@ -2960,25 +3201,7 @@ fn spawn_live_runtime(
|
||||
|
||||
fn main() {
|
||||
Application::new().run(|cx: &mut App| {
|
||||
cx.bind_keys([
|
||||
KeyBinding::new("alt-left", FocusLeft, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-right", FocusRight, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-up", FocusUp, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-down", FocusDown, Some("LumbridgeShell")),
|
||||
KeyBinding::new("secondary-k", OpenPalette, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-1", SelectPane1, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-2", SelectPane2, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-3", SelectPane3, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-4", SelectPane4, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-5", SelectPane5, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-6", SelectPane6, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-shift-n", AddPanel, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-shift-w", DetachSelectedPanel, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-shift-up", TerminalTaller, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-shift-down", TerminalShorter, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-shift-right", TerminalWider, Some("LumbridgeShell")),
|
||||
KeyBinding::new("alt-shift-left", TerminalNarrower, Some("LumbridgeShell")),
|
||||
]);
|
||||
cx.bind_keys(keymap::bindings());
|
||||
|
||||
let bounds = Bounds::centered(None, size(px(1500.0), px(960.0)), cx);
|
||||
cx.open_window(
|
||||
@@ -3021,6 +3244,34 @@ mod tests {
|
||||
assert_eq!(composed.key, TerminalKey::Text("é".into()));
|
||||
}
|
||||
|
||||
/// The regression this test exists for: a control chord carries no
|
||||
/// printable character, so requiring one dropped every control byte.
|
||||
#[test]
|
||||
fn a_control_chord_still_reaches_the_terminal_without_a_key_char() {
|
||||
for letter in ["k", "c", "d", "a", "r"] {
|
||||
let event = terminal_key_from_parts(letter, None, KeyModifiers::CONTROL)
|
||||
.unwrap_or_else(|| panic!("ctrl-{letter} must reach the terminal"));
|
||||
assert_eq!(event.key, TerminalKey::Text(letter.to_owned()));
|
||||
assert!(event.modifiers.contains(KeyModifiers::CONTROL));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_printable_character_still_wins_over_the_key_name() {
|
||||
// Shift-a arrives as key "a" with key_char "A"; the character is what
|
||||
// the terminal should receive.
|
||||
let event = terminal_key_from_parts("a", Some("A"), KeyModifiers::SHIFT).expect("shift-a");
|
||||
assert_eq!(event.key, TerminalKey::Text("A".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_key_with_no_character_is_still_refused() {
|
||||
// Multi-character key names that are not in the table above are not
|
||||
// text and must not be sent as though they were.
|
||||
assert!(terminal_key_from_parts("capslock", None, KeyModifiers::default()).is_none());
|
||||
assert!(terminal_key_from_parts("shift", None, KeyModifiers::default()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserves_platform_text_shortcuts_for_the_workspace() {
|
||||
assert!(terminal_key_from_parts("c", Some("c"), KeyModifiers::PLATFORM).is_none());
|
||||
|
||||
Reference in New Issue
Block a user