Bring Jupyter to Zed Editing (#12062)

Run any Jupyter kernel in Zed on any buffer (editor):

<img width="1074" alt="image"
src="https://github.com/zed-industries/zed/assets/836375/eac8ed69-d02b-4d46-b379-6186d8f59470">

## TODO

### Lifecycle

* [x] Launch kernels on demand
* [x] Wait for kernel to be started
* [x] Request Kernel info on start
* [x] Show in progress indicator
* [ ] Allow picking kernel (it defaults to first matching language name)
* [ ] Menu for interrupting and shutting down the kernel
* [ ] Drop running kernels once editor is dropped

### Media Outputs

* [x] Render text and tracebacks with ANSI color handling
* [x] Render markdown as text
* [x] Render PNG and JPEG images using an explicit height based on
line-height
* ~~Render SVG~~ -- not happening for this PR due to lack of text in SVG
support
* [ ] Process `update_display_data` message and related `display_id`
* [x] Process `page` data from payloads as outputs
* [ ] Render markdown as, well, rendered markdown -- Note: unsure if we
can get line heights here

### Document

* [x] Select code and run
* [x] Run current line
* [x] Clear previous overlapping runs
* [ ] Support running markdown code blocks
* [ ] Action to export session as notebook or output files
* [ ] Action to clear all outputs
* [ ] Delete outputs when lines are deleted

## Other missing features

The following is a list of missing functionality or expectations that
are out of scope for this PR.

### Python Environments

Detecting python environments should probably be done in a separate PR
in tandem with how they're used with LSP. Users likely want to pick an
environment for their project, whether a virtualenv, conda env, pyenv,
poetry backed virtualenv, or the system. Related issues:

* https://github.com/zed-industries/zed/issues/7646
* https://github.com/zed-industries/zed/issues/7808
* https://github.com/zed-industries/zed/issues/7296

### LSP Integration

* Submit `complete_request` messages for completions to interleave
interactive variables with LSP
* LSP for IPython semantics (`%%timeit`, `!ls`, `get_ipython`, etc.)

## Future release notes

- Run code in any editor, whether it's a script or a markdown document

Release Notes:

- N/A
This commit is contained in:
Kyle Kelley 2024-06-17 10:02:31 -07:00 committed by GitHub
parent d95c424d18
commit 221edfc267
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2117 additions and 24 deletions

222
Cargo.lock generated
View file

@ -86,6 +86,30 @@ dependencies = [
"memchr",
]
[[package]]
name = "alacritty_terminal"
version = "0.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6d1ea4484c8676f295307a4892d478c70ac8da1dbd8c7c10830a504b7f1022f"
dependencies = [
"base64 0.22.0",
"bitflags 2.4.2",
"home",
"libc",
"log",
"miow",
"parking_lot",
"piper",
"polling 3.3.2",
"regex-automata 0.4.5",
"rustix-openpty",
"serde",
"signal-hook",
"unicode-width",
"vte",
"windows-sys 0.48.0",
]
[[package]]
name = "alacritty_terminal"
version = "0.24.1-dev"
@ -424,6 +448,16 @@ dependencies = [
"util",
]
[[package]]
name = "async-attributes"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5"
dependencies = [
"quote",
"syn 1.0.109",
]
[[package]]
name = "async-broadcast"
version = "0.7.0"
@ -487,6 +521,16 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-dispatcher"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c8bff43baa5b0ca8f8bcd7f9338f5d30fbd75236a2aa89130a7c5121a06d6ca"
dependencies = [
"async-task",
"futures-lite 1.13.0",
]
[[package]]
name = "async-executor"
version = "1.5.1"
@ -736,6 +780,7 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62565bb4402e926b29953c785397c6dc0391b7b446e45008b0049eb43cec6f5d"
dependencies = [
"async-attributes",
"async-channel 1.9.0",
"async-global-executor",
"async-io 1.13.0",
@ -838,6 +883,19 @@ dependencies = [
"thiserror",
]
[[package]]
name = "asynchronous-codec"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233"
dependencies = [
"bytes 1.5.0",
"futures-sink",
"futures-util",
"memchr",
"pin-project-lite",
]
[[package]]
name = "atoi"
version = "2.0.0"
@ -1999,9 +2057,9 @@ dependencies = [
[[package]]
name = "chrono"
version = "0.4.31"
version = "0.4.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38"
checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401"
dependencies = [
"android-tzdata",
"iana-time-zone",
@ -2009,7 +2067,7 @@ dependencies = [
"num-traits",
"serde",
"wasm-bindgen",
"windows-targets 0.48.5",
"windows-targets 0.52.5",
]
[[package]]
@ -3340,7 +3398,7 @@ version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30baa043103c9d0c2a57cf537cc2f35623889dc0d405e6c3cccfadbc81c71309"
dependencies = [
"dirs-sys",
"dirs-sys 0.3.7",
]
[[package]]
@ -3349,7 +3407,16 @@ version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys",
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
@ -3373,6 +3440,18 @@ dependencies = [
"winapi",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]]
name = "dirs-sys-next"
version = "0.1.2"
@ -4922,7 +5001,7 @@ dependencies = [
"project",
"rpc",
"settings",
"shellexpand",
"shellexpand 2.1.2",
"signal-hook",
"util",
]
@ -5620,7 +5699,7 @@ dependencies = [
"schemars",
"serde",
"settings",
"shellexpand",
"shellexpand 2.1.2",
"workspace",
]
@ -7071,6 +7150,12 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ordered-float"
version = "2.10.0"
@ -8392,6 +8477,38 @@ dependencies = [
"thiserror",
]
[[package]]
name = "repl"
version = "0.1.0"
dependencies = [
"alacritty_terminal 0.23.0",
"anyhow",
"async-dispatcher",
"base64 0.13.1",
"collections",
"editor",
"env_logger",
"futures 0.3.28",
"gpui",
"http 0.1.0",
"image",
"language",
"log",
"project",
"runtimelib",
"schemars",
"serde",
"serde_json",
"settings",
"smol",
"terminal_view",
"theme",
"ui",
"util",
"uuid",
"workspace",
]
[[package]]
name = "reqwest"
version = "0.11.20"
@ -8637,6 +8754,32 @@ dependencies = [
"zeroize",
]
[[package]]
name = "runtimelib"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a4a788465cf51b7ac8f36e4e4ca3dd26013dcddd5ba8376f98752278244294"
dependencies = [
"anyhow",
"async-dispatcher",
"async-std",
"base64 0.22.0",
"bytes 1.5.0",
"chrono",
"data-encoding",
"dirs 5.0.1",
"futures 0.3.28",
"glob",
"rand 0.8.5",
"ring",
"serde",
"serde_json",
"shellexpand 3.1.0",
"smol",
"uuid",
"zeromq",
]
[[package]]
name = "rust-embed"
version = "8.4.0"
@ -9380,6 +9523,15 @@ dependencies = [
"dirs 4.0.0",
]
[[package]]
name = "shellexpand"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da03fa3b94cc19e3ebfc88c4229c49d8f08cdbd1228870a45f0ffdf84988e14b"
dependencies = [
"dirs 5.0.1",
]
[[package]]
name = "shlex"
version = "1.3.0"
@ -9611,12 +9763,12 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.5.4"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e"
checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c"
dependencies = [
"libc",
"windows-sys 0.48.0",
"windows-sys 0.52.0",
]
[[package]]
@ -10359,7 +10511,7 @@ dependencies = [
"serde",
"serde_json_lenient",
"sha2 0.10.7",
"shellexpand",
"shellexpand 2.1.2",
"util",
]
@ -10433,7 +10585,7 @@ dependencies = [
name = "terminal"
version = "0.1.0"
dependencies = [
"alacritty_terminal",
"alacritty_terminal 0.24.1-dev",
"anyhow",
"collections",
"dirs 4.0.0",
@ -10475,7 +10627,7 @@ dependencies = [
"serde",
"serde_json",
"settings",
"shellexpand",
"shellexpand 2.1.2",
"smol",
"task",
"tasks_ui",
@ -10754,9 +10906,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.32.0"
version = "1.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9"
checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787"
dependencies = [
"backtrace",
"bytes 1.5.0",
@ -10766,7 +10918,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.5.4",
"socket2 0.5.7",
"tokio-macros",
"windows-sys 0.48.0",
]
@ -10784,9 +10936,9 @@ dependencies = [
[[package]]
name = "tokio-macros"
version = "2.1.0"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e"
checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
dependencies = [
"proc-macro2",
"quote",
@ -11576,9 +11728,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.4.1"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79daa5ed5740825c40b389c5e50312b9c86df53fccd33f281df655642b43869d"
checksum = "a183cf7feeba97b4dd1c0d46788634f6221d87fa961b305bed08c851829efcc0"
dependencies = [
"getrandom 0.2.10",
"serde",
@ -12431,7 +12583,7 @@ dependencies = [
"heck 0.4.1",
"proc-macro2",
"quote",
"shellexpand",
"shellexpand 2.1.2",
"syn 2.0.59",
"witx",
]
@ -13334,6 +13486,7 @@ dependencies = [
"quick_action_bar",
"recent_projects",
"release_channel",
"repl",
"rope",
"search",
"serde",
@ -13620,6 +13773,33 @@ dependencies = [
"syn 2.0.59",
]
[[package]]
name = "zeromq"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb0560d00172817b7f7c2265060783519c475702ae290b154115ca75e976d4d0"
dependencies = [
"async-dispatcher",
"async-std",
"async-trait",
"asynchronous-codec",
"bytes 1.5.0",
"crossbeam-queue",
"dashmap",
"futures-channel",
"futures-io",
"futures-task",
"futures-util",
"log",
"num-traits",
"once_cell",
"parking_lot",
"rand 0.8.5",
"regex",
"thiserror",
"uuid",
]
[[package]]
name = "zstd"
version = "0.11.2+zstd.1.5.2"

View file

@ -77,6 +77,7 @@ members = [
"crates/refineable/derive_refineable",
"crates/release_channel",
"crates/dev_server_projects",
"crates/repl",
"crates/rich_text",
"crates/rope",
"crates/rpc",
@ -227,6 +228,7 @@ quick_action_bar = { path = "crates/quick_action_bar" }
recent_projects = { path = "crates/recent_projects" }
release_channel = { path = "crates/release_channel" }
dev_server_projects = { path = "crates/dev_server_projects" }
repl = { path = "crates/repl" }
rich_text = { path = "crates/rich_text" }
rope = { path = "crates/rope" }
rpc = { path = "crates/rpc" }
@ -264,10 +266,12 @@ workspace = { path = "crates/workspace" }
zed = { path = "crates/zed" }
zed_actions = { path = "crates/zed_actions" }
alacritty_terminal = "0.23"
anyhow = "1.0.57"
any_vec = "0.13"
ashpd = "0.8.0"
async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
async-dispatcher = { version = "0.1"}
async-fs = "1.6"
async-recursion = "1.0.0"
async-tar = "0.4.2"
@ -301,6 +305,7 @@ heed = { version = "0.20.1", features = ["read-txn-no-tls"] }
hex = "0.4.3"
html5ever = "0.27.0"
ignore = "0.4.22"
image = "0.23"
indexmap = { version = "1.6.2", features = ["serde"] }
indoc = "1"
# We explicitly disable http2 support in isahc.
@ -333,6 +338,7 @@ rand = "0.8.5"
refineable = { path = "./crates/refineable" }
regex = "1.5"
repair_json = "0.1.0"
runtimelib = { version="0.12", default-features = false, features = ["async-dispatcher-runtime"] }
rusqlite = { version = "0.29.0", features = ["blob", "array", "modern_sqlite"] }
rust-embed = { version = "8.4", features = ["include-exclude"] }
schemars = "0.8"

View file

@ -379,6 +379,7 @@ fn metadata_from_extension_and_version(
pub fn convert_time_to_chrono(time: time::PrimitiveDateTime) -> chrono::DateTime<Utc> {
chrono::DateTime::from_naive_utc_and_offset(
#[allow(deprecated)]
chrono::NaiveDateTime::from_timestamp_opt(time.assume_utc().unix_timestamp(), 0).unwrap(),
Utc,
)

View file

@ -25,14 +25,16 @@ use rand::rngs::StdRng;
/// for spawning background tasks.
#[derive(Clone)]
pub struct BackgroundExecutor {
dispatcher: Arc<dyn PlatformDispatcher>,
#[doc(hidden)]
pub dispatcher: Arc<dyn PlatformDispatcher>,
}
/// A pointer to the executor that is currently running,
/// for spawning tasks on the main thread.
#[derive(Clone)]
pub struct ForegroundExecutor {
dispatcher: Arc<dyn PlatformDispatcher>,
#[doc(hidden)]
pub dispatcher: Arc<dyn PlatformDispatcher>,
not_send: PhantomData<Rc<()>>,
}

View file

@ -117,6 +117,7 @@ impl ToPoint for Anchor {
pub trait AnchorRangeExt {
fn cmp(&self, b: &Range<Anchor>, buffer: &MultiBufferSnapshot) -> Ordering;
fn overlaps(&self, b: &Range<Anchor>, buffer: &MultiBufferSnapshot) -> bool;
fn to_offset(&self, content: &MultiBufferSnapshot) -> Range<usize>;
fn to_point(&self, content: &MultiBufferSnapshot) -> Range<Point>;
}
@ -129,6 +130,14 @@ impl AnchorRangeExt for Range<Anchor> {
}
}
fn overlaps(&self, other: &Range<Anchor>, buffer: &MultiBufferSnapshot) -> bool {
let start_cmp = self.start.cmp(&other.start, buffer);
let end_cmp = self.end.cmp(&other.end, buffer);
(start_cmp == Ordering::Less || start_cmp == Ordering::Equal)
&& (end_cmp == Ordering::Greater || end_cmp == Ordering::Equal)
}
fn to_offset(&self, content: &MultiBufferSnapshot) -> Range<usize> {
self.start.to_offset(content)..self.end.to_offset(content)
}

49
crates/repl/Cargo.toml Normal file
View file

@ -0,0 +1,49 @@
[package]
name = "repl"
version = "0.1.0"
edition = "2021"
publish = false
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/repl.rs"
doctest = false
[dependencies]
anyhow.workspace = true
alacritty_terminal.workspace = true
async-dispatcher.workspace = true
base64.workspace = true
collections.workspace = true
editor.workspace = true
gpui.workspace = true
futures.workspace = true
image.workspace = true
language.workspace = true
log.workspace = true
project.workspace = true
runtimelib.workspace = true
schemars.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
smol.workspace = true
theme.workspace = true
terminal_view.workspace = true
ui.workspace = true
uuid.workspace = true
workspace.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }
env_logger.workspace = true
gpui = { workspace = true, features = ["test-support"] }
language = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
theme = { workspace = true, features = ["test-support"] }
util = { workspace = true, features = ["test-support"] }
http = { workspace = true, features = ["test-support"] }

479
crates/repl/src/outputs.rs Normal file
View file

@ -0,0 +1,479 @@
use std::sync::Arc;
use crate::stdio::TerminalOutput;
use anyhow::Result;
use gpui::{img, AnyElement, FontWeight, ImageData, Render, View};
use runtimelib::datatable::TableSchema;
use runtimelib::media::datatable::TabularDataResource;
use runtimelib::{ExecutionState, JupyterMessageContent, MimeBundle, MimeType};
use serde_json::Value;
use ui::{div, prelude::*, v_flex, IntoElement, Styled, ViewContext};
// Given these outputs are destined for the editor with the block decorations API, all of them must report
// how many lines they will take up in the editor.
pub trait LineHeight: Sized {
fn num_lines(&self, cx: &mut WindowContext) -> u8;
}
// When deciding what to render from a collection of mediatypes, we need to rank them in order of importance
fn rank_mime_type(mimetype: &MimeType) -> usize {
match mimetype {
MimeType::DataTable(_) => 6,
MimeType::Png(_) => 4,
MimeType::Jpeg(_) => 3,
MimeType::Markdown(_) => 2,
MimeType::Plain(_) => 1,
// All other media types are not supported in Zed at this time
_ => 0,
}
}
/// ImageView renders an image inline in an editor, adapting to the line height to fit the image.
pub struct ImageView {
height: u32,
width: u32,
image: Arc<ImageData>,
}
impl ImageView {
fn render(&self, cx: &ViewContext<ExecutionView>) -> AnyElement {
let line_height = cx.line_height();
let (height, width) = if self.height as f32 / line_height.0 == u8::MAX as f32 {
let height = u8::MAX as f32 * line_height.0;
let width = self.width as f32 * height / self.height as f32;
(height, width)
} else {
(self.height as f32, self.width as f32)
};
let image = self.image.clone();
div()
.h(Pixels(height))
.w(Pixels(width))
.child(img(image))
.into_any_element()
}
fn from(base64_encoded_data: &str) -> Result<Self> {
let bytes = base64::decode(base64_encoded_data)?;
let format = image::guess_format(&bytes)?;
let data = image::load_from_memory_with_format(&bytes, format)?.into_bgra8();
let height = data.height();
let width = data.width();
let gpui_image_data = ImageData::new(data);
return Ok(ImageView {
height,
width,
image: Arc::new(gpui_image_data),
});
}
}
impl LineHeight for ImageView {
fn num_lines(&self, cx: &mut WindowContext) -> u8 {
let line_height = cx.line_height();
let lines = self.height as f32 / line_height.0;
if lines > u8::MAX as f32 {
return u8::MAX;
}
lines as u8
}
}
/// TableView renders a static table inline in a buffer.
/// It uses the https://specs.frictionlessdata.io/tabular-data-resource/ specification for data interchange.
pub struct TableView {
pub table: TabularDataResource,
}
impl TableView {
pub fn render(&self, cx: &ViewContext<ExecutionView>) -> AnyElement {
let data = match &self.table.data {
Some(data) => data,
None => return div().into_any_element(),
};
// todo!(): compute the width of each column by finding the widest cell in each column
let mut headings = serde_json::Map::new();
for field in &self.table.schema.fields {
headings.insert(field.name.clone(), Value::String(field.name.clone()));
}
let header = self.render_row(&self.table.schema, true, &Value::Object(headings), cx);
let body = data
.iter()
.map(|row| self.render_row(&self.table.schema, false, &row, cx));
v_flex()
.w_full()
.child(header)
.children(body)
.into_any_element()
}
pub fn render_row(
&self,
schema: &TableSchema,
is_header: bool,
row: &Value,
cx: &ViewContext<ExecutionView>,
) -> AnyElement {
let theme = cx.theme();
let row_cells = schema
.fields
.iter()
.map(|field| {
let container = match field.field_type {
runtimelib::datatable::FieldType::String => div(),
runtimelib::datatable::FieldType::Number
| runtimelib::datatable::FieldType::Integer
| runtimelib::datatable::FieldType::Date
| runtimelib::datatable::FieldType::Time
| runtimelib::datatable::FieldType::Datetime
| runtimelib::datatable::FieldType::Year
| runtimelib::datatable::FieldType::Duration
| runtimelib::datatable::FieldType::Yearmonth => v_flex().items_end(),
_ => div(),
};
let value = match row.get(&field.name) {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
Some(Value::Bool(b)) => b.to_string(),
Some(Value::Array(arr)) => format!("{:?}", arr),
Some(Value::Object(obj)) => format!("{:?}", obj),
Some(Value::Null) | None => String::new(),
};
let mut cell = container
.w_full()
.child(value)
.px_2()
.py_1()
.border_color(theme.colors().border);
if is_header {
cell = cell.border_2().bg(theme.colors().border_focused)
} else {
cell = cell.border_1()
}
cell
})
.collect::<Vec<_>>();
h_flex().children(row_cells).into_any_element()
}
}
impl LineHeight for TableView {
fn num_lines(&self, _cx: &mut WindowContext) -> u8 {
let num_rows = match &self.table.data {
Some(data) => data.len(),
// We don't support Path based data sources
None => 0,
};
// Given that each cell has both `py_1` and a border, we have to estimate
// a reasonable size to add on, then round up.
let row_heights = (num_rows as f32 * 1.2) + 1.0;
(row_heights as u8).saturating_add(2) // Header + spacing
}
}
// Userspace error from the kernel
pub struct ErrorView {
pub ename: String,
pub evalue: String,
pub traceback: TerminalOutput,
}
impl ErrorView {
fn render(&self, cx: &ViewContext<ExecutionView>) -> Option<AnyElement> {
let theme = cx.theme();
let colors = cx.theme().colors();
Some(
v_flex()
.w_full()
.bg(colors.background)
.p_4()
.border_l_1()
.border_color(theme.status().error_border)
.child(
h_flex()
.font_weight(FontWeight::BOLD)
.child(format!("{}: {}", self.ename, self.evalue)),
)
.child(self.traceback.render(cx))
.into_any_element(),
)
}
}
impl LineHeight for ErrorView {
fn num_lines(&self, cx: &mut WindowContext) -> u8 {
let mut height: u8 = 0;
height = height.saturating_add(self.ename.lines().count() as u8);
height = height.saturating_add(self.evalue.lines().count() as u8);
height = height.saturating_add(self.traceback.num_lines(cx));
height
}
}
pub enum OutputType {
Plain(TerminalOutput),
Stream(TerminalOutput),
Image(ImageView),
ErrorOutput(ErrorView),
Message(String),
Table(TableView),
ClearOutputWaitMarker,
}
impl OutputType {
fn render(&self, cx: &ViewContext<ExecutionView>) -> Option<AnyElement> {
let el = match self {
// Note: in typical frontends we would show the execute_result.execution_count
// Here we can just handle either
Self::Plain(stdio) => Some(stdio.render(cx)),
// Self::Markdown(markdown) => Some(markdown.render(theme)),
Self::Stream(stdio) => Some(stdio.render(cx)),
Self::Image(image) => Some(image.render(cx)),
Self::Message(message) => Some(div().child(message.clone()).into_any_element()),
Self::Table(table) => Some(table.render(cx)),
Self::ErrorOutput(error_view) => error_view.render(cx),
Self::ClearOutputWaitMarker => None,
};
el
}
}
impl LineHeight for OutputType {
/// Calculates the expected number of lines
fn num_lines(&self, cx: &mut WindowContext) -> u8 {
match self {
Self::Plain(stdio) => stdio.num_lines(cx),
Self::Stream(stdio) => stdio.num_lines(cx),
Self::Image(image) => image.num_lines(cx),
Self::Message(message) => message.lines().count() as u8,
Self::Table(table) => table.num_lines(cx),
Self::ErrorOutput(error_view) => error_view.num_lines(cx),
Self::ClearOutputWaitMarker => 0,
}
}
}
impl From<&MimeBundle> for OutputType {
fn from(data: &MimeBundle) -> Self {
match data.richest(rank_mime_type) {
Some(MimeType::Plain(text)) => OutputType::Plain(TerminalOutput::from(text)),
Some(MimeType::Markdown(text)) => OutputType::Plain(TerminalOutput::from(text)),
Some(MimeType::Png(data)) | Some(MimeType::Jpeg(data)) => match ImageView::from(data) {
Ok(view) => OutputType::Image(view),
Err(error) => OutputType::Message(format!("Failed to load image: {}", error)),
},
Some(MimeType::DataTable(data)) => OutputType::Table(TableView {
table: data.clone(),
}),
// Any other media types are not supported
_ => OutputType::Message("Unsupported media type".to_string()),
}
}
}
#[derive(Default)]
pub enum ExecutionStatus {
#[default]
Unknown,
#[allow(unused)]
ConnectingToKernel,
Executing,
Finished,
}
pub struct ExecutionView {
pub outputs: Vec<OutputType>,
pub status: ExecutionStatus,
}
impl ExecutionView {
pub fn new(_cx: &mut ViewContext<Self>) -> Self {
Self {
outputs: Default::default(),
status: ExecutionStatus::Unknown,
}
}
/// Accept a Jupyter message belonging to this execution
pub fn push_message(&mut self, message: &JupyterMessageContent, cx: &mut ViewContext<Self>) {
let output: OutputType = match message {
JupyterMessageContent::ExecuteResult(result) => (&result.data).into(),
JupyterMessageContent::DisplayData(result) => (&result.data).into(),
JupyterMessageContent::StreamContent(result) => {
// Previous stream data will combine together, handling colors, carriage returns, etc
if let Some(new_terminal) = self.apply_terminal_text(&result.text) {
new_terminal
} else {
cx.notify();
return;
}
}
JupyterMessageContent::ErrorOutput(result) => {
let mut terminal = TerminalOutput::new();
terminal.append_text(&result.traceback.join("\n"));
OutputType::ErrorOutput(ErrorView {
ename: result.ename.clone(),
evalue: result.evalue.clone(),
traceback: terminal,
})
}
JupyterMessageContent::ExecuteReply(reply) => {
for payload in reply.payload.iter() {
match payload {
// Pager data comes in via `?` at the end of a statement in Python, used for showing documentation.
// Some UI will show this as a popup. For ease of implementation, it's included as an output here.
runtimelib::Payload::Page { data, .. } => {
let output: OutputType = (data).into();
self.outputs.push(output);
}
// Set next input adds text to the next cell. Not required to support.
// However, this could be implemented by
// 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!(),
//
// runtimelib::Payload::AskExit { keepkernel } => todo!(),
_ => {}
}
}
cx.notify();
return;
}
JupyterMessageContent::ClearOutput(options) => {
if !options.wait {
self.outputs.clear();
cx.notify();
return;
}
// Create a marker to clear the output after we get in a new output
OutputType::ClearOutputWaitMarker
}
JupyterMessageContent::Status(status) => {
match status.execution_state {
ExecutionState::Busy => {
self.status = ExecutionStatus::Executing;
}
ExecutionState::Idle => self.status = ExecutionStatus::Finished,
}
cx.notify();
return;
}
_msg => {
return;
}
};
// Check for a clear output marker as the previous output, so we can clear it out
if let Some(OutputType::ClearOutputWaitMarker) = self.outputs.last() {
self.outputs.clear();
}
self.outputs.push(output);
cx.notify();
}
fn apply_terminal_text(&mut self, text: &str) -> Option<OutputType> {
if let Some(last_output) = self.outputs.last_mut() {
match last_output {
OutputType::Stream(last_stream) => {
last_stream.append_text(text);
// Don't need to add a new output, we already have a terminal output
return None;
}
// Edge case note: a clear output marker
OutputType::ClearOutputWaitMarker => {
// Edge case note: a clear output marker is handled by the caller
// since we will return a new output at the end here as a new terminal output
}
// A different output type is "in the way", so we need to create a new output,
// which is the same as having no prior output
_ => {}
}
}
let mut new_terminal = TerminalOutput::new();
new_terminal.append_text(text);
Some(OutputType::Stream(new_terminal))
}
pub fn set_status(&mut self, status: ExecutionStatus, cx: &mut ViewContext<Self>) {
self.status = status;
cx.notify();
}
}
impl Render for ExecutionView {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
if self.outputs.len() == 0 {
match self.status {
ExecutionStatus::ConnectingToKernel => {
return div().child("Connecting to kernel...").into_any_element()
}
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(),
}
}
div()
.w_full()
.children(self.outputs.iter().filter_map(|output| output.render(cx)))
.into_any_element()
}
}
impl LineHeight for ExecutionView {
fn num_lines(&self, cx: &mut WindowContext) -> u8 {
if self.outputs.is_empty() {
return 1; // For the status message if outputs are not there
}
self.outputs
.iter()
.map(|output| output.num_lines(cx))
.fold(0, |acc, additional_height| {
acc.saturating_add(additional_height)
})
}
}
impl LineHeight for View<ExecutionView> {
fn num_lines(&self, cx: &mut WindowContext) -> u8 {
self.update(cx, |execution_view, cx| execution_view.num_lines(cx))
}
}

571
crates/repl/src/repl.rs Normal file
View file

@ -0,0 +1,571 @@
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 std::{sync::Arc, time::Duration};
use theme::{ActiveTheme, ThemeSettings};
use ui::prelude::*;
use workspace::Workspace;
mod outputs;
// mod runtime_panel;
mod runtime_settings;
mod runtimes;
mod stdio;
use runtimes::{get_runtime_specifications, Request, RunningKernel, RuntimeSpecification};
actions!(repl, [Run]);
#[derive(Clone)]
pub struct RuntimeManagerGlobal(Model<RuntimeManager>);
impl Global for RuntimeManagerGlobal {}
pub fn zed_dispatcher(cx: &mut AppContext) -> impl Dispatcher {
struct ZedDispatcher {
dispatcher: Arc<dyn PlatformDispatcher>,
}
// PlatformDispatcher is _super_ close to the same interface we put in
// async-dispatcher, except for the task label in dispatch. Later we should
// just make that consistent so we have this dispatcher ready to go for
// other crates in Zed.
impl Dispatcher for ZedDispatcher {
fn dispatch(&self, runnable: Runnable) {
self.dispatcher.dispatch(runnable, None)
}
fn dispatch_after(&self, duration: Duration, runnable: Runnable) {
self.dispatcher.dispatch_after(duration, runnable);
}
}
ZedDispatcher {
dispatcher: cx.background_executor().dispatcher.clone(),
}
}
pub fn init(fs: Arc<dyn Fs>, 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>| {
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<dyn Fs>, 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<dyn Fs>, cx: &mut AppContext) {
cx.observe_global::<SettingsStore>(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<Task<()>>),
FailedLaunch,
}
// Per workspace
pub struct RuntimeManager {
fs: Arc<dyn Fs>,
runtime_specifications: Vec<RuntimeSpecification>,
instances: HashMap<EntityId, Kernel>,
editors: HashMap<WeakView<Editor>, 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<String, View<ExecutionView>>,
}
#[derive(Debug, Clone)]
struct EditorRuntimeState {
blocks: Vec<EditorRuntimeBlock>,
// 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<Anchor>,
_execution_id: String,
block_id: BlockId,
_execution_view: View<ExecutionView>,
}
impl RuntimeManager {
pub fn new(fs: Arc<dyn Fs>, _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<str>,
cx: &mut ModelContext<Self>,
) -> Task<Result<UnboundedSender<Request>>> {
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<str>,
cx: &mut ModelContext<Self>,
) -> Task<Result<RunningKernel>> {
// 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<str>,
code: String,
cx: &mut ModelContext<Self>,
) -> impl Future<Output = Result<mpsc::UnboundedReceiver<JupyterMessageContent>>> {
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<Model<Self>> {
cx.try_global::<RuntimeManagerGlobal>()
.map(|runtime_manager| runtime_manager.0.clone())
}
pub fn set_global(runtime_manager: Model<Self>, 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::<RuntimeManagerGlobal>();
}
}
}
pub fn get_active_editor(
workspace: &mut Workspace,
cx: &mut ViewContext<Workspace>,
) -> Option<View<Editor>> {
workspace
.active_item(cx)
.and_then(|item| item.act_as::<Editor>(cx))
}
// Gets the active selection in the editor or the current line
pub fn selection(editor: View<Editor>, cx: &mut ViewContext<Workspace>) -> Range<Anchor> {
let editor = editor.read(cx);
let selection = editor.selections.newest::<usize>(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<Workspace>) {
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::<String>();
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<BlockId> = 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<ExecutionView>) -> 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)
}

View file

@ -0,0 +1,66 @@
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<bool>,
/// Where to dock the runtimes panel.
///
/// Default: `right`
dock: Option<RuntimesDockPosition>,
}
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<Self::FileContent>,
_cx: &mut gpui::AppContext,
) -> anyhow::Result<Self>
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)
}
}

329
crates/repl/src/runtimes.rs Normal file
View file

@ -0,0 +1,329 @@
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<JupyterMessageContent>,
}
#[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<Command> {
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<Request>,
}
impl RunningKernel {
pub async fn new(
runtime: RuntimeSpecification,
entity_id: EntityId,
fs: Arc<dyn Fs>,
cx: AsyncAppContext,
) -> anyhow::Result<Self> {
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<HashMap<String, mpsc::UnboundedSender<JupyterMessageContent>>>,
> = Default::default();
let (request_tx, mut request_rx) = mpsc::unbounded::<Request>();
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<dyn Fs>,
) -> anyhow::Result<RuntimeSpecification> {
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::<JupyterKernelspec>(&spec)?;
Ok(RuntimeSpecification {
name: kernel_name,
path,
kernelspec: spec,
})
}
/// Read a directory of kernelspec directories
async fn read_kernels_dir(
path: PathBuf,
fs: Arc<dyn Fs>,
) -> anyhow::Result<Vec<RuntimeSpecification>> {
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<dyn Fs>,
) -> anyhow::Result<Vec<RuntimeSpecification>> {
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::<Vec<_>>();
let kernel_dirs = futures::future::join_all(kernel_dirs).await;
let kernel_dirs = kernel_dirs
.into_iter()
.filter_map(Result::ok)
.flatten()
.collect::<Vec<_>>();
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<_>>(),
vec!["deno", "python"]
);
}
}

394
crates/repl/src/stdio.rs Normal file
View file

@ -0,0 +1,394 @@
use crate::outputs::{ExecutionView, LineHeight};
use alacritty_terminal::vte::{
ansi::{Attr, Color, NamedColor, Rgb},
Params, ParamsIter, Parser, Perform,
};
use core::iter;
use gpui::{font, prelude::*, AnyElement, StyledText, TextRun};
use settings::Settings as _;
use theme::ThemeSettings;
use ui::{div, prelude::*, IntoElement, ViewContext, WindowContext};
/// Implements the most basic of terminal output for use by Jupyter outputs
/// whether:
///
/// * stdout
/// * stderr
/// * text/plain
/// * traceback from an error output
///
/// Ideally, we would instead use alacritty::vte::Processor to collect the
/// output and then render up to u8::MAX lines of text. However, it's likely
/// overkill for 95% of outputs.
///
/// Instead, this implementation handles:
///
/// * ANSI color codes (background, foreground), including 256 color
/// * Carriage returns/line feeds
///
/// There is no support for cursor movement, clearing the screen, and other text styles
pub struct TerminalOutput {
parser: Parser,
handler: TerminalHandler,
}
impl TerminalOutput {
pub fn new() -> Self {
Self {
parser: Parser::new(),
handler: TerminalHandler::new(),
}
}
pub fn from(text: &str) -> Self {
let mut output = Self::new();
output.append_text(text);
output
}
pub fn append_text(&mut self, text: &str) {
for byte in text.as_bytes() {
self.parser.advance(&mut self.handler, *byte);
}
}
pub fn render(&self, cx: &ViewContext<ExecutionView>) -> 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
.iter()
.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(
&ansi_run.bg,
theme,
));
TextRun {
len: ansi_run.len,
color,
background_color,
underline: Default::default(),
font: font(buffer_font.clone()),
strikethrough: None,
}
})
.collect::<Vec<TextRun>>();
let text = StyledText::new(self.handler.buffer.trim_end().to_string()).with_runs(runs);
div()
.font_family(buffer_font)
.child(text)
.into_any_element()
}
}
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,
}
impl AnsiTextRun {
fn default() -> Self {
Self {
len: 0,
fg: Color::Named(NamedColor::Foreground),
bg: Color::Named(NamedColor::Background),
}
}
}
struct TerminalHandler {
text_runs: Vec<AnsiTextRun>,
current_text_run: AnsiTextRun,
buffer: String,
}
impl TerminalHandler {
fn new() -> Self {
Self {
text_runs: Vec::new(),
current_text_run: AnsiTextRun {
len: 0,
fg: Color::Named(NamedColor::Foreground),
bg: Color::Named(NamedColor::Background),
},
buffer: String::new(),
}
}
fn add_text(&mut self, c: char) {
self.buffer.push(c);
self.current_text_run.len += 1;
}
fn reset(&mut self) {
if self.current_text_run.len > 0 {
self.text_runs.push(self.current_text_run.clone());
}
self.current_text_run = AnsiTextRun::default();
}
fn terminal_attribute(&mut self, attr: Attr) {
// println!("[terminal_attribute] attr={:?}", attr);
if Attr::Reset == attr {
self.reset();
return;
}
if self.current_text_run.len > 0 {
self.text_runs.push(self.current_text_run.clone());
}
let mut text_run = AnsiTextRun {
len: 0,
fg: self.current_text_run.fg,
bg: self.current_text_run.bg,
};
match attr {
Attr::Foreground(color) => text_run.fg = color,
Attr::Background(color) => text_run.bg = color,
_ => {}
}
self.current_text_run = text_run;
}
fn process_carriage_return(&mut self) {
// Find last carriage return's position
let last_cr = self.buffer.rfind('\r').unwrap_or(0);
self.buffer = self.buffer.chars().take(last_cr).collect();
// First work through our current text run
let mut total_len = self.current_text_run.len;
if total_len > last_cr {
// We are in the current text run
self.current_text_run.len = self.current_text_run.len - last_cr;
} else {
let mut last_cr_run = 0;
// Find the last run before the last carriage return
for (i, run) in self.text_runs.iter().enumerate() {
total_len += run.len;
if total_len > last_cr {
last_cr_run = i;
break;
}
}
self.text_runs = self.text_runs[..last_cr_run].to_vec();
self.current_text_run = self.text_runs.pop().unwrap_or(AnsiTextRun::default());
}
self.buffer.push('\r');
self.current_text_run.len += 1;
}
}
impl Perform for TerminalHandler {
fn print(&mut self, c: char) {
// println!("[print] c={:?}", c);
self.add_text(c);
}
fn execute(&mut self, byte: u8) {
match byte {
b'\n' => {
self.add_text('\n');
}
b'\r' => {
self.process_carriage_return();
}
_ => {
// Format as hex
println!("[execute] byte={:02x}", byte);
}
}
}
fn hook(&mut self, _params: &Params, _intermediates: &[u8], _ignore: bool, _c: char) {
// noop
// println!(
// "[hook] params={:?}, intermediates={:?}, c={:?}",
// _params, _intermediates, _c
// );
}
fn put(&mut self, _byte: u8) {
// noop
// println!("[put] byte={:02x}", _byte);
}
fn unhook(&mut self) {
// noop
}
fn osc_dispatch(&mut self, _params: &[&[u8]], _bell_terminated: bool) {
// noop
// println!("[osc_dispatch] params={:?}", _params);
}
fn csi_dispatch(
&mut self,
params: &alacritty_terminal::vte::Params,
intermediates: &[u8],
_ignore: bool,
action: char,
) {
// println!(
// "[csi_dispatch] action={:?}, params={:?}, intermediates={:?}",
// action, params, intermediates
// );
let mut params_iter = params.iter();
// Collect colors
match (action, intermediates) {
('m', []) => {
if params.is_empty() {
self.terminal_attribute(Attr::Reset);
} else {
for attr in attrs_from_sgr_parameters(&mut params_iter) {
match attr {
Some(attr) => self.terminal_attribute(attr),
None => return,
}
}
}
}
_ => {}
}
}
fn esc_dispatch(&mut self, _intermediates: &[u8], _ignore: bool, _byte: u8) {
// noop
// println!(
// "[esc_dispatch] intermediates={:?}, byte={:?}",
// _intermediates, _byte
// );
}
}
// The following was pulled from vte::ansi
#[inline]
fn attrs_from_sgr_parameters(params: &mut ParamsIter<'_>) -> Vec<Option<Attr>> {
let mut attrs = Vec::with_capacity(params.size_hint().0);
while let Some(param) = params.next() {
let attr = match param {
[0] => Some(Attr::Reset),
[1] => Some(Attr::Bold),
[2] => Some(Attr::Dim),
[3] => Some(Attr::Italic),
[4, 0] => Some(Attr::CancelUnderline),
[4, 2] => Some(Attr::DoubleUnderline),
[4, 3] => Some(Attr::Undercurl),
[4, 4] => Some(Attr::DottedUnderline),
[4, 5] => Some(Attr::DashedUnderline),
[4, ..] => Some(Attr::Underline),
[5] => Some(Attr::BlinkSlow),
[6] => Some(Attr::BlinkFast),
[7] => Some(Attr::Reverse),
[8] => Some(Attr::Hidden),
[9] => Some(Attr::Strike),
[21] => Some(Attr::CancelBold),
[22] => Some(Attr::CancelBoldDim),
[23] => Some(Attr::CancelItalic),
[24] => Some(Attr::CancelUnderline),
[25] => Some(Attr::CancelBlink),
[27] => Some(Attr::CancelReverse),
[28] => Some(Attr::CancelHidden),
[29] => Some(Attr::CancelStrike),
[30] => Some(Attr::Foreground(Color::Named(NamedColor::Black))),
[31] => Some(Attr::Foreground(Color::Named(NamedColor::Red))),
[32] => Some(Attr::Foreground(Color::Named(NamedColor::Green))),
[33] => Some(Attr::Foreground(Color::Named(NamedColor::Yellow))),
[34] => Some(Attr::Foreground(Color::Named(NamedColor::Blue))),
[35] => Some(Attr::Foreground(Color::Named(NamedColor::Magenta))),
[36] => Some(Attr::Foreground(Color::Named(NamedColor::Cyan))),
[37] => Some(Attr::Foreground(Color::Named(NamedColor::White))),
[38] => {
let mut iter = params.map(|param| param[0]);
parse_sgr_color(&mut iter).map(Attr::Foreground)
}
[38, params @ ..] => handle_colon_rgb(params).map(Attr::Foreground),
[39] => Some(Attr::Foreground(Color::Named(NamedColor::Foreground))),
[40] => Some(Attr::Background(Color::Named(NamedColor::Black))),
[41] => Some(Attr::Background(Color::Named(NamedColor::Red))),
[42] => Some(Attr::Background(Color::Named(NamedColor::Green))),
[43] => Some(Attr::Background(Color::Named(NamedColor::Yellow))),
[44] => Some(Attr::Background(Color::Named(NamedColor::Blue))),
[45] => Some(Attr::Background(Color::Named(NamedColor::Magenta))),
[46] => Some(Attr::Background(Color::Named(NamedColor::Cyan))),
[47] => Some(Attr::Background(Color::Named(NamedColor::White))),
[48] => {
let mut iter = params.map(|param| param[0]);
parse_sgr_color(&mut iter).map(Attr::Background)
}
[48, params @ ..] => handle_colon_rgb(params).map(Attr::Background),
[49] => Some(Attr::Background(Color::Named(NamedColor::Background))),
[58] => {
let mut iter = params.map(|param| param[0]);
parse_sgr_color(&mut iter).map(|color| Attr::UnderlineColor(Some(color)))
}
[58, params @ ..] => {
handle_colon_rgb(params).map(|color| Attr::UnderlineColor(Some(color)))
}
[59] => Some(Attr::UnderlineColor(None)),
[90] => Some(Attr::Foreground(Color::Named(NamedColor::BrightBlack))),
[91] => Some(Attr::Foreground(Color::Named(NamedColor::BrightRed))),
[92] => Some(Attr::Foreground(Color::Named(NamedColor::BrightGreen))),
[93] => Some(Attr::Foreground(Color::Named(NamedColor::BrightYellow))),
[94] => Some(Attr::Foreground(Color::Named(NamedColor::BrightBlue))),
[95] => Some(Attr::Foreground(Color::Named(NamedColor::BrightMagenta))),
[96] => Some(Attr::Foreground(Color::Named(NamedColor::BrightCyan))),
[97] => Some(Attr::Foreground(Color::Named(NamedColor::BrightWhite))),
[100] => Some(Attr::Background(Color::Named(NamedColor::BrightBlack))),
[101] => Some(Attr::Background(Color::Named(NamedColor::BrightRed))),
[102] => Some(Attr::Background(Color::Named(NamedColor::BrightGreen))),
[103] => Some(Attr::Background(Color::Named(NamedColor::BrightYellow))),
[104] => Some(Attr::Background(Color::Named(NamedColor::BrightBlue))),
[105] => Some(Attr::Background(Color::Named(NamedColor::BrightMagenta))),
[106] => Some(Attr::Background(Color::Named(NamedColor::BrightCyan))),
[107] => Some(Attr::Background(Color::Named(NamedColor::BrightWhite))),
_ => None,
};
attrs.push(attr);
}
attrs
}
/// Handle colon separated rgb color escape sequence.
#[inline]
fn handle_colon_rgb(params: &[u16]) -> Option<Color> {
let rgb_start = if params.len() > 4 { 2 } else { 1 };
let rgb_iter = params[rgb_start..].iter().copied();
let mut iter = iter::once(params[0]).chain(rgb_iter);
parse_sgr_color(&mut iter)
}
/// Parse a color specifier from list of attributes.
fn parse_sgr_color(params: &mut dyn Iterator<Item = u16>) -> Option<Color> {
match params.next() {
Some(2) => Some(Color::Spec(Rgb {
r: u8::try_from(params.next()?).ok()?,
g: u8::try_from(params.next()?).ok()?,
b: u8::try_from(params.next()?).ok()?,
})),
Some(5) => Some(Color::Indexed(u8::try_from(params.next()?).ok()?)),
_ => None,
}
}

View file

@ -1041,7 +1041,7 @@ fn to_highlighted_range_lines(
}
/// Converts a 2, 8, or 24 bit color ANSI color to the GPUI equivalent.
fn convert_color(fg: &terminal::alacritty_terminal::vte::ansi::Color, theme: &Theme) -> Hsla {
pub fn convert_color(fg: &terminal::alacritty_terminal::vte::ansi::Color, theme: &Theme) -> Hsla {
let colors = theme.colors();
match fg {
// Named and theme defined colors

View file

@ -268,9 +268,11 @@ mod tests {
#[test]
fn test_format_distance() {
let date = DateTimeType::Naive(
#[allow(deprecated)]
NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
);
let base_date = DateTimeType::Naive(
#[allow(deprecated)]
NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
);
@ -283,9 +285,11 @@ mod tests {
#[test]
fn test_format_distance_with_suffix() {
let date = DateTimeType::Naive(
#[allow(deprecated)]
NaiveDateTime::from_timestamp_opt(9600, 0).expect("Invalid NaiveDateTime for date"),
);
let base_date = DateTimeType::Naive(
#[allow(deprecated)]
NaiveDateTime::from_timestamp_opt(0, 0).expect("Invalid NaiveDateTime for base_date"),
);

View file

@ -78,6 +78,7 @@ quick_action_bar.workspace = true
recent_projects.workspace = true
dev_server_projects.workspace = true
release_channel.workspace = true
repl.workspace = true
rope.workspace = true
search.workspace = true
serde.workspace = true

View file

@ -221,6 +221,8 @@ fn init_ui(app_state: Arc<AppState>, cx: &mut AppContext) -> Result<()> {
assistant::init(app_state.client.clone(), cx);
repl::init(app_state.fs.clone(), cx);
cx.observe_global::<SettingsStore>({
let languages = app_state.languages.clone();
let http = app_state.client.http_client();