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:
82
crates/repl/Cargo.toml
Normal file
82
crates/repl/Cargo.toml
Normal 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
1
crates/repl/LICENSE-GPL
Symbolic link
@@ -0,0 +1 @@
|
||||
../../LICENSE-GPL
|
||||
5
crates/repl/src/components.rs
Normal file
5
crates/repl/src/components.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod kernel_list_item;
|
||||
mod kernel_options;
|
||||
|
||||
pub use kernel_list_item::*;
|
||||
pub use kernel_options::*;
|
||||
60
crates/repl/src/components/kernel_list_item.rs
Normal file
60
crates/repl/src/components/kernel_list_item.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
490
crates/repl/src/components/kernel_options.rs
Normal file
490
crates/repl/src/components/kernel_options.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
28
crates/repl/src/jupyter_settings.rs
Normal file
28
crates/repl/src/jupyter_settings.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
767
crates/repl/src/kernels/mod.rs
Normal file
767
crates/repl/src/kernels/mod.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
456
crates/repl/src/kernels/native_kernel.rs
Normal file
456
crates/repl/src/kernels/native_kernel.rs
Normal 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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
307
crates/repl/src/kernels/remote_kernels.rs
Normal file
307
crates/repl/src/kernels/remote_kernels.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
312
crates/repl/src/kernels/ssh_kernel.rs
Normal file
312
crates/repl/src/kernels/ssh_kernel.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
598
crates/repl/src/kernels/wsl_kernel.rs
Normal file
598
crates/repl/src/kernels/wsl_kernel.rs
Normal 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)
|
||||
}
|
||||
4
crates/repl/src/notebook.rs
Normal file
4
crates/repl/src/notebook.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
mod cell;
|
||||
mod notebook_ui;
|
||||
pub use cell::*;
|
||||
pub use notebook_ui::*;
|
||||
1313
crates/repl/src/notebook/cell.rs
Normal file
1313
crates/repl/src/notebook/cell.rs
Normal file
File diff suppressed because it is too large
Load Diff
1906
crates/repl/src/notebook/notebook_ui.rs
Normal file
1906
crates/repl/src/notebook/notebook_ui.rs
Normal file
File diff suppressed because it is too large
Load Diff
1290
crates/repl/src/outputs.rs
Normal file
1290
crates/repl/src/outputs.rs
Normal file
File diff suppressed because it is too large
Load Diff
198
crates/repl/src/outputs/html.rs
Normal file
198
crates/repl/src/outputs/html.rs
Normal 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 | ✓ |"));
|
||||
}
|
||||
}
|
||||
192
crates/repl/src/outputs/image.rs
Normal file
192
crates/repl/src/outputs/image.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
296
crates/repl/src/outputs/json.rs
Normal file
296
crates/repl/src/outputs/json.rs
Normal 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"));
|
||||
});
|
||||
}
|
||||
}
|
||||
56
crates/repl/src/outputs/markdown.rs
Normal file
56
crates/repl/src/outputs/markdown.rs
Normal 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)
|
||||
}
|
||||
420
crates/repl/src/outputs/plain.rs
Normal file
420
crates/repl/src/outputs/plain.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
290
crates/repl/src/outputs/table.rs
Normal file
290
crates/repl/src/outputs/table.rs
Normal 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
|
||||
}
|
||||
}
|
||||
45
crates/repl/src/outputs/user_error.rs
Normal file
45
crates/repl/src/outputs/user_error.rs
Normal 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
77
crates/repl/src/repl.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
1084
crates/repl/src/repl_editor.rs
Normal file
1084
crates/repl/src/repl_editor.rs
Normal file
File diff suppressed because it is too large
Load Diff
287
crates/repl/src/repl_sessions_ui.rs
Normal file
287
crates/repl/src/repl_sessions_ui.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
44
crates/repl/src/repl_settings.rs
Normal file
44
crates/repl/src/repl_settings.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
441
crates/repl/src/repl_store.rs
Normal file
441
crates/repl/src/repl_store.rs
Normal 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
1027
crates/repl/src/session.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user