blob: 70f87c5e77b01105dea4ce1a7ad1aaa6a5cf5be6 [file]
//! Pieces shared by every scripting engine.
//!
//! Callback storage and the failure surface are the same problem whatever the
//! VM is: handlers belong to exactly one render snapshot, and a script error has
//! to land on screen rather than take the host down. Only the type of the stored
//! handler differs, so it is a type parameter.
use std::{
cell::{Cell, RefCell},
collections::HashMap,
ops::Range,
path::{Path, PathBuf},
rc::Rc,
};
use anyhow::{Result, anyhow};
use gpui::{
AnyElement, App, BorderStyle, Bounds, ClipboardItem, Corners, Edges, Element, ElementId,
Entity, GlobalElementId, Hitbox, Hsla, InspectorElementId, InteractiveElement, IntoElement,
LayoutId, PaintQuad, ParentElement, PathBuilder, Pixels, Point, SharedString,
StatefulInteractiveElement, Styled, StyledText, WeakEntity, Window, canvas, div, point,
prelude::FluentBuilder as _, relative, rems, transparent_black,
};
use gpui_base::{
Button, ColorTokens, TextSelectionHandle, TextSelectionRegistration, TextSelectionRun,
};
use crate::{spec::CallbackId, view::ScriptView};
/// Where an application's data lives, given who it is.
pub(crate) fn app_data_dir(id: &str) -> Result<PathBuf> {
validate_identity(id)?;
Ok(data_home().join("gpui-shell").join("apps").join(id))
}
fn validate_identity(id: &str) -> Result<()> {
if id.is_empty() {
return Err(anyhow!("an application identity cannot be empty"));
}
if id.contains("..") {
return Err(anyhow!(
"`{id}` is not a usable application identity: it contains `..`, which would \
reach outside the data directory"
));
}
if let Some(character) = id
.chars()
.find(|character| !matches!(character, 'a'..='z' | '0'..='9' | '.' | '-' | '_'))
{
return Err(anyhow!(
"`{id}` is not a usable application identity: `{character}` is not allowed. \
Use lower-case letters, digits, `.`, `-` and `_`"
));
}
Ok(())
}
/// A bundle id for a host that has only a directory to go on.
///
/// The directory name keeps the folder recognizable and a digest of the full
/// path disambiguates it, so the same directory always reaches the same data and
/// two never collide — including two checkouts of the same source, which are
/// genuinely different installations of something being developed.
///
/// Deliberately *not* what an installed application should use: data keyed by
/// path does not survive the directory moving, which is right while you are
/// editing it and wrong once it is installed.
pub(crate) fn path_identity(root: &Path) -> String {
let name: String = root
.file_name()
.map(|name| name.to_string_lossy().to_lowercase())
.unwrap_or_default()
.chars()
.map(|character| match character {
'a'..='z' | '0'..='9' | '.' | '-' | '_' => character,
_ => '-',
})
.collect();
// The digest carries the identity, so a name that sanitizes to nothing — or
// to something with a leading dot — costs recognizability and not
// correctness.
let name = name.replace("..", "-");
let name = name.trim_matches(['.', '-', '_']);
let name = if name.is_empty() { "app" } else { name };
format!("{name}-{:016x}", path_digest(root))
}
/// The user's home directory: `HOME`, or `USERPROFILE` on Windows. `None` when
/// neither names an absolute directory, rather than a relative path that would
/// quietly resolve against wherever the process happened to start.
pub(crate) fn home_dir() -> Option<PathBuf> {
["HOME", "USERPROFILE"]
.into_iter()
.filter_map(std::env::var_os)
.map(PathBuf::from)
.find(|home| home.is_absolute())
}
/// The platform's per-user data directory, honouring the usual overrides.
pub(crate) fn data_home() -> PathBuf {
if let Some(explicit) = std::env::var_os("XDG_DATA_HOME").filter(|it| !it.is_empty()) {
return PathBuf::from(explicit);
}
let home = home_dir().unwrap_or_else(std::env::temp_dir);
if cfg!(target_os = "macos") {
home.join("Library").join("Application Support")
} else if cfg!(target_os = "windows") {
std::env::var_os("APPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData").join("Roaming"))
} else {
home.join(".local").join("share")
}
}
/// FNV-1a over the path. Not a security boundary — it only has to keep two
/// directories that have no name of their own from sharing a folder.
fn path_digest(root: &Path) -> u64 {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in root.to_string_lossy().as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
/// Finds the directory an application is rooted at.
///
/// Being pointed at the entry file itself, or at the parent of the real
/// application directory, is the most common way to start — so both are handled
/// here rather than failing with a bare "no such file". The error tells the
/// author what was expected and, when it can tell, where the application
/// actually is.
pub fn resolve_app_root(path: &Path, entry: &str) -> Result<PathBuf> {
let candidate = if path.is_file() {
path.parent().map(Path::to_path_buf).unwrap_or_default()
} else {
path.to_path_buf()
};
if !candidate.exists() {
return Err(anyhow!("`{}` does not exist", path.display()));
}
let root = candidate
.canonicalize()
.map_err(|error| anyhow!("cannot read `{}`: {error}", candidate.display()))?;
if root.join(entry).is_file() {
return Ok(root);
}
Err(anyhow!("{}", missing_entry_message(&root, entry)))
}
fn missing_entry_message(root: &Path, entry: &str) -> String {
let mut message = format!(
"no `{entry}` in {}
An application directory must contain {entry}, which default-exports a view class.",
root.display()
);
let nested: Vec<PathBuf> = std::fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.map(|item| item.path())
.filter(|path| path.join(entry).is_file())
.collect();
match nested.as_slice() {
[] => {}
[only] => {
message.push_str(&format!(
"
Did you mean `{}`?",
only.display()
));
}
several => {
message.push_str(
"
Applications found below this directory:",
);
for path in several {
message.push_str(&format!(
"
{}",
path.display()
));
}
}
}
message
}
/// One evaluated incarnation of an application.
///
/// Policy answers what code may do; this token answers which incarnation the
/// code belongs to. Reload keeps the policy but replaces this identity. The
/// explicit liveness bit lets every retained entry point reject work from a
/// superseded or rolled-back incarnation without guessing from creation time.
pub(crate) struct ApplicationGeneration {
id: u64,
active: Cell<bool>,
}
impl ApplicationGeneration {
pub(crate) fn new(id: u64) -> Rc<Self> {
Rc::new(Self {
id,
active: Cell::new(true),
})
}
pub(crate) fn is_active(&self) -> bool {
self.active.get()
}
pub(crate) fn retire(&self) {
tracing::trace!(
application_generation = self.id,
"retiring shell application generation"
);
self.active.set(false);
}
}
/// A script callback together with the view it was registered from. The view is
/// what a later notify has to reach.
///
/// `pub(crate)` throughout: an engine builds one and the dispatcher reads it,
/// and nothing outside this crate has any use for either half. Narrowing it is
/// what keeps a later field from being a breaking change to somebody else.
pub(crate) struct CallbackEntry<T> {
pub(crate) value: T,
pub(crate) view: Option<WeakEntity<ScriptView>>,
pub(crate) application: Option<Rc<ApplicationGeneration>>,
/// The host call this callback was registered from, as
/// [`crate::scope`] numbers them.
///
/// Only the virtualized list reads it, and only to keep the `cx` of that
/// call usable inside the item renderer it registered — see
/// [`crate::scope::adopt`]. Every other dispatch opens a scope of its own
/// and has no use for the one it came from.
pub(crate) registered_in: Option<u64>,
}
impl<T> CallbackEntry<T> {
/// Resolves the callback's owner without letting the callback retain it.
///
/// The outer option distinguishes an owner that has gone away from a
/// callback deliberately registered without a view.
pub(crate) fn live_view(&self) -> Option<Option<Entity<ScriptView>>> {
match &self.view {
Some(view) => view.upgrade().map(Some),
None => Some(None),
}
}
}
impl<T: Clone> Clone for CallbackEntry<T> {
fn clone(&self) -> Self {
Self {
value: self.value.clone(),
view: self.view.clone(),
application: self.application.clone(),
registered_in: self.registered_in,
}
}
}
/// Callbacks live for exactly as long as the snapshot that produced them.
///
/// A script render publishes one [`crate::snapshot::RenderSnapshot`], and that
/// snapshot may be materialized by many GPUI frames. So a handler cannot be
/// retired when a frame ends — only when the snapshot it belongs to is dropped,
/// which is what [`retire`](Self::retire) does.
///
/// Building is staged: [`begin`](Self::begin) opens a generation, handlers
/// accumulate into it, and it becomes reachable only on
/// [`commit`](Self::commit). A script render that fails half-way calls
/// [`abort`](Self::abort) instead, so a failed build leaves no trace — the same
/// transactional rule the snapshot itself follows.
pub(crate) struct CallbackArena<T> {
next_generation: u64,
next_callback: CallbackId,
/// The generation currently being recorded, if a build is open.
building: Option<(u64, Vec<(CallbackId, CallbackEntry<T>)>)>,
/// Committed callbacks, indexed by their runtime-unique id. The generation
/// remains beside each entry so dropping a snapshot can retire its whole
/// group without making callback lookup linear in the number of handlers.
live: HashMap<CallbackId, (u64, CallbackEntry<T>)>,
}
impl<T> Default for CallbackArena<T> {
fn default() -> Self {
Self {
next_generation: 0,
next_callback: 0,
building: None,
live: HashMap::new(),
}
}
}
impl<T: Clone> CallbackArena<T> {
pub(crate) fn is_building(&self) -> bool {
self.building.is_some()
}
pub(crate) fn checkpoint(&self) -> usize {
self.building
.as_ref()
.map_or(0, |(_, entries)| entries.len())
}
pub(crate) fn rollback_to(&mut self, checkpoint: usize) {
if let Some((_, entries)) = self.building.as_mut() {
entries.truncate(checkpoint);
}
}
/// Opens a generation. Any generation left open by an earlier failed build
/// is discarded rather than committed.
pub(crate) fn begin(&mut self) -> u64 {
let generation = self.next_generation;
self.next_generation = self
.next_generation
.checked_add(1)
.expect("a shell runtime exhausted its callback generations");
self.building = Some((generation, Vec::new()));
generation
}
/// Publishes the open generation, so its handlers become callable.
pub(crate) fn commit(&mut self) {
if let Some((generation, entries)) = self.building.take() {
self.live.extend(
entries
.into_iter()
.map(|(id, entry)| (id, (generation, entry))),
);
}
}
/// Whether the open generation has registered any handler yet.
pub(crate) fn has_staged(&self) -> bool {
self.building
.as_ref()
.is_some_and(|(_, entries)| !entries.is_empty())
}
/// Drops the open generation. A failed script render must not leave
/// callable handlers behind.
pub(crate) fn abort(&mut self) {
self.building = None;
}
/// Empties the open generation without closing it.
///
/// The diagnostic retry runs the same render a second time to produce a
/// better message; the second run must start from an empty index space
/// rather than stack its handlers on the abandoned ones. The generation
/// number survives, because the caller is already holding it.
pub(crate) fn rollback(&mut self) {
if let Some((_, entries)) = self.building.as_mut() {
entries.clear();
}
}
/// Releases the handlers of one committed generation, called when the
/// snapshot that owns them is dropped.
pub(crate) fn retire(&mut self, generation: u64) {
self.live
.retain(|_, (live_generation, _)| *live_generation != generation);
}
/// Retains only callbacks accepted by `keep`, including a generation still
/// being staged. Release uses this to retire an owner's current and
/// previous generations even when a GPUI frame still retains the entity.
pub(crate) fn retain(&mut self, mut keep: impl FnMut(&CallbackEntry<T>) -> bool) {
if let Some((_, entries)) = self.building.as_mut() {
entries.retain(|(_, entry)| keep(entry));
}
self.live.retain(|_, (_, entry)| keep(entry));
}
pub(crate) fn push(&mut self, entry: CallbackEntry<T>) -> CallbackId {
let Some((_, entries)) = self.building.as_mut() else {
// Reached only if a handler is registered outside a script render,
// which is a host bug. An id no lookup can match is the harmless
// answer.
tracing::error!("a callback was registered outside a snapshot build");
return CallbackId::MAX;
};
let id = self.next_callback;
self.next_callback = self
.next_callback
.checked_add(1)
.expect("a shell runtime exhausted its callback ids");
entries.push((id, entry));
id
}
/// How many committed handlers the arena holds.
///
/// For the regression that says a virtual list does not accumulate them:
/// its rows are rebuilt every frame, and "the arena is the same size after
/// a thousand frames of scrolling" is the only way to state that as a fact
/// rather than as a comment.
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.live.len()
}
#[cfg(test)]
pub(crate) fn ids(&self) -> Vec<CallbackId> {
self.live.keys().copied().collect()
}
pub(crate) fn get(&self, id: CallbackId) -> Option<CallbackEntry<T>> {
self.live.get(&id).map(|(_, entry)| entry.clone())
}
/// Releases every stored handler.
///
/// Engines whose values must outlive nothing — QuickJS `Persistent` handles
/// in particular — call this before tearing the VM down, because a handle
/// released after its runtime aborts the process.
pub(crate) fn clear(&mut self) {
self.building = None;
self.live.clear();
}
}
/// What `process.exit(code)` means, decided by the host.
///
/// The script only ever *asks*: one plugin must not be able to take down an
/// application somebody is working in, so the runtime never calls `exit(2)`
/// itself. But a request nobody answers is a lie told in the flattering
/// direction — the script gets a success and nothing happens — so a host that
/// grants the capability installs what it should do, and a host that grants it
/// without installing one is told so at the call rather than never.
///
/// A standalone CLI ends the process. An embedded host might close the plugin's
/// panel, or its window, or refuse.
pub type ExitHandler = Rc<dyn Fn(ExitRequest, &mut Window, &mut App)>;
/// Who asked to exit, and with what code.
///
/// The handler is installed once by the host and may be reached by any script
/// the host is running, so "exit" is not answerable without knowing which one
/// asked: a plugin host closes *that* plugin's panel, and a shell that quit the
/// window instead would let one plugin end another's work.
#[derive(Clone)]
pub struct ExitRequest {
code: i32,
view: Option<Entity<ScriptView>>,
}
impl ExitRequest {
pub(crate) fn new(code: i32, view: Option<Entity<ScriptView>>) -> Self {
Self { code, view }
}
/// The status the script passed, or `0`.
pub fn code(self: &ExitRequest) -> i32 {
self.code
}
/// The view whose script asked.
///
/// `None` when the request came from outside any view — a module still
/// loading, or a host call the runtime made itself.
pub fn view(&self) -> Option<&Entity<ScriptView>> {
self.view.as_ref()
}
}
thread_local! {
static EXIT_HANDLER: RefCell<Option<ExitHandler>> = const { RefCell::new(None) };
}
/// Installs what an exit request does. Replaces any previous handler.
pub fn on_exit_request(handler: impl Fn(ExitRequest, &mut Window, &mut App) + 'static) {
EXIT_HANDLER.with(|installed| *installed.borrow_mut() = Some(Rc::new(handler)));
}
/// The installed handler, if the host installed one.
///
/// Cloned out rather than borrowed across the call: a handler that reinstalls
/// itself — or that tears down a panel which drops something that does — would
/// otherwise panic on the outstanding borrow.
pub fn exit_handler() -> Option<ExitHandler> {
EXIT_HANDLER.with(|installed| installed.borrow().clone())
}
/// Forgets the installed handler. A host that goes away should not leave one
/// behind pointing at state it owned.
pub fn clear_exit_handler() {
EXIT_HANDLER.with(|installed| *installed.borrow_mut() = None);
}
/// A failure reported over an interface that still works.
///
/// A render that throws does not take the last valid description with it — the
/// snapshot is only replaced after a build succeeds — so there is usually still
/// a working interface to show. Blanking it would lose the reader's scroll
/// position, their focus, and whatever they were reading, in exchange for a
/// message that fits in a strip.
///
/// So the strip is what they get: one line over the interface that says what
/// broke, with the stack folded behind `Details` and a copy action for pasting
/// elsewhere. A banner that grows with the stack would cover the very interface
/// it exists to keep; the reader opens it when they want it.
pub fn error_banner(
failure: ViewFailure,
message: &str,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let colors = FailureColors::resolve();
let text = FailureText::parse(message);
let copied =
window.use_keyed_state(SharedString::from("shell-banner-copied"), cx, |_, _| false);
let is_copied = *copied.read(cx);
let expanded =
window.use_keyed_state(SharedString::from("shell-banner-expanded"), cx, |_, _| {
false
});
let is_expanded = *expanded.read(cx);
let payload = format!("{}\n\n{message}", failure.heading());
let detail = text
.detail
.clone()
.filter(|_| is_expanded)
.map(|detail| failure_detail("shell-banner", detail, colors, window, cx));
div()
.absolute()
.top_0()
.left_0()
.right_0()
.flex()
.flex_col()
.bg(colors.surface)
.border_b_1()
.border_color(colors.border)
.shadow_sm()
.child(
div()
.flex()
.items_center()
.gap(rems(0.75))
.pl(rems(1.))
.pr(rems(0.5))
.py(rems(0.375))
.text_size(rems(0.75))
.line_height(relative(1.5))
.child(
div()
.flex()
.flex_none()
.items_center()
.gap(rems(0.5))
.text_color(colors.foreground)
.child(status_dot(colors))
.child(SharedString::from(failure.label())),
)
.child(
div()
.flex_1()
.min_w_0()
.text_color(colors.muted)
.when(is_expanded, |this| this.whitespace_normal())
.when(!is_expanded, |this| this.truncate())
.child(SharedString::from(text.summary.clone())),
)
.child(
div()
.flex()
.flex_none()
.items_center()
.gap(rems(0.25))
.when(text.is_expandable(), |this| {
this.child(details_button(
"shell-banner-details",
expanded,
is_expanded,
colors,
))
})
.child(copy_button(copied, is_copied, payload, colors)),
),
)
.when(is_expanded, |this| {
this.child(
div()
.flex()
.flex_col()
.gap(rems(0.5))
.px(rems(1.))
.pt(rems(0.25))
.pb(rems(0.75))
.children(detail)
.child(
div()
.text_size(rems(0.75))
.line_height(relative(1.5))
.text_color(colors.muted)
.child(SharedString::from(failure.kept_note())),
),
)
})
.into_any_element()
}
/// A failure message split into what fits in one line and what does not.
///
/// The first line of a message is the sentence that says what happened — the
/// engine guarantees that much — and the rest is the stack or a recovery hint.
/// Collapsed, a surface shows only the first; the rest waits behind `Details`.
struct FailureText {
summary: String,
detail: Option<String>,
}
impl FailureText {
/// A single line this long will almost certainly be cut off, so it earns a
/// disclosure even with nothing below it. GPUI does not report whether text
/// was truncated, so the length stands in for that.
const LONG_SUMMARY: usize = 96;
fn parse(message: &str) -> Self {
let message = relative_error_paths(message.trim());
let (summary, detail) = message.split_once('\n').unwrap_or((&message, ""));
let detail = detail.trim_matches('\n');
Self {
summary: summary.trim_end().to_owned(),
detail: (!detail.trim().is_empty()).then(|| detail.to_owned()),
}
}
fn is_expandable(&self) -> bool {
self.detail.is_some() || self.summary.chars().count() > Self::LONG_SUMMARY
}
}
/// Shortens paths owned by the process working directory while leaving every
/// external path untouched. This is presentation only: copied diagnostics keep
/// the original path and therefore remain unambiguous.
fn relative_error_paths(message: &str) -> String {
let Ok(current_dir) = std::env::current_dir() else {
return message.to_owned();
};
let root = current_dir.to_string_lossy();
message.replace(&format!("{root}/"), "")
}
fn system_monospace_font() -> &'static str {
if cfg!(target_os = "macos") {
"Menlo"
} else if cfg!(target_os = "windows") {
"Consolas"
} else {
"DejaVu Sans Mono"
}
}
/// A visible, non-fatal failure surface.
///
/// Used when there is nothing to keep — a view whose very first render failed
/// has no last good interface to put a banner over. The message belongs where
/// the interface was supposed to be.
pub fn error_overlay(
failure: ViewFailure,
message: &str,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let (heading, recovery) = match failure {
ViewFailure::Render => (
"This view could not be rendered",
"Fix the script and save; the view re-renders on the next change.",
),
ViewFailure::Reload => (
"This application could not be reloaded",
"Fix the script and save; it reloads on the next change.",
),
};
failure_surface(heading, message, recovery, window, cx)
}
/// What a view's failure surface is reporting.
///
/// A render that threw and a reload that failed are both "the interface on
/// screen is not the one the source describes", so they share one surface —
/// two stacked messages about one broken state read as two problems. They
/// differ only in what is being kept and what brings it back.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ViewFailure {
/// The script threw while building the view.
Render,
/// Edited source could not be loaded; the previous code keeps running.
Reload,
}
impl ViewFailure {
/// The banner's lead-in, ahead of the message's own first line.
fn label(self) -> &'static str {
match self {
ViewFailure::Render => "Render failed",
ViewFailure::Reload => "Reload failed",
}
}
/// The heading a copied report starts with.
fn heading(self) -> &'static str {
match self {
ViewFailure::Render => "This view could not be re-rendered",
ViewFailure::Reload => "This application could not be reloaded",
}
}
/// What the reader is looking at instead, said once they open the detail.
fn kept_note(self) -> &'static str {
match self {
ViewFailure::Render => {
"Showing the last version that rendered. Save a fix to re-render."
}
ViewFailure::Reload => {
"Still running the code from before this change. Save a fix to reload."
}
}
}
}
/// The one place a failure becomes an interface.
///
/// Design Guides asks an error to say what happened and what to do next, and to
/// take its colors from semantic roles rather than literals — a failure surface
/// that hardcodes red is unreadable in half the themes it will be seen in. So
/// this is a normal composed surface: one heading, one line of detail, one
/// recovery line, on the same tokens every other screen uses. `destructive`
/// appears once, as a status dot beside the heading, because emphasis is a
/// budget and the message itself is already the focal point.
///
/// The message shows its first line only. A stack trace is long, and whatever
/// the application looks like, a wall of monospace in the middle of it reads as
/// a crash rather than as a message; `Details` opens it in place.
///
/// The panel has square corners on purpose: it is not a card floating in the
/// window, it *is* the window's content for as long as the failure lasts.
///
/// A stack trace exists to be pasted somewhere else, so copying it is a first
/// class action rather than something the reader retypes.
pub fn failure_surface(
heading: &str,
message: &str,
recovery: &str,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let colors = FailureColors::resolve();
let text = FailureText::parse(message);
let copied =
window.use_keyed_state(SharedString::from("shell-failure-copied"), cx, |_, _| false);
let is_copied = *copied.read(cx);
let expanded =
window.use_keyed_state(SharedString::from("shell-failure-expanded"), cx, |_, _| {
false
});
let is_expanded = *expanded.read(cx);
let payload = format!("{heading}\n\n{message}");
let detail = text
.detail
.clone()
.filter(|_| is_expanded)
.map(|detail| failure_detail("shell-failure", detail, colors, window, cx));
div()
.size_full()
.flex()
.items_center()
.justify_center()
.bg(colors.background)
.p(rems(2.))
.child(
div()
.flex()
.flex_col()
.w_full()
.max_w(rems(42.))
.bg(colors.surface)
.border_1()
.border_color(colors.border)
.child(
div()
.flex()
.flex_col()
.gap(rems(0.375))
.px(rems(1.25))
.pt(rems(1.))
.pb(rems(0.75))
.child(
div()
.flex()
.items_center()
.gap(rems(0.5))
.text_size(rems(0.875))
.line_height(relative(1.4))
.text_color(colors.foreground)
.child(status_dot(colors))
.child(SharedString::from(heading.to_owned())),
)
.child(
div()
.text_size(rems(0.75))
.line_height(relative(1.5))
.text_color(colors.muted)
.when(!is_expanded, |this| this.truncate())
.child(SharedString::from(text.summary.clone())),
)
.children(detail.map(|detail| div().pt(rems(0.25)).child(detail))),
)
.child(
div()
.flex()
.items_center()
.gap(rems(1.))
.pl(rems(1.25))
.pr(rems(0.75))
.py(rems(0.5))
.border_t_1()
.border_color(colors.border)
.child(
div()
.flex_1()
.min_w_0()
.text_size(rems(0.75))
.line_height(relative(1.5))
.text_color(colors.muted)
.child(SharedString::from(recovery.to_owned())),
)
.child(
div()
.flex()
.flex_none()
.items_center()
.gap(rems(0.25))
.when(text.is_expandable(), |this| {
this.child(details_button(
"shell-failure-details",
expanded,
is_expanded,
colors,
))
})
.child(copy_button(copied, is_copied, payload, colors)),
),
),
)
.into_any_element()
}
/// The semantic roles a failure surface draws with, resolved once per render.
#[derive(Clone, Copy)]
struct FailureColors {
background: Hsla,
surface: Hsla,
foreground: Hsla,
muted: Hsla,
border: Hsla,
accent: Hsla,
hover: Hsla,
}
impl FailureColors {
fn resolve() -> Self {
let defaults = ColorTokens::default();
Self {
background: token("background", defaults.background),
surface: token("surface", defaults.surface),
foreground: token("foreground", defaults.foreground),
muted: token("muted_foreground", defaults.muted_foreground),
border: token("border", defaults.border),
accent: token("destructive", defaults.destructive),
hover: token("muted", defaults.muted),
}
}
}
/// Marks the surface as a failure. A dot beside the words rather than a rule
/// across the edge: a colored band reads as the window's own chrome and clashes
/// with whatever the application drew beneath it, while a dot stays attached
/// to the message it qualifies.
fn status_dot(colors: FailureColors) -> impl IntoElement {
div()
.flex_none()
.size(rems(0.375))
.rounded_full()
.bg(colors.accent)
}
/// The folded part of a message: selectable monospace in a well of its own,
/// scrolling past a height that keeps the surface's actions on screen.
fn failure_detail(
key: &'static str,
detail: String,
colors: FailureColors,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let selection = window.use_keyed_state(
SharedString::from(format!("{key}-selection")),
cx,
|_, cx| TextSelectionHandle::new(detail.clone(), cx),
);
let selection = selection.read(cx).clone();
div()
.id(SharedString::from(format!("{key}-detail")))
.max_h(rems(16.))
.overflow_y_scroll()
.px(rems(0.75))
.py(rems(0.5))
.bg(colors.background)
.border_1()
.border_color(colors.border)
.font_family(system_monospace_font())
.text_size(rems(0.6875))
.line_height(relative(1.6))
.text_color(colors.muted)
.child(FailureDetailText::new(selection, detail))
.into_any_element()
}
/// Opens and closes the folded detail. Labelled rather than an icon alone,
/// because a bare chevron beside a copy icon does not say what it reveals.
fn details_button(
id: &'static str,
state: Entity<bool>,
expanded: bool,
colors: FailureColors,
) -> AnyElement {
Button::new(id)
.accessibility_label(if expanded {
"Hide details"
} else {
"Show details"
})
.flex()
.items_center()
.gap(rems(0.25))
.h(rems(1.625))
.pl(rems(0.5))
.pr(rems(0.375))
.text_size(rems(0.75))
.text_color(colors.foreground)
.hover(move |style| style.bg(colors.hover))
.on_click(move |_, _, cx| {
state.update(cx, |expanded, cx| {
*expanded = !*expanded;
cx.notify();
});
})
.child(SharedString::from("Details"))
.child(
div()
.size(rems(0.75))
.child(chevron_icon(expanded, colors.muted)),
)
.into_any_element()
}
/// Lucide's `chevron-down` and `chevron-up`, stroked directly for the same
/// reason as [`copy_icon`].
fn chevron_icon(expanded: bool, color: Hsla) -> impl IntoElement {
canvas(
|_, _, _| {},
move |bounds, (), window, _| {
let side = bounds.size.width.min(bounds.size.height);
let unit = side / 24.;
let at = |x: f32, y: f32| point(bounds.origin.x + unit * x, bounds.origin.y + unit * y);
let mut path = PathBuilder::stroke(unit * 2.);
let (edge, tip) = if expanded { (15., 9.) } else { (9., 15.) };
path.move_to(at(6., edge));
path.line_to(at(12., tip));
path.line_to(at(18., edge));
if let Ok(path) = path.build() {
window.paint_path(path, color);
}
},
)
.size_full()
}
/// The error detail's `StyledText` adapter for gpui-base selection geometry.
/// Selection state and gestures remain entirely owned by `TextSelection`.
struct FailureDetailText {
selection: TextSelectionHandle,
text: SharedString,
styled_text: StyledText,
}
impl FailureDetailText {
fn new(selection: TextSelectionHandle, text: impl Into<SharedString>) -> Self {
let text = text.into();
Self {
selection,
styled_text: StyledText::new(text.clone()),
text,
}
}
}
impl IntoElement for FailureDetailText {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for FailureDetailText {
type RequestLayoutState = ();
type PrepaintState = Hitbox;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
id: Option<&GlobalElementId>,
inspector_id: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, ()) {
self.styled_text
.request_layout(id, inspector_id, window, cx)
}
fn prepaint(
&mut self,
id: Option<&GlobalElementId>,
inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut (),
window: &mut Window,
cx: &mut App,
) -> Hitbox {
self.styled_text
.prepaint(id, inspector_id, bounds, &mut (), window, cx);
let hitbox = window.insert_hitbox(bounds, gpui::HitboxBehavior::Normal);
let registration = TextSelectionRegistration::new(hitbox.clone(), bounds)
.with_text_bounds(vec![bounds])
.with_rendered_element(&self.selection, window, cx);
self.selection.register(registration, window, cx);
hitbox
}
fn paint(
&mut self,
id: Option<&GlobalElementId>,
inspector_id: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut (),
_: &mut Hitbox,
window: &mut Window,
cx: &mut App,
) {
let layout = self.styled_text.layout().clone();
let projection = self.selection.update_runs(
&[TextSelectionRun::new(
self.text.clone(),
layout.clone(),
bounds,
)],
cx,
);
if let Some(range) = projection.ranges().first().and_then(Clone::clone) {
paint_selection(&layout, range, window);
}
self.styled_text
.paint(id, inspector_id, bounds, &mut (), &mut (), window, cx);
}
}
fn paint_selection(layout: &gpui::TextLayout, range: Range<usize>, window: &mut Window) {
let (Some(start), Some(end)) = (
layout.position_for_index(range.start),
layout.position_for_index(range.end),
) else {
return;
};
let line_height = layout.line_height();
let bounds = layout.bounds();
let mut quads = Vec::new();
if start.y == end.y {
quads.push(Bounds::from_corners(
start,
Point::new(end.x, end.y + line_height),
));
} else {
quads.push(Bounds::from_corners(
start,
Point::new(bounds.right(), start.y + line_height),
));
if end.y > start.y + line_height {
quads.push(Bounds::from_corners(
Point::new(bounds.left(), start.y + line_height),
Point::new(bounds.right(), end.y),
));
}
quads.push(Bounds::from_corners(
Point::new(bounds.left(), end.y),
Point::new(end.x, end.y + line_height),
));
}
let color = token("primary", ColorTokens::default().primary).opacity(0.28);
for bounds in quads {
window.paint_quad(PaintQuad {
bounds,
background: color.into(),
corner_radii: Corners::default(),
border_widths: Edges::default(),
border_color: transparent_black(),
border_style: BorderStyle::default(),
});
}
}
/// Copies the heading and the full message, stack included. An icon rather
/// than a caption, so the line beside it keeps the width and the button cannot
/// be pushed out of the surface; the check mark confirms the copy in place,
/// because a copy leaves no visible trace otherwise.
fn copy_button(
state: Entity<bool>,
copied: bool,
payload: String,
colors: FailureColors,
) -> AnyElement {
Button::new("shell-failure-copy")
.accessibility_label("Copy error")
.flex()
.flex_none()
.items_center()
.justify_center()
.size(rems(1.625))
.hover(move |style| style.bg(colors.hover))
.on_click(move |_, _, cx| {
cx.write_to_clipboard(ClipboardItem::new_string(payload.clone()));
state.update(cx, |copied, cx| {
*copied = true;
cx.notify();
});
})
.child(div().size(rems(0.875)).child(copy_icon(
copied,
if copied {
colors.muted
} else {
colors.foreground
},
)))
.into_any_element()
}
/// Lucide's `copy` and `check`, stroked directly.
///
/// Drawn rather than loaded because the asset source belongs to the
/// application: an `svg()` here would be looked up in the very directory that
/// just failed to load, or in an embedding host's own assets.
fn copy_icon(copied: bool, color: Hsla) -> impl IntoElement {
canvas(
|_, _, _| {},
move |bounds, (), window, _| {
let side = if bounds.size.width < bounds.size.height {
bounds.size.width
} else {
bounds.size.height
};
// Lucide draws on a 24-unit grid with a 2-unit stroke.
let unit = side / 24.;
let at = |x: f32, y: f32| point(bounds.origin.x + unit * x, bounds.origin.y + unit * y);
let mut path = PathBuilder::stroke(unit * 2.);
if copied {
path.move_to(at(20., 6.));
path.line_to(at(9., 17.));
path.line_to(at(4., 12.));
} else {
path.move_to(at(8., 8.));
path.line_to(at(22., 8.));
path.line_to(at(22., 22.));
path.line_to(at(8., 22.));
path.close();
path.move_to(at(4., 16.));
path.line_to(at(2., 16.));
path.line_to(at(2., 2.));
path.line_to(at(16., 2.));
path.line_to(at(16., 4.));
}
if let Ok(path) = path.build() {
window.paint_path(path, color);
}
},
)
.size_full()
}
/// Semantic token with a fallback, because a failure surface must render even
/// when the failure is that the theme never got installed.
fn token(name: &str, fallback: Hsla) -> Hsla {
crate::theme_tokens::token_color(name).unwrap_or(fallback)
}
#[cfg(test)]
mod identity_tests {
use super::*;
fn callback(value: u32) -> CallbackEntry<u32> {
CallbackEntry {
value,
view: None,
application: None,
registered_in: None,
}
}
#[test]
fn callback_generations_do_not_wrap_at_the_old_sixteen_bit_boundary() {
let mut arena = CallbackArena::default();
let first_generation = arena.begin();
let first = arena.push(callback(1));
arena.commit();
arena.next_generation = u64::from(u16::MAX) + 1;
let later_generation = arena.begin();
let later = arena.push(callback(2));
arena.commit();
assert_ne!(first_generation, later_generation);
assert_ne!(first, later);
assert_eq!(arena.get(first).map(|entry| entry.value), Some(1));
assert_eq!(arena.get(later).map(|entry| entry.value), Some(2));
arena.retire(later_generation);
assert_eq!(arena.get(first).map(|entry| entry.value), Some(1));
assert!(arena.get(later).is_none());
}
/// The id is joined onto the user's data directory, so an unchecked one
/// reaches the rest of it. This is the only place that check happens.
#[test]
fn an_identity_that_could_escape_the_data_directory_is_refused() {
for escape in ["../other", "..", "a/b", "a\\b", "a..b"] {
let error = app_data_dir(escape)
.err()
.unwrap_or_else(|| panic!("`{escape}` must be refused"))
.to_string();
assert!(
error.contains(escape),
"the refusal has to name the id, got: {error}"
);
}
assert!(app_data_dir("").is_err(), "an empty id names nothing");
}
#[test]
fn a_reverse_dns_identity_is_one_path_component() {
let directory = app_data_dir("com.example.notes").expect("a valid id");
assert!(directory.ends_with("com.example.notes"));
assert!(directory.starts_with(data_home()));
}
/// Two directories that share a name must not share a folder, and the same
/// directory must reach the same one every time.
#[test]
fn a_path_identity_separates_two_checkouts_of_one_name() {
let left = path_identity(Path::new("/home/someone/dev/notes"));
let right = path_identity(Path::new("/home/someone/other/notes"));
assert_ne!(left, right);
assert!(left.starts_with("notes-"), "{left}");
assert_eq!(left, path_identity(Path::new("/home/someone/dev/notes")));
// And whatever it produces has to survive the same check a host's own
// id does, or the fallback could reach where a real id cannot.
assert!(app_data_dir(&left).is_ok());
}
/// A directory whose name is entirely unusable still has to land somewhere,
/// because the digest — not the name — is what keeps two of them apart.
#[test]
fn a_path_identity_survives_a_name_with_nothing_usable_in_it() {
let id = path_identity(Path::new("/tmp/../中文"));
assert!(app_data_dir(&id).is_ok(), "{id}");
}
#[test]
fn a_path_identity_sanitizes_parent_marker_like_directory_names() {
let id = path_identity(Path::new("/tmp/my..app"));
assert!(app_data_dir(&id).is_ok(), "{id}");
assert!(!id.contains(".."), "{id}");
}
/// Collapsed, a surface shows one line; the stack waits behind `Details`.
#[test]
fn a_failure_message_splits_into_one_line_and_its_detail() {
let text = FailureText::parse("interrupted\n at walk (scan.js:1:2)\n");
assert_eq!(text.summary, "interrupted");
assert_eq!(text.detail.as_deref(), Some(" at walk (scan.js:1:2)"));
assert!(text.is_expandable());
let text = FailureText::parse("a short message");
assert!(text.detail.is_none());
assert!(!text.is_expandable(), "nothing is folded away");
}
#[test]
fn error_paths_inside_the_working_directory_are_relative() {
let root = std::env::current_dir().expect("a working directory");
let message = format!(
"{}:12: unexpected token",
root.join("src/main.js").display()
);
assert_eq!(
relative_error_paths(&message),
"src/main.js:12: unexpected token"
);
let external = "/opt/runtime/internal.js:3: host error";
assert_eq!(relative_error_paths(external), external);
}
}