From c77ea47f432e23592cf1b2f1881bc34127acb3f5 Mon Sep 17 00:00:00 2001 From: Kyle Kelley Date: Fri, 5 Jul 2024 08:15:50 -0700 Subject: [PATCH] Runtimes UI Starter (#13625) Initial runtimes UI panel. The main draw here is that all message subscription occurs with two background tasks that run for the life of the kernel. Follow on to #12062 * [x] Disable previous cmd-enter behavior only if runtimes are enabled in settings * [x] Only show the runtimes panel if it is enabled via settings * [x] Create clean UI for the current sessions ### Running Kernels UI image * [x] List running kernels * [x] Implement shutdown * [x] Delete connection file on `drop` of `RunningKernel` * [x] Implement interrupt #### Project-specific Kernel Settings - [x] Modify JupyterSettings to include a `kernel_selections` field (`HashMap`). - [x] Implement saving and loading of kernel selections to/from `.zed/settings.json` (by default, rather than global settings?) #### Kernel Selection Persistence - [x] Save the selected kernel for each language when the user makes a choice. - [x] Load these selections when the RuntimePanel is initialized. #### Use Selected Kernels - [x] Modify kernel launch to use the selected kernel for the detected language. - [x] Fallback to default behavior if no selection is made. ### Empty states - [x] Create helpful UI for when the user has 0 kernels they can launch and/or 0 kernels running image ## Future work ### Kernel Discovery - Improve the kernel discovery process to handle various installation methods (system, virtualenv, poetry, etc.). - Create a way to refresh the available kernels on demand ### Documentation: - Update documentation to explain how users can configure kernels for their projects. - Provide examples of .zed/settings.json configurations for kernel selection. ### Kernel Selection UI - Implement a new section in the RuntimePanel to display available kernels. - Group on the language name from the kernel specification - Create a dropdown for each language group to select the default kernel. Release Notes: - N/A --------- Co-authored-by: Kirill --- crates/editor/src/editor.rs | 2 +- crates/repl/src/jupyter_settings.rs | 149 ++++++++ crates/repl/src/kernels.rs | 395 ++++++++++++++++++++ crates/repl/src/outputs.rs | 43 +-- crates/repl/src/repl.rs | 551 +--------------------------- crates/repl/src/runtime_panel.rs | 443 ++++++++++++++++++++++ crates/repl/src/runtime_settings.rs | 66 ---- crates/repl/src/runtimes.rs | 329 ----------------- crates/repl/src/session.rs | 400 ++++++++++++++++++++ crates/repl/src/stdio.rs | 15 +- crates/zed/src/main.rs | 2 +- crates/zed/src/zed.rs | 8 + 12 files changed, 1438 insertions(+), 965 deletions(-) create mode 100644 crates/repl/src/jupyter_settings.rs create mode 100644 crates/repl/src/kernels.rs create mode 100644 crates/repl/src/runtime_panel.rs delete mode 100644 crates/repl/src/runtime_settings.rs delete mode 100644 crates/repl/src/runtimes.rs create mode 100644 crates/repl/src/session.rs diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 951a41f9dd..c2475fedcd 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -12719,7 +12719,7 @@ pub(crate) fn split_words(text: &str) -> impl std::iter::Iterator + }) } -trait RangeToAnchorExt { +pub trait RangeToAnchorExt { fn to_anchors(self, snapshot: &MultiBufferSnapshot) -> Range; } diff --git a/crates/repl/src/jupyter_settings.rs b/crates/repl/src/jupyter_settings.rs new file mode 100644 index 0000000000..50c7d9899f --- /dev/null +++ b/crates/repl/src/jupyter_settings.rs @@ -0,0 +1,149 @@ +use std::collections::HashMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsSources}; +use ui::Pixels; + +#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum JupyterDockPosition { + Left, + #[default] + Right, + Bottom, +} + +#[derive(Debug, Default)] +pub struct JupyterSettings { + pub enabled: bool, + pub dock: JupyterDockPosition, + pub default_width: Pixels, + pub kernel_selections: HashMap, +} + +#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] +pub struct JupyterSettingsContent { + /// Whether the Jupyter feature is enabled. + /// + /// Default: `false` + enabled: Option, + /// Where to dock the Jupyter panel. + /// + /// Default: `right` + dock: Option, + /// Default width in pixels when the jupyter panel is docked to the left or right. + /// + /// Default: 640 + pub default_width: Option, + /// Default kernels to select for each language. + /// + /// Default: `{}` + pub kernel_selections: Option>, +} + +impl JupyterSettingsContent { + pub fn set_dock(&mut self, dock: JupyterDockPosition) { + self.dock = Some(dock); + } +} + +impl Default for JupyterSettingsContent { + fn default() -> Self { + JupyterSettingsContent { + enabled: Some(false), + dock: Some(JupyterDockPosition::Right), + default_width: Some(640.0), + kernel_selections: Some(HashMap::new()), + } + } +} + +impl Settings for JupyterSettings { + const KEY: Option<&'static str> = Some("jupyter"); + + type FileContent = JupyterSettingsContent; + + fn load( + sources: SettingsSources, + _cx: &mut gpui::AppContext, + ) -> anyhow::Result + where + Self: Sized, + { + let mut settings = JupyterSettings::default(); + + for value in sources.defaults_and_customizations() { + if let Some(enabled) = value.enabled { + settings.enabled = enabled; + } + if let Some(dock) = value.dock { + settings.dock = dock; + } + + if let Some(default_width) = value.default_width { + settings.default_width = Pixels::from(default_width); + } + + if let Some(source) = &value.kernel_selections { + for (k, v) in source { + settings.kernel_selections.insert(k.clone(), v.clone()); + } + } + } + + Ok(settings) + } +} + +#[cfg(test)] +mod tests { + use gpui::{AppContext, UpdateGlobal}; + use settings::SettingsStore; + + use super::*; + + #[gpui::test] + fn test_deserialize_jupyter_settings(cx: &mut AppContext) { + let store = settings::SettingsStore::test(cx); + cx.set_global(store); + + JupyterSettings::register(cx); + + assert_eq!(JupyterSettings::get_global(cx).enabled, false); + assert_eq!( + JupyterSettings::get_global(cx).dock, + JupyterDockPosition::Right + ); + assert_eq!( + JupyterSettings::get_global(cx).default_width, + Pixels::from(640.0) + ); + + // Setting a custom setting through user settings + SettingsStore::update_global(cx, |store, cx| { + store + .set_user_settings( + r#"{ + "jupyter": { + "enabled": true, + "dock": "left", + "default_width": 800.0 + } + }"#, + cx, + ) + .unwrap(); + }); + + assert_eq!(JupyterSettings::get_global(cx).enabled, true); + assert_eq!( + JupyterSettings::get_global(cx).dock, + JupyterDockPosition::Left + ); + assert_eq!( + JupyterSettings::get_global(cx).default_width, + Pixels::from(800.0) + ); + } +} diff --git a/crates/repl/src/kernels.rs b/crates/repl/src/kernels.rs new file mode 100644 index 0000000000..2348a8faea --- /dev/null +++ b/crates/repl/src/kernels.rs @@ -0,0 +1,395 @@ +use anyhow::{Context as _, Result}; +use futures::{ + channel::mpsc::{self, Receiver}, + future::Shared, + stream::{self, SelectAll, StreamExt}, + SinkExt as _, +}; +use gpui::{AppContext, EntityId, Task}; +use project::Fs; +use runtimelib::{ + dirs, ConnectionInfo, ExecutionState, JupyterKernelspec, JupyterMessage, JupyterMessageContent, + KernelInfoReply, +}; +use smol::{net::TcpListener, process::Command}; +use std::{ + fmt::Debug, + net::{IpAddr, Ipv4Addr, SocketAddr}, + path::PathBuf, + sync::Arc, +}; +use ui::{Color, Indicator}; + +#[derive(Debug, Clone)] +pub struct KernelSpecification { + pub name: String, + pub path: PathBuf, + pub kernelspec: JupyterKernelspec, +} + +impl KernelSpecification { + #[must_use] + fn command(&self, connection_path: &PathBuf) -> anyhow::Result { + let argv = &self.kernelspec.argv; + + anyhow::ensure!(!argv.is_empty(), "Empty argv in kernelspec {}", self.name); + anyhow::ensure!(argv.len() >= 2, "Invalid argv in kernelspec {}", self.name); + anyhow::ensure!( + argv.iter().any(|arg| arg == "{connection_file}"), + "Missing 'connection_file' in argv in kernelspec {}", + self.name + ); + + let mut cmd = Command::new(&argv[0]); + + for arg in &argv[1..] { + if arg == "{connection_file}" { + cmd.arg(connection_path); + } else { + cmd.arg(arg); + } + } + + if let Some(env) = &self.kernelspec.env { + cmd.envs(env); + } + + Ok(cmd) + } +} + +// Find a set of open ports. This creates a listener with port set to 0. The listener will be closed at the end when it goes out of scope. +// There's a race condition between closing the ports and usage by a kernel, but it's inherent to the Jupyter protocol. +async fn peek_ports(ip: IpAddr) -> anyhow::Result<[u16; 5]> { + let mut addr_zeroport: SocketAddr = SocketAddr::new(ip, 0); + addr_zeroport.set_port(0); + let mut ports: [u16; 5] = [0; 5]; + for i in 0..5 { + let listener = TcpListener::bind(addr_zeroport).await?; + let addr = listener.local_addr()?; + ports[i] = addr.port(); + } + Ok(ports) +} + +#[derive(Debug)] +pub enum Kernel { + RunningKernel(RunningKernel), + StartingKernel(Shared>), + ErroredLaunch(String), + ShuttingDown, + Shutdown, +} + +impl Kernel { + pub fn dot(&mut self) -> Indicator { + match self { + Kernel::RunningKernel(kernel) => match kernel.execution_state { + ExecutionState::Idle => Indicator::dot().color(Color::Success), + ExecutionState::Busy => Indicator::dot().color(Color::Modified), + }, + Kernel::StartingKernel(_) => Indicator::dot().color(Color::Modified), + Kernel::ErroredLaunch(_) => Indicator::dot().color(Color::Error), + Kernel::ShuttingDown => Indicator::dot().color(Color::Modified), + Kernel::Shutdown => Indicator::dot().color(Color::Disabled), + } + } + + pub fn set_execution_state(&mut self, status: &ExecutionState) { + match self { + Kernel::RunningKernel(running_kernel) => { + running_kernel.execution_state = status.clone(); + } + _ => {} + } + } + + pub fn set_kernel_info(&mut self, kernel_info: &KernelInfoReply) { + match self { + Kernel::RunningKernel(running_kernel) => { + running_kernel.kernel_info = Some(kernel_info.clone()); + } + _ => {} + } + } +} + +pub struct RunningKernel { + pub process: smol::process::Child, + _shell_task: Task>, + _iopub_task: Task>, + _control_task: Task>, + _routing_task: Task>, + connection_path: PathBuf, + pub request_tx: mpsc::Sender, + pub execution_state: ExecutionState, + pub kernel_info: Option, +} + +type JupyterMessageChannel = stream::SelectAll>; + +impl Debug for RunningKernel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RunningKernel") + .field("process", &self.process) + .finish() + } +} + +impl RunningKernel { + pub fn new( + kernel_specification: KernelSpecification, + entity_id: EntityId, + fs: Arc, + cx: &mut AppContext, + ) -> Task> { + cx.spawn(|cx| async move { + let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); + let ports = peek_ports(ip).await?; + + let connection_info = ConnectionInfo { + transport: "tcp".to_string(), + ip: ip.to_string(), + stdin_port: ports[0], + control_port: ports[1], + hb_port: ports[2], + shell_port: ports[3], + iopub_port: ports[4], + signature_scheme: "hmac-sha256".to_string(), + key: uuid::Uuid::new_v4().to_string(), + kernel_name: Some(format!("zed-{}", kernel_specification.name)), + }; + + let connection_path = dirs::runtime_dir().join(format!("kernel-zed-{entity_id}.json")); + let content = serde_json::to_string(&connection_info)?; + // write out file to disk for kernel + fs.atomic_write(connection_path.clone(), content).await?; + + let mut cmd = kernel_specification.command(&connection_path)?; + let process = cmd + // .stdout(Stdio::null()) + // .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .context("failed to start the kernel process")?; + + let mut iopub_socket = connection_info.create_client_iopub_connection("").await?; + let mut shell_socket = connection_info.create_client_shell_connection().await?; + let mut control_socket = connection_info.create_client_control_connection().await?; + + let (mut iopub, iosub) = futures::channel::mpsc::channel(100); + + let (request_tx, mut request_rx) = + futures::channel::mpsc::channel::(100); + + let (mut control_reply_tx, control_reply_rx) = futures::channel::mpsc::channel(100); + let (mut shell_reply_tx, shell_reply_rx) = futures::channel::mpsc::channel(100); + + let mut messages_rx = SelectAll::new(); + messages_rx.push(iosub); + messages_rx.push(control_reply_rx); + messages_rx.push(shell_reply_rx); + + let _iopub_task = cx.background_executor().spawn({ + async move { + while let Ok(message) = iopub_socket.read().await { + iopub.send(message).await?; + } + anyhow::Ok(()) + } + }); + + let (mut control_request_tx, mut control_request_rx) = + futures::channel::mpsc::channel(100); + let (mut shell_request_tx, mut shell_request_rx) = futures::channel::mpsc::channel(100); + + let _routing_task = cx.background_executor().spawn({ + async move { + while let Some(message) = request_rx.next().await { + match message.content { + JupyterMessageContent::DebugRequest(_) + | JupyterMessageContent::InterruptRequest(_) + | JupyterMessageContent::ShutdownRequest(_) => { + control_request_tx.send(message).await?; + } + _ => { + shell_request_tx.send(message).await?; + } + } + } + anyhow::Ok(()) + } + }); + + let _shell_task = cx.background_executor().spawn({ + async move { + while let Some(message) = shell_request_rx.next().await { + shell_socket.send(message).await.ok(); + let reply = shell_socket.read().await?; + shell_reply_tx.send(reply).await?; + } + anyhow::Ok(()) + } + }); + + let _control_task = cx.background_executor().spawn({ + async move { + while let Some(message) = control_request_rx.next().await { + control_socket.send(message).await.ok(); + let reply = control_socket.read().await?; + control_reply_tx.send(reply).await?; + } + anyhow::Ok(()) + } + }); + + anyhow::Ok(( + Self { + process, + request_tx, + _shell_task, + _iopub_task, + _control_task, + _routing_task, + connection_path, + execution_state: ExecutionState::Busy, + kernel_info: None, + }, + messages_rx, + )) + }) + } +} + +impl Drop for RunningKernel { + fn drop(&mut self) { + std::fs::remove_file(&self.connection_path).ok(); + + self.request_tx.close_channel(); + } +} + +async fn read_kernelspec_at( + // Path should be a directory to a jupyter kernelspec, as in + // /usr/local/share/jupyter/kernels/python3 + kernel_dir: PathBuf, + fs: &dyn Fs, +) -> anyhow::Result { + let path = kernel_dir; + let kernel_name = if let Some(kernel_name) = path.file_name() { + kernel_name.to_string_lossy().to_string() + } else { + anyhow::bail!("Invalid kernelspec directory: {path:?}"); + }; + + if !fs.is_dir(path.as_path()).await { + anyhow::bail!("Not a directory: {path:?}"); + } + + let expected_kernel_json = path.join("kernel.json"); + let spec = fs.load(expected_kernel_json.as_path()).await?; + let spec = serde_json::from_str::(&spec)?; + + Ok(KernelSpecification { + name: kernel_name, + path, + kernelspec: spec, + }) +} + +/// Read a directory of kernelspec directories +async fn read_kernels_dir(path: PathBuf, fs: &dyn Fs) -> anyhow::Result> { + let mut kernelspec_dirs = fs.read_dir(&path).await?; + + let mut valid_kernelspecs = Vec::new(); + while let Some(path) = kernelspec_dirs.next().await { + match path { + Ok(path) => { + if fs.is_dir(path.as_path()).await { + if let Ok(kernelspec) = read_kernelspec_at(path, fs).await { + valid_kernelspecs.push(kernelspec); + } + } + } + Err(err) => log::warn!("Error reading kernelspec directory: {err:?}"), + } + } + + Ok(valid_kernelspecs) +} + +pub async fn kernel_specifications(fs: Arc) -> anyhow::Result> { + let data_dirs = dirs::data_dirs(); + let kernel_dirs = data_dirs + .iter() + .map(|dir| dir.join("kernels")) + .map(|path| read_kernels_dir(path, fs.as_ref())) + .collect::>(); + + let kernel_dirs = futures::future::join_all(kernel_dirs).await; + let kernel_dirs = kernel_dirs + .into_iter() + .filter_map(Result::ok) + .flatten() + .collect::>(); + + Ok(kernel_dirs) +} + +#[cfg(test)] +mod test { + use super::*; + use std::path::PathBuf; + + use gpui::TestAppContext; + use project::FakeFs; + use serde_json::json; + + #[gpui::test] + async fn test_get_kernelspecs(cx: &mut TestAppContext) { + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/jupyter", + json!({ + ".zed": { + "settings.json": r#"{ "tab_size": 8 }"#, + "tasks.json": r#"[{ + "label": "cargo check", + "command": "cargo", + "args": ["check", "--all"] + },]"#, + }, + "kernels": { + "python": { + "kernel.json": r#"{ + "display_name": "Python 3", + "language": "python", + "argv": ["python3", "-m", "ipykernel_launcher", "-f", "{connection_file}"], + "env": {} + }"# + }, + "deno": { + "kernel.json": r#"{ + "display_name": "Deno", + "language": "typescript", + "argv": ["deno", "run", "--unstable", "--allow-net", "--allow-read", "https://deno.land/std/http/file_server.ts", "{connection_file}"], + "env": {} + }"# + } + }, + }), + ) + .await; + + let mut kernels = read_kernels_dir(PathBuf::from("/jupyter/kernels"), fs.as_ref()) + .await + .unwrap(); + + kernels.sort_by(|a, b| a.name.cmp(&b.name)); + + assert_eq!( + kernels.iter().map(|c| c.name.clone()).collect::>(), + vec!["deno", "python"] + ); + } +} diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index aae5af6f8d..d502941667 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -301,14 +301,17 @@ impl From<&MimeBundle> for OutputType { } } -#[derive(Default)] +#[derive(Default, Clone)] pub enum ExecutionStatus { #[default] Unknown, - #[allow(unused)] ConnectingToKernel, + Queued, Executing, Finished, + ShuttingDown, + Shutdown, + KernelErrored(String), } pub struct ExecutionView { @@ -317,10 +320,10 @@ pub struct ExecutionView { } impl ExecutionView { - pub fn new(_cx: &mut ViewContext) -> Self { + pub fn new(status: ExecutionStatus, _cx: &mut ViewContext) -> Self { Self { outputs: Default::default(), - status: ExecutionStatus::Unknown, + status, } } @@ -358,14 +361,16 @@ impl ExecutionView { self.outputs.push(output); } + // Comments from @rgbkrk, reach out with questions + // Set next input adds text to the next cell. Not required to support. - // However, this could be implemented by + // However, this could be implemented by adding text to the buffer. // runtimelib::Payload::SetNextInput { text, replace } => todo!(), // Not likely to be used in the context of Zed, where someone could just open the buffer themselves // runtimelib::Payload::EditMagic { filename, line_number } => todo!(), - // + // Ask the user if they want to exit the kernel. Not required to support. // runtimelib::Payload::AskExit { keepkernel } => todo!(), _ => {} } @@ -431,28 +436,24 @@ impl ExecutionView { new_terminal.append_text(text); Some(OutputType::Stream(new_terminal)) } - - pub fn set_status(&mut self, status: ExecutionStatus, cx: &mut ViewContext) { - self.status = status; - cx.notify(); - } } impl Render for ExecutionView { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { if self.outputs.len() == 0 { - match self.status { - ExecutionStatus::ConnectingToKernel => { - return div().child("Connecting to kernel...").into_any_element() + return match &self.status { + ExecutionStatus::ConnectingToKernel => div().child("Connecting to kernel..."), + ExecutionStatus::Executing => div().child("Executing..."), + ExecutionStatus::Finished => div().child(Icon::new(IconName::Check)), + ExecutionStatus::Unknown => div().child("..."), + ExecutionStatus::ShuttingDown => div().child("Kernel shutting down..."), + ExecutionStatus::Shutdown => div().child("Kernel shutdown"), + ExecutionStatus::Queued => div().child("Queued"), + ExecutionStatus::KernelErrored(error) => { + div().child(format!("Kernel error: {}", error)) } - ExecutionStatus::Executing => { - return div().child("Executing...").into_any_element() - } - ExecutionStatus::Finished => { - return div().child(Icon::new(IconName::Check)).into_any_element() - } - ExecutionStatus::Unknown => return div().child("...").into_any_element(), } + .into_any_element(); } div() diff --git a/crates/repl/src/repl.rs b/crates/repl/src/repl.rs index 2ae9809d33..0c402cfc48 100644 --- a/crates/repl/src/repl.rs +++ b/crates/repl/src/repl.rs @@ -1,51 +1,19 @@ -use anyhow::{anyhow, Context as _, Result}; -use async_dispatcher::{set_dispatcher, timeout, Dispatcher, Runnable}; -use collections::{HashMap, HashSet}; -use editor::{ - display_map::{ - BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock, - }, - Anchor, AnchorRangeExt, Editor, -}; -use futures::{ - channel::mpsc::{self, UnboundedSender}, - future::Shared, - Future, FutureExt, SinkExt as _, StreamExt, -}; -use gpui::prelude::*; -use gpui::{ - actions, AppContext, Context, EntityId, Global, Model, ModelContext, PlatformDispatcher, Task, - WeakView, -}; -use gpui::{Entity, View}; -use language::Point; -use outputs::{ExecutionStatus, ExecutionView, LineHeight as _}; -use project::Fs; -use runtime_settings::JupyterSettings; -use runtimelib::JupyterMessageContent; -use settings::{Settings as _, SettingsStore}; -use std::{ops::Range, time::Instant}; +use async_dispatcher::{set_dispatcher, Dispatcher, Runnable}; +use gpui::{AppContext, PlatformDispatcher}; +use settings::Settings as _; use std::{sync::Arc, time::Duration}; -use theme::{ActiveTheme, ThemeSettings}; -use ui::prelude::*; -use workspace::Workspace; +mod jupyter_settings; +mod kernels; mod outputs; -// mod runtime_panel; -mod runtime_settings; -mod runtimes; +mod runtime_panel; +mod session; mod stdio; -use runtimes::{get_runtime_specifications, Request, RunningKernel, RuntimeSpecification}; +pub use jupyter_settings::JupyterSettings; +pub use runtime_panel::RuntimePanel; -actions!(repl, [Run]); - -#[derive(Clone)] -pub struct RuntimeManagerGlobal(Model); - -impl Global for RuntimeManagerGlobal {} - -pub fn zed_dispatcher(cx: &mut AppContext) -> impl Dispatcher { +fn zed_dispatcher(cx: &mut AppContext) -> impl Dispatcher { struct ZedDispatcher { dispatcher: Arc, } @@ -69,503 +37,8 @@ pub fn zed_dispatcher(cx: &mut AppContext) -> impl Dispatcher { } } -pub fn init(fs: Arc, cx: &mut AppContext) { +pub fn init(cx: &mut AppContext) { set_dispatcher(zed_dispatcher(cx)); JupyterSettings::register(cx); - - observe_jupyter_settings_changes(fs.clone(), cx); - - cx.observe_new_views( - |workspace: &mut Workspace, _: &mut ViewContext| { - workspace.register_action(run); - }, - ) - .detach(); - - let settings = JupyterSettings::get_global(cx); - - if !settings.enabled { - return; - } - - initialize_runtime_manager(fs, cx); -} - -fn initialize_runtime_manager(fs: Arc, cx: &mut AppContext) { - let runtime_manager = cx.new_model(|cx| RuntimeManager::new(fs.clone(), cx)); - RuntimeManager::set_global(runtime_manager.clone(), cx); - - cx.spawn(|mut cx| async move { - let fs = fs.clone(); - - let runtime_specifications = get_runtime_specifications(fs).await?; - - runtime_manager.update(&mut cx, |this, _cx| { - this.runtime_specifications = runtime_specifications; - })?; - - anyhow::Ok(()) - }) - .detach_and_log_err(cx); -} - -fn observe_jupyter_settings_changes(fs: Arc, cx: &mut AppContext) { - cx.observe_global::(move |cx| { - let settings = JupyterSettings::get_global(cx); - if settings.enabled && RuntimeManager::global(cx).is_none() { - initialize_runtime_manager(fs.clone(), cx); - } else { - RuntimeManager::remove_global(cx); - // todo!(): Remove action from workspace(s) - } - }) - .detach(); -} - -#[derive(Debug)] -pub enum Kernel { - RunningKernel(RunningKernel), - StartingKernel(Shared>), - FailedLaunch, -} - -// Per workspace -pub struct RuntimeManager { - fs: Arc, - runtime_specifications: Vec, - - instances: HashMap, - editors: HashMap, EditorRuntimeState>, - // todo!(): Next - // To reduce the number of open tasks and channels we have, let's feed the response - // messages by ID over to the paired ExecutionView - _execution_views_by_id: HashMap>, -} - -#[derive(Debug, Clone)] -struct EditorRuntimeState { - blocks: Vec, - // todo!(): Store a subscription to the editor so we can drop them when the editor is dropped - // subscription: gpui::Subscription, -} - -#[derive(Debug, Clone)] -struct EditorRuntimeBlock { - code_range: Range, - _execution_id: String, - block_id: BlockId, - _execution_view: View, -} - -impl RuntimeManager { - pub fn new(fs: Arc, _cx: &mut AppContext) -> Self { - Self { - fs, - runtime_specifications: Default::default(), - instances: Default::default(), - editors: Default::default(), - _execution_views_by_id: Default::default(), - } - } - - fn get_or_launch_kernel( - &mut self, - entity_id: EntityId, - language_name: Arc, - cx: &mut ModelContext, - ) -> Task>> { - let kernel = self.instances.get(&entity_id); - let pending_kernel_start = match kernel { - Some(Kernel::RunningKernel(running_kernel)) => { - return Task::ready(anyhow::Ok(running_kernel.request_tx.clone())); - } - Some(Kernel::StartingKernel(task)) => task.clone(), - Some(Kernel::FailedLaunch) | None => { - self.instances.remove(&entity_id); - - let kernel = self.launch_kernel(entity_id, language_name, cx); - let pending_kernel = cx - .spawn(|this, mut cx| async move { - let running_kernel = kernel.await; - - match running_kernel { - Ok(running_kernel) => { - let _ = this.update(&mut cx, |this, _cx| { - this.instances - .insert(entity_id, Kernel::RunningKernel(running_kernel)); - }); - } - Err(_err) => { - let _ = this.update(&mut cx, |this, _cx| { - this.instances.insert(entity_id, Kernel::FailedLaunch); - }); - } - } - }) - .shared(); - - self.instances - .insert(entity_id, Kernel::StartingKernel(pending_kernel.clone())); - - pending_kernel - } - }; - - cx.spawn(|this, mut cx| async move { - pending_kernel_start.await; - - this.update(&mut cx, |this, _cx| { - let kernel = this - .instances - .get(&entity_id) - .ok_or(anyhow!("unable to get a running kernel"))?; - - match kernel { - Kernel::RunningKernel(running_kernel) => Ok(running_kernel.request_tx.clone()), - _ => Err(anyhow!("unable to get a running kernel")), - } - })? - }) - } - - fn launch_kernel( - &mut self, - entity_id: EntityId, - language_name: Arc, - cx: &mut ModelContext, - ) -> Task> { - // Get first runtime that matches the language name (for now) - let runtime_specification = - self.runtime_specifications - .iter() - .find(|runtime_specification| { - runtime_specification.kernelspec.language == language_name.to_string() - }); - - let runtime_specification = match runtime_specification { - Some(runtime_specification) => runtime_specification, - None => { - return Task::ready(Err(anyhow::anyhow!( - "No runtime found for language {}", - language_name - ))); - } - }; - - let runtime_specification = runtime_specification.clone(); - - let fs = self.fs.clone(); - - cx.spawn(|_, cx| async move { - let running_kernel = - RunningKernel::new(runtime_specification, entity_id, fs.clone(), cx); - - let running_kernel = running_kernel.await?; - - let mut request_tx = running_kernel.request_tx.clone(); - - let overall_timeout_duration = Duration::from_secs(10); - - let start_time = Instant::now(); - - loop { - if start_time.elapsed() > overall_timeout_duration { - // todo!(): Kill the kernel - return Err(anyhow::anyhow!("Kernel did not respond in time")); - } - - let (tx, rx) = mpsc::unbounded(); - match request_tx - .send(Request { - request: runtimelib::KernelInfoRequest {}.into(), - responses_rx: tx, - }) - .await - { - Ok(_) => {} - Err(_err) => { - break; - } - }; - - let mut rx = rx.fuse(); - - let kernel_info_timeout = Duration::from_secs(1); - - let mut got_kernel_info = false; - while let Ok(Some(message)) = timeout(kernel_info_timeout, rx.next()).await { - match message { - JupyterMessageContent::KernelInfoReply(_) => { - got_kernel_info = true; - } - _ => {} - } - } - - if got_kernel_info { - break; - } - } - - anyhow::Ok(running_kernel) - }) - } - - fn execute_code( - &mut self, - entity_id: EntityId, - language_name: Arc, - code: String, - cx: &mut ModelContext, - ) -> impl Future>> { - let (tx, rx) = mpsc::unbounded(); - - let request_tx = self.get_or_launch_kernel(entity_id, language_name, cx); - - async move { - let request_tx = request_tx.await?; - - request_tx - .unbounded_send(Request { - request: runtimelib::ExecuteRequest { - code, - allow_stdin: false, - silent: false, - store_history: true, - stop_on_error: true, - ..Default::default() - } - .into(), - responses_rx: tx, - }) - .context("Failed to send execution request")?; - - Ok(rx) - } - } - - pub fn global(cx: &AppContext) -> Option> { - cx.try_global::() - .map(|runtime_manager| runtime_manager.0.clone()) - } - - pub fn set_global(runtime_manager: Model, cx: &mut AppContext) { - cx.set_global(RuntimeManagerGlobal(runtime_manager)); - } - - pub fn remove_global(cx: &mut AppContext) { - if RuntimeManager::global(cx).is_some() { - cx.remove_global::(); - } - } -} - -pub fn get_active_editor( - workspace: &mut Workspace, - cx: &mut ViewContext, -) -> Option> { - workspace - .active_item(cx) - .and_then(|item| item.act_as::(cx)) -} - -// Gets the active selection in the editor or the current line -pub fn selection(editor: View, cx: &mut ViewContext) -> Range { - let editor = editor.read(cx); - let selection = editor.selections.newest::(cx); - let buffer = editor.buffer().read(cx).snapshot(cx); - - let range = if selection.is_empty() { - let cursor = selection.head(); - - let line_start = buffer.offset_to_point(cursor).row; - let mut start_offset = buffer.point_to_offset(Point::new(line_start, 0)); - - // Iterate backwards to find the start of the line - while start_offset > 0 { - let ch = buffer.chars_at(start_offset - 1).next().unwrap_or('\0'); - if ch == '\n' { - break; - } - start_offset -= 1; - } - - let mut end_offset = cursor; - - // Iterate forwards to find the end of the line - while end_offset < buffer.len() { - let ch = buffer.chars_at(end_offset).next().unwrap_or('\0'); - if ch == '\n' { - break; - } - end_offset += 1; - } - - // Create a range from the start to the end of the line - start_offset..end_offset - } else { - selection.range() - }; - - let anchor_range = buffer.anchor_before(range.start)..buffer.anchor_after(range.end); - anchor_range -} - -pub fn run(workspace: &mut Workspace, _: &Run, cx: &mut ViewContext) { - let (editor, runtime_manager) = if let (Some(editor), Some(runtime_manager)) = - (get_active_editor(workspace, cx), RuntimeManager::global(cx)) - { - (editor, runtime_manager) - } else { - log::warn!("No active editor or runtime manager found"); - return; - }; - - let anchor_range = selection(editor.clone(), cx); - - let buffer = editor.read(cx).buffer().read(cx).snapshot(cx); - - let selected_text = buffer - .text_for_range(anchor_range.clone()) - .collect::(); - - let start_language = buffer.language_at(anchor_range.start); - let end_language = buffer.language_at(anchor_range.end); - - let language_name = if start_language == end_language { - start_language - .map(|language| language.code_fence_block_name()) - .filter(|lang| **lang != *"markdown") - } else { - // If the selection spans multiple languages, don't run it - return; - }; - - let language_name = if let Some(language_name) = language_name { - language_name - } else { - return; - }; - - let entity_id = editor.entity_id(); - - let execution_view = cx.new_view(|cx| ExecutionView::new(cx)); - - // If any block overlaps with the new block, remove it - // TODO: When inserting a new block, put it in order so that search is efficient - let blocks_to_remove = runtime_manager.update(cx, |runtime_manager, _cx| { - // Get the current `EditorRuntimeState` for this runtime_manager, inserting it if it doesn't exist - let editor_runtime_state = runtime_manager - .editors - .entry(editor.downgrade()) - .or_insert_with(|| EditorRuntimeState { blocks: Vec::new() }); - - let mut blocks_to_remove: HashSet = HashSet::default(); - - editor_runtime_state.blocks.retain(|block| { - if anchor_range.overlaps(&block.code_range, &buffer) { - blocks_to_remove.insert(block.block_id); - // Drop this block - false - } else { - true - } - }); - - blocks_to_remove - }); - - let blocks_to_remove = blocks_to_remove.clone(); - - let block_id = editor.update(cx, |editor, cx| { - editor.remove_blocks(blocks_to_remove, None, cx); - let block = BlockProperties { - position: anchor_range.end, - height: execution_view.num_lines(cx).saturating_add(1), - style: BlockStyle::Sticky, - render: create_output_area_render(execution_view.clone()), - disposition: BlockDisposition::Below, - }; - - editor.insert_blocks([block], None, cx)[0] - }); - - let receiver = runtime_manager.update(cx, |runtime_manager, cx| { - let editor_runtime_state = runtime_manager - .editors - .entry(editor.downgrade()) - .or_insert_with(|| EditorRuntimeState { blocks: Vec::new() }); - - let editor_runtime_block = EditorRuntimeBlock { - code_range: anchor_range.clone(), - block_id, - _execution_view: execution_view.clone(), - _execution_id: Default::default(), - }; - - editor_runtime_state - .blocks - .push(editor_runtime_block.clone()); - - runtime_manager.execute_code(entity_id, language_name, selected_text.clone(), cx) - }); - - cx.spawn(|_this, mut cx| async move { - execution_view.update(&mut cx, |execution_view, cx| { - execution_view.set_status(ExecutionStatus::ConnectingToKernel, cx); - })?; - let mut receiver = receiver.await?; - - let execution_view = execution_view.clone(); - while let Some(content) = receiver.next().await { - execution_view.update(&mut cx, |execution_view, cx| { - execution_view.push_message(&content, cx) - })?; - - editor.update(&mut cx, |editor, cx| { - let mut replacements = HashMap::default(); - replacements.insert( - block_id, - ( - Some(execution_view.num_lines(cx).saturating_add(1)), - create_output_area_render(execution_view.clone()), - ), - ); - editor.replace_blocks(replacements, None, cx); - })?; - } - anyhow::Ok(()) - }) - .detach_and_log_err(cx); -} - -fn create_output_area_render(execution_view: View) -> RenderBlock { - let render = move |cx: &mut BlockContext| { - let execution_view = execution_view.clone(); - let text_font = ThemeSettings::get_global(cx).buffer_font.family.clone(); - // Note: we'll want to use `cx.anchor_x` when someone runs something with no output -- just show a checkmark and not make the full block below the line - - let gutter_width = cx.gutter_dimensions.width; - - h_flex() - .w_full() - .bg(cx.theme().colors().background) - .border_y_1() - .border_color(cx.theme().colors().border) - .pl(gutter_width) - .child( - div() - .font_family(text_font) - // .ml(gutter_width) - .mx_1() - .my_2() - .h_full() - .w_full() - .mr(gutter_width) - .child(execution_view), - ) - .into_any_element() - }; - - Box::new(render) + runtime_panel::init(cx) } diff --git a/crates/repl/src/runtime_panel.rs b/crates/repl/src/runtime_panel.rs new file mode 100644 index 0000000000..e167f9e785 --- /dev/null +++ b/crates/repl/src/runtime_panel.rs @@ -0,0 +1,443 @@ +use crate::{ + jupyter_settings::{JupyterDockPosition, JupyterSettings}, + kernels::{kernel_specifications, KernelSpecification}, + session::{Session, SessionEvent}, +}; +use anyhow::{Context as _, Result}; +use collections::HashMap; +use editor::{Anchor, Editor, RangeToAnchorExt}; +use gpui::{ + actions, prelude::*, AppContext, AsyncWindowContext, Entity, EntityId, EventEmitter, + FocusHandle, FocusOutEvent, FocusableView, Subscription, Task, View, WeakView, +}; +use language::Point; +use project::Fs; +use settings::{Settings as _, SettingsStore}; +use std::{ops::Range, sync::Arc}; +use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding}; +use workspace::{ + dock::{Panel, PanelEvent}, + Workspace, +}; + +actions!(repl, [Run, ToggleFocus, ClearOutputs]); + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views( + |workspace: &mut Workspace, _cx: &mut ViewContext| { + workspace + .register_action(|workspace, _: &ToggleFocus, cx| { + workspace.toggle_panel_focus::(cx); + }) + .register_action(run) + .register_action(clear_outputs); + }, + ) + .detach(); +} + +pub struct RuntimePanel { + fs: Arc, + enabled: bool, + focus_handle: FocusHandle, + width: Option, + sessions: HashMap>, + kernel_specifications: Vec, + _subscriptions: Vec, +} + +impl RuntimePanel { + pub fn load( + workspace: WeakView, + cx: AsyncWindowContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + let view = workspace.update(&mut cx, |workspace, cx| { + cx.new_view::(|cx| { + let focus_handle = cx.focus_handle(); + + let fs = workspace.app_state().fs.clone(); + + let subscriptions = vec![ + cx.on_focus_in(&focus_handle, Self::focus_in), + cx.on_focus_out(&focus_handle, Self::focus_out), + cx.observe_global::(move |this, cx| { + let settings = JupyterSettings::get_global(cx); + this.set_enabled(settings.enabled, cx); + }), + ]; + + let enabled = JupyterSettings::get_global(cx).enabled; + + Self { + fs, + width: None, + focus_handle, + kernel_specifications: Vec::new(), + sessions: Default::default(), + _subscriptions: subscriptions, + enabled, + } + }) + })?; + + view.update(&mut cx, |this, cx| this.refresh_kernelspecs(cx))? + .await?; + + Ok(view) + }) + } + + fn set_enabled(&mut self, enabled: bool, cx: &mut ViewContext) { + if self.enabled != enabled { + self.enabled = enabled; + cx.notify(); + } + } + + fn focus_in(&mut self, cx: &mut ViewContext) { + cx.notify(); + } + + fn focus_out(&mut self, _event: FocusOutEvent, cx: &mut ViewContext) { + cx.notify(); + } + + // Gets the active selection in the editor or the current line + fn selection(&self, editor: View, cx: &mut ViewContext) -> Range { + let editor = editor.read(cx); + let selection = editor.selections.newest::(cx); + let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx); + + let range = if selection.is_empty() { + let cursor = selection.head(); + + let line_start = multi_buffer_snapshot.offset_to_point(cursor).row; + let mut start_offset = multi_buffer_snapshot.point_to_offset(Point::new(line_start, 0)); + + // Iterate backwards to find the start of the line + while start_offset > 0 { + let ch = multi_buffer_snapshot + .chars_at(start_offset - 1) + .next() + .unwrap_or('\0'); + if ch == '\n' { + break; + } + start_offset -= 1; + } + + let mut end_offset = cursor; + + // Iterate forwards to find the end of the line + while end_offset < multi_buffer_snapshot.len() { + let ch = multi_buffer_snapshot + .chars_at(end_offset) + .next() + .unwrap_or('\0'); + if ch == '\n' { + break; + } + end_offset += 1; + } + + // Create a range from the start to the end of the line + start_offset..end_offset + } else { + selection.range() + }; + + range.to_anchors(&multi_buffer_snapshot) + } + + pub fn snippet( + &self, + editor: View, + cx: &mut ViewContext, + ) -> Option<(String, Arc, Range)> { + let buffer = editor.read(cx).buffer().read(cx).snapshot(cx); + let anchor_range = self.selection(editor, cx); + + let selected_text = buffer + .text_for_range(anchor_range.clone()) + .collect::(); + + let start_language = buffer.language_at(anchor_range.start); + let end_language = buffer.language_at(anchor_range.end); + + let language_name = if start_language == end_language { + start_language + .map(|language| language.code_fence_block_name()) + .filter(|lang| **lang != *"markdown")? + } else { + // If the selection spans multiple languages, don't run it + return None; + }; + + Some((selected_text, language_name, anchor_range)) + } + + pub fn refresh_kernelspecs(&mut self, cx: &mut ViewContext) -> Task> { + let kernel_specifications = kernel_specifications(self.fs.clone()); + cx.spawn(|this, mut cx| async move { + let kernel_specifications = kernel_specifications.await?; + + this.update(&mut cx, |this, cx| { + this.kernel_specifications = kernel_specifications; + cx.notify(); + }) + }) + } + + pub fn kernelspec( + &self, + language_name: &str, + cx: &mut ViewContext, + ) -> Option { + let settings = JupyterSettings::get_global(cx); + let selected_kernel = settings.kernel_selections.get(language_name); + + self.kernel_specifications + .iter() + .find(|runtime_specification| { + if let Some(selected) = selected_kernel { + // Top priority is the selected kernel + runtime_specification.name.to_lowercase() == selected.to_lowercase() + } else { + // Otherwise, we'll try to find a kernel that matches the language + runtime_specification.kernelspec.language.to_lowercase() + == language_name.to_lowercase() + } + }) + .cloned() + } + + pub fn run( + &mut self, + editor: View, + fs: Arc, + cx: &mut ViewContext, + ) -> anyhow::Result<()> { + if !self.enabled { + return Ok(()); + } + + let (selected_text, language_name, anchor_range) = match self.snippet(editor.clone(), cx) { + Some(snippet) => snippet, + None => return Ok(()), + }; + + let entity_id = editor.entity_id(); + + let kernel_specification = self + .kernelspec(&language_name, cx) + .with_context(|| format!("No kernel found for language: {language_name}"))?; + + let session = self.sessions.entry(entity_id).or_insert_with(|| { + let view = cx.new_view(|cx| Session::new(editor, fs.clone(), kernel_specification, cx)); + cx.notify(); + + let subscription = cx.subscribe( + &view, + |panel: &mut RuntimePanel, _session: View, event: &SessionEvent, _cx| { + match event { + SessionEvent::Shutdown(shutdown_event) => { + panel.sessions.remove(&shutdown_event.entity_id()); + } + } + // + }, + ); + + subscription.detach(); + + view + }); + + session.update(cx, |session, cx| { + session.execute(&selected_text, anchor_range, cx); + }); + + anyhow::Ok(()) + } + + pub fn clear_outputs(&mut self, editor: View, cx: &mut ViewContext) { + let entity_id = editor.entity_id(); + if let Some(session) = self.sessions.get_mut(&entity_id) { + session.update(cx, |session, cx| { + session.clear_outputs(cx); + }); + cx.notify(); + } + } +} + +pub fn run(workspace: &mut Workspace, _: &Run, cx: &mut ViewContext) { + let settings = JupyterSettings::get_global(cx); + if !settings.enabled { + return; + } + + let editor = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)); + + if let (Some(editor), Some(runtime_panel)) = (editor, workspace.panel::(cx)) { + runtime_panel.update(cx, |runtime_panel, cx| { + runtime_panel + .run(editor, workspace.app_state().fs.clone(), cx) + .ok(); + }); + } +} + +pub fn clear_outputs(workspace: &mut Workspace, _: &ClearOutputs, cx: &mut ViewContext) { + let settings = JupyterSettings::get_global(cx); + if !settings.enabled { + return; + } + + let editor = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)); + + if let (Some(editor), Some(runtime_panel)) = (editor, workspace.panel::(cx)) { + runtime_panel.update(cx, |runtime_panel, cx| { + runtime_panel.clear_outputs(editor, cx); + }); + } +} + +impl Panel for RuntimePanel { + fn persistent_name() -> &'static str { + "RuntimePanel" + } + + fn position(&self, cx: &ui::WindowContext) -> workspace::dock::DockPosition { + match JupyterSettings::get_global(cx).dock { + JupyterDockPosition::Left => workspace::dock::DockPosition::Left, + JupyterDockPosition::Right => workspace::dock::DockPosition::Right, + JupyterDockPosition::Bottom => workspace::dock::DockPosition::Bottom, + } + } + + fn position_is_valid(&self, _position: workspace::dock::DockPosition) -> bool { + true + } + + fn set_position( + &mut self, + position: workspace::dock::DockPosition, + cx: &mut ViewContext, + ) { + settings::update_settings_file::(self.fs.clone(), cx, move |settings| { + let dock = match position { + workspace::dock::DockPosition::Left => JupyterDockPosition::Left, + workspace::dock::DockPosition::Right => JupyterDockPosition::Right, + workspace::dock::DockPosition::Bottom => JupyterDockPosition::Bottom, + }; + settings.set_dock(dock); + }) + } + + fn size(&self, cx: &ui::WindowContext) -> Pixels { + let settings = JupyterSettings::get_global(cx); + + self.width.unwrap_or(settings.default_width) + } + + fn set_size(&mut self, size: Option, _cx: &mut ViewContext) { + self.width = size; + } + + fn icon(&self, _cx: &ui::WindowContext) -> Option { + if !self.enabled { + return None; + } + + Some(IconName::Code) + } + + fn icon_tooltip(&self, _cx: &ui::WindowContext) -> Option<&'static str> { + Some("Runtime Panel") + } + + fn toggle_action(&self) -> Box { + Box::new(ToggleFocus) + } +} + +impl EventEmitter for RuntimePanel {} + +impl FocusableView for RuntimePanel { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for RuntimePanel { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + // When there are no kernel specifications, show a link to the Zed docs explaining how to + // install kernels. It can be assumed they don't have a running kernel if we have no + // specifications. + if self.kernel_specifications.is_empty() { + return v_flex() + .p_4() + .size_full() + .gap_2() + .child(Label::new("No Jupyter Kernels Available").size(LabelSize::Large)) + .child( + Label::new("To start interactively running code in your editor, you need to install and configure Jupyter kernels.") + .size(LabelSize::Default), + ) + .child( + h_flex().w_full().p_4().justify_center().gap_2().child( + ButtonLike::new("install-kernels") + .style(ButtonStyle::Filled) + .size(ButtonSize::Large) + .layer(ElevationIndex::ModalSurface) + .child(Label::new("Install Kernels")) + .on_click(move |_, cx| { + cx.open_url( + "https://docs.jupyter.org/en/latest/install/kernels.html", + ) + }), + ), + ) + .into_any_element(); + } + + // When there are no sessions, show the command to run code in an editor + if self.sessions.is_empty() { + return v_flex() + .p_4() + .size_full() + .gap_2() + .child(Label::new("No Jupyter Kernel Sessions").size(LabelSize::Large)) + .child( + v_flex().child( + Label::new("To run code in a Jupyter kernel, select some code and use the 'repl::Run' command.") + .size(LabelSize::Default) + ) + .children( + KeyBinding::for_action(&Run, cx) + .map(|binding| + binding.into_any_element() + ) + ) + ) + + .into_any_element(); + } + + v_flex() + .p_4() + .child(Label::new("Jupyter Kernel Sessions").size(LabelSize::Large)) + .children( + self.sessions + .values() + .map(|session| session.clone().into_any_element()), + ) + .into_any_element() + } +} diff --git a/crates/repl/src/runtime_settings.rs b/crates/repl/src/runtime_settings.rs deleted file mode 100644 index 64fe8cb3a3..0000000000 --- a/crates/repl/src/runtime_settings.rs +++ /dev/null @@ -1,66 +0,0 @@ -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources}; - -#[derive(Copy, Clone, Default, Debug, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -pub enum RuntimesDockPosition { - Left, - #[default] - Right, - Bottom, -} - -#[derive(Debug, Default)] -pub struct JupyterSettings { - pub enabled: bool, - pub dock: RuntimesDockPosition, -} - -#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] -pub struct JupyterSettingsContent { - /// Whether the Runtimes feature is enabled. - /// - /// Default: `false` - enabled: Option, - /// Where to dock the runtimes panel. - /// - /// Default: `right` - dock: Option, -} - -impl Default for JupyterSettingsContent { - fn default() -> Self { - JupyterSettingsContent { - enabled: Some(false), - dock: Some(RuntimesDockPosition::Right), - } - } -} - -impl Settings for JupyterSettings { - const KEY: Option<&'static str> = Some("jupyter"); - - type FileContent = JupyterSettingsContent; - - fn load( - sources: SettingsSources, - _cx: &mut gpui::AppContext, - ) -> anyhow::Result - where - Self: Sized, - { - let mut settings = JupyterSettings::default(); - - for value in sources.defaults_and_customizations() { - if let Some(enabled) = value.enabled { - settings.enabled = enabled; - } - if let Some(dock) = value.dock { - settings.dock = dock; - } - } - - Ok(settings) - } -} diff --git a/crates/repl/src/runtimes.rs b/crates/repl/src/runtimes.rs deleted file mode 100644 index ec4150d545..0000000000 --- a/crates/repl/src/runtimes.rs +++ /dev/null @@ -1,329 +0,0 @@ -use anyhow::{Context as _, Result}; -use collections::HashMap; -use futures::lock::Mutex; -use futures::{channel::mpsc, SinkExt as _, StreamExt as _}; -use gpui::{AsyncAppContext, EntityId}; -use project::Fs; -use runtimelib::{dirs, ConnectionInfo, JupyterKernelspec, JupyterMessage, JupyterMessageContent}; -use smol::{net::TcpListener, process::Command}; -use std::fmt::Debug; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use std::{path::PathBuf, sync::Arc}; - -#[derive(Debug)] -pub struct Request { - pub request: runtimelib::JupyterMessageContent, - pub responses_rx: mpsc::UnboundedSender, -} - -#[derive(Debug, Clone)] -pub struct RuntimeSpecification { - pub name: String, - pub path: PathBuf, - pub kernelspec: JupyterKernelspec, -} - -impl RuntimeSpecification { - #[must_use] - fn command(&self, connection_path: &PathBuf) -> Result { - let argv = &self.kernelspec.argv; - - if argv.is_empty() { - return Err(anyhow::anyhow!("Empty argv in kernelspec {}", self.name)); - } - - if argv.len() < 2 { - return Err(anyhow::anyhow!("Invalid argv in kernelspec {}", self.name)); - } - - if !argv.contains(&"{connection_file}".to_string()) { - return Err(anyhow::anyhow!( - "Missing 'connection_file' in argv in kernelspec {}", - self.name - )); - } - - let mut cmd = Command::new(&argv[0]); - - for arg in &argv[1..] { - if arg == "{connection_file}" { - cmd.arg(connection_path); - } else { - cmd.arg(arg); - } - } - - if let Some(env) = &self.kernelspec.env { - cmd.envs(env); - } - - Ok(cmd) - } -} - -// Find a set of open ports. This creates a listener with port set to 0. The listener will be closed at the end when it goes out of scope. -// There's a race condition between closing the ports and usage by a kernel, but it's inherent to the Jupyter protocol. -async fn peek_ports(ip: IpAddr) -> anyhow::Result<[u16; 5]> { - let mut addr_zeroport: SocketAddr = SocketAddr::new(ip, 0); - addr_zeroport.set_port(0); - let mut ports: [u16; 5] = [0; 5]; - for i in 0..5 { - let listener = TcpListener::bind(addr_zeroport).await?; - let addr = listener.local_addr()?; - ports[i] = addr.port(); - } - Ok(ports) -} - -#[derive(Debug)] -pub struct RunningKernel { - #[allow(unused)] - runtime: RuntimeSpecification, - #[allow(unused)] - process: smol::process::Child, - pub request_tx: mpsc::UnboundedSender, -} - -impl RunningKernel { - pub async fn new( - runtime: RuntimeSpecification, - entity_id: EntityId, - fs: Arc, - cx: AsyncAppContext, - ) -> anyhow::Result { - let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); - let ports = peek_ports(ip).await?; - - let connection_info = ConnectionInfo { - transport: "tcp".to_string(), - ip: ip.to_string(), - stdin_port: ports[0], - control_port: ports[1], - hb_port: ports[2], - shell_port: ports[3], - iopub_port: ports[4], - signature_scheme: "hmac-sha256".to_string(), - key: uuid::Uuid::new_v4().to_string(), - kernel_name: Some(format!("zed-{}", runtime.name)), - }; - - let connection_path = dirs::runtime_dir().join(format!("kernel-zed-{}.json", entity_id)); - let content = serde_json::to_string(&connection_info)?; - // write out file to disk for kernel - fs.atomic_write(connection_path.clone(), content).await?; - - let mut cmd = runtime.command(&connection_path)?; - let process = cmd - // .stdout(Stdio::null()) - // .stderr(Stdio::null()) - .kill_on_drop(true) - .spawn() - .context("failed to start the kernel process")?; - - let mut iopub = connection_info.create_client_iopub_connection("").await?; - let mut shell = connection_info.create_client_shell_connection().await?; - - // Spawn a background task to handle incoming messages from the kernel as well - // as outgoing messages to the kernel - - let child_messages: Arc< - Mutex>>, - > = Default::default(); - - let (request_tx, mut request_rx) = mpsc::unbounded::(); - - cx.background_executor() - .spawn({ - let child_messages = child_messages.clone(); - - async move { - let child_messages = child_messages.clone(); - while let Ok(message) = iopub.read().await { - if let Some(parent_header) = message.parent_header { - let child_messages = child_messages.lock().await; - - let sender = child_messages.get(&parent_header.msg_id); - - match sender { - Some(mut sender) => { - sender.send(message.content).await?; - } - None => {} - } - } - } - - anyhow::Ok(()) - } - }) - .detach(); - - cx.background_executor() - .spawn({ - let child_messages = child_messages.clone(); - async move { - while let Some(request) = request_rx.next().await { - let rx = request.responses_rx.clone(); - - let request: JupyterMessage = request.request.into(); - let msg_id = request.header.msg_id.clone(); - - let mut sender = rx.clone(); - - child_messages - .lock() - .await - .insert(msg_id.clone(), sender.clone()); - - shell.send(request).await?; - - let response = shell.read().await?; - - sender.send(response.content).await?; - } - - anyhow::Ok(()) - } - }) - .detach(); - - Ok(Self { - runtime, - process, - request_tx, - }) - } -} - -async fn read_kernelspec_at( - // Path should be a directory to a jupyter kernelspec, as in - // /usr/local/share/jupyter/kernels/python3 - kernel_dir: PathBuf, - fs: Arc, -) -> anyhow::Result { - let path = kernel_dir; - let kernel_name = if let Some(kernel_name) = path.file_name() { - kernel_name.to_string_lossy().to_string() - } else { - return Err(anyhow::anyhow!("Invalid kernelspec directory: {:?}", path)); - }; - - if !fs.is_dir(path.as_path()).await { - return Err(anyhow::anyhow!("Not a directory: {:?}", path)); - } - - let expected_kernel_json = path.join("kernel.json"); - let spec = fs.load(expected_kernel_json.as_path()).await?; - let spec = serde_json::from_str::(&spec)?; - - Ok(RuntimeSpecification { - name: kernel_name, - path, - kernelspec: spec, - }) -} - -/// Read a directory of kernelspec directories -async fn read_kernels_dir( - path: PathBuf, - fs: Arc, -) -> anyhow::Result> { - let mut kernelspec_dirs = fs.read_dir(&path).await?; - - let mut valid_kernelspecs = Vec::new(); - while let Some(path) = kernelspec_dirs.next().await { - match path { - Ok(path) => { - if fs.is_dir(path.as_path()).await { - let fs = fs.clone(); - if let Ok(kernelspec) = read_kernelspec_at(path, fs).await { - valid_kernelspecs.push(kernelspec); - } - } - } - Err(err) => { - log::warn!("Error reading kernelspec directory: {:?}", err); - } - } - } - - Ok(valid_kernelspecs) -} - -pub async fn get_runtime_specifications( - fs: Arc, -) -> anyhow::Result> { - let data_dirs = dirs::data_dirs(); - let kernel_dirs = data_dirs - .iter() - .map(|dir| dir.join("kernels")) - .map(|path| read_kernels_dir(path, fs.clone())) - .collect::>(); - - let kernel_dirs = futures::future::join_all(kernel_dirs).await; - let kernel_dirs = kernel_dirs - .into_iter() - .filter_map(Result::ok) - .flatten() - .collect::>(); - - Ok(kernel_dirs) -} - -#[cfg(test)] -mod test { - use super::*; - use std::path::PathBuf; - - use gpui::TestAppContext; - use project::FakeFs; - use serde_json::json; - - #[gpui::test] - async fn test_get_kernelspecs(cx: &mut TestAppContext) { - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/jupyter", - json!({ - ".zed": { - "settings.json": r#"{ "tab_size": 8 }"#, - "tasks.json": r#"[{ - "label": "cargo check", - "command": "cargo", - "args": ["check", "--all"] - },]"#, - }, - "kernels": { - "python": { - "kernel.json": r#"{ - "display_name": "Python 3", - "language": "python", - "argv": ["python3", "-m", "ipykernel_launcher", "-f", "{connection_file}"], - "env": {} - }"# - }, - "deno": { - "kernel.json": r#"{ - "display_name": "Deno", - "language": "typescript", - "argv": ["deno", "run", "--unstable", "--allow-net", "--allow-read", "https://deno.land/std/http/file_server.ts", "{connection_file}"], - "env": {} - }"# - } - }, - }), - ) - .await; - - let mut kernels = read_kernels_dir(PathBuf::from("/jupyter/kernels"), fs) - .await - .unwrap(); - - kernels.sort_by(|a, b| a.name.cmp(&b.name)); - - assert_eq!( - kernels.iter().map(|c| c.name.clone()).collect::>(), - vec!["deno", "python"] - ); - } -} diff --git a/crates/repl/src/session.rs b/crates/repl/src/session.rs new file mode 100644 index 0000000000..37e08df660 --- /dev/null +++ b/crates/repl/src/session.rs @@ -0,0 +1,400 @@ +use crate::{ + kernels::{Kernel, KernelSpecification, RunningKernel}, + outputs::{ExecutionStatus, ExecutionView, LineHeight as _}, +}; +use collections::{HashMap, HashSet}; +use editor::{ + display_map::{ + BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock, + }, + Anchor, AnchorRangeExt as _, Editor, +}; +use futures::{FutureExt as _, StreamExt as _}; +use gpui::{div, prelude::*, Entity, EventEmitter, Render, Task, View, ViewContext}; +use project::Fs; +use runtimelib::{ + ExecuteRequest, InterruptRequest, JupyterMessage, JupyterMessageContent, KernelInfoRequest, + ShutdownRequest, +}; +use settings::Settings as _; +use std::{ops::Range, sync::Arc, time::Duration}; +use theme::{ActiveTheme, ThemeSettings}; +use ui::{h_flex, prelude::*, v_flex, ButtonLike, ButtonStyle, Label}; + +pub struct Session { + editor: View, + kernel: Kernel, + blocks: HashMap, + messaging_task: Task<()>, + kernel_specification: KernelSpecification, +} + +#[derive(Debug)] +struct EditorBlock { + editor: View, + code_range: Range, + block_id: BlockId, + execution_view: View, +} + +impl EditorBlock { + fn new( + editor: View, + code_range: Range, + status: ExecutionStatus, + cx: &mut ViewContext, + ) -> Self { + let execution_view = cx.new_view(|cx| ExecutionView::new(status, cx)); + + let block_id = editor.update(cx, |editor, cx| { + let block = BlockProperties { + position: code_range.end, + height: execution_view.num_lines(cx).saturating_add(1), + style: BlockStyle::Sticky, + render: Self::create_output_area_render(execution_view.clone()), + disposition: BlockDisposition::Below, + }; + + editor.insert_blocks([block], None, cx)[0] + }); + + Self { + editor, + code_range, + block_id, + execution_view, + } + } + + fn handle_message(&mut self, message: &JupyterMessage, cx: &mut ViewContext) { + self.execution_view.update(cx, |execution_view, cx| { + execution_view.push_message(&message.content, cx); + }); + + self.editor.update(cx, |editor, cx| { + let mut replacements = HashMap::default(); + replacements.insert( + self.block_id, + ( + Some(self.execution_view.num_lines(cx).saturating_add(1)), + Self::create_output_area_render(self.execution_view.clone()), + ), + ); + editor.replace_blocks(replacements, None, cx); + }) + } + + fn create_output_area_render(execution_view: View) -> RenderBlock { + let render = move |cx: &mut BlockContext| { + let execution_view = execution_view.clone(); + let text_font = ThemeSettings::get_global(cx).buffer_font.family.clone(); + // Note: we'll want to use `cx.anchor_x` when someone runs something with no output -- just show a checkmark and not make the full block below the line + + let gutter_width = cx.gutter_dimensions.width; + + h_flex() + .w_full() + .bg(cx.theme().colors().background) + .border_y_1() + .border_color(cx.theme().colors().border) + .pl(gutter_width) + .child( + div() + .font_family(text_font) + // .ml(gutter_width) + .mx_1() + .my_2() + .h_full() + .w_full() + .mr(gutter_width) + .child(execution_view), + ) + .into_any_element() + }; + + Box::new(render) + } +} + +impl Session { + pub fn new( + editor: View, + fs: Arc, + kernel_specification: KernelSpecification, + cx: &mut ViewContext, + ) -> Self { + let entity_id = editor.entity_id(); + let kernel = RunningKernel::new(kernel_specification.clone(), entity_id, fs.clone(), cx); + + let pending_kernel = cx + .spawn(|this, mut cx| async move { + let kernel = kernel.await; + + match kernel { + Ok((kernel, mut messages_rx)) => { + this.update(&mut cx, |this, cx| { + // At this point we can create a new kind of kernel that has the process and our long running background tasks + this.kernel = Kernel::RunningKernel(kernel); + + this.messaging_task = cx.spawn(|session, mut cx| async move { + while let Some(message) = messages_rx.next().await { + session + .update(&mut cx, |session, cx| { + session.route(&message, cx); + }) + .ok(); + } + }); + + // For some reason sending a kernel info request will brick the ark (R) kernel. + // Note that Deno and Python do not have this issue. + if this.kernel_specification.name == "ark" { + return; + } + + // Get kernel info after (possibly) letting the kernel start + cx.spawn(|this, mut cx| async move { + cx.background_executor() + .timer(Duration::from_millis(120)) + .await; + this.update(&mut cx, |this, _cx| { + this.send(KernelInfoRequest {}.into(), _cx).ok(); + }) + .ok(); + }) + .detach(); + }) + .ok(); + } + Err(err) => { + this.update(&mut cx, |this, _cx| { + this.kernel = Kernel::ErroredLaunch(err.to_string()); + }) + .ok(); + } + } + }) + .shared(); + + return Self { + editor, + kernel: Kernel::StartingKernel(pending_kernel), + messaging_task: Task::ready(()), + blocks: HashMap::default(), + kernel_specification, + }; + } + + fn send(&mut self, message: JupyterMessage, _cx: &mut ViewContext) -> anyhow::Result<()> { + match &mut self.kernel { + Kernel::RunningKernel(kernel) => { + kernel.request_tx.try_send(message).ok(); + } + _ => {} + } + + anyhow::Ok(()) + } + + pub fn clear_outputs(&mut self, cx: &mut ViewContext) { + let blocks_to_remove: HashSet = + self.blocks.values().map(|block| block.block_id).collect(); + + self.editor.update(cx, |editor, cx| { + editor.remove_blocks(blocks_to_remove, None, cx); + }); + + self.blocks.clear(); + } + + pub fn execute(&mut self, code: &str, anchor_range: Range, cx: &mut ViewContext) { + let execute_request = ExecuteRequest { + code: code.to_string(), + ..ExecuteRequest::default() + }; + + let message: JupyterMessage = execute_request.into(); + + let mut blocks_to_remove: HashSet = HashSet::default(); + + let buffer = self.editor.read(cx).buffer().read(cx).snapshot(cx); + + self.blocks.retain(|_key, block| { + if anchor_range.overlaps(&block.code_range, &buffer) { + blocks_to_remove.insert(block.block_id); + false + } else { + true + } + }); + + self.editor.update(cx, |editor, cx| { + editor.remove_blocks(blocks_to_remove, None, cx); + }); + + let status = match &self.kernel { + Kernel::RunningKernel(_) => ExecutionStatus::Queued, + Kernel::StartingKernel(_) => ExecutionStatus::ConnectingToKernel, + Kernel::ErroredLaunch(error) => ExecutionStatus::KernelErrored(error.clone()), + Kernel::ShuttingDown => ExecutionStatus::ShuttingDown, + Kernel::Shutdown => ExecutionStatus::Shutdown, + }; + + let editor_block = EditorBlock::new(self.editor.clone(), anchor_range, status, cx); + + self.blocks + .insert(message.header.msg_id.clone(), editor_block); + + match &self.kernel { + Kernel::RunningKernel(_) => { + self.send(message, cx).ok(); + } + Kernel::StartingKernel(task) => { + // Queue up the execution as a task to run after the kernel starts + let task = task.clone(); + let message = message.clone(); + + cx.spawn(|this, mut cx| async move { + task.await; + this.update(&mut cx, |this, cx| { + this.send(message, cx).ok(); + }) + .ok(); + }) + .detach(); + } + _ => {} + } + } + + fn route(&mut self, message: &JupyterMessage, cx: &mut ViewContext) { + let parent_message_id = match message.parent_header.as_ref() { + Some(header) => &header.msg_id, + None => return, + }; + + match &message.content { + JupyterMessageContent::Status(status) => { + self.kernel.set_execution_state(&status.execution_state); + cx.notify(); + } + JupyterMessageContent::KernelInfoReply(reply) => { + self.kernel.set_kernel_info(&reply); + cx.notify(); + } + _ => {} + } + + if let Some(block) = self.blocks.get_mut(parent_message_id) { + block.handle_message(&message, cx); + return; + } + } + + fn interrupt(&mut self, cx: &mut ViewContext) { + match &mut self.kernel { + Kernel::RunningKernel(_kernel) => { + self.send(InterruptRequest {}.into(), cx).ok(); + } + Kernel::StartingKernel(_task) => { + // NOTE: If we switch to a literal queue instead of chaining on to the task, clear all queued executions + } + _ => {} + } + } + + fn shutdown(&mut self, cx: &mut ViewContext) { + let kernel = std::mem::replace(&mut self.kernel, Kernel::ShuttingDown); + // todo!(): emit event for the runtime panel to remove this session once in shutdown state + + match kernel { + Kernel::RunningKernel(mut kernel) => { + let mut request_tx = kernel.request_tx.clone(); + + cx.spawn(|this, mut cx| async move { + let message: JupyterMessage = ShutdownRequest { restart: false }.into(); + request_tx.try_send(message).ok(); + + // Give the kernel a bit of time to clean up + cx.background_executor().timer(Duration::from_secs(3)).await; + + kernel.process.kill().ok(); + + this.update(&mut cx, |this, cx| { + cx.emit(SessionEvent::Shutdown(this.editor.clone())); + this.clear_outputs(cx); + this.kernel = Kernel::Shutdown; + cx.notify(); + }) + .ok(); + }) + .detach(); + } + Kernel::StartingKernel(_kernel) => { + self.kernel = Kernel::Shutdown; + } + _ => { + self.kernel = Kernel::Shutdown; + } + } + cx.notify(); + } +} + +pub enum SessionEvent { + Shutdown(View), +} + +impl EventEmitter for Session {} + +impl Render for Session { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let mut buttons = vec![]; + + buttons.push( + ButtonLike::new("shutdown") + .child(Label::new("Shutdown")) + .style(ButtonStyle::Subtle) + .on_click(cx.listener(move |session, _, cx| { + session.shutdown(cx); + })), + ); + + let status_text = match &self.kernel { + Kernel::RunningKernel(kernel) => { + buttons.push( + ButtonLike::new("interrupt") + .child(Label::new("Interrupt")) + .style(ButtonStyle::Subtle) + .on_click(cx.listener(move |session, _, cx| { + session.interrupt(cx); + })), + ); + let mut name = self.kernel_specification.name.clone(); + + if let Some(info) = &kernel.kernel_info { + name.push_str(" ("); + name.push_str(&info.language_info.name); + name.push_str(")"); + } + name + } + Kernel::StartingKernel(_) => format!("{} (Starting)", self.kernel_specification.name), + Kernel::ErroredLaunch(err) => { + format!("{} (Error: {})", self.kernel_specification.name, err) + } + Kernel::ShuttingDown => format!("{} (Shutting Down)", self.kernel_specification.name), + Kernel::Shutdown => format!("{} (Shutdown)", self.kernel_specification.name), + }; + + return v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .child(self.kernel.dot()) + .child(Label::new(status_text)), + ) + .child(h_flex().gap_2().children(buttons)); + } +} diff --git a/crates/repl/src/stdio.rs b/crates/repl/src/stdio.rs index e2c9bbc745..38a154e289 100644 --- a/crates/repl/src/stdio.rs +++ b/crates/repl/src/stdio.rs @@ -55,11 +55,11 @@ impl TerminalOutput { pub fn render(&self, cx: &ViewContext) -> AnyElement { let theme = cx.theme(); let buffer_font = ThemeSettings::get_global(cx).buffer_font.family.clone(); - let mut text_runs = self.handler.text_runs.clone(); - text_runs.push(self.handler.current_text_run.clone()); - - let runs = text_runs + let runs = self + .handler + .text_runs .iter() + .chain(Some(&self.handler.current_text_run)) .map(|ansi_run| { let color = terminal_view::terminal_element::convert_color(&ansi_run.fg, theme); let background_color = Some(terminal_view::terminal_element::convert_color( @@ -88,16 +88,15 @@ impl TerminalOutput { impl LineHeight for TerminalOutput { fn num_lines(&self, _cx: &mut WindowContext) -> u8 { - // todo!(): Track this over time with our parser and just return it when needed self.handler.buffer.lines().count() as u8 } } #[derive(Clone)] struct AnsiTextRun { - pub len: usize, - pub fg: alacritty_terminal::vte::ansi::Color, - pub bg: alacritty_terminal::vte::ansi::Color, + len: usize, + fg: alacritty_terminal::vte::ansi::Color, + bg: alacritty_terminal::vte::ansi::Color, } impl AnsiTextRun { diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index f623e5fcdf..3812e83c3c 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -221,7 +221,7 @@ fn init_ui(app_state: Arc, cx: &mut AppContext) -> Result<()> { assistant::init(app_state.fs.clone(), app_state.client.clone(), cx); - repl::init(app_state.fs.clone(), cx); + repl::init(cx); cx.observe_global::({ let languages = app_state.languages.clone(); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 3bd98963f9..8984fba4d3 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -197,6 +197,10 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { cx.spawn(|workspace_handle, mut cx| async move { let assistant_panel = assistant::AssistantPanel::load(workspace_handle.clone(), cx.clone()); + + // todo!(): enable/disable this based on settings + let runtime_panel = repl::RuntimePanel::load(workspace_handle.clone(), cx.clone()); + let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); let outline_panel = OutlinePanel::load(workspace_handle.clone(), cx.clone()); let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone()); @@ -214,6 +218,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { outline_panel, terminal_panel, assistant_panel, + runtime_panel, channels_panel, chat_panel, notification_panel, @@ -222,6 +227,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { outline_panel, terminal_panel, assistant_panel, + runtime_panel, channels_panel, chat_panel, notification_panel, @@ -229,6 +235,7 @@ pub fn initialize_workspace(app_state: Arc, cx: &mut AppContext) { workspace_handle.update(&mut cx, |workspace, cx| { workspace.add_panel(assistant_panel, cx); + workspace.add_panel(runtime_panel, cx); workspace.add_panel(project_panel, cx); workspace.add_panel(outline_panel, cx); workspace.add_panel(terminal_panel, cx); @@ -3188,6 +3195,7 @@ mod tests { outline_panel::init((), cx); terminal_view::init(cx); assistant::init(app_state.fs.clone(), app_state.client.clone(), cx); + repl::init(cx); tasks_ui::init(cx); initialize_workspace(app_state.clone(), cx); app_state