From 7755444703361f68910190de08e680ae6ab8cbbd Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 24 Mar 2026 09:22:03 -0700 Subject: [PATCH 01/15] update comms --- .../amalthea/src/comm/data_explorer_comm.rs | 48 ++++++++++++++++++- crates/amalthea/src/comm/ui_comm.rs | 23 --------- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/crates/amalthea/src/comm/data_explorer_comm.rs b/crates/amalthea/src/comm/data_explorer_comm.rs index 56dd29927..f15666728 100644 --- a/crates/amalthea/src/comm/data_explorer_comm.rs +++ b/crates/amalthea/src/comm/data_explorer_comm.rs @@ -1,7 +1,7 @@ // @generated /*--------------------------------------------------------------------------------------------- - * Copyright (C) 2024-2025 Posit Software, PBC. All rights reserved. + * Copyright (C) 2024-2026 Posit Software, PBC. All rights reserved. *--------------------------------------------------------------------------------------------*/ // @@ -60,6 +60,13 @@ pub struct FilterResult { pub had_errors: Option } +/// Result of setting import options +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct SetDatasetImportOptionsResult { + /// An error message if setting the options failed + pub error_message: Option +} + /// The current backend state for the data explorer #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct BackendState { @@ -703,6 +710,15 @@ pub struct ColumnSelection { pub spec: ArraySelection } +/// Import options for file-based data sources. Currently supports options +/// for delimited text files (CSV, TSV). +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct DatasetImportOptions { + /// Whether the first row contains column headers (for delimited text + /// files) + pub has_header_row: Option +} + /// Possible values for SortOrder in SearchSchema #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, strum_macros::Display, strum_macros::EnumString)] pub enum SearchSchemaSortOrder { @@ -1193,6 +1209,13 @@ pub struct GetColumnProfilesParams { pub format_options: FormatOptions, } +/// Parameters for the SetDatasetImportOptions method. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct SetDatasetImportOptionsParams { + /// Import options to apply + pub options: DatasetImportOptions, +} + /// Parameters for the ReturnColumnProfiles method. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct ReturnColumnProfilesParams { @@ -1289,6 +1312,23 @@ pub enum DataExplorerBackendRequest { #[serde(rename = "get_column_profiles")] GetColumnProfiles(GetColumnProfilesParams), + /// Set import options for file-based data sources + /// + /// Set import options for file-based data sources (like CSV files) and + /// reimport the data. This method is primarily used by file-based + /// backends like DuckDB. + #[serde(rename = "set_dataset_import_options")] + SetDatasetImportOptions(SetDatasetImportOptionsParams), + + /// Open a full data explorer for the same data + /// + /// Creates a new, independent data explorer comm for the same underlying + /// data. The new comm has its own state (filters, sorts). Used when + /// promoting an inline notebook data explorer to a full data explorer + /// panel. + #[serde(rename = "open_data_explorer")] + OpenDataExplorer, + /// Get the state /// /// Request the current backend state (table metadata, explorer state, and @@ -1337,6 +1377,12 @@ pub enum DataExplorerBackendReply { /// Reply for the get_column_profiles method (no result) GetColumnProfilesReply(), + /// Result of setting import options + SetDatasetImportOptionsReply(SetDatasetImportOptionsResult), + + /// Reply for the open_data_explorer method (no result) + OpenDataExplorerReply(), + /// The current backend state for the data explorer GetStateReply(BackendState), diff --git a/crates/amalthea/src/comm/ui_comm.rs b/crates/amalthea/src/comm/ui_comm.rs index 77ade724e..78bf214cc 100644 --- a/crates/amalthea/src/comm/ui_comm.rs +++ b/crates/amalthea/src/comm/ui_comm.rs @@ -183,17 +183,6 @@ pub struct EvaluateCodeParams { pub code: String, } -/// Parameters for the EditorContextChanged method. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] -pub struct EditorContextChangedParams { - /// The URI of the active document, or empty string if no editor is active - pub document_uri: String, - - /// Whether this editor is the source of code being executed. When true, - /// the backend may temporarily add the file's directory to sys.path. - pub is_execution_source: bool, -} - /// Parameters for the Busy method. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct BusyParams { @@ -437,15 +426,6 @@ pub enum UiBackendRequest { #[serde(rename = "evaluate_code")] EvaluateCode(EvaluateCodeParams), - /// Active editor context changed - /// - /// This notification is sent from the frontend to the backend when the - /// active text editor changes or when code is about to be executed from a - /// file. It provides the document URI and indicates whether this is the - /// source file for code execution. - #[serde(rename = "editor_context_changed")] - EditorContextChanged(EditorContextChangedParams), - } /** @@ -463,9 +443,6 @@ pub enum UiBackendReply { /// The results of evaluating the statement EvaluateCodeReply(EvalResult), - /// Unused response to notification - EditorContextChangedReply(), - } /** From 2c3dc8e663a7864e643f656b32b81dfd40535a41 Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 24 Mar 2026 09:50:41 -0700 Subject: [PATCH 02/15] initial impl of inline data explorer --- crates/ark/src/console/console_repl.rs | 60 +++++++++- .../ark/src/data_explorer/r_data_explorer.rs | 27 ++++- crates/ark/src/ui/ui_comm.rs | 16 --- crates/ark/src/variables/r_variables.rs | 2 +- crates/ark/tests/data_explorer.rs | 4 +- .../tests/kernel-notebook-data-explorer.rs | 111 ++++++++++++++++++ crates/ark_test/src/dummy_frontend.rs | 15 +++ 7 files changed, 210 insertions(+), 25 deletions(-) create mode 100644 crates/ark/tests/kernel-notebook-data-explorer.rs diff --git a/crates/ark/src/console/console_repl.rs b/crates/ark/src/console/console_repl.rs index bc1dd20da..f4ec94544 100644 --- a/crates/ark/src/console/console_repl.rs +++ b/crates/ark/src/console/console_repl.rs @@ -11,8 +11,11 @@ //! ReadConsole, WriteConsole, and R frontend callbacks. use super::*; +use crate::data_explorer::r_data_explorer::RDataExplorer; +use crate::data_explorer::r_data_explorer::DATA_EXPLORER_COMM_NAME; use crate::r_task::QueuedRTask; use crate::r_task::RTask; +use harp::vector::Vector; static RE_DEBUG_PROMPT: Lazy = Lazy::new(|| Regex::new(r"Browse\[\d+\]").unwrap()); @@ -1131,24 +1134,73 @@ impl Console { data.insert("text/plain".to_string(), json!(autoprint)); } - // Include HTML representation of data.frame + // Include HTML representation of data.frame and optionally open an + // inline data explorer in Positron notebook mode. Only do this when + // there is visible output (autoprint produced text/plain). unsafe { - let value = Rf_findVarInFrame(R_GlobalEnv, r_symbol!(".Last.value")); - if r_is_data_frame(value) { + let value = libr::Rf_findVar(r_symbol!(".Last.value"), R_GlobalEnv); + if !data.is_empty() && r_is_data_frame(value) { match to_html(value) { Ok(html) => { data.insert("text/html".to_string(), json!(html)); }, Err(err) => { - log::error!("{:?}", err); + log::error!("{err:?}"); }, }; + + if self.session_mode == SessionMode::Notebook && self.ui_comm_id.is_some() { + match self.open_inline_data_explorer(value) { + Ok(mime_data) => { + data.insert( + "application/vnd.positron.dataExplorer+json".to_string(), + mime_data, + ); + }, + Err(err) => { + log::error!("Failed to open inline data explorer: {err:?}"); + }, + } + } } } data } + /// Open an inline data explorer for a data frame value and return the MIME + /// type payload to include in the execute result. + fn open_inline_data_explorer( + &mut self, + value: SEXP, + ) -> anyhow::Result { + let data = RObject::new(value); + + // Derive title from the first R class (e.g. "tbl_df", "data.table", "data.frame") + let title = harp::utils::r_classes(value) + .and_then(|classes| { + classes.get_unchecked(0).map(|s| s.to_string()) + }) + .unwrap_or_else(|| String::from("data.frame")); + + let shape = RDataExplorer::get_shape(data.clone())?; + + let explorer = RDataExplorer::new(title.clone(), data, None, true)?; + let comm_id = + self.comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; + + Ok(json!({ + "version": 1, + "comm_id": comm_id, + "shape": { + "num_rows": shape.num_rows, + "num_columns": shape.columns.len(), + }, + "title": title, + "source": title, + })) + } + /// Reset debug flag on the global environment. /// /// This is a workaround for when a breakpoint was entered at top-level, in diff --git a/crates/ark/src/data_explorer/r_data_explorer.rs b/crates/ark/src/data_explorer/r_data_explorer.rs index 3d06e9b87..7a9a74df8 100644 --- a/crates/ark/src/data_explorer/r_data_explorer.rs +++ b/crates/ark/src/data_explorer/r_data_explorer.rs @@ -157,6 +157,11 @@ pub struct RDataExplorer { /// row indices. This is the set of row indices that are displayed in the /// data viewer. view_indices: Option>, + + /// Whether this explorer is for inline display only (e.g. in a notebook + /// cell output). When true, the frontend renders a compact inline grid + /// instead of opening the full Data Explorer panel. + inline_only: bool, } impl std::fmt::Debug for RDataExplorer { @@ -173,6 +178,7 @@ impl RDataExplorer { title: String, data: RObject, binding: Option, + inline_only: bool, ) -> anyhow::Result { let table = Table::new(data); let shape = Self::get_shape(table.get().clone())?; @@ -187,6 +193,7 @@ impl RDataExplorer { sort_keys: vec![], row_filters: vec![], col_filters: vec![], + inline_only, }) } @@ -416,13 +423,29 @@ impl RDataExplorer { DataExplorerBackendRequest::SuggestCodeSyntax => Ok( DataExplorerBackendReply::SuggestCodeSyntaxReply(self.suggest_code_syntax()), ), + + DataExplorerBackendRequest::SetDatasetImportOptions(_) => { + Err(anyhow!("Data Explorer: Not yet supported")) + }, + + DataExplorerBackendRequest::OpenDataExplorer => { + let explorer = RDataExplorer::new( + self.title.clone(), + self.table.get().clone(), + None, + false, + )?; + Console::get_mut() + .comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; + Ok(DataExplorerBackendReply::OpenDataExplorerReply()) + }, } } } impl CommHandler for RDataExplorer { fn open_metadata(&self) -> serde_json::Value { - serde_json::json!({ "title": self.title }) + serde_json::json!({ "title": self.title, "inline_only": self.inline_only }) } fn handle_msg(&mut self, msg: CommMsg, ctx: &CommHandlerContext) { @@ -1210,7 +1233,7 @@ pub unsafe extern "C-unwind" fn ps_view_data_frame( None }; - let explorer = RDataExplorer::new(title, x, env_info)?; + let explorer = RDataExplorer::new(title, x, env_info, false)?; Console::get_mut().comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; Ok(R_NilValue) diff --git a/crates/ark/src/ui/ui_comm.rs b/crates/ark/src/ui/ui_comm.rs index 675fe23a6..597dfe404 100644 --- a/crates/ark/src/ui/ui_comm.rs +++ b/crates/ark/src/ui/ui_comm.rs @@ -10,7 +10,6 @@ use std::path::PathBuf; use amalthea::comm::comm_channel::CommMsg; use amalthea::comm::ui_comm::CallMethodParams; use amalthea::comm::ui_comm::DidChangePlotsRenderSettingsParams; -use amalthea::comm::ui_comm::EditorContextChangedParams; use amalthea::comm::ui_comm::EvalResult; use amalthea::comm::ui_comm::EvaluateCodeParams; use amalthea::comm::ui_comm::PromptStateParams; @@ -87,9 +86,6 @@ impl UiComm { UiBackendRequest::DidChangePlotsRenderSettings(params) => { self.handle_did_change_plot_render_settings(params) }, - UiBackendRequest::EditorContextChanged(params) => { - self.handle_editor_context_changed(params) - }, UiBackendRequest::EvaluateCode(params) => self.handle_evaluate_code(params), } } @@ -149,18 +145,6 @@ impl UiComm { Ok(UiBackendReply::DidChangePlotsRenderSettingsReply()) } - fn handle_editor_context_changed( - &self, - params: EditorContextChangedParams, - ) -> anyhow::Result { - log::trace!( - "Editor context changed: document_uri={}, is_execution_source={}", - params.document_uri, - params.is_execution_source - ); - Ok(UiBackendReply::EditorContextChangedReply()) - } - fn handle_evaluate_code(&self, params: EvaluateCodeParams) -> anyhow::Result { log::trace!("Evaluating code: {}", params.code); diff --git a/crates/ark/src/variables/r_variables.rs b/crates/ark/src/variables/r_variables.rs index f49c2d6de..fd926be22 100644 --- a/crates/ark/src/variables/r_variables.rs +++ b/crates/ark/src/variables/r_variables.rs @@ -357,7 +357,7 @@ impl RVariables { env, }; - let explorer = RDataExplorer::new(name.clone(), obj, Some(binding)) + let explorer = RDataExplorer::new(name.clone(), obj, Some(binding), false) .map_err(harp::Error::Anyhow)?; let viewer_id = Console::get_mut() .comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer)) diff --git a/crates/ark/tests/data_explorer.rs b/crates/ark/tests/data_explorer.rs index c0ea656ec..a43991e0e 100644 --- a/crates/ark/tests/data_explorer.rs +++ b/crates/ark/tests/data_explorer.rs @@ -106,7 +106,7 @@ fn open_data_explorer(dataset: String) -> TestSetup { let inner = r_task(|| unsafe { let data = RObject::new(Rf_eval(r_symbol!(&dataset), R_GlobalEnv)); - let handler = RDataExplorer::new(dataset, data, None).unwrap(); + let handler = RDataExplorer::new(dataset, data, None, false).unwrap(); TestInner(handler, ctx) }); @@ -131,7 +131,7 @@ fn open_data_explorer_from_expression(expr: &str, bind: Option<&str>) -> anyhow: name: name.to_string(), env: RObject::view(R_ENVS.global), }); - let handler = RDataExplorer::new(String::from("obj"), object, binding)?; + let handler = RDataExplorer::new(String::from("obj"), object, binding, false)?; Ok(TestInner(handler, ctx)) })?; diff --git a/crates/ark/tests/kernel-notebook-data-explorer.rs b/crates/ark/tests/kernel-notebook-data-explorer.rs new file mode 100644 index 000000000..6067b625e --- /dev/null +++ b/crates/ark/tests/kernel-notebook-data-explorer.rs @@ -0,0 +1,111 @@ +// +// kernel-notebook-data-explorer.rs +// +// Copyright (C) 2026 Posit Software, PBC. All rights reserved. +// +// + +use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; +use ark_test::DummyArkFrontendNotebook; + +/// Open the UI comm if it hasn't been opened yet. Since tests in this file +/// share a single kernel process and run serially (via `lock()`), the UI comm +/// only needs to be opened once. Returns the UI comm ID. +fn ensure_ui_comm(frontend: &DummyArkFrontendNotebook) -> String { + use std::sync::OnceLock; + static UI_COMM_ID: OnceLock = OnceLock::new(); + UI_COMM_ID + .get_or_init(|| frontend.open_ui_comm()) + .clone() +} + +/// Drain the UI comm messages that arrive during execution (busy=true, +/// busy=false, prompt_state). These are CommMsg messages on the UI comm's +/// channel that interleave with the execute result on IOPub. +fn drain_ui_comm_msgs(frontend: &DummyArkFrontendNotebook, ui_comm_id: &str) { + // busy=true + let msg = frontend.recv_iopub_comm_msg(); + assert_eq!(msg.comm_id, ui_comm_id); + assert_eq!(msg.data["method"], "busy"); + assert_eq!(msg.data["params"]["busy"], true); + + // busy=false + let msg = frontend.recv_iopub_comm_msg(); + assert_eq!(msg.comm_id, ui_comm_id); + assert_eq!(msg.data["method"], "busy"); + assert_eq!(msg.data["params"]["busy"], false); +} + +fn drain_ui_comm_prompt_state(frontend: &DummyArkFrontendNotebook, ui_comm_id: &str) { + let msg = frontend.recv_iopub_comm_msg(); + assert_eq!(msg.comm_id, ui_comm_id); + assert_eq!(msg.data["method"], "prompt_state"); +} + +#[test] +fn test_notebook_inline_data_explorer() { + let frontend = DummyArkFrontendNotebook::lock(); + let ui_comm_id = ensure_ui_comm(&frontend); + + frontend.send_execute_request( + "data.frame(x = 1:3, y = 4:6)", + ExecuteRequestOptions::default(), + ); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + + // Drain UI comm busy events + drain_ui_comm_msgs(&frontend, &ui_comm_id); + + let result_data = frontend.recv_iopub_execute_result_data(); + + // Should have text/plain (autoprint output) + assert!(result_data.contains_key("text/plain")); + + // Should have the inline data explorer MIME type + let mime_key = "application/vnd.positron.dataExplorer+json"; + assert!(result_data.contains_key(mime_key)); + + let de_data = result_data.get(mime_key).unwrap(); + assert_eq!(de_data["version"], 1); + assert_eq!(de_data["shape"]["num_rows"], 3); + assert_eq!(de_data["shape"]["num_columns"], 2); + assert!(de_data["comm_id"].as_str().is_some()); + assert!(de_data["title"].as_str().is_some()); + + // prompt_state arrives after execute_result + drain_ui_comm_prompt_state(&frontend, &ui_comm_id); + + frontend.recv_iopub_idle(); + frontend.recv_shell_execute_reply(); + + // The comm_open for the inline data explorer arrives after Idle + // (it goes through Shell's comm event channel) + let comm_open = frontend.recv_iopub_comm_open(); + assert_eq!(comm_open.target_name, "positron.dataExplorer"); + assert_eq!(comm_open.data["inline_only"], true); + assert_eq!(comm_open.comm_id, de_data["comm_id"].as_str().unwrap()); +} + +#[test] +fn test_notebook_no_inline_data_explorer_for_non_data_frame() { + let frontend = DummyArkFrontendNotebook::lock(); + let ui_comm_id = ensure_ui_comm(&frontend); + + frontend.send_execute_request("1:10", ExecuteRequestOptions::default()); + frontend.recv_iopub_busy(); + frontend.recv_iopub_execute_input(); + + drain_ui_comm_msgs(&frontend, &ui_comm_id); + + let result_data = frontend.recv_iopub_execute_result_data(); + + // Should have text/plain but NOT the data explorer MIME type + assert!(result_data.contains_key("text/plain")); + assert!(!result_data.contains_key("application/vnd.positron.dataExplorer+json")); + + drain_ui_comm_prompt_state(&frontend, &ui_comm_id); + + frontend.recv_iopub_idle(); + frontend.recv_shell_execute_reply(); +} diff --git a/crates/ark_test/src/dummy_frontend.rs b/crates/ark_test/src/dummy_frontend.rs index 9cb8db2ef..26a0544f7 100644 --- a/crates/ark_test/src/dummy_frontend.rs +++ b/crates/ark_test/src/dummy_frontend.rs @@ -602,6 +602,21 @@ impl DummyArkFrontend { } } + /// Receive from IOPub and assert ExecuteResult message. + /// Returns the full data map. + /// Automatically skips any Stream messages. + #[track_caller] + pub fn recv_iopub_execute_result_data(&self) -> serde_json::Map { + let msg = self.recv_iopub_next(); + match msg { + Message::ExecuteResult(data) => match data.content.data { + serde_json::Value::Object(map) => map, + other => panic!("Expected ExecuteResult data to be Object, got {:?}", other), + }, + other => panic!("Expected ExecuteResult, got {:?}", other), + } + } + /// Receive from IOPub and assert ExecuteError message. /// Automatically skips any Stream messages. /// Returns the `evalue` field. From 452437cd1ae8fd5e757368bd2e435df17422792f Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 24 Mar 2026 11:38:36 -0700 Subject: [PATCH 03/15] fall back to positron env var --- crates/ark/src/console/console_repl.rs | 8 +++++--- crates/ark/tests/kernel-notebook-data-explorer.rs | 5 +++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/ark/src/console/console_repl.rs b/crates/ark/src/console/console_repl.rs index f4ec94544..75eff4840 100644 --- a/crates/ark/src/console/console_repl.rs +++ b/crates/ark/src/console/console_repl.rs @@ -1149,7 +1149,9 @@ impl Console { }, }; - if self.session_mode == SessionMode::Notebook && self.ui_comm_id.is_some() { + if self.session_mode == SessionMode::Notebook + && std::env::var("POSITRON").as_deref() == Ok("1") + { match self.open_inline_data_explorer(value) { Ok(mime_data) => { data.insert( @@ -1193,8 +1195,8 @@ impl Console { "version": 1, "comm_id": comm_id, "shape": { - "num_rows": shape.num_rows, - "num_columns": shape.columns.len(), + "rows": shape.num_rows, + "columns": shape.columns.len(), }, "title": title, "source": title, diff --git a/crates/ark/tests/kernel-notebook-data-explorer.rs b/crates/ark/tests/kernel-notebook-data-explorer.rs index 6067b625e..600703238 100644 --- a/crates/ark/tests/kernel-notebook-data-explorer.rs +++ b/crates/ark/tests/kernel-notebook-data-explorer.rs @@ -44,6 +44,7 @@ fn drain_ui_comm_prompt_state(frontend: &DummyArkFrontendNotebook, ui_comm_id: & #[test] fn test_notebook_inline_data_explorer() { + unsafe { std::env::set_var("POSITRON", "1") }; let frontend = DummyArkFrontendNotebook::lock(); let ui_comm_id = ensure_ui_comm(&frontend); @@ -68,8 +69,8 @@ fn test_notebook_inline_data_explorer() { let de_data = result_data.get(mime_key).unwrap(); assert_eq!(de_data["version"], 1); - assert_eq!(de_data["shape"]["num_rows"], 3); - assert_eq!(de_data["shape"]["num_columns"], 2); + assert_eq!(de_data["shape"]["rows"], 3); + assert_eq!(de_data["shape"]["columns"], 2); assert!(de_data["comm_id"].as_str().is_some()); assert!(de_data["title"].as_str().is_some()); From 042231db79accdbde665e1d259763b6b39fccc29 Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 24 Mar 2026 11:52:21 -0700 Subject: [PATCH 04/15] make the type more explicit --- crates/ark/src/console/console_repl.rs | 26 ++++++++++++------- .../ark/src/data_explorer/r_data_explorer.rs | 21 +++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/crates/ark/src/console/console_repl.rs b/crates/ark/src/console/console_repl.rs index 75eff4840..d8fd17204 100644 --- a/crates/ark/src/console/console_repl.rs +++ b/crates/ark/src/console/console_repl.rs @@ -11,6 +11,8 @@ //! ReadConsole, WriteConsole, and R frontend callbacks. use super::*; +use crate::data_explorer::r_data_explorer::InlineDataExplorerData; +use crate::data_explorer::r_data_explorer::InlineDataExplorerShape; use crate::data_explorer::r_data_explorer::RDataExplorer; use crate::data_explorer::r_data_explorer::DATA_EXPLORER_COMM_NAME; use crate::r_task::QueuedRTask; @@ -1149,6 +1151,10 @@ impl Console { }, }; + // The inline data explorer is a Positron-specific feature that + // requires comm support. Other Jupyter frontends don't understand + // this MIME type, so we gate on the POSITRON env var to avoid + // sending it to vanilla Jupyter notebooks. if self.session_mode == SessionMode::Notebook && std::env::var("POSITRON").as_deref() == Ok("1") { @@ -1191,16 +1197,18 @@ impl Console { let comm_id = self.comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; - Ok(json!({ - "version": 1, - "comm_id": comm_id, - "shape": { - "rows": shape.num_rows, - "columns": shape.columns.len(), + let data = InlineDataExplorerData { + version: 1, + comm_id, + shape: InlineDataExplorerShape { + rows: shape.num_rows, + columns: shape.columns.len(), }, - "title": title, - "source": title, - })) + title: title.clone(), + source: title, + }; + + Ok(serde_json::to_value(data)?) } /// Reset debug flag on the global environment. diff --git a/crates/ark/src/data_explorer/r_data_explorer.rs b/crates/ark/src/data_explorer/r_data_explorer.rs index 7a9a74df8..ee6d5b931 100644 --- a/crates/ark/src/data_explorer/r_data_explorer.rs +++ b/crates/ark/src/data_explorer/r_data_explorer.rs @@ -102,6 +102,27 @@ use crate::variables::variable::WorkspaceVariableDisplayType; pub const DATA_EXPLORER_COMM_NAME: &str = "positron.dataExplorer"; +/// Payload for the `application/vnd.positron.dataExplorer+json` MIME type +/// included in notebook execute results for data frames. This tells Positron's +/// notebook renderer to display an inline data explorer widget. +/// +/// Must stay in sync with `ParsedDataExplorerOutput` in Positron's +/// `IPositronNotebookCell.ts`. +#[derive(Clone, Debug, serde::Serialize)] +pub struct InlineDataExplorerData { + pub version: u32, + pub comm_id: String, + pub shape: InlineDataExplorerShape, + pub title: String, + pub source: String, +} + +#[derive(Clone, Debug, serde::Serialize)] +pub struct InlineDataExplorerShape { + pub rows: i32, + pub columns: usize, +} + /// A name/value binding pair in an environment. /// /// We use this to keep track of the data object that the data viewer is From 1e696038609220c80b3d0e5dba3ac36af0226634 Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 24 Mar 2026 12:02:57 -0700 Subject: [PATCH 05/15] clean some nits, use distinct title/source --- crates/ark/src/console/console_repl.rs | 43 ++++++++++++------- .../ark/src/data_explorer/r_data_explorer.rs | 5 +++ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/crates/ark/src/console/console_repl.rs b/crates/ark/src/console/console_repl.rs index d8fd17204..9c7650f7e 100644 --- a/crates/ark/src/console/console_repl.rs +++ b/crates/ark/src/console/console_repl.rs @@ -15,6 +15,7 @@ use crate::data_explorer::r_data_explorer::InlineDataExplorerData; use crate::data_explorer::r_data_explorer::InlineDataExplorerShape; use crate::data_explorer::r_data_explorer::RDataExplorer; use crate::data_explorer::r_data_explorer::DATA_EXPLORER_COMM_NAME; +use crate::data_explorer::r_data_explorer::POSITRON_DATA_EXPLORER_MIME; use crate::r_task::QueuedRTask; use crate::r_task::RTask; use harp::vector::Vector; @@ -1140,6 +1141,9 @@ impl Console { // inline data explorer in Positron notebook mode. Only do this when // there is visible output (autoprint produced text/plain). unsafe { + // Use `Rf_findVar` (not `Rf_findVarInFrame`) so that `.Last.value` + // resolves correctly through the environment chain. R stores + // `.Last.value` in the base environment, not the global environment. let value = libr::Rf_findVar(r_symbol!(".Last.value"), R_GlobalEnv); if !data.is_empty() && r_is_data_frame(value) { match to_html(value) { @@ -1161,7 +1165,7 @@ impl Console { match self.open_inline_data_explorer(value) { Ok(mime_data) => { data.insert( - "application/vnd.positron.dataExplorer+json".to_string(), + POSITRON_DATA_EXPLORER_MIME.to_string(), mime_data, ); }, @@ -1184,31 +1188,40 @@ impl Console { ) -> anyhow::Result { let data = RObject::new(value); - // Derive title from the first R class (e.g. "tbl_df", "data.table", "data.frame") - let title = harp::utils::r_classes(value) - .and_then(|classes| { - classes.get_unchecked(0).map(|s| s.to_string()) - }) + // `source` is the R class family (e.g. "tibble", "data.table", + // "data.frame"), following the Python kernel convention where `source` + // is the library name ("pandas", "polars"). + let source = harp::utils::r_classes(value) + .and_then(|classes| classes.get_unchecked(0).map(|s| s.to_string())) .unwrap_or_else(|| String::from("data.frame")); - let shape = RDataExplorer::get_shape(data.clone())?; + // `title` is the variable name when available, falling back to + // `source`. For inline explorers we don't have a variable binding, so + // we always use `source` as the title. + let title = source.clone(); let explorer = RDataExplorer::new(title.clone(), data, None, true)?; - let comm_id = - self.comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; - - let data = InlineDataExplorerData { + let shape = &explorer.shape(); + let inline_data = InlineDataExplorerData { version: 1, - comm_id, + comm_id: String::new(), // placeholder, filled after comm_open shape: InlineDataExplorerShape { rows: shape.num_rows, columns: shape.columns.len(), }, - title: title.clone(), - source: title, + title, + source, + }; + + let comm_id = + self.comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; + + let inline_data = InlineDataExplorerData { + comm_id, + ..inline_data }; - Ok(serde_json::to_value(data)?) + Ok(serde_json::to_value(inline_data)?) } /// Reset debug flag on the global environment. diff --git a/crates/ark/src/data_explorer/r_data_explorer.rs b/crates/ark/src/data_explorer/r_data_explorer.rs index ee6d5b931..74fa13305 100644 --- a/crates/ark/src/data_explorer/r_data_explorer.rs +++ b/crates/ark/src/data_explorer/r_data_explorer.rs @@ -101,6 +101,7 @@ use crate::r_task::RTask; use crate::variables::variable::WorkspaceVariableDisplayType; pub const DATA_EXPLORER_COMM_NAME: &str = "positron.dataExplorer"; +pub const POSITRON_DATA_EXPLORER_MIME: &str = "application/vnd.positron.dataExplorer+json"; /// Payload for the `application/vnd.positron.dataExplorer+json` MIME type /// included in notebook execute results for data frames. This tells Positron's @@ -218,6 +219,10 @@ impl RDataExplorer { }) } + pub(crate) fn shape(&self) -> &DataObjectShape { + &self.shape + } + /// Check the environment bindings for updates to the underlying value /// /// Returns true if the update was processed; false if the binding has been From 9ee676c781acb14b1f80a63b4f37d5bb0c2c837f Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 24 Mar 2026 13:10:38 -0700 Subject: [PATCH 06/15] use last_value from harp --- crates/ark/src/console.rs | 1 - crates/ark/src/console/console_repl.rs | 66 ++++++++----------- .../ark/src/data_explorer/r_data_explorer.rs | 8 +-- .../tests/kernel-notebook-data-explorer.rs | 4 +- 4 files changed, 32 insertions(+), 47 deletions(-) diff --git a/crates/ark/src/console.rs b/crates/ark/src/console.rs index 85064352f..421671e2d 100644 --- a/crates/ark/src/console.rs +++ b/crates/ark/src/console.rs @@ -82,7 +82,6 @@ use harp::utils::r_poke_option; use harp::utils::r_typeof; use harp::CONSOLE_THREAD_ID; use libr::R_BaseNamespace; -use libr::R_GlobalEnv; use libr::R_ProcessEvents; use libr::R_RunPendingFinalizers; use libr::Rf_ScalarInteger; diff --git a/crates/ark/src/console/console_repl.rs b/crates/ark/src/console/console_repl.rs index 9c7650f7e..e8821b116 100644 --- a/crates/ark/src/console/console_repl.rs +++ b/crates/ark/src/console/console_repl.rs @@ -10,6 +10,8 @@ //! This module contains `impl Console` with methods and functions related to //! ReadConsole, WriteConsole, and R frontend callbacks. +use harp::vector::Vector; + use super::*; use crate::data_explorer::r_data_explorer::InlineDataExplorerData; use crate::data_explorer::r_data_explorer::InlineDataExplorerShape; @@ -18,7 +20,6 @@ use crate::data_explorer::r_data_explorer::DATA_EXPLORER_COMM_NAME; use crate::data_explorer::r_data_explorer::POSITRON_DATA_EXPLORER_MIME; use crate::r_task::QueuedRTask; use crate::r_task::RTask; -use harp::vector::Vector; static RE_DEBUG_PROMPT: Lazy = Lazy::new(|| Regex::new(r"Browse\[\d+\]").unwrap()); @@ -1140,39 +1141,34 @@ impl Console { // Include HTML representation of data.frame and optionally open an // inline data explorer in Positron notebook mode. Only do this when // there is visible output (autoprint produced text/plain). - unsafe { - // Use `Rf_findVar` (not `Rf_findVarInFrame`) so that `.Last.value` - // resolves correctly through the environment chain. R stores - // `.Last.value` in the base environment, not the global environment. - let value = libr::Rf_findVar(r_symbol!(".Last.value"), R_GlobalEnv); - if !data.is_empty() && r_is_data_frame(value) { - match to_html(value) { - Ok(html) => { - data.insert("text/html".to_string(), json!(html)); + let Ok(value) = harp::environment::last_value() else { + return data; + }; + if !data.is_empty() && r_is_data_frame(value.sexp) { + let value = value.sexp; + match to_html(value) { + Ok(html) => { + data.insert("text/html".to_string(), json!(html)); + }, + Err(err) => { + log::error!("{err:?}"); + }, + }; + + // The inline data explorer is a Positron-specific feature that + // requires comm support. Other Jupyter frontends don't understand + // this MIME type, so we gate on the POSITRON env var to avoid + // sending it to vanilla Jupyter notebooks. + if self.session_mode == SessionMode::Notebook && + std::env::var("POSITRON").as_deref() == Ok("1") + { + match self.open_inline_data_explorer(value) { + Ok(mime_data) => { + data.insert(POSITRON_DATA_EXPLORER_MIME.to_string(), mime_data); }, Err(err) => { - log::error!("{err:?}"); + log::error!("Failed to open inline data explorer: {err:?}"); }, - }; - - // The inline data explorer is a Positron-specific feature that - // requires comm support. Other Jupyter frontends don't understand - // this MIME type, so we gate on the POSITRON env var to avoid - // sending it to vanilla Jupyter notebooks. - if self.session_mode == SessionMode::Notebook - && std::env::var("POSITRON").as_deref() == Ok("1") - { - match self.open_inline_data_explorer(value) { - Ok(mime_data) => { - data.insert( - POSITRON_DATA_EXPLORER_MIME.to_string(), - mime_data, - ); - }, - Err(err) => { - log::error!("Failed to open inline data explorer: {err:?}"); - }, - } } } } @@ -1182,10 +1178,7 @@ impl Console { /// Open an inline data explorer for a data frame value and return the MIME /// type payload to include in the execute result. - fn open_inline_data_explorer( - &mut self, - value: SEXP, - ) -> anyhow::Result { + fn open_inline_data_explorer(&mut self, value: SEXP) -> anyhow::Result { let data = RObject::new(value); // `source` is the R class family (e.g. "tibble", "data.table", @@ -1213,8 +1206,7 @@ impl Console { source, }; - let comm_id = - self.comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; + let comm_id = self.comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; let inline_data = InlineDataExplorerData { comm_id, diff --git a/crates/ark/src/data_explorer/r_data_explorer.rs b/crates/ark/src/data_explorer/r_data_explorer.rs index 74fa13305..2bc60df30 100644 --- a/crates/ark/src/data_explorer/r_data_explorer.rs +++ b/crates/ark/src/data_explorer/r_data_explorer.rs @@ -455,12 +455,8 @@ impl RDataExplorer { }, DataExplorerBackendRequest::OpenDataExplorer => { - let explorer = RDataExplorer::new( - self.title.clone(), - self.table.get().clone(), - None, - false, - )?; + let explorer = + RDataExplorer::new(self.title.clone(), self.table.get().clone(), None, false)?; Console::get_mut() .comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; Ok(DataExplorerBackendReply::OpenDataExplorerReply()) diff --git a/crates/ark/tests/kernel-notebook-data-explorer.rs b/crates/ark/tests/kernel-notebook-data-explorer.rs index 600703238..c177d3c30 100644 --- a/crates/ark/tests/kernel-notebook-data-explorer.rs +++ b/crates/ark/tests/kernel-notebook-data-explorer.rs @@ -14,9 +14,7 @@ use ark_test::DummyArkFrontendNotebook; fn ensure_ui_comm(frontend: &DummyArkFrontendNotebook) -> String { use std::sync::OnceLock; static UI_COMM_ID: OnceLock = OnceLock::new(); - UI_COMM_ID - .get_or_init(|| frontend.open_ui_comm()) - .clone() + UI_COMM_ID.get_or_init(|| frontend.open_ui_comm()).clone() } /// Drain the UI comm messages that arrive during execution (busy=true, From 3da1aa039a1da1493dbc455cc5cf4775fb6f5d76 Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 31 Mar 2026 09:03:34 -0700 Subject: [PATCH 07/15] early return for empty data --- crates/ark/src/console/console_repl.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/ark/src/console/console_repl.rs b/crates/ark/src/console/console_repl.rs index e8821b116..b4c58e194 100644 --- a/crates/ark/src/console/console_repl.rs +++ b/crates/ark/src/console/console_repl.rs @@ -1144,7 +1144,15 @@ impl Console { let Ok(value) = harp::environment::last_value() else { return data; }; - if !data.is_empty() && r_is_data_frame(value.sexp) { + + // If there is no data, return early + if data.is_empty() { + return data; + } + + // If this is a data frame, add HTML representation and open inline explorer + // (only in Positron notebook mode) + if r_is_data_frame(value.sexp) { let value = value.sexp; match to_html(value) { Ok(html) => { From 26152f0b85818916315835aa343422875b3696c5 Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 31 Mar 2026 09:07:32 -0700 Subject: [PATCH 08/15] move inline data explorer to console integration --- crates/ark/src/console/console_integration.rs | 52 +++++++++++++++++++ crates/ark/src/console/console_repl.rs | 46 ---------------- 2 files changed, 52 insertions(+), 46 deletions(-) diff --git a/crates/ark/src/console/console_integration.rs b/crates/ark/src/console/console_integration.rs index 22779f400..a0a763799 100644 --- a/crates/ark/src/console/console_integration.rs +++ b/crates/ark/src/console/console_integration.rs @@ -7,7 +7,13 @@ //! Help, LSP, UI comm, and frontend method integration for the R console. +use harp::vector::Vector; + use super::*; +use crate::data_explorer::r_data_explorer::InlineDataExplorerData; +use crate::data_explorer::r_data_explorer::InlineDataExplorerShape; +use crate::data_explorer::r_data_explorer::RDataExplorer; +use crate::data_explorer::r_data_explorer::DATA_EXPLORER_COMM_NAME; /// UI comm integration. impl Console { @@ -164,6 +170,52 @@ impl Console { } } +/// Inline data explorer integration. +impl Console { + /// Open an inline data explorer for a data frame value and return the MIME + /// type payload to include in the execute result. + pub(super) fn open_inline_data_explorer( + &mut self, + value: SEXP, + ) -> anyhow::Result { + let data = RObject::new(value); + + // `source` is the R class family (e.g. "tibble", "data.table", + // "data.frame"), following the Python kernel convention where `source` + // is the library name ("pandas", "polars"). + let source = harp::utils::r_classes(value) + .and_then(|classes| classes.get_unchecked(0).map(|s| s.to_string())) + .unwrap_or_else(|| String::from("data.frame")); + + // `title` is the variable name when available, falling back to + // `source`. For inline explorers we don't have a variable binding, so + // we always use `source` as the title. + let title = source.clone(); + + let explorer = RDataExplorer::new(title.clone(), data, None, true)?; + let shape = &explorer.shape(); + let inline_data = InlineDataExplorerData { + version: 1, + comm_id: String::new(), // placeholder, filled after comm_open + shape: InlineDataExplorerShape { + rows: shape.num_rows, + columns: shape.columns.len(), + }, + title, + source, + }; + + let comm_id = self.comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; + + let inline_data = InlineDataExplorerData { + comm_id, + ..inline_data + }; + + Ok(serde_json::to_value(inline_data)?) + } +} + /// Reference to the UI comm. Returned by `Console::ui_comm()`. /// /// Existence of this value guarantees the comm is connected. diff --git a/crates/ark/src/console/console_repl.rs b/crates/ark/src/console/console_repl.rs index b4c58e194..62c8241c4 100644 --- a/crates/ark/src/console/console_repl.rs +++ b/crates/ark/src/console/console_repl.rs @@ -10,13 +10,7 @@ //! This module contains `impl Console` with methods and functions related to //! ReadConsole, WriteConsole, and R frontend callbacks. -use harp::vector::Vector; - use super::*; -use crate::data_explorer::r_data_explorer::InlineDataExplorerData; -use crate::data_explorer::r_data_explorer::InlineDataExplorerShape; -use crate::data_explorer::r_data_explorer::RDataExplorer; -use crate::data_explorer::r_data_explorer::DATA_EXPLORER_COMM_NAME; use crate::data_explorer::r_data_explorer::POSITRON_DATA_EXPLORER_MIME; use crate::r_task::QueuedRTask; use crate::r_task::RTask; @@ -1184,46 +1178,6 @@ impl Console { data } - /// Open an inline data explorer for a data frame value and return the MIME - /// type payload to include in the execute result. - fn open_inline_data_explorer(&mut self, value: SEXP) -> anyhow::Result { - let data = RObject::new(value); - - // `source` is the R class family (e.g. "tibble", "data.table", - // "data.frame"), following the Python kernel convention where `source` - // is the library name ("pandas", "polars"). - let source = harp::utils::r_classes(value) - .and_then(|classes| classes.get_unchecked(0).map(|s| s.to_string())) - .unwrap_or_else(|| String::from("data.frame")); - - // `title` is the variable name when available, falling back to - // `source`. For inline explorers we don't have a variable binding, so - // we always use `source` as the title. - let title = source.clone(); - - let explorer = RDataExplorer::new(title.clone(), data, None, true)?; - let shape = &explorer.shape(); - let inline_data = InlineDataExplorerData { - version: 1, - comm_id: String::new(), // placeholder, filled after comm_open - shape: InlineDataExplorerShape { - rows: shape.num_rows, - columns: shape.columns.len(), - }, - title, - source, - }; - - let comm_id = self.comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; - - let inline_data = InlineDataExplorerData { - comm_id, - ..inline_data - }; - - Ok(serde_json::to_value(inline_data)?) - } - /// Reset debug flag on the global environment. /// /// This is a workaround for when a breakpoint was entered at top-level, in From b11cec7a454c2eb657191f3fb66b204d716d2a06 Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 31 Mar 2026 09:12:20 -0700 Subject: [PATCH 09/15] use RObject::class() --- crates/ark/src/console/console_integration.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ark/src/console/console_integration.rs b/crates/ark/src/console/console_integration.rs index a0a763799..96a8b7351 100644 --- a/crates/ark/src/console/console_integration.rs +++ b/crates/ark/src/console/console_integration.rs @@ -7,7 +7,6 @@ //! Help, LSP, UI comm, and frontend method integration for the R console. -use harp::vector::Vector; use super::*; use crate::data_explorer::r_data_explorer::InlineDataExplorerData; @@ -183,8 +182,11 @@ impl Console { // `source` is the R class family (e.g. "tibble", "data.table", // "data.frame"), following the Python kernel convention where `source` // is the library name ("pandas", "polars"). - let source = harp::utils::r_classes(value) - .and_then(|classes| classes.get_unchecked(0).map(|s| s.to_string())) + let source = data + .class() + .ok() + .flatten() + .and_then(|classes| classes.into_iter().next()) .unwrap_or_else(|| String::from("data.frame")); // `title` is the variable name when available, falling back to From b78bb44f62c224ee3b1a8d4409c25b4217d160df Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 31 Mar 2026 09:17:19 -0700 Subject: [PATCH 10/15] use an enum for data explorer mode --- crates/ark/src/console/console_integration.rs | 3 ++- .../ark/src/data_explorer/r_data_explorer.rs | 24 ++++++++++++------- crates/ark/src/variables/r_variables.rs | 3 ++- crates/ark/tests/data_explorer.rs | 5 ++-- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/crates/ark/src/console/console_integration.rs b/crates/ark/src/console/console_integration.rs index 96a8b7351..0a0d01e32 100644 --- a/crates/ark/src/console/console_integration.rs +++ b/crates/ark/src/console/console_integration.rs @@ -9,6 +9,7 @@ use super::*; +use crate::data_explorer::r_data_explorer::DataExplorerMode; use crate::data_explorer::r_data_explorer::InlineDataExplorerData; use crate::data_explorer::r_data_explorer::InlineDataExplorerShape; use crate::data_explorer::r_data_explorer::RDataExplorer; @@ -194,7 +195,7 @@ impl Console { // we always use `source` as the title. let title = source.clone(); - let explorer = RDataExplorer::new(title.clone(), data, None, true)?; + let explorer = RDataExplorer::new(title.clone(), data, None, DataExplorerMode::Inline)?; let shape = &explorer.shape(); let inline_data = InlineDataExplorerData { version: 1, diff --git a/crates/ark/src/data_explorer/r_data_explorer.rs b/crates/ark/src/data_explorer/r_data_explorer.rs index 2bc60df30..1626571de 100644 --- a/crates/ark/src/data_explorer/r_data_explorer.rs +++ b/crates/ark/src/data_explorer/r_data_explorer.rs @@ -100,6 +100,12 @@ use crate::r_task; use crate::r_task::RTask; use crate::variables::variable::WorkspaceVariableDisplayType; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DataExplorerMode { + Inline, + Full, +} + pub const DATA_EXPLORER_COMM_NAME: &str = "positron.dataExplorer"; pub const POSITRON_DATA_EXPLORER_MIME: &str = "application/vnd.positron.dataExplorer+json"; @@ -180,10 +186,9 @@ pub struct RDataExplorer { /// data viewer. view_indices: Option>, - /// Whether this explorer is for inline display only (e.g. in a notebook - /// cell output). When true, the frontend renders a compact inline grid - /// instead of opening the full Data Explorer panel. - inline_only: bool, + /// The display mode for this explorer. `Inline` renders a compact grid + /// in a notebook cell output; `Full` opens the full Data Explorer panel. + explorer_mode: DataExplorerMode, } impl std::fmt::Debug for RDataExplorer { @@ -200,7 +205,7 @@ impl RDataExplorer { title: String, data: RObject, binding: Option, - inline_only: bool, + explorer_mode: DataExplorerMode, ) -> anyhow::Result { let table = Table::new(data); let shape = Self::get_shape(table.get().clone())?; @@ -215,7 +220,7 @@ impl RDataExplorer { sort_keys: vec![], row_filters: vec![], col_filters: vec![], - inline_only, + explorer_mode, }) } @@ -456,7 +461,7 @@ impl RDataExplorer { DataExplorerBackendRequest::OpenDataExplorer => { let explorer = - RDataExplorer::new(self.title.clone(), self.table.get().clone(), None, false)?; + RDataExplorer::new(self.title.clone(), self.table.get().clone(), None, DataExplorerMode::Full)?; Console::get_mut() .comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; Ok(DataExplorerBackendReply::OpenDataExplorerReply()) @@ -467,7 +472,8 @@ impl RDataExplorer { impl CommHandler for RDataExplorer { fn open_metadata(&self) -> serde_json::Value { - serde_json::json!({ "title": self.title, "inline_only": self.inline_only }) + let inline_only = self.explorer_mode == DataExplorerMode::Inline; + serde_json::json!({ "title": self.title, "inline_only": inline_only }) } fn handle_msg(&mut self, msg: CommMsg, ctx: &CommHandlerContext) { @@ -1255,7 +1261,7 @@ pub unsafe extern "C-unwind" fn ps_view_data_frame( None }; - let explorer = RDataExplorer::new(title, x, env_info, false)?; + let explorer = RDataExplorer::new(title, x, env_info, DataExplorerMode::Full)?; Console::get_mut().comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; Ok(R_NilValue) diff --git a/crates/ark/src/variables/r_variables.rs b/crates/ark/src/variables/r_variables.rs index fd926be22..98f140c4a 100644 --- a/crates/ark/src/variables/r_variables.rs +++ b/crates/ark/src/variables/r_variables.rs @@ -42,6 +42,7 @@ use stdext::spawn; use crate::console; use crate::console::Console; +use crate::data_explorer::r_data_explorer::DataExplorerMode; use crate::data_explorer::r_data_explorer::DataObjectEnvInfo; use crate::data_explorer::r_data_explorer::RDataExplorer; use crate::data_explorer::r_data_explorer::DATA_EXPLORER_COMM_NAME; @@ -357,7 +358,7 @@ impl RVariables { env, }; - let explorer = RDataExplorer::new(name.clone(), obj, Some(binding), false) + let explorer = RDataExplorer::new(name.clone(), obj, Some(binding), DataExplorerMode::Full) .map_err(harp::Error::Anyhow)?; let viewer_id = Console::get_mut() .comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer)) diff --git a/crates/ark/tests/data_explorer.rs b/crates/ark/tests/data_explorer.rs index a43991e0e..310aa22f2 100644 --- a/crates/ark/tests/data_explorer.rs +++ b/crates/ark/tests/data_explorer.rs @@ -72,6 +72,7 @@ use ark::comm_handler::CommHandlerContext; use ark::comm_handler::EnvironmentChanged; use ark::data_explorer::format::format_column; use ark::data_explorer::format::format_string; +use ark::data_explorer::r_data_explorer::DataExplorerMode; use ark::data_explorer::r_data_explorer::DataObjectEnvInfo; use ark::data_explorer::r_data_explorer::RDataExplorer; use ark::r_task::r_task; @@ -106,7 +107,7 @@ fn open_data_explorer(dataset: String) -> TestSetup { let inner = r_task(|| unsafe { let data = RObject::new(Rf_eval(r_symbol!(&dataset), R_GlobalEnv)); - let handler = RDataExplorer::new(dataset, data, None, false).unwrap(); + let handler = RDataExplorer::new(dataset, data, None, DataExplorerMode::Full).unwrap(); TestInner(handler, ctx) }); @@ -131,7 +132,7 @@ fn open_data_explorer_from_expression(expr: &str, bind: Option<&str>) -> anyhow: name: name.to_string(), env: RObject::view(R_ENVS.global), }); - let handler = RDataExplorer::new(String::from("obj"), object, binding, false)?; + let handler = RDataExplorer::new(String::from("obj"), object, binding, DataExplorerMode::Full)?; Ok(TestInner(handler, ctx)) })?; From 14ad3f2ae5992ead859d5cf9e5f66a7f94b14818 Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 31 Mar 2026 09:19:58 -0700 Subject: [PATCH 11/15] clarify comment --- crates/ark/src/data_explorer/r_data_explorer.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/ark/src/data_explorer/r_data_explorer.rs b/crates/ark/src/data_explorer/r_data_explorer.rs index 1626571de..43b208cdf 100644 --- a/crates/ark/src/data_explorer/r_data_explorer.rs +++ b/crates/ark/src/data_explorer/r_data_explorer.rs @@ -459,9 +459,14 @@ impl RDataExplorer { Err(anyhow!("Data Explorer: Not yet supported")) }, + // Promotes an inline data explorer to a full data explorer. DataExplorerBackendRequest::OpenDataExplorer => { - let explorer = - RDataExplorer::new(self.title.clone(), self.table.get().clone(), None, DataExplorerMode::Full)?; + let explorer = RDataExplorer::new( + self.title.clone(), + self.table.get().clone(), + None, + DataExplorerMode::Full, + )?; Console::get_mut() .comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer))?; Ok(DataExplorerBackendReply::OpenDataExplorerReply()) From 24e3256f08c886b66e7ba02f8f2b335f0e659de3 Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 31 Mar 2026 09:24:04 -0700 Subject: [PATCH 12/15] the UI comm does not need to be ensured --- crates/ark/tests/kernel-notebook-data-explorer.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/crates/ark/tests/kernel-notebook-data-explorer.rs b/crates/ark/tests/kernel-notebook-data-explorer.rs index c177d3c30..c0c1284c4 100644 --- a/crates/ark/tests/kernel-notebook-data-explorer.rs +++ b/crates/ark/tests/kernel-notebook-data-explorer.rs @@ -8,15 +8,6 @@ use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; use ark_test::DummyArkFrontendNotebook; -/// Open the UI comm if it hasn't been opened yet. Since tests in this file -/// share a single kernel process and run serially (via `lock()`), the UI comm -/// only needs to be opened once. Returns the UI comm ID. -fn ensure_ui_comm(frontend: &DummyArkFrontendNotebook) -> String { - use std::sync::OnceLock; - static UI_COMM_ID: OnceLock = OnceLock::new(); - UI_COMM_ID.get_or_init(|| frontend.open_ui_comm()).clone() -} - /// Drain the UI comm messages that arrive during execution (busy=true, /// busy=false, prompt_state). These are CommMsg messages on the UI comm's /// channel that interleave with the execute result on IOPub. @@ -44,7 +35,7 @@ fn drain_ui_comm_prompt_state(frontend: &DummyArkFrontendNotebook, ui_comm_id: & fn test_notebook_inline_data_explorer() { unsafe { std::env::set_var("POSITRON", "1") }; let frontend = DummyArkFrontendNotebook::lock(); - let ui_comm_id = ensure_ui_comm(&frontend); + let ui_comm_id = frontend.open_ui_comm(); frontend.send_execute_request( "data.frame(x = 1:3, y = 4:6)", @@ -89,7 +80,7 @@ fn test_notebook_inline_data_explorer() { #[test] fn test_notebook_no_inline_data_explorer_for_non_data_frame() { let frontend = DummyArkFrontendNotebook::lock(); - let ui_comm_id = ensure_ui_comm(&frontend); + let ui_comm_id = frontend.open_ui_comm(); frontend.send_execute_request("1:10", ExecuteRequestOptions::default()); frontend.recv_iopub_busy(); From dbbac19ff94c8b6a5c4b9e45e86e87d268e05111 Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 31 Mar 2026 09:32:17 -0700 Subject: [PATCH 13/15] create a dummy notebook frontend --- .../tests/kernel-notebook-data-explorer.rs | 11 ++--- crates/ark_test/src/dummy_frontend.rs | 46 +++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/ark/tests/kernel-notebook-data-explorer.rs b/crates/ark/tests/kernel-notebook-data-explorer.rs index c0c1284c4..d38b7cf22 100644 --- a/crates/ark/tests/kernel-notebook-data-explorer.rs +++ b/crates/ark/tests/kernel-notebook-data-explorer.rs @@ -6,12 +6,12 @@ // use amalthea::fixtures::dummy_frontend::ExecuteRequestOptions; -use ark_test::DummyArkFrontendNotebook; +use ark_test::DummyArkPositronNotebook; /// Drain the UI comm messages that arrive during execution (busy=true, /// busy=false, prompt_state). These are CommMsg messages on the UI comm's /// channel that interleave with the execute result on IOPub. -fn drain_ui_comm_msgs(frontend: &DummyArkFrontendNotebook, ui_comm_id: &str) { +fn drain_ui_comm_msgs(frontend: &DummyArkPositronNotebook, ui_comm_id: &str) { // busy=true let msg = frontend.recv_iopub_comm_msg(); assert_eq!(msg.comm_id, ui_comm_id); @@ -25,7 +25,7 @@ fn drain_ui_comm_msgs(frontend: &DummyArkFrontendNotebook, ui_comm_id: &str) { assert_eq!(msg.data["params"]["busy"], false); } -fn drain_ui_comm_prompt_state(frontend: &DummyArkFrontendNotebook, ui_comm_id: &str) { +fn drain_ui_comm_prompt_state(frontend: &DummyArkPositronNotebook, ui_comm_id: &str) { let msg = frontend.recv_iopub_comm_msg(); assert_eq!(msg.comm_id, ui_comm_id); assert_eq!(msg.data["method"], "prompt_state"); @@ -33,8 +33,7 @@ fn drain_ui_comm_prompt_state(frontend: &DummyArkFrontendNotebook, ui_comm_id: & #[test] fn test_notebook_inline_data_explorer() { - unsafe { std::env::set_var("POSITRON", "1") }; - let frontend = DummyArkFrontendNotebook::lock(); + let frontend = DummyArkPositronNotebook::lock(); let ui_comm_id = frontend.open_ui_comm(); frontend.send_execute_request( @@ -79,7 +78,7 @@ fn test_notebook_inline_data_explorer() { #[test] fn test_notebook_no_inline_data_explorer_for_non_data_frame() { - let frontend = DummyArkFrontendNotebook::lock(); + let frontend = DummyArkPositronNotebook::lock(); let ui_comm_id = frontend.open_ui_comm(); frontend.send_execute_request("1:10", ExecuteRequestOptions::default()); diff --git a/crates/ark_test/src/dummy_frontend.rs b/crates/ark_test/src/dummy_frontend.rs index 26a0544f7..6aa166901 100644 --- a/crates/ark_test/src/dummy_frontend.rs +++ b/crates/ark_test/src/dummy_frontend.rs @@ -1816,6 +1816,52 @@ impl DerefMut for DummyArkFrontendNotebook { } } +/// Wrapper around `DummyArkFrontend` that uses `SessionMode::Notebook` and +/// sets the `POSITRON` env var to simulate running inside Positron. +pub struct DummyArkPositronNotebook { + inner: DummyArkFrontend, +} + +impl DummyArkPositronNotebook { + /// Lock a Positron notebook frontend. + /// + /// NOTE: Only one `DummyArkFrontend` variant should call `lock()` within + /// a given process. + pub fn lock() -> Self { + Self::init(); + + Self { + inner: DummyArkFrontend::lock(), + } + } + + /// Initialize with Notebook session mode and `POSITRON=1` + fn init() { + unsafe { std::env::set_var("POSITRON", "1") }; + + let options = DummyArkFrontendOptions { + session_mode: SessionMode::Notebook, + ..Default::default() + }; + FRONTEND.get_or_init(|| Arc::new(Mutex::new(DummyArkFrontend::init(options)))); + } +} + +// Allow method calls to be forwarded to inner type +impl Deref for DummyArkPositronNotebook { + type Target = DummyArkFrontend; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for DummyArkPositronNotebook { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + impl DummyArkFrontendDefaultRepos { /// Lock a frontend with a default repos setting. /// From 3f35405abcf6c4fd8e9c77bfb685fdf0c45be8e5 Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 31 Mar 2026 09:33:32 -0700 Subject: [PATCH 14/15] reformat --- crates/ark/src/console/console_integration.rs | 1 - crates/ark/src/variables/r_variables.rs | 5 +++-- crates/ark/tests/data_explorer.rs | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/ark/src/console/console_integration.rs b/crates/ark/src/console/console_integration.rs index 0a0d01e32..0f91ab04d 100644 --- a/crates/ark/src/console/console_integration.rs +++ b/crates/ark/src/console/console_integration.rs @@ -7,7 +7,6 @@ //! Help, LSP, UI comm, and frontend method integration for the R console. - use super::*; use crate::data_explorer::r_data_explorer::DataExplorerMode; use crate::data_explorer::r_data_explorer::InlineDataExplorerData; diff --git a/crates/ark/src/variables/r_variables.rs b/crates/ark/src/variables/r_variables.rs index 98f140c4a..e49421db9 100644 --- a/crates/ark/src/variables/r_variables.rs +++ b/crates/ark/src/variables/r_variables.rs @@ -358,8 +358,9 @@ impl RVariables { env, }; - let explorer = RDataExplorer::new(name.clone(), obj, Some(binding), DataExplorerMode::Full) - .map_err(harp::Error::Anyhow)?; + let explorer = + RDataExplorer::new(name.clone(), obj, Some(binding), DataExplorerMode::Full) + .map_err(harp::Error::Anyhow)?; let viewer_id = Console::get_mut() .comm_open_backend(DATA_EXPLORER_COMM_NAME, Box::new(explorer)) .map_err(harp::Error::Anyhow)?; diff --git a/crates/ark/tests/data_explorer.rs b/crates/ark/tests/data_explorer.rs index 310aa22f2..69e761975 100644 --- a/crates/ark/tests/data_explorer.rs +++ b/crates/ark/tests/data_explorer.rs @@ -132,7 +132,8 @@ fn open_data_explorer_from_expression(expr: &str, bind: Option<&str>) -> anyhow: name: name.to_string(), env: RObject::view(R_ENVS.global), }); - let handler = RDataExplorer::new(String::from("obj"), object, binding, DataExplorerMode::Full)?; + let handler = + RDataExplorer::new(String::from("obj"), object, binding, DataExplorerMode::Full)?; Ok(TestInner(handler, ctx)) })?; From 48142a8b390d9bdd7dc0daa1fad2860f0e8ae226 Mon Sep 17 00:00:00 2001 From: Jonathan McPherson Date: Tue, 31 Mar 2026 09:34:12 -0700 Subject: [PATCH 15/15] tibble -> tbl_df --- crates/ark/src/console/console_integration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ark/src/console/console_integration.rs b/crates/ark/src/console/console_integration.rs index 0f91ab04d..fee036d9f 100644 --- a/crates/ark/src/console/console_integration.rs +++ b/crates/ark/src/console/console_integration.rs @@ -179,7 +179,7 @@ impl Console { ) -> anyhow::Result { let data = RObject::new(value); - // `source` is the R class family (e.g. "tibble", "data.table", + // `source` is the R class family (e.g. "tbl_df", "data.table", // "data.frame"), following the Python kernel convention where `source` // is the library name ("pandas", "polars"). let source = data