fix(linux): wrap the confirmation dialog's text instead of clipping it (#948)

Linux has no native prompt, so every window.prompt fell through to gpui's
fallback renderer, which sets the message and the detail as unbreakable
single lines in a fixed-width box with overflow hidden. The quit-and-stop
warning was cut off mid-sentence. Install a prompt builder on platforms
without a native dialog that lays the same prompt out as a card whose text
wraps, in the app's own surfaces, with Return taking answer 0 and Escape
the cancel answer.

Claude-Session: https://claude.ai/code/session_01JRqYZ9E153WpSHGS2AW3BM
This commit is contained in:
l0ng-ai
2026-09-25 13:50:39 +08:00
committed by GitHub
parent f852629ba1
commit 3a52e34b02
3 changed files with 182 additions and 0 deletions
+1
View File
@@ -705,6 +705,7 @@ fn main() {
.detach();
gpui_component::init(cx);
register_bundled_fonts(cx);
crate::ui::prompt::install(cx);
cx.activate(true);
#[cfg(target_os = "macos")]
set_dock_icon_for_bare_binary();
+1
View File
@@ -27,6 +27,7 @@ pub mod pending_pane;
pub mod perf;
pub mod prefill;
pub mod presets;
pub mod prompt;
pub mod remote_connect;
pub mod remote_workspace;
pub mod reorder;
+180
View File
@@ -0,0 +1,180 @@
//! The confirmation dialog on platforms with no native one.
//!
//! macOS and Windows answer `window.prompt` with NSAlert and TaskDialog. Linux
//! has neither, so gpui falls back to a renderer of its own — and that one sets
//! the message and the detail as single unbreakable lines inside a fixed-width
//! box that clips its overflow. Any sentence longer than the box is cut off
//! mid-word, which for the quit-and-stop warning is exactly the part that says
//! what will be lost (#920). This is the same dialog laid out as a card that
//! wraps its text, in the app's own surfaces, with Return and Escape answering
//! the way they do on the native dialogs.
use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, PromptButton, PromptHandle, PromptLevel,
PromptResponse, RenderablePromptHandle, Window, div, prelude::*, px, relative,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::{ActiveTheme as _, Sizable as _, h_flex, v_flex};
/// Route every `window.prompt` through [`TextPrompt`] where the platform has
/// no dialog of its own to show.
pub(crate) fn install(cx: &mut App) {
if cfg!(any(target_os = "macos", target_os = "windows")) {
return;
}
cx.set_prompt_builder(build);
}
fn build(
_level: PromptLevel,
message: &str,
detail: Option<&str>,
answers: &[PromptButton],
handle: PromptHandle,
window: &mut Window,
cx: &mut App,
) -> RenderablePromptHandle {
let prompt = cx.new(|cx| TextPrompt::new(message, detail, answers, cx));
handle.with_view(prompt, window, cx)
}
pub(crate) struct TextPrompt {
message: String,
detail: Option<String>,
answers: Vec<PromptButton>,
focus: FocusHandle,
}
impl TextPrompt {
fn new(
message: &str,
detail: Option<&str>,
answers: &[PromptButton],
cx: &mut Context<Self>,
) -> Self {
Self {
message: message.to_string(),
detail: detail.map(str::to_string),
answers: answers.to_vec(),
focus: cx.focus_handle(),
}
}
/// The answer a key picks. Return is answer 0 — `confirm_answers` puts the
/// action there for exactly this — and Escape is the one marked cancel;
/// a prompt with no cancel answer has nothing Escape can safely mean.
fn answer_for_key(&self, key: &str) -> Option<usize> {
match key {
"enter" if !self.answers.is_empty() => Some(0),
"escape" => self.answers.iter().position(PromptButton::is_cancel),
_ => None,
}
}
}
impl EventEmitter<PromptResponse> for TextPrompt {}
impl Focusable for TextPrompt {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus.clone()
}
}
impl Render for TextPrompt {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let muted = cx.theme().muted_foreground;
// Answer 0 goes rightmost, where the native dialogs put it and where
// `confirm_answers` expects it to land.
let buttons = self.answers.iter().enumerate().rev().map(|(ix, answer)| {
Button::new(("prompt-answer", ix))
.label(answer.label().clone())
.small()
.when(ix == 0 && !answer.is_cancel(), |b| b.primary())
.on_click(cx.listener(move |_, _, _, cx| {
cx.emit(PromptResponse(ix));
cx.stop_propagation();
}))
});
let card = v_flex()
.occlude()
.w(px(420.))
.max_w(relative(0.9))
.gap_3()
.p_5()
.map(|card| crate::ui::theme::floating_surface(card, cx))
.child(
div()
.text_sm()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(self.message.clone()),
)
.children(
self.detail
.clone()
.map(|detail| div().text_sm().text_color(muted).child(detail)),
)
.child(
h_flex()
.pt_2()
.justify_end()
.flex_wrap()
.gap_2()
.children(buttons),
);
div()
.id("text-prompt")
.track_focus(&self.focus)
.size_full()
.cursor_default()
.bg(crate::ui::presets::scrim_fill(cx))
.on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, _, cx| {
if let Some(ix) = this.answer_for_key(&ev.keystroke.key) {
cx.emit(PromptResponse(ix));
cx.stop_propagation();
}
}))
.flex()
.items_center()
.justify_center()
.child(card)
}
}
#[cfg(test)]
mod tests {
use super::TextPrompt;
use gpui::{AppContext as _, PromptButton, TestAppContext};
fn prompt(cx: &mut TestAppContext, answers: &[PromptButton]) -> gpui::Entity<TextPrompt> {
cx.update(|cx| {
cx.new(|cx| {
TextPrompt::new(
"Quit and stop tty7 server?",
Some("a detail long enough to need more than one line"),
answers,
cx,
)
})
})
}
#[gpui::test]
fn return_confirms_and_escape_takes_the_cancel_answer(cx: &mut TestAppContext) {
let p = prompt(cx, &crate::ui::confirm_answers("Quit", "Cancel"));
p.read_with(cx, |p, _| {
assert_eq!(p.answer_for_key("enter"), Some(0));
assert_eq!(p.answer_for_key("escape"), Some(1));
assert_eq!(p.answer_for_key("a"), None);
});
}
#[gpui::test]
fn escape_does_nothing_without_a_cancel_answer(cx: &mut TestAppContext) {
let p = prompt(cx, &[PromptButton::ok("OK"), PromptButton::new("Retry")]);
p.read_with(cx, |p, _| {
assert_eq!(p.answer_for_key("escape"), None);
});
}
}