diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 0cc3a7ea..8f3f01bf 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -19,7 +19,7 @@ mod loopback; pub mod palette; pub(crate) mod pane_liveness; pub(crate) mod parked_cursor; -mod prompt_reflow; +pub(crate) mod prompt_reflow; mod remote; mod reverse_search; pub(crate) mod scrollbar; diff --git a/src/terminal/prompt_reflow.rs b/src/terminal/prompt_reflow.rs index 2a326e7e..56504ec2 100644 --- a/src/terminal/prompt_reflow.rs +++ b/src/terminal/prompt_reflow.rs @@ -48,17 +48,9 @@ pub(super) fn resize(term: &mut Term, size: TermSize, shell } let cursor = term.grid().cursor.point; - let last = Column(term.columns() - 1); // Stops at the top of the screen: the shell's cursor-up stops there too, // so rows already in scrollback are not part of what it will move over. - let mut start = cursor.line; - while start > Line(0) - && term.grid()[Line(start.0 - 1)][last] - .flags - .contains(Flags::WRAPLINE) - { - start -= 1; - } + let start = wrapped_line_start(term, cursor.line); let rows_up = (cursor.line.0 - start.0) as usize; term.grid_mut().cursor.point = Point::new(start, Column(0)); @@ -75,6 +67,114 @@ pub(super) fn resize(term: &mut Term, size: TermSize, shell term.grid_mut().cursor.point.column = cursor.column.min(last); } +/// The two prompt marks the shell brackets its prompt with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptMark { + /// OSC 133;A — a fresh prompt is about to be drawn. + Opened, + /// OSC 133;B — the prompt is drawn and input begins here. + Ready, +} + +impl PromptMark { + /// Reads an OSC payload (`133;A`, `133;B`, …). + pub fn parse(payload: &[u8]) -> Option { + let mark = payload.strip_prefix(b"133;")?; + let (&kind, rest) = mark.split_first()?; + if !(rest.is_empty() || rest.first() == Some(&b';')) { + return None; + } + match kind { + b'A' => Some(Self::Opened), + b'B' => Some(Self::Ready), + _ => None, + } + } +} + +/// Puts the line break back between output that did not end in a newline and +/// the prompt after it. +/// +/// zsh's `PROMPT_SP` moves such a prompt onto a line of its own by writing +/// the `%` end-of-line mark and then padding it with spaces until the cursor +/// *wraps*. To the grid that is one wrapped line: `printf xyz` and the prompt +/// under it read as a single line of text, so the reflow above took the +/// output for the prompt's own first row and erased it on the next resize, +/// and widening the pane joined the two onto one row. +/// +/// The marks say where that happened. `A` comes from precmd, before +/// `PROMPT_SP`: a cursor off column 0 there is output left dangling. By `B` +/// the prompt is drawn, and if the row that output ended on is now padding +/// all the way to its last column and wraps into the rows below, that wrap is +/// the padding's, not the text's — so it becomes a hard break. A prompt that +/// simply carried on from the output (bash has no `PROMPT_SP`) leaves its own +/// text on that row rather than blanks, and is left alone. +#[derive(Default)] +pub struct PromptBreak { + /// Where `A` found a dangling line: how many rows into its wrapped line + /// the cursor was, the column, and the character it had just written — + /// the last to recognize the row by again at `B`, which the prompt may + /// have scrolled. + dangling: Option<(usize, Column, char)>, +} + +impl PromptBreak { + pub fn apply(&mut self, term: &mut Term, mark: PromptMark) { + match mark { + PromptMark::Opened => { + let cursor = term.grid().cursor.point; + // A full row leaves the cursor parked on its last cell: the + // text ends there, one column short of where it stands. + let end = match term.grid().cursor.input_needs_wrap { + true => cursor.column + 1, + false => cursor.column, + }; + self.dangling = (end.0 > 0).then(|| { + let into = (cursor.line.0 - wrapped_line_start(term, cursor.line).0) as usize; + let last = term.grid()[cursor.line][end - 1].c; + (into, end, last) + }); + } + PromptMark::Ready => { + let Some((into, end, last)) = self.dangling.take() else { + return; + }; + let cursor = term.grid().cursor.point.line; + let row = wrapped_line_start(term, cursor) + into; + if row >= cursor { + return; + } + let cols = term.columns(); + let grid = term.grid(); + let padded = end.0 < cols + && grid[row][end - 1].c == last + && (end.0 + 1..cols).all(|col| grid[row][Column(col)].c == ' ') + && grid[row][Column(cols - 1)].flags.contains(Flags::WRAPLINE); + if padded { + term.grid_mut()[row][Column(cols - 1)] + .flags + .remove(Flags::WRAPLINE); + } + } + } + } +} + +/// The first row of the wrapped line `line` is part of, stopping at the top of +/// the screen as the shell's own cursor-up does. +fn wrapped_line_start(term: &Term, line: Line) -> Line { + let last = Column(term.columns() - 1); + let mut start = line; + while start > Line(0) + && term.grid()[Line(start.0 - 1)][last] + .flags + .contains(Flags::WRAPLINE) + { + start -= 1; + } + start +} + #[cfg(test)] mod tests { use super::*; @@ -287,4 +387,86 @@ mod tests { resize(&mut term, TermSize::new(98, 12), true); assert_eq!(text(&term), ["status bar"]); } + + /// What zsh 5.9 writes between precmd's `A` and the prompt when the last + /// output did not end in a newline, as captured off a real pty: the + /// standout `%` end-of-line mark, `cols - 1` spaces to force a wrap, then + /// back to column 0 and clear below. + fn prompt_sp(cols: usize) -> Vec { + let mut out = b"\x1b[1m\x1b[7m%\x1b[27m\x1b[1m\x1b[0m".to_vec(); + out.extend(std::iter::repeat_n(b' ', cols - 1)); + out.extend_from_slice(b"\r \r\r\x1b[0m\x1b[27m\x1b[24m\x1b[J"); + out + } + + /// `printf xyz` at a zsh prompt, through a stream the reader has cut at + /// the prompt marks, then drawn out at `cols`. + fn dangling_output(cols: usize, guarded: bool) -> (Term, Processor) { + let (mut term, mut parser) = pane(cols); + let mut marks = PromptBreak::default(); + parser.advance(&mut term, b"xyz"); + if guarded { + marks.apply(&mut term, PromptMark::Opened); + } + parser.advance(&mut term, &prompt_sp(cols)); + parser.advance(&mut term, &zsh_redraw(cols, 0, "")); + if guarded { + marks.apply(&mut term, PromptMark::Ready); + } + (term, parser) + } + + /// `PROMPT_SP` wraps the dangling output's row into the prompt's. Taken + /// for the prompt's own first row, that output was cleared by the next + /// resize at the prompt — `printf xyz` simply vanished when a panel + /// opened. Measured against the stream without the marks' fix, so the + /// test also proves the scenario loses the output there. + #[test] + fn output_without_a_newline_survives_a_resize_at_the_prompt() { + let narrow = |guarded: bool| { + let (mut term, mut parser) = dangling_output(98, guarded); + resize(&mut term, TermSize::new(60, 12), true); + parser.advance(&mut term, &zsh_redraw(60, 0, "")); + text(&term) + }; + assert!( + !narrow(false).iter().any(|row| row.starts_with("xyz")), + "the scenario no longer loses the output without the fix" + ); + assert_eq!(narrow(true), ["echo hi", "hi", "xyz%", &prompt_row(60)]); + } + + /// Widening joins wrapped rows back together, and the padding's wrap + /// joined the output and the prompt onto one row. + #[test] + fn widening_keeps_dangling_output_off_the_prompt_row() { + let (mut term, mut parser) = dangling_output(60, true); + resize(&mut term, TermSize::new(98, 12), true); + parser.advance(&mut term, &zsh_redraw(98, 0, "")); + assert_eq!(text(&term), ["echo hi", "hi", "xyz%", &prompt_row(98)]); + } + + /// A prompt that carries straight on from the output — bash has no + /// `PROMPT_SP` — wraps on its own text, not on padding, and that wrap is + /// the shell's to count. + #[test] + fn a_prompt_continuing_the_output_row_keeps_its_wrap() { + let (mut term, mut parser) = pane(20); + let mut marks = PromptBreak::default(); + parser.advance(&mut term, b"xyz"); + marks.apply(&mut term, PromptMark::Opened); + parser.advance(&mut term, b"user@host:~/some/long/path$ "); + marks.apply(&mut term, PromptMark::Ready); + let row: Line = term.grid().cursor.point.line - 1; + assert!(term.grid()[row][Column(19)].flags.contains(Flags::WRAPLINE)); + } + + #[test] + fn prompt_marks_parse() { + assert_eq!(PromptMark::parse(b"133;A"), Some(PromptMark::Opened)); + assert_eq!(PromptMark::parse(b"133;B"), Some(PromptMark::Ready)); + assert_eq!(PromptMark::parse(b"133;A;k=s"), Some(PromptMark::Opened)); + assert_eq!(PromptMark::parse(b"133;C"), None); + assert_eq!(PromptMark::parse(b"133;AB"), None); + } } diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index bf212a9f..336d6503 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -14,6 +14,7 @@ use alacritty_terminal::vte::ansi::{self, CursorShape, CursorStyle}; use crate::terminal::command_cursor::{CommandCursorStyle, CommandMark}; use crate::terminal::parked_cursor::{CursorCut, ParkedCursorRepair, ParkedCursorScanner}; +use crate::terminal::prompt_reflow::{PromptBreak, PromptMark}; use std::collections::VecDeque; @@ -1239,6 +1240,7 @@ impl RemoteTerminal { // against the bytes before its `D`, not the whole batch. let mut command_tok = OscTokenizer::new(&[b"133"]); let mut command_cursor = CommandCursorStyle::default(); + let mut prompt_break = PromptBreak::default(); let mut pending: Vec = buffered; // Kitty-graphics decode runs on its own thread with newest-frame // coalescing (issue #213): inflating a full-window browser frame @@ -1335,6 +1337,9 @@ impl RemoteTerminal { ReaderCut::Command(mark) => { command_cursor.apply(term, mark) } + ReaderCut::Prompt(mark) => { + prompt_break.apply(term, mark) + } }, ) else { return; @@ -1472,8 +1477,14 @@ impl RemoteTerminal { command_cuts(&mut command_tok, &bytes, &mut cuts); let fed = feed_grid(&term, &mut processor, &bytes, cuts, &quit, |term, cut| { - if let ReaderCut::Command(mark) = cut { - command_cursor.apply(term, mark); + match cut { + ReaderCut::Command(mark) => { + command_cursor.apply(term, mark) + } + ReaderCut::Prompt(mark) => { + prompt_break.apply(term, mark) + } + ReaderCut::Parked(_) => {} } }); if fed.is_none() { @@ -3501,6 +3512,7 @@ mod chunking_tests { enum ReaderCut { Parked(CursorCut), Command(CommandMark), + Prompt(PromptMark), } /// Adds the batch's command marks to `cuts`, keeping them in stream order @@ -3510,6 +3522,8 @@ fn command_cuts(tok: &mut OscTokenizer, bytes: &[u8], cuts: &mut Vec<(usize, Rea tok.feed_at(bytes, |off, payload| { if let Some(mark) = CommandMark::parse(payload) { cuts.push((off, ReaderCut::Command(mark))); + } else if let Some(mark) = PromptMark::parse(payload) { + cuts.push((off, ReaderCut::Prompt(mark))); } }); if before > 0 && cuts.len() > before { diff --git a/src/ui/code_editor/nav.rs b/src/ui/code_editor/nav.rs index 8ccd30b8..73237a25 100644 --- a/src/ui/code_editor/nav.rs +++ b/src/ui/code_editor/nav.rs @@ -754,7 +754,10 @@ impl Tty7App { } else { let roots = self.tab_code().map(|c| c.roots.as_slice()).unwrap_or(&[]); let local = f.host.id() == self.spawn_host(cx); - path_segments(&f.path, if local { roots } else { &[] }) + match local { + true => path_segments(&self.project_spelling(&f.path, cx), roots), + false => path_segments(&f.path, &[]), + } }; let file = (f.untitled.is_none()).then(|| f.path.clone()); // A rendered Markdown file keeps the path; its symbols are the source's. diff --git a/src/ui/code_editor/problems.rs b/src/ui/code_editor/problems.rs index 31c1b21b..a28b93bb 100644 --- a/src/ui/code_editor/problems.rs +++ b/src/ui/code_editor/problems.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use gpui::prelude::*; -use gpui::{AnyElement, Context, ScrollHandle, Window, div, px}; +use gpui::{AnyElement, App, Context, ScrollHandle, Window, div, px}; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex}; use lsp_types::DiagnosticSeverity; @@ -108,12 +108,13 @@ impl Tty7App { /// Where a file's name sits in the list: relative to the project the /// tab shows, when it is inside it. - fn problems_label(&self, path: &Path) -> (String, String) { + fn problems_label(&self, path: &Path, cx: &App) -> (String, String) { let name = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| path.display().to_string()); - let dir = path.parent().map(|parent| { + let spelled = self.project_spelling(path, cx); + let dir = spelled.parent().map(|parent| { self.tab_code() .and_then(|c| { c.roots @@ -192,7 +193,7 @@ impl Tty7App { left += file.problems.len(); continue; } - let (name, dir) = self.problems_label(&file.path); + let (name, dir) = self.problems_label(&file.path, cx); rows.push( h_flex() .id(("editor-problems-file", file_ix)) diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index aa3da09e..b09cc70c 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -205,6 +205,11 @@ pub(crate) struct FileTreeState { unreadable: HashSet, stale: HashSet, repo_roots: ByHost, + /// A project root's real path, where it differs from the root as spelled + /// (`/tmp/x` → `/private/tmp/x`, a symlinked checkout). The editor opens + /// files by their real path, so this is how a file finds the root it is + /// under — see [`Tty7App::project_spelling`]. + root_aliases: ByHost, repo_root_loads: InFlight, search: SearchState, /// Every file under the project, for the search's Files tab @@ -252,6 +257,7 @@ impl FileTreeState { unreadable: HashSet::new(), stale: HashSet::new(), repo_roots: ByHost::default(), + root_aliases: ByHost::default(), repo_root_loads: InFlight::default(), search: SearchState::default(), quick_open: Default::default(), @@ -683,6 +689,7 @@ impl FileTreeState { fn invalidate_repo_roots(&mut self) -> bool { let had = !self.repo_roots.is_empty() || !self.repo_root_loads.is_empty(); self.repo_roots.clear(); + self.root_aliases.clear(); self.repo_root_loads.invalidate_all(); had } @@ -857,13 +864,18 @@ impl Tty7App { cx, { let cwd = cwd.clone(); - move |h| h.repo_root(&cwd).ok().flatten() + move |h| { + let root = h.repo_root(&cwd).ok().flatten().unwrap_or(cwd); + let real = h.canonicalize(&root).ok().filter(|real| *real != root); + (root, real) + } }, - move |app, root, cx| { + move |app, (root, real), cx| { if app.file_tree.repo_root_loads.finish(&key) { - app.file_tree - .repo_roots - .insert(id, cwd.clone(), root.unwrap_or(cwd)); + if let Some(real) = real { + app.file_tree.root_aliases.insert(id, root.clone(), real); + } + app.file_tree.repo_roots.insert(id, cwd, root); } cx.notify(); }, @@ -1034,7 +1046,28 @@ impl Tty7App { /// no row to scroll to. An empty root list is not that: it means the panel /// has never drawn and does not know its roots yet, and the request /// outlives the render that fills them in. + /// `path` as the tree spells it: under the project root as the tab names + /// it, when `path` is that root's real path spelled out. The editor keeps + /// files by their real path (`load_file` canonicalizes), the tree by the + /// pane's — on macOS every project under `/tmp` differs that way, as does + /// any checkout reached through a symlink. Anything else comes back as is. + pub(crate) fn project_spelling(&self, path: &Path, cx: &App) -> PathBuf { + let host = self.spawn_host(cx); + let roots = self.tab_code().map(|c| c.roots.as_slice()).unwrap_or(&[]); + if roots.iter().any(|root| path.starts_with(root)) { + return path.to_path_buf(); + } + roots + .iter() + .find_map(|root| { + let real = self.file_tree.root_aliases.get(host, root)?; + Some(root.join(path.strip_prefix(real).ok()?)) + }) + .unwrap_or_else(|| path.to_path_buf()) + } + pub(crate) fn file_tree_reveal_path(&mut self, path: &Path, cx: &mut Context) -> bool { + let path = &self.project_spelling(path, cx); let roots = self.tab_code().map(|c| c.roots.clone()).unwrap_or_default(); if !roots.is_empty() && !roots.iter().any(|root| path.starts_with(root)) { return false; diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index ef4ea82d..c4372950 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -275,7 +275,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsThemeIntroDesc => "Each theme sets its own light or dark look.", L10nKey::SettingsTypography => "Typography", L10nKey::SettingsFontSize => "Terminal font size", - L10nKey::SettingsFontSizeDesc => "Terminal text size in pixels.", + L10nKey::SettingsFontSizeDesc => "Terminal text size in points.", L10nKey::SettingsUiFontSize => "Interface font size", L10nKey::SettingsUiFontSizeDesc => "Text size for tabs, panels and settings.", L10nKey::SettingsUiFontFamily => "Interface font", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 4498b6fc..b868ffc4 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -292,7 +292,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsThemeIntroDesc => "テーマごとにライトかダークが決まります", L10nKey::SettingsTypography => "タイポグラフィ", L10nKey::SettingsFontSize => "ターミナルの文字サイズ", - L10nKey::SettingsFontSizeDesc => "ターミナルテキストのサイズ(ピクセル)", + L10nKey::SettingsFontSizeDesc => "ターミナルテキストのサイズ(ポイント)", L10nKey::SettingsUiFontSize => "画面の文字サイズ", L10nKey::SettingsUiFontSizeDesc => "タブ・パネル・設定などの文字サイズ", L10nKey::SettingsUiFontFamily => "画面のフォント", diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 47ecea52..ea0bee8a 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -248,7 +248,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsThemeIntroDesc => "每个主题自带浅色或深色外观。", L10nKey::SettingsTypography => "字体排版", L10nKey::SettingsFontSize => "终端字号", - L10nKey::SettingsFontSizeDesc => "终端文字大小(像素)。", + L10nKey::SettingsFontSizeDesc => "终端文字大小(磅)。", L10nKey::SettingsUiFontSize => "界面字号", L10nKey::SettingsUiFontSizeDesc => "标签页、面板、设置等非终端文字的大小。", L10nKey::SettingsUiFontFamily => "界面字体",