logiguard fork v3: full patch set on verified 8c74db0 tree

Includes prior-session patches (carry forward so the app compiles):
  - crates/gpui/build.rs: cross-compile manifest fix
  - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method
  - crates/gpui/src/window.rs: Window::activate_with_token public API
  - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix

Plus the focus-serial fix:
  - serial.rs: SerialKind::KeyboardEnter
  - client.rs: store wl_keyboard.enter serial; latest_serial_of()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mohamad Khani
2026-07-14 01:52:12 +03:30
commit b9819977a5
3984 changed files with 1487015 additions and 0 deletions

82
crates/repl/Cargo.toml Normal file
View File

@@ -0,0 +1,82 @@
[package]
name = "repl"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[features]
test-support = []
[lib]
path = "src/repl.rs"
doctest = false
[dependencies]
alacritty_terminal.workspace = true
anyhow.workspace = true
async-dispatcher.workspace = true
async-task.workspace = true
async-tungstenite = { workspace = true, features = ["tokio", "tokio-rustls-manual-roots", "tokio-runtime"] }
base64.workspace = true
client.workspace = true
collections.workspace = true
command_palette_hooks.workspace = true
editor.workspace = true
feature_flags.workspace = true
file_icons.workspace = true
futures.workspace = true
gpui.workspace = true
html_to_markdown.workspace = true
http_client.workspace = true
image.workspace = true
jupyter-websocket-client.workspace = true
jupyter-protocol.workspace = true
language.workspace = true
log.workspace = true
markdown.workspace = true
menu.workspace = true
multi_buffer.workspace = true
nbformat.workspace = true
project.workspace = true
remote.workspace = true
runtimelib.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
shlex.workspace = true
smol.workspace = true
telemetry.workspace = true
terminal.workspace = true
terminal_view.workspace = true
theme.workspace = true
theme_settings.workspace = true
ui.workspace = true
util.workspace = true
uuid.workspace = true
workspace.workspace = true
picker.workspace = true
zed_actions.workspace = true
[dev-dependencies]
editor = { workspace = true, features = ["test-support"] }
gpui = { workspace = true, features = ["test-support"] }
http_client = { workspace = true, features = ["test-support"] }
indoc.workspace = true
language = { workspace = true, features = ["test-support"] }
languages = { workspace = true, features = ["test-support"] }
project = { workspace = true, features = ["test-support"] }
settings = { workspace = true, features = ["test-support"] }
terminal_view = { workspace = true, features = ["test-support"] }
theme = { workspace = true, features = ["test-support"] }
tree-sitter-md.workspace = true
tree-sitter-typescript.workspace = true
tree-sitter-python.workspace = true
workspace = { workspace = true, features = ["test-support"] }
util = { workspace = true, features = ["test-support"] }
[package.metadata.cargo-machete]
ignored = ["remote"]

1
crates/repl/LICENSE-GPL Symbolic link
View File

@@ -0,0 +1 @@
../../LICENSE-GPL

View File

@@ -0,0 +1,5 @@
mod kernel_list_item;
mod kernel_options;
pub use kernel_list_item::*;
pub use kernel_options::*;

View File

@@ -0,0 +1,60 @@
use gpui::AnyElement;
use ui::{Indicator, ListItem, prelude::*};
use crate::KernelSpecification;
#[derive(IntoElement)]
pub struct KernelListItem {
kernel_specification: KernelSpecification,
status_color: Color,
buttons: Vec<AnyElement>,
children: Vec<AnyElement>,
}
impl KernelListItem {
pub fn new(kernel_specification: KernelSpecification) -> Self {
Self {
kernel_specification,
status_color: Color::Disabled,
buttons: Vec::new(),
children: Vec::new(),
}
}
pub fn status_color(mut self, color: Color) -> Self {
self.status_color = color;
self
}
pub fn button(mut self, button: impl IntoElement) -> Self {
self.buttons.push(button.into_any_element());
self
}
pub fn buttons(mut self, buttons: impl IntoIterator<Item = impl IntoElement>) -> Self {
self.buttons
.extend(buttons.into_iter().map(|button| button.into_any_element()));
self
}
}
impl ParentElement for KernelListItem {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements);
}
}
impl RenderOnce for KernelListItem {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
ListItem::new(self.kernel_specification.name())
.selectable(false)
.start_slot(
h_flex()
.size_3()
.justify_center()
.child(Indicator::dot().color(self.status_color)),
)
.children(self.children)
.end_slot(h_flex().gap_2().children(self.buttons))
}
}

View File

@@ -0,0 +1,490 @@
use crate::KERNEL_DOCS_URL;
use crate::kernels::KernelSpecification;
use crate::repl_store::ReplStore;
use gpui::{AnyView, DismissEvent, FontWeight, SharedString, Task};
use picker::{Picker, PickerDelegate};
use project::WorktreeId;
use std::sync::Arc;
use ui::{ListItem, ListItemSpacing, PopoverMenu, PopoverMenuHandle, PopoverTrigger, prelude::*};
type OnSelect = Box<dyn Fn(KernelSpecification, &mut Window, &mut App)>;
#[derive(Clone)]
pub enum KernelPickerEntry {
SectionHeader(SharedString),
Kernel {
spec: KernelSpecification,
is_recommended: bool,
},
}
fn build_grouped_entries(store: &ReplStore, worktree_id: WorktreeId) -> Vec<KernelPickerEntry> {
let mut entries = Vec::new();
let mut recommended_entry: Option<KernelPickerEntry> = None;
let mut found_selected = false;
let selected_kernel = store.selected_kernel(worktree_id);
let mut python_envs = Vec::new();
let mut jupyter_kernels = Vec::new();
let mut wsl_kernels = Vec::new();
let mut remote_kernels = Vec::new();
for spec in store.kernel_specifications_for_worktree(worktree_id) {
let is_recommended = store.is_recommended_kernel(worktree_id, spec);
let is_selected = selected_kernel.map_or(false, |s| s == spec);
if is_selected {
recommended_entry = Some(KernelPickerEntry::Kernel {
spec: spec.clone(),
is_recommended: true,
});
found_selected = true;
} else if is_recommended && !found_selected {
recommended_entry = Some(KernelPickerEntry::Kernel {
spec: spec.clone(),
is_recommended: true,
});
}
match spec {
KernelSpecification::PythonEnv(_) => {
python_envs.push(KernelPickerEntry::Kernel {
spec: spec.clone(),
is_recommended,
});
}
KernelSpecification::Jupyter(_) => {
jupyter_kernels.push(KernelPickerEntry::Kernel {
spec: spec.clone(),
is_recommended,
});
}
KernelSpecification::JupyterServer(_) | KernelSpecification::SshRemote(_) => {
remote_kernels.push(KernelPickerEntry::Kernel {
spec: spec.clone(),
is_recommended,
});
}
KernelSpecification::WslRemote(_) => {
wsl_kernels.push(KernelPickerEntry::Kernel {
spec: spec.clone(),
is_recommended,
});
}
}
}
// Sort Python envs: has_ipykernel first, then by name
python_envs.sort_by(|a, b| {
let (spec_a, spec_b) = match (a, b) {
(
KernelPickerEntry::Kernel { spec: sa, .. },
KernelPickerEntry::Kernel { spec: sb, .. },
) => (sa, sb),
_ => return std::cmp::Ordering::Equal,
};
spec_b
.has_ipykernel()
.cmp(&spec_a.has_ipykernel())
.then_with(|| spec_a.name().cmp(&spec_b.name()))
});
// Recommended section
if let Some(rec) = recommended_entry {
entries.push(KernelPickerEntry::SectionHeader("Recommended".into()));
entries.push(rec);
}
// Python Environments section
if !python_envs.is_empty() {
entries.push(KernelPickerEntry::SectionHeader(
"Python Environments".into(),
));
entries.extend(python_envs);
}
// Jupyter Kernels section
if !jupyter_kernels.is_empty() {
entries.push(KernelPickerEntry::SectionHeader("Jupyter Kernels".into()));
entries.extend(jupyter_kernels);
}
// WSL Kernels section
if !wsl_kernels.is_empty() {
entries.push(KernelPickerEntry::SectionHeader("WSL Kernels".into()));
entries.extend(wsl_kernels);
}
// Remote section
if !remote_kernels.is_empty() {
entries.push(KernelPickerEntry::SectionHeader("Remote Servers".into()));
entries.extend(remote_kernels);
}
entries
}
#[derive(IntoElement)]
pub struct KernelSelector<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
handle: Option<PopoverMenuHandle<Picker<KernelPickerDelegate>>>,
on_select: OnSelect,
trigger: T,
tooltip: TT,
info_text: Option<SharedString>,
worktree_id: WorktreeId,
}
pub struct KernelPickerDelegate {
all_entries: Vec<KernelPickerEntry>,
filtered_entries: Vec<KernelPickerEntry>,
selected_kernelspec: Option<KernelSpecification>,
selected_index: usize,
on_select: OnSelect,
}
impl<T, TT> KernelSelector<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
pub fn new(on_select: OnSelect, worktree_id: WorktreeId, trigger: T, tooltip: TT) -> Self {
KernelSelector {
on_select,
handle: None,
trigger,
tooltip,
info_text: None,
worktree_id,
}
}
pub fn with_handle(mut self, handle: PopoverMenuHandle<Picker<KernelPickerDelegate>>) -> Self {
self.handle = Some(handle);
self
}
pub fn with_info_text(mut self, text: impl Into<SharedString>) -> Self {
self.info_text = Some(text.into());
self
}
}
impl KernelPickerDelegate {
fn first_selectable_index(entries: &[KernelPickerEntry]) -> usize {
entries
.iter()
.position(|e| matches!(e, KernelPickerEntry::Kernel { .. }))
.unwrap_or(0)
}
fn next_selectable_index(&self, from: usize, direction: i32) -> usize {
let len = self.filtered_entries.len();
if len == 0 {
return 0;
}
let mut index = from as i32 + direction;
while index >= 0 && (index as usize) < len {
if matches!(
self.filtered_entries.get(index as usize),
Some(KernelPickerEntry::Kernel { .. })
) {
return index as usize;
}
index += direction;
}
from
}
}
impl PickerDelegate for KernelPickerDelegate {
type ListItem = ListItem;
fn match_count(&self) -> usize {
self.filtered_entries.len()
}
fn selected_index(&self) -> usize {
self.selected_index
}
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
if matches!(
self.filtered_entries.get(ix),
Some(KernelPickerEntry::SectionHeader(_))
) {
let forward = self.next_selectable_index(ix, 1);
if forward != ix {
self.selected_index = forward;
} else {
self.selected_index = self.next_selectable_index(ix, -1);
}
} else {
self.selected_index = ix;
}
if let Some(KernelPickerEntry::Kernel { spec, .. }) =
self.filtered_entries.get(self.selected_index)
{
self.selected_kernelspec = Some(spec.clone());
}
cx.notify();
}
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
"Select a kernel...".into()
}
fn update_matches(
&mut self,
query: String,
_window: &mut Window,
_cx: &mut Context<Picker<Self>>,
) -> Task<()> {
if query.is_empty() {
self.filtered_entries = self.all_entries.clone();
} else {
let query_lower = query.to_lowercase();
let mut filtered = Vec::new();
let mut pending_header: Option<KernelPickerEntry> = None;
for entry in &self.all_entries {
match entry {
KernelPickerEntry::SectionHeader(_) => {
pending_header = Some(entry.clone());
}
KernelPickerEntry::Kernel { spec, .. } => {
if spec.name().to_lowercase().contains(&query_lower) {
if let Some(header) = pending_header.take() {
filtered.push(header);
}
filtered.push(entry.clone());
}
}
}
}
self.filtered_entries = filtered;
}
self.selected_index = Self::first_selectable_index(&self.filtered_entries);
if let Some(KernelPickerEntry::Kernel { spec, .. }) =
self.filtered_entries.get(self.selected_index)
{
self.selected_kernelspec = Some(spec.clone());
}
Task::ready(())
}
fn separators_after_indices(&self) -> Vec<usize> {
let mut separators = Vec::new();
for (index, entry) in self.filtered_entries.iter().enumerate() {
if matches!(entry, KernelPickerEntry::SectionHeader(_)) && index > 0 {
separators.push(index - 1);
}
}
separators
}
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
if let Some(KernelPickerEntry::Kernel { spec, .. }) =
self.filtered_entries.get(self.selected_index)
{
(self.on_select)(spec.clone(), window, cx);
cx.emit(DismissEvent);
}
}
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
fn render_match(
&self,
ix: usize,
selected: bool,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<Self::ListItem> {
let entry = self.filtered_entries.get(ix)?;
match entry {
KernelPickerEntry::SectionHeader(title) => Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Dense)
.selectable(false)
.child(
Label::new(title.clone())
.size(LabelSize::Small)
.weight(FontWeight::SEMIBOLD)
.color(Color::Muted),
),
),
KernelPickerEntry::Kernel {
spec,
is_recommended,
} => {
let is_currently_selected = self.selected_kernelspec.as_ref() == Some(spec);
let icon = spec.icon(cx);
let has_ipykernel = spec.has_ipykernel();
let subtitle = match spec {
KernelSpecification::Jupyter(_) => None,
KernelSpecification::WslRemote(_) => Some(spec.path().to_string()),
KernelSpecification::PythonEnv(_)
| KernelSpecification::JupyterServer(_)
| KernelSpecification::SshRemote(_) => {
let env_kind = spec.environment_kind_label();
let path = spec.path();
match env_kind {
Some(kind) => Some(format!("{} \u{2013} {}", kind, path)),
None => Some(path.to_string()),
}
}
};
Some(
ListItem::new(ix)
.inset(true)
.spacing(ListItemSpacing::Sparse)
.toggle_state(selected)
.child(
h_flex()
.w_full()
.gap_3()
.when(!has_ipykernel, |flex| flex.opacity(0.5))
.child(icon.color(Color::Default).size(IconSize::Medium))
.child(
v_flex()
.flex_grow()
.overflow_x_hidden()
.gap_0p5()
.child(
h_flex()
.gap_1()
.child(
div()
.overflow_x_hidden()
.flex_shrink()
.text_ellipsis()
.child(
Label::new(spec.name())
.weight(FontWeight::MEDIUM)
.size(LabelSize::Default),
),
)
.when(*is_recommended, |flex| {
flex.child(
Label::new("Recommended")
.size(LabelSize::XSmall)
.color(Color::Accent),
)
})
.when(!has_ipykernel, |flex| {
flex.child(
Label::new("ipykernel not installed")
.size(LabelSize::XSmall)
.color(Color::Warning),
)
}),
)
.when_some(subtitle, |flex, subtitle| {
flex.child(
div().overflow_x_hidden().text_ellipsis().child(
Label::new(subtitle)
.size(LabelSize::Small)
.color(Color::Muted),
),
)
}),
),
)
.when(is_currently_selected, |item| {
item.end_slot(
Icon::new(IconName::Check)
.color(Color::Accent)
.size(IconSize::Small),
)
}),
)
}
}
}
fn render_footer(
&self,
_: &mut Window,
cx: &mut Context<Picker<Self>>,
) -> Option<gpui::AnyElement> {
Some(
h_flex()
.w_full()
.border_t_1()
.border_color(cx.theme().colors().border_variant)
.p_1()
.gap_4()
.child(
Button::new("kernel-docs", "Kernel Docs")
.end_icon(
Icon::new(IconName::ArrowUpRight)
.size(IconSize::Small)
.color(Color::Muted),
)
.on_click(move |_, _, cx| cx.open_url(KERNEL_DOCS_URL)),
)
.into_any(),
)
}
}
impl<T, TT> RenderOnce for KernelSelector<T, TT>
where
T: PopoverTrigger + ButtonCommon,
TT: Fn(&mut Window, &mut App) -> AnyView + 'static,
{
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let store = ReplStore::global(cx);
store.update(cx, |store, cx| store.ensure_kernelspecs(cx));
let store = store.read(cx);
let all_entries = build_grouped_entries(store, self.worktree_id);
let selected_kernelspec = store.active_kernelspec(self.worktree_id, None, cx);
let selected_index = all_entries
.iter()
.position(|entry| {
if let KernelPickerEntry::Kernel { spec, .. } = entry {
selected_kernelspec.as_ref() == Some(spec)
} else {
false
}
})
.unwrap_or_else(|| KernelPickerDelegate::first_selectable_index(&all_entries));
let delegate = KernelPickerDelegate {
on_select: self.on_select,
all_entries: all_entries.clone(),
filtered_entries: all_entries,
selected_kernelspec,
selected_index,
};
let picker_view = cx.new(|cx| {
Picker::list(delegate, window, cx)
.list_measure_all()
.width(rems(34.))
.max_height(Some(rems(24.).into()))
});
PopoverMenu::new("kernel-switcher")
.menu(move |_window, _cx| Some(picker_view.clone()))
.trigger_with_tooltip(self.trigger, self.tooltip)
.attach(gpui::Anchor::BottomLeft)
.when_some(self.handle, |menu, handle| menu.with_handle(handle))
}
}

View File

@@ -0,0 +1,28 @@
use collections::HashMap;
use editor::EditorSettings;
use gpui::App;
use settings::{RegisterSetting, Settings};
#[derive(Debug, Default, RegisterSetting)]
pub struct JupyterSettings {
pub kernel_selections: HashMap<String, String>,
}
impl JupyterSettings {
pub fn enabled(cx: &App) -> bool {
// In order to avoid a circular dependency between `editor` and `repl` crates,
// we put the `enable` flag on its settings.
// This allows the editor to set up context for key bindings/actions.
EditorSettings::jupyter_enabled(cx)
}
}
impl Settings for JupyterSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
let jupyter = content.editor.jupyter.clone().unwrap();
Self {
kernel_selections: jupyter.kernel_selections.unwrap_or_default(),
}
}
}

View File

@@ -0,0 +1,767 @@
mod native_kernel;
use std::{fmt::Debug, future::Future, path::PathBuf};
use futures::{channel::mpsc, future::Shared};
use gpui::{App, Entity, Task, Window};
use language::LanguageName;
use log;
pub use native_kernel::*;
mod remote_kernels;
use project::{Project, ProjectPath, Toolchains, WorktreeId};
use remote::RemoteConnectionOptions;
pub use remote_kernels::*;
mod ssh_kernel;
pub use ssh_kernel::*;
mod wsl_kernel;
pub use wsl_kernel::*;
use std::collections::HashMap;
use anyhow::Result;
use futures::{FutureExt, StreamExt};
use gpui::{AppContext, AsyncWindowContext, Context};
use jupyter_protocol::{JupyterKernelspec, JupyterMessageContent};
use runtimelib::{
ClientControlConnection, ClientIoPubConnection, ClientShellConnection, ClientStdinConnection,
ExecutionState, JupyterMessage, KernelInfoReply,
};
use ui::{Icon, IconName, SharedString};
use util::rel_path::RelPath;
pub(crate) const VENV_DIR_NAMES: &[&str] = &[".venv", "venv", ".env", "env"];
// Build a POSIX shell script that attempts to find and exec the best Python binary to run with the given arguments.
pub(crate) fn build_python_exec_shell_script(
python_args: &str,
cd_command: &str,
env_command: &str,
) -> String {
let venv_dirs = VENV_DIR_NAMES.join(" ");
format!(
"set -e; \
{cd_command}\
{env_command}\
for venv_dir in {venv_dirs}; do \
if [ -f \"$venv_dir/pyvenv.cfg\" ] || [ -f \"$venv_dir/bin/activate\" ]; then \
if [ -x \"$venv_dir/bin/python\" ]; then \
exec \"$venv_dir/bin/python\" {python_args}; \
elif [ -x \"$venv_dir/bin/python3\" ]; then \
exec \"$venv_dir/bin/python3\" {python_args}; \
fi; \
fi; \
done; \
if command -v python3 >/dev/null 2>&1; then \
exec python3 {python_args}; \
elif command -v python >/dev/null 2>&1; then \
exec python {python_args}; \
else \
echo 'Error: Python not found in virtual environment or PATH' >&2; \
exit 127; \
fi"
)
}
/// Build a POSIX shell script that outputs the best Python binary.
#[cfg(target_os = "windows")]
pub(crate) fn build_python_discovery_shell_script() -> String {
let venv_dirs = VENV_DIR_NAMES.join(" ");
format!(
"for venv_dir in {venv_dirs}; do \
if [ -f \"$venv_dir/pyvenv.cfg\" ] || [ -f \"$venv_dir/bin/activate\" ]; then \
if [ -x \"$venv_dir/bin/python\" ]; then \
echo \"$venv_dir/bin/python\"; exit 0; \
elif [ -x \"$venv_dir/bin/python3\" ]; then \
echo \"$venv_dir/bin/python3\"; exit 0; \
fi; \
fi; \
done; \
if command -v python3 >/dev/null 2>&1; then \
echo python3; exit 0; \
elif command -v python >/dev/null 2>&1; then \
echo python; exit 0; \
fi; \
exit 1"
)
}
pub fn start_kernel_tasks<S: KernelSession + 'static>(
session: Entity<S>,
iopub_socket: ClientIoPubConnection,
shell_socket: ClientShellConnection,
control_socket: ClientControlConnection,
stdin_socket: ClientStdinConnection,
cx: &mut AsyncWindowContext,
) -> (
futures::channel::mpsc::Sender<JupyterMessage>,
futures::channel::mpsc::Sender<JupyterMessage>,
) {
let (mut shell_send, shell_recv) = shell_socket.split();
let (mut control_send, control_recv) = control_socket.split();
let (mut stdin_send, stdin_recv) = stdin_socket.split();
let (request_tx, mut request_rx) = futures::channel::mpsc::channel::<JupyterMessage>(100);
let (stdin_tx, mut stdin_rx) = futures::channel::mpsc::channel::<JupyterMessage>(100);
let recv_task = cx.spawn({
let session = session.clone();
let mut iopub = iopub_socket;
let mut shell = shell_recv;
let mut control = control_recv;
let mut stdin = stdin_recv;
async move |cx| -> anyhow::Result<()> {
loop {
let (channel, result) = futures::select! {
msg = iopub.read().fuse() => ("iopub", msg),
msg = shell.read().fuse() => ("shell", msg),
msg = control.read().fuse() => ("control", msg),
msg = stdin.read().fuse() => ("stdin", msg),
};
match result {
Ok(message) => {
session
.update_in(cx, |session, window, cx| {
session.route(&message, window, cx);
})
.ok();
}
Err(
ref err @ (runtimelib::RuntimeError::ParseError { .. }
| runtimelib::RuntimeError::SerdeError(_)),
) => {
let error_detail = format!("Kernel issue on {channel} channel\n\n{err}");
log::warn!("kernel: {error_detail}");
session
.update_in(cx, |session, _window, cx| {
session.kernel_errored(error_detail, cx);
cx.notify();
})
.ok();
}
Err(err) => {
log::warn!("kernel: error reading from {channel}: {err:?}");
anyhow::bail!("{channel} recv: {err}");
}
}
}
}
});
let routing_task = cx.background_spawn(async move {
while let Some(message) = request_rx.next().await {
match message.content {
JupyterMessageContent::DebugRequest(_)
| JupyterMessageContent::InterruptRequest(_)
| JupyterMessageContent::ShutdownRequest(_) => {
control_send.send(message).await?;
}
_ => {
shell_send.send(message).await?;
}
}
}
anyhow::Ok(())
});
let stdin_routing_task = cx.background_spawn(async move {
while let Some(message) = stdin_rx.next().await {
stdin_send.send(message).await?;
}
anyhow::Ok(())
});
cx.spawn({
async move |cx| {
async fn with_name(
name: &'static str,
task: Task<Result<()>>,
) -> (&'static str, Result<()>) {
(name, task.await)
}
let mut tasks = futures::stream::FuturesUnordered::new();
tasks.push(with_name("recv task", recv_task));
tasks.push(with_name("routing task", routing_task));
tasks.push(with_name("stdin routing task", stdin_routing_task));
while let Some((name, result)) = tasks.next().await {
if let Err(err) = result {
session.update(cx, |session, cx| {
session.kernel_errored(format!("handling failed for {name}: {err}"), cx);
cx.notify();
});
}
}
}
})
.detach();
(request_tx, stdin_tx)
}
pub trait KernelSession: Sized {
fn route(&mut self, message: &JupyterMessage, window: &mut Window, cx: &mut Context<Self>);
fn kernel_errored(&mut self, error_message: String, cx: &mut Context<Self>);
}
#[derive(Debug, Clone)]
pub struct PythonEnvKernelSpecification {
pub name: String,
pub path: PathBuf,
pub kernelspec: JupyterKernelspec,
pub has_ipykernel: bool,
/// Display label for the environment type: "venv", "Conda", "Pyenv", etc.
pub environment_kind: Option<String>,
}
impl PartialEq for PythonEnvKernelSpecification {
fn eq(&self, other: &Self) -> bool {
self.name == other.name && self.path == other.path
}
}
impl Eq for PythonEnvKernelSpecification {}
impl PythonEnvKernelSpecification {
pub fn as_local_spec(&self) -> LocalKernelSpecification {
LocalKernelSpecification {
name: self.name.clone(),
path: self.path.clone(),
kernelspec: self.kernelspec.clone(),
}
}
pub fn is_uv(&self) -> bool {
matches!(
self.environment_kind.as_deref(),
Some("uv" | "uv (Workspace)")
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KernelSpecification {
JupyterServer(RemoteKernelSpecification),
Jupyter(LocalKernelSpecification),
PythonEnv(PythonEnvKernelSpecification),
SshRemote(SshRemoteKernelSpecification),
WslRemote(WslKernelSpecification),
}
#[derive(Debug, Clone)]
pub struct SshRemoteKernelSpecification {
pub name: String,
pub path: SharedString,
pub kernelspec: JupyterKernelspec,
}
#[derive(Debug, Clone)]
pub struct WslKernelSpecification {
pub name: String,
pub kernelspec: JupyterKernelspec,
pub distro: String,
}
impl PartialEq for SshRemoteKernelSpecification {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.kernelspec.argv == other.kernelspec.argv
&& self.path == other.path
&& self.kernelspec.display_name == other.kernelspec.display_name
&& self.kernelspec.language == other.kernelspec.language
&& self.kernelspec.interrupt_mode == other.kernelspec.interrupt_mode
&& self.kernelspec.env == other.kernelspec.env
&& self.kernelspec.metadata == other.kernelspec.metadata
}
}
impl Eq for SshRemoteKernelSpecification {}
impl PartialEq for WslKernelSpecification {
fn eq(&self, other: &Self) -> bool {
self.name == other.name
&& self.kernelspec.argv == other.kernelspec.argv
&& self.kernelspec.display_name == other.kernelspec.display_name
&& self.kernelspec.language == other.kernelspec.language
&& self.kernelspec.interrupt_mode == other.kernelspec.interrupt_mode
&& self.kernelspec.env == other.kernelspec.env
&& self.kernelspec.metadata == other.kernelspec.metadata
&& self.distro == other.distro
}
}
impl Eq for WslKernelSpecification {}
impl KernelSpecification {
pub fn name(&self) -> SharedString {
match self {
Self::Jupyter(spec) => spec.name.clone().into(),
Self::PythonEnv(spec) => spec.name.clone().into(),
Self::JupyterServer(spec) => spec.name.clone().into(),
Self::SshRemote(spec) => spec.name.clone().into(),
Self::WslRemote(spec) => spec.kernelspec.display_name.clone().into(),
}
}
pub fn type_name(&self) -> SharedString {
match self {
Self::Jupyter(_) => "Jupyter".into(),
Self::PythonEnv(spec) => SharedString::from(
spec.environment_kind
.clone()
.unwrap_or_else(|| "Python Environment".to_string()),
),
Self::JupyterServer(_) => "Jupyter Server".into(),
Self::SshRemote(_) => "SSH Remote".into(),
Self::WslRemote(_) => "WSL Remote".into(),
}
}
pub fn path(&self) -> SharedString {
SharedString::from(match self {
Self::Jupyter(spec) => spec.path.to_string_lossy().into_owned(),
Self::PythonEnv(spec) => spec.path.to_string_lossy().into_owned(),
Self::JupyterServer(spec) => spec.url.to_string(),
Self::SshRemote(spec) => spec.path.to_string(),
Self::WslRemote(spec) => spec.distro.clone(),
})
}
pub fn language(&self) -> SharedString {
SharedString::from(match self {
Self::Jupyter(spec) => spec.kernelspec.language.clone(),
Self::PythonEnv(spec) => spec.kernelspec.language.clone(),
Self::JupyterServer(spec) => spec.kernelspec.language.clone(),
Self::SshRemote(spec) => spec.kernelspec.language.clone(),
Self::WslRemote(spec) => spec.kernelspec.language.clone(),
})
}
pub fn has_ipykernel(&self) -> bool {
match self {
Self::Jupyter(_) | Self::JupyterServer(_) | Self::SshRemote(_) | Self::WslRemote(_) => {
true
}
Self::PythonEnv(spec) => spec.has_ipykernel,
}
}
pub fn environment_kind_label(&self) -> Option<SharedString> {
match self {
Self::PythonEnv(spec) => spec
.environment_kind
.as_ref()
.map(|kind| SharedString::from(kind.clone())),
Self::Jupyter(_) => Some("Jupyter".into()),
Self::JupyterServer(_) => Some("Jupyter Server".into()),
Self::SshRemote(_) => Some("SSH Remote".into()),
Self::WslRemote(_) => Some("WSL Remote".into()),
}
}
pub fn icon(&self, cx: &App) -> Icon {
let lang_name = match self {
Self::Jupyter(spec) => spec.kernelspec.language.clone(),
Self::PythonEnv(spec) => spec.kernelspec.language.clone(),
Self::JupyterServer(spec) => spec.kernelspec.language.clone(),
Self::SshRemote(spec) => spec.kernelspec.language.clone(),
Self::WslRemote(spec) => spec.kernelspec.language.clone(),
};
file_icons::FileIcons::get(cx)
.get_icon_for_type(&lang_name.to_lowercase(), cx)
.map(Icon::from_path)
.unwrap_or(Icon::new(IconName::ReplNeutral))
}
}
fn extract_environment_kind(toolchain_json: &serde_json::Value) -> Option<String> {
let kind_str = toolchain_json.get("kind")?.as_str()?;
let label = match kind_str {
"Conda" => "Conda",
"Pixi" => "pixi",
"Homebrew" => "Homebrew",
"Pyenv" => "global (Pyenv)",
"GlobalPaths" => "global",
"PyenvVirtualEnv" => "Pyenv",
"Pipenv" => "Pipenv",
"Poetry" => "Poetry",
"MacPythonOrg" => "global (Python.org)",
"MacCommandLineTools" => "global (Command Line Tools for Xcode)",
"LinuxGlobal" => "global",
"MacXCode" => "global (Xcode)",
"Venv" => "venv",
"VirtualEnv" => "virtualenv",
"VirtualEnvWrapper" => "virtualenvwrapper",
"WindowsStore" => "global (Windows Store)",
"WindowsRegistry" => "global (Windows Registry)",
"Uv" => "uv",
"UvWorkspace" => "uv (Workspace)",
_ => kind_str,
};
Some(label.to_string())
}
pub fn python_env_kernel_specifications(
project: &Entity<Project>,
worktree_id: WorktreeId,
cx: &mut App,
) -> impl Future<Output = Result<Vec<KernelSpecification>>> + use<> {
let python_language = LanguageName::new_static("Python");
let is_remote = project.read(cx).is_remote();
let wsl_distro = project
.read(cx)
.remote_connection_options(cx)
.and_then(|opts| {
if let RemoteConnectionOptions::Wsl(wsl) = opts {
Some(wsl.distro_name)
} else {
None
}
});
let toolchains = project.read(cx).available_toolchains(
ProjectPath {
worktree_id,
path: RelPath::empty().into(),
},
python_language,
cx,
);
#[allow(unused)]
let worktree_root_path: Option<std::sync::Arc<std::path::Path>> = project
.read(cx)
.worktree_for_id(worktree_id, cx)
.map(|w| w.read(cx).abs_path());
let background_executor = cx.background_executor().clone();
async move {
let (toolchains, user_toolchains) = if let Some(Toolchains {
toolchains,
root_path: _,
user_toolchains,
}) = toolchains.await
{
(toolchains, user_toolchains)
} else {
return Ok(Vec::new());
};
let kernelspecs = user_toolchains
.into_values()
.flatten()
.chain(toolchains.toolchains)
.map(|toolchain| {
let wsl_distro = wsl_distro.clone();
background_executor.spawn(async move {
// For remote projects, we assume python is available assuming toolchain is reported.
// We can skip the `ipykernel` check or run it remotely.
// For MVP, lets trust the toolchain existence or do the check if it's cheap.
// `new_smol_command` runs locally. We need to run remotely if `is_remote`.
if is_remote {
let default_kernelspec = JupyterKernelspec {
argv: vec![
toolchain.path.to_string(),
"-m".to_string(),
"ipykernel_launcher".to_string(),
"-f".to_string(),
"{connection_file}".to_string(),
],
display_name: toolchain.name.to_string(),
language: "python".to_string(),
interrupt_mode: None,
metadata: None,
env: None,
};
if let Some(distro) = wsl_distro {
log::debug!(
"python_env_kernel_specifications: returning WslRemote for toolchain {}",
toolchain.name
);
return Some(KernelSpecification::WslRemote(WslKernelSpecification {
name: toolchain.name.to_string(),
kernelspec: default_kernelspec,
distro,
}));
}
log::debug!(
"python_env_kernel_specifications: returning SshRemote for toolchain {}",
toolchain.name
);
return Some(KernelSpecification::SshRemote(
SshRemoteKernelSpecification {
name: format!("Remote {}", toolchain.name),
path: toolchain.path.clone(),
kernelspec: default_kernelspec,
},
));
}
let python_path = toolchain.path.to_string();
let environment_kind = extract_environment_kind(&toolchain.as_json);
let has_ipykernel = util::command::new_command(&python_path)
.args(&["-c", "import ipykernel"])
.output()
.await
.map(|output| output.status.success())
.unwrap_or(false);
let mut env = HashMap::new();
if let Some(python_bin_dir) = PathBuf::from(&python_path).parent() {
if let Some(path_var) = std::env::var_os("PATH") {
let mut paths = std::env::split_paths(&path_var).collect::<Vec<_>>();
paths.insert(0, python_bin_dir.to_path_buf());
if let Ok(new_path) = std::env::join_paths(paths) {
env.insert("PATH".to_string(), new_path.to_string_lossy().to_string());
}
}
if let Some(venv_root) = python_bin_dir.parent() {
env.insert("VIRTUAL_ENV".to_string(), venv_root.to_string_lossy().to_string());
}
}
log::info!("Preparing Python kernel for toolchain: {}", toolchain.name);
log::info!("Python path: {}", python_path);
if let Some(path) = env.get("PATH") {
log::info!("Kernel PATH: {}", path);
} else {
log::info!("Kernel PATH not set in env");
}
if let Some(venv) = env.get("VIRTUAL_ENV") {
log::info!("Kernel VIRTUAL_ENV: {}", venv);
}
let kernelspec = JupyterKernelspec {
argv: vec![
python_path.clone(),
"-m".to_string(),
"ipykernel_launcher".to_string(),
"-f".to_string(),
"{connection_file}".to_string(),
],
display_name: toolchain.name.to_string(),
language: "python".to_string(),
interrupt_mode: None,
metadata: None,
env: Some(env),
};
Some(KernelSpecification::PythonEnv(PythonEnvKernelSpecification {
name: toolchain.name.to_string(),
path: PathBuf::from(&python_path),
kernelspec,
has_ipykernel,
environment_kind,
}))
})
});
#[allow(unused_mut)]
let mut kernel_specs: Vec<KernelSpecification> = futures::stream::iter(kernelspecs)
.buffer_unordered(4)
.filter_map(|x| async move { x })
.collect::<Vec<_>>()
.await;
#[cfg(target_os = "windows")]
if kernel_specs.is_empty() && !is_remote {
if let Some(root_path) = worktree_root_path {
let root_path_str: std::borrow::Cow<str> = root_path.to_string_lossy();
let (distro, internal_path) =
if let Some(path_without_prefix) = root_path_str.strip_prefix(r"\\wsl$\") {
if let Some((distro, path)) = path_without_prefix.split_once('\\') {
let replaced_path: String = path.replace('\\', "/");
(Some(distro), Some(format!("/{}", replaced_path)))
} else {
(Some(path_without_prefix), Some("/".to_string()))
}
} else if let Some(path_without_prefix) =
root_path_str.strip_prefix(r"\\wsl.localhost\")
{
if let Some((distro, path)) = path_without_prefix.split_once('\\') {
let replaced_path: String = path.replace('\\', "/");
(Some(distro), Some(format!("/{}", replaced_path)))
} else {
(Some(path_without_prefix), Some("/".to_string()))
}
} else {
(None, None)
};
if let (Some(distro), Some(internal_path)) = (distro, internal_path) {
let discovery_script = build_python_discovery_shell_script();
let script = format!(
"cd {} && {}",
shlex::try_quote(&internal_path)
.unwrap_or(std::borrow::Cow::Borrowed(&internal_path)),
discovery_script
);
let output = util::command::new_command("wsl")
.arg("-d")
.arg(distro)
.arg("bash")
.arg("-l")
.arg("-c")
.arg(&script)
.output()
.await;
if let Ok(output) = output {
if output.status.success() {
let python_cmd =
String::from_utf8_lossy(&output.stdout).trim().to_string();
let (python_path, display_suffix) = if python_cmd.contains('/') {
let venv_name = python_cmd.split('/').next().unwrap_or("venv");
(
format!("{}/{}", internal_path, python_cmd),
format!("({})", venv_name),
)
} else {
(python_cmd, "(System)".to_string())
};
let display_name = format!("WSL: {} {}", distro, display_suffix);
let default_kernelspec = JupyterKernelspec {
argv: vec![
python_path,
"-m".to_string(),
"ipykernel_launcher".to_string(),
"-f".to_string(),
"{connection_file}".to_string(),
],
display_name: display_name.clone(),
language: "python".to_string(),
interrupt_mode: None,
metadata: None,
env: None,
};
kernel_specs.push(KernelSpecification::WslRemote(
WslKernelSpecification {
name: display_name,
kernelspec: default_kernelspec,
distro: distro.to_string(),
},
));
}
}
}
}
}
anyhow::Ok(kernel_specs)
}
}
pub trait RunningKernel: Send + Debug {
fn request_tx(&self) -> mpsc::Sender<JupyterMessage>;
fn stdin_tx(&self) -> mpsc::Sender<JupyterMessage>;
fn working_directory(&self) -> &PathBuf;
fn execution_state(&self) -> &ExecutionState;
fn set_execution_state(&mut self, state: ExecutionState);
fn kernel_info(&self) -> Option<&KernelInfoReply>;
fn set_kernel_info(&mut self, info: KernelInfoReply);
fn force_shutdown(&mut self, window: &mut Window, cx: &mut App) -> Task<anyhow::Result<()>>;
fn kill(&mut self);
}
#[derive(Debug, Clone)]
pub enum KernelStatus {
Idle,
Busy,
Starting,
Error,
ShuttingDown,
Shutdown,
Restarting,
}
impl KernelStatus {
pub fn is_connected(&self) -> bool {
matches!(self, KernelStatus::Idle | KernelStatus::Busy)
}
}
impl ToString for KernelStatus {
fn to_string(&self) -> String {
match self {
KernelStatus::Idle => "Idle".to_string(),
KernelStatus::Busy => "Busy".to_string(),
KernelStatus::Starting => "Starting".to_string(),
KernelStatus::Error => "Error".to_string(),
KernelStatus::ShuttingDown => "Shutting Down".to_string(),
KernelStatus::Shutdown => "Shutdown".to_string(),
KernelStatus::Restarting => "Restarting".to_string(),
}
}
}
#[derive(Debug)]
pub enum Kernel {
RunningKernel(Box<dyn RunningKernel>),
StartingKernel(Shared<Task<()>>),
ErroredLaunch(String),
ShuttingDown,
Shutdown,
Restarting,
}
impl From<&Kernel> for KernelStatus {
fn from(kernel: &Kernel) -> Self {
match kernel {
Kernel::RunningKernel(kernel) => match kernel.execution_state() {
ExecutionState::Idle => KernelStatus::Idle,
ExecutionState::Busy => KernelStatus::Busy,
ExecutionState::Unknown => KernelStatus::Error,
ExecutionState::Starting => KernelStatus::Starting,
ExecutionState::Restarting => KernelStatus::Restarting,
ExecutionState::Terminating => KernelStatus::ShuttingDown,
ExecutionState::AutoRestarting => KernelStatus::Restarting,
ExecutionState::Dead => KernelStatus::Error,
ExecutionState::Other(_) => KernelStatus::Error,
},
Kernel::StartingKernel(_) => KernelStatus::Starting,
Kernel::ErroredLaunch(_) => KernelStatus::Error,
Kernel::ShuttingDown => KernelStatus::ShuttingDown,
Kernel::Shutdown => KernelStatus::Shutdown,
Kernel::Restarting => KernelStatus::Restarting,
}
}
}
impl Kernel {
pub fn status(&self) -> KernelStatus {
self.into()
}
pub fn set_execution_state(&mut self, status: &ExecutionState) {
if let Kernel::RunningKernel(running_kernel) = self {
running_kernel.set_execution_state(status.clone());
}
}
pub fn set_kernel_info(&mut self, kernel_info: &KernelInfoReply) {
if let Kernel::RunningKernel(running_kernel) = self {
running_kernel.set_kernel_info(kernel_info.clone());
}
}
pub fn is_shutting_down(&self) -> bool {
match self {
Kernel::Restarting | Kernel::ShuttingDown => true,
Kernel::RunningKernel(_)
| Kernel::StartingKernel(_)
| Kernel::ErroredLaunch(_)
| Kernel::Shutdown => false,
}
}
}

View File

@@ -0,0 +1,456 @@
use anyhow::{Context as _, Result};
use futures::{
AsyncBufReadExt as _, StreamExt as _,
channel::mpsc::{self},
io::BufReader,
};
use gpui::{App, Entity, EntityId, Task, Window};
use jupyter_protocol::{
ExecutionState, JupyterKernelspec, JupyterMessage, KernelInfoReply,
connection_info::{ConnectionInfo, Transport},
};
use project::Fs;
use runtimelib::dirs;
use smol::net::TcpListener;
use std::{
env,
fmt::Debug,
net::{IpAddr, Ipv4Addr, SocketAddr},
path::PathBuf,
sync::Arc,
};
use uuid::Uuid;
use super::{KernelSession, RunningKernel, start_kernel_tasks};
#[derive(Debug, Clone)]
pub struct LocalKernelSpecification {
pub name: String,
pub path: PathBuf,
pub kernelspec: JupyterKernelspec,
}
impl PartialEq for LocalKernelSpecification {
fn eq(&self, other: &Self) -> bool {
self.name == other.name && self.path == other.path
}
}
impl Eq for LocalKernelSpecification {}
impl LocalKernelSpecification {
#[must_use]
fn command(&self, connection_path: &PathBuf) -> Result<std::process::Command> {
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 = util::command::new_std_command(&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 {
log::info!(
"LocalKernelSpecification: applying env to command: {:?}",
env.keys()
);
cmd.envs(env);
} else {
log::info!("LocalKernelSpecification: no env in kernelspec");
}
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) -> 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)
}
pub struct NativeRunningKernel {
pub process: util::process::Child,
connection_path: PathBuf,
_process_status_task: Option<Task<()>>,
pub working_directory: PathBuf,
pub request_tx: mpsc::Sender<JupyterMessage>,
pub stdin_tx: mpsc::Sender<JupyterMessage>,
pub execution_state: ExecutionState,
pub kernel_info: Option<KernelInfoReply>,
}
impl Debug for NativeRunningKernel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RunningKernel")
.field("process", &*self.process)
.finish()
}
}
impl NativeRunningKernel {
pub fn new<S: KernelSession + 'static>(
kernel_specification: LocalKernelSpecification,
entity_id: EntityId,
working_directory: PathBuf,
fs: Arc<dyn Fs>,
// todo: convert to weak view
session: Entity<S>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Box<dyn RunningKernel>>> {
window.spawn(cx, async move |cx| {
let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
let ports = peek_ports(ip).await?;
let connection_info = ConnectionInfo {
transport: Transport::TCP,
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 runtime_dir = dirs::runtime_dir();
fs.create_dir(&runtime_dir)
.await
.with_context(|| format!("Failed to create jupyter runtime dir {runtime_dir:?}"))?;
let connection_path = runtime_dir.join(format!("kernel-zed-{entity_id}.json"));
let content = serde_json::to_string(&connection_info)?;
fs.atomic_write(connection_path.clone(), content).await?;
let mut cmd = kernel_specification.command(&connection_path)?;
cmd.current_dir(&working_directory);
let mut process = util::process::Child::spawn(
cmd,
std::process::Stdio::piped(),
std::process::Stdio::piped(),
std::process::Stdio::piped(),
)?;
let session_id = Uuid::new_v4().to_string();
let iopub_socket =
runtimelib::create_client_iopub_connection(&connection_info, "", &session_id)
.await?;
let control_socket =
runtimelib::create_client_control_connection(&connection_info, &session_id).await?;
let peer_identity = runtimelib::peer_identity_for_session(&session_id)?;
let shell_socket = runtimelib::create_client_shell_connection_with_identity(
&connection_info,
&session_id,
peer_identity.clone(),
)
.await?;
let stdin_socket = runtimelib::create_client_stdin_connection_with_identity(
&connection_info,
&session_id,
peer_identity,
)
.await?;
let (request_tx, stdin_tx) = start_kernel_tasks(
session.clone(),
iopub_socket,
shell_socket,
control_socket,
stdin_socket,
cx,
);
let stderr = process.stderr.take();
let stdout = process.stdout.take();
cx.spawn(async move |_cx| {
use futures::future::Either;
let stderr_lines = match stderr {
Some(s) => Either::Left(
BufReader::new(s)
.lines()
.map(|line| (log::Level::Error, line)),
),
None => Either::Right(futures::stream::empty()),
};
let stdout_lines = match stdout {
Some(s) => Either::Left(
BufReader::new(s)
.lines()
.map(|line| (log::Level::Info, line)),
),
None => Either::Right(futures::stream::empty()),
};
let mut lines = futures::stream::select(stderr_lines, stdout_lines);
while let Some((level, Ok(line))) = lines.next().await {
log::log!(level, "kernel: {}", line);
}
})
.detach();
let status = process.status();
let process_status_task = cx.spawn(async move |cx| {
let error_message = match status.await {
Ok(status) => {
if status.success() {
log::info!("kernel process exited successfully");
return;
}
format!("kernel process exited with status: {:?}", status)
}
Err(err) => {
format!("kernel process exited with error: {:?}", err)
}
};
log::error!("{}", error_message);
session.update(cx, |session, cx| {
session.kernel_errored(error_message, cx);
cx.notify();
});
});
anyhow::Ok(Box::new(Self {
process,
request_tx,
stdin_tx,
working_directory,
_process_status_task: Some(process_status_task),
connection_path,
execution_state: ExecutionState::Idle,
kernel_info: None,
}) as Box<dyn RunningKernel>)
})
}
}
impl RunningKernel for NativeRunningKernel {
fn request_tx(&self) -> mpsc::Sender<JupyterMessage> {
self.request_tx.clone()
}
fn stdin_tx(&self) -> mpsc::Sender<JupyterMessage> {
self.stdin_tx.clone()
}
fn working_directory(&self) -> &PathBuf {
&self.working_directory
}
fn execution_state(&self) -> &ExecutionState {
&self.execution_state
}
fn set_execution_state(&mut self, state: ExecutionState) {
self.execution_state = state;
}
fn kernel_info(&self) -> Option<&KernelInfoReply> {
self.kernel_info.as_ref()
}
fn set_kernel_info(&mut self, info: KernelInfoReply) {
self.kernel_info = Some(info);
}
fn force_shutdown(&mut self, _window: &mut Window, _cx: &mut App) -> Task<anyhow::Result<()>> {
self.kill();
Task::ready(Ok(()))
}
fn kill(&mut self) {
self._process_status_task.take();
self.request_tx.close_channel();
self.stdin_tx.close_channel();
self.process.kill().ok();
}
}
impl Drop for NativeRunningKernel {
fn drop(&mut self) {
std::fs::remove_file(&self.connection_path).ok();
self.kill();
}
}
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,
) -> Result<LocalKernelSpecification> {
let path = kernel_dir;
let kernel_name = if let Some(kernel_name) = path.file_name() {
kernel_name.to_string_lossy().into_owned()
} 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::<JupyterKernelspec>(&spec)?;
Ok(LocalKernelSpecification {
name: kernel_name,
path,
kernelspec: spec,
})
}
/// Read a directory of kernelspec directories
async fn read_kernels_dir(path: PathBuf, fs: &dyn Fs) -> Result<Vec<LocalKernelSpecification>> {
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 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 local_kernel_specifications(fs: Arc<dyn Fs>) -> Result<Vec<LocalKernelSpecification>> {
let mut data_dirs = dirs::data_dirs();
// Pick up any kernels from conda or conda environment
if let Ok(conda_prefix) = env::var("CONDA_PREFIX") {
let conda_prefix = PathBuf::from(conda_prefix);
let conda_data_dir = conda_prefix.join("share").join("jupyter");
data_dirs.push(conda_data_dir);
}
// Search for kernels inside the base python environment
let command = util::command::new_command("python")
.arg("-c")
.arg("import sys; print(sys.prefix)")
.output()
.await;
if let Ok(command) = command
&& command.status.success()
{
let python_prefix = String::from_utf8(command.stdout);
if let Ok(python_prefix) = python_prefix {
let python_prefix = PathBuf::from(python_prefix.trim());
let python_data_dir = python_prefix.join("share").join("jupyter");
data_dirs.push(python_data_dir);
}
}
let kernel_dirs = data_dirs
.iter()
.map(|dir| dir.join("kernels"))
.map(|path| read_kernels_dir(path, fs.as_ref()))
.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.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<_>>(),
vec!["deno", "python"]
);
}
}

View File

@@ -0,0 +1,307 @@
use futures::{SinkExt as _, channel::mpsc};
use gpui::{App, AppContext as _, Entity, Task, Window};
use http_client::{AsyncBody, HttpClient, Request};
use jupyter_protocol::{ExecutionState, JupyterKernelspec, JupyterMessage, KernelInfoReply};
use async_tungstenite::tokio::connect_async;
use async_tungstenite::tungstenite::{client::IntoClientRequest, http::HeaderValue};
use futures::StreamExt;
use smol::io::AsyncReadExt as _;
use super::{KernelSession, RunningKernel};
use anyhow::Result;
use jupyter_websocket_client::{
JupyterWebSocket, JupyterWebSocketReader, JupyterWebSocketWriter, KernelLaunchRequest,
KernelSpecsResponse, ProtocolMode, RemoteServer,
};
use std::{fmt::Debug, sync::Arc};
#[derive(Debug, Clone)]
pub struct RemoteKernelSpecification {
pub name: String,
pub url: String,
pub token: String,
pub kernelspec: JupyterKernelspec,
}
pub async fn launch_remote_kernel(
remote_server: &RemoteServer,
http_client: Arc<dyn HttpClient>,
kernel_name: &str,
_path: &str,
) -> Result<String> {
//
let kernel_launch_request = KernelLaunchRequest {
name: kernel_name.to_string(),
// Note: since the path we have locally may not be the same as the one on the remote server,
// we don't send it. We'll have to evaluate this decision along the way.
path: None,
};
let kernel_launch_request = serde_json::to_string(&kernel_launch_request)?;
let request = Request::builder()
.method("POST")
.uri(&remote_server.api_url("/kernels"))
.header("Authorization", format!("token {}", remote_server.token))
.body(AsyncBody::from(kernel_launch_request))?;
let response = http_client.send(request).await?;
if !response.status().is_success() {
let mut body = String::new();
response.into_body().read_to_string(&mut body).await?;
anyhow::bail!("Failed to launch kernel: {body}");
}
let mut body = String::new();
response.into_body().read_to_string(&mut body).await?;
let response: jupyter_websocket_client::Kernel = serde_json::from_str(&body)?;
Ok(response.id)
}
pub async fn list_remote_kernelspecs(
remote_server: RemoteServer,
http_client: Arc<dyn HttpClient>,
) -> Result<Vec<RemoteKernelSpecification>> {
let url = remote_server.api_url("/kernelspecs");
let request = Request::builder()
.method("GET")
.uri(&url)
.header("Authorization", format!("token {}", remote_server.token))
.body(AsyncBody::default())?;
let response = http_client.send(request).await?;
anyhow::ensure!(
response.status().is_success(),
"Failed to fetch kernel specs: {}",
response.status()
);
let mut body = response.into_body();
let mut body_bytes = Vec::new();
body.read_to_end(&mut body_bytes).await?;
let kernel_specs: KernelSpecsResponse = serde_json::from_slice(&body_bytes)?;
let remote_kernelspecs = kernel_specs
.kernelspecs
.into_iter()
.map(|(name, spec)| RemoteKernelSpecification {
name,
url: remote_server.base_url.clone(),
token: remote_server.token.clone(),
kernelspec: spec.spec,
})
.collect::<Vec<RemoteKernelSpecification>>();
anyhow::ensure!(!remote_kernelspecs.is_empty(), "No kernel specs found");
Ok(remote_kernelspecs)
}
impl PartialEq for RemoteKernelSpecification {
fn eq(&self, other: &Self) -> bool {
self.name == other.name && self.url == other.url
}
}
impl Eq for RemoteKernelSpecification {}
pub struct RemoteRunningKernel {
remote_server: RemoteServer,
_receiving_task: Task<Result<()>>,
_routing_task: Task<Result<()>>,
http_client: Arc<dyn HttpClient>,
pub working_directory: std::path::PathBuf,
pub request_tx: mpsc::Sender<JupyterMessage>,
pub stdin_tx: mpsc::Sender<JupyterMessage>,
pub execution_state: ExecutionState,
pub kernel_info: Option<KernelInfoReply>,
pub kernel_id: String,
}
impl RemoteRunningKernel {
pub fn new<S: KernelSession + 'static>(
kernelspec: RemoteKernelSpecification,
working_directory: std::path::PathBuf,
session: Entity<S>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Box<dyn RunningKernel>>> {
let remote_server = RemoteServer {
base_url: kernelspec.url,
token: kernelspec.token,
};
let http_client = cx.http_client();
window.spawn(cx, async move |cx| {
let kernel_id = launch_remote_kernel(
&remote_server,
http_client.clone(),
&kernelspec.name,
working_directory.to_str().unwrap_or_default(),
)
.await?;
let ws_url = format!(
"{}/api/kernels/{}/channels?token={}",
remote_server.base_url.replace("http", "ws"),
kernel_id,
remote_server.token
);
let mut req: Request<()> = ws_url.into_client_request()?;
let headers = req.headers_mut();
headers.insert(
"User-Agent",
HeaderValue::from_str(&format!(
"Zed/{} ({}; {})",
"repl",
std::env::consts::OS,
std::env::consts::ARCH
))?,
);
let response = connect_async(req).await;
let (ws_stream, _response) = response?;
let kernel_socket = JupyterWebSocket {
inner: ws_stream,
protocol_mode: ProtocolMode::Json,
};
let (mut w, mut r): (JupyterWebSocketWriter, JupyterWebSocketReader) =
kernel_socket.split();
let (request_tx, mut request_rx) =
futures::channel::mpsc::channel::<JupyterMessage>(100);
let routing_task = cx.background_spawn({
async move {
while let Some(message) = request_rx.next().await {
w.send(message).await.ok();
}
Ok(())
}
});
let receiving_task = cx.spawn({
let session = session.clone();
async move |cx| {
while let Some(message) = r.next().await {
match message {
Ok(message) => {
session
.update_in(cx, |session, window, cx| {
session.route(&message, window, cx);
})
.ok();
}
Err(e) => {
log::error!("Error receiving message: {:?}", e);
}
}
}
Ok(())
}
});
let stdin_tx = request_tx.clone();
anyhow::Ok(Box::new(Self {
_routing_task: routing_task,
_receiving_task: receiving_task,
remote_server,
working_directory,
request_tx,
stdin_tx,
// todo(kyle): pull this from the kernel API to start with
execution_state: ExecutionState::Idle,
kernel_info: None,
kernel_id,
http_client: http_client.clone(),
}) as Box<dyn RunningKernel>)
})
}
}
impl Debug for RemoteRunningKernel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RemoteRunningKernel")
// custom debug that keeps tokens out of logs
.field("remote_server url", &self.remote_server.base_url)
.field("working_directory", &self.working_directory)
.field("request_tx", &self.request_tx)
.field("execution_state", &self.execution_state)
.field("kernel_info", &self.kernel_info)
.finish()
}
}
impl RunningKernel for RemoteRunningKernel {
fn request_tx(&self) -> futures::channel::mpsc::Sender<runtimelib::JupyterMessage> {
self.request_tx.clone()
}
fn stdin_tx(&self) -> futures::channel::mpsc::Sender<runtimelib::JupyterMessage> {
self.stdin_tx.clone()
}
fn working_directory(&self) -> &std::path::PathBuf {
&self.working_directory
}
fn execution_state(&self) -> &runtimelib::ExecutionState {
&self.execution_state
}
fn set_execution_state(&mut self, state: runtimelib::ExecutionState) {
self.execution_state = state;
}
fn kernel_info(&self) -> Option<&runtimelib::KernelInfoReply> {
self.kernel_info.as_ref()
}
fn set_kernel_info(&mut self, info: runtimelib::KernelInfoReply) {
self.kernel_info = Some(info);
}
fn force_shutdown(&mut self, window: &mut Window, cx: &mut App) -> Task<anyhow::Result<()>> {
let url = self
.remote_server
.api_url(&format!("/kernels/{}", self.kernel_id));
let token = self.remote_server.token.clone();
let http_client = self.http_client.clone();
window.spawn(cx, async move |_| {
let request = Request::builder()
.method("DELETE")
.uri(&url)
.header("Authorization", format!("token {}", token))
.body(AsyncBody::default())?;
let response = http_client.send(request).await?;
anyhow::ensure!(
response.status().is_success(),
"Failed to shutdown kernel: {}",
response.status()
);
Ok(())
})
}
fn kill(&mut self) {
self.request_tx.close_channel();
self.stdin_tx.close_channel();
}
}

View File

@@ -0,0 +1,312 @@
use super::{KernelSession, RunningKernel, SshRemoteKernelSpecification, start_kernel_tasks};
use anyhow::{Context as _, Result};
use client::proto;
use futures::{
AsyncBufReadExt as _, StreamExt as _,
channel::mpsc::{self},
io::BufReader,
};
use gpui::{App, Entity, Task, Window};
use project::Project;
use runtimelib::{ExecutionState, JupyterMessage, KernelInfoReply};
use std::path::PathBuf;
use util::ResultExt;
#[derive(Debug)]
pub struct SshRunningKernel {
request_tx: mpsc::Sender<JupyterMessage>,
stdin_tx: mpsc::Sender<JupyterMessage>,
execution_state: ExecutionState,
kernel_info: Option<KernelInfoReply>,
working_directory: PathBuf,
_ssh_tunnel_process: util::command::Child,
_local_connection_file: PathBuf,
kernel_id: String,
project: Entity<Project>,
project_id: u64,
}
impl SshRunningKernel {
pub fn new<S: KernelSession + 'static>(
kernel_spec: SshRemoteKernelSpecification,
working_directory: PathBuf,
project: Entity<Project>,
session: Entity<S>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Box<dyn RunningKernel>>> {
let client = project.read(cx).client();
let remote_client = project.read(cx).remote_client();
let project_id = project
.read(cx)
.remote_id()
.unwrap_or(proto::REMOTE_SERVER_PROJECT_ID);
window.spawn(cx, async move |cx| {
let command = kernel_spec
.kernelspec
.argv
.first()
.cloned()
.unwrap_or_default();
let args = kernel_spec
.kernelspec
.argv
.iter()
.skip(1)
.cloned()
.collect();
let request = proto::SpawnKernel {
kernel_name: kernel_spec.name.clone(),
working_directory: working_directory.to_string_lossy().to_string(),
project_id,
command,
args,
};
let response = if let Some(remote_client) = remote_client.as_ref() {
remote_client
.read_with(cx, |client, _| client.proto_client())
.request(request)
.await?
} else {
client.request(request).await?
};
let kernel_id = response.kernel_id.clone();
let connection_info: serde_json::Value =
serde_json::from_str(&response.connection_file)?;
// Setup SSH Tunneling - allocate local ports
let mut local_ports = Vec::new();
for _ in 0..5 {
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
drop(listener);
local_ports.push(port);
}
let remote_shell_port = connection_info["shell_port"]
.as_u64()
.context("missing shell_port")? as u16;
let remote_iopub_port = connection_info["iopub_port"]
.as_u64()
.context("missing iopub_port")? as u16;
let remote_stdin_port = connection_info["stdin_port"]
.as_u64()
.context("missing stdin_port")? as u16;
let remote_control_port = connection_info["control_port"]
.as_u64()
.context("missing control_port")? as u16;
let remote_hb_port = connection_info["hb_port"]
.as_u64()
.context("missing hb_port")? as u16;
let forwards = vec![
(local_ports[0], "127.0.0.1".to_string(), remote_shell_port),
(local_ports[1], "127.0.0.1".to_string(), remote_iopub_port),
(local_ports[2], "127.0.0.1".to_string(), remote_stdin_port),
(local_ports[3], "127.0.0.1".to_string(), remote_control_port),
(local_ports[4], "127.0.0.1".to_string(), remote_hb_port),
];
let remote_client = remote_client.ok_or_else(|| anyhow::anyhow!("no remote client"))?;
let command_template = cx.update(|_window, cx| {
remote_client.read(cx).build_forward_ports_command(forwards)
})??;
let mut command = util::command::new_command(&command_template.program);
command.args(&command_template.args);
command.envs(&command_template.env);
let mut ssh_tunnel_process = command.spawn().context("failed to spawn ssh tunnel")?;
let stderr = ssh_tunnel_process.stderr.take();
cx.spawn(async move |_cx| {
if let Some(stderr) = stderr {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
while let Some(Ok(line)) = lines.next().await {
log::warn!("ssh tunnel stderr: {}", line);
}
}
})
.detach();
let stdout = ssh_tunnel_process.stdout.take();
cx.spawn(async move |_cx| {
if let Some(stdout) = stdout {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Some(Ok(line)) = lines.next().await {
log::debug!("ssh tunnel stdout: {}", line);
}
}
})
.detach();
// We might or might not need this, perhaps we can just wait for a second or test it this way
let shell_port = local_ports[0];
let max_attempts = 100;
let mut connected = false;
for attempt in 0..max_attempts {
match smol::net::TcpStream::connect(format!("127.0.0.1:{}", shell_port)).await {
Ok(_) => {
connected = true;
log::info!(
"SSH tunnel established for kernel {} on attempt {}",
kernel_id,
attempt + 1
);
// giving the tunnel a moment to fully establish forwarding
cx.background_executor()
.timer(std::time::Duration::from_millis(500))
.await;
break;
}
Err(err) => {
if attempt % 10 == 0 {
log::debug!(
"Waiting for SSH tunnel (attempt {}/{}): {}",
attempt + 1,
max_attempts,
err
);
}
if attempt < max_attempts - 1 {
cx.background_executor()
.timer(std::time::Duration::from_millis(100))
.await;
}
}
}
}
if !connected {
anyhow::bail!(
"SSH tunnel failed to establish after {} attempts",
max_attempts
);
}
let mut local_connection_info = connection_info.clone();
local_connection_info["shell_port"] = serde_json::json!(local_ports[0]);
local_connection_info["iopub_port"] = serde_json::json!(local_ports[1]);
local_connection_info["stdin_port"] = serde_json::json!(local_ports[2]);
local_connection_info["control_port"] = serde_json::json!(local_ports[3]);
local_connection_info["hb_port"] = serde_json::json!(local_ports[4]);
local_connection_info["ip"] = serde_json::json!("127.0.0.1");
let local_connection_file =
std::env::temp_dir().join(format!("zed_ssh_kernel_{}.json", kernel_id));
std::fs::write(
&local_connection_file,
serde_json::to_string_pretty(&local_connection_info)?,
)?;
// Parse connection info and create ZMQ connections
let connection_info_struct: runtimelib::ConnectionInfo =
serde_json::from_value(local_connection_info)?;
let session_id = uuid::Uuid::new_v4().to_string();
let output_socket = runtimelib::create_client_iopub_connection(
&connection_info_struct,
"",
&session_id,
)
.await
.context("Failed to create iopub connection. Is `ipykernel` installed in the remote environment? Try running `pip install ipykernel` on the remote host.")?;
let peer_identity = runtimelib::peer_identity_for_session(&session_id)?;
let shell_socket = runtimelib::create_client_shell_connection_with_identity(
&connection_info_struct,
&session_id,
peer_identity.clone(),
)
.await
.context("failed to create shell connection")?;
let control_socket =
runtimelib::create_client_control_connection(&connection_info_struct, &session_id)
.await
.context("failed to create control connection")?;
let stdin_socket = runtimelib::create_client_stdin_connection_with_identity(
&connection_info_struct,
&session_id,
peer_identity,
)
.await
.context("failed to create stdin connection")?;
let (request_tx, stdin_tx) = start_kernel_tasks(
session.clone(),
output_socket,
shell_socket,
control_socket,
stdin_socket,
cx,
);
Ok(Box::new(SshRunningKernel {
request_tx,
stdin_tx,
execution_state: ExecutionState::Idle,
kernel_info: None,
working_directory,
_ssh_tunnel_process: ssh_tunnel_process,
_local_connection_file: local_connection_file,
kernel_id,
project,
project_id,
}) as Box<dyn RunningKernel>)
})
}
}
impl RunningKernel for SshRunningKernel {
fn request_tx(&self) -> mpsc::Sender<JupyterMessage> {
self.request_tx.clone()
}
fn stdin_tx(&self) -> mpsc::Sender<JupyterMessage> {
self.stdin_tx.clone()
}
fn working_directory(&self) -> &PathBuf {
&self.working_directory
}
fn execution_state(&self) -> &ExecutionState {
&self.execution_state
}
fn set_execution_state(&mut self, state: ExecutionState) {
self.execution_state = state;
}
fn kernel_info(&self) -> Option<&KernelInfoReply> {
self.kernel_info.as_ref()
}
fn set_kernel_info(&mut self, info: KernelInfoReply) {
self.kernel_info = Some(info);
}
fn force_shutdown(&mut self, _window: &mut Window, cx: &mut App) -> Task<Result<()>> {
let kernel_id = self.kernel_id.clone();
let project_id = self.project_id;
let client = self.project.read(cx).client();
cx.background_executor().spawn(async move {
let request = proto::KillKernel {
kernel_id,
project_id,
};
client.request::<proto::KillKernel>(request).await?;
Ok(())
})
}
fn kill(&mut self) {
self._ssh_tunnel_process.kill().log_err();
}
}

View File

@@ -0,0 +1,598 @@
use super::{
KernelSession, KernelSpecification, RunningKernel, WslKernelSpecification,
build_python_exec_shell_script, start_kernel_tasks,
};
use anyhow::{Context as _, Result};
use futures::{
AsyncBufReadExt as _, StreamExt as _,
channel::mpsc::{self},
io::BufReader,
};
use gpui::{App, BackgroundExecutor, Entity, EntityId, Task, Window};
use jupyter_protocol::{
ExecutionState, JupyterMessage, KernelInfoReply,
connection_info::{ConnectionInfo, Transport},
};
use project::Fs;
use runtimelib::dirs;
use smol::net::TcpListener;
use std::{
fmt::Debug,
net::{IpAddr, Ipv4Addr, SocketAddr},
path::PathBuf,
sync::Arc,
};
use uuid::Uuid;
// 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) -> 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)
}
pub struct WslRunningKernel {
pub process: util::command::Child,
connection_path: PathBuf,
_process_status_task: Option<Task<()>>,
pub working_directory: PathBuf,
pub request_tx: mpsc::Sender<JupyterMessage>,
pub stdin_tx: mpsc::Sender<JupyterMessage>,
pub execution_state: ExecutionState,
pub kernel_info: Option<KernelInfoReply>,
}
impl Debug for WslRunningKernel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WslRunningKernel")
.field("process", &self.process)
.finish()
}
}
fn quote_posix_shell_arguments(arguments: &[String]) -> Result<String> {
let mut quoted_arguments = Vec::with_capacity(arguments.len());
for argument in arguments {
let quoted = shlex::try_quote(argument).map(|quoted| quoted.into_owned())?;
quoted_arguments.push(quoted);
}
Ok(quoted_arguments.join(" "))
}
impl WslRunningKernel {
pub fn new<S: KernelSession + 'static>(
kernel_specification: WslKernelSpecification,
entity_id: EntityId,
working_directory: PathBuf,
fs: Arc<dyn Fs>,
session: Entity<S>,
window: &mut Window,
cx: &mut App,
) -> Task<Result<Box<dyn RunningKernel>>> {
window.spawn(cx, async move |cx| {
// For WSL2, we need to get the WSL VM's IP address to connect to it
// because WSL2 runs in a lightweight VM with its own network namespace.
// The kernel will bind to 127.0.0.1 inside WSL, and we connect to localhost.
// WSL2 localhost forwarding handles the rest.
let bind_ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
// Use 127.0.0.1 and rely on WSL 2 localhost forwarding.
// This avoids issues where the VM IP is unreachable or binding fails on Windows.
let connect_ip = "127.0.0.1".to_string();
let ports = peek_ports(bind_ip).await?;
let connection_info = ConnectionInfo {
transport: Transport::TCP,
ip: bind_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-wsl-{}", kernel_specification.name)),
};
let runtime_dir = dirs::runtime_dir();
fs.create_dir(&runtime_dir)
.await
.with_context(|| format!("Failed to create jupyter runtime dir {runtime_dir:?}"))?;
let connection_path = runtime_dir.join(format!("kernel-zed-wsl-{entity_id}.json"));
let content = serde_json::to_string(&connection_info)?;
fs.atomic_write(connection_path.clone(), content).await?;
// Convert connection_path to WSL path
// yeah we can't assume this is available on WSL.
// running `wsl -d <distro> wslpath -u <windows_path>`
let mut wslpath_cmd = util::command::new_command("wsl");
// On Windows, passing paths with backslashes to wsl.exe can sometimes cause
// escaping issues or be misinterpreted. Converting to forward slashes is safer
// and often accepted by wslpath.
let connection_path_str = connection_path.to_string_lossy().replace('\\', "/");
wslpath_cmd
.arg("-d")
.arg(&kernel_specification.distro)
.arg("wslpath")
.arg("-u")
.arg(&connection_path_str);
let output = wslpath_cmd.output().await?;
if !output.status.success() {
anyhow::bail!("Failed to convert path to WSL path: {:?}", output);
}
let wsl_connection_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
// Construct the kernel command
// The kernel spec argv might have absolute paths valid INSIDE WSL.
// We need to run inside WSL.
// `wsl -d <distro> --exec <argv0> <argv1> ...`
// But we need to replace {connection_file} with wsl_connection_path.
anyhow::ensure!(
!kernel_specification.kernelspec.argv.is_empty(),
"Empty argv in kernelspec {}",
kernel_specification.name
);
let working_directory_str = working_directory.to_string_lossy().replace('\\', "/");
let wsl_working_directory = if working_directory_str.starts_with('/') {
// If path starts with /, assume it is already a WSL path (e.g. /home/user)
Some(working_directory_str)
} else {
let mut wslpath_wd_cmd = util::command::new_command("wsl");
wslpath_wd_cmd
.arg("-d")
.arg(&kernel_specification.distro)
.arg("wslpath")
.arg("-u")
.arg(&working_directory_str);
let wd_output = wslpath_wd_cmd.output().await;
if let Ok(output) = wd_output {
if output.status.success() {
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
None
}
} else {
None
}
};
// If we couldn't convert the working directory or it's a temp directory,
// and the kernel spec uses a relative path (like .venv/bin/python),
// we need to handle this better. For now, let's use the converted path
// if available, otherwise we'll rely on WSL's default home directory.
let mut cmd = util::command::new_command("wsl");
cmd.arg("-d").arg(&kernel_specification.distro);
// Set CWD for the host process to a safe location to avoid "Directory name is invalid"
// if the project root is a path not supported by Windows CWD (e.g. UNC path for some tools).
cmd.current_dir(std::env::temp_dir());
if let Some(wd) = wsl_working_directory.as_ref() {
cmd.arg("--cd").arg(wd);
}
// Build the command to run inside WSL
// We use bash -lc to run in a login shell for proper environment setup
let mut kernel_args: Vec<String> = Vec::new();
let resolved_argv: Vec<String> = kernel_specification
.kernelspec
.argv
.iter()
.map(|arg| {
if arg == "{connection_file}" {
wsl_connection_path.clone()
} else {
arg.clone()
}
})
.collect();
let executable = resolved_argv.first().map(String::as_str);
let needs_python_resolution = executable.map_or(false, |executable| {
executable == "python" || executable == "python3" || !executable.starts_with('/')
});
let mut env_assignments: Vec<String> = Vec::new();
if let Some(env) = &kernel_specification.kernelspec.env {
env_assignments.reserve(env.len());
for (key, value) in env {
let assignment = format!("{key}={value}");
let assignment = shlex::try_quote(&assignment)
.map(|quoted| quoted.into_owned())?;
env_assignments.push(assignment);
}
if !env_assignments.is_empty() {
kernel_args.push("env".to_string());
kernel_args.extend(env_assignments.iter().cloned());
}
}
kernel_args.extend(resolved_argv.iter().cloned());
let shell_command = if needs_python_resolution {
let rest_args: Vec<String> = resolved_argv.iter().skip(1).cloned().collect();
let arg_string = quote_posix_shell_arguments(&rest_args)?;
let set_env_command = if env_assignments.is_empty() {
String::new()
} else {
format!("export {}; ", env_assignments.join(" "))
};
let cd_command = if let Some(wd) = wsl_working_directory.as_ref() {
let quoted_wd = shlex::try_quote(wd)
.map(|quoted| quoted.into_owned())?;
format!("cd {quoted_wd} && ")
} else {
String::new()
};
build_python_exec_shell_script(&arg_string, &cd_command, &set_env_command)
} else {
let args_string = quote_posix_shell_arguments(&resolved_argv)?;
let cd_command = if let Some(wd) = wsl_working_directory.as_ref() {
let quoted_wd = shlex::try_quote(wd)
.map(|quoted| quoted.into_owned())?;
format!("cd {quoted_wd} && ")
} else {
String::new()
};
let env_prefix_inline = if !env_assignments.is_empty() {
format!("env {} ", env_assignments.join(" "))
} else {
String::new()
};
format!("{cd_command}exec {env_prefix_inline}{args_string}")
};
cmd.arg("bash")
.arg("-l")
.arg("-c")
.arg(&shell_command);
let mut process = cmd
.stdout(util::command::Stdio::piped())
.stderr(util::command::Stdio::piped())
.stdin(util::command::Stdio::piped())
.kill_on_drop(true)
.spawn()
.context("failed to start the kernel process")?;
let session_id = Uuid::new_v4().to_string();
let mut client_connection_info = connection_info.clone();
client_connection_info.ip = connect_ip.clone();
// Give the kernel a moment to start and bind to ports.
// WSL kernel startup can be slow, I am not sure if this is because of my testing environment
// or inherent to WSL. We can improve this later with better readiness checks.
cx.background_executor()
.timer(std::time::Duration::from_secs(2))
.await;
match process.try_status() {
Ok(Some(status)) => {
let mut stderr_content = String::new();
if let Some(mut stderr) = process.stderr.take() {
use futures::AsyncReadExt;
let mut buf = Vec::new();
if stderr.read_to_end(&mut buf).await.is_ok() {
stderr_content = String::from_utf8_lossy(&buf).to_string();
}
}
let mut stdout_content = String::new();
if let Some(mut stdout) = process.stdout.take() {
use futures::AsyncReadExt;
let mut buf = Vec::new();
if stdout.read_to_end(&mut buf).await.is_ok() {
stdout_content = String::from_utf8_lossy(&buf).to_string();
}
}
anyhow::bail!(
"WSL kernel process exited prematurely with status: {:?}\nstderr: {}\nstdout: {}",
status,
stderr_content,
stdout_content
);
}
Ok(None) => {}
Err(_) => {}
}
let output_socket = runtimelib::create_client_iopub_connection(
&client_connection_info,
"",
&session_id,
)
.await
.context("Failed to create iopub connection. Is `ipykernel` installed in the WSL environment? Try running `pip install ipykernel` inside your WSL distribution.")?;
let peer_identity = runtimelib::peer_identity_for_session(&session_id)?;
let shell_socket = runtimelib::create_client_shell_connection_with_identity(
&client_connection_info,
&session_id,
peer_identity.clone(),
)
.await?;
let control_socket =
runtimelib::create_client_control_connection(&client_connection_info, &session_id)
.await?;
let stdin_socket = runtimelib::create_client_stdin_connection_with_identity(
&client_connection_info,
&session_id,
peer_identity,
)
.await?;
let (request_tx, stdin_tx) = start_kernel_tasks(
session.clone(),
output_socket,
shell_socket,
control_socket,
stdin_socket,
cx,
);
let stderr = process.stderr.take();
cx.spawn(async move |_cx| {
if let Some(stderr) = stderr {
let reader = BufReader::new(stderr);
let mut lines = reader.lines();
while let Some(Ok(line)) = lines.next().await {
log::warn!("wsl kernel stderr: {}", line);
}
}
})
.detach();
let stdout = process.stdout.take();
cx.spawn(async move |_cx| {
if let Some(stdout) = stdout {
let reader = BufReader::new(stdout);
let mut lines = reader.lines();
while let Some(Ok(_line)) = lines.next().await {}
}
})
.detach();
let status = process.status();
let process_status_task = cx.spawn(async move |cx| {
let error_message = match status.await {
Ok(status) => {
if status.success() {
return;
}
format!("WSL kernel: kernel process exited with status: {:?}", status)
}
Err(err) => {
format!("WSL kernel: kernel process exited with error: {:?}", err)
}
};
session.update(cx, |session, cx| {
session.kernel_errored(error_message, cx);
cx.notify();
});
});
anyhow::Ok(Box::new(Self {
process,
request_tx,
stdin_tx,
working_directory,
_process_status_task: Some(process_status_task),
connection_path,
execution_state: ExecutionState::Idle,
kernel_info: None,
}) as Box<dyn RunningKernel>)
})
}
}
impl RunningKernel for WslRunningKernel {
fn request_tx(&self) -> mpsc::Sender<JupyterMessage> {
self.request_tx.clone()
}
fn stdin_tx(&self) -> mpsc::Sender<JupyterMessage> {
self.stdin_tx.clone()
}
fn working_directory(&self) -> &PathBuf {
&self.working_directory
}
fn execution_state(&self) -> &ExecutionState {
&self.execution_state
}
fn set_execution_state(&mut self, state: ExecutionState) {
self.execution_state = state;
}
fn kernel_info(&self) -> Option<&KernelInfoReply> {
self.kernel_info.as_ref()
}
fn set_kernel_info(&mut self, info: KernelInfoReply) {
self.kernel_info = Some(info);
}
fn force_shutdown(&mut self, _window: &mut Window, _cx: &mut App) -> Task<anyhow::Result<()>> {
self._process_status_task.take();
self.request_tx.close_channel();
self.process.kill().ok();
Task::ready(Ok(()))
}
fn kill(&mut self) {
self._process_status_task.take();
self.request_tx.close_channel();
self.process.kill().ok();
}
}
impl Drop for WslRunningKernel {
fn drop(&mut self) {
std::fs::remove_file(&self.connection_path).ok();
self.request_tx.close_channel();
self.process.kill().ok();
}
}
#[derive(serde::Deserialize)]
struct LocalKernelSpecsResponse {
kernelspecs: std::collections::HashMap<String, LocalKernelSpec>,
}
#[derive(serde::Deserialize)]
struct LocalKernelSpec {
spec: LocalKernelSpecContent,
}
#[derive(serde::Deserialize)]
struct LocalKernelSpecContent {
argv: Vec<String>,
display_name: String,
language: String,
interrupt_mode: Option<String>,
env: Option<std::collections::HashMap<String, String>>,
metadata: Option<std::collections::HashMap<String, serde_json::Value>>,
}
pub async fn wsl_kernel_specifications(
background_executor: BackgroundExecutor,
) -> Result<Vec<KernelSpecification>> {
let output = util::command::new_command("wsl")
.arg("-l")
.arg("-q")
.output()
.await;
if output.is_err() {
return Ok(Vec::new());
}
let output = output.unwrap();
if !output.status.success() {
return Ok(Vec::new());
}
// wsl output is often UTF-16LE, but -l -q might be simpler or just ASCII compatible if not using weird charsets.
// However, on Windows, wsl often outputs UTF-16LE.
// We can try to detect or use from_utf16 if valid, or just use String::from_utf8_lossy and see.
// Actually, `smol::process` on Windows might receive bytes that are UTF-16LE if wsl writes that.
// But typically terminal output for wsl is UTF-16.
// Let's try to parse as UTF-16LE if it looks like it (BOM or just 00 bytes).
let stdout = output.stdout;
let distros_str = if stdout.len() >= 2 && stdout[1] == 0 {
// likely UTF-16LE
let u16s: Vec<u16> = stdout
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&u16s)
} else {
String::from_utf8_lossy(&stdout).to_string()
};
let distros: Vec<String> = distros_str
.lines()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect();
let tasks = distros.into_iter().map(|distro| {
background_executor.spawn(async move {
let output = util::command::new_command("wsl")
.arg("-d")
.arg(&distro)
.arg("bash")
.arg("-l")
.arg("-c")
.arg("jupyter kernelspec list --json")
.output()
.await;
if let Ok(output) = output {
if output.status.success() {
let json_str = String::from_utf8_lossy(&output.stdout);
// Use local permissive struct instead of strict KernelSpecsResponse from jupyter-protocol
if let Ok(specs_response) =
serde_json::from_str::<LocalKernelSpecsResponse>(&json_str)
{
return specs_response
.kernelspecs
.into_iter()
.map(|(name, spec)| {
KernelSpecification::WslRemote(WslKernelSpecification {
name,
kernelspec: jupyter_protocol::JupyterKernelspec {
argv: spec.spec.argv,
display_name: spec.spec.display_name,
language: spec.spec.language,
interrupt_mode: spec.spec.interrupt_mode,
env: spec.spec.env,
metadata: spec.spec.metadata,
},
distro: distro.clone(),
})
})
.collect::<Vec<_>>();
} else if let Err(e) =
serde_json::from_str::<LocalKernelSpecsResponse>(&json_str)
{
log::error!(
"wsl_kernel_specifications parse error: {} \nJSON: {}",
e,
json_str
);
}
} else {
log::error!("wsl_kernel_specifications command failed");
}
} else if let Err(e) = output {
log::error!("wsl_kernel_specifications command execution failed: {}", e);
}
Vec::new()
})
});
let specs: Vec<_> = futures::future::join_all(tasks)
.await
.into_iter()
.flatten()
.collect();
Ok(specs)
}

View File

@@ -0,0 +1,4 @@
mod cell;
mod notebook_ui;
pub use cell::*;
pub use notebook_ui::*;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1290
crates/repl/src/outputs.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,198 @@
use anyhow::Result;
use html_to_markdown::markdown::{
CodeHandler, HeadingHandler, ListHandler, ParagraphHandler, StyledTextHandler, TableHandler,
WebpageChromeRemover,
};
use html_to_markdown::{TagHandler, convert_html_to_markdown};
use std::cell::RefCell;
use std::rc::Rc;
/// Convert HTML to Markdown for rendering in the REPL.
pub fn html_to_markdown(html: &str) -> Result<String> {
let mut handlers: Vec<TagHandler> = vec![
// WebpageChromeRemover must come first to skip style, script, head, nav tags
Rc::new(RefCell::new(WebpageChromeRemover)),
Rc::new(RefCell::new(ParagraphHandler)),
Rc::new(RefCell::new(HeadingHandler)),
Rc::new(RefCell::new(ListHandler)),
Rc::new(RefCell::new(TableHandler::new())),
Rc::new(RefCell::new(StyledTextHandler)),
Rc::new(RefCell::new(CodeHandler)),
];
let markdown = convert_html_to_markdown(html.as_bytes(), &mut handlers)?;
Ok(clean_markdown_tables(&markdown))
}
/// Clean up markdown table formatting and ensure tables have separator rows.
fn clean_markdown_tables(markdown: &str) -> String {
let lines: Vec<&str> = markdown.lines().collect();
let mut result: Vec<String> = Vec::new();
let mut in_table = false;
let mut has_separator = false;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('|') {
let normalized = normalize_table_row(trimmed);
if !in_table {
// Starting a new table
in_table = true;
has_separator = false;
}
// Check if this line is a separator row
if trimmed.contains("---") {
has_separator = true;
}
result.push(normalized.clone());
// If this is the first row and no separator exists yet,
// check if next row is a table row (not separator) and add one
if !has_separator {
let next_is_table_row = i + 1 < lines.len()
&& lines[i + 1].trim().starts_with('|')
&& !lines[i + 1].contains("---");
if next_is_table_row {
// Insert separator after first row
let col_count = normalized.matches('|').count().saturating_sub(1);
if col_count > 0 {
let separator = (0..col_count)
.map(|_| "---")
.collect::<Vec<_>>()
.join(" | ");
result.push(format!("| {} |", separator));
has_separator = true;
}
}
}
} else {
// Not a table row
if !trimmed.is_empty() {
result.push(trimmed.to_string());
}
in_table = false;
has_separator = false;
}
}
result.join("\n")
}
/// Normalize a table row by trimming cells and ensuring consistent spacing.
fn normalize_table_row(row: &str) -> String {
let parts: Vec<&str> = row.split('|').collect();
let normalized: Vec<String> = parts.iter().map(|cell| cell.trim().to_string()).collect();
normalized.join(" | ").trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_html_table_to_markdown() {
let html = r#"<table>
<thead><tr><th>A</th><th>B</th></tr></thead>
<tbody><tr><td>1</td><td>x</td></tr></tbody>
</table>"#;
let md = html_to_markdown(html).unwrap();
assert!(md.contains("|"));
assert!(md.contains("---"));
}
#[test]
fn test_html_with_headings() {
let html = "<h1>Title</h1><p>Content</p>";
let md = html_to_markdown(html).unwrap();
assert!(md.contains("# Title"));
}
#[test]
fn test_pandas_dataframe_html() {
let html = r#"<table border="1" class="dataframe">
<thead><tr><th></th><th>A</th><th>B</th></tr></thead>
<tbody>
<tr><th>0</th><td>1</td><td>x</td></tr>
<tr><th>1</th><td>2</td><td>y</td></tr>
</tbody>
</table>"#;
let md = html_to_markdown(html).unwrap();
assert!(md.contains("|"));
// Verify table rows are properly formatted (start with |)
for line in md.lines() {
if line.contains("|") {
assert!(
line.starts_with("|"),
"Table line should start with |: {:?}",
line
);
}
}
}
#[test]
fn test_table_format_normalized() {
let html = r#"<table>
<thead>
<tr><th>Name</th><th>Age</th></tr>
</thead>
<tbody>
<tr><td>Alice</td><td>25</td></tr>
</tbody>
</table>"#;
let md = html_to_markdown(html).unwrap();
// Should have clean table format
assert!(md.contains("| Name | Age |"));
assert!(md.contains("| --- | --- |"));
assert!(md.contains("| Alice | 25 |"));
}
#[test]
fn test_style_tags_are_filtered() {
let html = r#"<style>
.dataframe { border: 1px solid; }
</style>
<table>
<thead><tr><th>A</th></tr></thead>
<tbody><tr><td>1</td></tr></tbody>
</table>"#;
let md = html_to_markdown(html).unwrap();
// Style content should not appear in output
assert!(!md.contains("dataframe"));
assert!(!md.contains("border"));
// Table should still be present
assert!(md.contains("| A |"));
}
#[test]
fn test_table_without_thead() {
// Tables without <thead> should still get a separator row
let html = r#"<table>
<tr><th>Feature</th><th>Supported</th></tr>
<tr><td>Tables</td><td>✓</td></tr>
<tr><td>Lists</td><td>✓</td></tr>
</table>"#;
let md = html_to_markdown(html).unwrap();
// Should have separator row inserted after first row
assert!(
md.contains("| --- | --- |"),
"Missing separator row: {}",
md
);
assert!(md.contains("| Feature | Supported |"));
assert!(md.contains("| Tables | ✓ |"));
}
}

View File

@@ -0,0 +1,192 @@
use anyhow::Result;
use base64::{
Engine as _, alphabet,
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
};
use gpui::{App, ClipboardItem, Image, ImageFormat, Pixels, RenderImage, Window, img};
use settings::Settings as _;
use std::sync::Arc;
use ui::{IntoElement, Styled, prelude::*};
use crate::outputs::{OutputContent, plain};
use crate::repl_settings::ReplSettings;
/// ImageView renders an image inline in an editor, adapting to the line height to fit the image.
pub struct ImageView {
clipboard_image: Arc<Image>,
height: u32,
width: u32,
image: Arc<RenderImage>,
}
pub const STANDARD_INDIFFERENT: GeneralPurpose = GeneralPurpose::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new()
.with_encode_padding(false)
.with_decode_padding_mode(DecodePaddingMode::Indifferent),
);
impl ImageView {
pub fn from(base64_encoded_data: &str) -> Result<Self> {
let filtered =
base64_encoded_data.replace(&[' ', '\n', '\t', '\r', '\x0b', '\x0c'][..], "");
let bytes = STANDARD_INDIFFERENT.decode(filtered)?;
let format = image::guess_format(&bytes)?;
let mut data = image::load_from_memory_with_format(&bytes, format)?.into_rgba8();
// Convert from RGBA to BGRA.
for pixel in data.chunks_exact_mut(4) {
pixel.swap(0, 2);
}
let height = data.height();
let width = data.width();
let gpui_image_data = RenderImage::new(vec![image::Frame::new(data)]);
let format = match format {
image::ImageFormat::Png => ImageFormat::Png,
image::ImageFormat::Jpeg => ImageFormat::Jpeg,
image::ImageFormat::Gif => ImageFormat::Gif,
image::ImageFormat::WebP => ImageFormat::Webp,
image::ImageFormat::Tiff => ImageFormat::Tiff,
image::ImageFormat::Bmp => ImageFormat::Bmp,
image::ImageFormat::Ico => ImageFormat::Ico,
format => {
anyhow::bail!("unsupported image format {format:?}");
}
};
// Convert back to a GPUI image for use with the clipboard
let clipboard_image = Arc::new(Image::from_bytes(format, bytes));
Ok(ImageView {
clipboard_image,
height,
width,
image: Arc::new(gpui_image_data),
})
}
fn scaled_size(
&self,
line_height: Pixels,
max_width: Option<Pixels>,
max_height: Option<Pixels>,
) -> (Pixels, Pixels) {
let (mut height, mut width) = if self.height as f32 / f32::from(line_height)
== u8::MAX as f32
{
let height = u8::MAX as f32 * line_height;
let width = Pixels::from(self.width as f32 * f32::from(height) / self.height as f32);
(height, width)
} else {
(self.height.into(), self.width.into())
};
let mut scale: f32 = 1.0;
if let Some(max_width) = max_width {
if width > max_width {
scale = scale.min(max_width / width);
}
}
if let Some(max_height) = max_height {
if height > max_height {
scale = scale.min(max_height / height);
}
}
if scale < 1.0 {
width *= scale;
height *= scale;
}
(height, width)
}
}
impl Render for ImageView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let settings = ReplSettings::get_global(cx);
let line_height = window.line_height();
let max_width = plain::max_width_for_columns(settings.max_columns, window, cx);
let max_height = if settings.output_max_height_lines > 0 {
Some(line_height * settings.output_max_height_lines as f32)
} else {
None
};
let (height, width) = self.scaled_size(line_height, max_width, max_height);
let image = self.image.clone();
img(image).w(width).h(height)
}
}
impl OutputContent for ImageView {
fn clipboard_content(&self, _window: &Window, _cx: &App) -> Option<ClipboardItem> {
Some(ClipboardItem::new_image(self.clipboard_image.as_ref()))
}
fn has_clipboard_content(&self, _window: &Window, _cx: &App) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn encode_test_image(width: u32, height: u32) -> String {
let image_buffer =
image::ImageBuffer::from_pixel(width, height, image::Rgba([0, 0, 0, 255]));
let image = image::DynamicImage::ImageRgba8(image_buffer);
let mut bytes = Vec::new();
let mut cursor = std::io::Cursor::new(&mut bytes);
if let Err(error) = image.write_to(&mut cursor, image::ImageFormat::Png) {
panic!("failed to encode test image: {error}");
}
base64::engine::general_purpose::STANDARD.encode(bytes)
}
#[test]
fn test_image_view_scaled_size_respects_limits() {
let encoded = encode_test_image(200, 120);
let image_view = match ImageView::from(&encoded) {
Ok(view) => view,
Err(error) => panic!("failed to decode image view: {error}"),
};
let line_height = Pixels::from(10.0);
let max_width = Pixels::from(50.0);
let max_height = Pixels::from(40.0);
let (height, width) =
image_view.scaled_size(line_height, Some(max_width), Some(max_height));
assert_eq!(f32::from(width), 50.0);
assert_eq!(f32::from(height), 30.0);
}
#[test]
fn test_image_view_scaled_size_unbounded() {
let encoded = encode_test_image(200, 120);
let image_view = match ImageView::from(&encoded) {
Ok(view) => view,
Err(error) => panic!("failed to decode image view: {error}"),
};
let line_height = Pixels::from(10.0);
let (height, width) = image_view.scaled_size(line_height, None, None);
assert_eq!(f32::from(width), 200.0);
assert_eq!(f32::from(height), 120.0);
}
}

View File

@@ -0,0 +1,296 @@
//! # JSON Output for REPL
//!
//! This module provides an interactive JSON viewer for displaying JSON data in the REPL.
//! It supports collapsible/expandable tree views for objects and arrays, with syntax
//! highlighting for different value types.
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use gpui::{App, ClipboardItem, Context, Entity, Window, div, prelude::*};
use language::Buffer;
use serde_json::Value;
use ui::{Disclosure, prelude::*};
use crate::outputs::OutputContent;
pub struct JsonView {
root: Value,
expanded_paths: HashMap<String, bool>,
}
impl JsonView {
pub fn from_value(value: Value) -> anyhow::Result<Self> {
let mut expanded_paths = HashMap::new();
expanded_paths.insert("root".to_string(), true);
Ok(Self {
root: value,
expanded_paths,
})
}
fn toggle_path(&mut self, path: &str, cx: &mut Context<Self>) {
let current = self.expanded_paths.get(path).copied().unwrap_or(false);
self.expanded_paths.insert(path.to_string(), !current);
cx.notify();
}
fn is_expanded(&self, path: &str) -> bool {
self.expanded_paths.get(path).copied().unwrap_or(false)
}
fn path_hash(path: &str) -> u64 {
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
hasher.finish()
}
fn render_value(
&self,
path: String,
key: Option<&str>,
value: &Value,
depth: usize,
window: &mut Window,
cx: &mut Context<Self>,
) -> AnyElement {
let indent = depth * 12;
match value {
Value::Object(map) if map.is_empty() => {
self.render_line(path, key, "{}", depth, Color::Muted, window, cx)
}
Value::Object(map) => {
let is_expanded = self.is_expanded(&path);
let preview = if is_expanded {
String::new()
} else {
format!("{{ {} fields }}", map.len())
};
v_flex()
.child(
h_flex()
.gap_1()
.pl(px(indent as f32))
.cursor_pointer()
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener({
let path = path.clone();
move |this, _, _, cx| {
this.toggle_path(&path, cx);
}
}),
)
.child(Disclosure::new(
("json-disclosure", Self::path_hash(&path)),
is_expanded,
))
.child(
h_flex()
.gap_1()
.when_some(key, |this, k| {
this.child(
Label::new(format!("{}: ", k)).color(Color::Accent),
)
})
.when(!is_expanded, |this| {
this.child(Label::new("{").color(Color::Muted))
.child(
Label::new(format!(" {} ", preview))
.color(Color::Muted),
)
.child(Label::new("}").color(Color::Muted))
}),
),
)
.when(is_expanded, |this| {
this.children(
map.iter()
.map(|(k, v)| {
let child_path = format!("{}.{}", path, k);
self.render_value(child_path, Some(k), v, depth + 1, window, cx)
})
.collect::<Vec<_>>(),
)
})
.into_any_element()
}
Value::Array(arr) if arr.is_empty() => {
self.render_line(path, key, "[]", depth, Color::Muted, window, cx)
}
Value::Array(arr) => {
let is_expanded = self.is_expanded(&path);
let preview = if is_expanded {
String::new()
} else {
format!("[ {} items ]", arr.len())
};
v_flex()
.child(
h_flex()
.gap_1()
.pl(px(indent as f32))
.cursor_pointer()
.on_mouse_down(
gpui::MouseButton::Left,
cx.listener({
let path = path.clone();
move |this, _, _, cx| {
this.toggle_path(&path, cx);
}
}),
)
.child(Disclosure::new(
("json-disclosure", Self::path_hash(&path)),
is_expanded,
))
.child(
h_flex()
.gap_1()
.when_some(key, |this, k| {
this.child(
Label::new(format!("{}: ", k)).color(Color::Accent),
)
})
.when(!is_expanded, |this| {
this.child(Label::new("[").color(Color::Muted))
.child(
Label::new(format!(" {} ", preview))
.color(Color::Muted),
)
.child(Label::new("]").color(Color::Muted))
}),
),
)
.when(is_expanded, |this| {
this.children(
arr.iter()
.enumerate()
.map(|(i, v)| {
let child_path = format!("{}[{}]", path, i);
self.render_value(child_path, None, v, depth + 1, window, cx)
})
.collect::<Vec<_>>(),
)
})
.into_any_element()
}
Value::String(s) => {
let display = format!("\"{}\"", s);
self.render_line(path, key, &display, depth, Color::Success, window, cx)
}
Value::Number(n) => {
let display = n.to_string();
self.render_line(path, key, &display, depth, Color::Modified, window, cx)
}
Value::Bool(b) => {
let display = b.to_string();
self.render_line(path, key, &display, depth, Color::Info, window, cx)
}
Value::Null => self.render_line(path, key, "null", depth, Color::Disabled, window, cx),
}
}
fn render_line(
&self,
_path: String,
key: Option<&str>,
value: &str,
depth: usize,
color: Color,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> AnyElement {
let indent = depth * 16;
h_flex()
.pl(px(indent as f32))
.gap_1()
.when_some(key, |this, k| {
this.child(Label::new(format!("{}: ", k)).color(Color::Accent))
})
.child(Label::new(value.to_string()).color(color))
.into_any_element()
}
}
impl Render for JsonView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let root_clone = self.root.clone();
let root_element = self.render_value("root".to_string(), None, &root_clone, 0, window, cx);
div().w_full().child(root_element)
}
}
impl OutputContent for JsonView {
fn clipboard_content(&self, _window: &Window, _cx: &App) -> Option<ClipboardItem> {
serde_json::to_string_pretty(&self.root)
.ok()
.map(ClipboardItem::new_string)
}
fn has_clipboard_content(&self, _window: &Window, _cx: &App) -> bool {
true
}
fn has_buffer_content(&self, _window: &Window, _cx: &App) -> bool {
true
}
fn buffer_content(&mut self, _window: &mut Window, cx: &mut App) -> Option<Entity<Buffer>> {
let json_text = serde_json::to_string_pretty(&self.root).ok()?;
let buffer = cx.new(|cx| {
let mut buffer =
Buffer::local(json_text, cx).with_language(language::PLAIN_TEXT.clone(), cx);
buffer.set_capability(language::Capability::ReadOnly, cx);
buffer
});
Some(buffer)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_json_view_from_value_root_expanded() {
let view = JsonView::from_value(serde_json::json!({"key": "value"})).unwrap();
assert!(
view.is_expanded("root"),
"root should be expanded by default"
);
}
#[test]
fn test_json_view_is_expanded_unknown_path() {
let view = JsonView::from_value(serde_json::json!({"key": "value"})).unwrap();
assert!(
!view.is_expanded("root.key"),
"non-root paths should not be expanded by default"
);
assert!(
!view.is_expanded("nonexistent"),
"unknown paths should not be expanded"
);
}
#[gpui::test]
fn test_json_view_toggle_path(cx: &mut gpui::App) {
let view =
cx.new(|_cx| JsonView::from_value(serde_json::json!({"nested": {"a": 1}})).unwrap());
view.update(cx, |view, cx| {
assert!(!view.is_expanded("root.nested"));
view.toggle_path("root.nested", cx);
assert!(view.is_expanded("root.nested"));
view.toggle_path("root.nested", cx);
assert!(!view.is_expanded("root.nested"));
});
}
}

View File

@@ -0,0 +1,56 @@
use gpui::{App, AppContext, ClipboardItem, Context, Entity, Window, div, prelude::*};
use language::Buffer;
use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle};
use crate::outputs::OutputContent;
pub struct MarkdownView {
markdown: Entity<Markdown>,
}
impl MarkdownView {
pub fn from(text: String, cx: &mut Context<Self>) -> Self {
let markdown = cx.new(|cx| Markdown::new(text.clone().into(), None, None, cx));
Self { markdown }
}
}
impl OutputContent for MarkdownView {
fn clipboard_content(&self, _window: &Window, cx: &App) -> Option<ClipboardItem> {
let source = self.markdown.read(cx).source().to_string();
Some(ClipboardItem::new_string(source))
}
fn has_clipboard_content(&self, _window: &Window, _cx: &App) -> bool {
true
}
fn has_buffer_content(&self, _window: &Window, _cx: &App) -> bool {
true
}
fn buffer_content(&mut self, _: &mut Window, cx: &mut App) -> Option<Entity<Buffer>> {
let source = self.markdown.read(cx).source().to_string();
let buffer = cx.new(|cx| {
let mut buffer =
Buffer::local(source.clone(), cx).with_language(language::PLAIN_TEXT.clone(), cx);
buffer.set_capability(language::Capability::ReadOnly, cx);
buffer
});
Some(buffer)
}
}
impl Render for MarkdownView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let style = markdown_style(window, cx);
div()
.w_full()
.child(MarkdownElement::new(self.markdown.clone(), style))
}
}
fn markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
MarkdownStyle::themed(MarkdownFont::Editor, window, cx)
}

View File

@@ -0,0 +1,420 @@
//! # Plain Text Output
//!
//! This module provides functionality for rendering plain text output in a terminal-like format.
//! It uses the Alacritty terminal emulator backend to process and display text, supporting
//! ANSI escape sequences for formatting, colors, and other terminal features.
//!
//! The main component of this module is the `TerminalOutput` struct, which handles the parsing
//! and rendering of text input, simulating a basic terminal environment within REPL output.
//!
//! This module is used for displaying:
//!
//! - Standard output (stdout)
//! - Standard error (stderr)
//! - Plain text content
//! - Error tracebacks
//!
use alacritty_terminal::{
event::VoidListener,
grid::Dimensions as _,
index::{Column, Line, Point},
term::Config,
vte::ansi::Processor,
};
use gpui::{Bounds, ClipboardItem, Entity, FontStyle, Pixels, TextStyle, WhiteSpace, canvas, size};
use language::Buffer;
use settings::Settings as _;
use terminal::terminal_settings::TerminalSettings;
use terminal_view::terminal_element::TerminalElement;
use theme_settings::ThemeSettings;
use ui::{IntoElement, prelude::*};
use crate::outputs::OutputContent;
use crate::repl_settings::ReplSettings;
/// The `TerminalOutput` struct handles the parsing and rendering of text input,
/// simulating a basic terminal environment within REPL output.
///
/// `TerminalOutput` is designed to handle various types of text-based output, including:
///
/// * stdout (standard output)
/// * stderr (standard error)
/// * text/plain content
/// * error tracebacks
///
/// It uses the Alacritty terminal emulator backend to process and render text,
/// supporting ANSI escape sequences for text formatting and colors.
///
pub struct TerminalOutput {
full_buffer: Option<Entity<Buffer>>,
/// ANSI escape sequence processor for parsing input text.
parser: Processor,
/// Alacritty terminal instance that manages the terminal state and content.
handler: alacritty_terminal::Term<VoidListener>,
}
/// Returns the default text style for the terminal output.
pub fn text_style(window: &mut Window, cx: &App) -> TextStyle {
let settings = ThemeSettings::get_global(cx).clone();
let font_size = settings.buffer_font_size(cx).into();
let font_family = settings.buffer_font.family;
let font_features = settings.buffer_font.features;
let font_weight = settings.buffer_font.weight;
let font_fallbacks = settings.buffer_font.fallbacks;
let theme = cx.theme();
TextStyle {
font_family,
font_features,
font_weight,
font_fallbacks,
font_size,
font_style: FontStyle::Normal,
line_height: window.line_height().into(),
background_color: Some(theme.colors().terminal_ansi_background),
white_space: WhiteSpace::Normal,
// These are going to be overridden per-cell
color: theme.colors().terminal_foreground,
..Default::default()
}
}
/// Returns the default terminal size for the terminal output.
pub fn terminal_size(window: &mut Window, cx: &mut App) -> terminal::TerminalBounds {
let text_style = text_style(window, cx);
let text_system = window.text_system();
let line_height = window.line_height();
let font_pixels = text_style.font_size.to_pixels(window.rem_size());
let font_id = text_system.resolve_font(&text_style.font());
let cell_width = text_system
.advance(font_id, font_pixels, 'w')
.map(|advance| advance.width)
.unwrap_or(Pixels::ZERO);
let num_lines = ReplSettings::get_global(cx).max_lines;
let columns = ReplSettings::get_global(cx).max_columns;
// Reversed math from terminal::TerminalSize to get pixel width according to terminal width
let width = columns as f32 * cell_width;
let height = num_lines as f32 * window.line_height();
terminal::TerminalBounds {
cell_width,
line_height,
bounds: Bounds {
origin: gpui::Point::default(),
size: size(width, height),
},
}
}
pub fn max_width_for_columns(
columns: usize,
window: &mut Window,
cx: &App,
) -> Option<gpui::Pixels> {
if columns == 0 {
return None;
}
let text_style = text_style(window, cx);
let text_system = window.text_system();
let font_pixels = text_style.font_size.to_pixels(window.rem_size());
let font_id = text_system.resolve_font(&text_style.font());
let cell_width = text_system
.advance(font_id, font_pixels, 'w')
.map(|advance| advance.width)
.unwrap_or(Pixels::ZERO);
Some(cell_width * columns as f32)
}
impl TerminalOutput {
/// Creates a new `TerminalOutput` instance.
///
/// This method initializes a new terminal emulator with default configuration
/// and sets up the necessary components for handling terminal events and rendering.
///
pub fn new(window: &mut Window, cx: &mut App) -> Self {
let term = alacritty_terminal::Term::new(
Config::default(),
&terminal_size(window, cx),
VoidListener,
);
Self {
parser: Processor::new(),
handler: term,
full_buffer: None,
}
}
/// Creates a new `TerminalOutput` instance with initial content.
///
/// Initializes a new terminal output and populates it with the provided text.
///
/// # Arguments
///
/// * `text` - A string slice containing the initial text for the terminal output.
/// * `cx` - A mutable reference to the `WindowContext` for initialization.
///
/// # Returns
///
/// A new instance of `TerminalOutput` containing the provided text.
pub fn from(text: &str, window: &mut Window, cx: &mut App) -> Self {
let mut output = Self::new(window, cx);
output.append_text(text, cx);
output
}
/// Appends text to the terminal output.
///
/// Processes each byte of the input text, handling newline characters specially
/// to ensure proper cursor movement. Uses the ANSI parser to process the input
/// and update the terminal state.
///
/// As an example, if the user runs the following Python code in this REPL:
///
/// ```python
/// import time
/// print("Hello,", end="")
/// time.sleep(1)
/// print(" world!")
/// ```
///
/// Then append_text will be called twice, with the following arguments:
///
/// ```ignore
/// terminal_output.append_text("Hello,");
/// terminal_output.append_text(" world!");
/// ```
/// Resulting in a single output of "Hello, world!".
///
/// # Arguments
///
/// * `text` - A string slice containing the text to be appended.
pub fn append_text(&mut self, text: &str, cx: &mut App) {
for byte in text.as_bytes() {
if *byte == b'\n' {
// Dirty (?) hack to move the cursor down
self.parser.advance(&mut self.handler, &[b'\r']);
self.parser.advance(&mut self.handler, &[b'\n']);
} else {
self.parser.advance(&mut self.handler, &[*byte]);
}
}
// This will keep the buffer up to date, though with some terminal codes it won't be perfect
if let Some(buffer) = self.full_buffer.as_ref() {
buffer.update(cx, |buffer, cx| {
buffer.edit([(buffer.len()..buffer.len(), text)], None, cx);
});
}
}
pub fn full_text(&self) -> String {
fn sanitize(mut line: String) -> Option<String> {
line.retain(|ch| ch != '\u{0}' && ch != '\r');
if line.trim().is_empty() {
return None;
}
let trimmed = line.trim_end_matches([' ', '\t']);
Some(trimmed.to_owned())
}
let mut lines = Vec::new();
// Get the total number of lines, including history
let total_lines = self.handler.grid().total_lines();
let visible_lines = self.handler.screen_lines();
let history_lines = total_lines - visible_lines;
// Capture history lines in correct order (oldest to newest)
for line in (0..history_lines).rev() {
let line_index = Line(-(line as i32) - 1);
let start = Point::new(line_index, Column(0));
let end = Point::new(line_index, Column(self.handler.columns() - 1));
if let Some(cleaned) = sanitize(self.handler.bounds_to_string(start, end)) {
lines.push(cleaned);
}
}
// Capture visible lines
for line in 0..visible_lines {
let line_index = Line(line as i32);
let start = Point::new(line_index, Column(0));
let end = Point::new(line_index, Column(self.handler.columns() - 1));
if let Some(cleaned) = sanitize(self.handler.bounds_to_string(start, end)) {
lines.push(cleaned);
}
}
if lines.is_empty() {
String::new()
} else {
let mut full_text = lines.join("\n");
full_text.push('\n');
full_text
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{TestAppContext, VisualTestContext};
use settings::SettingsStore;
fn init_test(cx: &mut TestAppContext) -> &mut VisualTestContext {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
theme_settings::init(theme::LoadThemes::JustBase, cx);
});
cx.add_empty_window()
}
#[gpui::test]
fn test_max_width_for_columns_zero(cx: &mut TestAppContext) {
let cx = init_test(cx);
let result = cx.update(|window, cx| max_width_for_columns(0, window, cx));
assert!(result.is_none());
}
#[gpui::test]
fn test_max_width_for_columns_matches_cell_width(cx: &mut TestAppContext) {
let cx = init_test(cx);
let columns = 5;
let (result, expected) = cx.update(|window, cx| {
let text_style = text_style(window, cx);
let text_system = window.text_system();
let font_pixels = text_style.font_size.to_pixels(window.rem_size());
let font_id = text_system.resolve_font(&text_style.font());
let cell_width = text_system
.advance(font_id, font_pixels, 'w')
.map(|advance| advance.width)
.unwrap_or(gpui::Pixels::ZERO);
let result = max_width_for_columns(columns, window, cx);
(result, cell_width * columns as f32)
});
let Some(result) = result else {
panic!("expected max width for columns {columns}");
};
let result_f32: f32 = result.into();
let expected_f32: f32 = expected.into();
assert!((result_f32 - expected_f32).abs() < 0.01);
}
}
impl Render for TerminalOutput {
/// Renders the terminal output as a GPUI element.
///
/// Converts the current terminal state into a renderable GPUI element. It handles
/// the layout of the terminal grid, calculates the dimensions of the output, and
/// creates a canvas element that paints the terminal cells and background rectangles.
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let text_style = text_style(window, cx);
let text_system = window.text_system();
let grid = self
.handler
.renderable_content()
.display_iter
.map(|ic| terminal::IndexedCell {
point: ic.point,
cell: ic.cell.clone(),
});
let minimum_contrast = TerminalSettings::get_global(cx).minimum_contrast;
let (rects, batched_text_runs) =
TerminalElement::layout_grid(grid, 0, &text_style, None, minimum_contrast, cx);
// lines are 0-indexed, so we must add 1 to get the number of lines
let text_line_height = text_style.line_height_in_pixels(window.rem_size());
let num_lines = batched_text_runs
.iter()
.map(|b| b.start_point.line)
.max()
.unwrap_or(0)
+ 1;
let height = num_lines as f32 * text_line_height;
let font_pixels = text_style.font_size.to_pixels(window.rem_size());
let font_id = text_system.resolve_font(&text_style.font());
let cell_width = text_system
.advance(font_id, font_pixels, 'w')
.map(|advance| advance.width)
.unwrap_or(Pixels::ZERO);
canvas(
// prepaint
move |_bounds, _, _| {},
// paint
move |bounds, _, window, cx| {
for rect in rects {
rect.paint(
bounds.origin,
&terminal::TerminalBounds {
cell_width,
line_height: text_line_height,
bounds,
},
window,
);
}
for batch in batched_text_runs {
batch.paint(
bounds.origin,
&terminal::TerminalBounds {
cell_width,
line_height: text_line_height,
bounds,
},
window,
cx,
);
}
},
)
// We must set the height explicitly for the editor block to size itself correctly
.h(height)
}
}
impl OutputContent for TerminalOutput {
fn clipboard_content(&self, _window: &Window, _cx: &App) -> Option<ClipboardItem> {
Some(ClipboardItem::new_string(self.full_text()))
}
fn has_clipboard_content(&self, _window: &Window, _cx: &App) -> bool {
true
}
fn has_buffer_content(&self, _window: &Window, _cx: &App) -> bool {
true
}
fn buffer_content(&mut self, _: &mut Window, cx: &mut App) -> Option<Entity<Buffer>> {
if self.full_buffer.as_ref().is_some() {
return self.full_buffer.clone();
}
let buffer = cx.new(|cx| {
let mut buffer =
Buffer::local(self.full_text(), cx).with_language(language::PLAIN_TEXT.clone(), cx);
buffer.set_capability(language::Capability::ReadOnly, cx);
buffer
});
self.full_buffer = Some(buffer.clone());
Some(buffer)
}
}

View File

@@ -0,0 +1,290 @@
//! # Table Output for REPL
//!
//! This module provides functionality to render tabular data in Zed's REPL output.
//!
//! It supports the [Frictionless Data Table Schema](https://specs.frictionlessdata.io/table-schema/)
//! for data interchange, implemented by Pandas in Python and Polars for Deno.
//!
//! # Python Example
//!
//! Tables can be created and displayed in two main ways:
//!
//! 1. Using raw JSON data conforming to the Tabular Data Resource specification.
//! 2. Using Pandas DataFrames (in Python kernels).
//!
//! ## Raw JSON Method
//!
//! To create a table using raw JSON, you need to provide a JSON object that conforms
//! to the Tabular Data Resource specification. Here's an example:
//!
//! ```json
//! {
//! "schema": {
//! "fields": [
//! {"name": "id", "type": "integer"},
//! {"name": "name", "type": "string"},
//! {"name": "age", "type": "integer"}
//! ]
//! },
//! "data": [
//! {"id": 1, "name": "Alice", "age": 30},
//! {"id": 2, "name": "Bob", "age": 28},
//! {"id": 3, "name": "Charlie", "age": 35}
//! ]
//! }
//! ```
//!
//! ## Pandas Method
//!
//! To create a table using Pandas in a Python kernel, you can use the following steps:
//!
//! ```python
//! import pandas as pd
//!
//! # Enable table schema output
//! pd.set_option('display.html.table_schema', True)
//!
//! # Create a DataFrame
//! df = pd.DataFrame({
//! 'id': [1, 2, 3],
//! 'name': ['Alice', 'Bob', 'Charlie'],
//! 'age': [30, 28, 35]
//! })
//!
//! # Display the DataFrame
//! display(df)
//! ```
use gpui::{AnyElement, ClipboardItem, TextRun};
use runtimelib::datatable::TableSchema;
use runtimelib::media::datatable::TabularDataResource;
use serde_json::Value;
use settings::Settings;
use theme_settings::ThemeSettings;
use ui::{IntoElement, Styled, div, prelude::*, v_flex};
use util::markdown::MarkdownEscaped;
use crate::outputs::OutputContent;
/// 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,
pub widths: Vec<Pixels>,
cached_clipboard_content: ClipboardItem,
}
fn cell_content(row: &Value, field: &str) -> String {
match row.get(field) {
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(),
}
}
// Declare constant for the padding multiple on the line height
const TABLE_Y_PADDING_MULTIPLE: f32 = 0.5;
impl TableView {
pub fn new(table: &TabularDataResource, window: &mut Window, cx: &mut App) -> Self {
let mut widths = Vec::with_capacity(table.schema.fields.len());
let text_system = window.text_system();
let text_style = window.text_style();
let text_font = ThemeSettings::get_global(cx).buffer_font.clone();
let font_size = ThemeSettings::get_global(cx).buffer_font_size(cx);
let mut runs = [TextRun {
len: 0,
font: text_font,
color: text_style.color,
..Default::default()
}];
for field in table.schema.fields.iter() {
runs[0].len = field.name.len();
let mut width = text_system
.layout_line(&field.name, font_size, &runs, None)
.width;
let Some(data) = table.data.as_ref() else {
widths.push(width);
continue;
};
for row in data {
let content = cell_content(row, &field.name);
runs[0].len = content.len();
let cell_width = window
.text_system()
.layout_line(&content, font_size, &runs, None)
.width;
width = width.max(cell_width)
}
widths.push(width)
}
let cached_clipboard_content = Self::create_clipboard_content(table);
Self {
table: table.clone(),
widths,
cached_clipboard_content: ClipboardItem::new_string(cached_clipboard_content),
}
}
fn create_clipboard_content(table: &TabularDataResource) -> String {
let data = match table.data.as_ref() {
Some(data) => data,
None => &Vec::new(),
};
let schema = table.schema.clone();
let mut markdown = format!(
"| {} |\n",
table
.schema
.fields
.iter()
.map(|field| field.name.clone())
.collect::<Vec<_>>()
.join(" | ")
);
markdown.push_str("|---");
for _ in 1..table.schema.fields.len() {
markdown.push_str("|---");
}
markdown.push_str("|\n");
let body = data
.iter()
.map(|record: &Value| {
let row_content = schema
.fields
.iter()
.map(|field| MarkdownEscaped(&cell_content(record, &field.name)).to_string())
.collect::<Vec<_>>();
row_content.join(" | ")
})
.collect::<Vec<String>>();
for row in body {
markdown.push_str(&format!("| {} |\n", row));
}
markdown
}
pub fn render_row(
&self,
schema: &TableSchema,
is_header: bool,
row: &Value,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let theme = cx.theme();
let line_height = window.line_height();
let row_cells = schema
.fields
.iter()
.zip(self.widths.iter())
.map(|(field, width)| {
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 = cell_content(row, &field.name);
let mut cell = container
.min_w(*width + px(22.))
.w(*width + px(22.))
.child(value)
.px_2()
.py((TABLE_Y_PADDING_MULTIPLE / 2.0) * line_height)
.border_color(theme.colors().border);
if is_header {
cell = cell.border_1().bg(theme.colors().border_focused)
} else {
cell = cell.border_1()
}
cell
})
.collect::<Vec<_>>();
let mut total_width = px(0.);
for width in self.widths.iter() {
// Width fudge factor: border + 2 (heading), padding
total_width += *width + px(22.);
}
h_flex()
.w(total_width)
.children(row_cells)
.into_any_element()
}
}
impl Render for TableView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let data = match &self.table.data {
Some(data) => data,
None => return div().into_any_element(),
};
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),
window,
cx,
);
let body = data
.iter()
.map(|row| self.render_row(&self.table.schema, false, row, window, cx));
v_flex()
.id("table")
.overflow_x_scroll()
.w_full()
.child(header)
.children(body)
.into_any_element()
}
}
impl OutputContent for TableView {
fn clipboard_content(&self, _window: &Window, _cx: &App) -> Option<ClipboardItem> {
Some(self.cached_clipboard_content.clone())
}
fn has_clipboard_content(&self, _window: &Window, _cx: &App) -> bool {
true
}
}

View File

@@ -0,0 +1,45 @@
use gpui::{AnyElement, App, Entity, FontWeight, Window};
use ui::{Label, h_flex, prelude::*, v_flex};
use crate::outputs::plain::TerminalOutput;
/// Userspace error from the kernel
#[derive(Clone)]
pub struct ErrorView {
pub ename: String,
pub evalue: String,
pub traceback: Entity<TerminalOutput>,
}
impl ErrorView {
pub fn render(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
let theme = cx.theme();
let padding = window.line_height() / 2.;
Some(
v_flex()
.gap_3()
.child(
h_flex()
.font_buffer(cx)
.child(
Label::new(format!("{}: ", self.ename.clone()))
.color(Color::Error)
.weight(FontWeight::BOLD),
)
.child(Label::new(self.evalue.clone()).weight(FontWeight::BOLD)),
)
.child(
div()
.w_full()
.px(padding)
.py(padding)
.border_l_1()
.border_color(theme.status().error_border)
.child(self.traceback.clone()),
)
.into_any_element(),
)
}
}

77
crates/repl/src/repl.rs Normal file
View File

@@ -0,0 +1,77 @@
pub mod components;
mod jupyter_settings;
pub mod kernels;
pub mod notebook;
mod outputs;
mod repl_editor;
mod repl_sessions_ui;
mod repl_settings;
mod repl_store;
mod session;
use std::{sync::Arc, time::Duration};
use async_dispatcher::{Dispatcher, Runnable, set_dispatcher};
use gpui::{App, PlatformDispatcher, Priority, RunnableMeta};
use project::Fs;
pub use runtimelib::ExecutionState;
pub use crate::jupyter_settings::JupyterSettings;
pub use crate::kernels::{Kernel, KernelSpecification, KernelStatus, PythonEnvKernelSpecification};
pub use crate::repl_editor::*;
pub use crate::repl_sessions_ui::{
ClearCurrentOutput, ClearOutputs, Interrupt, ReplSessionsPage, Restart, Run, Sessions, Shutdown,
};
pub use crate::repl_settings::ReplSettings;
pub use crate::repl_store::ReplStore;
pub use crate::session::Session;
pub const KERNEL_DOCS_URL: &str = "https://zed.dev/docs/repl#changing-kernels";
pub fn init(fs: Arc<dyn Fs>, cx: &mut App) {
set_dispatcher(zed_dispatcher(cx));
repl_sessions_ui::init(cx);
ReplStore::init(fs, cx);
}
fn zed_dispatcher(cx: &mut App) -> 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 {
#[track_caller]
fn dispatch(&self, runnable: Runnable) {
let location = core::panic::Location::caller();
let (wrapper, task) = async_task::Builder::new()
.metadata(RunnableMeta { location })
.spawn(|_| async move { runnable.run() }, {
let dispatcher = self.dispatcher.clone();
move |r| dispatcher.dispatch(r, Priority::default())
});
wrapper.schedule();
task.detach();
}
#[track_caller]
fn dispatch_after(&self, duration: Duration, runnable: Runnable) {
let location = core::panic::Location::caller();
let (wrapper, task) = async_task::Builder::new()
.metadata(RunnableMeta { location })
.spawn(|_| async move { runnable.run() }, {
let dispatcher = self.dispatcher.clone();
move |r| dispatcher.dispatch_after(duration, r)
});
wrapper.schedule();
task.detach();
}
}
ZedDispatcher {
dispatcher: cx.background_executor().dispatcher().clone(),
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,287 @@
use editor::Editor;
use gpui::{
AnyElement, App, Entity, EventEmitter, FocusHandle, Focusable, Subscription, TaskExt, actions,
prelude::*,
};
use project::ProjectItem as _;
use ui::{ButtonLike, ElevationIndex, KeyBinding, prelude::*};
use util::ResultExt as _;
use workspace::item::ItemEvent;
use workspace::{Workspace, item::Item};
use crate::jupyter_settings::JupyterSettings;
use crate::repl_store::ReplStore;
actions!(
repl,
[
/// Runs the current cell and advances to the next one.
Run,
/// Runs the current cell without advancing.
RunInPlace,
/// Clears all outputs in the REPL.
ClearOutputs,
/// Clears the output of the cell at the current cursor position.
ClearCurrentOutput,
/// Opens the REPL sessions panel.
Sessions,
/// Interrupts the currently running kernel.
Interrupt,
/// Shuts down the current kernel.
Shutdown,
/// Restarts the current kernel.
Restart,
/// Refreshes the list of available kernelspecs.
RefreshKernelspecs
]
);
pub fn init(cx: &mut App) {
cx.observe_new(
|workspace: &mut Workspace, _window, _cx: &mut Context<Workspace>| {
workspace.register_action(|workspace, _: &Sessions, window, cx| {
let existing = workspace
.active_pane()
.read(cx)
.items()
.find_map(|item| item.downcast::<ReplSessionsPage>());
if let Some(existing) = existing {
workspace.activate_item(&existing, true, true, window, cx);
} else {
let repl_sessions_page = ReplSessionsPage::new(window, cx);
workspace.add_item_to_active_pane(
Box::new(repl_sessions_page),
None,
true,
window,
cx,
)
}
});
workspace.register_action(|_workspace, _: &RefreshKernelspecs, _, cx| {
let store = ReplStore::global(cx);
store.update(cx, |store, cx| {
store.refresh_kernelspecs(cx).detach();
});
});
},
)
.detach();
cx.observe_new(
move |editor: &mut Editor, window, cx: &mut Context<Editor>| {
let Some(window) = window else {
return;
};
if !editor.use_modal_editing() || !editor.buffer().read(cx).is_singleton() {
return;
}
cx.defer_in(window, |editor, _window, cx| {
let project = editor.project().cloned();
let is_valid_project = project
.as_ref()
.map(|project| {
let p = project.read(cx);
!p.is_via_collab()
})
.unwrap_or(false);
if !is_valid_project {
return;
}
let buffer = editor.buffer().read(cx).as_singleton();
let language = buffer
.as_ref()
.and_then(|buffer| buffer.read(cx).language());
let project_path = buffer.and_then(|buffer| buffer.read(cx).project_path(cx));
let editor_handle = cx.entity().downgrade();
if let Some(language) = language
&& language.name() == "Python"
&& let (Some(project_path), Some(project)) = (project_path, project)
{
let store = ReplStore::global(cx);
store.update(cx, |store, cx| {
store
.refresh_python_kernelspecs(project_path.worktree_id, &project, cx)
.detach_and_log_err(cx);
});
}
editor
.register_action({
let editor_handle = editor_handle.clone();
move |_: &Run, window, cx| {
if !JupyterSettings::enabled(cx) {
return;
}
crate::run(editor_handle.clone(), true, window, cx).log_err();
}
})
.detach();
editor
.register_action({
move |_: &RunInPlace, window, cx| {
if !JupyterSettings::enabled(cx) {
return;
}
crate::run(editor_handle.clone(), false, window, cx).log_err();
}
})
.detach();
});
},
)
.detach();
}
pub struct ReplSessionsPage {
focus_handle: FocusHandle,
_subscriptions: Vec<Subscription>,
}
impl ReplSessionsPage {
pub fn new(window: &mut Window, cx: &mut Context<Workspace>) -> Entity<Self> {
cx.new(|cx| {
let focus_handle = cx.focus_handle();
let subscriptions = vec![
cx.on_focus_in(&focus_handle, window, |_this, _window, cx| cx.notify()),
cx.on_focus_out(&focus_handle, window, |_this, _event, _window, cx| {
cx.notify()
}),
];
Self {
focus_handle,
_subscriptions: subscriptions,
}
})
}
}
impl EventEmitter<ItemEvent> for ReplSessionsPage {}
impl Focusable for ReplSessionsPage {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Item for ReplSessionsPage {
type Event = ItemEvent;
fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
"REPL Sessions".into()
}
fn telemetry_event_text(&self) -> Option<&'static str> {
Some("REPL Session Started")
}
fn show_toolbar(&self) -> bool {
false
}
fn to_item_events(event: &Self::Event, f: &mut dyn FnMut(workspace::item::ItemEvent)) {
f(*event)
}
}
impl Render for ReplSessionsPage {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let store = ReplStore::global(cx);
let (kernel_specifications, sessions) = store.update(cx, |store, cx| {
store.ensure_kernelspecs(cx);
(
store
.pure_jupyter_kernel_specifications()
.cloned()
.collect::<Vec<_>>(),
store.sessions().cloned().collect::<Vec<_>>(),
)
});
// 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 kernel_specifications.is_empty() {
let instructions = "To start interactively running code in your editor, you need to install and configure Jupyter kernels.";
return ReplSessionsContainer::new("No Jupyter Kernels Available")
.child(Label::new(instructions))
.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://zed.dev/docs/repl#language-specific-instructions",
)
}),
),
);
}
// When there are no sessions, show the command to run code in an editor
if sessions.is_empty() {
let instructions = "To run code in a Jupyter kernel, select some code and use the 'repl::Run' command.";
return ReplSessionsContainer::new("No Jupyter Kernel Sessions").child(
v_flex()
.child(Label::new(instructions))
.child(KeyBinding::for_action(&Run, cx)),
);
}
ReplSessionsContainer::new("Jupyter Kernel Sessions").children(sessions)
}
}
#[derive(IntoElement)]
struct ReplSessionsContainer {
title: SharedString,
children: Vec<AnyElement>,
}
impl ReplSessionsContainer {
pub fn new(title: impl Into<SharedString>) -> Self {
Self {
title: title.into(),
children: Vec::new(),
}
}
}
impl ParentElement for ReplSessionsContainer {
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
self.children.extend(elements)
}
}
impl RenderOnce for ReplSessionsContainer {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
v_flex()
.p_4()
.gap_2()
.size_full()
.child(Label::new(self.title).size(LabelSize::Large))
.children(self.children)
}
}

View File

@@ -0,0 +1,44 @@
use settings::{RegisterSetting, Settings};
/// Settings for configuring REPL display and behavior.
#[derive(Clone, Debug, RegisterSetting)]
pub struct ReplSettings {
/// Maximum number of lines to keep in REPL's scrollback buffer.
/// Clamped with [4, 256] range.
///
/// Default: 32
pub max_lines: usize,
/// Maximum number of columns to keep in REPL's scrollback buffer.
/// Clamped with [20, 512] range.
///
/// Default: 128
pub max_columns: usize,
/// Whether to show small single-line outputs inline instead of in a block.
///
/// Default: true
pub inline_output: bool,
/// Maximum number of characters for an output to be shown inline.
/// Only applies when `inline_output` is true.
///
/// Default: 50
pub inline_output_max_length: usize,
/// Maximum number of lines of output to display before scrolling.
/// Set to 0 to disable output height limits.
///
/// Default: 0
pub output_max_height_lines: usize,
}
impl Settings for ReplSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
let repl = content.repl.as_ref().unwrap();
Self {
max_lines: repl.max_lines.unwrap(),
max_columns: repl.max_columns.unwrap(),
inline_output: repl.inline_output.unwrap_or(true),
inline_output_max_length: repl.inline_output_max_length.unwrap_or(50),
output_max_height_lines: repl.output_max_height_lines.unwrap_or(0),
}
}
}

View File

@@ -0,0 +1,441 @@
use std::future::Future;
use std::sync::Arc;
use anyhow::{Context as _, Result};
use collections::{HashMap, HashSet};
use command_palette_hooks::CommandPaletteFilter;
use gpui::{
App, Context, Entity, EntityId, Global, SharedString, Subscription, Task, TaskExt, prelude::*,
};
use jupyter_websocket_client::RemoteServer;
use language::{Language, LanguageName};
use project::{Fs, Project, ProjectPath, WorktreeId};
use remote::RemoteConnectionOptions;
use settings::{Settings, SettingsStore};
use util::rel_path::RelPath;
use crate::kernels::{
Kernel, PythonEnvKernelSpecification, list_remote_kernelspecs, local_kernel_specifications,
python_env_kernel_specifications, wsl_kernel_specifications,
};
use crate::{JupyterSettings, KernelSpecification, Session};
struct GlobalReplStore(Entity<ReplStore>);
impl Global for GlobalReplStore {}
pub struct ReplStore {
fs: Arc<dyn Fs>,
enabled: bool,
sessions: HashMap<EntityId, Entity<Session>>,
kernel_specifications: Vec<KernelSpecification>,
kernelspecs_initialized: bool,
selected_kernel_for_worktree: HashMap<WorktreeId, KernelSpecification>,
kernel_specifications_for_worktree: HashMap<WorktreeId, Vec<KernelSpecification>>,
active_python_toolchain_for_worktree: HashMap<WorktreeId, SharedString>,
remote_worktrees: HashSet<WorktreeId>,
fetching_python_kernelspecs: HashSet<WorktreeId>,
_subscriptions: Vec<Subscription>,
}
impl ReplStore {
const NAMESPACE: &'static str = "repl";
pub(crate) fn init(fs: Arc<dyn Fs>, cx: &mut App) {
let store = cx.new(move |cx| Self::new(fs, cx));
cx.set_global(GlobalReplStore(store))
}
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalReplStore>().0.clone()
}
pub fn new(fs: Arc<dyn Fs>, cx: &mut Context<Self>) -> Self {
let subscriptions = vec![
cx.observe_global::<SettingsStore>(move |this, cx| {
this.set_enabled(JupyterSettings::enabled(cx), cx);
}),
cx.on_app_quit(Self::shutdown_all_sessions),
];
let this = Self {
fs,
enabled: JupyterSettings::enabled(cx),
sessions: HashMap::default(),
kernel_specifications: Vec::new(),
kernelspecs_initialized: false,
_subscriptions: subscriptions,
kernel_specifications_for_worktree: HashMap::default(),
selected_kernel_for_worktree: HashMap::default(),
active_python_toolchain_for_worktree: HashMap::default(),
remote_worktrees: HashSet::default(),
fetching_python_kernelspecs: HashSet::default(),
};
this.on_enabled_changed(cx);
this
}
pub fn fs(&self) -> &Arc<dyn Fs> {
&self.fs
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn has_python_kernelspecs(&self, worktree_id: WorktreeId) -> bool {
self.kernel_specifications_for_worktree
.contains_key(&worktree_id)
}
pub fn kernel_specifications_for_worktree(
&self,
worktree_id: WorktreeId,
) -> impl Iterator<Item = &KernelSpecification> {
let global_specs = if self.remote_worktrees.contains(&worktree_id) {
None
} else {
Some(self.kernel_specifications.iter())
};
self.kernel_specifications_for_worktree
.get(&worktree_id)
.into_iter()
.flat_map(|specs| specs.iter())
.chain(global_specs.into_iter().flatten())
}
pub fn pure_jupyter_kernel_specifications(&self) -> impl Iterator<Item = &KernelSpecification> {
self.kernel_specifications.iter()
}
pub fn sessions(&self) -> impl Iterator<Item = &Entity<Session>> {
self.sessions.values()
}
fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
if self.enabled == enabled {
return;
}
self.enabled = enabled;
self.on_enabled_changed(cx);
}
fn on_enabled_changed(&self, cx: &mut Context<Self>) {
if !self.enabled {
CommandPaletteFilter::update_global(cx, |filter, _cx| {
filter.hide_namespace(Self::NAMESPACE);
});
return;
}
CommandPaletteFilter::update_global(cx, |filter, _cx| {
filter.show_namespace(Self::NAMESPACE);
});
cx.notify();
}
pub fn mark_ipykernel_installed(
&mut self,
cx: &mut Context<Self>,
spec: &PythonEnvKernelSpecification,
) {
for specs in self.kernel_specifications_for_worktree.values_mut() {
for kernel_spec in specs.iter_mut() {
if let KernelSpecification::PythonEnv(env_spec) = kernel_spec {
if env_spec == spec {
env_spec.has_ipykernel = true;
}
}
}
}
cx.notify();
}
pub fn refresh_python_kernelspecs(
&mut self,
worktree_id: WorktreeId,
project: &Entity<Project>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
if !self.fetching_python_kernelspecs.insert(worktree_id) {
return Task::ready(Ok(()));
}
let is_remote = project.read(cx).is_remote();
// WSL does require access to global kernel specs, so we only exclude remote worktrees that aren't WSL.
// TODO: a better way to handle WSL vs SSH/remote projects,
let is_wsl_remote = project
.read(cx)
.remote_connection_options(cx)
.map_or(false, |opts| {
matches!(opts, RemoteConnectionOptions::Wsl(_))
});
let kernel_specifications_task = python_env_kernel_specifications(project, worktree_id, cx);
let active_toolchain = project.read(cx).active_toolchain(
ProjectPath {
worktree_id,
path: RelPath::empty().into(),
},
LanguageName::new_static("Python"),
cx,
);
cx.spawn(async move |this, cx| {
let kernel_specifications_res = kernel_specifications_task.await;
this.update(cx, |this, _cx| {
this.fetching_python_kernelspecs.remove(&worktree_id);
})
.ok();
let kernel_specifications =
kernel_specifications_res.context("getting python kernelspecs")?;
let active_toolchain_path = active_toolchain.await.map(|toolchain| toolchain.path);
this.update(cx, |this, cx| {
this.kernel_specifications_for_worktree
.insert(worktree_id, kernel_specifications);
if let Some(path) = active_toolchain_path {
this.active_python_toolchain_for_worktree
.insert(worktree_id, path);
}
if is_remote && !is_wsl_remote {
this.remote_worktrees.insert(worktree_id);
} else {
this.remote_worktrees.remove(&worktree_id);
}
cx.notify();
})
})
}
fn get_remote_kernel_specifications(
&self,
cx: &mut Context<Self>,
) -> Option<Task<Result<Vec<KernelSpecification>>>> {
match (
std::env::var("JUPYTER_SERVER"),
std::env::var("JUPYTER_TOKEN"),
) {
(Ok(server), Ok(token)) => {
let remote_server = RemoteServer {
base_url: server,
token,
};
let http_client = cx.http_client();
Some(cx.spawn(async move |_, _| {
list_remote_kernelspecs(remote_server, http_client)
.await
.map(|specs| {
specs
.into_iter()
.map(KernelSpecification::JupyterServer)
.collect()
})
}))
}
_ => None,
}
}
pub fn ensure_kernelspecs(&mut self, cx: &mut Context<Self>) {
if self.kernelspecs_initialized {
return;
}
self.kernelspecs_initialized = true;
self.refresh_kernelspecs(cx).detach_and_log_err(cx);
}
pub fn refresh_kernelspecs(&mut self, cx: &mut Context<Self>) -> Task<Result<()>> {
let local_kernel_specifications = local_kernel_specifications(self.fs.clone());
let wsl_kernel_specifications = wsl_kernel_specifications(cx.background_executor().clone());
let remote_kernel_specifications = self.get_remote_kernel_specifications(cx);
let all_specs = cx.background_spawn(async move {
let mut all_specs = local_kernel_specifications
.await?
.into_iter()
.map(KernelSpecification::Jupyter)
.collect::<Vec<_>>();
if let Ok(wsl_specs) = wsl_kernel_specifications.await {
all_specs.extend(wsl_specs);
}
if let Some(remote_task) = remote_kernel_specifications
&& let Ok(remote_specs) = remote_task.await
{
all_specs.extend(remote_specs);
}
anyhow::Ok(all_specs)
});
cx.spawn(async move |this, cx| {
let all_specs = all_specs.await;
if let Ok(specs) = all_specs {
this.update(cx, |this, cx| {
this.kernel_specifications = specs;
cx.notify();
})
.ok();
}
anyhow::Ok(())
})
}
pub fn set_active_kernelspec(
&mut self,
worktree_id: WorktreeId,
kernelspec: KernelSpecification,
_cx: &mut Context<Self>,
) {
self.selected_kernel_for_worktree
.insert(worktree_id, kernelspec);
}
pub fn active_python_toolchain_path(&self, worktree_id: WorktreeId) -> Option<&SharedString> {
self.active_python_toolchain_for_worktree.get(&worktree_id)
}
pub fn selected_kernel(&self, worktree_id: WorktreeId) -> Option<&KernelSpecification> {
self.selected_kernel_for_worktree.get(&worktree_id)
}
pub fn is_recommended_kernel(
&self,
worktree_id: WorktreeId,
spec: &KernelSpecification,
) -> bool {
if let Some(active_path) = self.active_python_toolchain_path(worktree_id) {
spec.path().as_ref() == active_path.as_ref()
} else {
false
}
}
pub fn active_kernelspec(
&self,
worktree_id: WorktreeId,
language_at_cursor: Option<Arc<Language>>,
cx: &App,
) -> Option<KernelSpecification> {
if let Some(selected) = self.selected_kernel_for_worktree.get(&worktree_id).cloned() {
return Some(selected);
}
let language_at_cursor = language_at_cursor?;
// Prefer the recommended (active toolchain) kernel if it has ipykernel
if let Some(active_path) = self.active_python_toolchain_path(worktree_id) {
let recommended = self
.kernel_specifications_for_worktree(worktree_id)
.find(|spec| {
spec.has_ipykernel()
&& language_at_cursor.matches_kernel_language(spec.language().as_ref())
&& spec.path().as_ref() == active_path.as_ref()
})
.cloned();
if recommended.is_some() {
return recommended;
}
}
// Then try the first PythonEnv with ipykernel matching the language
let python_env = self
.kernel_specifications_for_worktree(worktree_id)
.find(|spec| {
matches!(spec, KernelSpecification::PythonEnv(_))
&& spec.has_ipykernel()
&& language_at_cursor.matches_kernel_language(spec.language().as_ref())
})
.cloned();
if python_env.is_some() {
return python_env;
}
// Fall back to legacy name-based and language-based matching
self.kernelspec_legacy_by_lang_only(worktree_id, language_at_cursor, cx)
}
fn kernelspec_legacy_by_lang_only(
&self,
worktree_id: WorktreeId,
language_at_cursor: Arc<Language>,
cx: &App,
) -> Option<KernelSpecification> {
let settings = JupyterSettings::get_global(cx);
let selected_kernel = settings
.kernel_selections
.get(language_at_cursor.code_fence_block_name().as_ref());
let found_by_name = self
.kernel_specifications_for_worktree(worktree_id)
.find(|runtime_specification| {
if let (Some(selected), KernelSpecification::Jupyter(runtime_specification)) =
(selected_kernel, runtime_specification)
{
return runtime_specification.name.to_lowercase() == selected.to_lowercase();
}
false
})
.cloned();
if let Some(found_by_name) = found_by_name {
return Some(found_by_name);
}
self.kernel_specifications_for_worktree(worktree_id)
.find(|spec| {
spec.has_ipykernel()
&& language_at_cursor.matches_kernel_language(spec.language().as_ref())
})
.cloned()
}
pub fn get_session(&self, entity_id: EntityId) -> Option<&Entity<Session>> {
self.sessions.get(&entity_id)
}
pub fn insert_session(&mut self, entity_id: EntityId, session: Entity<Session>) {
self.sessions.insert(entity_id, session);
}
pub fn remove_session(&mut self, entity_id: EntityId) {
self.sessions.remove(&entity_id);
}
fn shutdown_all_sessions(
&mut self,
cx: &mut Context<Self>,
) -> impl Future<Output = ()> + use<> {
for session in self.sessions.values() {
session.update(cx, |session, _cx| {
if let Kernel::RunningKernel(mut kernel) =
std::mem::replace(&mut session.kernel, Kernel::Shutdown)
{
kernel.kill();
}
});
}
self.sessions.clear();
futures::future::ready(())
}
#[cfg(test)]
pub fn set_kernel_specs_for_testing(
&mut self,
specs: Vec<KernelSpecification>,
cx: &mut Context<Self>,
) {
self.kernel_specifications = specs;
cx.notify();
}
}

1027
crates/repl/src/session.rs Normal file

File diff suppressed because it is too large Load Diff