diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index d85f761a82e2f4..34d797b152220d 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -325,6 +325,33 @@ impl<'a> Iterator for InlayChunks<'a> { }), InlayId::Hint(_) => self.highlight_styles.inlay_hint, InlayId::DebuggerValue(_) => self.highlight_styles.inlay_hint, + InlayId::ReplResult(_) => { + let text = inlay.text().to_string(); + renderer = Some(ChunkRenderer { + id: ChunkRendererId::Inlay(inlay.id), + render: Arc::new(move |cx| { + let colors = cx.theme().colors(); + div() + .flex() + .flex_row() + .items_center() + .child(div().w_4()) + .child( + div() + .px_1() + .rounded_sm() + .bg(colors.surface_background) + .text_color(colors.text_muted) + .text_xs() + .child(text.trim().to_string()), + ) + .into_any_element() + }), + constrain_width: false, + measured_width: None, + }); + self.highlight_styles.inlay_hint + } InlayId::Color(_) => { if let InlayContent::Color(color) = inlay.content { renderer = Some(ChunkRenderer { diff --git a/crates/editor/src/inlays.rs b/crates/editor/src/inlays.rs index f07bf0b315161f..32e2b383c5503b 100644 --- a/crates/editor/src/inlays.rs +++ b/crates/editor/src/inlays.rs @@ -104,6 +104,14 @@ impl Inlay { } } + pub fn repl_result>(id: usize, position: Anchor, text: T) -> Self { + Self { + id: InlayId::ReplResult(id), + position, + content: InlayContent::Text(text.into()), + } + } + pub fn text(&self) -> &Rope { static COLOR_TEXT: OnceLock = OnceLock::new(); match &self.content { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index ec44a60d71e4b0..78a27bd0a2d1bb 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -413,6 +413,7 @@ pub enum InlayId { // LSP Hint(usize), Color(usize), + ReplResult(usize), } impl InlayId { @@ -422,6 +423,7 @@ impl InlayId { Self::DebuggerValue(id) => *id, Self::Hint(id) => *id, Self::Color(id) => *id, + Self::ReplResult(id) => *id, } } } diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index b99562393a2bba..68b73b67bb4772 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -34,7 +34,7 @@ //! interpreting and displaying various types of Jupyter output. use editor::{Editor, MultiBuffer}; -use gpui::{AnyElement, ClipboardItem, Entity, Render, WeakEntity}; +use gpui::{AnyElement, ClipboardItem, Entity, EventEmitter, Render, WeakEntity}; use language::Buffer; use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType}; use ui::{ @@ -58,6 +58,9 @@ pub(crate) mod user_error; use user_error::ErrorView; use workspace::Workspace; +use crate::repl_settings::ReplSettings; +use settings::Settings; + /// When deciding what to render from a collection of mediatypes, we need to rank them in order of importance fn rank_mime_type(mimetype: &MimeType) -> usize { match mimetype { @@ -389,6 +392,9 @@ pub enum ExecutionStatus { Restarting, } +pub struct ExecutionViewFinishedEmpty; +pub struct ExecutionViewFinishedSmall(pub String); + /// An ExecutionView shows the outputs of an execution. /// It can hold zero or more outputs, which the user /// sees as "the output" for a single execution. @@ -399,6 +405,9 @@ pub struct ExecutionView { pub status: ExecutionStatus, } +impl EventEmitter for ExecutionView {} +impl EventEmitter for ExecutionView {} + impl ExecutionView { pub fn new( status: ExecutionStatus, @@ -475,7 +484,16 @@ impl ExecutionView { ExecutionState::Busy => { self.status = ExecutionStatus::Executing; } - ExecutionState::Idle => self.status = ExecutionStatus::Finished, + ExecutionState::Idle => { + self.status = ExecutionStatus::Finished; + if self.outputs.is_empty() { + cx.emit(ExecutionViewFinishedEmpty); + } else if ReplSettings::get_global(cx).inline_output { + if let Some(small_text) = self.get_small_inline_output(cx) { + cx.emit(ExecutionViewFinishedSmall(small_text)); + } + } + } ExecutionState::Unknown => self.status = ExecutionStatus::Unknown, ExecutionState::Starting => self.status = ExecutionStatus::ConnectingToKernel, ExecutionState::Restarting => self.status = ExecutionStatus::Restarting, @@ -527,6 +545,35 @@ impl ExecutionView { } } + /// Check if the output is a single small plain text that can be shown inline. + /// Returns the text if it's suitable for inline display (single line, short enough). + fn get_small_inline_output(&self, cx: &App) -> Option { + // Only consider single outputs + if self.outputs.len() != 1 { + return None; + } + + let output = self.outputs.first()?; + + // Only Plain outputs can be inlined + let content = match output { + Output::Plain { content, .. } => content, + _ => return None, + }; + + let text = content.read(cx).full_text(); + let trimmed = text.trim(); + + let max_length = ReplSettings::get_global(cx).inline_output_max_length; + + // Must be a single line and within the configured max length + if trimmed.contains('\n') || trimmed.len() > max_length { + return None; + } + + Some(trimmed.to_string()) + } + fn apply_terminal_text( &mut self, text: &str, diff --git a/crates/repl/src/repl_editor.rs b/crates/repl/src/repl_editor.rs index 9e52637ab75c02..85bd8bfb4a070f 100644 --- a/crates/repl/src/repl_editor.rs +++ b/crates/repl/src/repl_editor.rs @@ -433,6 +433,36 @@ fn runnable_ranges( } let snippet_range = cell_range(buffer, range.start.row, range.end.row); + + // Check if the snippet range is entirely blank, if so, skip forward to find code + let is_blank = + (snippet_range.start.row..=snippet_range.end.row).all(|row| buffer.is_line_blank(row)); + + if is_blank { + // Search forward for the next non-blank line + let max_row = buffer.max_point().row; + let mut next_row = snippet_range.end.row + 1; + while next_row <= max_row && buffer.is_line_blank(next_row) { + next_row += 1; + } + + if next_row <= max_row { + // Found a non-blank line, find the extent of this cell + let next_snippet_range = cell_range(buffer, next_row, next_row); + let start_language = buffer.language_at(next_snippet_range.start); + let end_language = buffer.language_at(next_snippet_range.end); + + if start_language + .zip(end_language) + .is_some_and(|(start, end)| start == end) + { + return (vec![next_snippet_range], None); + } + } + + return (Vec::new(), None); + } + let start_language = buffer.language_at(snippet_range.start); let end_language = buffer.language_at(snippet_range.end); @@ -821,4 +851,76 @@ mod tests { },] ); } + + #[gpui::test] + fn test_skip_blank_lines_to_next_cell(cx: &mut App) { + let test_language = Arc::new(Language::new( + LanguageConfig { + name: "TestLang".into(), + line_comments: vec!["# ".into()], + ..Default::default() + }, + None, + )); + + let buffer = cx.new(|cx| { + Buffer::local( + indoc! { r#" + print(1 + 1) + + print(2 + 2) + "# }, + cx, + ) + .with_language(test_language.clone(), cx) + }); + let snapshot = buffer.read(cx).snapshot(); + + // Selection on blank line should skip to next non-blank cell + let (snippets, _) = runnable_ranges(&snapshot, Point::new(1, 0)..Point::new(1, 0), cx); + let snippets = snippets + .into_iter() + .map(|range| snapshot.text_for_range(range).collect::()) + .collect::>(); + assert_eq!(snippets, vec!["print(2 + 2)"]); + + // Multiple blank lines should also skip forward + let buffer = cx.new(|cx| { + Buffer::local( + indoc! { r#" + print(1 + 1) + + + + print(2 + 2) + "# }, + cx, + ) + .with_language(test_language.clone(), cx) + }); + let snapshot = buffer.read(cx).snapshot(); + + let (snippets, _) = runnable_ranges(&snapshot, Point::new(2, 0)..Point::new(2, 0), cx); + let snippets = snippets + .into_iter() + .map(|range| snapshot.text_for_range(range).collect::()) + .collect::>(); + assert_eq!(snippets, vec!["print(2 + 2)"]); + + // Blank lines at end of file should return nothing + let buffer = cx.new(|cx| { + Buffer::local( + indoc! { r#" + print(1 + 1) + + "# }, + cx, + ) + .with_language(test_language, cx) + }); + let snapshot = buffer.read(cx).snapshot(); + + let (snippets, _) = runnable_ranges(&snapshot, Point::new(1, 0)..Point::new(1, 0), cx); + assert!(snippets.is_empty()); + } } diff --git a/crates/repl/src/repl_settings.rs b/crates/repl/src/repl_settings.rs index 9faed72e557dd2..b7fb0672e1a0ee 100644 --- a/crates/repl/src/repl_settings.rs +++ b/crates/repl/src/repl_settings.rs @@ -13,6 +13,15 @@ pub struct ReplSettings { /// /// Default: 128 pub max_columns: usize, + /// Whether to show small single-line outputs inline instead of in a block. + /// + /// Default: true + pub inline_output: bool, + /// Maximum number of characters for an output to be shown inline. + /// Only applies when `inline_output` is true. + /// + /// Default: 50 + pub inline_output_max_length: usize, } impl Settings for ReplSettings { @@ -22,6 +31,8 @@ impl Settings for ReplSettings { Self { max_lines: repl.max_lines.unwrap(), max_columns: repl.max_columns.unwrap(), + inline_output: repl.inline_output.unwrap_or(true), + inline_output_max_length: repl.inline_output_max_length.unwrap_or(50), } } } diff --git a/crates/repl/src/session.rs b/crates/repl/src/session.rs index 1fa0bfec356c43..337b3714362914 100644 --- a/crates/repl/src/session.rs +++ b/crates/repl/src/session.rs @@ -4,19 +4,26 @@ use crate::setup_editor_session_actions; use crate::{ KernelStatus, kernels::{Kernel, KernelSpecification, NativeRunningKernel}, - outputs::{ExecutionStatus, ExecutionView}, + outputs::{ + ExecutionStatus, ExecutionView, ExecutionViewFinishedEmpty, ExecutionViewFinishedSmall, + }, }; use anyhow::Context as _; use collections::{HashMap, HashSet}; use editor::SelectionEffects; use editor::{ - Anchor, AnchorRangeExt as _, Editor, MultiBuffer, ToPoint, + Anchor, AnchorRangeExt as _, Editor, Inlay, MultiBuffer, ToOffset, ToPoint, display_map::{ BlockContext, BlockId, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId, RenderBlock, }, scroll::Autoscroll, }; +use project::InlayId; + +/// Marker types +enum ReplExecutedRange {} + use futures::FutureExt as _; use gpui::{ Context, Entity, EventEmitter, Render, Subscription, Task, WeakEntity, Window, div, prelude::*, @@ -36,9 +43,13 @@ pub struct Session { fs: Arc, editor: WeakEntity, pub kernel: Kernel, - blocks: HashMap, pub kernel_specification: KernelSpecification, - _buffer_subscription: Subscription, + + blocks: HashMap, + result_inlays: HashMap, usize)>, + next_inlay_id: usize, + + _subscriptions: Vec, } struct EditorBlock { @@ -220,8 +231,10 @@ impl Session { editor, kernel: Kernel::StartingKernel(Task::ready(()).shared()), blocks: HashMap::default(), + result_inlays: HashMap::default(), + next_inlay_id: 0, kernel_specification, - _buffer_subscription: subscription, + _subscriptions: vec![subscription], }; session.start_kernel(window, cx); @@ -321,20 +334,56 @@ impl Session { let snapshot = buffer.read(cx).snapshot(cx); let mut blocks_to_remove: HashSet = HashSet::default(); + let mut gutter_ranges_to_remove: Vec> = Vec::new(); + let mut keys_to_remove: Vec = Vec::new(); - self.blocks.retain(|_id, block| { + self.blocks.retain(|id, block| { if block.invalidation_anchor.is_valid(&snapshot) { true } else { blocks_to_remove.insert(block.block_id); + gutter_ranges_to_remove.push(block.code_range.clone()); + keys_to_remove.push(id.clone()); false } }); - if !blocks_to_remove.is_empty() { + let mut inlays_to_remove: Vec = Vec::new(); + + self.result_inlays + .retain(|id, (inlay_id, code_range, original_len)| { + let start_offset = code_range.start.to_offset(&snapshot); + let end_offset = code_range.end.to_offset(&snapshot); + let current_len = end_offset.saturating_sub(start_offset); + + if current_len != *original_len { + inlays_to_remove.push(*inlay_id); + gutter_ranges_to_remove.push(code_range.clone()); + keys_to_remove.push(id.clone()); + false + } else { + true + } + }); + + if !blocks_to_remove.is_empty() + || !inlays_to_remove.is_empty() + || !gutter_ranges_to_remove.is_empty() + { self.editor .update(cx, |editor, cx| { - editor.remove_blocks(blocks_to_remove, None, cx); + if !blocks_to_remove.is_empty() { + editor.remove_blocks(blocks_to_remove, None, cx); + } + if !inlays_to_remove.is_empty() { + editor.splice_inlays(&inlays_to_remove, vec![], cx); + } + if !gutter_ranges_to_remove.is_empty() { + editor.remove_gutter_highlights::( + gutter_ranges_to_remove, + cx, + ); + } }) .ok(); cx.notify(); @@ -350,17 +399,72 @@ impl Session { anyhow::Ok(()) } + fn replace_block_with_inlay(&mut self, message_id: &str, text: &str, cx: &mut Context) { + let Some(block) = self.blocks.remove(message_id) else { + return; + }; + + let Some(editor) = self.editor.upgrade() else { + return; + }; + + let code_range = block.code_range.clone(); + + editor.update(cx, |editor, cx| { + let mut block_ids = HashSet::default(); + block_ids.insert(block.block_id); + editor.remove_blocks(block_ids, None, cx); + + let buffer = editor.buffer().read(cx).snapshot(cx); + let start_offset = code_range.start.to_offset(&buffer); + let end_offset = code_range.end.to_offset(&buffer); + let original_len = end_offset.saturating_sub(start_offset); + + let end_point = code_range.end.to_point(&buffer); + let inlay_position = buffer.anchor_after(end_point); + + let inlay_id = self.next_inlay_id; + self.next_inlay_id += 1; + + let inlay = Inlay::repl_result(inlay_id, inlay_position, format!(" {}", text)); + + editor.splice_inlays(&[], vec![inlay], cx); + self.result_inlays.insert( + message_id.to_string(), + ( + InlayId::ReplResult(inlay_id), + code_range.clone(), + original_len, + ), + ); + + editor.insert_gutter_highlight::( + code_range, + |cx| cx.theme().status().success, + cx, + ); + }); + + cx.notify(); + } + pub fn clear_outputs(&mut self, cx: &mut Context) { let blocks_to_remove: HashSet = self.blocks.values().map(|block| block.block_id).collect(); + let inlays_to_remove: Vec = + self.result_inlays.values().map(|(id, _, _)| *id).collect(); + self.editor .update(cx, |editor, cx| { editor.remove_blocks(blocks_to_remove, None, cx); + editor.splice_inlays(&inlays_to_remove, vec![], cx); + editor.clear_gutter_highlights::(cx); }) .ok(); self.blocks.clear(); + self.result_inlays.clear(); } pub fn execute( @@ -388,6 +492,8 @@ impl Session { let message: JupyterMessage = execute_request.into(); let mut blocks_to_remove: HashSet = HashSet::default(); + let mut inlays_to_remove: Vec = Vec::new(); + let mut gutter_ranges_to_remove: Vec> = Vec::new(); let buffer = editor.read(cx).buffer().read(cx).snapshot(cx); @@ -400,9 +506,27 @@ impl Session { } }); + self.result_inlays + .retain(|_key, (inlay_id, inlay_range, _)| { + if anchor_range.overlaps(inlay_range, &buffer) { + inlays_to_remove.push(*inlay_id); + gutter_ranges_to_remove.push(inlay_range.clone()); + false + } else { + true + } + }); + self.editor .update(cx, |editor, cx| { editor.remove_blocks(blocks_to_remove, None, cx); + if !inlays_to_remove.is_empty() { + editor.splice_inlays(&inlays_to_remove, vec![], cx); + } + if !gutter_ranges_to_remove.is_empty() { + editor + .remove_gutter_highlights::(gutter_ranges_to_remove, cx); + } }) .ok(); @@ -418,6 +542,7 @@ impl Session { let parent_message_id = message.header.msg_id.clone(); let session_view = cx.entity().downgrade(); let weak_editor = self.editor.clone(); + let code_range_for_close = anchor_range.clone(); let on_close: CloseBlockFn = Arc::new( move |block_id: CustomBlockId, _: &mut Window, cx: &mut App| { @@ -433,23 +558,59 @@ impl Session { let mut block_ids = HashSet::default(); block_ids.insert(block_id); editor.remove_blocks(block_ids, None, cx); + editor.remove_gutter_highlights::( + vec![code_range_for_close.clone()], + cx, + ); }); } }, ); - let Ok(editor_block) = - EditorBlock::new(self.editor.clone(), anchor_range, status, on_close, cx) - else { + let Ok(editor_block) = EditorBlock::new( + self.editor.clone(), + anchor_range.clone(), + status, + on_close, + cx, + ) else { return; }; + self.editor + .update(cx, |editor, cx| { + editor.insert_gutter_highlight::( + anchor_range.clone(), + |cx| cx.theme().status().success, + cx, + ); + }) + .ok(); + let new_cursor_pos = if let Some(next_cursor) = next_cell { next_cursor } else { editor_block.invalidation_anchor }; + let msg_id = message.header.msg_id.clone(); + let subscription = cx.subscribe( + &editor_block.execution_view, + move |session, _execution_view, _event: &ExecutionViewFinishedEmpty, cx| { + session.replace_block_with_inlay(&msg_id, "✓", cx); + }, + ); + self._subscriptions.push(subscription); + + let msg_id = message.header.msg_id.clone(); + let subscription = cx.subscribe( + &editor_block.execution_view, + move |session, _execution_view, event: &ExecutionViewFinishedSmall, cx| { + session.replace_block_with_inlay(&msg_id, &event.0, cx); + }, + ); + self._subscriptions.push(subscription); + self.blocks .insert(message.header.msg_id.clone(), editor_block); diff --git a/crates/settings/src/settings_content.rs b/crates/settings/src/settings_content.rs index 743e22b04d9cf8..82e5c597a5c046 100644 --- a/crates/settings/src/settings_content.rs +++ b/crates/settings/src/settings_content.rs @@ -967,6 +967,15 @@ pub struct ReplSettingsContent { /// /// Default: 128 pub max_columns: Option, + /// Whether to show small single-line outputs inline instead of in a block. + /// + /// Default: true + pub inline_output: Option, + /// Maximum number of characters for an output to be shown inline. + /// Only applies when `inline_output` is true. + /// + /// Default: 50 + pub inline_output_max_length: Option, } #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]