debugger: Parse and highlight text with ANSI escape sequences (#32915)

Relanding #32817 with an improved approach, bugs fixed, and a test.

Release Notes:

- N/A
This commit is contained in:
Cole Miller 2025-06-17 23:39:31 -04:00 committed by GitHub
parent 4da58188fb
commit bfffc293a3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 606 additions and 80 deletions

View file

@ -26,6 +26,7 @@ test-support = [
]
[dependencies]
alacritty_terminal.workspace = true
anyhow.workspace = true
client.workspace = true
collections.workspace = true

View file

@ -2,13 +2,15 @@ use super::{
stack_frame_list::{StackFrameList, StackFrameListEvent},
variable_list::VariableList,
};
use alacritty_terminal::vte::ansi;
use anyhow::Result;
use collections::HashMap;
use dap::OutputEvent;
use editor::{Bias, CompletionProvider, Editor, EditorElement, EditorStyle, ExcerptId};
use fuzzy::StringMatchCandidate;
use gpui::{
Context, Entity, FocusHandle, Focusable, Render, Subscription, Task, TextStyle, WeakEntity,
Context, Entity, FocusHandle, Focusable, HighlightStyle, Hsla, Render, Subscription, Task,
TextStyle, WeakEntity,
};
use language::{Buffer, CodeLabel, ToOffset};
use menu::Confirm;
@ -17,8 +19,8 @@ use project::{
debugger::session::{CompletionsQuery, OutputToken, Session, SessionEvent},
};
use settings::Settings;
use std::{cell::RefCell, rc::Rc, usize};
use theme::ThemeSettings;
use std::{cell::RefCell, ops::Range, rc::Rc, usize};
use theme::{Theme, ThemeSettings};
use ui::{Divider, prelude::*};
pub struct Console {
@ -136,18 +138,193 @@ impl Console {
cx: &mut App,
) {
self.console.update(cx, |console, cx| {
let mut to_insert = String::default();
for event in events {
use std::fmt::Write;
console.set_read_only(false);
_ = write!(to_insert, "{}\n", event.output.trim_end());
for event in events {
let to_insert = format!("{}\n", event.output.trim_end());
let mut ansi_handler = ConsoleHandler::default();
let mut ansi_processor = ansi::Processor::<ansi::StdSyncHandler>::default();
let len = console.buffer().read(cx).len(cx);
ansi_processor.advance(&mut ansi_handler, to_insert.as_bytes());
let output = std::mem::take(&mut ansi_handler.output);
let mut spans = std::mem::take(&mut ansi_handler.spans);
let mut background_spans = std::mem::take(&mut ansi_handler.background_spans);
if ansi_handler.current_range_start < output.len() {
spans.push((
ansi_handler.current_range_start..output.len(),
ansi_handler.current_color,
));
}
if ansi_handler.current_background_range_start < output.len() {
background_spans.push((
ansi_handler.current_background_range_start..output.len(),
ansi_handler.current_background_color,
));
}
console.move_to_end(&editor::actions::MoveToEnd, window, cx);
console.insert(&output, window, cx);
let buffer = console.buffer().read(cx).snapshot(cx);
struct ConsoleAnsiHighlight;
for (range, color) in spans {
let Some(color) = color else { continue };
let start_offset = len + range.start;
let range = start_offset..len + range.end;
let range = buffer.anchor_after(range.start)..buffer.anchor_before(range.end);
let style = HighlightStyle {
color: Some(terminal_view::terminal_element::convert_color(
&color,
cx.theme(),
)),
..Default::default()
};
console.highlight_text_key::<ConsoleAnsiHighlight>(
start_offset,
vec![range],
style,
cx,
);
}
for (range, color) in background_spans {
let Some(color) = color else { continue };
let start_offset = len + range.start;
let range = start_offset..len + range.end;
let range = buffer.anchor_after(range.start)..buffer.anchor_before(range.end);
let color_fetcher: fn(&Theme) -> Hsla = match color {
// Named and theme defined colors
ansi::Color::Named(n) => match n {
ansi::NamedColor::Black => |theme| theme.colors().terminal_ansi_black,
ansi::NamedColor::Red => |theme| theme.colors().terminal_ansi_red,
ansi::NamedColor::Green => |theme| theme.colors().terminal_ansi_green,
ansi::NamedColor::Yellow => |theme| theme.colors().terminal_ansi_yellow,
ansi::NamedColor::Blue => |theme| theme.colors().terminal_ansi_blue,
ansi::NamedColor::Magenta => {
|theme| theme.colors().terminal_ansi_magenta
}
ansi::NamedColor::Cyan => |theme| theme.colors().terminal_ansi_cyan,
ansi::NamedColor::White => |theme| theme.colors().terminal_ansi_white,
ansi::NamedColor::BrightBlack => {
|theme| theme.colors().terminal_ansi_bright_black
}
ansi::NamedColor::BrightRed => {
|theme| theme.colors().terminal_ansi_bright_red
}
ansi::NamedColor::BrightGreen => {
|theme| theme.colors().terminal_ansi_bright_green
}
ansi::NamedColor::BrightYellow => {
|theme| theme.colors().terminal_ansi_bright_yellow
}
ansi::NamedColor::BrightBlue => {
|theme| theme.colors().terminal_ansi_bright_blue
}
ansi::NamedColor::BrightMagenta => {
|theme| theme.colors().terminal_ansi_bright_magenta
}
ansi::NamedColor::BrightCyan => {
|theme| theme.colors().terminal_ansi_bright_cyan
}
ansi::NamedColor::BrightWhite => {
|theme| theme.colors().terminal_ansi_bright_white
}
ansi::NamedColor::Foreground => {
|theme| theme.colors().terminal_foreground
}
ansi::NamedColor::Background => {
|theme| theme.colors().terminal_background
}
ansi::NamedColor::Cursor => |theme| theme.players().local().cursor,
ansi::NamedColor::DimBlack => {
|theme| theme.colors().terminal_ansi_dim_black
}
ansi::NamedColor::DimRed => {
|theme| theme.colors().terminal_ansi_dim_red
}
ansi::NamedColor::DimGreen => {
|theme| theme.colors().terminal_ansi_dim_green
}
ansi::NamedColor::DimYellow => {
|theme| theme.colors().terminal_ansi_dim_yellow
}
ansi::NamedColor::DimBlue => {
|theme| theme.colors().terminal_ansi_dim_blue
}
ansi::NamedColor::DimMagenta => {
|theme| theme.colors().terminal_ansi_dim_magenta
}
ansi::NamedColor::DimCyan => {
|theme| theme.colors().terminal_ansi_dim_cyan
}
ansi::NamedColor::DimWhite => {
|theme| theme.colors().terminal_ansi_dim_white
}
ansi::NamedColor::BrightForeground => {
|theme| theme.colors().terminal_bright_foreground
}
ansi::NamedColor::DimForeground => {
|theme| theme.colors().terminal_dim_foreground
}
},
// 'True' colors
ansi::Color::Spec(_) => |theme| theme.colors().editor_background,
// 8 bit, indexed colors
ansi::Color::Indexed(i) => {
match i {
// 0-15 are the same as the named colors above
0 => |theme| theme.colors().terminal_ansi_black,
1 => |theme| theme.colors().terminal_ansi_red,
2 => |theme| theme.colors().terminal_ansi_green,
3 => |theme| theme.colors().terminal_ansi_yellow,
4 => |theme| theme.colors().terminal_ansi_blue,
5 => |theme| theme.colors().terminal_ansi_magenta,
6 => |theme| theme.colors().terminal_ansi_cyan,
7 => |theme| theme.colors().terminal_ansi_white,
8 => |theme| theme.colors().terminal_ansi_bright_black,
9 => |theme| theme.colors().terminal_ansi_bright_red,
10 => |theme| theme.colors().terminal_ansi_bright_green,
11 => |theme| theme.colors().terminal_ansi_bright_yellow,
12 => |theme| theme.colors().terminal_ansi_bright_blue,
13 => |theme| theme.colors().terminal_ansi_bright_magenta,
14 => |theme| theme.colors().terminal_ansi_bright_cyan,
15 => |theme| theme.colors().terminal_ansi_bright_white,
// 16-231 are a 6x6x6 RGB color cube, mapped to 0-255 using steps defined by XTerm.
// See: https://github.com/xterm-x11/xterm-snapshots/blob/master/256colres.pl
// 16..=231 => {
// let (r, g, b) = rgb_for_index(index as u8);
// rgba_color(
// if r == 0 { 0 } else { r * 40 + 55 },
// if g == 0 { 0 } else { g * 40 + 55 },
// if b == 0 { 0 } else { b * 40 + 55 },
// )
// }
// 232-255 are a 24-step grayscale ramp from (8, 8, 8) to (238, 238, 238).
// 232..=255 => {
// let i = index as u8 - 232; // Align index to 0..24
// let value = i * 10 + 8;
// rgba_color(value, value, value)
// }
// For compatibility with the alacritty::Colors interface
// See: https://github.com/alacritty/alacritty/blob/master/alacritty_terminal/src/term/color.rs
_ => |_| gpui::black(),
}
}
};
console.highlight_background_key::<ConsoleAnsiHighlight>(
start_offset,
&[range],
color_fetcher,
cx,
);
}
}
console.set_read_only(false);
console.move_to_end(&editor::actions::MoveToEnd, window, cx);
console.insert(&to_insert, window, cx);
console.set_read_only(true);
cx.notify();
});
}
@ -459,3 +636,69 @@ impl ConsoleQueryBarCompletionProvider {
})
}
}
#[derive(Default)]
struct ConsoleHandler {
output: String,
spans: Vec<(Range<usize>, Option<ansi::Color>)>,
background_spans: Vec<(Range<usize>, Option<ansi::Color>)>,
current_range_start: usize,
current_background_range_start: usize,
current_color: Option<ansi::Color>,
current_background_color: Option<ansi::Color>,
pos: usize,
}
impl ConsoleHandler {
fn break_span(&mut self, color: Option<ansi::Color>) {
self.spans.push((
self.current_range_start..self.output.len(),
self.current_color,
));
self.current_color = color;
self.current_range_start = self.pos;
}
fn break_background_span(&mut self, color: Option<ansi::Color>) {
self.background_spans.push((
self.current_background_range_start..self.output.len(),
self.current_background_color,
));
self.current_background_color = color;
self.current_background_range_start = self.pos;
}
}
impl ansi::Handler for ConsoleHandler {
fn input(&mut self, c: char) {
self.output.push(c);
self.pos += c.len_utf8();
}
fn linefeed(&mut self) {
self.output.push('\n');
self.pos += 1;
}
fn put_tab(&mut self, count: u16) {
self.output
.extend(std::iter::repeat('\t').take(count as usize));
self.pos += count as usize;
}
fn terminal_attribute(&mut self, attr: ansi::Attr) {
match attr {
ansi::Attr::Foreground(color) => {
self.break_span(Some(color));
}
ansi::Attr::Background(color) => {
self.break_background_span(Some(color));
}
ansi::Attr::Reset => {
self.break_span(None);
self.break_background_span(None);
}
_ => {}
}
}
}

View file

@ -3,6 +3,7 @@ use crate::{
*,
};
use dap::requests::StackTrace;
use editor::{DisplayPoint, display_map::DisplayRow};
use gpui::{BackgroundExecutor, TestAppContext, VisualTestContext};
use project::{FakeFs, Project};
use serde_json::json;
@ -110,7 +111,7 @@ async fn test_handle_output_event(executor: BackgroundExecutor, cx: &mut TestApp
client
.fake_event(dap::messages::Events::Output(dap::OutputEvent {
category: Some(dap::OutputEventCategory::Stdout),
output: "Second output line after thread stopped!".to_string(),
output: "\tSecond output line after thread stopped!".to_string(),
data: None,
variables_reference: None,
source: None,
@ -124,7 +125,7 @@ async fn test_handle_output_event(executor: BackgroundExecutor, cx: &mut TestApp
client
.fake_event(dap::messages::Events::Output(dap::OutputEvent {
category: Some(dap::OutputEventCategory::Console),
output: "Second console output line after thread stopped!".to_string(),
output: "\tSecond console output line after thread stopped!".to_string(),
data: None,
variables_reference: None,
source: None,
@ -150,13 +151,209 @@ async fn test_handle_output_event(executor: BackgroundExecutor, cx: &mut TestApp
.unwrap();
assert_eq!(
"First console output line before thread stopped!\nFirst output line before thread stopped!\nSecond output line after thread stopped!\nSecond console output line after thread stopped!\n",
"First console output line before thread stopped!\nFirst output line before thread stopped!\n\tSecond output line after thread stopped!\n\tSecond console output line after thread stopped!\n",
active_session_panel.read(cx).running_state().read(cx).console().read(cx).editor().read(cx).text(cx).as_str()
);
})
.unwrap();
}
#[gpui::test]
async fn test_escape_code_processing(executor: BackgroundExecutor, cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(executor.clone());
fs.insert_tree(
path!("/project"),
json!({
"main.rs": "First line\nSecond line\nThird line\nFourth line",
}),
)
.await;
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
let workspace = init_test_workspace(&project, cx).await;
let cx = &mut VisualTestContext::from_window(*workspace, cx);
workspace
.update(cx, |workspace, window, cx| {
workspace.focus_panel::<DebugPanel>(window, cx);
})
.unwrap();
let session = start_debug_session(&workspace, cx, |_| {}).unwrap();
let client = session.read_with(cx, |session, _| session.adapter_client().unwrap());
client.on_request::<StackTrace, _>(move |_, _| {
Ok(dap::StackTraceResponse {
stack_frames: Vec::default(),
total_frames: None,
})
});
client
.fake_event(dap::messages::Events::Output(dap::OutputEvent {
category: None,
output: "Checking latest version of JavaScript...".to_string(),
data: None,
variables_reference: None,
source: None,
line: None,
column: None,
group: None,
location_reference: None,
}))
.await;
client
.fake_event(dap::messages::Events::Output(dap::OutputEvent {
category: None,
output: " \u{1b}[1m\u{1b}[38;2;173;127;168m▲ Next.js 15.1.5\u{1b}[39m\u{1b}[22m"
.to_string(),
data: None,
variables_reference: None,
source: None,
line: None,
column: None,
group: None,
location_reference: None,
}))
.await;
client
.fake_event(dap::messages::Events::Output(dap::OutputEvent {
category: None,
output: " - Local: http://localhost:3000\n - Network: http://192.168.1.144:3000\n\n \u{1b}[32m\u{1b}[1m✓\u{1b}[22m\u{1b}[39m Starting..."
.to_string(),
data: None,
variables_reference: None,
source: None,
line: None,
column: None,
group: None,
location_reference: None,
}))
.await;
// [crates/debugger_ui/src/session/running/console.rs:147:9] &to_insert = "Could not read source map for file:///Users/cole/roles-at/node_modules/.pnpm/typescript@5.7.3/node_modules/typescript/lib/typescript.js: ENOENT: no such file or directory, open '/Users/cole/roles-at/node_modules/.pnpm/typescript@5.7.3/node_modules/typescript/lib/typescript.js.map'\n"
client
.fake_event(dap::messages::Events::Output(dap::OutputEvent {
category: None,
output: "Something else...".to_string(),
data: None,
variables_reference: None,
source: None,
line: None,
column: None,
group: None,
location_reference: None,
}))
.await;
client
.fake_event(dap::messages::Events::Output(dap::OutputEvent {
category: None,
output: " \u{1b}[32m\u{1b}[1m✓\u{1b}[22m\u{1b}[39m Ready in 1009ms\n".to_string(),
data: None,
variables_reference: None,
source: None,
line: None,
column: None,
group: None,
location_reference: None,
}))
.await;
// introduce some background highlight
client
.fake_event(dap::messages::Events::Output(dap::OutputEvent {
category: None,
output: "\u{1b}[41m\u{1b}[37mBoth background and foreground!".to_string(),
data: None,
variables_reference: None,
source: None,
line: None,
column: None,
group: None,
location_reference: None,
}))
.await;
// another random line
client
.fake_event(dap::messages::Events::Output(dap::OutputEvent {
category: None,
output: "Even more...".to_string(),
data: None,
variables_reference: None,
source: None,
line: None,
column: None,
group: None,
location_reference: None,
}))
.await;
cx.run_until_parked();
let _running_state =
active_debug_session_panel(workspace, cx).update_in(cx, |item, window, cx| {
cx.focus_self(window);
item.running_state().clone()
});
cx.run_until_parked();
workspace
.update(cx, |workspace, window, cx| {
let debug_panel = workspace.panel::<DebugPanel>(cx).unwrap();
let active_debug_session_panel = debug_panel
.update(cx, |this, _| this.active_session())
.unwrap();
let editor =
active_debug_session_panel
.read(cx)
.running_state()
.read(cx)
.console()
.read(cx)
.editor().clone();
assert_eq!(
"Checking latest version of JavaScript...\n ▲ Next.js 15.1.5\n - Local: http://localhost:3000\n - Network: http://192.168.1.144:3000\n\n ✓ Starting...\nSomething else...\n ✓ Ready in 1009ms\nBoth background and foreground!\nEven more...\n",
editor
.read(cx)
.text(cx)
.as_str()
);
let text_highlights = editor.update(cx, |editor, cx| {
let mut text_highlights = editor.all_text_highlights(window, cx).into_iter().flat_map(|(_, ranges)| ranges).collect::<Vec<_>>();
text_highlights.sort_by(|a, b| a.start.cmp(&b.start));
text_highlights
});
pretty_assertions::assert_eq!(
text_highlights,
[
DisplayPoint::new(DisplayRow(1), 3)..DisplayPoint::new(DisplayRow(1), 21),
DisplayPoint::new(DisplayRow(1), 21)..DisplayPoint::new(DisplayRow(2), 0),
DisplayPoint::new(DisplayRow(5), 1)..DisplayPoint::new(DisplayRow(5), 4),
DisplayPoint::new(DisplayRow(5), 4)..DisplayPoint::new(DisplayRow(6), 0),
DisplayPoint::new(DisplayRow(7), 1)..DisplayPoint::new(DisplayRow(7), 4),
DisplayPoint::new(DisplayRow(7), 4)..DisplayPoint::new(DisplayRow(8), 0),
DisplayPoint::new(DisplayRow(8), 0)..DisplayPoint::new(DisplayRow(9), 0),
]
);
let background_highlights = editor.update(cx, |editor, cx| {
editor.all_text_background_highlights(window, cx).into_iter().map(|(range, _)| range).collect::<Vec<_>>()
});
pretty_assertions::assert_eq!(
background_highlights,
[
DisplayPoint::new(DisplayRow(8), 0)..DisplayPoint::new(DisplayRow(9), 0),
]
)
})
.unwrap();
}
// #[gpui::test]
// async fn test_grouped_output(executor: BackgroundExecutor, cx: &mut TestAppContext) {
// init_test(cx);