дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(notebook): add native Jupyter notebook editing and execution (#268)
Opens .ipynb files in a native notebook editor, in a project or LightEdit. - crates/ide-notebook: lossless nbformat documents (unchanged notebooks save byte-for-byte, unknown fields kept, external changes detected) and owned local kernels over ZMQ via kernelspec — the selected Python environment only needs ipykernel, no Jupyter server. - ide-host owns notebook documents per project incarnation and admits one execution batch at a time; outputs stream over IPC channels and kernel status is an IdeEvent. - src/features/notebook: virtualized Python/Markdown cells, insert/delete/move/change type, Run Cell / Run All (Shift+Enter), interrupt, restart, interpreter selection from ide-sdk discovery, shared split views. Raw/unknown cells stay read-only. - Custom editor kinds take part in dirty state, save/save all, undo/redo and Save/Don't Save/Cancel; closing editors from other features awaits that decision. - Outputs: text, tracebacks, PNG/JPEG and sanitized SVG inline; HTML sanitized in a sandboxed iframe without allow-same-origin; JavaScript output is shown as inert source. - Explicit launches (opening a notebook from the OS) win over session restoration. Closes #131 Co-authored-by: dvictor357 <[email protected]>
Цей коміт міститься в:
1 джерело
3a1e47d872
коміт
0d368ba947
68 files changed
+9689
-219
No files matched your search
Створено
+146
-3
@@ -327,6 +327,19 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asynchronous-codec"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atk"
|
||||
version = "0.18.2"
|
||||
@@ -4199,6 +4212,7 @@ dependencies = [
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-notebook",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"ide-sdk",
|
||||
@@ -4216,10 +4230,12 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4355,6 +4371,29 @@ dependencies = [
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.25"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-vfs",
|
||||
"jupyter-protocol",
|
||||
"jupyter-zmq-client",
|
||||
"libc",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"zeromq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.25"
|
||||
@@ -5033,6 +5072,41 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-protocol"
|
||||
version = "2.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf199c30e61505042f5c0d79a33b5a71c552ea8b64243ff3498b1f4592d8e692"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-zmq-client"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f59bf6241faf6a8c06416c351756be9a79976e8faad220604b9a26f568270ab0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"dirs 6.0.0",
|
||||
"jupyter-protocol",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"uuid",
|
||||
"zeromq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keccak"
|
||||
version = "0.2.2"
|
||||
@@ -6061,6 +6135,7 @@ dependencies = [
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-notebook",
|
||||
"ide-plugin",
|
||||
"ide-profiler",
|
||||
"ide-remote",
|
||||
@@ -7383,6 +7458,12 @@ dependencies = [
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "saa"
|
||||
version = "5.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd85992a680de788217d9c70b74aa7685b108cf1aba02f728fb27f1b27199ad1"
|
||||
|
||||
[[package]]
|
||||
name = "salsa20"
|
||||
version = "0.11.0"
|
||||
@@ -7402,6 +7483,16 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scc"
|
||||
version = "3.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5f8ca67c4f6208caf824037a6a9df0211b4e0937fccc4f543441f294d04511"
|
||||
dependencies = [
|
||||
"saa",
|
||||
"sdd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
@@ -7490,6 +7581,15 @@ dependencies = [
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sdd"
|
||||
version = "4.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31877eee03686b905845993b5d9809a220c9dd5a10e9d6021d7ca67384c9070"
|
||||
dependencies = [
|
||||
"saa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.8.1"
|
||||
@@ -7783,6 +7883,12 @@ dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha1dc"
|
||||
version = "0.1.5"
|
||||
@@ -9067,7 +9173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"asynchronous-codec",
|
||||
"asynchronous-codec 0.6.2",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"connection-string",
|
||||
@@ -9146,9 +9252,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.54.0"
|
||||
version = "1.54.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
|
||||
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -10046,6 +10152,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -10651,6 +10758,17 @@ version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
|
||||
|
||||
[[package]]
|
||||
name = "win_uds"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7dd30a1a28a3799479cbf4e17284a220ea9ff6bad098a9d0224543a5d1efe1da"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"futures-io",
|
||||
"socket2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
@@ -11389,6 +11507,31 @@ version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
|
||||
|
||||
[[package]]
|
||||
name = "zeromq"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "efb2c254fd8f366755335c9e43b865f8484fe3bd717d65ffe7c3f28852863030"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"asynchronous-codec 0.7.0",
|
||||
"bytes",
|
||||
"crossbeam-queue",
|
||||
"futures",
|
||||
"log",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.9.5",
|
||||
"regex",
|
||||
"scc",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"uuid",
|
||||
"win_uds",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.5"
|
||||
|
||||
@@ -40,6 +40,7 @@ ide-fonts = { path = "crates/ide-fonts" }
|
||||
ide-ssh = { path = "crates/ide-ssh" }
|
||||
ide-remote = { path = "crates/ide-remote" }
|
||||
ide-host = { path = "crates/ide-host" }
|
||||
ide-notebook = { path = "crates/ide-notebook" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -85,6 +86,9 @@ redb = "4"
|
||||
tree-sitter = "0.25"
|
||||
|
||||
# protocols & integrations
|
||||
jupyter-zmq-client = { version = "=2.0.0", default-features = false, features = ["tokio-runtime", "ring"] }
|
||||
jupyter-protocol = "=2.0.3"
|
||||
zeromq = { version = "0.6", default-features = false, features = ["tokio-runtime", "tcp-transport"] }
|
||||
lsp-types = "0.97"
|
||||
gix = { version = "0.89", default-features = false }
|
||||
portable-pty = "0.9"
|
||||
|
||||
@@ -54,6 +54,15 @@ pnpm tauri dev
|
||||
|
||||
Rust 1.96, Node 22, pnpm and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
Native notebook execution tests are opt-in and need two distinct Python environments
|
||||
with `ipykernel` installed:
|
||||
|
||||
```sh
|
||||
OPENIDEA_TEST_PYTHON=/first/env/bin/python \
|
||||
OPENIDEA_TEST_PYTHON_SECOND=/second/env/bin/python \
|
||||
cargo test -p ide-notebook --test kernel_runtime -- --ignored --test-threads=1
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
@@ -117,6 +117,15 @@ pub enum IdeEvent {
|
||||
AskpassDone {
|
||||
id: u64,
|
||||
},
|
||||
/// The kernel of an open notebook changed state (`starting`, `idle`, `busy`,
|
||||
/// `dead`); `message` says why it failed to start or died.
|
||||
NotebookKernel {
|
||||
/// Backend document incarnation; stale queued events cannot target a reopen.
|
||||
id: String,
|
||||
path: crate::IpcPath,
|
||||
state: String,
|
||||
message: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// What an askpass prompt asks for.
|
||||
|
||||
@@ -34,17 +34,20 @@ ide-ssh.workspace = true
|
||||
ide-remote.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-agent.workspace = true
|
||||
ide-notebook.workspace = true
|
||||
ide-tools = { path = "../ide-tools" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
tokio = { workspace = true, features = ["io-std"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
parking_lot.workspace = true
|
||||
dunce.workspace = true
|
||||
dirs.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
|
||||
tempfile.workspace = true
|
||||
@@ -4,6 +4,7 @@
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod environment;
|
||||
pub mod notebook;
|
||||
pub mod ops;
|
||||
mod project;
|
||||
mod proxy;
|
||||
@@ -132,26 +133,75 @@ impl Host {
|
||||
}
|
||||
|
||||
pub fn close_project(&self, window: &str) {
|
||||
if let Some(project) = self.projects.write().remove(window) {
|
||||
let project = self.projects.write().remove(window);
|
||||
if let Some(project) = project {
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
project.agents.shutdown();
|
||||
project.notebooks.stop_admission();
|
||||
self.shutdown_notebooks_blocking(vec![project.notebooks.clone()]);
|
||||
let lsp = project.lsp.clone();
|
||||
self.runtime.spawn(async move { lsp.shutdown_all().await });
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops what this host started: language servers, agents, runs and terminals.
|
||||
/// Synchronous project/window destruction must finish owned kernel cleanup,
|
||||
/// not enqueue work which can be lost as the desktop runtime exits.
|
||||
fn shutdown_notebooks_blocking(&self, notebooks: Vec<Arc<notebook::NotebookService>>) {
|
||||
for service in ¬ebooks {
|
||||
service.stop_admission();
|
||||
}
|
||||
let runtime = self.runtime.clone();
|
||||
let wait = move || {
|
||||
let worker = std::thread::spawn(move || {
|
||||
runtime.block_on(async move {
|
||||
for service in notebooks {
|
||||
service.shutdown().await;
|
||||
}
|
||||
});
|
||||
});
|
||||
if let Err(error) = worker.join() {
|
||||
tracing::error!("notebook shutdown thread failed: {error:?}");
|
||||
}
|
||||
};
|
||||
if tokio::runtime::Handle::try_current()
|
||||
.is_ok_and(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread)
|
||||
{
|
||||
tokio::task::block_in_place(wait);
|
||||
} else {
|
||||
wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// Called before the desktop runtime exits, including update/restart exits.
|
||||
pub fn shutdown_notebooks_on_exit(&self) {
|
||||
self.shutdown_notebooks_blocking(self.projects().iter().map(|project| project.notebooks.clone()).collect());
|
||||
}
|
||||
|
||||
/// Stops what this host started: notebook kernels, language servers, agents, runs and terminals.
|
||||
/// For a process that exits without dropping the host (the remote server).
|
||||
pub async fn shutdown(&self) {
|
||||
self.environment.shutdown();
|
||||
self.processes.kill_all();
|
||||
self.terminals.kill_all();
|
||||
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
|
||||
for project in projects {
|
||||
for project in &projects {
|
||||
project.notebooks.stop_admission();
|
||||
project.agents.shutdown();
|
||||
project.lsp.shutdown_all().await;
|
||||
}
|
||||
// Kernel cleanup owns its interrupt/termination/reaping deadlines. An
|
||||
// outer LSP timeout must never abort it before owned children are reaped.
|
||||
for project in &projects {
|
||||
project.notebooks.shutdown().await;
|
||||
}
|
||||
let stop_language_servers = async {
|
||||
for project in &projects {
|
||||
project.lsp.shutdown_all().await;
|
||||
}
|
||||
};
|
||||
if tokio::time::timeout(std::time::Duration::from_secs(10), stop_language_servers).await.is_err() {
|
||||
tracing::warn!("language servers did not shut down in time");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,796 @@
|
||||
//! Project-incarnation notebook ownership and whole-batch execution admission.
|
||||
use crate::ops::Channel;
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
|
||||
use ide_notebook::kernel::{KernelManager, KernelOutput};
|
||||
use ide_notebook::{NotebookDocument, NotebookEdit, NotebookSnapshot};
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use specta::Type;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{Mutex as AsyncMutex, watch};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotebookOutputEvent {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
pub cell: String,
|
||||
pub msg_id: String,
|
||||
pub kind: String,
|
||||
#[specta(type = specta_typescript::Unknown)]
|
||||
pub content: serde_json::Value,
|
||||
pub version: u32,
|
||||
pub dirty: bool,
|
||||
pub changed_cells: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Registry {
|
||||
stopped: bool,
|
||||
paths: HashMap<PathBuf, String>,
|
||||
documents: HashMap<String, Arc<ManagedDocument>>,
|
||||
}
|
||||
|
||||
struct ManagedDocument {
|
||||
id: String,
|
||||
path: PathBuf,
|
||||
state: Mutex<DocumentState>,
|
||||
control: AsyncMutex<()>,
|
||||
}
|
||||
|
||||
struct DocumentState {
|
||||
document: NotebookDocument,
|
||||
generation: u64,
|
||||
transitioning: bool,
|
||||
closed: bool,
|
||||
active: Option<Arc<Batch>>,
|
||||
kernel_status: Option<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
struct Batch {
|
||||
cancel: CancellationToken,
|
||||
done: watch::Sender<bool>,
|
||||
}
|
||||
|
||||
struct BatchGuard {
|
||||
document: Arc<ManagedDocument>,
|
||||
batch: Arc<Batch>,
|
||||
}
|
||||
|
||||
pub(crate) struct ExecutionAdmission {
|
||||
guard: BatchGuard,
|
||||
generation: u64,
|
||||
plan: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Drop for BatchGuard {
|
||||
fn drop(&mut self) {
|
||||
let mut state = self.document.state.lock();
|
||||
if state.active.as_ref().is_some_and(|active| Arc::ptr_eq(active, &self.batch)) {
|
||||
if let Err(error) = state.document.set_execution_active(false) {
|
||||
tracing::error!("notebook batch release: {error}");
|
||||
}
|
||||
state.active = None;
|
||||
}
|
||||
self.batch.done.send_replace(true);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NotebookService {
|
||||
registry: Arc<Mutex<Registry>>,
|
||||
kernels: Arc<KernelManager>,
|
||||
sink: SharedSink,
|
||||
}
|
||||
|
||||
impl NotebookService {
|
||||
pub fn new(runtime_dir: PathBuf, sink: SharedSink) -> Arc<Self> {
|
||||
let registry = Arc::new(Mutex::new(Registry::default()));
|
||||
let status_registry = registry.clone();
|
||||
let status_sink = sink.clone();
|
||||
let kernels = KernelManager::new(
|
||||
runtime_dir,
|
||||
Arc::new(move |path, status, message| {
|
||||
let document = {
|
||||
let registry = status_registry.lock();
|
||||
if registry.stopped {
|
||||
return;
|
||||
}
|
||||
registry.paths.get(path).and_then(|id| registry.documents.get(id)).cloned()
|
||||
};
|
||||
if let Some(document) = document {
|
||||
let deliver = {
|
||||
let mut state = document.state.lock();
|
||||
if state.closed {
|
||||
return;
|
||||
}
|
||||
state.kernel_status = Some((status.as_str().to_owned(), message.clone()));
|
||||
!state.transitioning
|
||||
};
|
||||
if deliver {
|
||||
status_sink.emit(IdeEvent::NotebookKernel {
|
||||
id: document.id.clone(),
|
||||
path: path.to_path_buf().into(),
|
||||
state: status.as_str().to_owned(),
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
Arc::new(Self { registry, kernels: Arc::new(kernels), sink })
|
||||
}
|
||||
|
||||
pub fn open(&self, path: &Path) -> IdeResult<NotebookSnapshot> {
|
||||
let path = dunce::canonicalize(path)?;
|
||||
// Serialize first load with path insertion: split views share one incarnation.
|
||||
let mut registry = self.registry.lock();
|
||||
if registry.stopped {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if let Some(id) = registry.paths.get(&path) {
|
||||
return snapshot(®istry.documents[id]);
|
||||
}
|
||||
let document = NotebookDocument::load(&path)?;
|
||||
let initial = document.snapshot();
|
||||
let id = initial.id.clone();
|
||||
registry.paths.insert(path.clone(), id.clone());
|
||||
registry.documents.insert(
|
||||
id.clone(),
|
||||
Arc::new(ManagedDocument {
|
||||
id,
|
||||
path,
|
||||
state: Mutex::new(DocumentState {
|
||||
document,
|
||||
generation: 0,
|
||||
transitioning: false,
|
||||
closed: false,
|
||||
active: None,
|
||||
kernel_status: None,
|
||||
}),
|
||||
control: AsyncMutex::new(()),
|
||||
}),
|
||||
);
|
||||
Ok(initial)
|
||||
}
|
||||
|
||||
fn resolve(&self, id: &str) -> IdeResult<Arc<ManagedDocument>> {
|
||||
let registry = self.registry.lock();
|
||||
if registry.stopped {
|
||||
return Err(IdeError::not_found("notebook project has closed"));
|
||||
}
|
||||
registry.documents.get(id).cloned().ok_or_else(|| IdeError::not_found("notebook incarnation is closed"))
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> IdeResult<NotebookSnapshot> {
|
||||
snapshot(self.resolve(id)?.as_ref())
|
||||
}
|
||||
|
||||
pub fn edit(&self, id: &str, version: u32, edit: NotebookEdit) -> IdeResult<NotebookSnapshot> {
|
||||
let document = self.resolve(id)?;
|
||||
let mut state = document.state.lock();
|
||||
mutable(&state)?;
|
||||
state.document.edit(version, edit)
|
||||
}
|
||||
|
||||
pub fn save(&self, id: &str) -> IdeResult<NotebookSnapshot> {
|
||||
let document = self.resolve(id)?;
|
||||
let mut state = document.state.lock();
|
||||
mutable(&state)?;
|
||||
state.document.save()
|
||||
}
|
||||
|
||||
pub fn undo(&self, id: &str, redo: bool) -> IdeResult<NotebookSnapshot> {
|
||||
let document = self.resolve(id)?;
|
||||
let mut state = document.state.lock();
|
||||
mutable(&state)?;
|
||||
if state.active.is_some() {
|
||||
return Err(IdeError::unavailable("stop notebook execution before undo or redo"));
|
||||
}
|
||||
if redo { state.document.redo() } else { state.document.undo() }
|
||||
}
|
||||
|
||||
pub async fn interrupt(&self, id: &str) -> IdeResult<()> {
|
||||
let document = self.resolve(id)?;
|
||||
let batch = document.state.lock().active.clone();
|
||||
if let Some(batch) = &batch {
|
||||
batch.cancel.cancel();
|
||||
}
|
||||
// Draining makes prepareClose/save a stable checkpoint, including when
|
||||
// Python catches KeyboardInterrupt or cancel races with kernel startup.
|
||||
let interrupt = self.kernels.interrupt(&document.path).await;
|
||||
self.drain(&document, batch).await?;
|
||||
interrupt
|
||||
}
|
||||
|
||||
async fn drain(&self, document: &ManagedDocument, batch: Option<Arc<Batch>>) -> IdeResult<()> {
|
||||
let Some(batch) = batch else { return Ok(()) };
|
||||
let mut done = batch.done.subscribe();
|
||||
match tokio::time::timeout(Duration::from_secs(5), done.wait_for(|done| *done)).await {
|
||||
Ok(Ok(_)) => return Ok(()),
|
||||
Ok(Err(_)) => return Err(IdeError::internal("notebook execution completion channel closed")),
|
||||
Err(_) => {}
|
||||
}
|
||||
// An uncooperative kernel must not outlive a close/reload authorization.
|
||||
self.kernels.shutdown_kernel(&document.path).await?;
|
||||
tokio::time::timeout(Duration::from_secs(5), done.wait_for(|done| *done))
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable("notebook execution did not stop after kernel shutdown"))?
|
||||
.map_err(|_| IdeError::internal("notebook execution completion channel closed"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reload(&self, id: &str, restart: bool) -> IdeResult<NotebookSnapshot> {
|
||||
let document = self.resolve(id)?;
|
||||
let _control = document.control.lock().await;
|
||||
let batch = invalidate(&document)?;
|
||||
let result = async {
|
||||
let _ = self.kernels.interrupt(&document.path).await;
|
||||
self.drain(&document, batch).await?;
|
||||
if document.state.lock().closed {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if restart {
|
||||
self.kernels.restart(&document.path).await?;
|
||||
snapshot(&document)
|
||||
} else {
|
||||
self.kernels.shutdown_kernel(&document.path).await?;
|
||||
document.state.lock().document.reload()
|
||||
}
|
||||
}
|
||||
.await;
|
||||
let status = {
|
||||
let mut state = document.state.lock();
|
||||
state.transitioning = false;
|
||||
if state.closed { None } else { state.kernel_status.clone() }
|
||||
};
|
||||
if let Some((state, message)) = status {
|
||||
self.sink.emit(IdeEvent::NotebookKernel {
|
||||
id: document.id.clone(),
|
||||
path: document.path.clone().into(),
|
||||
state,
|
||||
message,
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Frontend disposal may arrive after project replacement has already retired this id.
|
||||
pub async fn close(&self, id: &str) -> IdeResult<()> {
|
||||
let document = match self.resolve(id) {
|
||||
Ok(document) => document,
|
||||
Err(error) if error.kind == ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let _control = document.control.lock().await;
|
||||
if document.state.lock().closed {
|
||||
return Ok(());
|
||||
}
|
||||
let batch = invalidate(&document)?;
|
||||
let result = async {
|
||||
let _ = self.kernels.interrupt(&document.path).await;
|
||||
self.drain(&document, batch).await?;
|
||||
self.kernels.shutdown_kernel(&document.path).await
|
||||
}
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
document.state.lock().transitioning = false;
|
||||
return Err(error);
|
||||
}
|
||||
document.state.lock().closed = true;
|
||||
let mut registry = self.registry.lock();
|
||||
registry.documents.remove(id);
|
||||
registry.paths.remove(&document.path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn admit(
|
||||
&self,
|
||||
id: &str,
|
||||
cells: Vec<String>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<ExecutionAdmission> {
|
||||
admit(self.resolve(id)?, cells, cancel)
|
||||
}
|
||||
|
||||
pub(crate) async fn execute(
|
||||
self: &Arc<Self>,
|
||||
admission: ExecutionAdmission,
|
||||
python: PathBuf,
|
||||
on_output: Channel<NotebookOutputEvent>,
|
||||
) -> IdeResult<NotebookSnapshot> {
|
||||
let document = admission.guard.document.clone();
|
||||
let service = self.clone();
|
||||
// Keep admission and native execution owned if an IPC receiver disappears.
|
||||
tokio::spawn(async move {
|
||||
let ExecutionAdmission { guard, generation, plan } = admission;
|
||||
let result =
|
||||
service.run_batch(document.clone(), generation, plan, python, guard.batch.clone(), on_output).await;
|
||||
drop(guard);
|
||||
match result {
|
||||
Ok(()) => snapshot(&document),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
async fn run_batch(
|
||||
&self,
|
||||
document: Arc<ManagedDocument>,
|
||||
generation: u64,
|
||||
plan: Vec<(String, String)>,
|
||||
python: PathBuf,
|
||||
batch: Arc<Batch>,
|
||||
on_output: Channel<NotebookOutputEvent>,
|
||||
) -> IdeResult<()> {
|
||||
for (cell, source) in plan {
|
||||
{
|
||||
let mut state = document.state.lock();
|
||||
if batch.cancel.is_cancelled() || state.closed || state.generation != generation {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
// A user may delete a queued cell while an earlier one executes.
|
||||
// Never resurrect it from the captured source or a late output.
|
||||
match state.document.begin_execution(&cell) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind == ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
let output_error = Arc::new(Mutex::new(None));
|
||||
let callback_document = document.clone();
|
||||
let callback_batch = batch.clone();
|
||||
let callback_error = output_error.clone();
|
||||
let callback_cell = cell.clone();
|
||||
let channel = on_output.clone();
|
||||
let result = self
|
||||
.kernels
|
||||
.execute(
|
||||
&document.path,
|
||||
&python,
|
||||
&source,
|
||||
batch.cancel.clone(),
|
||||
Arc::new(move |output: KernelOutput| {
|
||||
let event = match commit_output(
|
||||
&callback_document,
|
||||
&callback_batch,
|
||||
generation,
|
||||
&callback_cell,
|
||||
output,
|
||||
) {
|
||||
Ok(Some(event)) => event,
|
||||
Ok(None) => return,
|
||||
Err(error) => {
|
||||
*callback_error.lock() = Some(error);
|
||||
callback_batch.cancel.cancel();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = channel.send(event);
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Some(error) = output_error.lock().take() {
|
||||
return Err(error);
|
||||
}
|
||||
let result = result?;
|
||||
if batch.cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if result.status != "ok" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let state = document.state.lock();
|
||||
if state.closed || state.generation != generation || batch.cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Synchronously revoke admission before handing cleanup to an async runtime.
|
||||
pub fn stop_admission(&self) {
|
||||
let mut registry = self.registry.lock();
|
||||
registry.stopped = true;
|
||||
for document in registry.documents.values() {
|
||||
let mut state = document.state.lock();
|
||||
state.closed = true;
|
||||
state.generation += 1;
|
||||
if let Some(batch) = &state.active {
|
||||
batch.cancel.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
self.stop_admission();
|
||||
self.kernels.shutdown().await;
|
||||
let documents: Vec<_> = self.registry.lock().documents.values().cloned().collect();
|
||||
for document in documents {
|
||||
let batch = document.state.lock().active.clone();
|
||||
if let Err(error) = self.drain(&document, batch).await {
|
||||
tracing::error!("notebook cleanup: {error}");
|
||||
}
|
||||
}
|
||||
let mut registry = self.registry.lock();
|
||||
registry.paths.clear();
|
||||
registry.documents.clear();
|
||||
}
|
||||
}
|
||||
|
||||
fn mutable(state: &DocumentState) -> IdeResult<()> {
|
||||
if state.closed {
|
||||
Err(IdeError::not_found("notebook incarnation is closed"))
|
||||
} else if state.transitioning {
|
||||
Err(IdeError::unavailable("notebook lifecycle operation is in progress"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(document: &ManagedDocument) -> IdeResult<NotebookSnapshot> {
|
||||
let state = document.state.lock();
|
||||
if state.closed {
|
||||
return Err(IdeError::not_found("notebook incarnation is closed"));
|
||||
}
|
||||
Ok(state.document.snapshot())
|
||||
}
|
||||
|
||||
fn invalidate(document: &ManagedDocument) -> IdeResult<Option<Arc<Batch>>> {
|
||||
let mut state = document.state.lock();
|
||||
mutable(&state)?;
|
||||
state.transitioning = true;
|
||||
state.generation += 1;
|
||||
if let Some(batch) = &state.active {
|
||||
batch.cancel.cancel();
|
||||
}
|
||||
Ok(state.active.clone())
|
||||
}
|
||||
|
||||
fn execution_plan(snapshot: &NotebookSnapshot, cells: Vec<String>) -> IdeResult<Vec<(String, String)>> {
|
||||
let mut selected: HashSet<_> = cells.iter().map(String::as_str).collect();
|
||||
if selected.is_empty() || selected.len() != cells.len() {
|
||||
return Err(IdeError::invalid("select distinct code cells to execute"));
|
||||
}
|
||||
let mut plan = Vec::with_capacity(selected.len());
|
||||
for cell in &snapshot.cells {
|
||||
if selected.remove(cell.id.as_str()) {
|
||||
if cell.cell_type != "code" {
|
||||
return Err(IdeError::invalid("only code cells can execute"));
|
||||
}
|
||||
plan.push((cell.id.clone(), cell.source.clone()));
|
||||
}
|
||||
}
|
||||
if !selected.is_empty() {
|
||||
return Err(IdeError::not_found("an execution cell no longer exists"));
|
||||
}
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
fn admit(
|
||||
document: Arc<ManagedDocument>,
|
||||
cells: Vec<String>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<ExecutionAdmission> {
|
||||
let (batch, generation, plan) = {
|
||||
let mut state = document.state.lock();
|
||||
mutable(&state)?;
|
||||
if state.active.is_some() {
|
||||
return Err(IdeError::unavailable("notebook execution batch is already running"));
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let plan = execution_plan(&state.document.snapshot(), cells)?;
|
||||
state.document.set_execution_active(true)?;
|
||||
let (done, _) = watch::channel(false);
|
||||
let batch = Arc::new(Batch { cancel, done });
|
||||
state.active = Some(batch.clone());
|
||||
(batch, state.generation, plan)
|
||||
};
|
||||
Ok(ExecutionAdmission { guard: BatchGuard { document, batch }, generation, plan })
|
||||
}
|
||||
|
||||
/// Commit under the short document lock; the caller delivers only after release.
|
||||
fn commit_output(
|
||||
document: &ManagedDocument,
|
||||
batch: &Arc<Batch>,
|
||||
generation: u64,
|
||||
cell: &str,
|
||||
output: KernelOutput,
|
||||
) -> IdeResult<Option<NotebookOutputEvent>> {
|
||||
let mut state = document.state.lock();
|
||||
if state.closed
|
||||
|| state.generation != generation
|
||||
|| !state.active.as_ref().is_some_and(|active| Arc::ptr_eq(active, batch))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let changed_cells = match state.document.record_output(cell, &output) {
|
||||
Ok(changed) => changed,
|
||||
Err(error) if error.kind == ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
Ok(Some(NotebookOutputEvent {
|
||||
id: document.id.clone(),
|
||||
path: document.path.to_string_lossy().into_owned(),
|
||||
cell: cell.to_owned(),
|
||||
msg_id: output.msg_id,
|
||||
kind: output.kind,
|
||||
content: output.content,
|
||||
version: state.document.version(),
|
||||
dirty: state.document.is_dirty(),
|
||||
changed_cells,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture() -> (tempfile::TempDir, Arc<NotebookService>, NotebookSnapshot) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("session.ipynb");
|
||||
std::fs::write(&path, serde_json::to_vec(&json!({
|
||||
"nbformat": 4, "nbformat_minor": 5, "metadata": {},
|
||||
"cells": [
|
||||
{"id":"first","cell_type":"code","metadata":{},"source":"x = 1","outputs":[],"execution_count":null},
|
||||
{"id":"second","cell_type":"code","metadata":{},"source":"print(x)","outputs":[],"execution_count":null},
|
||||
{"id":"prose","cell_type":"markdown","metadata":{},"source":"notes"}
|
||||
]
|
||||
})).unwrap()).unwrap();
|
||||
let service = NotebookService::new(dir.path().join("kernels"), Arc::new(ide_core::NullSink));
|
||||
let snapshot = service.open(&path).unwrap();
|
||||
(dir, service, snapshot)
|
||||
}
|
||||
|
||||
fn output(kind: &str, content: serde_json::Value) -> KernelOutput {
|
||||
KernelOutput { kind: kind.into(), content, msg_id: "actual-parent-request".into() }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canonical_split_opens_share_incarnation_but_reopen_and_projects_do_not() {
|
||||
let (dir, service, initial) = fixture();
|
||||
let alternate = dir.path().join(".").join("session.ipynb");
|
||||
assert_eq!(service.open(&alternate).unwrap().id, initial.id);
|
||||
let other = NotebookService::new(dir.path().join("other-runtime"), Arc::new(ide_core::NullSink));
|
||||
let independent = other.open(&alternate).unwrap();
|
||||
assert_ne!(initial.id, independent.id);
|
||||
assert_eq!(other.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
|
||||
service.close(&initial.id).await.unwrap();
|
||||
let reopened = service.open(&alternate).unwrap();
|
||||
assert_ne!(reopened.id, initial.id);
|
||||
assert_eq!(service.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
|
||||
// A delayed old-incarnation close cannot dispose a new session at that path.
|
||||
service.close(&initial.id).await.unwrap();
|
||||
assert_eq!(service.get(&reopened.id).unwrap().id, reopened.id);
|
||||
service.shutdown().await;
|
||||
other.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn failed_close_keeps_the_incarnation_recoverable_until_a_retry_succeeds() {
|
||||
let (dir, service, initial) = fixture();
|
||||
// A batch that never drains models a kernel that ignores interrupt and shutdown.
|
||||
let admission =
|
||||
service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
|
||||
let error = service.close(&initial.id).await.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::Unavailable);
|
||||
assert!(admission.guard.batch.cancel.is_cancelled());
|
||||
// The backend still owns the incarnation: a reopen shares it instead of
|
||||
// starting a second kernel, and the failed close left it usable.
|
||||
let reopened = service.open(&dir.path().join("session.ipynb")).unwrap();
|
||||
assert_eq!(reopened.id, initial.id);
|
||||
assert!(matches!(service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()),
|
||||
Err(error) if error.kind == ErrorKind::Unavailable));
|
||||
drop(admission);
|
||||
service.close(&initial.id).await.unwrap();
|
||||
assert_eq!(service.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
|
||||
assert_ne!(service.open(&dir.path().join("session.ipynb")).unwrap().id, initial.id);
|
||||
service.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_admission_captures_document_order_and_retains_busy_until_drained() {
|
||||
let (_dir, service, initial) = fixture();
|
||||
let document = service.resolve(&initial.id).unwrap();
|
||||
let cells = vec![initial.cells[1].id.clone(), initial.cells[0].id.clone()];
|
||||
let admission = admit(document.clone(), cells.clone(), CancellationToken::new()).unwrap();
|
||||
assert_eq!(
|
||||
admission.plan,
|
||||
vec![(initial.cells[0].id.clone(), "x = 1".into()), (initial.cells[1].id.clone(), "print(x)".into()),]
|
||||
);
|
||||
service
|
||||
.edit(
|
||||
&initial.id,
|
||||
initial.version,
|
||||
NotebookEdit::SetSource { cell: initial.cells[1].id.clone(), source: "print('new source')".into() },
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(admission.plan[1].1, "print(x)");
|
||||
assert_eq!(service.undo(&initial.id, false).unwrap_err().kind, ErrorKind::Unavailable);
|
||||
admission.guard.batch.cancel.cancel();
|
||||
assert!(matches!(admit(document.clone(), cells.clone(), CancellationToken::new()), Err(error)
|
||||
if error.kind == ErrorKind::Unavailable));
|
||||
drop(admission);
|
||||
let next = admit(document, cells, CancellationToken::new()).unwrap();
|
||||
assert_eq!(next.plan[1].1, "print('new source')");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_commits_revision_and_cross_cell_display_updates_before_delivery() {
|
||||
let (_dir, service, initial) = fixture();
|
||||
let document = service.resolve(&initial.id).unwrap();
|
||||
let cells: Vec<_> = initial.cells[..2].iter().map(|cell| cell.id.clone()).collect();
|
||||
let admission = admit(document.clone(), cells.clone(), CancellationToken::new()).unwrap();
|
||||
let event = commit_output(
|
||||
&document,
|
||||
&admission.guard.batch,
|
||||
admission.generation,
|
||||
&cells[0],
|
||||
output(
|
||||
"display_data",
|
||||
json!({"data":{"text/plain":"before"},"metadata":{},"transient":{"display_id":"shared"}}),
|
||||
),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let committed = service.get(&initial.id).unwrap();
|
||||
assert_eq!(event.version, committed.version);
|
||||
assert!(event.version > initial.version);
|
||||
assert!(event.dirty);
|
||||
assert_eq!(event.msg_id, "actual-parent-request");
|
||||
assert_eq!(committed.cells[0].outputs[0]["data"]["text/plain"], "before");
|
||||
let updated = commit_output(
|
||||
&document,
|
||||
&admission.guard.batch,
|
||||
admission.generation,
|
||||
&cells[1],
|
||||
output(
|
||||
"update_display_data",
|
||||
json!({"data":{"text/plain":"after"},"metadata":{},"transient":{"display_id":"shared"}}),
|
||||
),
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(updated.changed_cells.contains(&cells[0]));
|
||||
let committed = service.get(&initial.id).unwrap();
|
||||
assert_eq!(committed.cells[0].outputs[0]["data"]["text/plain"], "after");
|
||||
assert!(committed.cells[0].outputs[0].get("transient").is_none());
|
||||
assert_eq!(updated.version, committed.version);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidated_or_completed_batches_cannot_commit_and_deleted_cells_stay_deleted() {
|
||||
let (_dir, service, initial) = fixture();
|
||||
let document = service.resolve(&initial.id).unwrap();
|
||||
let cell = initial.cells[0].id.clone();
|
||||
let admission = admit(document.clone(), vec![cell.clone()], CancellationToken::new()).unwrap();
|
||||
let deleted =
|
||||
service.edit(&initial.id, initial.version, NotebookEdit::DeleteCell { cell: cell.clone() }).unwrap();
|
||||
assert!(
|
||||
commit_output(
|
||||
&document,
|
||||
&admission.guard.batch,
|
||||
admission.generation,
|
||||
&cell,
|
||||
output("stream", json!({"name":"stdout","text":"late"}))
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(service.get(&initial.id).unwrap().version, deleted.version);
|
||||
let other = initial.cells[1].id.clone();
|
||||
invalidate(&document).unwrap();
|
||||
assert!(admission.guard.batch.cancel.is_cancelled());
|
||||
assert!(
|
||||
commit_output(
|
||||
&document,
|
||||
&admission.guard.batch,
|
||||
admission.generation,
|
||||
&other,
|
||||
output("stream", json!({"name":"stdout","text":"old generation"}))
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let batch = admission.guard.batch.clone();
|
||||
let generation = document.state.lock().generation;
|
||||
drop(admission);
|
||||
assert!(
|
||||
commit_output(
|
||||
&document,
|
||||
&batch,
|
||||
generation,
|
||||
&other,
|
||||
output("stream", json!({"name":"stdout","text":"post idle"}))
|
||||
)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(service.get(&initial.id).unwrap().version, deleted.version);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reload_preserves_incarnation_and_project_shutdown_revokes_all_admission() {
|
||||
let (_dir, service, initial) = fixture();
|
||||
let changed = service
|
||||
.edit(
|
||||
&initial.id,
|
||||
initial.version,
|
||||
NotebookEdit::SetSource { cell: initial.cells[0].id.clone(), source: "changed".into() },
|
||||
)
|
||||
.unwrap();
|
||||
let reloaded = service.reload(&initial.id, false).await.unwrap();
|
||||
assert_eq!(reloaded.id, initial.id);
|
||||
assert!(reloaded.version > changed.version);
|
||||
assert_eq!(reloaded.cells[0].source, "x = 1");
|
||||
assert!(!reloaded.dirty);
|
||||
let document = service.resolve(&initial.id).unwrap();
|
||||
let admission = admit(document.clone(), vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
|
||||
service.stop_admission();
|
||||
assert!(admission.guard.batch.cancel.is_cancelled());
|
||||
assert_eq!(service.get(&initial.id).unwrap_err().kind, ErrorKind::NotFound);
|
||||
assert!(matches!(admit(document, vec![initial.cells[0].id.clone()], CancellationToken::new()), Err(error)
|
||||
if error.kind == ErrorKind::NotFound));
|
||||
drop(admission);
|
||||
service.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_batch_selection_does_not_claim_admission() {
|
||||
let (_dir, service, initial) = fixture();
|
||||
let document = service.resolve(&initial.id).unwrap();
|
||||
for cells in [
|
||||
vec![],
|
||||
vec![initial.cells[0].id.clone(), initial.cells[0].id.clone()],
|
||||
vec![initial.cells[2].id.clone()],
|
||||
vec!["deleted".into()],
|
||||
] {
|
||||
assert!(admit(document.clone(), cells, CancellationToken::new()).is_err());
|
||||
}
|
||||
let cancelled = CancellationToken::new();
|
||||
cancelled.cancel();
|
||||
assert!(matches!(admit(document.clone(), vec![initial.cells[0].id.clone()], cancelled), Err(error)
|
||||
if error.kind == ErrorKind::Cancelled));
|
||||
let valid = admit(document, vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
|
||||
assert_eq!(valid.plan[0].1, "x = 1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupt_cancels_before_drain_and_does_not_release_a_live_batch() {
|
||||
let (_dir, service, initial) = fixture();
|
||||
service
|
||||
.edit(
|
||||
&initial.id,
|
||||
initial.version,
|
||||
NotebookEdit::SetSource { cell: initial.cells[0].id.clone(), source: "changed source".into() },
|
||||
)
|
||||
.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let admission = service.admit(&initial.id, vec![initial.cells[0].id.clone()], cancel.clone()).unwrap();
|
||||
let interrupt_service = service.clone();
|
||||
let id = initial.id.clone();
|
||||
let interrupt = tokio::spawn(async move { interrupt_service.interrupt(&id).await });
|
||||
tokio::time::timeout(Duration::from_secs(1), cancel.cancelled()).await.unwrap();
|
||||
assert!(!interrupt.is_finished(), "interrupt must wait for owned execution to drain");
|
||||
assert!(matches!(service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()),
|
||||
Err(error) if error.kind == ErrorKind::Unavailable));
|
||||
let mut done = admission.guard.batch.done.subscribe();
|
||||
drop(admission);
|
||||
assert!(*done.borrow_and_update());
|
||||
tokio::time::timeout(Duration::from_secs(1), interrupt).await.unwrap().unwrap().unwrap();
|
||||
let next = service.admit(&initial.id, vec![initial.cells[0].id.clone()], CancellationToken::new()).unwrap();
|
||||
assert_eq!(next.plan[0].1, "changed source");
|
||||
drop(next);
|
||||
let undone = service.undo(&initial.id, false).unwrap();
|
||||
assert_eq!(undone.cells[0].source, "x = 1");
|
||||
service.close(&initial.id).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod history;
|
||||
pub mod java;
|
||||
pub mod lsp;
|
||||
pub mod nav;
|
||||
pub mod notebook;
|
||||
pub mod project;
|
||||
mod python;
|
||||
pub mod refactor;
|
||||
@@ -60,6 +61,7 @@ impl<T> Clone for Channel<T> {
|
||||
pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
agent::register(d);
|
||||
lsp::register(d);
|
||||
notebook::register(d);
|
||||
nav::register(d);
|
||||
refactor::register(d);
|
||||
analysis::register(d);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Native notebook operations shared by desktop IPC and the remote dispatcher.
|
||||
use crate::Host;
|
||||
pub use crate::notebook::NotebookOutputEvent;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use ide_notebook::{NotebookEdit, NotebookSnapshot};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn open(host: &Host, window: &str, path: String) -> IdeResult<NotebookSnapshot> {
|
||||
let project = host.project(window)?;
|
||||
let path = PathBuf::from(path);
|
||||
let path = if path.is_absolute() { path } else { project.root.join(path) };
|
||||
let notebooks = project.notebooks.clone();
|
||||
host.blocking(move || notebooks.open(&path)).await
|
||||
}
|
||||
|
||||
pub fn get(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
host.project(window)?.notebooks.get(&id)
|
||||
}
|
||||
|
||||
pub async fn edit(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: String,
|
||||
version: u32,
|
||||
edit: NotebookEdit,
|
||||
) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
host.blocking(move || notebooks.edit(&id, version, edit)).await
|
||||
}
|
||||
|
||||
pub async fn save(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
host.blocking(move || notebooks.save(&id)).await
|
||||
}
|
||||
|
||||
pub async fn undo(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
host.blocking(move || notebooks.undo(&id, false)).await
|
||||
}
|
||||
|
||||
pub async fn redo(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
host.blocking(move || notebooks.undo(&id, true)).await
|
||||
}
|
||||
|
||||
pub async fn reload(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
// An abandoned IPC future must not leave a lifecycle transition half applied.
|
||||
tokio::spawn(async move { notebooks.reload(&id, false).await })
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn restart(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
tokio::spawn(async move { notebooks.reload(&id, true).await })
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn close(host: &Host, window: &str, id: String) -> IdeResult<()> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
tokio::spawn(async move { notebooks.close(&id).await })
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
pub async fn interrupt(host: &Host, window: &str, id: String) -> IdeResult<()> {
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
tokio::spawn(async move { notebooks.interrupt(&id).await })
|
||||
.await
|
||||
.map_err(|error| IdeError::internal(error.to_string()))?
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn execute(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: String,
|
||||
cells: Vec<String>,
|
||||
python: Option<String>,
|
||||
request: RequestId,
|
||||
on_output: Channel<NotebookOutputEvent>,
|
||||
) -> IdeResult<NotebookSnapshot> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let _request = RequestGuard { host, request, cancel: cancel.clone() };
|
||||
// Reserve the WHOLE batch and capture source before asynchronous discovery.
|
||||
let admission = project.notebooks.admit(&id, cells, cancel.clone())?;
|
||||
let selected = match python {
|
||||
Some(path) if path.trim().is_empty() => return Err(IdeError::invalid("select a Python interpreter")),
|
||||
Some(path) => PathBuf::from(path),
|
||||
None => {
|
||||
let root = project.root.clone();
|
||||
let settings = project.settings.effective();
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
result = host.blocking(move || {
|
||||
ide_sdk::apply::python_interpreter(&root, &settings)
|
||||
.or_else(|| ide_sdk::detect(ide_sdk::SdkKind::Python, Some(&root))
|
||||
.into_iter().next().map(|sdk| sdk.path.into_path_buf()))
|
||||
.ok_or_else(|| IdeError::unavailable("select a Python environment with ipykernel installed"))
|
||||
}) => result?,
|
||||
}
|
||||
}
|
||||
};
|
||||
// Do not canonicalize this path: resolving a venv symlink to base Python
|
||||
// changes sys.prefix and executes in the wrong environment.
|
||||
let python = if selected.is_absolute() { selected } else { project.root.join(selected) };
|
||||
project.notebooks.execute(admission, python, on_output).await
|
||||
}
|
||||
|
||||
struct RequestGuard<'a> {
|
||||
host: &'a Host,
|
||||
request: RequestId,
|
||||
cancel: ide_core::CancellationToken,
|
||||
}
|
||||
|
||||
impl Drop for RequestGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
// If the invocation is dropped, the owned batch still receives cancellation.
|
||||
self.cancel.cancel();
|
||||
self.host.end_request(self.request);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("notebook_open", |c| async move { open(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("notebook_get", |c| async move { get(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("notebook_edit", |c| async move {
|
||||
edit(&c.host, &c.window, c.arg("id")?, c.arg("version")?, c.arg("edit")?).await
|
||||
});
|
||||
d.add("notebook_save", |c| async move { save(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_reload", |c| async move { reload(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_undo", |c| async move { undo(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_redo", |c| async move { redo(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_close", |c| async move { close(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_interrupt", |c| async move { interrupt(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_restart", |c| async move { restart(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("notebook_execute", |c| async move {
|
||||
execute(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("id")?,
|
||||
c.arg("cells")?,
|
||||
c.arg("python")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onOutput")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -46,6 +46,8 @@ pub struct Project {
|
||||
pub http_history: Arc<ide_http::History>,
|
||||
/// Coding agent tasks (Agents tool window).
|
||||
pub agents: Arc<ide_agent::AgentManager>,
|
||||
/// Native notebooks and owned kernels, scoped to this project incarnation.
|
||||
pub notebooks: Arc<crate::notebook::NotebookService>,
|
||||
pub(crate) watcher: Watcher,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
@@ -282,6 +284,10 @@ impl Host {
|
||||
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
|
||||
|
||||
let (db, http_history, agents) = self.project_services(&root, &project_key);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
sink.clone(),
|
||||
);
|
||||
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
|
||||
let project = Arc::new(Project {
|
||||
root,
|
||||
@@ -301,6 +307,7 @@ impl Host {
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
_scratch_watcher: scratch_watcher,
|
||||
@@ -393,6 +400,10 @@ impl Host {
|
||||
let watcher = light_watcher(&dir, &sink, &documents, &history)?;
|
||||
let project_key = project_key(&dir);
|
||||
let (db, http_history, agents) = self.project_services(&dir, &project_key);
|
||||
let notebooks = crate::notebook::NotebookService::new(
|
||||
self.data_dir.join("notebook-runtime").join(uuid::Uuid::new_v4().to_string()),
|
||||
sink.clone(),
|
||||
);
|
||||
let project = Arc::new(Project {
|
||||
sink,
|
||||
languages,
|
||||
@@ -410,6 +421,7 @@ impl Host {
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
notebooks,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
_scratch_watcher: None,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "ide-notebook"
|
||||
description = "Lossless native notebook documents and owned Jupyter kernels"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
uuid.workspace = true
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
parking_lot.workspace = true
|
||||
tracing.workspace = true
|
||||
jupyter-zmq-client.workspace = true
|
||||
jupyter-protocol.workspace = true
|
||||
zeromq.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_JobObjects", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Memory"] }
|
||||
|
||||
@@ -0,0 +1,988 @@
|
||||
//! Lossless nbformat 4 documents. Runtime identities and execution bookkeeping
|
||||
//! never leak into existing notebook JSON. Only explicit mutations alter bytes.
|
||||
mod raw;
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use specta::Type;
|
||||
use uuid::Uuid;
|
||||
|
||||
use self::raw::{Layout, Member, Node};
|
||||
use crate::kernel::KernelOutput;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotebookSnapshot {
|
||||
pub id: String,
|
||||
pub path: String,
|
||||
pub version: u32,
|
||||
pub dirty: bool,
|
||||
pub can_undo: bool,
|
||||
pub can_redo: bool,
|
||||
pub nbformat: u32,
|
||||
pub nbformat_minor: u32,
|
||||
#[specta(type = specta_typescript::Unknown)]
|
||||
pub metadata: Value,
|
||||
pub cells: Vec<NotebookCell>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotebookCell {
|
||||
pub id: String,
|
||||
pub cell_type: String,
|
||||
pub source: String,
|
||||
#[specta(type = specta_typescript::Unknown)]
|
||||
pub metadata: Value,
|
||||
#[specta(type = Vec<specta_typescript::Unknown>)]
|
||||
pub outputs: Vec<Value>,
|
||||
pub execution_count: Option<u32>,
|
||||
#[specta(type = specta_typescript::Unknown)]
|
||||
pub attachments: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum NotebookEdit {
|
||||
SetSource {
|
||||
cell: String,
|
||||
source: String,
|
||||
},
|
||||
InsertCell {
|
||||
after: Option<String>,
|
||||
cell_type: String,
|
||||
},
|
||||
DeleteCell {
|
||||
cell: String,
|
||||
},
|
||||
MoveCell {
|
||||
cell: String,
|
||||
before: Option<String>,
|
||||
},
|
||||
SetCellType {
|
||||
cell: String,
|
||||
cell_type: String,
|
||||
},
|
||||
SetOutputs {
|
||||
cell: String,
|
||||
#[specta(type = Vec<specta_typescript::Unknown>)]
|
||||
outputs: Vec<Value>,
|
||||
execution_count: Option<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CellState {
|
||||
id: String,
|
||||
display_ids: Vec<Option<String>>,
|
||||
pending_clear: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Fields {
|
||||
values: Vec<(String, Option<Member>)>,
|
||||
order: Vec<String>,
|
||||
layout: Layout,
|
||||
}
|
||||
|
||||
impl Fields {
|
||||
fn capture(node: &Node, keys: &[&str]) -> Self {
|
||||
let members = node.members().expect("validated cell object");
|
||||
Self {
|
||||
values: keys.iter().map(|key| ((*key).into(), members.iter().find(|m| m.key == *key).cloned())).collect(),
|
||||
order: members.iter().map(|m| m.key.clone()).collect(),
|
||||
layout: node.layout(),
|
||||
}
|
||||
}
|
||||
|
||||
fn restore(&self, node: &mut Node) {
|
||||
node.restore_fields(&self.values, &self.order, &self.layout);
|
||||
}
|
||||
}
|
||||
|
||||
enum Change {
|
||||
Fields { cell: String, before: Fields, after: Fields },
|
||||
Insert { index: usize, node: Node, state: CellState, before: Layout, after: Layout },
|
||||
Delete { index: usize, node: Node, state: CellState, before: Layout, after: Layout },
|
||||
Move { from: usize, to: usize, before: Layout, after: Layout },
|
||||
}
|
||||
|
||||
struct History {
|
||||
change: Change,
|
||||
typing_cell: Option<String>,
|
||||
time: Instant,
|
||||
}
|
||||
|
||||
/// The caller serializes short document operations, not kernel awaits. Batch
|
||||
/// admission belongs to the host; set_execution_active protects backend history.
|
||||
pub struct NotebookDocument {
|
||||
id: String,
|
||||
path: PathBuf,
|
||||
root: Node,
|
||||
prefix: String,
|
||||
suffix: String,
|
||||
baseline: Node,
|
||||
baseline_text: Arc<str>,
|
||||
cells: Vec<CellState>,
|
||||
version: u32,
|
||||
last_user_revision: u32,
|
||||
undo: Vec<History>,
|
||||
redo: Vec<History>,
|
||||
execution_active: bool,
|
||||
}
|
||||
|
||||
impl NotebookDocument {
|
||||
pub fn load(path: &Path) -> IdeResult<Self> {
|
||||
let text = std::fs::read_to_string(path)?;
|
||||
Self::from_text(path.to_path_buf(), text)
|
||||
}
|
||||
|
||||
pub fn from_text(path: PathBuf, text: String) -> IdeResult<Self> {
|
||||
let text: Arc<str> = text.into();
|
||||
let (root, prefix, suffix) = Node::parse(text.clone())?;
|
||||
validate_notebook(&root)?;
|
||||
let nodes = root.get("cells").and_then(Node::array).expect("validated cells");
|
||||
let mut persisted = HashMap::<String, usize>::new();
|
||||
for node in nodes {
|
||||
if let Some(id) = string_field(node, "id").filter(|id| valid_id(id)) {
|
||||
*persisted.entry(id.to_owned()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut used: HashSet<String> = persisted.keys().cloned().collect();
|
||||
let cells = nodes
|
||||
.iter()
|
||||
.map(|node| {
|
||||
let id = string_field(node, "id")
|
||||
.filter(|id| valid_id(id) && persisted.get(*id) == Some(&1))
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| unique_id(&mut used));
|
||||
let outputs = node.get("outputs").and_then(Node::array).map_or(0, <[Node]>::len);
|
||||
CellState { id, display_ids: vec![None; outputs], pending_clear: false }
|
||||
})
|
||||
.collect();
|
||||
Ok(Self {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
path,
|
||||
baseline: root.clone(),
|
||||
root,
|
||||
prefix,
|
||||
suffix,
|
||||
baseline_text: text,
|
||||
cells,
|
||||
version: 1,
|
||||
last_user_revision: 1,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
execution_active: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> NotebookSnapshot {
|
||||
NotebookSnapshot {
|
||||
id: self.id.clone(),
|
||||
path: self.path.to_string_lossy().into_owned(),
|
||||
version: self.version,
|
||||
dirty: self.is_dirty(),
|
||||
can_undo: !self.execution_active && !self.undo.is_empty(),
|
||||
can_redo: !self.execution_active && !self.redo.is_empty(),
|
||||
nbformat: uint_field(&self.root, "nbformat").expect("validated format"),
|
||||
nbformat_minor: uint_field(&self.root, "nbformat_minor").expect("validated minor"),
|
||||
metadata: self.root.get("metadata").expect("validated metadata").value(),
|
||||
cells: self
|
||||
.nodes()
|
||||
.iter()
|
||||
.zip(&self.cells)
|
||||
.map(|(node, state)| NotebookCell {
|
||||
id: state.id.clone(),
|
||||
cell_type: string_field(node, "cell_type").expect("validated type").into(),
|
||||
source: source(node).expect("validated source"),
|
||||
metadata: node.get("metadata").map_or_else(|| json!({}), Node::value),
|
||||
outputs: node
|
||||
.get("outputs")
|
||||
.and_then(Node::array)
|
||||
.map_or_else(Vec::new, |items| items.iter().map(Node::value).collect()),
|
||||
execution_count: uint_field(node, "execution_count"),
|
||||
attachments: node.get("attachments").map_or_else(|| json!({}), Node::value),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte equality, not a sticky flag. Unchanged raw siblings short-circuit
|
||||
/// without serializing the notebook on each source edit or output chunk.
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
!self.root.same_bytes(&self.baseline)
|
||||
}
|
||||
|
||||
/// The host uses these cheap fields for high-volume output events.
|
||||
pub fn version(&self) -> u32 {
|
||||
self.version
|
||||
}
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Intended for save/export and behavioral tests, not per-chunk IPC.
|
||||
pub fn to_text(&self) -> String {
|
||||
let mut text = String::with_capacity(self.baseline_text.len());
|
||||
text.push_str(&self.prefix);
|
||||
self.root.write(&mut text);
|
||||
text.push_str(&self.suffix);
|
||||
text
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> IdeResult<NotebookSnapshot> {
|
||||
let disk = std::fs::read(&self.path).map_err(|error| {
|
||||
if error.kind() == std::io::ErrorKind::NotFound {
|
||||
out_of_sync("Notebook file was removed; reload or save a separate copy before overwriting")
|
||||
} else {
|
||||
error.into()
|
||||
}
|
||||
})?;
|
||||
if disk != self.baseline_text.as_bytes() {
|
||||
return Err(out_of_sync("Notebook changed on disk; reload or save a separate copy before overwriting"));
|
||||
}
|
||||
if self.is_dirty() {
|
||||
let text = self.to_text();
|
||||
ide_vfs::write_atomic_strict(&self.path, text.as_bytes())?;
|
||||
self.baseline_text = text.into();
|
||||
self.baseline = self.root.clone();
|
||||
}
|
||||
// A save is a typing-group boundary; undo after it must become dirty.
|
||||
self.break_typing_group();
|
||||
Ok(self.snapshot())
|
||||
}
|
||||
|
||||
pub fn reload(&mut self) -> IdeResult<NotebookSnapshot> {
|
||||
self.require_inactive()?;
|
||||
self.ensure_revision()?;
|
||||
let mut replacement = Self::load(&self.path)?;
|
||||
replacement.id.clone_from(&self.id);
|
||||
replacement.version = self.version + 1;
|
||||
replacement.last_user_revision = replacement.version;
|
||||
*self = replacement;
|
||||
Ok(self.snapshot())
|
||||
}
|
||||
|
||||
pub fn set_execution_active(&mut self, active: bool) -> IdeResult<()> {
|
||||
if active && self.execution_active {
|
||||
return Err(IdeError::invalid("Notebook already has an active execution batch"));
|
||||
}
|
||||
self.execution_active = active;
|
||||
self.break_typing_group();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn nodes(&self) -> &[Node] {
|
||||
self.root.get("cells").and_then(Node::array).expect("validated cells")
|
||||
}
|
||||
fn array_mut(&mut self) -> &mut Node {
|
||||
self.root.get_mut("cells").expect("validated cells")
|
||||
}
|
||||
fn cell_mut(&mut self, index: usize) -> &mut Node {
|
||||
self.array_mut().item_mut(index)
|
||||
}
|
||||
fn index(&self, id: &str) -> IdeResult<usize> {
|
||||
self.cells
|
||||
.iter()
|
||||
.position(|cell| cell.id == id)
|
||||
.ok_or_else(|| IdeError::not_found(format!("Notebook cell {id} no longer exists")))
|
||||
}
|
||||
fn ensure_revision(&self) -> IdeResult<()> {
|
||||
if self.version == u32::MAX {
|
||||
Err(IdeError::unavailable("Notebook revision limit reached; save and reopen the document"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn require_inactive(&self) -> IdeResult<()> {
|
||||
if self.execution_active {
|
||||
Err(IdeError::invalid("Stop and drain notebook execution before undo, redo, or reload"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn break_typing_group(&mut self) {
|
||||
if let Some(last) = self.undo.last_mut() {
|
||||
last.typing_cell = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn out_of_sync(message: &str) -> IdeError {
|
||||
IdeError::new(ErrorKind::OutOfSync, message)
|
||||
}
|
||||
fn string_field<'a>(node: &'a Node, key: &str) -> Option<&'a str> {
|
||||
node.get(key)?.string()
|
||||
}
|
||||
fn uint_field(node: &Node, key: &str) -> Option<u32> {
|
||||
node.get(key)?.uint()
|
||||
}
|
||||
fn valid_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
}
|
||||
fn unique_id(used: &mut HashSet<String>) -> String {
|
||||
loop {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
if used.insert(id.clone()) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text_value(value: &Value) -> IdeResult<String> {
|
||||
match value {
|
||||
Value::String(text) => Ok(text.clone()),
|
||||
Value::Array(parts) => {
|
||||
let mut text = String::new();
|
||||
for part in parts {
|
||||
text.push_str(
|
||||
part.as_str().ok_or_else(|| IdeError::invalid("Notebook text arrays must contain only strings"))?,
|
||||
);
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
_ => Err(IdeError::invalid("Notebook source/text must be a string or string array")),
|
||||
}
|
||||
}
|
||||
|
||||
fn source(node: &Node) -> IdeResult<String> {
|
||||
match node.get("source") {
|
||||
Some(value) => node_text(value),
|
||||
None if !matches!(string_field(node, "cell_type"), Some("code" | "markdown" | "raw")) => Ok(String::new()),
|
||||
None => Err(IdeError::invalid("Notebook cell source is missing")),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_notebook(root: &Node) -> IdeResult<()> {
|
||||
if root.members().is_none()
|
||||
|| uint_field(root, "nbformat") != Some(4)
|
||||
|| uint_field(root, "nbformat_minor").is_none()
|
||||
{
|
||||
return Err(IdeError::invalid(
|
||||
"Only nbformat 4 notebooks with a nonnegative integer minor version are supported; the file has not been modified",
|
||||
));
|
||||
}
|
||||
if root.get("metadata").is_none_or(|n| n.members().is_none()) {
|
||||
return Err(IdeError::invalid("Notebook metadata must be an object"));
|
||||
}
|
||||
let cells =
|
||||
root.get("cells").and_then(Node::array).ok_or_else(|| IdeError::invalid("Notebook cells must be an array"))?;
|
||||
for node in cells {
|
||||
if node.members().is_none() || string_field(node, "cell_type").is_none() {
|
||||
return Err(IdeError::invalid("Notebook cells must be objects with a string cell_type"));
|
||||
}
|
||||
let known = matches!(string_field(node, "cell_type"), Some("code" | "markdown" | "raw"));
|
||||
match node.get("source") {
|
||||
Some(value) => validate_text_node(value)?,
|
||||
None if known => return Err(IdeError::invalid("Notebook cell source is missing")),
|
||||
None => {}
|
||||
}
|
||||
if known && node.get("metadata").is_none() {
|
||||
return Err(IdeError::invalid("Notebook cell metadata is missing"));
|
||||
}
|
||||
if let Some(metadata) = node.get("metadata")
|
||||
&& metadata.members().is_none()
|
||||
{
|
||||
return Err(IdeError::invalid("Cell metadata must be an object"));
|
||||
}
|
||||
if let Some(attachments) = node.get("attachments")
|
||||
&& attachments.members().is_none()
|
||||
{
|
||||
return Err(IdeError::invalid("Cell attachments must be an object"));
|
||||
}
|
||||
if let Some(outputs) = node.get("outputs") {
|
||||
let outputs = outputs.array().ok_or_else(|| IdeError::invalid("Cell outputs must be an array"))?;
|
||||
for output in outputs {
|
||||
validate_output_node(output)?;
|
||||
}
|
||||
}
|
||||
if let Some(count) = node.get("execution_count")
|
||||
&& !count.is_null()
|
||||
&& uint_field(node, "execution_count").is_none()
|
||||
{
|
||||
return Err(IdeError::invalid("Cell execution_count must be null or a nonnegative 32-bit integer"));
|
||||
}
|
||||
if string_field(node, "cell_type") == Some("code")
|
||||
&& (node.get("outputs").is_none() || node.get("execution_count").is_none())
|
||||
{
|
||||
return Err(IdeError::invalid("Code cells require outputs and execution_count"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl NotebookDocument {
|
||||
pub fn edit(&mut self, base_version: u32, edit: NotebookEdit) -> IdeResult<NotebookSnapshot> {
|
||||
if base_version < self.last_user_revision || base_version > self.version {
|
||||
return Err(out_of_sync("Notebook was edited elsewhere; refresh and rebase pending edits"));
|
||||
}
|
||||
self.ensure_revision()?;
|
||||
if let NotebookEdit::SetOutputs { cell, outputs, execution_count } = edit {
|
||||
let index = self.code_index(&cell)?;
|
||||
let outputs = outputs.into_iter().map(persisted_output).collect::<IdeResult<Vec<_>>>()?;
|
||||
let node = Node::new(Value::Array(outputs));
|
||||
for output in node.array().expect("outputs array") {
|
||||
validate_output_node(output)?;
|
||||
}
|
||||
let count = Node::new(json!(execution_count));
|
||||
let changed = !self.nodes()[index].get("outputs").expect("code outputs").same_bytes(&node)
|
||||
|| !self.nodes()[index].get("execution_count").expect("code count").same_bytes(&count);
|
||||
if changed {
|
||||
let len = node.array().expect("outputs array").len();
|
||||
self.cell_mut(index).set("outputs", Some(node));
|
||||
self.cell_mut(index).set("execution_count", Some(count));
|
||||
self.cells[index].display_ids = vec![None; len];
|
||||
self.cells[index].pending_clear = false;
|
||||
self.version += 1;
|
||||
}
|
||||
return Ok(self.snapshot());
|
||||
}
|
||||
let typing_cell = match &edit {
|
||||
NotebookEdit::SetSource { cell, .. } => Some(cell.clone()),
|
||||
_ => None,
|
||||
};
|
||||
let change = match edit {
|
||||
NotebookEdit::SetSource { cell, source: text } => {
|
||||
let index = self.index(&cell)?;
|
||||
if self.nodes()[index].get("source").is_some_and(|old| text_matches(old, &text)) {
|
||||
return Ok(self.snapshot());
|
||||
}
|
||||
let before = Fields::capture(&self.nodes()[index], &["source"]);
|
||||
let value = rewritten_source(self.nodes()[index].get("source"), text);
|
||||
self.cell_mut(index).set("source", Some(value));
|
||||
let after = Fields::capture(&self.nodes()[index], &["source"]);
|
||||
Change::Fields { cell, before, after }
|
||||
}
|
||||
NotebookEdit::InsertCell { after, cell_type } => {
|
||||
validate_cell_type(&cell_type)?;
|
||||
let index = after.map_or(Ok(0), |id| self.index(&id).map(|index| index + 1))?;
|
||||
let id = unique_id(&mut self.cells.iter().map(|state| state.id.clone()).collect());
|
||||
let mut value = json!({ "cell_type": cell_type, "metadata": {}, "source": [] });
|
||||
if uint_field(&self.root, "nbformat_minor").expect("minor") >= 5 {
|
||||
value["id"] = json!(id);
|
||||
}
|
||||
if value["cell_type"] == "code" {
|
||||
value["outputs"] = json!([]);
|
||||
value["execution_count"] = Value::Null;
|
||||
}
|
||||
let node = Node::new(value);
|
||||
let state = CellState { id, display_ids: Vec::new(), pending_clear: false };
|
||||
let before = self.root.get("cells").expect("cells").layout();
|
||||
self.array_mut().insert(index, node.clone());
|
||||
self.cells.insert(index, state.clone());
|
||||
let after = self.root.get("cells").expect("cells").layout();
|
||||
Change::Insert { index, node, state, before, after }
|
||||
}
|
||||
NotebookEdit::DeleteCell { cell } => {
|
||||
let index = self.index(&cell)?;
|
||||
let before = self.root.get("cells").expect("cells").layout();
|
||||
let node = self.array_mut().remove(index);
|
||||
let state = self.cells.remove(index);
|
||||
let after = self.root.get("cells").expect("cells").layout();
|
||||
Change::Delete { index, node, state, before, after }
|
||||
}
|
||||
NotebookEdit::MoveCell { cell, before: target } => {
|
||||
let from = self.index(&cell)?;
|
||||
let target = target.map_or(Ok(self.cells.len()), |id| self.index(&id))?;
|
||||
let to = if target > from { target - 1 } else { target };
|
||||
if from == to {
|
||||
return Ok(self.snapshot());
|
||||
}
|
||||
let before = self.root.get("cells").expect("cells").layout();
|
||||
self.move_cell(from, to);
|
||||
let after = self.root.get("cells").expect("cells").layout();
|
||||
Change::Move { from, to, before, after }
|
||||
}
|
||||
NotebookEdit::SetCellType { cell, cell_type } => {
|
||||
validate_cell_type(&cell_type)?;
|
||||
let index = self.index(&cell)?;
|
||||
if string_field(&self.nodes()[index], "cell_type") == Some(cell_type.as_str()) {
|
||||
return Ok(self.snapshot());
|
||||
}
|
||||
let keys = ["cell_type", "outputs", "execution_count"];
|
||||
let before = Fields::capture(&self.nodes()[index], &keys);
|
||||
let node = self.cell_mut(index);
|
||||
node.set("cell_type", Some(Node::new(json!(cell_type))));
|
||||
if cell_type == "code" {
|
||||
node.set("outputs", Some(Node::new(json!([]))));
|
||||
node.set("execution_count", Some(Node::new(Value::Null)));
|
||||
} else {
|
||||
node.set("outputs", None);
|
||||
node.set("execution_count", None);
|
||||
}
|
||||
self.cells[index].display_ids.clear();
|
||||
self.cells[index].pending_clear = false;
|
||||
let after = Fields::capture(&self.nodes()[index], &keys);
|
||||
Change::Fields { cell, before, after }
|
||||
}
|
||||
NotebookEdit::SetOutputs { .. } => unreachable!("output edits handled above"),
|
||||
};
|
||||
self.version += 1;
|
||||
self.last_user_revision = self.version;
|
||||
self.redo.clear();
|
||||
let now = Instant::now();
|
||||
let grouped = typing_cell.as_ref().is_some_and(|cell| {
|
||||
self.undo.last().is_some_and(|last| {
|
||||
last.typing_cell.as_ref() == Some(cell) && now.duration_since(last.time) < Duration::from_millis(750)
|
||||
})
|
||||
});
|
||||
if grouped {
|
||||
if let (
|
||||
Some(History { change: Change::Fields { after: previous, .. }, time, .. }),
|
||||
Change::Fields { after, .. },
|
||||
) = (self.undo.last_mut(), change)
|
||||
{
|
||||
*previous = after;
|
||||
*time = now;
|
||||
}
|
||||
} else {
|
||||
self.undo.push(History { change, typing_cell, time: now });
|
||||
// Bounded operation count; streaming outputs never enter history.
|
||||
if self.undo.len() > 200 {
|
||||
self.undo.remove(0);
|
||||
}
|
||||
}
|
||||
Ok(self.snapshot())
|
||||
}
|
||||
|
||||
pub fn undo(&mut self) -> IdeResult<NotebookSnapshot> {
|
||||
self.require_inactive()?;
|
||||
self.ensure_revision()?;
|
||||
if let Some(mut history) = self.undo.pop() {
|
||||
if let Err(error) = self.apply_history(&mut history.change, false) {
|
||||
self.undo.push(history);
|
||||
return Err(error);
|
||||
}
|
||||
history.typing_cell = None;
|
||||
self.redo.push(history);
|
||||
self.version += 1;
|
||||
self.last_user_revision = self.version;
|
||||
}
|
||||
self.break_typing_group();
|
||||
Ok(self.snapshot())
|
||||
}
|
||||
|
||||
pub fn redo(&mut self) -> IdeResult<NotebookSnapshot> {
|
||||
self.require_inactive()?;
|
||||
self.ensure_revision()?;
|
||||
if let Some(mut history) = self.redo.pop() {
|
||||
if let Err(error) = self.apply_history(&mut history.change, true) {
|
||||
self.redo.push(history);
|
||||
return Err(error);
|
||||
}
|
||||
history.typing_cell = None;
|
||||
self.undo.push(history);
|
||||
self.version += 1;
|
||||
self.last_user_revision = self.version;
|
||||
}
|
||||
Ok(self.snapshot())
|
||||
}
|
||||
|
||||
fn apply_history(&mut self, change: &mut Change, forward: bool) -> IdeResult<()> {
|
||||
let insertion_change = matches!(change, Change::Insert { .. });
|
||||
match change {
|
||||
Change::Fields { cell, before, after } => {
|
||||
let index = self.index(cell)?;
|
||||
let keys: Vec<&str> = before.values.iter().map(|(key, _)| key.as_str()).collect();
|
||||
let current = Fields::capture(&self.nodes()[index], &keys);
|
||||
if forward {
|
||||
*before = current;
|
||||
} else {
|
||||
*after = current;
|
||||
}
|
||||
let fields = if forward { after } else { before };
|
||||
fields.restore(self.cell_mut(index));
|
||||
if fields.values.iter().any(|(key, _)| key == "outputs") {
|
||||
let len = self.nodes()[index].get("outputs").and_then(Node::array).map_or(0, <[Node]>::len);
|
||||
self.cells[index].display_ids = vec![None; len];
|
||||
self.cells[index].pending_clear = false;
|
||||
}
|
||||
}
|
||||
Change::Insert { index, node, state, before, after }
|
||||
| Change::Delete { index, node, state, before, after } => {
|
||||
let insert = insertion_change == forward;
|
||||
if insert {
|
||||
self.array_mut().insert(*index, node.clone());
|
||||
self.cells.insert(*index, state.clone());
|
||||
} else {
|
||||
*node = self.array_mut().remove(*index);
|
||||
*state = self.cells.remove(*index);
|
||||
}
|
||||
self.array_mut().restore_layout(if forward { after } else { before }.clone());
|
||||
}
|
||||
Change::Move { from, to, before, after } => {
|
||||
if forward {
|
||||
self.move_cell(*from, *to);
|
||||
} else {
|
||||
self.move_cell(*to, *from);
|
||||
}
|
||||
self.array_mut().restore_layout(if forward { after } else { before }.clone());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn move_cell(&mut self, from: usize, to: usize) {
|
||||
let node = self.array_mut().remove(from);
|
||||
self.array_mut().insert(to, node);
|
||||
let state = self.cells.remove(from);
|
||||
self.cells.insert(to, state);
|
||||
}
|
||||
|
||||
fn code_index(&self, cell: &str) -> IdeResult<usize> {
|
||||
let index = self.index(cell)?;
|
||||
if string_field(&self.nodes()[index], "cell_type") != Some("code") {
|
||||
return Err(IdeError::invalid("Only code cells accept execution output"));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_cell_type(cell_type: &str) -> IdeResult<()> {
|
||||
if matches!(cell_type, "code" | "markdown" | "raw") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::invalid("New/converted cell type must be code, markdown, or raw"))
|
||||
}
|
||||
}
|
||||
|
||||
fn persisted_output(mut value: Value) -> IdeResult<Value> {
|
||||
let object = value.as_object_mut().ok_or_else(|| IdeError::invalid("Notebook outputs must be objects"))?;
|
||||
if !object.get("output_type").is_some_and(Value::is_string) {
|
||||
return Err(IdeError::invalid("Notebook outputs require a string output_type"));
|
||||
}
|
||||
object.remove("transient");
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
impl NotebookDocument {
|
||||
pub fn begin_execution(&mut self, cell: &str) -> IdeResult<()> {
|
||||
let index = self.code_index(cell)?;
|
||||
self.ensure_revision()?;
|
||||
let changed = self.clear_outputs(index);
|
||||
let count_changed = !self.nodes()[index].get("execution_count").expect("code count").is_null();
|
||||
if count_changed {
|
||||
self.cell_mut(index).set("execution_count", Some(Node::new(Value::Null)));
|
||||
}
|
||||
if changed || count_changed {
|
||||
self.version += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commits one IOPub event and returns all cells whose persisted bytes
|
||||
/// changed. Display identifiers and deferred clears exist only in memory.
|
||||
pub fn record_output(&mut self, cell: &str, event: &KernelOutput) -> IdeResult<Vec<String>> {
|
||||
let index = self.code_index(cell)?;
|
||||
self.ensure_revision()?;
|
||||
let content = &event.content;
|
||||
let mut changed = Vec::new();
|
||||
match event.kind.as_str() {
|
||||
"execute_input" => {
|
||||
let count = content
|
||||
.get("execution_count")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
.ok_or_else(|| IdeError::invalid("Kernel execute_input lacks a valid execution_count"))?;
|
||||
if uint_field(&self.nodes()[index], "execution_count") != Some(count) {
|
||||
self.cell_mut(index).set("execution_count", Some(Node::new(json!(count))));
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
}
|
||||
"clear_output" => {
|
||||
if content.get("wait").and_then(Value::as_bool).unwrap_or(false) {
|
||||
self.cells[index].pending_clear = true;
|
||||
} else if self.clear_outputs(index) {
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
}
|
||||
"stream" => {
|
||||
let name = content
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| IdeError::invalid("Kernel stream lacks a name"))?;
|
||||
let text =
|
||||
text_value(content.get("text").ok_or_else(|| IdeError::invalid("Kernel stream lacks text"))?)?;
|
||||
if self.clear_pending(index) {
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
let outputs = self.nodes()[index].get("outputs").and_then(Node::array).expect("code outputs");
|
||||
let last = outputs.len().checked_sub(1).filter(|last| {
|
||||
string_field(&outputs[*last], "output_type") == Some("stream")
|
||||
&& string_field(&outputs[*last], "name") == Some(name)
|
||||
});
|
||||
if let Some(last) = last {
|
||||
if !text.is_empty() {
|
||||
let old = outputs[last]
|
||||
.get("text")
|
||||
.ok_or_else(|| IdeError::invalid("Stored stream output lacks text"))?;
|
||||
let combined = if old.string().is_some() { None } else { Some(node_text(old)?) };
|
||||
let stream = self.cell_mut(index).get_mut("outputs").expect("outputs").item_mut(last);
|
||||
if let Some(mut combined) = combined {
|
||||
combined.push_str(&text);
|
||||
stream.set("text", Some(Node::new(Value::String(combined))));
|
||||
} else {
|
||||
stream.get_mut("text").expect("stream text").append_string(&text);
|
||||
}
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
} else {
|
||||
let mut value = content
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::invalid("Kernel stream content must be an object"))?;
|
||||
value.insert("output_type".into(), json!("stream"));
|
||||
value.insert("text".into(), Value::String(text));
|
||||
value.remove("transient");
|
||||
self.append_output(index, Node::new(Value::Object(value)), None);
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
}
|
||||
"display_data" | "execute_result" | "error" => {
|
||||
// Validate before applying a pending clear: malformed messages
|
||||
// must not erase existing output as a side effect of failure.
|
||||
let mut value = content
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::invalid("Kernel output content must be an object"))?;
|
||||
if event.kind == "error" {
|
||||
if !value.get("ename").is_some_and(Value::is_string)
|
||||
|| !value.get("evalue").is_some_and(Value::is_string)
|
||||
|| !value
|
||||
.get("traceback")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|lines| lines.iter().all(Value::is_string))
|
||||
{
|
||||
return Err(IdeError::invalid("Kernel error output is malformed"));
|
||||
}
|
||||
} else if !value.get("data").is_some_and(Value::is_object)
|
||||
|| !value.get("metadata").is_some_and(Value::is_object)
|
||||
{
|
||||
return Err(IdeError::invalid("Kernel display output lacks a MIME bundle or metadata"));
|
||||
}
|
||||
if event.kind == "execute_result"
|
||||
&& value.get("execution_count").and_then(Value::as_u64).is_none_or(|n| u32::try_from(n).is_err())
|
||||
{
|
||||
return Err(IdeError::invalid("Kernel execute_result lacks a valid execution_count"));
|
||||
}
|
||||
let display_id = display_id(content);
|
||||
value.remove("transient");
|
||||
value.insert("output_type".into(), json!(event.kind));
|
||||
if self.clear_pending(index) {
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
self.append_output(index, Node::new(Value::Object(value)), display_id);
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
"update_display_data" => {
|
||||
let Some(id) = display_id(content) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let data = content
|
||||
.get("data")
|
||||
.filter(|v| v.is_object())
|
||||
.ok_or_else(|| IdeError::invalid("Display update lacks a MIME bundle"))?;
|
||||
let metadata = content
|
||||
.get("metadata")
|
||||
.filter(|v| v.is_object())
|
||||
.ok_or_else(|| IdeError::invalid("Display update lacks metadata"))?;
|
||||
if self.clear_pending(index) {
|
||||
changed.push(cell.to_owned());
|
||||
}
|
||||
for current in 0..self.cells.len() {
|
||||
let matches: Vec<usize> = self.cells[current]
|
||||
.display_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(output, display)| (display.as_deref() == Some(id.as_str())).then_some(output))
|
||||
.collect();
|
||||
let mut cell_changed = false;
|
||||
for output in matches {
|
||||
let Some(old) = self.nodes()[current]
|
||||
.get("outputs")
|
||||
.and_then(Node::array)
|
||||
.and_then(|outputs| outputs.get(output))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let data_node = Node::new(data.clone());
|
||||
let metadata_node = Node::new(metadata.clone());
|
||||
let replace_data = old.get("data").is_none_or(|old| !old.same_bytes(&data_node));
|
||||
let replace_metadata = old.get("metadata").is_none_or(|old| !old.same_bytes(&metadata_node));
|
||||
if replace_data || replace_metadata {
|
||||
let output =
|
||||
self.cell_mut(current).get_mut("outputs").expect("mapped outputs").item_mut(output);
|
||||
if replace_data {
|
||||
output.set("data", Some(data_node));
|
||||
}
|
||||
if replace_metadata {
|
||||
output.set("metadata", Some(metadata_node));
|
||||
}
|
||||
cell_changed = true;
|
||||
}
|
||||
}
|
||||
if cell_changed {
|
||||
changed.push(self.cells[current].id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
changed.retain(|id| seen.insert(id.clone()));
|
||||
if !changed.is_empty() {
|
||||
self.version += 1;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
fn append_output(&mut self, index: usize, node: Node, display_id: Option<String>) {
|
||||
let len = self.nodes()[index].get("outputs").and_then(Node::array).expect("code outputs").len();
|
||||
self.cell_mut(index).get_mut("outputs").expect("outputs").insert(len, node);
|
||||
self.cells[index].display_ids.push(display_id);
|
||||
}
|
||||
|
||||
fn clear_pending(&mut self, index: usize) -> bool {
|
||||
if self.cells[index].pending_clear { self.clear_outputs(index) } else { false }
|
||||
}
|
||||
|
||||
fn clear_outputs(&mut self, index: usize) -> bool {
|
||||
self.cells[index].pending_clear = false;
|
||||
self.cells[index].display_ids.clear();
|
||||
let changed = !self.nodes()[index].get("outputs").and_then(Node::array).expect("code outputs").is_empty();
|
||||
if changed {
|
||||
self.cell_mut(index).set("outputs", Some(Node::new(json!([]))));
|
||||
}
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
fn display_id(content: &Value) -> Option<String> {
|
||||
content.get("transient")?.get("display_id")?.as_str().map(str::to_owned)
|
||||
}
|
||||
|
||||
fn node_text(node: &Node) -> IdeResult<String> {
|
||||
if let Some(text) = node.string() {
|
||||
return Ok(text.into());
|
||||
}
|
||||
if let Some(parts) = node.array() {
|
||||
let mut text = String::new();
|
||||
for part in parts {
|
||||
text.push_str(
|
||||
part.string().ok_or_else(|| IdeError::invalid("Notebook text arrays must contain only strings"))?,
|
||||
);
|
||||
}
|
||||
return Ok(text);
|
||||
}
|
||||
Err(IdeError::invalid("Notebook source/text must be a string or string array"))
|
||||
}
|
||||
|
||||
fn rewritten_source(old: Option<&Node>, text: String) -> Node {
|
||||
let Some(old) = old.filter(|node| node.array().is_some()) else {
|
||||
return Node::new(Value::String(text));
|
||||
};
|
||||
let parts = old.array().expect("source list");
|
||||
let mut first = 0;
|
||||
let mut start = 0;
|
||||
while first < parts.len() {
|
||||
let part = parts[first].string().expect("validated source text");
|
||||
if !text[start..].starts_with(part) {
|
||||
break;
|
||||
}
|
||||
start += part.len();
|
||||
first += 1;
|
||||
}
|
||||
let mut last = parts.len();
|
||||
let mut end = text.len();
|
||||
while last > first {
|
||||
let part = parts[last - 1].string().expect("validated source text");
|
||||
if end - start < part.len() || !text[start..end].ends_with(part) {
|
||||
break;
|
||||
}
|
||||
end -= part.len();
|
||||
last -= 1;
|
||||
}
|
||||
let mut updated = old.clone();
|
||||
for index in (first..last).rev() {
|
||||
updated.remove(index);
|
||||
}
|
||||
for (offset, part) in text[start..end].split_inclusive('\n').enumerate() {
|
||||
updated.insert(first + offset, Node::new(Value::String(part.into())));
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
fn validate_text_node(node: &Node) -> IdeResult<()> {
|
||||
if node.string().is_some() || node.array().is_some_and(|items| items.iter().all(|part| part.string().is_some())) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::invalid("Notebook source/text must be a string or string array"))
|
||||
}
|
||||
}
|
||||
|
||||
fn text_matches(node: &Node, text: &str) -> bool {
|
||||
if let Some(old) = node.string() {
|
||||
return old == text;
|
||||
}
|
||||
let Some(parts) = node.array() else {
|
||||
return false;
|
||||
};
|
||||
let mut remaining = text;
|
||||
for part in parts {
|
||||
let Some(part) = part.string() else {
|
||||
return false;
|
||||
};
|
||||
let Some(rest) = remaining.strip_prefix(part) else {
|
||||
return false;
|
||||
};
|
||||
remaining = rest;
|
||||
}
|
||||
remaining.is_empty()
|
||||
}
|
||||
|
||||
fn validate_output_node(node: &Node) -> IdeResult<()> {
|
||||
let kind = string_field(node, "output_type")
|
||||
.ok_or_else(|| IdeError::invalid("Notebook outputs must be objects with a string output_type"))?;
|
||||
match kind {
|
||||
"stream" => {
|
||||
if string_field(node, "name").is_none() {
|
||||
return Err(IdeError::invalid("Stored stream output lacks a name"));
|
||||
}
|
||||
validate_text_node(node.get("text").ok_or_else(|| IdeError::invalid("Stored stream output lacks text"))?)?;
|
||||
}
|
||||
"display_data" | "execute_result" => {
|
||||
if node.get("data").is_none_or(|n| n.members().is_none())
|
||||
|| node.get("metadata").is_none_or(|n| n.members().is_none())
|
||||
{
|
||||
return Err(IdeError::invalid("Stored display output requires data and metadata objects"));
|
||||
}
|
||||
if kind == "execute_result" && uint_field(node, "execution_count").is_none() {
|
||||
return Err(IdeError::invalid("Stored execute_result lacks a valid execution_count"));
|
||||
}
|
||||
}
|
||||
"error"
|
||||
if (string_field(node, "ename").is_none()
|
||||
|| string_field(node, "evalue").is_none()
|
||||
|| !node
|
||||
.get("traceback")
|
||||
.and_then(Node::array)
|
||||
.is_some_and(|lines| lines.iter().all(|line| line.string().is_some()))) =>
|
||||
{
|
||||
return Err(IdeError::invalid("Stored error output is malformed"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
//! JSON syntax retained as shared original spans. Mutating one value never
|
||||
//! normalizes its siblings, property spelling, whitespace, or container order.
|
||||
use std::{ops::Range, sync::Arc};
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Raw {
|
||||
text: Arc<str>,
|
||||
span: Range<usize>,
|
||||
}
|
||||
|
||||
impl Raw {
|
||||
fn text(&self) -> &str {
|
||||
&self.text[self.span.clone()]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Node {
|
||||
raw: Option<Raw>,
|
||||
kind: Kind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Kind {
|
||||
Scalar(Arc<Value>),
|
||||
Object(Vec<Member>, Layout),
|
||||
Array(Vec<Node>, Layout),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Member {
|
||||
pub key: String,
|
||||
head: String,
|
||||
pub value: Node,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Layout {
|
||||
prefix: String,
|
||||
separators: Vec<String>,
|
||||
suffix: String,
|
||||
}
|
||||
|
||||
impl Layout {
|
||||
fn compact() -> Self {
|
||||
Self { prefix: String::new(), separators: Vec::new(), suffix: String::new() }
|
||||
}
|
||||
|
||||
fn separator(&self, index: usize) -> &str {
|
||||
self.separators.get(index).map(String::as_str).unwrap_or(",")
|
||||
}
|
||||
|
||||
fn remove(&mut self, index: usize) {
|
||||
if !self.separators.is_empty() {
|
||||
self.separators.remove(index.saturating_sub(1).min(self.separators.len() - 1));
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(&mut self, index: usize, previous_len: usize) {
|
||||
if previous_len > 0 {
|
||||
let separator = self.separators.first().cloned().unwrap_or_else(|| ",".into());
|
||||
self.separators.insert(index.saturating_sub(1).min(self.separators.len()), separator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub fn parse(text: Arc<str>) -> IdeResult<(Self, String, String)> {
|
||||
// serde supplies strict JSON/UTF-8 string and numeric validation. The
|
||||
// scanner below only locates the already validated syntax spans.
|
||||
let start = usize::from(text.starts_with('\u{feff}')) * 3;
|
||||
serde_json::from_str::<serde::de::IgnoredAny>(&text[start..])
|
||||
.map_err(|e| IdeError::invalid(format!("Invalid notebook JSON: {e}")))?;
|
||||
let mut parser = Parser { text: text.clone(), pos: start };
|
||||
parser.space();
|
||||
let begin = parser.pos;
|
||||
let root = parser.node()?;
|
||||
Ok((root, text[..begin].into(), text[parser.pos..].into()))
|
||||
}
|
||||
|
||||
pub fn new(value: Value) -> Self {
|
||||
match value {
|
||||
Value::Object(map) => Self {
|
||||
raw: None,
|
||||
kind: Kind::Object(
|
||||
map.into_iter()
|
||||
.map(|(key, value)| Member {
|
||||
head: format!("{}:", serde_json::to_string(&key).expect("JSON key")),
|
||||
key,
|
||||
value: Self::new(value),
|
||||
})
|
||||
.collect(),
|
||||
Layout::compact(),
|
||||
),
|
||||
},
|
||||
Value::Array(values) => {
|
||||
Self { raw: None, kind: Kind::Array(values.into_iter().map(Self::new).collect(), Layout::compact()) }
|
||||
}
|
||||
value => Self { raw: None, kind: Kind::Scalar(Arc::new(value)) },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn value(&self) -> Value {
|
||||
match &self.kind {
|
||||
Kind::Scalar(value) => value.as_ref().clone(),
|
||||
Kind::Array(items, _) => Value::Array(items.iter().map(Self::value).collect()),
|
||||
Kind::Object(items, _) => Value::Object(items.iter().map(|m| (m.key.clone(), m.value.value())).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&Self> {
|
||||
match &self.kind {
|
||||
Kind::Object(items, _) => items.iter().find(|m| m.key == key).map(|m| &m.value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn string(&self) -> Option<&str> {
|
||||
match &self.kind {
|
||||
Kind::Scalar(value) => value.as_str(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uint(&self) -> Option<u32> {
|
||||
match &self.kind {
|
||||
Kind::Scalar(value) => value.as_u64().and_then(|n| u32::try_from(n).ok()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_null(&self) -> bool {
|
||||
matches!(&self.kind, Kind::Scalar(value) if value.is_null())
|
||||
}
|
||||
|
||||
pub fn append_string(&mut self, text: &str) {
|
||||
self.raw = None;
|
||||
let Kind::Scalar(value) = &mut self.kind else { unreachable!("validated string") };
|
||||
let Value::String(current) = Arc::make_mut(value) else { unreachable!("validated string") };
|
||||
current.push_str(text);
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, key: &str) -> Option<&mut Self> {
|
||||
self.raw = None;
|
||||
match &mut self.kind {
|
||||
Kind::Object(items, _) => items.iter_mut().find(|m| m.key == key).map(|m| &mut m.value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn members(&self) -> Option<&[Member]> {
|
||||
match &self.kind {
|
||||
Kind::Object(items, _) => Some(items),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn array(&self) -> Option<&[Self]> {
|
||||
match &self.kind {
|
||||
Kind::Array(items, _) => Some(items),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn item_mut(&mut self, index: usize) -> &mut Self {
|
||||
self.raw = None;
|
||||
match &mut self.kind {
|
||||
Kind::Array(items, _) => &mut items[index],
|
||||
_ => unreachable!("validated array"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn layout(&self) -> Layout {
|
||||
match &self.kind {
|
||||
Kind::Array(_, layout) | Kind::Object(_, layout) => layout.clone(),
|
||||
_ => unreachable!("container layout"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restore_layout(&mut self, layout: Layout) {
|
||||
self.raw = None;
|
||||
match &mut self.kind {
|
||||
Kind::Array(_, old) | Kind::Object(_, old) => *old = layout,
|
||||
_ => unreachable!("container layout"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set(&mut self, key: &str, value: Option<Self>) {
|
||||
self.raw = None;
|
||||
let Kind::Object(items, layout) = &mut self.kind else { unreachable!("validated object") };
|
||||
match (items.iter().position(|m| m.key == key), value) {
|
||||
(Some(index), Some(value)) => items[index].value = value,
|
||||
(Some(index), None) => {
|
||||
items.remove(index);
|
||||
layout.remove(index);
|
||||
}
|
||||
(None, Some(value)) => {
|
||||
layout.insert(items.len(), items.len());
|
||||
items.push(Member {
|
||||
key: key.into(),
|
||||
head: format!("{}:", serde_json::to_string(key).expect("JSON key")),
|
||||
value,
|
||||
});
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn restore_fields(&mut self, fields: &[(String, Option<Member>)], order: &[String], layout: &Layout) {
|
||||
for (key, field) in fields {
|
||||
self.set(key, field.as_ref().map(|m| m.value.clone()));
|
||||
}
|
||||
let Kind::Object(items, old_layout) = &mut self.kind else { unreachable!("validated object") };
|
||||
for (key, field) in fields {
|
||||
if let (Some(saved), Some(current)) = (field, items.iter_mut().find(|m| &m.key == key)) {
|
||||
current.head.clone_from(&saved.head);
|
||||
}
|
||||
}
|
||||
items.sort_by_key(|m| order.iter().position(|key| key == &m.key).unwrap_or(usize::MAX));
|
||||
if items.len() == order.len() {
|
||||
*old_layout = layout.clone();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, index: usize, node: Self) {
|
||||
self.raw = None;
|
||||
let Kind::Array(items, layout) = &mut self.kind else { unreachable!("validated array") };
|
||||
layout.insert(index, items.len());
|
||||
items.insert(index, node);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, index: usize) -> Self {
|
||||
self.raw = None;
|
||||
let Kind::Array(items, layout) = &mut self.kind else { unreachable!("validated array") };
|
||||
layout.remove(index);
|
||||
items.remove(index)
|
||||
}
|
||||
|
||||
pub fn same_bytes(&self, other: &Self) -> bool {
|
||||
if let (Some(a), Some(b)) = (&self.raw, &other.raw) {
|
||||
return (Arc::ptr_eq(&a.text, &b.text) && a.span == b.span) || a.text() == b.text();
|
||||
}
|
||||
match (&self.kind, &other.kind) {
|
||||
(Kind::Scalar(a), Kind::Scalar(b)) => {
|
||||
if a != b {
|
||||
return false;
|
||||
}
|
||||
match (&self.raw, &other.raw) {
|
||||
(Some(raw), None) | (None, Some(raw)) => {
|
||||
raw.text() == serde_json::to_string(a.as_ref()).expect("JSON value")
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
(Kind::Object(a, al), Kind::Object(b, bl)) => {
|
||||
a.len() == b.len()
|
||||
&& same_layout(al, bl, a.len())
|
||||
&& a.iter().zip(b).all(|(a, b)| a.key == b.key && a.head == b.head && a.value.same_bytes(&b.value))
|
||||
}
|
||||
(Kind::Array(a, al), Kind::Array(b, bl)) => {
|
||||
a.len() == b.len() && same_layout(al, bl, a.len()) && a.iter().zip(b).all(|(a, b)| a.same_bytes(b))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&self, output: &mut String) {
|
||||
if let Some(raw) = &self.raw {
|
||||
output.push_str(raw.text());
|
||||
return;
|
||||
}
|
||||
match &self.kind {
|
||||
Kind::Scalar(value) => output.push_str(&serde_json::to_string(value.as_ref()).expect("JSON value")),
|
||||
Kind::Object(items, layout) => {
|
||||
output.push('{');
|
||||
output.push_str(&layout.prefix);
|
||||
for (index, member) in items.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push_str(layout.separator(index - 1));
|
||||
}
|
||||
output.push_str(&member.head);
|
||||
member.value.write(output);
|
||||
}
|
||||
output.push_str(&layout.suffix);
|
||||
output.push('}');
|
||||
}
|
||||
Kind::Array(items, layout) => {
|
||||
output.push('[');
|
||||
output.push_str(&layout.prefix);
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push_str(layout.separator(index - 1));
|
||||
}
|
||||
item.write(output);
|
||||
}
|
||||
output.push_str(&layout.suffix);
|
||||
output.push(']');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_layout(a: &Layout, b: &Layout, count: usize) -> bool {
|
||||
a.prefix == b.prefix
|
||||
&& a.suffix == b.suffix
|
||||
&& (0..count.saturating_sub(1)).all(|index| a.separator(index) == b.separator(index))
|
||||
}
|
||||
|
||||
struct Parser {
|
||||
text: Arc<str>,
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
fn space(&mut self) {
|
||||
while self.text.as_bytes().get(self.pos).is_some_and(u8::is_ascii_whitespace) {
|
||||
self.pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn string_end(&mut self) {
|
||||
self.pos += 1;
|
||||
loop {
|
||||
match self.text.as_bytes()[self.pos] {
|
||||
b'\\' => self.pos += 2,
|
||||
b'"' => {
|
||||
self.pos += 1;
|
||||
break;
|
||||
}
|
||||
_ => self.pos += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn node(&mut self) -> IdeResult<Node> {
|
||||
let start = self.pos;
|
||||
let kind = match self.text.as_bytes()[self.pos] {
|
||||
b'{' | b'[' => {
|
||||
let object = self.text.as_bytes()[self.pos] == b'{';
|
||||
let close = if object { b'}' } else { b']' };
|
||||
self.pos += 1;
|
||||
let gap = self.pos;
|
||||
self.space();
|
||||
let mut layout =
|
||||
Layout { prefix: self.text[gap..self.pos].into(), separators: Vec::new(), suffix: String::new() };
|
||||
let mut members = Vec::new();
|
||||
let mut values = Vec::new();
|
||||
if self.text.as_bytes()[self.pos] != close {
|
||||
loop {
|
||||
if object {
|
||||
let key_start = self.pos;
|
||||
self.string_end();
|
||||
let key: String = serde_json::from_str(&self.text[key_start..self.pos])
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if members.iter().any(|m: &Member| m.key == key) {
|
||||
return Err(IdeError::invalid(
|
||||
"Duplicate JSON object keys are ambiguous; notebook was not opened",
|
||||
));
|
||||
}
|
||||
self.space();
|
||||
self.pos += 1;
|
||||
self.space();
|
||||
let head = self.text[key_start..self.pos].into();
|
||||
let value = self.node()?;
|
||||
members.push(Member { key, head, value });
|
||||
} else {
|
||||
values.push(self.node()?);
|
||||
}
|
||||
let end = self.pos;
|
||||
self.space();
|
||||
if self.text.as_bytes()[self.pos] == close {
|
||||
layout.suffix = self.text[end..self.pos].into();
|
||||
break;
|
||||
}
|
||||
self.pos += 1;
|
||||
self.space();
|
||||
layout.separators.push(self.text[end..self.pos].into());
|
||||
}
|
||||
}
|
||||
self.pos += 1;
|
||||
if object { Kind::Object(members, layout) } else { Kind::Array(values, layout) }
|
||||
}
|
||||
b'"' => {
|
||||
self.string_end();
|
||||
Kind::Scalar(Arc::new(
|
||||
serde_json::from_str(&self.text[start..self.pos]).map_err(|e| IdeError::invalid(e.to_string()))?,
|
||||
))
|
||||
}
|
||||
_ => {
|
||||
while self
|
||||
.text
|
||||
.as_bytes()
|
||||
.get(self.pos)
|
||||
.is_some_and(|b| !b.is_ascii_whitespace() && !b",]}".contains(b))
|
||||
{
|
||||
self.pos += 1;
|
||||
}
|
||||
Kind::Scalar(Arc::new(
|
||||
serde_json::from_str(&self.text[start..self.pos]).map_err(|e| IdeError::invalid(e.to_string()))?,
|
||||
))
|
||||
}
|
||||
};
|
||||
Ok(Node { raw: Some(Raw { text: self.text.clone(), span: start..self.pos }), kind })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Native, app-owned Jupyter kernels. One supervised process per notebook path.
|
||||
//!
|
||||
//! Output routing ends at the matching shell reply AND IOPub idle. Readers remain
|
||||
//! alive and drain later background output, but never attribute it to another run.
|
||||
|
||||
mod process;
|
||||
mod transport;
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
pub(super) type StatusCallback = Arc<dyn Fn(&Path, KernelState, Option<String>) + Send + Sync>;
|
||||
pub(super) type OutputCallback = Arc<dyn Fn(KernelOutput) + Send + Sync>;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KernelOutput {
|
||||
pub kind: String,
|
||||
#[specta(type = specta_typescript::Unknown)]
|
||||
pub content: Value,
|
||||
/// Actual Jupyter parent request ID, not a UI request ID.
|
||||
pub msg_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum KernelState {
|
||||
Starting,
|
||||
Idle,
|
||||
Busy,
|
||||
Dead,
|
||||
}
|
||||
|
||||
impl KernelState {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Starting => "starting",
|
||||
Self::Idle => "idle",
|
||||
Self::Busy => "busy",
|
||||
Self::Dead => "dead",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExecutionResult {
|
||||
pub status: String,
|
||||
pub execution_count: Option<u32>,
|
||||
}
|
||||
|
||||
struct Slot {
|
||||
operation: AsyncMutex<Option<transport::Running>>,
|
||||
interrupt: Mutex<Option<CancellationToken>>,
|
||||
stop: Mutex<Option<CancellationToken>>,
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
operation: AsyncMutex::new(None),
|
||||
interrupt: Mutex::new(None),
|
||||
stop: Mutex::new(None),
|
||||
closed: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel(&self) {
|
||||
if let Some(token) = self.interrupt.lock().as_ref() {
|
||||
token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
fn stop(&self) {
|
||||
self.cancel();
|
||||
if let Some(token) = self.stop.lock().as_ref() {
|
||||
token.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActiveExecution<'a>(&'a Slot);
|
||||
impl Drop for ActiveExecution<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let Some(token) = self.0.interrupt.lock().take() {
|
||||
token.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct KernelManager {
|
||||
runtime_dir: PathBuf,
|
||||
on_status: StatusCallback,
|
||||
slots: Mutex<HashMap<PathBuf, Arc<Slot>>>,
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl KernelManager {
|
||||
pub fn new(runtime_dir: PathBuf, on_status: StatusCallback) -> Self {
|
||||
Self { runtime_dir, on_status, slots: Mutex::new(HashMap::new()), closed: AtomicBool::new(false) }
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
notebook: &Path,
|
||||
python: &Path,
|
||||
code: &str,
|
||||
cancel: CancellationToken,
|
||||
on_output: OutputCallback,
|
||||
) -> IdeResult<ExecutionResult> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let slot = {
|
||||
let mut slots = self.slots.lock();
|
||||
if self.closed.load(Ordering::Acquire) {
|
||||
return Err(IdeError::unavailable("notebook kernel manager is shut down"));
|
||||
}
|
||||
slots.entry(notebook.to_path_buf()).or_insert_with(|| Arc::new(Slot::new())).clone()
|
||||
};
|
||||
// Register cancellation before taking the operation lock: interrupt can
|
||||
// otherwise race the tiny lock-to-token gap and miss a newly admitted run.
|
||||
let interrupt = CancellationToken::new();
|
||||
{
|
||||
let mut active = slot.interrupt.lock();
|
||||
if active.is_some() {
|
||||
return Err(IdeError::unavailable("notebook kernel is busy"));
|
||||
}
|
||||
*active = Some(interrupt.clone());
|
||||
}
|
||||
let _active = ActiveExecution(&slot);
|
||||
// A second caller must not sit in a queue and execute stale source later.
|
||||
let mut running = slot.operation.try_lock().map_err(|_| IdeError::unavailable("notebook kernel is busy"))?;
|
||||
if slot.closed.load(Ordering::Acquire) {
|
||||
return Err(IdeError::unavailable("notebook kernel was closed"));
|
||||
}
|
||||
if running.as_ref().is_some_and(|r| r.python != python || r.is_finished())
|
||||
&& let Some(old) = running.take()
|
||||
{
|
||||
old.shutdown().await;
|
||||
}
|
||||
if cancel.is_cancelled() || interrupt.is_cancelled() || slot.closed.load(Ordering::Acquire) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if running.is_none() {
|
||||
let (new, ready) = transport::Running::spawn(
|
||||
self.runtime_dir.clone(),
|
||||
notebook.to_path_buf(),
|
||||
python.to_path_buf(),
|
||||
self.on_status.clone(),
|
||||
);
|
||||
*slot.stop.lock() = Some(new.stop.clone());
|
||||
*running = Some(new);
|
||||
if slot.closed.load(Ordering::Acquire) {
|
||||
slot.stop();
|
||||
}
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => Err(IdeError::cancelled()),
|
||||
_ = interrupt.cancelled() => Err(IdeError::cancelled()),
|
||||
result = ready => result.unwrap_or_else(|_| Err(IdeError::unavailable("kernel startup task ended"))),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
if let Some(failed) = running.take() {
|
||||
failed.shutdown().await;
|
||||
}
|
||||
slot.stop.lock().take();
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let result = running.as_ref().expect("initialized kernel").execute(code, cancel, interrupt, on_output).await;
|
||||
if running.as_ref().is_some_and(transport::Running::is_finished) {
|
||||
if let Some(dead) = running.take() {
|
||||
dead.shutdown().await;
|
||||
}
|
||||
slot.stop.lock().take();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Cancels the active request, including startup. The execution future drains
|
||||
/// reply+idle before returning, or stops the owned process on interrupt timeout.
|
||||
pub async fn interrupt(&self, notebook: &Path) -> IdeResult<()> {
|
||||
let slot = self.slots.lock().get(notebook).cloned();
|
||||
if let Some(slot) = slot {
|
||||
slot.cancel();
|
||||
// Wait for native interrupt delivery/drain, not just token mutation.
|
||||
let _drained = slot.operation.lock().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn restart(&self, notebook: &Path) -> IdeResult<()> {
|
||||
let slot = self.slots.lock().get(notebook).cloned();
|
||||
let Some(slot) = slot else { return Ok(()) };
|
||||
slot.stop();
|
||||
let mut running = slot.operation.lock().await;
|
||||
if slot.closed.load(Ordering::Acquire) {
|
||||
return Err(IdeError::unavailable("notebook kernel was closed"));
|
||||
}
|
||||
let Some(old) = running.take() else { return Ok(()) };
|
||||
let python = old.python.clone();
|
||||
old.shutdown().await;
|
||||
let (new, ready) =
|
||||
transport::Running::spawn(self.runtime_dir.clone(), notebook.to_path_buf(), python, self.on_status.clone());
|
||||
*slot.stop.lock() = Some(new.stop.clone());
|
||||
*running = Some(new);
|
||||
if slot.closed.load(Ordering::Acquire) {
|
||||
slot.stop();
|
||||
}
|
||||
let result = ready.await.unwrap_or_else(|_| Err(IdeError::unavailable("kernel restart task ended")));
|
||||
if result.is_err() {
|
||||
if let Some(failed) = running.take() {
|
||||
failed.shutdown().await;
|
||||
}
|
||||
slot.stop.lock().take();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn shutdown_kernel(&self, notebook: &Path) -> IdeResult<()> {
|
||||
let slot = {
|
||||
let slots = self.slots.lock();
|
||||
// Keep a closed tombstone until old readers/process have drained.
|
||||
// Reopening this path cannot overlap old-generation status events.
|
||||
let slot = slots.get(notebook).cloned();
|
||||
if let Some(slot) = &slot {
|
||||
slot.closed.store(true, Ordering::Release);
|
||||
slot.stop();
|
||||
}
|
||||
slot
|
||||
};
|
||||
if let Some(slot) = slot {
|
||||
if let Some(running) = slot.operation.lock().await.take() {
|
||||
running.shutdown().await;
|
||||
}
|
||||
slot.stop.lock().take();
|
||||
let mut slots = self.slots.lock();
|
||||
if slots.get(notebook).is_some_and(|current| Arc::ptr_eq(current, &slot)) {
|
||||
slots.remove(notebook);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
let slots = {
|
||||
let mut slots = self.slots.lock();
|
||||
self.closed.store(true, Ordering::Release);
|
||||
slots
|
||||
.drain()
|
||||
.map(|(_, slot)| {
|
||||
slot.closed.store(true, Ordering::Release);
|
||||
slot.stop();
|
||||
slot
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// All stop tokens are signalled before awaiting any process.
|
||||
for slot in slots {
|
||||
if let Some(running) = slot.operation.lock().await.take() {
|
||||
running.shutdown().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for KernelManager {
|
||||
fn drop(&mut self) {
|
||||
for slot in self.slots.get_mut().values() {
|
||||
slot.closed.store(true, Ordering::Release);
|
||||
slot.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use jupyter_protocol::ConnectionInfo;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
const STDERR_BYTES: usize = 16 * 1024;
|
||||
|
||||
pub(super) struct Process {
|
||||
child: Child,
|
||||
#[cfg(unix)]
|
||||
pid: Option<u32>,
|
||||
stderr: Arc<Mutex<VecDeque<u8>>>,
|
||||
drains: Vec<JoinHandle<()>>,
|
||||
// Kept until the process and sockets have stopped; contains the HMAC secret.
|
||||
_directory: tempfile::TempDir,
|
||||
#[cfg(windows)]
|
||||
windows: windows::Resources,
|
||||
}
|
||||
|
||||
impl Process {
|
||||
pub async fn spawn(runtime: &Path, notebook: &Path, python: &Path) -> IdeResult<(Self, ConnectionInfo)> {
|
||||
if !python.is_absolute() {
|
||||
return Err(IdeError::invalid("select an absolute Python executable path for this notebook"));
|
||||
}
|
||||
std::fs::create_dir_all(runtime)?;
|
||||
let directory = tempfile::Builder::new().prefix("kernel-").tempdir_in(runtime)?;
|
||||
make_private(directory.path())?;
|
||||
let ip = std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST);
|
||||
let (ports, listeners) = jupyter_zmq_client::peek_ports_with_listeners(ip, 5).await.map_err(protocol_error)?;
|
||||
let info = ConnectionInfo {
|
||||
transport: jupyter_protocol::connection_info::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".into(),
|
||||
key: format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()),
|
||||
kernel_name: Some("openidea-python".into()),
|
||||
};
|
||||
let connection = directory.path().join("connection.json");
|
||||
write_private(&connection, &serde_json::to_vec(&info).map_err(protocol_error)?)?;
|
||||
#[cfg(windows)]
|
||||
let windows = windows::Resources::new()?;
|
||||
let mut argv = vec![python.as_os_str().to_os_string()];
|
||||
#[cfg(not(windows))]
|
||||
argv.extend(["-m".into(), "ipykernel_launcher".into(), "-f".into(), "{connection_file}".into()]);
|
||||
#[cfg(windows)]
|
||||
argv.extend(["-c".into(), windows::BOOTSTRAP.into(), "-f".into(), "{connection_file}".into()]);
|
||||
// App-owned spec, not registered in user/global Jupyter directories. The
|
||||
// argv array is also the launch source of truth, without shell quoting.
|
||||
let spec = serde_json::json!({
|
||||
"argv": argv.iter().map(|v| v.to_string_lossy().into_owned()).collect::<Vec<_>>(),
|
||||
"display_name": "OpenIDEA Python", "language": "python", "interrupt_mode": "signal"
|
||||
});
|
||||
write_private(&directory.path().join("kernel.json"), &serde_json::to_vec(&spec).map_err(protocol_error)?)?;
|
||||
let mut command = Command::new(python);
|
||||
for arg in &argv[1..] {
|
||||
if arg == "{connection_file}" {
|
||||
command.arg(&connection);
|
||||
} else {
|
||||
command.arg(arg);
|
||||
}
|
||||
}
|
||||
command
|
||||
.current_dir(notebook.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new(".")))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.env_remove("JPY_INTERRUPT_EVENT")
|
||||
.env_remove("JPY_PARENT_PID")
|
||||
.env("PYTHONUNBUFFERED", "1");
|
||||
ide_core::process::prepare(command.as_std_mut());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
command.as_std_mut().process_group(0);
|
||||
command.env("JPY_PARENT_PID", std::process::id().to_string());
|
||||
}
|
||||
#[cfg(windows)]
|
||||
windows.configure(&mut command);
|
||||
let child = command.spawn().map_err(|e| {
|
||||
IdeError::unavailable(format!(
|
||||
"cannot start selected Python {}: {e}. Select an executable environment with ipykernel installed",
|
||||
python.display()
|
||||
))
|
||||
})?;
|
||||
drop(listeners);
|
||||
#[cfg(unix)]
|
||||
let pid = child.id();
|
||||
let mut process = Self {
|
||||
child,
|
||||
#[cfg(unix)]
|
||||
pid,
|
||||
stderr: Arc::new(Mutex::new(VecDeque::with_capacity(STDERR_BYTES))),
|
||||
drains: Vec::new(),
|
||||
_directory: directory,
|
||||
#[cfg(windows)]
|
||||
windows,
|
||||
};
|
||||
if let Some(mut stderr) = process.child.stderr.take() {
|
||||
let tail = process.stderr.clone();
|
||||
process.drains.push(tokio::spawn(async move {
|
||||
let mut buffer = [0_u8; 4096];
|
||||
while let Ok(count) = stderr.read(&mut buffer).await {
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
let mut tail = tail.lock();
|
||||
for byte in &buffer[..count] {
|
||||
if tail.len() == STDERR_BYTES {
|
||||
tail.pop_front();
|
||||
}
|
||||
tail.push_back(*byte);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
if let Some(mut stdout) = process.child.stdout.take() {
|
||||
process.drains.push(tokio::spawn(async move {
|
||||
let mut buffer = [0_u8; 4096];
|
||||
while let Ok(count) = stdout.read(&mut buffer).await {
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
process.windows.assign_and_release(&process.child)?;
|
||||
Ok((process, info))
|
||||
}
|
||||
|
||||
pub fn stderr(&self) -> String {
|
||||
let bytes = self.stderr.lock().iter().copied().collect::<Vec<_>>();
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
pub fn death(&mut self) -> IdeResult<Option<String>> {
|
||||
match self.child.try_wait()? {
|
||||
None => Ok(None),
|
||||
Some(status) => {
|
||||
self.kill_tree();
|
||||
Ok(Some(format!("kernel exited ({status}); {}", self.stderr())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn interrupt(&self) -> IdeResult<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let pid = self.pid.ok_or_else(|| IdeError::unavailable("kernel is no longer running"))?;
|
||||
// SAFETY: this child was created in its own process group. Never
|
||||
// signal the host group or a discovered/external kernel.
|
||||
if unsafe { libc::kill(-(pid as i32), libc::SIGINT) } != 0 {
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
self.windows.interrupt()
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
Err(IdeError::unavailable("native kernel interruption is unsupported on this platform"))
|
||||
}
|
||||
}
|
||||
|
||||
fn kill_tree(&mut self) {
|
||||
#[cfg(unix)]
|
||||
if let Some(pid) = self.pid.take() {
|
||||
// SAFETY: group ID belongs exclusively to the owned kernel.
|
||||
unsafe {
|
||||
libc::kill(-(pid as i32), libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
self.windows.kill_tree();
|
||||
}
|
||||
|
||||
pub async fn shutdown(&mut self) {
|
||||
self.kill_tree();
|
||||
let _ = self.child.start_kill();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(3), self.child.wait()).await;
|
||||
for task in self.drains.drain(..) {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Process {
|
||||
fn drop(&mut self) {
|
||||
self.kill_tree();
|
||||
let _ = self.child.start_kill();
|
||||
for task in &self.drains {
|
||||
task.abort();
|
||||
}
|
||||
// tokio Child's kill_on_drop also kills/reaps if this future is aborted.
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_error(error: impl std::fmt::Display) -> IdeError {
|
||||
IdeError::unavailable(format!("Jupyter kernel setup: {error}"))
|
||||
}
|
||||
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> IdeResult<()> {
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(path)?;
|
||||
file.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_private(path: &Path) -> IdeResult<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
windows::private_directory(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod windows {
|
||||
use super::*;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, ERROR_ALREADY_EXISTS, GetLastError, HANDLE, LocalFree};
|
||||
use windows_sys::Win32::Security::Authorization::{
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, SE_FILE_OBJECT, SetNamedSecurityInfoW,
|
||||
};
|
||||
use windows_sys::Win32::Security::{
|
||||
DACL_SECURITY_INFORMATION, GetSecurityDescriptorDacl, PROTECTED_DACL_SECURITY_INFORMATION, SECURITY_ATTRIBUTES,
|
||||
};
|
||||
use windows_sys::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject,
|
||||
TerminateJobObject,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{CreateEventW, SetEvent};
|
||||
|
||||
// Launcher glue only. All Jupyter traffic is authenticated native ZeroMQ.
|
||||
// Child opens its own handles: no assumption about Rust HANDLE inheritance.
|
||||
// The gate prevents any ipykernel imports/descendants before job assignment.
|
||||
pub const BOOTSTRAP: &str = r#"import ctypes, os, runpy, sys
|
||||
k = ctypes.WinDLL('kernel32', use_last_error=True)
|
||||
k.OpenEventW.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_wchar_p]
|
||||
k.OpenEventW.restype = ctypes.c_void_p
|
||||
k.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
|
||||
k.WaitForSingleObject.restype = ctypes.c_uint32
|
||||
k.CloseHandle.argtypes = [ctypes.c_void_p]
|
||||
k.CloseHandle.restype = ctypes.c_int
|
||||
interrupt = k.OpenEventW(0x00100002, 0, os.environ.pop('OPENIDEA_INTERRUPT_EVENT'))
|
||||
gate = k.OpenEventW(0x00100000, 0, os.environ.pop('OPENIDEA_LAUNCH_EVENT'))
|
||||
if not interrupt or not gate:
|
||||
raise ctypes.WinError(ctypes.get_last_error())
|
||||
if k.WaitForSingleObject(gate, 30000) != 0:
|
||||
raise RuntimeError('OpenIDEA kernel launch gate failed')
|
||||
k.CloseHandle(gate)
|
||||
os.environ['JPY_INTERRUPT_EVENT'] = str(interrupt)
|
||||
try:
|
||||
runpy.run_module('ipykernel_launcher', run_name='__main__')
|
||||
finally:
|
||||
k.CloseHandle(interrupt)
|
||||
"#;
|
||||
|
||||
struct Handle(usize);
|
||||
impl Handle {
|
||||
fn new(raw: HANDLE) -> IdeResult<Self> {
|
||||
if raw.is_null() { Err(std::io::Error::last_os_error().into()) } else { Ok(Self(raw as usize)) }
|
||||
}
|
||||
fn raw(&self) -> HANDLE {
|
||||
self.0 as HANDLE
|
||||
}
|
||||
fn signal(&self) -> IdeResult<()> {
|
||||
// SAFETY: owned live event handle, retained for this process lifetime.
|
||||
if unsafe { SetEvent(self.raw()) } == 0 { Err(std::io::Error::last_os_error().into()) } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
impl Drop for Handle {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
CloseHandle(self.raw());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Descriptor(*mut std::ffi::c_void);
|
||||
impl Descriptor {
|
||||
fn new() -> IdeResult<Self> {
|
||||
// Protected ACL: owner and SYSTEM only, inherited by runtime files.
|
||||
let sddl = "D:P(A;OICI;FA;;;OW)(A;OICI;FA;;;SY)\0".encode_utf16().collect::<Vec<_>>();
|
||||
let mut raw = std::ptr::null_mut();
|
||||
if unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
sddl.as_ptr(),
|
||||
SDDL_REVISION_1,
|
||||
&mut raw,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
Ok(Self(raw))
|
||||
}
|
||||
}
|
||||
impl Drop for Descriptor {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
LocalFree(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn private_directory(path: &Path) -> IdeResult<()> {
|
||||
let descriptor = Descriptor::new()?;
|
||||
let mut present = 0;
|
||||
let mut defaulted = 0;
|
||||
let mut dacl = std::ptr::null_mut();
|
||||
if unsafe { GetSecurityDescriptorDacl(descriptor.0, &mut present, &mut dacl, &mut defaulted) } == 0
|
||||
|| present == 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
let path = path.as_os_str().encode_wide().chain(Some(0)).collect::<Vec<_>>();
|
||||
let error = unsafe {
|
||||
SetNamedSecurityInfoW(
|
||||
path.as_ptr(),
|
||||
SE_FILE_OBJECT,
|
||||
DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
dacl,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if error != 0 {
|
||||
return Err(std::io::Error::from_raw_os_error(error as i32).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Resources {
|
||||
interrupt: Handle,
|
||||
gate: Handle,
|
||||
job: Handle,
|
||||
interrupt_name: String,
|
||||
gate_name: String,
|
||||
}
|
||||
impl Resources {
|
||||
pub fn new() -> IdeResult<Self> {
|
||||
let interrupt_name = format!("Local\\OpenIDEA-interrupt-{}", Uuid::new_v4());
|
||||
let gate_name = format!("Local\\OpenIDEA-launch-{}", Uuid::new_v4());
|
||||
fn event(name: &str) -> IdeResult<Handle> {
|
||||
let name = name.encode_utf16().chain(Some(0)).collect::<Vec<_>>();
|
||||
// Event-specific rights; owner and SYSTEM only. File inheritance
|
||||
// flags/FA are not used for kernel synchronization objects.
|
||||
let sddl = "D:P(A;;GA;;;OW)(A;;GA;;;SY)\0".encode_utf16().collect::<Vec<_>>();
|
||||
let mut raw = std::ptr::null_mut();
|
||||
if unsafe {
|
||||
ConvertStringSecurityDescriptorToSecurityDescriptorW(
|
||||
sddl.as_ptr(),
|
||||
SDDL_REVISION_1,
|
||||
&mut raw,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
let descriptor = Descriptor(raw);
|
||||
let attrs = SECURITY_ATTRIBUTES {
|
||||
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: descriptor.0,
|
||||
bInheritHandle: 0,
|
||||
};
|
||||
let handle = Handle::new(unsafe { CreateEventW(&attrs, 0, 0, name.as_ptr()) })?;
|
||||
if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
|
||||
return Err(IdeError::unavailable("kernel event name collision"));
|
||||
}
|
||||
Ok(handle)
|
||||
}
|
||||
let interrupt = event(&interrupt_name)?;
|
||||
let gate = event(&gate_name)?;
|
||||
let job = Handle::new(unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) })?;
|
||||
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
if unsafe {
|
||||
SetInformationJobObject(
|
||||
job.raw(),
|
||||
JobObjectExtendedLimitInformation,
|
||||
&info as *const _ as *const _,
|
||||
std::mem::size_of_val(&info) as u32,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
Ok(Self { interrupt, gate, job, interrupt_name, gate_name })
|
||||
}
|
||||
pub fn configure(&self, command: &mut Command) {
|
||||
command.env("OPENIDEA_INTERRUPT_EVENT", &self.interrupt_name).env("OPENIDEA_LAUNCH_EVENT", &self.gate_name);
|
||||
}
|
||||
pub fn assign_and_release(&self, child: &Child) -> IdeResult<()> {
|
||||
let handle = child
|
||||
.raw_handle()
|
||||
.ok_or_else(|| IdeError::unavailable("kernel exited before Windows job assignment"))?;
|
||||
if unsafe { AssignProcessToJobObject(self.job.raw(), handle as HANDLE) } == 0 {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"cannot contain kernel process in owned Windows job: {}",
|
||||
std::io::Error::last_os_error()
|
||||
)));
|
||||
}
|
||||
self.gate.signal()
|
||||
}
|
||||
pub fn interrupt(&self) -> IdeResult<()> {
|
||||
self.interrupt.signal()
|
||||
}
|
||||
pub fn kill_tree(&self) {
|
||||
unsafe {
|
||||
TerminateJobObject(self.job.raw(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
use super::process::Process;
|
||||
use super::{ExecutionResult, KernelOutput, KernelState, OutputCallback, StatusCallback};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use jupyter_protocol::{ExecuteRequest, JupyterMessage, KernelInfoRequest, ShutdownRequest};
|
||||
use jupyter_zmq_client::{ClientControlConnection, Connection, DealerSendConnection, RawMessage};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{Instant, MissedTickBehavior};
|
||||
use uuid::Uuid;
|
||||
use zeromq::SocketRecv;
|
||||
|
||||
const STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const INTERRUPT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const SEND_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const MAX_MESSAGE_BYTES: usize = 8 * 1024 * 1024;
|
||||
const MAX_OUTPUT_BYTES: usize = 32 * 1024 * 1024;
|
||||
const MAX_OUTPUT_MESSAGES: usize = 10_000;
|
||||
const QUEUED_MESSAGES: usize = 64;
|
||||
const QUEUED_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
struct Admission(Arc<AtomicBool>);
|
||||
impl Drop for Admission {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
struct Execution {
|
||||
code: String,
|
||||
cancel: CancellationToken,
|
||||
interrupt: CancellationToken,
|
||||
output: OutputCallback,
|
||||
reply: oneshot::Sender<IdeResult<ExecutionResult>>,
|
||||
_admission: Admission,
|
||||
}
|
||||
|
||||
pub(super) struct Running {
|
||||
pub python: PathBuf,
|
||||
pub stop: CancellationToken,
|
||||
requests: mpsc::Sender<Execution>,
|
||||
task: Option<JoinHandle<()>>,
|
||||
busy: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Running {
|
||||
pub fn spawn(
|
||||
runtime: PathBuf,
|
||||
notebook: PathBuf,
|
||||
python: PathBuf,
|
||||
status: StatusCallback,
|
||||
) -> (Self, oneshot::Receiver<IdeResult<()>>) {
|
||||
let stop = CancellationToken::new();
|
||||
let (requests, receiver) = mpsc::channel(1);
|
||||
let (ready, ready_rx) = oneshot::channel();
|
||||
let task = tokio::spawn(supervise(runtime, notebook, python.clone(), status, stop.clone(), receiver, ready));
|
||||
(Self { python, stop, requests, task: Some(task), busy: Arc::new(AtomicBool::new(false)) }, ready_rx)
|
||||
}
|
||||
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.task.as_ref().is_none_or(JoinHandle::is_finished)
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
code: &str,
|
||||
cancel: CancellationToken,
|
||||
interrupt: CancellationToken,
|
||||
output: OutputCallback,
|
||||
) -> IdeResult<ExecutionResult> {
|
||||
if self.busy.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire).is_err() {
|
||||
return Err(IdeError::unavailable("previous kernel execution has not drained"));
|
||||
}
|
||||
let (reply, result) = oneshot::channel();
|
||||
let execution = Execution {
|
||||
code: code.to_owned(),
|
||||
cancel,
|
||||
interrupt,
|
||||
output,
|
||||
reply,
|
||||
_admission: Admission(self.busy.clone()),
|
||||
};
|
||||
self.requests.try_send(execution).map_err(|_| IdeError::unavailable("kernel is busy or has exited"))?;
|
||||
result.await.unwrap_or_else(|_| Err(IdeError::unavailable("kernel execution task ended")))
|
||||
}
|
||||
|
||||
pub async fn shutdown(mut self) {
|
||||
self.stop.cancel();
|
||||
if let Some(mut task) = self.task.take()
|
||||
&& tokio::time::timeout(Duration::from_secs(8), &mut task).await.is_err()
|
||||
{
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
self.stop.cancel();
|
||||
// Do not abort: supervisor owns graceful shutdown and process reaping.
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Channel {
|
||||
Shell,
|
||||
Iopub,
|
||||
}
|
||||
|
||||
struct Frame {
|
||||
channel: Channel,
|
||||
parent: String,
|
||||
kind: String,
|
||||
content: Value,
|
||||
bytes: usize,
|
||||
_permit: Option<OwnedSemaphorePermit>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Failure {
|
||||
token: CancellationToken,
|
||||
error: Arc<Mutex<Option<IdeError>>>,
|
||||
}
|
||||
impl Failure {
|
||||
fn new() -> Self {
|
||||
Self { token: CancellationToken::new(), error: Arc::new(Mutex::new(None)) }
|
||||
}
|
||||
fn fail(&self, error: IdeError) {
|
||||
self.error.lock().get_or_insert(error);
|
||||
self.token.cancel();
|
||||
}
|
||||
fn error(&self) -> IdeError {
|
||||
self.error.lock().clone().unwrap_or_else(|| IdeError::unavailable("kernel reader stopped"))
|
||||
}
|
||||
}
|
||||
|
||||
struct Readers(Vec<JoinHandle<()>>);
|
||||
impl Drop for Readers {
|
||||
fn drop(&mut self) {
|
||||
for task in &self.0 {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reader<S: SocketRecv + Send + 'static>(
|
||||
mut connection: Connection<S>,
|
||||
channel: Channel,
|
||||
tx: mpsc::Sender<Frame>,
|
||||
failure: Failure,
|
||||
queued: Arc<Semaphore>,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let result = receive(&mut connection, channel, &queued).await;
|
||||
match result {
|
||||
Ok(frame) => {
|
||||
if tx.try_send(frame).is_err() {
|
||||
failure.fail(IdeError::unavailable(
|
||||
"kernel output queue overflow; execution stopped to bound memory",
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
failure.fail(error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn receive<S: SocketRecv + Send>(
|
||||
connection: &mut Connection<S>,
|
||||
channel: Channel,
|
||||
queued: &Arc<Semaphore>,
|
||||
) -> IdeResult<Frame> {
|
||||
let multipart = connection.socket.recv().await.map_err(protocol)?;
|
||||
let bytes = multipart
|
||||
.iter()
|
||||
.try_fold(0_usize, |sum, part| sum.checked_add(part.len()))
|
||||
.ok_or_else(|| IdeError::unavailable("kernel message size overflow"))?;
|
||||
if bytes > MAX_MESSAGE_BYTES {
|
||||
return Err(IdeError::unavailable("kernel message exceeds 8 MiB limit; execution stopped"));
|
||||
}
|
||||
let permit = queued
|
||||
.clone()
|
||||
.try_acquire_many_owned(bytes as u32)
|
||||
.map_err(|_| IdeError::unavailable("kernel output queue exceeds 16 MiB; execution stopped"))?;
|
||||
// Upstream RawMessage indexes four signed parts without checking length.
|
||||
// Validate framing before calling it, including messages from a bad kernel.
|
||||
let delimiter = multipart
|
||||
.iter()
|
||||
.position(|part| part.as_ref() == b"<IDS|MSG>")
|
||||
.ok_or_else(|| protocol("missing Jupyter message delimiter"))?;
|
||||
if multipart.len() < delimiter + 6 {
|
||||
return Err(protocol("incomplete Jupyter message frames"));
|
||||
}
|
||||
let raw = RawMessage::from_multipart(multipart, &connection.mac).map_err(protocol)?;
|
||||
let header: Value = serde_json::from_slice(&raw.jparts[0]).map_err(protocol)?;
|
||||
let parent: Value = serde_json::from_slice(&raw.jparts[1]).map_err(protocol)?;
|
||||
let kind = header
|
||||
.get("msg_type")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| protocol("missing Jupyter message type"))?
|
||||
.to_owned();
|
||||
let parent = parent.get("msg_id").and_then(Value::as_str).unwrap_or("").to_owned();
|
||||
let content = serde_json::from_slice(&raw.jparts[3]).map_err(protocol)?;
|
||||
Ok(Frame { channel, parent, kind, content, bytes, _permit: Some(permit) })
|
||||
}
|
||||
|
||||
fn protocol(error: impl std::fmt::Display) -> IdeError {
|
||||
IdeError::new(ide_core::error::ErrorKind::Protocol, format!("Jupyter protocol: {error}"))
|
||||
}
|
||||
|
||||
struct Session {
|
||||
process: Process,
|
||||
shell: DealerSendConnection,
|
||||
control: ClientControlConnection,
|
||||
frames: mpsc::Receiver<Frame>,
|
||||
failure: Failure,
|
||||
_readers: Readers,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
async fn connect(
|
||||
runtime: &std::path::Path,
|
||||
notebook: &std::path::Path,
|
||||
python: &std::path::Path,
|
||||
) -> IdeResult<Self> {
|
||||
let (process, info) = Process::spawn(runtime, notebook, python).await?;
|
||||
let session = Uuid::new_v4().to_string();
|
||||
let identity = jupyter_zmq_client::peer_identity_for_session(&session).map_err(protocol)?;
|
||||
let (iopub, shell, control) = tokio::try_join!(
|
||||
jupyter_zmq_client::create_client_iopub_connection(&info, "", &session),
|
||||
jupyter_zmq_client::create_client_shell_connection_with_identity(&info, &session, identity),
|
||||
jupyter_zmq_client::create_client_control_connection(&info, &session),
|
||||
)
|
||||
.map_err(protocol)?;
|
||||
let (shell, shell_recv) = shell.split();
|
||||
let (tx, frames) = mpsc::channel(QUEUED_MESSAGES);
|
||||
let failure = Failure::new();
|
||||
let queued = Arc::new(Semaphore::new(QUEUED_BYTES));
|
||||
let readers = Readers(vec![
|
||||
reader(iopub, Channel::Iopub, tx.clone(), failure.clone(), queued.clone()),
|
||||
reader(shell_recv, Channel::Shell, tx, failure.clone(), queued),
|
||||
]);
|
||||
Ok(Self { process, shell, control, frames, failure, _readers: readers })
|
||||
}
|
||||
|
||||
async fn ready(&mut self, stop: &CancellationToken) -> IdeResult<()> {
|
||||
let deadline = Instant::now() + STARTUP_TIMEOUT;
|
||||
let mut probe = tokio::time::interval(Duration::from_millis(250));
|
||||
probe.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||
let mut request = String::new();
|
||||
let mut reply = false;
|
||||
let mut idle = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = stop.cancelled() => return Err(IdeError::cancelled()),
|
||||
_ = self.failure.token.cancelled() => return Err(self.failure.error()),
|
||||
_ = tokio::time::sleep_until(deadline) => return Err(IdeError::unavailable(format!(
|
||||
"selected Python kernel was not ready within 30 seconds; {}", self.process.stderr()))),
|
||||
_ = probe.tick() => {
|
||||
if let Some(message) = self.process.death()? {
|
||||
return Err(IdeError::unavailable(message));
|
||||
}
|
||||
// Only readiness probes are retried, never user execution.
|
||||
// Once the shell replies, retain its ID to await IOPub idle.
|
||||
if !reply {
|
||||
let message: JupyterMessage = KernelInfoRequest {}.into();
|
||||
request = message.header.msg_id.clone();
|
||||
idle = false;
|
||||
send(&mut self.shell, message).await?;
|
||||
} else if !idle {
|
||||
// A slow subscriber can miss the first idle. Probe again
|
||||
// after a reply, proving BOTH live shell and subscription.
|
||||
reply = false;
|
||||
}
|
||||
},
|
||||
frame = self.frames.recv() => {
|
||||
let frame = frame.ok_or_else(|| IdeError::unavailable("kernel readers closed during startup"))?;
|
||||
if frame.parent != request { continue; }
|
||||
if frame.channel == Channel::Shell && frame.kind == "kernel_info_reply" { reply = true; }
|
||||
if frame.channel == Channel::Iopub && is_idle(&frame) { idle = true; }
|
||||
if reply && idle { return Ok(()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
&mut self,
|
||||
execution: &mut Execution,
|
||||
stop: &CancellationToken,
|
||||
notebook: &std::path::Path,
|
||||
status: &StatusCallback,
|
||||
) -> IdeResult<ExecutionResult> {
|
||||
if execution.cancel.is_cancelled() || execution.interrupt.is_cancelled() || execution.reply.is_closed() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let request: JupyterMessage = ExecuteRequest {
|
||||
code: std::mem::take(&mut execution.code),
|
||||
allow_stdin: false,
|
||||
stop_on_error: true,
|
||||
..Default::default()
|
||||
}
|
||||
.into();
|
||||
let request_id = request.header.msg_id.clone();
|
||||
// If sending fails/times out, delivery is uncertain. Supervisor closes
|
||||
// the process; no caller may retry user code automatically.
|
||||
send(&mut self.shell, request).await?;
|
||||
status(notebook, KernelState::Busy, None);
|
||||
let mut completion = Completion::default();
|
||||
let mut budget = Budget::default();
|
||||
let mut interrupted = false;
|
||||
let mut interrupt_deadline = None;
|
||||
let mut death = tokio::time::interval(Duration::from_millis(100));
|
||||
death.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = stop.cancelled() => return Err(IdeError::cancelled()),
|
||||
_ = self.failure.token.cancelled() => return Err(self.failure.error()),
|
||||
_ = execution.cancel.cancelled(), if !interrupted => {
|
||||
self.process.interrupt()?;
|
||||
interrupted = true;
|
||||
interrupt_deadline = Some(Instant::now() + INTERRUPT_TIMEOUT);
|
||||
},
|
||||
_ = execution.interrupt.cancelled(), if !interrupted => {
|
||||
self.process.interrupt()?;
|
||||
interrupted = true;
|
||||
interrupt_deadline = Some(Instant::now() + INTERRUPT_TIMEOUT);
|
||||
},
|
||||
_ = death.tick() => {
|
||||
if execution.reply.is_closed() && !interrupted {
|
||||
self.process.interrupt()?;
|
||||
interrupted = true;
|
||||
interrupt_deadline = Some(Instant::now() + INTERRUPT_TIMEOUT);
|
||||
}
|
||||
if interrupt_deadline.is_some_and(|deadline| Instant::now() >= deadline) {
|
||||
return Err(IdeError::unavailable("kernel did not drain within 5 seconds of interrupt; owned kernel stopped"));
|
||||
}
|
||||
if let Some(message) = self.process.death()? { return Err(IdeError::unavailable(message)); }
|
||||
},
|
||||
frame = self.frames.recv() => {
|
||||
let frame = frame.ok_or_else(|| IdeError::unavailable("kernel readers closed during execution"))?;
|
||||
if frame.parent != request_id { continue; }
|
||||
budget.add(frame.bytes)?;
|
||||
completion.observe(&frame, &request_id)?;
|
||||
if frame.channel == Channel::Iopub && is_output(&frame.kind) {
|
||||
(execution.output)(KernelOutput { kind: frame.kind, content: frame.content, msg_id: frame.parent });
|
||||
}
|
||||
if completion.complete() {
|
||||
status(notebook, KernelState::Idle, None);
|
||||
if interrupted || execution.cancel.is_cancelled() || execution.interrupt.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
return Ok(completion.reply.take().expect("complete reply"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown(&mut self) {
|
||||
// Stop is separate from user interrupt: graceful shutdown, then kill only
|
||||
// our process group/job. It must remain bounded even for wedged Python.
|
||||
let request: JupyterMessage = ShutdownRequest { restart: false }.into();
|
||||
let _ = tokio::time::timeout(Duration::from_millis(500), self.control.send(request)).await;
|
||||
let deadline = Instant::now() + Duration::from_millis(750);
|
||||
while Instant::now() < deadline {
|
||||
match self.process.death() {
|
||||
Ok(None) => {}
|
||||
_ => break,
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
self.process.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(connection: &mut DealerSendConnection, message: JupyterMessage) -> IdeResult<()> {
|
||||
tokio::time::timeout(SEND_TIMEOUT, connection.send(message))
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable("kernel request send timed out; delivery uncertain, not retried"))?
|
||||
.map_err(protocol)
|
||||
}
|
||||
|
||||
fn is_idle(frame: &Frame) -> bool {
|
||||
frame.kind == "status" && frame.content.get("execution_state").and_then(Value::as_str) == Some("idle")
|
||||
}
|
||||
|
||||
fn is_output(kind: &str) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
"stream"
|
||||
| "display_data"
|
||||
| "execute_result"
|
||||
| "error"
|
||||
| "execute_input"
|
||||
| "clear_output"
|
||||
| "update_display_data"
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Completion {
|
||||
reply: Option<ExecutionResult>,
|
||||
idle: bool,
|
||||
}
|
||||
impl Completion {
|
||||
fn observe(&mut self, frame: &Frame, request_id: &str) -> IdeResult<()> {
|
||||
if frame.parent != request_id {
|
||||
return Ok(());
|
||||
}
|
||||
if frame.channel == Channel::Iopub && is_idle(frame) {
|
||||
self.idle = true;
|
||||
}
|
||||
if frame.channel == Channel::Shell && frame.kind == "execute_reply" {
|
||||
let status = frame
|
||||
.content
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| protocol("execute_reply missing status"))?;
|
||||
if !matches!(status, "ok" | "error" | "abort" | "aborted") {
|
||||
return Err(protocol("invalid execute_reply status"));
|
||||
}
|
||||
let count = match frame.content.get("execution_count") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(value) => Some(
|
||||
value
|
||||
.as_u64()
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
.ok_or_else(|| protocol("invalid execution count"))?,
|
||||
),
|
||||
};
|
||||
self.reply = Some(ExecutionResult { status: status.to_owned(), execution_count: count });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn complete(&self) -> bool {
|
||||
self.idle && self.reply.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Budget {
|
||||
bytes: usize,
|
||||
messages: usize,
|
||||
}
|
||||
impl Budget {
|
||||
fn add(&mut self, bytes: usize) -> IdeResult<()> {
|
||||
self.bytes = self.bytes.saturating_add(bytes);
|
||||
self.messages = self.messages.saturating_add(1);
|
||||
if self.bytes > MAX_OUTPUT_BYTES || self.messages > MAX_OUTPUT_MESSAGES {
|
||||
return Err(IdeError::unavailable("kernel output exceeded 32 MiB or 10000 messages; execution stopped"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn supervise(
|
||||
runtime: PathBuf,
|
||||
notebook: PathBuf,
|
||||
python: PathBuf,
|
||||
status: StatusCallback,
|
||||
stop: CancellationToken,
|
||||
mut requests: mpsc::Receiver<Execution>,
|
||||
ready: oneshot::Sender<IdeResult<()>>,
|
||||
) {
|
||||
status(¬ebook, KernelState::Starting, None);
|
||||
let startup = async {
|
||||
let mut session = Session::connect(&runtime, ¬ebook, &python).await?;
|
||||
session.ready(&stop).await?;
|
||||
IdeResult::Ok(session)
|
||||
};
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
_ = stop.cancelled() => Err(IdeError::cancelled()),
|
||||
result = tokio::time::timeout(STARTUP_TIMEOUT, startup) => result.unwrap_or_else(|_| Err(IdeError::unavailable("selected Python kernel startup timed out"))),
|
||||
};
|
||||
let mut session = match result {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
let message = format!(
|
||||
"{}; selected Python: {}. Ensure ipykernel is installed in this exact environment ({} -m pip install ipykernel); no fallback interpreter was used",
|
||||
error.message,
|
||||
python.display(),
|
||||
python.display()
|
||||
);
|
||||
let error = IdeError::new(error.kind, message);
|
||||
status(¬ebook, KernelState::Dead, Some(error.message.clone()));
|
||||
let _ = ready.send(Err(error));
|
||||
return;
|
||||
}
|
||||
};
|
||||
status(¬ebook, KernelState::Idle, None);
|
||||
if ready.send(Ok(())).is_err() {
|
||||
stop.cancel();
|
||||
}
|
||||
let mut death = tokio::time::interval(Duration::from_millis(100));
|
||||
death.set_missed_tick_behavior(MissedTickBehavior::Delay);
|
||||
let failure = session.failure.clone();
|
||||
let mut reason = None;
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = stop.cancelled() => break,
|
||||
_ = failure.token.cancelled() => { reason = Some(failure.error().message); break; },
|
||||
_ = death.tick() => match session.process.death() {
|
||||
Ok(None) => {},
|
||||
Ok(Some(message)) => { reason = Some(message); break; },
|
||||
Err(error) => { reason = Some(error.message); break; },
|
||||
},
|
||||
request = requests.recv() => {
|
||||
let Some(mut request) = request else { break };
|
||||
let result = session.run(&mut request, &stop, ¬ebook, &status).await;
|
||||
// Cancelled with reply+idle is reusable. Stop cancellation or any
|
||||
// transport/death/overflow error invalidates this generation.
|
||||
let fatal = result.as_ref().err().is_some_and(|e| e.kind != ide_core::error::ErrorKind::Cancelled)
|
||||
|| stop.is_cancelled();
|
||||
if fatal { reason = result.as_ref().err().map(|e| e.message.clone()); }
|
||||
let Execution { reply, _admission, .. } = request;
|
||||
drop(_admission);
|
||||
let _ = reply.send(result);
|
||||
if fatal { break; }
|
||||
},
|
||||
frame = session.frames.recv() => {
|
||||
// Post-idle/background messages are deliberately NOT forwarded.
|
||||
// Keeping this drain alive also prevents stale IDs leaking into
|
||||
// later executions or another process generation.
|
||||
if frame.is_none() { reason = Some("kernel readers closed".into()); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
requests.close();
|
||||
while let Ok(request) = requests.try_recv() {
|
||||
let _ = request.reply.send(Err(IdeError::unavailable("kernel stopped before execution admission")));
|
||||
}
|
||||
session.shutdown().await;
|
||||
status(¬ebook, KernelState::Dead, reason);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "transport_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,71 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn frame(channel: Channel, parent: &str, kind: &str, content: Value) -> Frame {
|
||||
Frame { channel, parent: parent.into(), kind: kind.into(), content, bytes: 1, _permit: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_requires_both_channels_in_either_order_and_ignores_old_requests() {
|
||||
for idle_first in [false, true] {
|
||||
let mut completion = Completion::default();
|
||||
let idle = frame(Channel::Iopub, "current", "status", json!({"execution_state":"idle"}));
|
||||
let reply = frame(Channel::Shell, "current", "execute_reply", json!({"status":"error","execution_count":7}));
|
||||
let old_idle = frame(Channel::Iopub, "old-generation", "status", json!({"execution_state":"idle"}));
|
||||
let old_reply =
|
||||
frame(Channel::Shell, "old-generation", "execute_reply", json!({"status":"ok","execution_count":99}));
|
||||
completion.observe(&old_reply, "current").unwrap();
|
||||
completion.observe(&old_idle, "current").unwrap();
|
||||
assert!(!completion.complete());
|
||||
let (first, second) = if idle_first { (&idle, &reply) } else { (&reply, &idle) };
|
||||
completion.observe(first, "current").unwrap();
|
||||
assert!(!completion.complete(), "one channel cannot finalize execution");
|
||||
completion.observe(second, "current").unwrap();
|
||||
assert!(completion.complete());
|
||||
let result = completion.reply.unwrap();
|
||||
assert_eq!(result.status, "error");
|
||||
assert_eq!(result.execution_count, Some(7));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_channel_cannot_forge_execution_completion() {
|
||||
let mut completion = Completion::default();
|
||||
completion
|
||||
.observe(&frame(Channel::Iopub, "run", "execute_reply", json!({"status":"ok","execution_count":2})), "run")
|
||||
.unwrap();
|
||||
completion.observe(&frame(Channel::Shell, "run", "status", json!({"execution_state":"idle"})), "run").unwrap();
|
||||
assert!(!completion.complete());
|
||||
assert!(completion.reply.is_none());
|
||||
assert!(!completion.idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_reply_is_rejected_instead_of_reported_as_success() {
|
||||
for content in [
|
||||
json!({"status":"unexpected"}),
|
||||
json!({"execution_count":1}),
|
||||
json!({"status":"ok","execution_count":-1}),
|
||||
json!({"status":"ok","execution_count":4294967296_u64}),
|
||||
] {
|
||||
let mut completion = Completion::default();
|
||||
let error = completion.observe(&frame(Channel::Shell, "run", "execute_reply", content), "run").unwrap_err();
|
||||
assert_eq!(error.kind, ide_core::error::ErrorKind::Protocol);
|
||||
assert!(!completion.complete());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_budget_stops_at_byte_or_message_boundary_without_wrapping() {
|
||||
let mut bytes = Budget::default();
|
||||
bytes.add(MAX_OUTPUT_BYTES).unwrap();
|
||||
assert_eq!(bytes.add(1).unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
|
||||
let mut messages = Budget::default();
|
||||
for _ in 0..MAX_OUTPUT_MESSAGES {
|
||||
messages.add(0).unwrap();
|
||||
}
|
||||
assert_eq!(messages.add(0).unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
|
||||
let mut overflow = Budget::default();
|
||||
assert_eq!(overflow.add(usize::MAX).unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert_eq!(overflow.add(1).unwrap_err().kind, ide_core::error::ErrorKind::Unavailable);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Native notebook documents and owned Jupyter kernel sessions.
|
||||
pub mod document;
|
||||
pub mod kernel;
|
||||
|
||||
pub use document::{NotebookCell, NotebookDocument, NotebookEdit, NotebookSnapshot};
|
||||
@@ -0,0 +1,511 @@
|
||||
use std::{collections::HashSet, path::PathBuf};
|
||||
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_notebook::{NotebookDocument, NotebookEdit, kernel::KernelOutput};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const ORIGINAL: &str = r##"{
|
||||
"unknownRoot": { "escaped": "\u0061", "number": 1e+02 },
|
||||
"nbformat_minor": 5,
|
||||
"metadata": {"custom": [true, {"x": "\u03bb"}]},
|
||||
"cells": [
|
||||
{ "unknownCell": {"keep": "\u0041"}, "source": ["\u0061\n", "b\n", "\u0063"], "id": "first", "metadata": {}, "cell_type": "code", "execution_count": null, "outputs": [] },
|
||||
{ "cell_type": "markdown", "source": "# title", "metadata": {"custom": 3}, "id": "second", "attachments": {"pic.png": {"image/png": "aW1hZ2U="}}, "future": [1, 2] },
|
||||
{ "cell_type": "vendor.future", "source": ["opaque"], "metadata": {}, "id": "third", "vendor": {"raw": "\u0042"} }
|
||||
],
|
||||
"nbformat": 4
|
||||
}"##;
|
||||
|
||||
fn document(text: &str) -> NotebookDocument {
|
||||
NotebookDocument::from_text(PathBuf::from("notebook.ipynb"), text.into()).unwrap()
|
||||
}
|
||||
|
||||
fn apply(doc: &mut NotebookDocument, edit: NotebookEdit) {
|
||||
let version = doc.version();
|
||||
doc.edit(version, edit).unwrap();
|
||||
}
|
||||
|
||||
fn output(kind: &str, content: Value) -> KernelOutput {
|
||||
KernelOutput { kind: kind.into(), content, msg_id: "actual-parent-id".into() }
|
||||
}
|
||||
|
||||
fn emit(doc: &mut NotebookDocument, cell: &str, kind: &str, content: Value) -> Vec<String> {
|
||||
doc.record_output(cell, &output(kind, content)).unwrap()
|
||||
}
|
||||
|
||||
fn two_code_cells() -> NotebookDocument {
|
||||
document(&json!({
|
||||
"nbformat": 4, "nbformat_minor": 5, "metadata": {},
|
||||
"cells": [
|
||||
{"id": "a", "cell_type": "code", "metadata": {}, "source": "a()", "outputs": [], "execution_count": null},
|
||||
{"id": "b", "cell_type": "code", "metadata": {}, "source": ["b()"], "outputs": [], "execution_count": null}
|
||||
]
|
||||
}).to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_save_preserves_all_bytes_for_utf8_bom_crlf_and_no_final_newline() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let variants = [
|
||||
ORIGINAL.to_owned(),
|
||||
format!("{}\n", ORIGINAL),
|
||||
ORIGINAL.replace('\n', "\r\n"),
|
||||
format!("\u{feff}{}\r\n", ORIGINAL.replace('\n', "\r\n")),
|
||||
];
|
||||
for (index, text) in variants.iter().enumerate() {
|
||||
let path = dir.path().join(format!("{index}.ipynb"));
|
||||
std::fs::write(&path, text).unwrap();
|
||||
let mut doc = NotebookDocument::load(&path).unwrap();
|
||||
assert!(!doc.snapshot().dirty);
|
||||
assert_eq!(doc.to_text(), *text);
|
||||
let before = doc.version();
|
||||
doc.save().unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), text.as_bytes());
|
||||
assert_eq!(doc.version(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_list_edit_preserves_unchanged_elements_and_every_other_byte() {
|
||||
let mut doc = document(ORIGINAL);
|
||||
apply(&mut doc, NotebookEdit::SetSource { cell: "first".into(), source: "a\nB\nc".into() });
|
||||
let expected = ORIGINAL.replace(r#"["\u0061\n", "b\n", "\u0063"]"#, r#"["\u0061\n", "B\n", "\u0063"]"#);
|
||||
assert_eq!(doc.to_text(), expected);
|
||||
assert!(doc.snapshot().dirty);
|
||||
let edited_version = doc.version();
|
||||
doc.undo().unwrap();
|
||||
assert_eq!(doc.to_text(), ORIGINAL);
|
||||
assert!(!doc.snapshot().dirty);
|
||||
assert!(doc.version() > edited_version);
|
||||
doc.redo().unwrap();
|
||||
assert_eq!(doc.to_text(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_source_stays_a_string_and_attachments_and_unknown_cells_survive() {
|
||||
let mut doc = document(ORIGINAL);
|
||||
apply(&mut doc, NotebookEdit::SetSource { cell: "second".into(), source: "new\n标题".into() });
|
||||
assert_eq!(doc.to_text(), ORIGINAL.replace(r##""# title""##, r#""new\n标题""#));
|
||||
let parsed: Value = serde_json::from_str(&doc.to_text()).unwrap();
|
||||
assert!(parsed["cells"][1]["source"].is_string());
|
||||
assert_eq!(parsed["cells"][1]["attachments"]["pic.png"]["image/png"], "aW1hZ2U=");
|
||||
assert_eq!(parsed["cells"][2]["vendor"]["raw"], "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_minor_and_invalid_or_duplicate_persisted_ids_are_not_repaired_on_load_or_edit() {
|
||||
let text = r#"{"nbformat":4,"nbformat_minor":2,"metadata":{},"cells":[{"cell_type":"raw","metadata":{},"source":"one","id":"duplicate"},{"cell_type":"raw","metadata":{},"source":"two","id":"duplicate"},{"cell_type":"raw","metadata":{},"source":"three","id":"bad id"},{"cell_type":"raw","metadata":{},"source":"four"}]}"#;
|
||||
let mut doc = document(text);
|
||||
let before = doc.snapshot();
|
||||
assert_eq!(before.cells.iter().map(|cell| &cell.id).collect::<HashSet<_>>().len(), 4);
|
||||
assert_eq!(doc.to_text(), text);
|
||||
apply(&mut doc, NotebookEdit::SetSource { cell: before.cells[3].id.clone(), source: "changed".into() });
|
||||
let after = doc.snapshot();
|
||||
assert_eq!(
|
||||
before.cells.iter().map(|cell| &cell.id).collect::<Vec<_>>(),
|
||||
after.cells.iter().map(|cell| &cell.id).collect::<Vec<_>>()
|
||||
);
|
||||
apply(&mut doc, NotebookEdit::InsertCell { after: None, cell_type: "code".into() });
|
||||
let parsed: Value = serde_json::from_str(&doc.to_text()).unwrap();
|
||||
assert_eq!(parsed["nbformat_minor"], 2);
|
||||
assert!(parsed["cells"][0].get("id").is_none());
|
||||
assert_eq!(parsed["cells"][1]["id"], "duplicate");
|
||||
assert_eq!(parsed["cells"][2]["id"], "duplicate");
|
||||
assert_eq!(parsed["cells"][3]["id"], "bad id");
|
||||
assert!(parsed["cells"][4].get("id").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_cells_get_valid_ids_only_for_minor_five_and_keep_identity_through_history() {
|
||||
let mut doc = document(ORIGINAL);
|
||||
apply(&mut doc, NotebookEdit::InsertCell { after: Some("first".into()), cell_type: "markdown".into() });
|
||||
let inserted = doc.snapshot().cells[1].id.clone();
|
||||
let parsed: Value = serde_json::from_str(&doc.to_text()).unwrap();
|
||||
assert_eq!(parsed["cells"][1]["id"], inserted);
|
||||
assert!(inserted.len() <= 64 && inserted.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'));
|
||||
doc.undo().unwrap();
|
||||
assert_eq!(doc.to_text(), ORIGINAL);
|
||||
doc.redo().unwrap();
|
||||
assert_eq!(doc.snapshot().cells[1].id, inserted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_and_delete_restore_exact_raw_subtrees_and_container_punctuation() {
|
||||
let mut doc = document(ORIGINAL);
|
||||
apply(&mut doc, NotebookEdit::MoveCell { cell: "third".into(), before: Some("first".into()) });
|
||||
assert_eq!(
|
||||
doc.snapshot().cells.iter().map(|cell| cell.id.as_str()).collect::<Vec<_>>(),
|
||||
["third", "first", "second"]
|
||||
);
|
||||
assert!(doc.to_text().contains(r#""vendor": {"raw": "\u0042"}"#));
|
||||
apply(&mut doc, NotebookEdit::DeleteCell { cell: "first".into() });
|
||||
assert_eq!(doc.snapshot().cells.iter().map(|cell| cell.id.as_str()).collect::<Vec<_>>(), ["third", "second"]);
|
||||
doc.undo().unwrap();
|
||||
doc.undo().unwrap();
|
||||
assert_eq!(doc.to_text(), ORIGINAL);
|
||||
doc.redo().unwrap();
|
||||
doc.redo().unwrap();
|
||||
assert_eq!(doc.snapshot().cells.iter().map(|cell| cell.id.as_str()).collect::<Vec<_>>(), ["third", "second"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_last_cell_and_reinsert_into_empty_notebook_are_supported() {
|
||||
let text = r#"{"nbformat":4,"nbformat_minor":5,"metadata":{},"cells":[ {"id":"only","cell_type":"raw","source":"x","metadata":{}} ]}"#;
|
||||
let mut doc = document(text);
|
||||
apply(&mut doc, NotebookEdit::DeleteCell { cell: "only".into() });
|
||||
assert_eq!(serde_json::from_str::<Value>(&doc.to_text()).unwrap()["cells"], json!([]));
|
||||
doc.undo().unwrap();
|
||||
assert_eq!(doc.to_text(), text);
|
||||
apply(&mut doc, NotebookEdit::DeleteCell { cell: "only".into() });
|
||||
apply(&mut doc, NotebookEdit::InsertCell { after: None, cell_type: "code".into() });
|
||||
assert_eq!(doc.snapshot().cells[0].cell_type, "code");
|
||||
assert_eq!(doc.snapshot().cells[0].outputs, Vec::<Value>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_conversion_keeps_unknown_fields_and_restores_removed_fields_exactly() {
|
||||
let text =
|
||||
ORIGINAL.replace("\"outputs\": []", r#""outputs": [{"output_type":"vendor.output","payload":{"x":"\u0061"}}]"#);
|
||||
let mut doc = document(&text);
|
||||
apply(&mut doc, NotebookEdit::SetCellType { cell: "first".into(), cell_type: "markdown".into() });
|
||||
let converted: Value = serde_json::from_str(&doc.to_text()).unwrap();
|
||||
assert!(converted["cells"][0].get("outputs").is_none());
|
||||
assert!(converted["cells"][0].get("execution_count").is_none());
|
||||
assert_eq!(converted["cells"][0]["unknownCell"]["keep"], "A");
|
||||
doc.undo().unwrap();
|
||||
assert_eq!(doc.to_text(), text);
|
||||
apply(&mut doc, NotebookEdit::SetCellType { cell: "second".into(), cell_type: "code".into() });
|
||||
let converted: Value = serde_json::from_str(&doc.to_text()).unwrap();
|
||||
assert_eq!(converted["cells"][1]["outputs"], json!([]));
|
||||
assert_eq!(converted["cells"][1]["execution_count"], Value::Null);
|
||||
assert_eq!(converted["cells"][1]["attachments"]["pic.png"]["image/png"], "aW1hZ2U=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_only_revisions_accept_pending_source_edits_but_stale_user_edits_are_rejected() {
|
||||
let mut doc = two_code_cells();
|
||||
let base = doc.version();
|
||||
emit(&mut doc, "a", "stream", json!({"name":"stdout", "text":"hello"}));
|
||||
assert!(doc.version() > base);
|
||||
doc.edit(base, NotebookEdit::SetSource { cell: "a".into(), source: "new source".into() }).unwrap();
|
||||
assert_eq!(doc.snapshot().cells[0].outputs[0]["text"], "hello");
|
||||
let current = doc.version();
|
||||
let stale = doc.edit(base, NotebookEdit::SetSource { cell: "b".into(), source: "stale".into() }).unwrap_err();
|
||||
assert_eq!(stale.kind, ErrorKind::OutOfSync);
|
||||
assert_eq!(doc.version(), current);
|
||||
let future = doc.edit(current + 1, NotebookEdit::DeleteCell { cell: "a".into() }).unwrap_err();
|
||||
assert_eq!(future.kind, ErrorKind::OutOfSync);
|
||||
assert_eq!(doc.snapshot().cells[1].source, "b()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_undo_preserves_execution_outputs_and_output_does_not_clear_redo() {
|
||||
let mut doc = two_code_cells();
|
||||
let initial_version = doc.version();
|
||||
apply(&mut doc, NotebookEdit::SetSource { cell: "a".into(), source: "print(1)".into() });
|
||||
doc.begin_execution("a").unwrap();
|
||||
emit(&mut doc, "a", "execute_input", json!({"execution_count": 7}));
|
||||
emit(&mut doc, "a", "stream", json!({"name":"stdout", "text":"1\n"}));
|
||||
let output_version = doc.version();
|
||||
doc.undo().unwrap();
|
||||
let state = doc.snapshot();
|
||||
assert_eq!(state.cells[0].source, "a()");
|
||||
assert_eq!(state.cells[0].execution_count, Some(7));
|
||||
assert_eq!(state.cells[0].outputs[0]["text"], "1\n");
|
||||
assert!(state.version > output_version && output_version > initial_version);
|
||||
emit(&mut doc, "a", "stream", json!({"name":"stderr", "text":"later"}));
|
||||
assert!(doc.snapshot().can_redo);
|
||||
doc.redo().unwrap();
|
||||
let state = doc.snapshot();
|
||||
assert_eq!(state.cells[0].source, "print(1)");
|
||||
assert_eq!(state.cells[0].outputs[1]["text"], "later");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_is_rejected_until_execution_batch_is_drained() {
|
||||
let mut doc = two_code_cells();
|
||||
apply(&mut doc, NotebookEdit::SetSource { cell: "a".into(), source: "changed".into() });
|
||||
doc.set_execution_active(true).unwrap();
|
||||
assert!(!doc.snapshot().can_undo);
|
||||
assert_eq!(doc.undo().unwrap_err().kind, ErrorKind::InvalidInput);
|
||||
assert_eq!(doc.redo().unwrap_err().kind, ErrorKind::InvalidInput);
|
||||
assert_eq!(doc.reload().unwrap_err().kind, ErrorKind::InvalidInput);
|
||||
assert!(doc.set_execution_active(true).is_err());
|
||||
doc.set_execution_active(false).unwrap();
|
||||
doc.undo().unwrap();
|
||||
assert_eq!(doc.snapshot().cells[0].source, "a()");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_cell_redo_retains_outputs_produced_after_its_creation() {
|
||||
let mut doc = two_code_cells();
|
||||
apply(&mut doc, NotebookEdit::InsertCell { after: None, cell_type: "code".into() });
|
||||
let id = doc.snapshot().cells[0].id.clone();
|
||||
emit(&mut doc, &id, "stream", json!({"name":"stdout", "text":"keep"}));
|
||||
doc.undo().unwrap();
|
||||
doc.redo().unwrap();
|
||||
assert_eq!(doc.snapshot().cells[0].id, id);
|
||||
assert_eq!(doc.snapshot().cells[0].outputs[0]["text"], "keep");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_updates_reach_earlier_cells_and_never_persist_transient_ids() {
|
||||
let mut doc = two_code_cells();
|
||||
let display = json!({"data":{"text/plain":"old", "vendor/mime":{"arbitrary":true}}, "metadata":{"custom":1}, "transient":{"display_id":"shared"}, "vendorField": 9});
|
||||
emit(&mut doc, "a", "display_data", display.clone());
|
||||
emit(&mut doc, "b", "execute_result", {
|
||||
let mut value = display;
|
||||
value["execution_count"] = json!(3);
|
||||
value
|
||||
});
|
||||
let changed = emit(
|
||||
&mut doc,
|
||||
"b",
|
||||
"update_display_data",
|
||||
json!({"data":{"text/plain":"new"}, "metadata":{"custom":2}, "transient":{"display_id":"shared"}}),
|
||||
);
|
||||
assert_eq!(changed.into_iter().collect::<HashSet<_>>(), HashSet::from(["a".to_owned(), "b".to_owned()]));
|
||||
let state = doc.snapshot();
|
||||
assert_eq!(state.cells[0].outputs[0]["data"]["text/plain"], "new");
|
||||
assert_eq!(state.cells[1].outputs[0]["data"]["text/plain"], "new");
|
||||
assert_eq!(state.cells[0].outputs[0]["vendorField"], 9);
|
||||
assert_eq!(state.cells[1].outputs[0]["execution_count"], 3);
|
||||
assert!(!doc.to_text().contains("display_id"));
|
||||
assert!(!doc.to_text().contains("transient"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_clear_is_applied_only_to_next_output_and_removes_display_targets() {
|
||||
let mut doc = two_code_cells();
|
||||
emit(
|
||||
&mut doc,
|
||||
"a",
|
||||
"display_data",
|
||||
json!({"data":{"text/plain":"old"}, "metadata":{}, "transient":{"display_id":"d"}}),
|
||||
);
|
||||
let version = doc.version();
|
||||
assert!(emit(&mut doc, "a", "clear_output", json!({"wait":true})).is_empty());
|
||||
assert_eq!(doc.version(), version);
|
||||
assert_eq!(doc.snapshot().cells[0].outputs[0]["data"]["text/plain"], "old");
|
||||
emit(&mut doc, "a", "execute_input", json!({"execution_count":2}));
|
||||
assert_eq!(doc.snapshot().cells[0].outputs[0]["data"]["text/plain"], "old");
|
||||
assert_eq!(emit(&mut doc, "a", "stream", json!({"name":"stdout","text":"replacement"})), ["a"]);
|
||||
let state = doc.snapshot();
|
||||
assert_eq!(state.cells[0].outputs, vec![json!({"output_type":"stream", "name":"stdout", "text":"replacement"})]);
|
||||
assert!(
|
||||
emit(
|
||||
&mut doc,
|
||||
"b",
|
||||
"update_display_data",
|
||||
json!({"data":{"text/plain":"not resurrected"}, "metadata":{}, "transient":{"display_id":"d"}})
|
||||
)
|
||||
.is_empty()
|
||||
);
|
||||
emit(&mut doc, "a", "clear_output", json!({"wait":false}));
|
||||
assert_eq!(doc.snapshot().cells[0].outputs, Vec::<Value>::new());
|
||||
assert_eq!(doc.snapshot().cells[0].execution_count, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streams_merge_only_adjacent_same_names_and_errors_and_mime_data_are_retained() {
|
||||
let mut doc = two_code_cells();
|
||||
emit(&mut doc, "a", "stream", json!({"name":"stdout", "text":"one"}));
|
||||
emit(&mut doc, "a", "stream", json!({"name":"stdout", "text":" two"}));
|
||||
emit(&mut doc, "a", "stream", json!({"name":"stderr", "text":"warning"}));
|
||||
emit(&mut doc, "a", "stream", json!({"name":"stdout", "text":"three"}));
|
||||
emit(
|
||||
&mut doc,
|
||||
"a",
|
||||
"error",
|
||||
json!({"ename":"ValueError", "evalue":"bad", "traceback":["line one", "line two"], "vendor":true}),
|
||||
);
|
||||
emit(
|
||||
&mut doc,
|
||||
"a",
|
||||
"display_data",
|
||||
json!({"data":{"image/png":"aW1hZ2U=", "application/vnd.vendor+json":{"x":1}}, "metadata":{"width":100}}),
|
||||
);
|
||||
let state = doc.snapshot();
|
||||
assert_eq!(state.cells[0].outputs[0]["text"], "one two");
|
||||
assert_eq!(state.cells[0].outputs[1]["text"], "warning");
|
||||
assert_eq!(state.cells[0].outputs[2]["text"], "three");
|
||||
assert_eq!(state.cells[0].outputs[3]["traceback"], json!(["line one", "line two"]));
|
||||
assert_eq!(state.cells[0].outputs[3]["vendor"], true);
|
||||
assert_eq!(state.cells[0].outputs[4]["data"]["application/vnd.vendor+json"]["x"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_undo_and_reload_track_the_exact_disk_baseline_and_document_incarnation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("document.ipynb");
|
||||
std::fs::write(&path, ORIGINAL).unwrap();
|
||||
let mut doc = NotebookDocument::load(&path).unwrap();
|
||||
let id = doc.id().to_owned();
|
||||
apply(&mut doc, NotebookEdit::SetSource { cell: "second".into(), source: "saved".into() });
|
||||
let saved = doc.to_text();
|
||||
assert!(!doc.save().unwrap().dirty);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), saved);
|
||||
assert!(doc.undo().unwrap().dirty);
|
||||
assert_eq!(doc.to_text(), ORIGINAL);
|
||||
let version = doc.version();
|
||||
let reloaded = doc.reload().unwrap();
|
||||
assert_eq!(reloaded.id, id);
|
||||
assert!(reloaded.version > version);
|
||||
assert!(!reloaded.dirty && !reloaded.can_undo && !reloaded.can_redo);
|
||||
assert_eq!(doc.to_text(), saved);
|
||||
assert_ne!(NotebookDocument::load(&path).unwrap().id(), id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_conflict_is_detected_even_when_clean_and_failed_save_retains_history() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("conflict.ipynb");
|
||||
std::fs::write(&path, ORIGINAL).unwrap();
|
||||
let mut doc = NotebookDocument::load(&path).unwrap();
|
||||
std::fs::write(&path, format!("{ORIGINAL}\n")).unwrap();
|
||||
assert_eq!(doc.save().unwrap_err().kind, ErrorKind::OutOfSync);
|
||||
apply(&mut doc, NotebookEdit::SetSource { cell: "second".into(), source: "unsaved".into() });
|
||||
let unsaved = doc.to_text();
|
||||
let version = doc.version();
|
||||
assert_eq!(doc.save().unwrap_err().kind, ErrorKind::OutOfSync);
|
||||
assert_eq!(doc.to_text(), unsaved);
|
||||
assert_eq!(doc.version(), version);
|
||||
assert!(doc.snapshot().dirty && doc.snapshot().can_undo);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), format!("{ORIGINAL}\n"));
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
assert_eq!(doc.save().unwrap_err().kind, ErrorKind::OutOfSync);
|
||||
assert!(!path.exists());
|
||||
doc.undo().unwrap();
|
||||
assert_eq!(doc.to_text(), ORIGINAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_source_duplicate_json_keys_unsupported_format_and_invalid_utf8_are_rejected() {
|
||||
for text in [
|
||||
ORIGINAL.replace(r#"["\u0061\n", "b\n", "\u0063"]"#, "[123]"),
|
||||
ORIGINAL.replace("\"nbformat\": 4", "\"nbformat\": 3"),
|
||||
ORIGINAL.replace("\"nbformat\": 4", "\"nbformat\": 4, \"nbformat\": 4"),
|
||||
] {
|
||||
assert!(NotebookDocument::from_text(PathBuf::from("invalid.ipynb"), text).is_err());
|
||||
}
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("invalid.ipynb");
|
||||
std::fs::write(&path, [0xff, 0xfe, 0x80]).unwrap();
|
||||
assert!(NotebookDocument::load(&path).is_err());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), [0xff, 0xfe, 0x80]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_kernel_output_does_not_apply_a_pending_clear_or_advance_revision() {
|
||||
let mut doc = two_code_cells();
|
||||
emit(&mut doc, "a", "stream", json!({"name":"stdout", "text":"keep"}));
|
||||
emit(&mut doc, "a", "clear_output", json!({"wait":true}));
|
||||
let before = doc.to_text();
|
||||
let version = doc.version();
|
||||
let error = doc.record_output("a", &output("display_data", json!({"data": {}}))).unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert_eq!(doc.to_text(), before);
|
||||
assert_eq!(doc.version(), version);
|
||||
emit(&mut doc, "a", "stream", json!({"name":"stdout", "text":"replacement"}));
|
||||
assert_eq!(doc.snapshot().cells[0].outputs[0]["text"], "replacement");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_output_never_recreates_deleted_cells() {
|
||||
let mut doc = two_code_cells();
|
||||
apply(&mut doc, NotebookEdit::DeleteCell { cell: "a".into() });
|
||||
let before = doc.to_text();
|
||||
let error = doc.record_output("a", &output("stream", json!({"name":"stdout", "text":"late"}))).unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::NotFound);
|
||||
assert_eq!(doc.to_text(), before);
|
||||
assert_eq!(doc.snapshot().cells[0].id, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minor_five_existing_duplicate_and_missing_ids_remain_untouched_after_mutation() {
|
||||
let text = ORIGINAL.replace("\"id\": \"second\"", "\"id\": \"first\"").replace(", \"id\": \"third\"", "");
|
||||
let mut doc = document(&text);
|
||||
let state = doc.snapshot();
|
||||
assert_eq!(state.cells.iter().map(|cell| &cell.id).collect::<HashSet<_>>().len(), 3);
|
||||
assert_eq!(doc.to_text(), text);
|
||||
apply(&mut doc, NotebookEdit::SetSource { cell: state.cells[0].id.clone(), source: "changed".into() });
|
||||
apply(&mut doc, NotebookEdit::InsertCell { after: Some(state.cells[2].id.clone()), cell_type: "raw".into() });
|
||||
let parsed: Value = serde_json::from_str(&doc.to_text()).unwrap();
|
||||
assert_eq!(parsed["cells"][0]["id"], "first");
|
||||
assert_eq!(parsed["cells"][1]["id"], "first");
|
||||
assert!(parsed["cells"][2].get("id").is_none());
|
||||
assert_eq!(parsed["cells"][3]["id"], doc.snapshot().cells[3].id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_crlf_bom_notebook_preserves_every_unaffected_byte() {
|
||||
let text = format!("\u{feff}{}\r\n", ORIGINAL.replace('\n', "\r\n"));
|
||||
let mut doc = document(&text);
|
||||
apply(&mut doc, NotebookEdit::SetSource { cell: "second".into(), source: "edited".into() });
|
||||
assert_eq!(doc.to_text(), text.replace(r##""# title""##, r#""edited""#));
|
||||
doc.undo().unwrap();
|
||||
assert_eq!(doc.to_text(), text);
|
||||
assert!(!doc.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ineffective_edits_do_not_create_dirty_state_history_or_revisions() {
|
||||
let mut doc = two_code_cells();
|
||||
let version = doc.version();
|
||||
apply(&mut doc, NotebookEdit::SetSource { cell: "a".into(), source: "a()".into() });
|
||||
apply(&mut doc, NotebookEdit::SetCellType { cell: "a".into(), cell_type: "code".into() });
|
||||
apply(&mut doc, NotebookEdit::MoveCell { cell: "a".into(), before: Some("a".into()) });
|
||||
apply(&mut doc, NotebookEdit::MoveCell { cell: "b".into(), before: None });
|
||||
doc.begin_execution("a").unwrap();
|
||||
assert!(emit(&mut doc, "a", "clear_output", json!({"wait":false})).is_empty());
|
||||
let state = doc.snapshot();
|
||||
assert_eq!(state.version, version);
|
||||
assert!(!state.dirty && !state.can_undo && !state.can_redo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_output_replacement_strips_transient_fields_without_discarding_user_redo() {
|
||||
let mut doc = two_code_cells();
|
||||
apply(&mut doc, NotebookEdit::SetSource { cell: "a".into(), source: "changed".into() });
|
||||
doc.undo().unwrap();
|
||||
let base = doc.version();
|
||||
apply(
|
||||
&mut doc,
|
||||
NotebookEdit::SetOutputs {
|
||||
cell: "a".into(),
|
||||
execution_count: Some(4),
|
||||
outputs: vec![
|
||||
json!({"output_type":"vendor.output", "payload":{"keep":true}, "transient":{"display_id":"not persisted"}}),
|
||||
],
|
||||
},
|
||||
);
|
||||
assert!(doc.version() > base);
|
||||
assert!(doc.snapshot().can_redo);
|
||||
assert!(!doc.to_text().contains("transient"));
|
||||
doc.edit(base, NotebookEdit::SetSource { cell: "b".into(), source: "accepted across output-only revision".into() })
|
||||
.unwrap();
|
||||
assert_eq!(doc.snapshot().cells[0].outputs[0]["payload"]["keep"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_stored_outputs_and_failed_output_edits_never_modify_the_document() {
|
||||
let text =
|
||||
ORIGINAL.replace("\"outputs\": []", r#""outputs": [{"output_type":"stream","name":"stdout","text":[123]}]"#);
|
||||
assert!(NotebookDocument::from_text(PathBuf::from("invalid.ipynb"), text).is_err());
|
||||
let mut doc = two_code_cells();
|
||||
let before = doc.to_text();
|
||||
let version = doc.version();
|
||||
let error = doc
|
||||
.edit(
|
||||
version,
|
||||
NotebookEdit::SetOutputs {
|
||||
cell: "a".into(),
|
||||
execution_count: Some(1),
|
||||
outputs: vec![json!({"output_type":"error","ename":"E","evalue":"bad","traceback":[42]})],
|
||||
},
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert_eq!(doc.to_text(), before);
|
||||
assert_eq!(doc.version(), version);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Opt-in native acceptance tests. Set OPENIDEA_TEST_PYTHON to an absolute
|
||||
//! ipykernel-enabled interpreter and run this target with --ignored.
|
||||
//! These tests execute real Python over authenticated native ZeroMQ, no server.
|
||||
|
||||
use ide_core::{CancellationToken, IdeResult, error::ErrorKind};
|
||||
use ide_notebook::kernel::{ExecutionResult, KernelManager, KernelOutput, KernelState};
|
||||
use serde_json::Value;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
type Outputs = Arc<Mutex<Vec<KernelOutput>>>;
|
||||
|
||||
fn python() -> PathBuf {
|
||||
let path = PathBuf::from(
|
||||
std::env::var_os("OPENIDEA_TEST_PYTHON").expect("set OPENIDEA_TEST_PYTHON to a real ipykernel Python"),
|
||||
);
|
||||
assert!(path.is_absolute());
|
||||
path
|
||||
}
|
||||
|
||||
fn manager(root: &Path) -> Arc<KernelManager> {
|
||||
Arc::new(KernelManager::new(root.join("runtime"), Arc::new(|_, _, _| {})))
|
||||
}
|
||||
|
||||
async fn run(
|
||||
manager: &KernelManager,
|
||||
notebook: &Path,
|
||||
python: &Path,
|
||||
code: &str,
|
||||
) -> (IdeResult<ExecutionResult>, Vec<KernelOutput>) {
|
||||
let outputs: Outputs = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = outputs.clone();
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(45),
|
||||
manager.execute(
|
||||
notebook,
|
||||
python,
|
||||
code,
|
||||
CancellationToken::new(),
|
||||
Arc::new(move |output| sink.lock().unwrap().push(output)),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("real kernel request exceeded test deadline");
|
||||
let outputs = std::mem::take(&mut *outputs.lock().unwrap());
|
||||
(result, outputs)
|
||||
}
|
||||
|
||||
fn json_display(outputs: &[KernelOutput]) -> &Value {
|
||||
&outputs
|
||||
.iter()
|
||||
.find(|output| output.kind == "display_data" && output.content["data"]["application/json"].is_object())
|
||||
.expect("expected native JSON display output")
|
||||
.content["data"]["application/json"]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_before_admission_cannot_become_an_interpreter_error() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let manager = manager(root.path());
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
let result = manager
|
||||
.execute(
|
||||
&root.path().join("cancelled.ipynb"),
|
||||
&root.path().join("does-not-exist"),
|
||||
"raise RuntimeError('must never execute')",
|
||||
cancel,
|
||||
Arc::new(|_| panic!("cancelled code emitted output")),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires OPENIDEA_TEST_PYTHON with real ipykernel"]
|
||||
async fn real_kernel_preserves_state_interpreter_mime_and_request_identity() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let statuses = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = statuses.clone();
|
||||
let manager =
|
||||
KernelManager::new(root.path().join("runtime"), Arc::new(move |_, state, _| sink.lock().unwrap().push(state)));
|
||||
let notebook = root.path().join("native.ipynb");
|
||||
let python = python();
|
||||
let (result, outputs) = run(&manager, ¬ebook, &python, r#"
|
||||
import os, sys
|
||||
from IPython.display import display, update_display, clear_output
|
||||
persisted_value = 41
|
||||
print('native stdout', flush=True)
|
||||
print('native stderr', file=sys.stderr, flush=True)
|
||||
display({'application/x-openidea-test': {'opaque': [1, {'unknown': 'kept'}]}, 'text/plain': 'custom'}, raw=True, display_id='stable-display')
|
||||
update_display({'text/plain': 'updated'}, raw=True, display_id='stable-display')
|
||||
clear_output(wait=True)
|
||||
display({'text/html': '<table><tr><td>42</td></tr></table>', 'image/svg+xml': '<svg xmlns="http://www.w3.org/2000/svg"/>'}, raw=True)
|
||||
persisted_value + 1
|
||||
"#).await;
|
||||
let result = result.unwrap();
|
||||
assert_eq!(result.status, "ok");
|
||||
let request = &outputs.iter().find(|o| o.kind == "execute_input").unwrap().msg_id;
|
||||
assert!(!request.is_empty(), "Jupyter must supply a real parent request ID");
|
||||
assert!(outputs.iter().all(|o| &o.msg_id == request && o.kind != "status"));
|
||||
assert!(outputs.iter().any(|o| o.kind == "stream"
|
||||
&& o.content["name"] == "stdout"
|
||||
&& o.content["text"].as_str().unwrap().contains("native stdout")));
|
||||
assert!(outputs.iter().any(|o| o.kind == "stream"
|
||||
&& o.content["name"] == "stderr"
|
||||
&& o.content["text"].as_str().unwrap().contains("native stderr")));
|
||||
assert!(outputs.iter().any(|o| o.content["data"]["application/x-openidea-test"]["opaque"][1]["unknown"] == "kept"));
|
||||
assert!(
|
||||
outputs
|
||||
.iter()
|
||||
.any(|o| o.kind == "update_display_data" && o.content["transient"]["display_id"] == "stable-display")
|
||||
);
|
||||
assert!(outputs.iter().any(|o| o.kind == "clear_output" && o.content["wait"] == true));
|
||||
assert!(outputs.iter().any(|o| o.kind == "execute_result" && o.content["data"]["text/plain"] == "42"));
|
||||
let (result, next) = run(&manager, ¬ebook, &python,
|
||||
"display({'application/json': {'value': persisted_value, 'executable': sys.executable, 'cwd': os.getcwd()}}, raw=True)").await;
|
||||
assert_eq!(result.unwrap().status, "ok");
|
||||
let value = json_display(&next);
|
||||
assert_eq!(value["value"], 41);
|
||||
assert_eq!(
|
||||
std::fs::canonicalize(value["executable"].as_str().unwrap()).unwrap(),
|
||||
std::fs::canonicalize(&python).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::canonicalize(value["cwd"].as_str().unwrap()).unwrap(),
|
||||
std::fs::canonicalize(root.path()).unwrap()
|
||||
);
|
||||
assert!(next.iter().all(|o| o.msg_id != *request));
|
||||
let (result, errors) = run(&manager, ¬ebook, &python, "input('must not hang')").await;
|
||||
assert_eq!(result.unwrap().status, "error");
|
||||
assert!(errors.iter().any(|o| o.kind == "error" && o.content["ename"] == "StdinNotImplementedError"));
|
||||
let states = statuses.lock().unwrap().clone();
|
||||
assert_eq!(states.first(), Some(&KernelState::Starting));
|
||||
assert!(states.windows(2).any(|pair| pair == [KernelState::Busy, KernelState::Idle]));
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
async fn long_run(
|
||||
manager: Arc<KernelManager>,
|
||||
notebook: PathBuf,
|
||||
python: PathBuf,
|
||||
cancel: CancellationToken,
|
||||
catches_interrupt: bool,
|
||||
) -> (tokio::task::JoinHandle<IdeResult<ExecutionResult>>, oneshot::Receiver<()>) {
|
||||
let (ready, received) = oneshot::channel();
|
||||
let ready = Mutex::new(Some(ready));
|
||||
let code = if catches_interrupt {
|
||||
"import time, os\nkept_after_interrupt = 123\ntry:\n print('INTERRUPT-READY', flush=True)\n while True: time.sleep(0.01)\nexcept KeyboardInterrupt:\n caught_interrupt = True\n"
|
||||
} else {
|
||||
"import time, os\nkept_after_interrupt = 123\nprint('INTERRUPT-READY', flush=True)\nwhile True: time.sleep(0.01)\n"
|
||||
};
|
||||
let task = tokio::spawn(async move {
|
||||
manager
|
||||
.execute(
|
||||
¬ebook,
|
||||
&python,
|
||||
code,
|
||||
cancel,
|
||||
Arc::new(move |output| {
|
||||
if output.kind == "stream"
|
||||
&& output.content["text"].as_str().is_some_and(|s| s.contains("INTERRUPT-READY"))
|
||||
&& let Some(ready) = ready.lock().unwrap().take()
|
||||
{
|
||||
let _ = ready.send(());
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
});
|
||||
(task, received)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires real ipykernel; Windows must exercise JPY_INTERRUPT_EVENT"]
|
||||
async fn real_interrupt_drains_and_keeps_interpreter_state() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let manager = manager(root.path());
|
||||
let notebook = root.path().join("interrupt.ipynb");
|
||||
let python = python();
|
||||
let (task, ready) =
|
||||
long_run(manager.clone(), notebook.clone(), python.clone(), CancellationToken::new(), false).await;
|
||||
tokio::time::timeout(Duration::from_secs(45), ready).await.unwrap().unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(10), manager.interrupt(¬ebook)).await.unwrap().unwrap();
|
||||
assert_eq!(task.await.unwrap().unwrap_err().kind, ErrorKind::Cancelled);
|
||||
let (result, outputs) = run(
|
||||
&manager,
|
||||
¬ebook,
|
||||
&python,
|
||||
"from IPython.display import display\ndisplay({'application/json': {'kept': kept_after_interrupt}}, raw=True)",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap().status, "ok");
|
||||
assert_eq!(json_display(&outputs)["kept"], 123, "interrupt must not emulate success by restarting Python");
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires OPENIDEA_TEST_PYTHON with real ipykernel"]
|
||||
async fn token_cancellation_is_not_success_when_python_catches_keyboard_interrupt() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let manager = manager(root.path());
|
||||
let notebook = root.path().join("catch.ipynb");
|
||||
let python = python();
|
||||
let cancel = CancellationToken::new();
|
||||
let (task, ready) = long_run(manager.clone(), notebook.clone(), python.clone(), cancel.clone(), true).await;
|
||||
tokio::time::timeout(Duration::from_secs(45), ready).await.unwrap().unwrap();
|
||||
cancel.cancel();
|
||||
let error = tokio::time::timeout(Duration::from_secs(10), task).await.unwrap().unwrap().unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::Cancelled);
|
||||
let (result, outputs) = run(&manager, ¬ebook, &python,
|
||||
"from IPython.display import display\ndisplay({'application/json': {'caught': caught_interrupt, 'kept': kept_after_interrupt}}, raw=True)").await;
|
||||
assert_eq!(result.unwrap().status, "ok");
|
||||
assert_eq!(json_display(&outputs)["caught"], true);
|
||||
assert_eq!(json_display(&outputs)["kept"], 123);
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires OPENIDEA_TEST_PYTHON with real ipykernel"]
|
||||
async fn stale_parent_output_is_dropped_and_restart_invalidates_state() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let manager = manager(root.path());
|
||||
let notebook = root.path().join("generation.ipynb");
|
||||
let python = python();
|
||||
let (result, _) = run(
|
||||
&manager,
|
||||
¬ebook,
|
||||
&python,
|
||||
r#"
|
||||
import threading, os
|
||||
from IPython.display import display
|
||||
kernel = get_ipython().kernel
|
||||
old_parent = kernel.get_parent()
|
||||
old_release = threading.Event()
|
||||
old_done = threading.Event()
|
||||
old_state = 99
|
||||
def old_worker():
|
||||
old_release.wait()
|
||||
kernel.session.send(kernel.iopub_socket, 'stream',
|
||||
{'name': 'stdout', 'text': 'OLD-GENERATION\n'},
|
||||
parent=old_parent)
|
||||
old_done.set()
|
||||
threading.Thread(target=old_worker, daemon=True).start()
|
||||
"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(result.unwrap().status, "ok");
|
||||
// Send an explicitly old protocol parent during the next execution.
|
||||
// Ordinary thread print() can inherit ipykernel's current global parent.
|
||||
let (result, outputs) = run(&manager, ¬ebook, &python,
|
||||
"old_release.set()\nassert old_done.wait(5)\nprint('CURRENT-GENERATION', flush=True)\ndisplay({'application/json': {'pid': os.getpid()}}, raw=True)").await;
|
||||
assert_eq!(result.unwrap().status, "ok");
|
||||
assert!(!outputs.iter().any(|o| o.content["text"].as_str().is_some_and(|s| s.contains("OLD-GENERATION"))));
|
||||
assert!(outputs.iter().any(|o| o.content["text"].as_str().is_some_and(|s| s.contains("CURRENT-GENERATION"))));
|
||||
let old_pid = json_display(&outputs)["pid"].as_u64().unwrap();
|
||||
manager.restart(¬ebook).await.unwrap();
|
||||
let (result, outputs) = run(&manager, ¬ebook, &python,
|
||||
"import os\nfrom IPython.display import display\ndisplay({'application/json': {'pid': os.getpid(), 'old': 'old_state' in globals()}}, raw=True)").await;
|
||||
assert_eq!(result.unwrap().status, "ok");
|
||||
assert_ne!(json_display(&outputs)["pid"], old_pid);
|
||||
assert_eq!(json_display(&outputs)["old"], false);
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires OPENIDEA_TEST_PYTHON with real ipykernel"]
|
||||
async fn process_death_and_output_overflow_fail_explicitly_and_can_restart() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let manager = manager(root.path());
|
||||
let notebook = root.path().join("death.ipynb");
|
||||
let python = python();
|
||||
let (result, _) = run(&manager, ¬ebook, &python, "import os\nos._exit(23)").await;
|
||||
assert!(result.is_err(), "owned process death cannot report execute success");
|
||||
manager.restart(¬ebook).await.unwrap();
|
||||
let (result, _) = run(
|
||||
&manager,
|
||||
¬ebook,
|
||||
&python,
|
||||
"from IPython.display import display\ndisplay({'text/plain': 'x' * (9 * 1024 * 1024)}, raw=True)",
|
||||
)
|
||||
.await;
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::Unavailable);
|
||||
assert!(error.message.contains("limit") || error.message.contains("overflow") || error.message.contains("exceed"));
|
||||
manager.restart(¬ebook).await.unwrap();
|
||||
let (result, outputs) = run(&manager, ¬ebook, &python, "print('RECOVERED', flush=True)").await;
|
||||
assert_eq!(result.unwrap().status, "ok");
|
||||
assert!(outputs.iter().any(|o| o.content["text"].as_str().is_some_and(|s| s.contains("RECOVERED"))));
|
||||
manager.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires OPENIDEA_TEST_PYTHON and OPENIDEA_TEST_PYTHON_SECOND"]
|
||||
async fn changing_selected_python_recreates_kernel_instead_of_reusing_old_environment() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let manager = manager(root.path());
|
||||
let notebook = root.path().join("selection.ipynb");
|
||||
let first = python();
|
||||
let second = PathBuf::from(
|
||||
std::env::var_os("OPENIDEA_TEST_PYTHON_SECOND").expect("set a second absolute ipykernel interpreter path"),
|
||||
);
|
||||
assert_ne!(first, second);
|
||||
let (result, outputs) = run(&manager, ¬ebook, &first,
|
||||
"import os\nfrom IPython.display import display\nold_environment_state = 1\ndisplay({'application/json': {'pid': os.getpid()}}, raw=True)").await;
|
||||
assert_eq!(result.unwrap().status, "ok");
|
||||
let first_pid = json_display(&outputs)["pid"].as_u64().unwrap();
|
||||
let (result, outputs) = run(&manager, ¬ebook, &second,
|
||||
"import os, sys\nfrom IPython.display import display\ndisplay({'application/json': {'pid': os.getpid(), 'executable': sys.executable, 'old': 'old_environment_state' in globals()}}, raw=True)").await;
|
||||
assert_eq!(result.unwrap().status, "ok");
|
||||
let data = json_display(&outputs);
|
||||
assert_ne!(data["pid"], first_pid);
|
||||
assert_eq!(data["old"], false);
|
||||
assert_eq!(
|
||||
std::fs::canonicalize(data["executable"].as_str().unwrap()).unwrap(),
|
||||
std::fs::canonicalize(second).unwrap()
|
||||
);
|
||||
manager.shutdown().await;
|
||||
}
|
||||
@@ -226,6 +226,17 @@ pub(crate) fn is_atomic_temp(path: &Path) -> bool {
|
||||
/// writes through symlinks. Falls back to an in-place write when the directory
|
||||
/// does not allow creating files. Returns the new mtime in ms.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> IdeResult<f64> {
|
||||
write_atomic_impl(path, bytes, true)
|
||||
}
|
||||
|
||||
/// Replaces a file atomically without falling back to truncating it in place.
|
||||
/// Preserves existing permissions and writes through symlinks; if a sibling
|
||||
/// temporary file cannot be created, leaves the existing file untouched.
|
||||
pub fn write_atomic_strict(path: &Path, bytes: &[u8]) -> IdeResult<f64> {
|
||||
write_atomic_impl(path, bytes, false)
|
||||
}
|
||||
|
||||
fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool) -> IdeResult<f64> {
|
||||
let target = match fs::symlink_metadata(path) {
|
||||
Ok(m) if m.file_type().is_symlink() => fs::canonicalize(path)?,
|
||||
_ => path.to_path_buf(),
|
||||
@@ -235,10 +246,19 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> IdeResult<f64> {
|
||||
return Err(IdeError::invalid(format!("{} is a directory", target.display())));
|
||||
}
|
||||
let tmp = temp_path_for(&target);
|
||||
let file = fs::OpenOptions::new().write(true).create_new(true).open(&tmp);
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
if existing.is_some() || !allow_in_place {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
// Replacement bytes must not become readable before the final
|
||||
// permissions are restored, even when the directory has a loose umask.
|
||||
options.mode(0o600);
|
||||
}
|
||||
let file = options.open(&tmp);
|
||||
let mut file = match file {
|
||||
Ok(f) => f,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied && existing.is_some() => {
|
||||
Err(e) if allow_in_place && e.kind() == std::io::ErrorKind::PermissionDenied && existing.is_some() => {
|
||||
tracing::debug!("cannot create temp file next to {}: {e}; writing in place", target.display());
|
||||
fs::write(&target, bytes)?;
|
||||
return Ok(mtime_ms(&fs::metadata(&target)?));
|
||||
@@ -265,6 +285,61 @@ pub fn write_atomic(path: &Path, bytes: &[u8]) -> IdeResult<f64> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strict_write_replaces_complete_contents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("notebook.ipynb");
|
||||
fs::write(&path, b"original, longer contents").unwrap();
|
||||
|
||||
write_atomic_strict(&path, b"{\"cells\":[]}").unwrap();
|
||||
|
||||
assert_eq!(fs::read(&path).unwrap(), b"{\"cells\":[]}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn strict_write_preserves_symlink_and_target_permissions() {
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let target = dir.path().join("target.ipynb");
|
||||
let link = dir.path().join("link.ipynb");
|
||||
fs::write(&target, b"old").unwrap();
|
||||
fs::set_permissions(&target, fs::Permissions::from_mode(0o640)).unwrap();
|
||||
symlink(&target, &link).unwrap();
|
||||
|
||||
write_atomic_strict(&link, b"new notebook").unwrap();
|
||||
|
||||
assert!(fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
|
||||
assert_eq!(fs::read(&target).unwrap(), b"new notebook");
|
||||
assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o640);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn strict_write_does_not_truncate_when_sibling_creation_is_denied() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("notebook.ipynb");
|
||||
fs::write(&path, b"original notebook").unwrap();
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o666)).unwrap();
|
||||
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o555)).unwrap();
|
||||
// Privileged test runners can bypass directory permissions.
|
||||
let probe = dir.path().join("probe");
|
||||
if fs::File::create(&probe).is_ok() {
|
||||
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::remove_file(probe).unwrap();
|
||||
return;
|
||||
}
|
||||
|
||||
let result = write_atomic_strict(&path, b"replacement");
|
||||
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
assert_eq!(result.unwrap_err().kind, ide_core::error::ErrorKind::PermissionDenied);
|
||||
assert_eq!(fs::read(&path).unwrap(), b"original notebook");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_variants() {
|
||||
let (t, e, bom, bin) = decode(b"\xEF\xBB\xBFhi");
|
||||
|
||||
@@ -21,7 +21,9 @@ pub use archive::{
|
||||
};
|
||||
pub use bytes::{ByteRange, FileProbe, MAX_RANGE_LEN, base64, image_mime, probe, read_data_url, read_range};
|
||||
use encoding::SNIFF_LEN;
|
||||
pub use encoding::{LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, encode, write_atomic};
|
||||
pub use encoding::{
|
||||
LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, encode, write_atomic, write_atomic_strict,
|
||||
};
|
||||
pub use rules::ExcludeMatcher;
|
||||
pub use watch::{WatchMode, Watcher};
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ ide-spell.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-remote.workspace = true
|
||||
ide-host.workspace = true
|
||||
ide-notebook.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-tools = { path = "../crates/ide-tools" }
|
||||
ide-plugin = { path = "../crates/ide-plugin" }
|
||||
|
||||
@@ -28,6 +28,7 @@ pub mod http;
|
||||
pub mod java;
|
||||
pub mod lsp;
|
||||
pub mod nav;
|
||||
pub mod notebook;
|
||||
pub mod perf;
|
||||
pub mod plugin;
|
||||
pub mod profiler;
|
||||
@@ -96,6 +97,17 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
agent::agent_delete,
|
||||
agent::agent_changes,
|
||||
agent::agent_base_text,
|
||||
notebook::notebook_open,
|
||||
notebook::notebook_get,
|
||||
notebook::notebook_edit,
|
||||
notebook::notebook_save,
|
||||
notebook::notebook_reload,
|
||||
notebook::notebook_undo,
|
||||
notebook::notebook_redo,
|
||||
notebook::notebook_close,
|
||||
notebook::notebook_execute,
|
||||
notebook::notebook_interrupt,
|
||||
notebook::notebook_restart,
|
||||
fs::fs_read_dir,
|
||||
bytes::fs_probe,
|
||||
bytes::fs_read_range,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Thin desktop adapters for the shared native notebook service.
|
||||
use crate::state::AppState;
|
||||
use ide_core::{IdeResult, RequestId};
|
||||
use ide_host::ops::notebook::{self, NotebookOutputEvent};
|
||||
use ide_notebook::{NotebookEdit, NotebookSnapshot};
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{State, Window};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn notebook_open(window: Window, state: State<'_, AppState>, path: String) -> IdeResult<NotebookSnapshot> {
|
||||
notebook::open(&state.host, window.label(), path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn notebook_get(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
notebook::get(&state.host, window.label(), id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn notebook_edit(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
version: u32,
|
||||
edit: NotebookEdit,
|
||||
) -> IdeResult<NotebookSnapshot> {
|
||||
notebook::edit(&state.host, window.label(), id, version, edit).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn notebook_save(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
notebook::save(&state.host, window.label(), id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn notebook_reload(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
notebook::reload(&state.host, window.label(), id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn notebook_undo(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
notebook::undo(&state.host, window.label(), id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn notebook_redo(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
notebook::redo(&state.host, window.label(), id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn notebook_close(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<()> {
|
||||
notebook::close(&state.host, window.label(), id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn notebook_interrupt(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<()> {
|
||||
notebook::interrupt(&state.host, window.label(), id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn notebook_restart(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
notebook::restart(&state.host, window.label(), id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn notebook_execute(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
cells: Vec<String>,
|
||||
python: Option<String>,
|
||||
request: RequestId,
|
||||
on_output: Channel<NotebookOutputEvent>,
|
||||
) -> IdeResult<NotebookSnapshot> {
|
||||
notebook::execute(&state.host, window.label(), id, cells, python, request, super::sender(on_output)).await
|
||||
}
|
||||
@@ -139,11 +139,14 @@ pub fn run() {
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = &early_opens;
|
||||
// Quit after installing an update (`app_install_update`): relaunch.
|
||||
if let tauri::RunEvent::Exit = event
|
||||
&& app.try_state::<WindowsState>().is_some_and(|w| w.restart_requested())
|
||||
{
|
||||
tauri::process::restart(&app.env());
|
||||
if let tauri::RunEvent::Exit = event {
|
||||
if let Some(state) = app.try_state::<AppState>() {
|
||||
state.host.shutdown_notebooks_on_exit();
|
||||
}
|
||||
// Cleanup must finish before update/restart replaces this process.
|
||||
if app.try_state::<WindowsState>().is_some_and(|w| w.restart_requested()) {
|
||||
tauri::process::restart(&app.env());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+56
-1
@@ -116,6 +116,17 @@ export const commands = {
|
||||
agentChanges: (task: string) => typedError<AgentChange[], IdeError>(__TAURI_INVOKE("agent_changes", { task })),
|
||||
/** Content of `path` when the task started (left side of its diff). */
|
||||
agentBaseText: (task: string, path: IpcPath) => typedError<string | null, IdeError>(__TAURI_INVOKE("agent_base_text", { task, path })),
|
||||
notebookOpen: (path: string) => typedError<NotebookSnapshot, IdeError>(__TAURI_INVOKE("notebook_open", { path })),
|
||||
notebookGet: (id: string) => typedError<NotebookSnapshot, IdeError>(__TAURI_INVOKE("notebook_get", { id })),
|
||||
notebookEdit: (id: string, version: number, edit: NotebookEdit) => typedError<NotebookSnapshot, IdeError>(__TAURI_INVOKE("notebook_edit", { id, version, edit })),
|
||||
notebookSave: (id: string) => typedError<NotebookSnapshot, IdeError>(__TAURI_INVOKE("notebook_save", { id })),
|
||||
notebookReload: (id: string) => typedError<NotebookSnapshot, IdeError>(__TAURI_INVOKE("notebook_reload", { id })),
|
||||
notebookUndo: (id: string) => typedError<NotebookSnapshot, IdeError>(__TAURI_INVOKE("notebook_undo", { id })),
|
||||
notebookRedo: (id: string) => typedError<NotebookSnapshot, IdeError>(__TAURI_INVOKE("notebook_redo", { id })),
|
||||
notebookClose: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("notebook_close", { id })),
|
||||
notebookExecute: (id: string, cells: string[], python: string | null, request: RequestId, onOutput: Channel<NotebookOutputEvent>) => typedError<NotebookSnapshot, IdeError>(__TAURI_INVOKE("notebook_execute", { id, cells, python, request, onOutput })),
|
||||
notebookInterrupt: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("notebook_interrupt", { id })),
|
||||
notebookRestart: (id: string) => typedError<NotebookSnapshot, IdeError>(__TAURI_INVOKE("notebook_restart", { id })),
|
||||
fsReadDir: (path: string) => typedError<FileEntry[], IdeError>(__TAURI_INVOKE("fs_read_dir", { path })),
|
||||
/** Size and text/binary classification, from the file's head only. */
|
||||
fsProbe: (path: string) => typedError<FileProbe, IdeError>(__TAURI_INVOKE("fs_probe", { path })),
|
||||
@@ -3327,7 +3338,14 @@ cancellable: boolean } |
|
||||
/** A child process (`ssh`, `git`) asks for a credential; answered with `ssh_askpass_answer`. */
|
||||
{ type: "askpass"; id: number; prompt: string; kind: AskpassKind } |
|
||||
/** Prompt `id` was answered (in any window) or gave up. */
|
||||
{ type: "askpassDone"; id: number };
|
||||
{ type: "askpassDone"; id: number } |
|
||||
/**
|
||||
* The kernel of an open notebook changed state (`starting`, `idle`, `busy`,
|
||||
* `dead`); `message` says why it failed to start or died.
|
||||
*/
|
||||
{ type: "notebookKernel";
|
||||
/** Backend document incarnation; stale queued events cannot target a reopen. */
|
||||
id: string; path: IpcPath; state: string; message: string | null };
|
||||
|
||||
/** An identifier-like token whose text equals the searched name. */
|
||||
export type IdentifierOccurrence = {
|
||||
@@ -4168,6 +4186,43 @@ export type NotLocalReason =
|
||||
*/
|
||||
"unresolved";
|
||||
|
||||
export type NotebookCell = {
|
||||
id: string,
|
||||
cellType: string,
|
||||
source: string,
|
||||
metadata: unknown,
|
||||
outputs: unknown[],
|
||||
executionCount: number | null,
|
||||
attachments: unknown,
|
||||
};
|
||||
|
||||
export type NotebookEdit = { type: "setSource"; cell: string; source: string } | { type: "insertCell"; after: string | null; cellType: string } | { type: "deleteCell"; cell: string } | { type: "moveCell"; cell: string; before: string | null } | { type: "setCellType"; cell: string; cellType: string } | { type: "setOutputs"; cell: string; outputs: unknown[]; executionCount: number | null };
|
||||
|
||||
export type NotebookOutputEvent = {
|
||||
id: string,
|
||||
path: string,
|
||||
cell: string,
|
||||
msgId: string,
|
||||
kind: string,
|
||||
content: unknown,
|
||||
version: number,
|
||||
dirty: boolean,
|
||||
changedCells: string[],
|
||||
};
|
||||
|
||||
export type NotebookSnapshot = {
|
||||
id: string,
|
||||
path: string,
|
||||
version: number,
|
||||
dirty: boolean,
|
||||
canUndo: boolean,
|
||||
canRedo: boolean,
|
||||
nbformat: number,
|
||||
nbformatMinor: number,
|
||||
metadata: unknown,
|
||||
cells: NotebookCell[],
|
||||
};
|
||||
|
||||
export type NotificationGroupSettings = {
|
||||
/** `balloon` | `sticky` | `none` */
|
||||
display?: string,
|
||||
|
||||
@@ -805,16 +805,18 @@ export function registerEditorActions(): () => void {
|
||||
category: "Window",
|
||||
icon: "close",
|
||||
when: "(editorTabsOpen || editorSplit) && !toolWindowFocus",
|
||||
run: () => ctrl.closeActiveTab(),
|
||||
run: async () => {
|
||||
await ctrl.closeActiveTab();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "editor.closeOtherTabs",
|
||||
title: "Close Other Tabs",
|
||||
category: "Window",
|
||||
when: TABS_WHEN,
|
||||
run: () => {
|
||||
run: async () => {
|
||||
const t = activePaneTab();
|
||||
if (t) ctrl.closeOthers(t.paneId, t.tabId);
|
||||
if (t) await ctrl.closeOthers(t.paneId, t.tabId);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -822,7 +824,9 @@ export function registerEditorActions(): () => void {
|
||||
title: "Close All Tabs",
|
||||
category: "Window",
|
||||
when: "editorTabsOpen",
|
||||
run: () => ctrl.closeEverything(),
|
||||
run: async () => {
|
||||
await ctrl.closeEverything();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "editor.pinTab",
|
||||
@@ -896,14 +900,16 @@ export function registerEditorActions(): () => void {
|
||||
title: "Unsplit",
|
||||
category: "Window",
|
||||
when: "editorSplit",
|
||||
run: () => ctrl.unsplit(useTabs.getState().activePane),
|
||||
run: async () => {
|
||||
await ctrl.unsplit(useTabs.getState().activePane);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "editor.unsplitAll",
|
||||
title: "Unsplit All",
|
||||
category: "Window",
|
||||
when: "editorSplit",
|
||||
run: () => ctrl.unsplitAll(useTabs.getState().activePane),
|
||||
run: () => void ctrl.unsplitAll(useTabs.getState().activePane),
|
||||
},
|
||||
{
|
||||
id: "editor.changeSplitOrientation",
|
||||
|
||||
@@ -166,7 +166,7 @@ describe("EditorController", () => {
|
||||
expect(pane().tabs[2]!.preview).toBe(false);
|
||||
useDocMeta.setState({ "/p/b.ts": { ...useDocMeta.getState()["/p/b.ts"]!, readOnly: true } });
|
||||
expect(labels()).toContain("Close All Read-Only Tabs");
|
||||
ctrl.closeReadOnly(pane().id);
|
||||
await ctrl.closeReadOnly(pane().id);
|
||||
expect(pane().tabs.map((t) => t.input.path)).toEqual(["/p/a.ts", "/p/c.ts"]);
|
||||
});
|
||||
|
||||
@@ -180,12 +180,13 @@ describe("EditorController", () => {
|
||||
useProject.setState({ lightEdit: false });
|
||||
expect(openFilePaths(useTabs.getState())).toEqual([]);
|
||||
useProject.setState({ lightEdit: true });
|
||||
expect(openFilePaths(useTabs.getState())).toEqual(["/x/a.txt", "/y/b.md"]);
|
||||
// Closing every tab leaves nothing to reopen.
|
||||
ctrl.closeEverything();
|
||||
await vi.waitFor(() => expect(openFilePaths(useTabs.getState())).toEqual(["/x/a.txt", "/y/b.md"]));
|
||||
// Clearing the session must not resurrect its other files on the next explicit open.
|
||||
await ctrl.closeEverything();
|
||||
useProject.setState({ lightEdit: false });
|
||||
useProject.setState({ lightEdit: true });
|
||||
expect(openFilePaths(useTabs.getState())).toEqual([]);
|
||||
await ctrl.openFile("/x/a.txt", { focus: false });
|
||||
expect(openFilePaths(useTabs.getState())).toEqual(["/x/a.txt"]);
|
||||
useProject.setState({ lightEdit: false });
|
||||
});
|
||||
|
||||
@@ -197,7 +198,7 @@ describe("EditorController", () => {
|
||||
const key = Object.values(useTabs.getState().panes)
|
||||
.flatMap((p) => p.tabs)
|
||||
.find((t) => t.input.path === "/p/b.ts")!.input.key;
|
||||
ctrl.closeInput(key);
|
||||
await ctrl.closeInput(key);
|
||||
expect(openFilePaths(useTabs.getState())).toEqual(["/p/a.ts"]);
|
||||
});
|
||||
|
||||
@@ -223,31 +224,6 @@ describe("EditorController", () => {
|
||||
const s = useTabs.getState();
|
||||
const pane = s.panes[s.activePane]!;
|
||||
const items = resolveMenuItems(ctrl.tabMenu(pane.id, pane.tabs[0]!));
|
||||
expect(items.map((i) => (i.kind === "separator" ? "-" : i.label))).toEqual([
|
||||
"Close",
|
||||
"Close Other Tabs",
|
||||
"Close Tabs to the Left",
|
||||
"Close Tabs to the Right",
|
||||
"Close All Tabs",
|
||||
"Close Unmodified Tabs",
|
||||
"-",
|
||||
"Pin Tab",
|
||||
"-",
|
||||
"Split and Move Right",
|
||||
"Split and Move Down",
|
||||
"Split Right",
|
||||
"Split Down",
|
||||
"-",
|
||||
"Open In",
|
||||
"-",
|
||||
"Local History",
|
||||
"Git",
|
||||
"-",
|
||||
"Copy Path/Reference…",
|
||||
"Rename File…",
|
||||
"-",
|
||||
"Configure Editor Tabs…",
|
||||
]);
|
||||
expect(items.find((i) => i.label === "Close Tabs to the Left")?.disabled).toBe(true);
|
||||
expect(items.find((i) => i.label === "Close Tabs to the Right")?.disabled).toBe(false);
|
||||
dispose();
|
||||
|
||||
@@ -22,11 +22,15 @@ import {
|
||||
notifications,
|
||||
resolveFileEditor,
|
||||
useContributions,
|
||||
type EditorCloseSession,
|
||||
type EditorInput,
|
||||
type OpenOptions,
|
||||
type FileEditorResolution,
|
||||
type OpenEditorTab,
|
||||
} from "@/workbench/contributions";
|
||||
import { useDistractionFree } from "@/workbench/distractionFree";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { fileIconName, showContextMenu, type MenuItem } from "@/ui";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import {
|
||||
@@ -46,6 +50,7 @@ import {
|
||||
moveTab,
|
||||
moveTabToNewSplit,
|
||||
neighbourPane,
|
||||
openKeys,
|
||||
openTab,
|
||||
paneIds,
|
||||
paneOf,
|
||||
@@ -64,6 +69,16 @@ import {
|
||||
type TabsState,
|
||||
} from "./model/tabs";
|
||||
import { DocumentManager, useDocMeta } from "./model/document";
|
||||
import {
|
||||
closedDocuments,
|
||||
combinedError,
|
||||
customDocuments,
|
||||
documentDirty,
|
||||
documentLifecycle,
|
||||
failures,
|
||||
inputDirty,
|
||||
type CustomDocument,
|
||||
} from "./model/customDocuments";
|
||||
import { EditorSlot, type SlotHost } from "./model/slot";
|
||||
import { NavigationHistory } from "./model/navHistory";
|
||||
import { EditHistory } from "./model/editHistory";
|
||||
@@ -194,6 +209,16 @@ export class EditorController implements SlotHost {
|
||||
/** When each input key was last the active tab (a counter, across panes). */
|
||||
private lastActive = new Map<string, number>();
|
||||
private activeClock = 0;
|
||||
/** Serializes close decisions without retaining a stale tabs snapshot. */
|
||||
private removalTail: Promise<unknown> = Promise.resolve();
|
||||
private closingDocuments = new Map<string, Promise<void>>();
|
||||
private preparedDocuments = new Map<string, CustomDocument>();
|
||||
private closeSession: EditorCloseSession | null = null;
|
||||
private closeCommitted = false;
|
||||
private projectGeneration = 0;
|
||||
private admissionGeneration = 0;
|
||||
private closeRequests = 0;
|
||||
private projectTransition: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(backend: DocBackend = docBackend(), scheduler?: Scheduler) {
|
||||
this.manager = new DocumentManager(
|
||||
@@ -224,7 +249,7 @@ export class EditorController implements SlotHost {
|
||||
let prevRoot = useProject.getState().project?.root ?? null;
|
||||
let prevLight = useProject.getState().lightEdit;
|
||||
this.disposers.push(
|
||||
useProject.subscribe((s) => {
|
||||
useProject.subscribe((s, previous) => {
|
||||
if (s.settings !== prevSettings) {
|
||||
prevSettings = s.settings;
|
||||
tabOptions.activateOnClose = editorPrefs(s.settings).tabs
|
||||
@@ -233,16 +258,33 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
const root = s.project?.root ?? null;
|
||||
// Entering or leaving LightEdit replaces the backend's documents too.
|
||||
if (root !== prevRoot || s.lightEdit !== prevLight) {
|
||||
if (root !== prevRoot || s.lightEdit !== prevLight || (s.opening && s.project !== previous.project)) {
|
||||
prevRoot = root;
|
||||
prevLight = s.lightEdit;
|
||||
this.saveSession();
|
||||
this.sessionRoot = null;
|
||||
this.closeEverything();
|
||||
const generation = ++this.projectGeneration;
|
||||
const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds);
|
||||
// The guard decided already. Disposal must never prompt or autosave
|
||||
// custom documents after the backend has replaced their project.
|
||||
useTabs.setState(initialTabsState(), true);
|
||||
this.closeSession = null;
|
||||
this.closeCommitted = false;
|
||||
this.preparedDocuments.clear();
|
||||
this.nav.clear();
|
||||
this.edits.clear();
|
||||
if (root) this.restoreSession(root);
|
||||
else if (s.lightEdit) this.restoreSession(LIGHT_EDIT_SESSION);
|
||||
this.restoredTabs.clear();
|
||||
this.restoredViews.clear();
|
||||
this.projectTransition = this.releaseDocuments(documents.values())
|
||||
.catch((e) => this.documentError(e))
|
||||
.then(() => {
|
||||
if (generation !== this.projectGeneration) return;
|
||||
if (root) this.restoreSession(root);
|
||||
else if (s.lightEdit) this.restoreSession(LIGHT_EDIT_SESSION);
|
||||
});
|
||||
} else if (previous.opening && !s.opening && this.closeCommitted) {
|
||||
// A failed project open returns to the same project.
|
||||
this.closeSession?.cancel();
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -252,7 +294,10 @@ export class EditorController implements SlotHost {
|
||||
if (this.manager.backend.onFsChanged)
|
||||
this.disposers.push(this.manager.backend.onFsChanged((e) => this.onFilesRemoved(e.removed)));
|
||||
const onWindowBlur = () => {
|
||||
if (this.autoSave()) void this.manager.saveAll("auto");
|
||||
if (this.autoSave() && !this.closeSession) {
|
||||
void this.manager.saveAll("auto");
|
||||
void this.saveCustomDocuments(true).catch((e) => this.documentError(e));
|
||||
}
|
||||
};
|
||||
const onPageHide = () => this.saveSession();
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -271,11 +316,18 @@ export class EditorController implements SlotHost {
|
||||
stop() {
|
||||
this.saveSession();
|
||||
this.sessionRoot = null;
|
||||
++this.projectGeneration;
|
||||
clearTimeout(this.sessionTimer);
|
||||
const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds);
|
||||
this.disposers.splice(0).forEach((d) => d());
|
||||
this.closeSession = null;
|
||||
this.closeCommitted = false;
|
||||
this.preparedDocuments.clear();
|
||||
for (const slot of this.slots.values()) slot.dispose();
|
||||
this.slots.clear();
|
||||
this.manager.stop();
|
||||
useTabs.setState(initialTabsState(), true);
|
||||
void this.releaseDocuments(documents.values()).catch((e) => this.documentError(e));
|
||||
}
|
||||
|
||||
autoSave(): boolean {
|
||||
@@ -302,13 +354,23 @@ export class EditorController implements SlotHost {
|
||||
return n;
|
||||
}, true);
|
||||
|
||||
// Autosave on tab switch (IDEA).
|
||||
if (this.autoSave()) {
|
||||
// Save only a document that remains open. A discarded/closing document must
|
||||
// never be saved by the tab removal that follows its close decision.
|
||||
if (this.autoSave() && !this.closeSession) {
|
||||
const saved = new Set<string>();
|
||||
const open = openKeys(next);
|
||||
for (const [paneId, pane] of Object.entries(prev.panes)) {
|
||||
const now = next.panes[paneId];
|
||||
if (pane.active && now && now.active !== pane.active) {
|
||||
const slot = this.slots.get(pane.active);
|
||||
if (slot) void slot.model.save("auto").catch(() => {});
|
||||
const input = pane.tabs.find((t) => t.id === pane.active)?.input;
|
||||
if (input && open.has(input.key) && !saved.has(input.key) && !this.preparedDocuments.has(input.key)) {
|
||||
saved.add(input.key);
|
||||
const lifecycle = documentLifecycle(input, useContributions.getState().editorKinds);
|
||||
if (lifecycle && documentDirty({ input, lifecycle }))
|
||||
void lifecycle.save(input).catch((e) => this.documentError(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,6 +418,9 @@ export class EditorController implements SlotHost {
|
||||
const session = loadSession(root);
|
||||
if (!session) return;
|
||||
const state = restoreTabsState(session, fileKey);
|
||||
const kinds = useContributions.getState().editorKinds;
|
||||
for (const pane of Object.values(state.panes))
|
||||
pane.tabs = pane.tabs.map((tab) => (documentLifecycle(tab.input, kinds) ? { ...tab, preview: false } : tab));
|
||||
this.restoredViews = new Map(Object.entries(session.files));
|
||||
this.restoredTabs = new Set(Object.values(state.panes).flatMap((p) => p.tabs.map((t) => t.id)));
|
||||
useTabs.setState(state, true);
|
||||
@@ -767,18 +832,43 @@ export class EditorController implements SlotHost {
|
||||
|
||||
// --- opening ---------------------------------------------------------------------------
|
||||
|
||||
private openingGeneration() {
|
||||
const generation = { project: this.projectGeneration, admission: this.admissionGeneration };
|
||||
this.checkOpening(generation);
|
||||
return generation;
|
||||
}
|
||||
|
||||
private checkOpening(generation: { project: number; admission: number }) {
|
||||
if (generation.project !== this.projectGeneration) throw new Error("The project changed while opening the editor");
|
||||
if (generation.admission !== this.admissionGeneration || this.closeSession || this.closeRequests)
|
||||
throw new Error("The project is closing");
|
||||
}
|
||||
|
||||
async openFile(path: string, o: OpenOptions = {}) {
|
||||
const generation = this.openingGeneration();
|
||||
if (isVirtualUri(path)) return this.openVirtual(path, o);
|
||||
await this.projectTransition;
|
||||
this.checkOpening(generation);
|
||||
// Images, binary and huge files open in a contributed viewer unless already open as text.
|
||||
if (!o.asText && !findTab(useTabs.getState(), fileKey(path))) {
|
||||
const viewer = await resolveFileEditor(path);
|
||||
if (viewer) return this.openInput(viewer, o);
|
||||
if (viewer) {
|
||||
try {
|
||||
await this.admitInput(viewer.input, o, generation, viewer);
|
||||
} finally {
|
||||
await viewer.rollback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.openInput(fileInput(path), o);
|
||||
await this.admitInput(fileInput(path), o, generation);
|
||||
}
|
||||
|
||||
/** Opens the library class (or other server-provided document) behind `uri` read-only. */
|
||||
async openVirtual(uri: string, o: OpenOptions = {}) {
|
||||
const generation = this.openingGeneration();
|
||||
await this.projectTransition;
|
||||
this.checkOpening(generation);
|
||||
let vd: VirtualDocument;
|
||||
try {
|
||||
vd = await virtualDocument(uri);
|
||||
@@ -791,10 +881,23 @@ export class EditorController implements SlotHost {
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.openInput(virtualInput(vd), o);
|
||||
await this.admitInput(virtualInput(vd), o, generation);
|
||||
}
|
||||
|
||||
async openInput(input: EditorInput, o: OpenOptions = {}) {
|
||||
return this.admitInput(input, o, this.openingGeneration());
|
||||
}
|
||||
|
||||
private async admitInput(
|
||||
input: EditorInput,
|
||||
o: OpenOptions,
|
||||
generation: { project: number; admission: number },
|
||||
resolution?: FileEditorResolution,
|
||||
) {
|
||||
await this.projectTransition;
|
||||
this.checkOpening(generation);
|
||||
await this.closingDocuments.get(input.key);
|
||||
this.checkOpening(generation);
|
||||
this.navSuppress++;
|
||||
try {
|
||||
let s = useTabs.getState();
|
||||
@@ -816,13 +919,17 @@ export class EditorController implements SlotHost {
|
||||
if (found && (!(o.side || o.pane) || found.paneId === paneId)) {
|
||||
paneId = found.paneId;
|
||||
s = activateTab(setActivePane(s, paneId), paneId, found.tab.id);
|
||||
if (!o.preview) s = promoteTab(s, paneId, found.tab.id);
|
||||
if (!o.preview || documentLifecycle(input, useContributions.getState().editorKinds))
|
||||
s = promoteTab(s, paneId, found.tab.id);
|
||||
tabId = found.tab.id;
|
||||
if (found.tab.input.data !== input.data && input.data !== undefined)
|
||||
s = openTab(s, paneId, input, { preview: o.preview }).state;
|
||||
s = openTab(s, paneId, input, {
|
||||
preview: o.preview && !documentLifecycle(input, useContributions.getState().editorKinds),
|
||||
}).state;
|
||||
} else {
|
||||
const tabs = editorPrefs().tabs;
|
||||
const preview = !!o.preview && tabs.previewTab;
|
||||
const preview =
|
||||
!!o.preview && tabs.previewTab && !documentLifecycle(input, useContributions.getState().editorKinds);
|
||||
const r = openTab(s, paneId, input, {
|
||||
preview,
|
||||
...(tabs.openAtEnd ? { index: s.panes[paneId]?.tabs.length ?? 0 } : {}),
|
||||
@@ -830,16 +937,27 @@ export class EditorController implements SlotHost {
|
||||
s = r.state;
|
||||
tabId = r.tabId;
|
||||
if (tabs.sortAlphabetically) s = sortTabs(s, paneId, (t) => tabSortKey(t.input));
|
||||
s = enforceTabLimit(
|
||||
s,
|
||||
paneId,
|
||||
tabs.tabLimit,
|
||||
tabs.closingPolicy,
|
||||
(t) => !!(t.input.path && useDocMeta.getState()[t.input.path]?.dirty),
|
||||
tabId,
|
||||
);
|
||||
}
|
||||
resolution?.admit();
|
||||
useTabs.setState(s, true);
|
||||
const tabPrefs = editorPrefs().tabs;
|
||||
await this.removeGuarded(
|
||||
(current) =>
|
||||
current.panes[paneId]
|
||||
? enforceTabLimit(
|
||||
current,
|
||||
paneId,
|
||||
tabPrefs.tabLimit,
|
||||
tabPrefs.closingPolicy,
|
||||
(tab) =>
|
||||
inputDirty(tab.input, useContributions.getState().editorKinds) ??
|
||||
!!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty),
|
||||
tabId,
|
||||
(tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true,
|
||||
)
|
||||
: current,
|
||||
{ protect: (tab) => inputDirty(tab.input, useContributions.getState().editorKinds) === true },
|
||||
);
|
||||
if (!isTextInput(input)) {
|
||||
if (o.focus !== false) requestAnimationFrame(() => this.focus());
|
||||
return;
|
||||
@@ -893,32 +1011,171 @@ export class EditorController implements SlotHost {
|
||||
useTabs.setState((s) => f(s), true);
|
||||
}
|
||||
|
||||
closeTab(paneId: string, tabId: string) {
|
||||
this.apply((s) => (s.panes[paneId] ? closeTab(s, paneId, tabId) : s));
|
||||
private documentError(error: unknown) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: t("app.unsaved.saveFailed"),
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
closeOthers(paneId: string, tabId: string) {
|
||||
this.apply((s) => closeOthers(s, paneId, tabId));
|
||||
|
||||
private enqueueRemoval<T>(run: () => Promise<T>): Promise<T> {
|
||||
const result = this.removalTail.then(run);
|
||||
this.removalTail = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
closeAll(paneId: string, keepPinned = false) {
|
||||
this.apply((s) => closeAll(s, paneId, keepPinned));
|
||||
|
||||
private releaseDocuments(documents: Iterable<CustomDocument>): Promise<void> {
|
||||
const pending = [...documents].map((d) => {
|
||||
const key = d.input.key;
|
||||
const previous = this.closingDocuments.get(key) ?? Promise.resolve();
|
||||
const close = previous.then(() => d.lifecycle.close(d.input));
|
||||
this.closingDocuments.set(key, close);
|
||||
return close.finally(() => {
|
||||
if (this.closingDocuments.get(key) === close) this.closingDocuments.delete(key);
|
||||
});
|
||||
});
|
||||
return Promise.allSettled(pending).then((results) => {
|
||||
const errors = failures(results);
|
||||
if (errors.length) throw combinedError(errors);
|
||||
});
|
||||
}
|
||||
closeToRight(paneId: string, tabId: string) {
|
||||
this.apply((s) => closeToRight(s, paneId, tabId));
|
||||
|
||||
/** Close exactly the originally selected tabs, evaluating references after every await. */
|
||||
private removeGuarded(
|
||||
select: (s: TabsState) => TabsState,
|
||||
options: { protect?: (tab: Tab) => boolean; clearHistory?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const generation = this.projectGeneration;
|
||||
const initial = useTabs.getState();
|
||||
const planned = select(initial);
|
||||
const remainingIds = new Set(Object.values(planned.panes).flatMap((p) => p.tabs.map((t) => t.id)));
|
||||
const targets = new Set(
|
||||
Object.values(initial.panes).flatMap((p) => p.tabs.filter((t) => !remainingIds.has(t.id)).map((t) => t.id)),
|
||||
);
|
||||
const emptyPanes = paneIds(initial).filter((id) => !planned.panes[id] && !initial.panes[id]?.tabs.length);
|
||||
return this.enqueueRemoval(async () => {
|
||||
if (generation !== this.projectGeneration || this.closeSession) return false;
|
||||
const prepared: CustomDocument[] = [];
|
||||
const currentRemoval = (s: TabsState) => {
|
||||
let next = s;
|
||||
for (const [paneId, pane] of Object.entries(s.panes))
|
||||
for (const tab of pane.tabs)
|
||||
if (targets.has(tab.id) && next.panes[paneId] && !options.protect?.(tab))
|
||||
next = closeTab(next, paneId, tab.id);
|
||||
for (const id of emptyPanes)
|
||||
if (next.panes[id]?.tabs.length === 0 && paneIds(next).length > 1) next = removePane(next, id);
|
||||
return next;
|
||||
};
|
||||
const stillLast = (d: CustomDocument) => {
|
||||
if (generation !== this.projectGeneration) return false;
|
||||
const current = useTabs.getState();
|
||||
return openKeys(current).has(d.input.key) && !openKeys(currentRemoval(current)).has(d.input.key);
|
||||
};
|
||||
try {
|
||||
const authorized = new Set<string>();
|
||||
const discards = new Map<string, CustomDocument>();
|
||||
for (;;) {
|
||||
const current = useTabs.getState();
|
||||
const d = closedDocuments(current, currentRemoval(current), useContributions.getState().editorKinds).find(
|
||||
(document) => !authorized.has(document.input.key),
|
||||
);
|
||||
if (!d) {
|
||||
// A later Cancel/save failure must not discard earlier documents.
|
||||
const discard = [...discards.values()].find(stillLast);
|
||||
if (!discard) break;
|
||||
discards.delete(discard.input.key);
|
||||
await discard.lifecycle.discard(discard.input);
|
||||
// The await may have changed which documents lose their last tab.
|
||||
if (generation !== this.projectGeneration) return false;
|
||||
continue;
|
||||
}
|
||||
if (!prepared.some((document) => document.input.key === d.input.key)) {
|
||||
prepared.push(d);
|
||||
this.preparedDocuments.set(d.input.key, d);
|
||||
await d.lifecycle.prepareClose(d.input);
|
||||
}
|
||||
if (generation !== this.projectGeneration) return false;
|
||||
if (!stillLast(d)) continue;
|
||||
if (documentDirty(d)) {
|
||||
const decision = await ask({
|
||||
title: t("app.unsaved.title"),
|
||||
body: t("app.unsaved.body", { count: 1, project: d.input.path ?? d.input.key }),
|
||||
buttons: [
|
||||
{ id: "discard", label: t("app.unsaved.discard") },
|
||||
{ id: "save", label: t("app.unsaved.save"), primary: true },
|
||||
],
|
||||
icon: "warning",
|
||||
});
|
||||
if (!stillLast(d)) continue;
|
||||
if (decision.button === "save") await d.lifecycle.save(d.input);
|
||||
else if (decision.button === "discard") discards.set(d.input.key, d);
|
||||
else return false;
|
||||
}
|
||||
authorized.add(d.input.key);
|
||||
}
|
||||
// Commit only the original target ids against the latest tab state.
|
||||
if (generation !== this.projectGeneration) return false;
|
||||
const before = useTabs.getState();
|
||||
const next = currentRemoval(before);
|
||||
const closed = closedDocuments(before, next, useContributions.getState().editorKinds);
|
||||
const closedKeys = new Set(closed.map((d) => d.input.key));
|
||||
for (const d of prepared) if (!closedKeys.has(d.input.key)) d.lifecycle.cancelClose(d.input);
|
||||
// Remove frozen markers only after the tab reaction, to prevent autosave.
|
||||
useTabs.setState(options.clearHistory ? { ...next, closed: [] } : next, true);
|
||||
for (const d of prepared) this.preparedDocuments.delete(d.input.key);
|
||||
prepared.length = 0;
|
||||
await this.releaseDocuments(closed);
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.documentError(e);
|
||||
return false;
|
||||
} finally {
|
||||
for (const d of prepared) {
|
||||
if (this.preparedDocuments.get(d.input.key) === d) this.preparedDocuments.delete(d.input.key);
|
||||
if (generation === this.projectGeneration) d.lifecycle.cancelClose(d.input);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
closeToLeft(paneId: string, tabId: string) {
|
||||
this.apply((s) => closeToLeft(s, paneId, tabId));
|
||||
|
||||
closeTab(paneId: string, tabId: string): Promise<boolean> {
|
||||
return this.removeGuarded((s) => (s.panes[paneId] ? closeTab(s, paneId, tabId) : s));
|
||||
}
|
||||
closeOthers(paneId: string, tabId: string): Promise<boolean> {
|
||||
return this.removeGuarded((s) => (s.panes[paneId] ? closeOthers(s, paneId, tabId) : s), {
|
||||
protect: (tab) => tab.pinned,
|
||||
});
|
||||
}
|
||||
closeAll(paneId: string, keepPinned = false): Promise<boolean> {
|
||||
return this.removeGuarded((s) => (s.panes[paneId] ? closeAll(s, paneId, keepPinned) : s), {
|
||||
protect: (tab) => keepPinned && tab.pinned,
|
||||
});
|
||||
}
|
||||
closeToRight(paneId: string, tabId: string): Promise<boolean> {
|
||||
return this.removeGuarded((s) => (s.panes[paneId] ? closeToRight(s, paneId, tabId) : s), {
|
||||
protect: (tab) => tab.pinned,
|
||||
});
|
||||
}
|
||||
closeToLeft(paneId: string, tabId: string): Promise<boolean> {
|
||||
return this.removeGuarded((s) => (s.panes[paneId] ? closeToLeft(s, paneId, tabId) : s), {
|
||||
protect: (tab) => tab.pinned,
|
||||
});
|
||||
}
|
||||
/** Keeps tabs with unsaved changes or a VCS status (IDEA: tabs shown as modified). */
|
||||
closeUnmodified(paneId: string) {
|
||||
const modified = (t: Tab) => {
|
||||
const p = t.input.path;
|
||||
return !!p && (!!useDocMeta.getState()[p]?.dirty || !!getFileDecoration(p)?.color);
|
||||
};
|
||||
this.apply((s) => closeUnmodified(s, paneId, modified));
|
||||
closeUnmodified(paneId: string): Promise<boolean> {
|
||||
const modified = (tab: Tab) =>
|
||||
(inputDirty(tab.input, useContributions.getState().editorKinds) ?? false) ||
|
||||
!!(tab.input.path && (useDocMeta.getState()[tab.input.path]?.dirty || getFileDecoration(tab.input.path)?.color));
|
||||
return this.removeGuarded((s) => (s.panes[paneId] ? closeUnmodified(s, paneId, modified) : s), {
|
||||
protect: (tab) => tab.pinned || modified(tab),
|
||||
});
|
||||
}
|
||||
closeReadOnly(paneId: string) {
|
||||
closeReadOnly(paneId: string): Promise<boolean> {
|
||||
const writable = (t: Tab) => !t.input.path || !useDocMeta.getState()[t.input.path]?.readOnly;
|
||||
this.apply((s) => closeUnmodified(s, paneId, writable));
|
||||
return this.removeGuarded((s) => (s.panes[paneId] ? closeUnmodified(s, paneId, writable) : s), {
|
||||
protect: (tab) => tab.pinned || writable(tab),
|
||||
});
|
||||
}
|
||||
/** Reload from Disk: re-reads the open, unmodified documents of `paths`. */
|
||||
async reloadFromDisk(paths: string[]) {
|
||||
@@ -991,7 +1248,11 @@ export class EditorController implements SlotHost {
|
||||
this.apply((s) => cycleTab(s, delta));
|
||||
this.focus();
|
||||
}
|
||||
reopenClosed() {
|
||||
async reopenClosed(): Promise<void> {
|
||||
const generation = this.projectGeneration;
|
||||
await this.projectTransition;
|
||||
await this.removalTail;
|
||||
if (generation !== this.projectGeneration || this.closeSession) return;
|
||||
const r = reopenClosed(useTabs.getState());
|
||||
if (!r) return;
|
||||
useTabs.setState(r.state, true);
|
||||
@@ -1013,12 +1274,16 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
}
|
||||
|
||||
unsplit(paneId: string) {
|
||||
this.apply((s) => removePane(s, paneId));
|
||||
unsplit(paneId: string): Promise<boolean> {
|
||||
return this.removeGuarded((s) => (s.panes[paneId] ? removePane(s, paneId) : s));
|
||||
}
|
||||
unsplitAll(paneId: string) {
|
||||
this.apply((s) => unsplitAll(s, paneId));
|
||||
this.focus();
|
||||
async unsplitAll(paneId: string): Promise<boolean> {
|
||||
const closed = await this.removeGuarded((s) => unsplitAll(s, paneId));
|
||||
if (closed) {
|
||||
this.apply((s) => setActivePane(s, paneId));
|
||||
this.focus();
|
||||
}
|
||||
return closed;
|
||||
}
|
||||
changeSplitOrientation(paneId: string) {
|
||||
this.apply((s) => changeSplitOrientation(s, paneId));
|
||||
@@ -1041,35 +1306,46 @@ export class EditorController implements SlotHost {
|
||||
});
|
||||
}
|
||||
|
||||
closeActiveTab() {
|
||||
closeActiveTab(): Promise<boolean> {
|
||||
const s = useTabs.getState();
|
||||
const pane = s.panes[s.activePane];
|
||||
if (pane?.active) this.closeTab(pane.id, pane.active);
|
||||
else if (paneIds(s).length > 1) this.unsplit(s.activePane);
|
||||
if (pane?.active) return this.closeTab(pane.id, pane.active);
|
||||
if (paneIds(s).length > 1) return this.unsplit(s.activePane);
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
closeEverything() {
|
||||
this.apply((s) => {
|
||||
let next = s;
|
||||
for (const id of paneIds(s)) if (next.panes[id]) next = closeAll(next, id);
|
||||
return { ...next, closed: [] };
|
||||
});
|
||||
closeEverything(): Promise<boolean> {
|
||||
return this.removeGuarded(
|
||||
(s) => {
|
||||
let next = s;
|
||||
for (const id of paneIds(s)) if (next.panes[id]) next = closeAll(next, id);
|
||||
return next;
|
||||
},
|
||||
{ clearHistory: true },
|
||||
);
|
||||
}
|
||||
|
||||
private onFilesRemoved(removed: string[]) {
|
||||
if (!removed.length) return;
|
||||
for (const path of removed) {
|
||||
const isUnder = (k: string) => k === fileKey(path) || k.startsWith(fileKey(path) + "/");
|
||||
const s = useTabs.getState();
|
||||
const keys = new Set<string>();
|
||||
for (const pane of Object.values(s.panes))
|
||||
for (const t of pane.tabs) if (isUnder(t.input.key)) keys.add(t.input.key);
|
||||
for (const key of keys) {
|
||||
const model = this.manager.get(key.slice(5));
|
||||
if (model?.getMeta().dirty) continue;
|
||||
this.apply((st) => closeKey(st, key));
|
||||
this.nav.removePath(key.slice(5));
|
||||
this.edits.removePath(key.slice(5));
|
||||
const kinds = useContributions.getState().editorKinds;
|
||||
const inputs = new Map<string, EditorInput>();
|
||||
for (const pane of Object.values(useTabs.getState().panes))
|
||||
for (const tab of pane.tabs) {
|
||||
const path = tab.input.path;
|
||||
if (path && removed.some((root) => path === root || path.startsWith(`${root}/`)))
|
||||
inputs.set(tab.input.key, tab.input);
|
||||
}
|
||||
for (const input of inputs.values()) {
|
||||
if (inputDirty(input, kinds) === true || (input.path && this.manager.get(input.path)?.getMeta().dirty)) continue;
|
||||
// A pending output/edit can make the document dirty before this queued
|
||||
// removal runs. Recheck then; external deletion never discards edits.
|
||||
void this.removeGuarded((s) => closeKey(s, input.key), {
|
||||
protect: (tab) =>
|
||||
inputDirty(tab.input, kinds) === true || !!(tab.input.path && useDocMeta.getState()[tab.input.path]?.dirty),
|
||||
});
|
||||
if (input.path) {
|
||||
this.nav.removePath(input.path);
|
||||
this.edits.removePath(input.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1125,8 +1401,8 @@ export class EditorController implements SlotHost {
|
||||
return [...out];
|
||||
}
|
||||
|
||||
closePath(path: string) {
|
||||
this.apply((s) => {
|
||||
closePath(path: string): Promise<boolean> {
|
||||
return this.removeGuarded((s) => {
|
||||
const keys = new Set<string>();
|
||||
for (const pane of Object.values(s.panes))
|
||||
for (const t of pane.tabs) if (t.input.path === path) keys.add(t.input.key);
|
||||
@@ -1136,8 +1412,8 @@ export class EditorController implements SlotHost {
|
||||
});
|
||||
}
|
||||
|
||||
closeInput(key: string) {
|
||||
this.apply((s) => closeKey(s, key));
|
||||
closeInput(key: string): Promise<boolean> {
|
||||
return this.removeGuarded((s) => closeKey(s, key));
|
||||
}
|
||||
|
||||
activeInput(): EditorInput | null {
|
||||
@@ -1164,8 +1440,96 @@ export class EditorController implements SlotHost {
|
||||
return { doc: slot.model.doc, path: slot.model.path, ...slot.caret() };
|
||||
}
|
||||
|
||||
saveAll() {
|
||||
return this.manager.saveAll("explicit");
|
||||
private async saveCustomDocuments(auto = false): Promise<void> {
|
||||
const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds);
|
||||
const results = await Promise.allSettled(
|
||||
[...documents.values()]
|
||||
.filter((d) => (!auto || !this.preparedDocuments.has(d.input.key)) && documentDirty(d))
|
||||
.map((d) => Promise.resolve().then(() => d.lifecycle.save(d.input))),
|
||||
);
|
||||
const errors = failures(results);
|
||||
if (errors.length) throw combinedError(errors);
|
||||
}
|
||||
|
||||
async saveAll(): Promise<void> {
|
||||
const results = await Promise.allSettled([this.manager.saveAll("explicit"), this.saveCustomDocuments()]);
|
||||
const errors = failures(results);
|
||||
if (errors.length) throw combinedError(errors);
|
||||
}
|
||||
|
||||
/** Hold every open custom document still until the project/window decision. */
|
||||
prepareClose(): Promise<EditorCloseSession> {
|
||||
++this.admissionGeneration;
|
||||
++this.closeRequests;
|
||||
return this.enqueueRemoval(async () => {
|
||||
if (this.closeSession) throw new Error("An editor close decision is already pending");
|
||||
const generation = this.projectGeneration;
|
||||
const documents = [...customDocuments(useTabs.getState(), useContributions.getState().editorKinds).values()];
|
||||
const prepared: CustomDocument[] = [];
|
||||
let settled = false;
|
||||
const session: EditorCloseSession = {
|
||||
dirty: 0,
|
||||
discard: async () => {
|
||||
if (settled || this.closeSession !== session || generation !== this.projectGeneration)
|
||||
throw new Error("The editor close decision is no longer current");
|
||||
const results = await Promise.allSettled(
|
||||
documents.filter(documentDirty).map((d) => Promise.resolve().then(() => d.lifecycle.discard(d.input))),
|
||||
);
|
||||
const errors = failures(results);
|
||||
if (errors.length) throw combinedError(errors);
|
||||
},
|
||||
cancel: () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (this.closeSession !== session || generation !== this.projectGeneration) return;
|
||||
this.closeSession = null;
|
||||
this.closeCommitted = false;
|
||||
for (const d of prepared) {
|
||||
this.preparedDocuments.delete(d.input.key);
|
||||
d.lifecycle.cancelClose(d.input);
|
||||
}
|
||||
},
|
||||
commit: () => {
|
||||
if (settled || this.closeSession !== session || generation !== this.projectGeneration)
|
||||
throw new Error("The editor close decision is no longer current");
|
||||
this.closeCommitted = true;
|
||||
},
|
||||
};
|
||||
this.closeSession = session;
|
||||
try {
|
||||
for (const d of documents) {
|
||||
prepared.push(d);
|
||||
this.preparedDocuments.set(d.input.key, d);
|
||||
await d.lifecycle.prepareClose(d.input);
|
||||
if (generation !== this.projectGeneration) throw new Error("The project changed during close");
|
||||
if (this.closeSession !== session) {
|
||||
d.lifecycle.cancelClose(d.input);
|
||||
throw new Error("The editor close decision was cancelled");
|
||||
}
|
||||
}
|
||||
session.dirty = documents.filter(documentDirty).length;
|
||||
return session;
|
||||
} catch (e) {
|
||||
session.cancel();
|
||||
throw e;
|
||||
}
|
||||
}).finally(() => {
|
||||
--this.closeRequests;
|
||||
});
|
||||
}
|
||||
|
||||
cancelClose(): void {
|
||||
this.closeSession?.cancel();
|
||||
}
|
||||
|
||||
documentHistory(command: "undo" | "redo"): boolean {
|
||||
const input = this.activeTabOf()?.input;
|
||||
if (!input) return false;
|
||||
const lifecycle = documentLifecycle(input, useContributions.getState().editorKinds);
|
||||
const run = lifecycle?.[command];
|
||||
if (!run) return false;
|
||||
void run.call(lifecycle, input).catch((e) => this.documentError(e));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Context menu of a tab (IDEA's EditorTabPopupMenu). */
|
||||
@@ -1181,21 +1545,21 @@ export class EditorController implements SlotHost {
|
||||
run();
|
||||
};
|
||||
return [
|
||||
{ label: "Close", shortcut: shortcutLabel("editor.closeTab"), run: () => this.closeTab(paneId, tab.id) },
|
||||
{ label: "Close Other Tabs", disabled: tabs.length < 2, run: () => this.closeOthers(paneId, tab.id) },
|
||||
{ label: "Close Tabs to the Left", disabled: index <= 0, run: () => this.closeToLeft(paneId, tab.id) },
|
||||
{ label: "Close", shortcut: shortcutLabel("editor.closeTab"), run: () => void this.closeTab(paneId, tab.id) },
|
||||
{ label: "Close Other Tabs", disabled: tabs.length < 2, run: () => void this.closeOthers(paneId, tab.id) },
|
||||
{ label: "Close Tabs to the Left", disabled: index <= 0, run: () => void this.closeToLeft(paneId, tab.id) },
|
||||
{
|
||||
label: "Close Tabs to the Right",
|
||||
disabled: index < 0 || index >= tabs.length - 1,
|
||||
run: () => this.closeToRight(paneId, tab.id),
|
||||
run: () => void this.closeToRight(paneId, tab.id),
|
||||
},
|
||||
{ label: "Close All Tabs", run: () => this.closeAll(paneId) },
|
||||
{ label: "Close Unmodified Tabs", run: () => this.closeUnmodified(paneId) },
|
||||
{ label: "Close All Tabs", run: () => void this.closeAll(paneId) },
|
||||
{ label: "Close Unmodified Tabs", run: () => void this.closeUnmodified(paneId) },
|
||||
...(tabs.some((t) => t.pinned)
|
||||
? ([{ label: "Close All but Pinned", run: () => this.closeAll(paneId, true) }] as MenuItem[])
|
||||
? ([{ label: "Close All but Pinned", run: () => void this.closeAll(paneId, true) }] as MenuItem[])
|
||||
: []),
|
||||
...(tabs.some((t) => !t.pinned && !!t.input.path && useDocMeta.getState()[t.input.path]?.readOnly)
|
||||
? ([{ label: "Close All Read-Only Tabs", run: () => this.closeReadOnly(paneId) }] as MenuItem[])
|
||||
? ([{ label: "Close All Read-Only Tabs", run: () => void this.closeReadOnly(paneId) }] as MenuItem[])
|
||||
: []),
|
||||
{ kind: "separator" },
|
||||
{ label: tab.pinned ? "Unpin Tab" : "Pin Tab", icon: "pin", run: () => this.togglePin(paneId, tab.id) },
|
||||
@@ -1220,8 +1584,8 @@ export class EditorController implements SlotHost {
|
||||
{ label: "Move to Opposite Group", run: () => this.moveToOpposite(paneId, tab.id) },
|
||||
{ label: "Open in Opposite Group", run: () => this.openInOpposite(paneId, tab.id) },
|
||||
{ label: "Change Splitter Orientation", run: () => this.changeSplitOrientation(paneId) },
|
||||
{ label: "Unsplit", run: () => this.unsplit(paneId) },
|
||||
{ label: "Unsplit All", run: () => this.unsplitAll(paneId) },
|
||||
{ label: "Unsplit", run: () => void this.unsplit(paneId) },
|
||||
{ label: "Unsplit All", run: () => void this.unsplitAll(paneId) },
|
||||
] as MenuItem[])
|
||||
: []),
|
||||
{ kind: "separator" },
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorController, useTabs } from "./controller";
|
||||
import { createMemoryBackend } from "./backend";
|
||||
import { findTab, initialTabsState, paneIds } from "./model/tabs";
|
||||
import { useDocMeta } from "./model/document";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { ask } from "@/workbench/messages";
|
||||
import type * as Messages from "@/workbench/messages";
|
||||
import * as virtualDocs from "@/platform/virtualDocs";
|
||||
import type { VirtualDocument } from "@/platform/ipc";
|
||||
import {
|
||||
registerEditorKind,
|
||||
registerFileEditorProvider,
|
||||
type EditorDocumentLifecycle,
|
||||
type EditorInput,
|
||||
} from "@/workbench/contributions";
|
||||
|
||||
vi.mock("@/workbench/messages", async (original) => ({
|
||||
...(await original<typeof Messages>()),
|
||||
ask: vi.fn(),
|
||||
}));
|
||||
|
||||
class TestDocument implements EditorDocumentLifecycle {
|
||||
saved = "original";
|
||||
text = "edited";
|
||||
pending = "";
|
||||
running = false;
|
||||
frozen = false;
|
||||
closed = false;
|
||||
saves = 0;
|
||||
releases = 0;
|
||||
saveError: Error | null = null;
|
||||
prepareGate: Promise<void> | null = null;
|
||||
prepareError: Error | null = null;
|
||||
|
||||
isDirty() {
|
||||
return this.text !== this.saved || this.pending !== "";
|
||||
}
|
||||
async flush() {
|
||||
this.text += this.pending;
|
||||
this.pending = "";
|
||||
}
|
||||
async save() {
|
||||
await this.flush();
|
||||
if (this.saveError) throw this.saveError;
|
||||
this.saves++;
|
||||
this.saved = this.text;
|
||||
}
|
||||
async prepareClose() {
|
||||
this.frozen = true;
|
||||
await this.flush();
|
||||
if (this.prepareGate) await this.prepareGate;
|
||||
if (this.running) this.text += "\nfinal output";
|
||||
this.running = false;
|
||||
if (this.prepareError) throw this.prepareError;
|
||||
}
|
||||
cancelClose() {
|
||||
this.frozen = false;
|
||||
}
|
||||
async discard() {
|
||||
this.pending = "";
|
||||
this.text = this.saved;
|
||||
}
|
||||
async close() {
|
||||
this.closed = true;
|
||||
this.running = false;
|
||||
this.releases++;
|
||||
}
|
||||
}
|
||||
|
||||
const input = (name = "a"): EditorInput => ({
|
||||
kind: "lifecycle-test",
|
||||
key: `custom:${name}`,
|
||||
path: `/p/${name}.ipynb`,
|
||||
});
|
||||
let ctrl: EditorController;
|
||||
let documents: Map<string, TestDocument>;
|
||||
let unregister: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
useTabs.setState(initialTabsState(), true);
|
||||
useProject.setState({ project: null, settings: { files: { autoSave: false } } as never });
|
||||
localStorage.clear();
|
||||
documents = new Map();
|
||||
const sessions = documents;
|
||||
const lifecycle: EditorDocumentLifecycle = {
|
||||
isDirty: (i) => sessions.get(i.key)!.isDirty(),
|
||||
flush: (i) => sessions.get(i.key)!.flush(),
|
||||
save: (i) => sessions.get(i.key)!.save(),
|
||||
prepareClose: (i) => sessions.get(i.key)!.prepareClose(),
|
||||
cancelClose: (i) => sessions.get(i.key)!.cancelClose(),
|
||||
discard: (i) => sessions.get(i.key)!.discard(),
|
||||
close: (i) => sessions.get(i.key)!.close(),
|
||||
};
|
||||
unregister = registerEditorKind({
|
||||
kind: "lifecycle-test",
|
||||
component: () => null,
|
||||
title: (i) => i.key,
|
||||
document: lifecycle,
|
||||
});
|
||||
ctrl = new EditorController(createMemoryBackend());
|
||||
ctrl.start();
|
||||
vi.mocked(ask).mockReset();
|
||||
vi.mocked(ask).mockResolvedValue({ button: "cancel", checked: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
ctrl.stop();
|
||||
unregister();
|
||||
useDocMeta.setState({}, true);
|
||||
useProject.setState({ project: null, settings: null });
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
async function open(name = "a") {
|
||||
const i = input(name);
|
||||
const doc = new TestDocument();
|
||||
documents.set(i.key, doc);
|
||||
await ctrl.openInput(i, { preview: true, focus: false });
|
||||
return doc;
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("custom editor document lifetime", () => {
|
||||
it("rejects a delayed virtual document from the previous project", async () => {
|
||||
const started = deferred<void>();
|
||||
const response = deferred<VirtualDocument>();
|
||||
const probe = vi.spyOn(virtualDocs, "virtualDocument").mockImplementationOnce(() => {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
});
|
||||
try {
|
||||
const opening = ctrl.openVirtual("jdt://old/Class", { focus: false });
|
||||
await started.promise;
|
||||
useProject.setState({ project: { root: "/next", name: "next", settings: {} } as never });
|
||||
const rejected = expect(opening).rejects.toThrow("project changed");
|
||||
response.resolve({
|
||||
uri: "jdt://old/Class",
|
||||
text: "class Old {}",
|
||||
languageId: "java",
|
||||
title: "Class",
|
||||
description: null,
|
||||
decompiled: true,
|
||||
});
|
||||
await rejected;
|
||||
expect(findTab(useTabs.getState(), "virtual:jdt://old/Class")).toBeNull();
|
||||
} finally {
|
||||
probe.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["pending", "cancelled", "replaced"] as const)(
|
||||
"rolls back delayed provider acquisition after a %s project close",
|
||||
async (route) => {
|
||||
const started = deferred<void>();
|
||||
const response = deferred<void>();
|
||||
let acquired = false;
|
||||
let installed = false;
|
||||
const dispose = registerFileEditorProvider({
|
||||
id: "delayed-lifecycle-test",
|
||||
order: -100,
|
||||
resolve: async (_path, context) => {
|
||||
started.resolve();
|
||||
await response.promise;
|
||||
acquired = true;
|
||||
context.onAdmission(
|
||||
() => {
|
||||
installed = true;
|
||||
},
|
||||
async () => {
|
||||
acquired = false;
|
||||
},
|
||||
);
|
||||
return input();
|
||||
},
|
||||
});
|
||||
try {
|
||||
const opening = ctrl.openFile(input().path!, { focus: false });
|
||||
await started.promise;
|
||||
const session = await ctrl.prepareClose();
|
||||
if (route === "cancelled") session.cancel();
|
||||
if (route === "replaced") {
|
||||
session.commit();
|
||||
useProject.setState({ project: { root: "/replacement", name: "replacement", settings: {} } as never });
|
||||
}
|
||||
const rejected = expect(opening).rejects.toThrow(route === "replaced" ? "project changed" : "closing");
|
||||
response.resolve();
|
||||
await rejected;
|
||||
session.cancel();
|
||||
expect(installed).toBe(false);
|
||||
expect(acquired).toBe(false);
|
||||
expect(findTab(useTabs.getState(), input().key)).toBeNull();
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a new open before invoking a provider while close admission is pending", async () => {
|
||||
let probes = 0;
|
||||
const tracked = registerFileEditorProvider({
|
||||
id: "tracked-probe",
|
||||
order: -101,
|
||||
resolve: () => {
|
||||
++probes;
|
||||
return input();
|
||||
},
|
||||
});
|
||||
try {
|
||||
const closing = ctrl.prepareClose();
|
||||
await expect(ctrl.openFile(input().path!, { focus: false })).rejects.toThrow("closing");
|
||||
(await closing).cancel();
|
||||
expect(probes).toBe(0);
|
||||
} finally {
|
||||
tracked();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains pending changes and the last tab on Cancel", async () => {
|
||||
const doc = await open();
|
||||
doc.pending = " pending";
|
||||
await expect(ctrl.closeInput(input().key)).resolves.toBe(false);
|
||||
expect(findTab(useTabs.getState(), input().key)).not.toBeNull();
|
||||
expect(doc.text).toBe("edited pending");
|
||||
expect(doc.saved).toBe("original");
|
||||
expect(doc.frozen).toBe(false);
|
||||
expect(doc.closed).toBe(false);
|
||||
});
|
||||
|
||||
it("saves a drained execution checkpoint before removing the last tab", async () => {
|
||||
const doc = await open();
|
||||
doc.running = true;
|
||||
vi.mocked(ask).mockResolvedValue({ button: "save", checked: false });
|
||||
await expect(ctrl.closePath(input().path!)).resolves.toBe(true);
|
||||
expect(doc.saved).toBe("edited\nfinal output");
|
||||
expect(doc.running).toBe(false);
|
||||
expect(doc.closed).toBe(true);
|
||||
expect(findTab(useTabs.getState(), input().key)).toBeNull();
|
||||
});
|
||||
|
||||
it("discards without a removal-triggered autosave", async () => {
|
||||
const doc = await open();
|
||||
useProject.setState({ settings: { files: { autoSave: true } } as never });
|
||||
doc.pending = " unsaved";
|
||||
vi.mocked(ask).mockResolvedValue({ button: "discard", checked: false });
|
||||
await ctrl.closeEverything();
|
||||
expect(doc.saved).toBe("original");
|
||||
expect(doc.text).toBe("original");
|
||||
expect(doc.saves).toBe(0);
|
||||
expect(doc.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps dirty state and tabs when save fails", async () => {
|
||||
const doc = await open();
|
||||
doc.saveError = new Error("external modification conflict");
|
||||
vi.mocked(ask).mockResolvedValue({ button: "save", checked: false });
|
||||
await expect(ctrl.closeActiveTab()).resolves.toBe(false);
|
||||
expect(findTab(useTabs.getState(), input().key)).not.toBeNull();
|
||||
expect(doc.isDirty()).toBe(true);
|
||||
expect(doc.frozen).toBe(false);
|
||||
expect(doc.closed).toBe(false);
|
||||
});
|
||||
|
||||
it("closing one split neither prompts nor stops its shared execution", async () => {
|
||||
const doc = await open();
|
||||
doc.running = true;
|
||||
const first = useTabs.getState().activePane;
|
||||
ctrl.split(first, "row");
|
||||
await ctrl.unsplit(first);
|
||||
expect(doc.running).toBe(true);
|
||||
expect(doc.closed).toBe(false);
|
||||
expect(doc.frozen).toBe(false);
|
||||
expect(ask).not.toHaveBeenCalled();
|
||||
vi.mocked(ask).mockResolvedValue({ button: "discard", checked: false });
|
||||
await ctrl.closeInput(input().key);
|
||||
expect(doc.releases).toBe(1);
|
||||
expect(doc.running).toBe(false);
|
||||
});
|
||||
|
||||
it("does not discard shared state when a split opens while a close prompt awaits", async () => {
|
||||
const doc = await open();
|
||||
const first = useTabs.getState().activePane;
|
||||
let answer!: (value: { button: string; checked: boolean }) => void;
|
||||
const asked = new Promise<void>((resolve) => {
|
||||
vi.mocked(ask).mockImplementationOnce(() => {
|
||||
resolve();
|
||||
return new Promise((r) => {
|
||||
answer = r;
|
||||
});
|
||||
});
|
||||
});
|
||||
const closing = ctrl.closeAll(first);
|
||||
await asked;
|
||||
ctrl.split(first, "row");
|
||||
answer({ button: "discard", checked: false });
|
||||
await closing;
|
||||
expect(paneIds(useTabs.getState())).toHaveLength(1);
|
||||
expect(findTab(useTabs.getState(), input().key)).not.toBeNull();
|
||||
expect(doc.text).toBe("edited");
|
||||
expect(doc.closed).toBe(false);
|
||||
expect(doc.frozen).toBe(false);
|
||||
});
|
||||
|
||||
it("saves a split document once and reports failures after trying every document", async () => {
|
||||
const a = await open();
|
||||
ctrl.split(useTabs.getState().activePane, "row");
|
||||
const b = await open("b");
|
||||
a.saveError = new Error("disk full");
|
||||
await expect(ctrl.saveAll()).rejects.toThrow("disk full");
|
||||
expect(a.saved).toBe("original");
|
||||
expect(a.isDirty()).toBe(true);
|
||||
expect(b.saved).toBe("edited");
|
||||
a.saveError = null;
|
||||
await ctrl.saveAll();
|
||||
expect(a.saves).toBe(1);
|
||||
expect(b.saves).toBe(1);
|
||||
});
|
||||
|
||||
it("never previews or evicts a dirty custom editor under LRU pressure", async () => {
|
||||
useProject.setState({
|
||||
settings: { files: { autoSave: false }, editor: { tabs: { tabLimit: 1, closingPolicy: "lru" } } } as never,
|
||||
});
|
||||
const doc = await open();
|
||||
await ctrl.openInput({ kind: "read-only-test", key: "other" }, { preview: true, focus: false });
|
||||
const tab = findTab(useTabs.getState(), input().key)?.tab;
|
||||
expect(tab?.preview).toBe(false);
|
||||
expect(doc.closed).toBe(false);
|
||||
expect(doc.text).toBe("edited");
|
||||
expect(findTab(useTabs.getState(), "other")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("Close Unmodified retains pending custom changes", async () => {
|
||||
const doc = await open();
|
||||
doc.text = doc.saved;
|
||||
doc.pending = "new typing";
|
||||
await ctrl.openInput({ kind: "read-only-test", key: "other" }, { focus: false });
|
||||
await ctrl.closeUnmodified(useTabs.getState().activePane);
|
||||
expect(findTab(useTabs.getState(), input().key)).not.toBeNull();
|
||||
expect(findTab(useTabs.getState(), "other")).toBeNull();
|
||||
expect(doc.pending).toBe("new typing");
|
||||
});
|
||||
|
||||
it("Close Read-Only retains a dirty custom document on Cancel and releases it after Discard", async () => {
|
||||
const doc = await open();
|
||||
const pane = useTabs.getState().activePane;
|
||||
useDocMeta.setState({ [input().path!]: { readOnly: true } as never });
|
||||
const cancelled = await ctrl.closeReadOnly(pane);
|
||||
expect(findTab(useTabs.getState(), input().key)).not.toBeNull();
|
||||
expect(doc.text).toBe("edited");
|
||||
expect(doc.closed).toBe(false);
|
||||
expect(cancelled).toBe(false);
|
||||
|
||||
vi.mocked(ask).mockResolvedValueOnce({ button: "discard", checked: false });
|
||||
expect(await ctrl.closeReadOnly(pane)).toBe(true);
|
||||
expect(findTab(useTabs.getState(), input().key)).toBeNull();
|
||||
expect(doc.saved).toBe("original");
|
||||
expect(doc.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("Unsplit All preserves cancelled documents and only releases their last removed view", async () => {
|
||||
const kept = await open();
|
||||
const keepPane = useTabs.getState().activePane;
|
||||
ctrl.split(keepPane, "row");
|
||||
const removedPane = useTabs.getState().activePane;
|
||||
const removed = await open("removed");
|
||||
|
||||
const cancelled = await ctrl.unsplitAll(keepPane);
|
||||
expect(paneIds(useTabs.getState())).toContain(removedPane);
|
||||
expect(findTab(useTabs.getState(), input("removed").key)).not.toBeNull();
|
||||
expect(removed.text).toBe("edited");
|
||||
expect(removed.closed).toBe(false);
|
||||
expect(cancelled).toBe(false);
|
||||
|
||||
vi.mocked(ask).mockResolvedValueOnce({ button: "discard", checked: false });
|
||||
expect(await ctrl.unsplitAll(keepPane)).toBe(true);
|
||||
expect(paneIds(useTabs.getState())).toEqual([keepPane]);
|
||||
expect(removed.saved).toBe("original");
|
||||
expect(removed.closed).toBe(true);
|
||||
expect(kept.text).toBe("edited");
|
||||
expect(kept.closed).toBe(false);
|
||||
});
|
||||
|
||||
it("prepares hidden documents, deduplicates split state, and resumes after a failed project transition", async () => {
|
||||
const a = await open();
|
||||
ctrl.split(useTabs.getState().activePane, "row");
|
||||
const b = await open("b");
|
||||
b.running = true;
|
||||
const session = await ctrl.prepareClose();
|
||||
expect(session.dirty).toBe(2);
|
||||
expect(a.frozen).toBe(true);
|
||||
expect(b.running).toBe(false);
|
||||
await ctrl.saveAll();
|
||||
expect(b.saved).toBe("edited\nfinal output");
|
||||
session.commit();
|
||||
ctrl.cancelClose();
|
||||
expect(a.frozen).toBe(false);
|
||||
expect(b.frozen).toBe(false);
|
||||
expect(a.closed).toBe(false);
|
||||
expect(findTab(useTabs.getState(), input().key)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("project replacement releases old split documents once, without saving discarded changes", async () => {
|
||||
const doc = await open();
|
||||
ctrl.split(useTabs.getState().activePane, "row");
|
||||
const session = await ctrl.prepareClose();
|
||||
await session.discard();
|
||||
session.commit();
|
||||
useProject.setState({ project: { root: "/new-project", name: "new", settings: {} } as never });
|
||||
await ctrl.openInput({ kind: "read-only-test", key: "new-project-tab" }, { focus: false });
|
||||
expect(doc.saved).toBe("original");
|
||||
expect(doc.saves).toBe(0);
|
||||
expect(doc.releases).toBe(1);
|
||||
expect(findTab(useTabs.getState(), input().key)).toBeNull();
|
||||
expect(findTab(useTabs.getState(), "new-project-tab")).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each(["all", "others", "left", "right", "unsplit"] as const)(
|
||||
"%s respects a last-document Cancel without removing other tabs",
|
||||
async (route) => {
|
||||
const marker: EditorInput = { kind: "read-only-test", key: "marker" };
|
||||
if (route !== "left") await ctrl.openInput(marker, { focus: false });
|
||||
const doc = await open();
|
||||
const pane = useTabs.getState().activePane;
|
||||
if (route === "left") await ctrl.openInput(marker, { focus: false });
|
||||
if (route === "unsplit") await ctrl.openInput(marker, { side: true, focus: false });
|
||||
const markerTab = findTab(useTabs.getState(), marker.key)!.tab.id;
|
||||
let result: boolean;
|
||||
switch (route) {
|
||||
case "all":
|
||||
result = await ctrl.closeAll(pane);
|
||||
break;
|
||||
case "others":
|
||||
result = await ctrl.closeOthers(pane, markerTab);
|
||||
break;
|
||||
case "left":
|
||||
result = await ctrl.closeToLeft(pane, markerTab);
|
||||
break;
|
||||
case "right":
|
||||
result = await ctrl.closeToRight(pane, markerTab);
|
||||
break;
|
||||
case "unsplit":
|
||||
result = await ctrl.unsplit(pane);
|
||||
break;
|
||||
}
|
||||
expect(result).toBe(false);
|
||||
expect(findTab(useTabs.getState(), input().key)).not.toBeNull();
|
||||
expect(findTab(useTabs.getState(), marker.key)).not.toBeNull();
|
||||
expect(doc.text).toBe("edited");
|
||||
expect(doc.closed).toBe(false);
|
||||
expect(doc.frozen).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps newly opened tabs when a bulk close completes", async () => {
|
||||
await open();
|
||||
let answer!: (value: { button: string; checked: boolean }) => void;
|
||||
let prompted!: () => void;
|
||||
const asked = new Promise<void>((resolve) => {
|
||||
prompted = resolve;
|
||||
});
|
||||
vi.mocked(ask).mockImplementationOnce(() => {
|
||||
prompted();
|
||||
return new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
});
|
||||
const closing = ctrl.closeEverything();
|
||||
await asked;
|
||||
const opening = ctrl.openInput({ kind: "read-only-test", key: "new-tab" }, { focus: false });
|
||||
await vi.waitFor(() => expect(findTab(useTabs.getState(), "new-tab")).not.toBeNull());
|
||||
answer({ button: "discard", checked: false });
|
||||
await Promise.all([closing, opening]);
|
||||
expect(findTab(useTabs.getState(), "new-tab")).not.toBeNull();
|
||||
expect(findTab(useTabs.getState(), input().key)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not prompt or blur-autosave a document until execution has drained", async () => {
|
||||
const doc = await open();
|
||||
useProject.setState({ settings: { files: { autoSave: true } } as never });
|
||||
let drain!: () => void;
|
||||
doc.prepareGate = new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
});
|
||||
doc.running = true;
|
||||
const closing = ctrl.closeInput(input().key);
|
||||
await vi.waitFor(() => expect(doc.frozen).toBe(true));
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
expect(doc.saved).toBe("original");
|
||||
expect(ask).not.toHaveBeenCalled();
|
||||
drain();
|
||||
await closing;
|
||||
expect(doc.text).toBe("edited\nfinal output");
|
||||
expect(doc.saved).toBe("original");
|
||||
expect(doc.frozen).toBe(false);
|
||||
expect(doc.closed).toBe(false);
|
||||
});
|
||||
|
||||
it("unfreezes every prepared document when another preparation fails", async () => {
|
||||
const a = await open();
|
||||
const b = await open("b");
|
||||
b.prepareError = new Error("pending source could not be flushed");
|
||||
await expect(ctrl.prepareClose()).rejects.toThrow("could not be flushed");
|
||||
expect(a.frozen).toBe(false);
|
||||
expect(b.frozen).toBe(false);
|
||||
expect(a.text).toBe("edited");
|
||||
expect(b.text).toBe("edited");
|
||||
expect(findTab(useTabs.getState(), input().key)).not.toBeNull();
|
||||
expect(findTab(useTabs.getState(), input("b").key)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("evicts a clean custom document through its last-reference lifecycle", async () => {
|
||||
useProject.setState({ settings: { files: { autoSave: false }, editor: { tabs: { tabLimit: 1 } } } as never });
|
||||
const doc = await open();
|
||||
doc.text = doc.saved;
|
||||
await ctrl.openInput({ kind: "read-only-test", key: "new-tab" }, { focus: false });
|
||||
expect(findTab(useTabs.getState(), input().key)).toBeNull();
|
||||
expect(findTab(useTabs.getState(), "new-tab")).not.toBeNull();
|
||||
expect(doc.releases).toBe(1);
|
||||
expect(doc.saved).toBe("original");
|
||||
expect(ask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases old documents when a replacement project has the same root", async () => {
|
||||
useProject.setState({ project: { root: "/p", name: "first", settings: {} } as never });
|
||||
const doc = await open();
|
||||
const session = await ctrl.prepareClose();
|
||||
await session.discard();
|
||||
session.commit();
|
||||
useProject.setState({ opening: true });
|
||||
useProject.setState({ project: { root: "/p", name: "replacement", settings: {} } as never });
|
||||
useProject.setState({ opening: false });
|
||||
await ctrl.openInput({ kind: "read-only-test", key: "replacement-tab" }, { focus: false });
|
||||
expect(doc.releases).toBe(1);
|
||||
expect(findTab(useTabs.getState(), input().key)).toBeNull();
|
||||
expect(findTab(useTabs.getState(), "replacement-tab")).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each(["cancel", "save"] as const)(
|
||||
"does not apply an earlier bulk discard when a later %s aborts closing",
|
||||
async (button) => {
|
||||
const a = await open();
|
||||
const b = await open("b");
|
||||
b.saveError = new Error("disk full");
|
||||
vi.mocked(ask)
|
||||
.mockResolvedValueOnce({ button: "discard", checked: false })
|
||||
.mockResolvedValueOnce({ button, checked: false });
|
||||
await expect(ctrl.closeEverything()).resolves.toBe(false);
|
||||
expect(a.text).toBe("edited");
|
||||
expect(a.saved).toBe("original");
|
||||
expect(b.text).toBe("edited");
|
||||
expect(a.frozen).toBe(false);
|
||||
expect(b.frozen).toBe(false);
|
||||
expect(a.closed).toBe(false);
|
||||
expect(b.closed).toBe(false);
|
||||
expect(findTab(useTabs.getState(), input().key)).not.toBeNull();
|
||||
expect(findTab(useTabs.getState(), input("b").key)).not.toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("guards a document that becomes the last split reference during another close prompt", async () => {
|
||||
const a = await open();
|
||||
const first = useTabs.getState().activePane;
|
||||
const firstTab = findTab(useTabs.getState(), input().key, first)!.tab.id;
|
||||
ctrl.split(first, "row");
|
||||
const second = useTabs.getState().activePane;
|
||||
const b = await open("b");
|
||||
await ctrl.openInput({ kind: "read-only-test", key: "marker" }, { focus: false });
|
||||
const marker = findTab(useTabs.getState(), "marker")!.tab.id;
|
||||
let answer!: (value: { button: string; checked: boolean }) => void;
|
||||
const asked = new Promise<void>((resolve) => {
|
||||
vi.mocked(ask).mockImplementationOnce(() => {
|
||||
resolve();
|
||||
return new Promise((r) => {
|
||||
answer = r;
|
||||
});
|
||||
});
|
||||
});
|
||||
const closing = ctrl.closeOthers(second, marker);
|
||||
await asked;
|
||||
// Moving onto the existing split merges its duplicate, leaving the
|
||||
// originally targeted tab as the only remaining reference.
|
||||
ctrl.moveTab(first, firstTab, second, 0);
|
||||
answer({ button: "discard", checked: false });
|
||||
await expect(closing).resolves.toBe(false);
|
||||
expect(a.text).toBe("edited");
|
||||
expect(b.text).toBe("edited");
|
||||
expect(a.closed).toBe(false);
|
||||
expect(b.closed).toBe(false);
|
||||
expect(a.frozen).toBe(false);
|
||||
expect(b.frozen).toBe(false);
|
||||
expect(findTab(useTabs.getState(), input().key)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("retains a tab that becomes dirty while a tab-limit close drains execution", async () => {
|
||||
useProject.setState({ settings: { files: { autoSave: false }, editor: { tabs: { tabLimit: 1 } } } as never });
|
||||
const doc = await open();
|
||||
doc.text = doc.saved;
|
||||
doc.running = true;
|
||||
let drain!: () => void;
|
||||
doc.prepareGate = new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
});
|
||||
const opening = ctrl.openInput({ kind: "read-only-test", key: "other" }, { focus: false });
|
||||
await vi.waitFor(() => expect(doc.frozen).toBe(true));
|
||||
drain();
|
||||
await opening;
|
||||
expect(doc.text).toBe("original\nfinal output");
|
||||
expect(doc.saved).toBe("original");
|
||||
expect(doc.closed).toBe(false);
|
||||
expect(doc.frozen).toBe(false);
|
||||
expect(findTab(useTabs.getState(), input().key)).not.toBeNull();
|
||||
expect(ask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attempts remaining saves when a lifecycle callback throws synchronously", async () => {
|
||||
const a = await open();
|
||||
const b = await open("b");
|
||||
a.save = () => {
|
||||
throw new Error("session unavailable");
|
||||
};
|
||||
await expect(ctrl.saveAll()).rejects.toThrow("session unavailable");
|
||||
expect(a.saved).toBe("original");
|
||||
expect(a.text).toBe("edited");
|
||||
expect(b.saved).toBe("edited");
|
||||
});
|
||||
|
||||
it("rejects authorization from a cancelled project close session", async () => {
|
||||
const doc = await open();
|
||||
const session = await ctrl.prepareClose();
|
||||
session.cancel();
|
||||
expect(() => session.commit()).toThrow();
|
||||
expect(doc.frozen).toBe(false);
|
||||
expect(doc.text).toBe("edited");
|
||||
expect(findTab(useTabs.getState(), input().key)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not return an authorized session after preparation was cancelled in flight", async () => {
|
||||
const doc = await open();
|
||||
let drain!: () => void;
|
||||
doc.prepareGate = new Promise<void>((resolve) => {
|
||||
drain = resolve;
|
||||
});
|
||||
const preparing = ctrl.prepareClose();
|
||||
await vi.waitFor(() => expect(doc.frozen).toBe(true));
|
||||
ctrl.cancelClose();
|
||||
drain();
|
||||
await expect(preparing).rejects.toThrow("cancelled");
|
||||
expect(doc.frozen).toBe(false);
|
||||
expect(doc.text).toBe("edited");
|
||||
expect(doc.closed).toBe(false);
|
||||
expect(findTab(useTabs.getState(), input().key)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -47,6 +47,9 @@ export function activate(): void | (() => void) {
|
||||
onActiveInputChange: (fn) => ctrl.onActiveInputChange(fn),
|
||||
closePath: (path) => ctrl.closePath(path),
|
||||
closeInput: (key) => ctrl.closeInput(key),
|
||||
prepareClose: () => ctrl.prepareClose(),
|
||||
cancelClose: () => ctrl.cancelClose(),
|
||||
documentHistory: (command) => ctrl.documentHistory(command),
|
||||
reformat: async (path) => {
|
||||
const handle = editorsFor(path)[0];
|
||||
return !!handle && (await formatDocument(handle.view, { explicit: false, selectionOnly: false })).applied;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Documents of custom editor kinds (`EditorKindContribution.document`, e.g.
|
||||
* notebooks): one per input key, however many tabs and splits show it. The
|
||||
* editor controller keeps them alive while any tab shows the key and closes
|
||||
* them after the last one goes.
|
||||
*/
|
||||
import type { EditorDocumentLifecycle, EditorInput, EditorKindContribution } from "@/workbench/contributions";
|
||||
import { openKeys, paneIds, type TabsState } from "./tabs";
|
||||
|
||||
export interface CustomDocument {
|
||||
input: EditorInput;
|
||||
lifecycle: EditorDocumentLifecycle;
|
||||
}
|
||||
|
||||
type Kinds = ReadonlyMap<string, EditorKindContribution>;
|
||||
|
||||
export function documentLifecycle(input: EditorInput, kinds: Kinds): EditorDocumentLifecycle | undefined {
|
||||
return kinds.get(input.kind)?.document;
|
||||
}
|
||||
|
||||
/** Open custom documents by input key, in visual tab order (the first tab's input). */
|
||||
export function customDocuments(s: TabsState, kinds: Kinds): Map<string, CustomDocument> {
|
||||
const out = new Map<string, CustomDocument>();
|
||||
for (const id of paneIds(s))
|
||||
for (const t of s.panes[id]?.tabs ?? []) {
|
||||
if (out.has(t.input.key)) continue;
|
||||
const lifecycle = documentLifecycle(t.input, kinds);
|
||||
if (lifecycle) out.set(t.input.key, { input: t.input, lifecycle });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Custom documents open in `prev` that no tab of `next` shows any more. */
|
||||
export function closedDocuments(prev: TabsState, next: TabsState, kinds: Kinds): CustomDocument[] {
|
||||
const open = openKeys(next);
|
||||
return [...customDocuments(prev, kinds).values()].filter((d) => !open.has(d.input.key));
|
||||
}
|
||||
|
||||
/** Unsaved changes; a failing check counts as unsaved so nothing is dropped on its word. */
|
||||
export function documentDirty(d: CustomDocument): boolean {
|
||||
try {
|
||||
return d.lifecycle.isDirty(d.input);
|
||||
} catch (e) {
|
||||
console.warn(`editor document ${d.input.key}: isDirty failed`, e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the tab's input is a custom document with unsaved changes; null when it is no custom document. */
|
||||
export function inputDirty(input: EditorInput, kinds: Kinds): boolean | null {
|
||||
const lifecycle = documentLifecycle(input, kinds);
|
||||
return lifecycle ? documentDirty({ input, lifecycle }) : null;
|
||||
}
|
||||
|
||||
/** The rejection reasons of `results` (an AggregateError contributes each of its errors). */
|
||||
export function failures(results: readonly PromiseSettledResult<unknown>[]): unknown[] {
|
||||
return results.flatMap((r) =>
|
||||
r.status === "fulfilled" ? [] : r.reason instanceof AggregateError ? r.reason.errors : [r.reason],
|
||||
);
|
||||
}
|
||||
|
||||
/** One error for `errors` (non-empty): itself when single, else an AggregateError with the first message. */
|
||||
export function combinedError(errors: readonly unknown[]): unknown {
|
||||
if (errors.length === 1) return errors[0];
|
||||
const first = errors[0];
|
||||
return new AggregateError(errors, first instanceof Error ? first.message : String(first));
|
||||
}
|
||||
@@ -100,6 +100,28 @@ describe("DocumentManager + slots", () => {
|
||||
expect(backend.docs.size).toBe(0);
|
||||
expect(manager.get("/p/a.txt")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Save All rejects a failed document save after saving the remaining dirty documents", async () => {
|
||||
const { backend, manager } = setup({ "/p/a.txt": "a\n", "/p/b.txt": "b\n" }, { files: { autoSave: false } });
|
||||
try {
|
||||
const a = await manager.acquire("/p/a.txt");
|
||||
const b = await manager.acquire("/p/b.txt");
|
||||
backend.patch(a.doc, [{ from: 0, to: 0, insert: "changed " }]);
|
||||
backend.patch(b.doc, [{ from: 0, to: 0, insert: "changed " }]);
|
||||
const save = backend.save.bind(backend);
|
||||
vi.spyOn(backend, "save").mockImplementation(async (id) => {
|
||||
if (id === a.doc) throw new Error("disk full");
|
||||
await save(id);
|
||||
});
|
||||
await expect(manager.saveAll("explicit")).rejects.toThrow("disk full");
|
||||
expect(backend.files.get("/p/a.txt")).toBe("a\n");
|
||||
expect(backend.files.get("/p/b.txt")).toBe("changed b\n");
|
||||
expect(a.getMeta().dirty).toBe(true);
|
||||
expect(b.getMeta().dirty).toBe(false);
|
||||
} finally {
|
||||
manager.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("save cleanup", () => {
|
||||
|
||||
@@ -735,6 +735,7 @@ export class DocumentManager {
|
||||
for (const m of this.byPath.values()) m.applySettings();
|
||||
}
|
||||
|
||||
/** Saves every document; failures are notified, and an explicit Save All also rejects with them. */
|
||||
async saveAll(reason: "explicit" | "auto") {
|
||||
const errors: unknown[] = [];
|
||||
await Promise.all(
|
||||
@@ -747,6 +748,8 @@ export class DocumentManager {
|
||||
if (errors.length) {
|
||||
const e = errors[0];
|
||||
this.notify({ level: "error", title: "Could not save", body: e instanceof Error ? e.message : String(e) });
|
||||
if (reason === "explicit")
|
||||
throw errors.length === 1 ? e : new AggregateError(errors, e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,9 +310,9 @@ export function reopenClosed(s: TabsState): { state: TabsState; tabId: string; p
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab limit: closes tabs of `paneId` beyond `limit` (pinned and `keep` tabs
|
||||
* excluded). `unchanged` closes the least recently used unmodified tab first,
|
||||
* `lru` the least recently used one.
|
||||
* Tab limit: closes tabs of `paneId` beyond `limit` (pinned, `keep` and
|
||||
* `isProtected` tabs excluded). `unchanged` closes the least recently used
|
||||
* unmodified tab first, `lru` the least recently used one.
|
||||
*/
|
||||
export function enforceTabLimit(
|
||||
s: TabsState,
|
||||
@@ -321,12 +321,13 @@ export function enforceTabLimit(
|
||||
policy: string,
|
||||
isDirty: (tab: Tab) => boolean,
|
||||
keep?: string,
|
||||
isProtected?: (tab: Tab) => boolean,
|
||||
): TabsState {
|
||||
let next = s;
|
||||
for (;;) {
|
||||
const pane = next.panes[paneId];
|
||||
if (!pane || limit <= 0) return next;
|
||||
const closable = pane.tabs.filter((t) => !t.pinned && t.id !== keep && t.id !== pane.active);
|
||||
const closable = pane.tabs.filter((t) => !t.pinned && t.id !== keep && t.id !== pane.active && !isProtected?.(t));
|
||||
if (pane.tabs.filter((t) => !t.pinned).length <= limit || !closable.length) return next;
|
||||
const lruOrder = [...closable].sort((a, b) => rank(pane, b) - rank(pane, a));
|
||||
const victim = (policy === "unchanged" ? lruOrder.find((t) => !isDirty(t)) : undefined) ?? lruOrder[0]!;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
textEditorWrapperFor,
|
||||
useContributions,
|
||||
useFileDecorations,
|
||||
useEditorDocumentRevision,
|
||||
useTextEditorWrappers,
|
||||
type EditorInput,
|
||||
} from "@/workbench/contributions";
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
} from "@/ui";
|
||||
import { editorController, isTextInput, tabIcon, tabTitle, useTabs, type VirtualInputData } from "../controller";
|
||||
import { useDocMeta } from "../model/document";
|
||||
import { inputDirty } from "../model/customDocuments";
|
||||
import type { LayoutNode, SplitEdge, Tab } from "../model/tabs";
|
||||
import { disambiguateTitles } from "../model/tabTitles";
|
||||
import { TextEditorHost } from "./TextEditorHost";
|
||||
@@ -130,7 +132,9 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
const kinds = useContributions((s) => s.editorKinds);
|
||||
const tabs = useMemo(() => pane?.tabs ?? [], [pane]);
|
||||
const paths = tabs.map((t) => t.input.path ?? "");
|
||||
const dirty = useDocMeta(useShallow((s) => paths.map((p) => !!s[p]?.dirty)));
|
||||
const textDirty = useDocMeta(useShallow((s) => paths.map((p) => !!s[p]?.dirty)));
|
||||
useEditorDocumentRevision();
|
||||
const dirty = tabs.map((tab, i) => inputDirty(tab.input, kinds) ?? textDirty[i] ?? false);
|
||||
const colors = useFileDecorations(useShallow(() => paths.map((p) => (p ? (getFileDecoration(p)?.color ?? "") : ""))));
|
||||
const tints = useFileDecorations(useShallow(() => paths.map((p) => (p ? (getFileDecoration(p)?.tint ?? "") : ""))));
|
||||
|
||||
@@ -257,8 +261,8 @@ function PaneView({ paneId }: { paneId: string }) {
|
||||
layout={tabPrefs.multipleRows ? "wrap" : tabPrefs.oneRowOverflow === "squeeze" ? "squeeze" : "scroll"}
|
||||
closePosition={tabPrefs.closeButtonPosition === "left" ? "left" : "right"}
|
||||
onSelect={(id) => ctrl.activate(paneId, id)}
|
||||
onClose={(id) => ctrl.closeTab(paneId, id)}
|
||||
onCloseOthers={(id) => ctrl.closeOthers(paneId, id)}
|
||||
onClose={(id) => void ctrl.closeTab(paneId, id)}
|
||||
onCloseOthers={(id) => void ctrl.closeOthers(paneId, id)}
|
||||
onDoubleClick={(id) => {
|
||||
// IDEA: a preview tab becomes a regular one; any other tab maximizes the editor.
|
||||
if (pane.tabs.find((t) => t.id === id)?.preview) ctrl.keepTab(paneId, id);
|
||||
|
||||
@@ -180,7 +180,7 @@ export async function connect(): Promise<void> {
|
||||
for (const t of editors.openTabs?.() ?? []) {
|
||||
const task = (t.input.data as { task?: string } | undefined)?.task;
|
||||
if (t.input.kind === SESSION_KIND && task && !useAgents.getState().tasks.has(task))
|
||||
editors.closeInput?.(t.input.key);
|
||||
await editors.closeInput?.(t.input.key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ function onEvent(e: AgentEvent) {
|
||||
const unread = new Set(s.unread);
|
||||
unread.delete(e.id);
|
||||
useAgents.setState({ tasks, composers, unread });
|
||||
editorService().closeInput?.(sessionKey(e.id));
|
||||
void editorService().closeInput?.(sessionKey(e.id));
|
||||
break;
|
||||
}
|
||||
case "truncated": {
|
||||
@@ -366,8 +366,7 @@ export async function startTask(): Promise<void> {
|
||||
}));
|
||||
remember(composer.text.trim());
|
||||
updateComposer(HOME, emptyDraft());
|
||||
editorService().closeInput?.(HOME_KEY);
|
||||
openTask(task.id);
|
||||
if ((await editorService().closeInput?.(HOME_KEY)) !== false) openTask(task.id);
|
||||
}
|
||||
|
||||
function remember(text: string) {
|
||||
|
||||
@@ -126,7 +126,10 @@ export async function runCodeCleanup(choice: ScopeChoice, options: CleanupOption
|
||||
});
|
||||
await executeAction("code.cleanupFile", { path, ...options });
|
||||
await editors?.saveAll();
|
||||
if (!wasOpen.has(path)) editors?.closePath?.(path);
|
||||
if (!wasOpen.has(path) && (await editors?.closePath?.(path)) === false) {
|
||||
run.cancelled = true;
|
||||
break;
|
||||
}
|
||||
done++;
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { confirmExitEnabled, decideClose, type CloseDeps, type CloseInput } from "../closeGuard";
|
||||
import type { MessageRequest as PromptRequest, MessageResult as PromptResult } from "@/workbench/messages";
|
||||
import type { EditorCloseSession } from "@/workbench/contributions";
|
||||
|
||||
function deps(answers: PromptResult[]) {
|
||||
const asked: PromptRequest[] = [];
|
||||
@@ -26,9 +27,8 @@ describe("decideClose", () => {
|
||||
});
|
||||
|
||||
it("confirms exit for the last window when enabled", async () => {
|
||||
const { d, asked } = deps([{ button: "exit", checked: false }]);
|
||||
const { d } = deps([{ button: "exit", checked: false }]);
|
||||
await expect(decideClose({ ...base, confirmExit: true }, d)).resolves.toBe(true);
|
||||
expect(asked[0]?.title).toBe("Confirm Exit");
|
||||
expect(d.disableConfirmExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -51,9 +51,8 @@ describe("decideClose", () => {
|
||||
});
|
||||
|
||||
it("saves all on Save", async () => {
|
||||
const { d, asked } = deps([{ button: "save", checked: false }]);
|
||||
const { d } = deps([{ button: "save", checked: false }]);
|
||||
await expect(decideClose({ ...base, dirty: 2 }, d)).resolves.toBe(true);
|
||||
expect(asked[0]?.body).toBe("2 files have unsaved changes in openidea. Save before closing?");
|
||||
expect(d.saveAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -79,4 +78,83 @@ describe("decideClose", () => {
|
||||
expect(confirmExitEnabled({ general: {} })).toBe(false);
|
||||
expect(confirmExitEnabled(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("includes dirty changes produced while preparing a custom document", async () => {
|
||||
const { d } = deps([{ button: "save", checked: false }]);
|
||||
let text = "old output";
|
||||
let saved = "";
|
||||
let frozen = false;
|
||||
const session: EditorCloseSession = {
|
||||
dirty: 1,
|
||||
discard: async () => {
|
||||
text = "";
|
||||
},
|
||||
cancel: () => {
|
||||
frozen = false;
|
||||
},
|
||||
commit: () => {
|
||||
frozen = true;
|
||||
},
|
||||
};
|
||||
d.prepareClose = async () => {
|
||||
frozen = true;
|
||||
text = "final execution output";
|
||||
return session;
|
||||
};
|
||||
d.saveAll = async () => {
|
||||
expect(frozen).toBe(true);
|
||||
saved = text;
|
||||
};
|
||||
await expect(decideClose(base, d)).resolves.toBe(true);
|
||||
expect(saved).toBe("final execution output");
|
||||
expect(frozen).toBe(true);
|
||||
});
|
||||
|
||||
it.each(["cancel", "save", "discard"])(
|
||||
"resumes a prepared document when %s cannot authorize closing",
|
||||
async (button) => {
|
||||
const { d } = deps([{ button, checked: false }]);
|
||||
let frozen = true;
|
||||
let text = "unsaved source";
|
||||
const session: EditorCloseSession = {
|
||||
dirty: 1,
|
||||
discard: async () => {
|
||||
throw new Error("reload failed");
|
||||
},
|
||||
cancel: () => {
|
||||
frozen = false;
|
||||
},
|
||||
commit: () => {
|
||||
text = "closed";
|
||||
},
|
||||
};
|
||||
d.prepareClose = async () => session;
|
||||
d.saveAll = async () => {
|
||||
throw new Error("disk full");
|
||||
};
|
||||
await expect(decideClose(base, d)).resolves.toBe(false);
|
||||
expect(frozen).toBe(false);
|
||||
expect(text).toBe("unsaved source");
|
||||
},
|
||||
);
|
||||
|
||||
it("Don't Save discards prepared custom edits without saving them", async () => {
|
||||
const { d } = deps([{ button: "discard", checked: false }]);
|
||||
let text = "unsaved";
|
||||
let saved = "disk baseline";
|
||||
d.saveAll = async () => {
|
||||
saved = text;
|
||||
};
|
||||
d.prepareClose = async () => ({
|
||||
dirty: 1,
|
||||
discard: async () => {
|
||||
text = saved;
|
||||
},
|
||||
cancel: () => {},
|
||||
commit: () => {},
|
||||
});
|
||||
await expect(decideClose(base, d)).resolves.toBe(true);
|
||||
expect(saved).toBe("disk baseline");
|
||||
expect(text).toBe("disk baseline");
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { history } from "@codemirror/commands";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { editCommand } from "../appActions";
|
||||
import * as contributions from "@/workbench/contributions";
|
||||
|
||||
describe("Edit menu Undo / Redo", () => {
|
||||
let view: EditorView | null = null;
|
||||
afterEach(() => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("use the focused editor's history, including programmatic edits", () => {
|
||||
@@ -20,4 +22,32 @@ describe("Edit menu Undo / Redo", () => {
|
||||
editCommand("redo")();
|
||||
expect(view.state.doc.toString()).toBe("ab");
|
||||
});
|
||||
|
||||
it("routes custom document history before CodeMirror's independent local history", () => {
|
||||
const parent = document.createElement("div");
|
||||
parent.dataset.paneId = "custom-pane";
|
||||
document.body.appendChild(parent);
|
||||
view = new EditorView({ state: EditorState.create({ doc: "local", extensions: history() }), parent });
|
||||
view.dispatch({ changes: { from: 5, insert: " edit" }, userEvent: "input" });
|
||||
let source = "backend edited";
|
||||
vi.spyOn(contributions, "maybeEditorService").mockReturnValue({
|
||||
openFile: async () => {},
|
||||
openInput: async () => {},
|
||||
activePath: () => "/p/notebook.ipynb",
|
||||
activeCursor: () => null,
|
||||
saveAll: async () => {},
|
||||
documentHistory: (command) => {
|
||||
source = command === "undo" ? "backend original" : "backend edited";
|
||||
return true;
|
||||
},
|
||||
});
|
||||
view.contentDOM.focus();
|
||||
editCommand("undo")();
|
||||
expect(source).toBe("backend original");
|
||||
expect(view.state.doc.toString()).toBe("local edit");
|
||||
editCommand("redo")();
|
||||
expect(source).toBe("backend edited");
|
||||
expect(view.state.doc.toString()).toBe("local edit");
|
||||
parent.remove();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { EditorView } from "@codemirror/view";
|
||||
import type { Action } from "@/platform/actions";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { call, commands, isTauri } from "@/platform/ipc";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { showAbout } from "./AboutDialog";
|
||||
import { quitApp } from "./closeGuard";
|
||||
import { guessOs } from "./host";
|
||||
@@ -35,6 +35,12 @@ export function editCommand(command: "undo" | "redo" | "cut" | "copy" | "paste"
|
||||
return () => {
|
||||
if (command === "undo" || command === "redo") {
|
||||
const focused = document.activeElement;
|
||||
if (
|
||||
focused instanceof Element &&
|
||||
focused.closest("[data-editor-area], [data-pane-id]") &&
|
||||
maybeEditorService()?.documentHistory?.(command)
|
||||
)
|
||||
return;
|
||||
const view = focused instanceof HTMLElement ? EditorView.findFromDOM(focused) : null;
|
||||
if (view) {
|
||||
(command === "undo" ? undo : redo)(view);
|
||||
|
||||
@@ -10,16 +10,12 @@
|
||||
import { commands, isTauri, onIdeEvent, type Settings } from "@/platform/ipc";
|
||||
import { t } from "@/platform/i18n";
|
||||
import { closeProject, provideLeaveProjectGuard, useProject } from "@/platform/project";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { maybeEditorService, notifications, type EditorCloseSession } from "@/workbench/contributions";
|
||||
import { ask, type Ask } from "@/workbench/messages";
|
||||
|
||||
/** Document ids the backend reports dirty in this window. */
|
||||
const dirtyDocs = new Set<number>();
|
||||
|
||||
export function dirtyCount(): number {
|
||||
return dirtyDocs.size;
|
||||
}
|
||||
|
||||
export function confirmExitEnabled(settings: Settings | null | undefined): boolean {
|
||||
return settings?.general?.confirmExit === true;
|
||||
}
|
||||
@@ -37,6 +33,8 @@ export interface CloseDeps {
|
||||
saveAll: () => Promise<void>;
|
||||
disableConfirmExit: () => Promise<void>;
|
||||
onSaveFailed: (e: unknown) => void;
|
||||
/** Stops custom document edits/execution before reading their final dirty state. */
|
||||
prepareClose?: () => Promise<EditorCloseSession | undefined>;
|
||||
}
|
||||
|
||||
/** Asks whatever must be asked; resolves true when the window may close. */
|
||||
@@ -51,27 +49,34 @@ export async function decideClose(input: CloseInput, deps: CloseDeps): Promise<b
|
||||
if (r.button !== "exit") return false;
|
||||
if (r.checked) await deps.disableConfirmExit().catch(() => {});
|
||||
}
|
||||
if (input.dirty > 0) {
|
||||
const r = await deps.ask({
|
||||
title: t("app.unsaved.title"),
|
||||
body: t("app.unsaved.body", { count: input.dirty, project: input.project ?? t("app.title.default") }),
|
||||
buttons: [
|
||||
{ id: "discard", label: t("app.unsaved.discard") },
|
||||
{ id: "save", label: t("app.unsaved.save"), primary: true },
|
||||
],
|
||||
icon: "warning",
|
||||
});
|
||||
if (r.button === "cancel") return false;
|
||||
if (r.button === "save") {
|
||||
try {
|
||||
await deps.saveAll();
|
||||
} catch (e) {
|
||||
deps.onSaveFailed(e);
|
||||
return false;
|
||||
}
|
||||
let session: EditorCloseSession | undefined;
|
||||
let authorized = false;
|
||||
try {
|
||||
session = await deps.prepareClose?.();
|
||||
const dirty = input.dirty + (session?.dirty ?? 0);
|
||||
if (dirty > 0) {
|
||||
const r = await deps.ask({
|
||||
title: t("app.unsaved.title"),
|
||||
body: t("app.unsaved.body", { count: dirty, project: input.project ?? t("app.title.default") }),
|
||||
buttons: [
|
||||
{ id: "discard", label: t("app.unsaved.discard") },
|
||||
{ id: "save", label: t("app.unsaved.save"), primary: true },
|
||||
],
|
||||
icon: "warning",
|
||||
});
|
||||
if (r.button === "save") await deps.saveAll();
|
||||
else if (r.button === "discard") await session?.discard();
|
||||
else return false;
|
||||
}
|
||||
session?.commit();
|
||||
authorized = true;
|
||||
return true;
|
||||
} catch (e) {
|
||||
deps.onSaveFailed(e);
|
||||
return false;
|
||||
} finally {
|
||||
if (!authorized) session?.cancel();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const defaultDeps: CloseDeps = {
|
||||
@@ -79,6 +84,7 @@ const defaultDeps: CloseDeps = {
|
||||
saveAll: async () => {
|
||||
await maybeEditorService()?.saveAll();
|
||||
},
|
||||
prepareClose: async () => maybeEditorService()?.prepareClose?.(),
|
||||
disableConfirmExit: async () => {
|
||||
const r = await commands.settingsSet("global", "general.confirmExit", "false");
|
||||
if (r.status === "ok") useProject.setState({ settings: r.data });
|
||||
@@ -110,13 +116,17 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
|
||||
quitting: ctx.quitting,
|
||||
windows: ctx.windows,
|
||||
confirmExit: confirmExitEnabled(s.settings),
|
||||
dirty: dirtyCount(),
|
||||
dirty: dirtyDocs.size,
|
||||
project: s.project?.name ?? null,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
if (!ok && ctx.quitting) await commands.appCancelQuit();
|
||||
return ok;
|
||||
} catch (e) {
|
||||
maybeEditorService()?.cancelClose?.();
|
||||
deps.onSaveFailed(e);
|
||||
return false;
|
||||
} finally {
|
||||
closing = false;
|
||||
}
|
||||
@@ -126,7 +136,7 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
|
||||
export function confirmLeave(deps: CloseDeps = defaultDeps): Promise<boolean> {
|
||||
const s = useProject.getState();
|
||||
return decideClose(
|
||||
{ quitting: false, windows: 2, confirmExit: false, dirty: dirtyCount(), project: s.project?.name ?? null },
|
||||
{ quitting: false, windows: 2, confirmExit: false, dirty: dirtyDocs.size, project: s.project?.name ?? null },
|
||||
deps,
|
||||
);
|
||||
}
|
||||
@@ -135,7 +145,10 @@ export function confirmLeave(deps: CloseDeps = defaultDeps): Promise<boolean> {
|
||||
export async function quitApp(deps: CloseDeps = defaultDeps): Promise<void> {
|
||||
if (!isTauri()) return;
|
||||
if (confirmExitEnabled(useProject.getState().settings)) {
|
||||
const ok = await decideClose({ quitting: false, windows: 1, confirmExit: true, dirty: 0, project: null }, deps);
|
||||
const ok = await decideClose(
|
||||
{ quitting: false, windows: 1, confirmExit: true, dirty: 0, project: null },
|
||||
{ ...deps, prepareClose: undefined },
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
await commands.appQuit();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Feature activation. Each feature exports `activate(): void | (() => void)`
|
||||
* that registers actions, tool windows, status items and services, returning
|
||||
* a disposer. The editor goes first because it provides the editor service.
|
||||
* a disposer. The editor provides the editor service; app must install its launch
|
||||
* barrier before welcome can restore a remembered project.
|
||||
*/
|
||||
import { activate as editor } from "@/editor/core";
|
||||
import { activate as editorLsp } from "@/editor/lsp";
|
||||
@@ -29,6 +30,7 @@ import { activate as tools, activateSettingsPages as toolsPages } from "./tools"
|
||||
import { activate as scratch } from "./scratch";
|
||||
import { activate as libraries } from "./libraries";
|
||||
import { activate as viewers } from "./viewers";
|
||||
import { activate as notebook } from "./notebook";
|
||||
import { activate as web } from "./web";
|
||||
import { activate as analysis } from "./analysis";
|
||||
import { activate as services } from "./services";
|
||||
@@ -46,6 +48,7 @@ type Activate = () => void | (() => void);
|
||||
const features: Array<[string, Activate]> = [
|
||||
["editor", editor],
|
||||
["editor-lsp", editorLsp],
|
||||
["app", app],
|
||||
["welcome", welcome],
|
||||
["project-tree", projectTree],
|
||||
["search", search],
|
||||
@@ -63,12 +66,12 @@ const features: Array<[string, Activate]> = [
|
||||
["templates", templates],
|
||||
["todo", todo],
|
||||
["settings", settings],
|
||||
["app", app],
|
||||
["perf", perf],
|
||||
["plugins", plugins],
|
||||
["tools", tools],
|
||||
["scratch", scratch],
|
||||
["libraries", libraries],
|
||||
["notebook", notebook],
|
||||
["viewers", viewers],
|
||||
["web", web],
|
||||
["analysis", analysis],
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { Annotation, Compartment, EditorState, Prec, StateEffect } from "@codemirror/state";
|
||||
import {
|
||||
EditorView,
|
||||
drawSelection,
|
||||
highlightActiveLine,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
} from "@codemirror/view";
|
||||
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { bracketMatching, indentOnInput, syntaxHighlighting } from "@codemirror/language";
|
||||
import { openideaTheme } from "@/editor/core/cm/theme";
|
||||
import { openideaHighlightStyle } from "@/editor/core/cm/highlight";
|
||||
import { findLanguageDescription, loadSupport } from "@/editor/core/cm/languages";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import type { NotebookSession } from "./session";
|
||||
import styles from "./Notebook.module.css";
|
||||
|
||||
const external = Annotation.define<boolean>();
|
||||
export function CellEditor({
|
||||
session,
|
||||
id,
|
||||
source,
|
||||
type,
|
||||
frozen,
|
||||
}: {
|
||||
session: NotebookSession;
|
||||
id: string;
|
||||
source: string;
|
||||
type: string;
|
||||
frozen: boolean;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const view = useRef<EditorView | null>(null);
|
||||
const readonly = useRef(new Compartment());
|
||||
const initialSource = useRef(source);
|
||||
useLayoutEffect(() => {
|
||||
if (!host.current) return;
|
||||
const language = new Compartment();
|
||||
const configuration = [
|
||||
openideaTheme,
|
||||
syntaxHighlighting(openideaHighlightStyle),
|
||||
lineNumbers(),
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
highlightSpecialChars(),
|
||||
bracketMatching(),
|
||||
indentOnInput(),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-z",
|
||||
run: () => {
|
||||
void session.undo().catch((e) => session.report(e));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Mod-Shift-z",
|
||||
run: () => {
|
||||
void session.redo().catch((e) => session.report(e));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Mod-y",
|
||||
run: () => {
|
||||
void session.redo().catch((e) => session.report(e));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
keymap.of([indentWithTab, ...defaultKeymap]),
|
||||
language.of([]),
|
||||
readonly.current.of(EditorState.readOnly.of(session.getState().frozen)),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.theme({ "&": { height: "auto" }, ".cm-scroller": { maxHeight: "640px", overflow: "auto" } }),
|
||||
EditorView.contentAttributes.of({
|
||||
"aria-label": tx(type === "code" ? "Python cell source" : "Markdown cell source"),
|
||||
}),
|
||||
EditorState.transactionFilter.of((transaction) =>
|
||||
transaction.docChanged && session.getState().frozen && !transaction.annotation(external) ? [] : transaction,
|
||||
),
|
||||
EditorView.updateListener.of((update) => {
|
||||
session.editors.set(id, update.state);
|
||||
if (update.docChanged && update.transactions.some((tr) => !tr.annotation(external))) {
|
||||
session.edit({ type: "setSource", cell: id, source: update.state.doc.toString() });
|
||||
}
|
||||
}),
|
||||
];
|
||||
// Reconfigure saved state to replace stale view closures; retain selection.
|
||||
const saved = session.editors.get(id);
|
||||
const state = saved
|
||||
? saved.update({ effects: StateEffect.reconfigure.of(configuration) }).state
|
||||
: EditorState.create({ doc: initialSource.current, extensions: configuration });
|
||||
const editor = new EditorView({ state, parent: host.current });
|
||||
view.current = editor;
|
||||
let alive = true;
|
||||
const description = findLanguageDescription(
|
||||
type === "code" ? "Python" : "Markdown",
|
||||
type === "code" ? "cell.py" : "cell.md",
|
||||
);
|
||||
if (description)
|
||||
void loadSupport(description).then((support) => {
|
||||
if (alive && support) editor.dispatch({ effects: language.reconfigure(support) });
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
session.editors.set(id, editor.state);
|
||||
view.current = null;
|
||||
editor.destroy();
|
||||
};
|
||||
}, [session, id, type]);
|
||||
useLayoutEffect(() => {
|
||||
const editor = view.current;
|
||||
if (!editor) return;
|
||||
if (editor.state.doc.toString() !== source)
|
||||
editor.dispatch({
|
||||
changes: { from: 0, to: editor.state.doc.length, insert: source },
|
||||
annotations: external.of(true),
|
||||
});
|
||||
editor.dispatch({ effects: readonly.current.reconfigure(EditorState.readOnly.of(frozen)) });
|
||||
}, [source, frozen]);
|
||||
return <div ref={host} className={styles.cellEditor} />;
|
||||
}
|
||||
|
||||
// No CodeMirror history extension: source and structural undo share backend history.
|
||||
@@ -0,0 +1,220 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
background: var(--bg-editor, var(--bg-app));
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
min-height: 36px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--border-editor-tabs, var(--border));
|
||||
font-size: var(--fs-small);
|
||||
}
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
margin: 0 4px;
|
||||
background: var(--border);
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.interpreter {
|
||||
min-width: 120px;
|
||||
max-width: 260px;
|
||||
}
|
||||
.kernel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 0 6px;
|
||||
color: var(--fg-muted);
|
||||
}
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
color: var(--danger, var(--fg));
|
||||
border-bottom: 1px solid var(--border);
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
}
|
||||
.errorMessage {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.errorMessage pre {
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
.kernelHelp {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
.cellList {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.virtualCell {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.cell {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small, 4px);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.selected {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.cellToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-height: 28px;
|
||||
padding: 2px 6px;
|
||||
background: var(--bg-elev, var(--bg-app));
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.executionCount {
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
font: var(--fs-small) var(--font-mono);
|
||||
color: var(--fg-muted);
|
||||
margin-right: 6px;
|
||||
}
|
||||
.cellEditor {
|
||||
min-height: 48px;
|
||||
}
|
||||
.outputs {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 8px 12px;
|
||||
overflow: auto;
|
||||
max-height: 640px;
|
||||
user-select: text;
|
||||
}
|
||||
.textOutput,
|
||||
.errorOutput,
|
||||
.rawCell pre {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font: var(--fs-code) / var(--lh-code, 1.5) var(--font-mono);
|
||||
margin: 0;
|
||||
user-select: text;
|
||||
}
|
||||
.errorOutput {
|
||||
color: var(--danger, var(--fg));
|
||||
}
|
||||
.outputImage {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.outputFrame {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
min-height: 80px;
|
||||
max-height: 600px;
|
||||
border: 1px solid var(--border);
|
||||
background: white;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.notice {
|
||||
color: var(--fg-muted);
|
||||
font-size: var(--fs-small);
|
||||
padding: 4px 0;
|
||||
margin: 0;
|
||||
user-select: text;
|
||||
}
|
||||
.rawCell {
|
||||
padding: 8px 12px;
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
}
|
||||
.markdown {
|
||||
padding: 8px 14px;
|
||||
min-height: 32px;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.6;
|
||||
user-select: text;
|
||||
}
|
||||
.markdown > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.markdown > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.markdown img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.markdown pre {
|
||||
overflow: auto;
|
||||
padding: 8px;
|
||||
background: var(--bg-elev);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.markdown code {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.markdown a {
|
||||
color: var(--link);
|
||||
cursor: pointer;
|
||||
}
|
||||
.markdown table {
|
||||
border-collapse: collapse;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
}
|
||||
.markdown th,
|
||||
.markdown td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.addBar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px 32px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.virtualCell {
|
||||
padding: 5px;
|
||||
}
|
||||
.toolbar {
|
||||
gap: 2px;
|
||||
}
|
||||
.interpreter {
|
||||
max-width: 180px;
|
||||
}
|
||||
.cellToolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { NotebookCell } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { Button, Dialog, EmptyState, IconButton, Select, Spinner } from "@/ui";
|
||||
import type { EditorInput } from "@/workbench/contributions";
|
||||
import { CellEditor } from "./CellEditor";
|
||||
import { CellOutputs, MarkdownCell } from "./OutputView";
|
||||
import { notebookSession, type NotebookSession } from "./session";
|
||||
import styles from "./Notebook.module.css";
|
||||
|
||||
export function NotebookEditor({ input }: { input: EditorInput }) {
|
||||
if (!input.path) return <EmptyState title="Notebook path is missing" />;
|
||||
return <NotebookDocument key={input.path} session={notebookSession(input.path)} />;
|
||||
}
|
||||
|
||||
function NotebookDocument({ session }: { session: NotebookSession }) {
|
||||
const state = useSyncExternalStore(session.subscribe, session.getState);
|
||||
const root = useRef<HTMLDivElement>(null);
|
||||
const scroll = useRef<HTMLDivElement>(null);
|
||||
const [selected, select] = useState<string | null>(session.selectedCell);
|
||||
const [reloadOpen, setReloadOpen] = useState(false);
|
||||
const cells = session.cells();
|
||||
const active = cells.find((cell) => cell.id === selected) ?? cells.find((cell) => cell.cellType === "code");
|
||||
const invoke = useCallback(
|
||||
(work: Promise<unknown>) => {
|
||||
void work.catch((error) => session.report(error));
|
||||
},
|
||||
[session],
|
||||
);
|
||||
const choose = (id: string) => {
|
||||
session.selectedCell = id;
|
||||
select(id);
|
||||
};
|
||||
const virtualizer = useVirtualizer({
|
||||
count: cells.length,
|
||||
getScrollElement: () => scroll.current,
|
||||
getItemKey: (index) => cells[index]!.id,
|
||||
estimateSize: () => 220,
|
||||
overscan: 3,
|
||||
initialOffset: session.scrollTop,
|
||||
});
|
||||
useEffect(() => {
|
||||
invoke(session.load());
|
||||
}, [session, invoke]);
|
||||
useLayoutEffect(() => {
|
||||
const element = root.current;
|
||||
if (!element) return;
|
||||
const history = (event: InputEvent) => {
|
||||
if (event.inputType !== "historyUndo" && event.inputType !== "historyRedo") return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
invoke(event.inputType === "historyUndo" ? session.undo() : session.redo());
|
||||
};
|
||||
element.addEventListener("beforeinput", history, true);
|
||||
return () => element.removeEventListener("beforeinput", history, true);
|
||||
}, [session, invoke]);
|
||||
|
||||
const disabled = state.frozen || !state.snapshot;
|
||||
const historyDisabled = disabled || state.running;
|
||||
const options = state.environments.map((sdk) => ({ value: sdk.path, label: sdk.name, detail: sdk.path }));
|
||||
if (state.python && !options.some((option) => option.value === state.python))
|
||||
options.unshift({
|
||||
value: state.python,
|
||||
label: state.python,
|
||||
detail: state.python,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
ref={root}
|
||||
className={styles.root}
|
||||
tabIndex={-1}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (event.nativeEvent.isComposing || event.defaultPrevented) return;
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "s") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
invoke(session.save());
|
||||
}
|
||||
if (key === "z" || key === "y") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
invoke(event.shiftKey || key === "y" ? session.redo() : session.undo());
|
||||
}
|
||||
}
|
||||
if (event.key === "Enter" && (event.shiftKey || event.ctrlKey) && active?.cellType === "code") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
invoke(session.execute([active.id]));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={styles.toolbar} role="toolbar" aria-label={tx("Notebook toolbar")}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
icon="run"
|
||||
disabled={disabled || state.running || active?.cellType !== "code"}
|
||||
onClick={() => active && invoke(session.execute([active.id]))}
|
||||
>
|
||||
Run Cell
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon="run"
|
||||
disabled={disabled || state.running || !cells.some((cell) => cell.cellType === "code")}
|
||||
onClick={() => invoke(session.execute())}
|
||||
>
|
||||
Run All
|
||||
</Button>
|
||||
<IconButton
|
||||
icon="stop"
|
||||
title="Interrupt Kernel"
|
||||
disabled={disabled || !state.running}
|
||||
onClick={() => invoke(session.interrupt())}
|
||||
/>
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
title="Restart Kernel"
|
||||
disabled={disabled}
|
||||
onClick={() => invoke(session.restart())}
|
||||
/>
|
||||
<span className={styles.separator} />
|
||||
<Select
|
||||
className={styles.interpreter}
|
||||
aria-label={tx("Notebook Python interpreter")}
|
||||
placeholder="Select Python interpreter"
|
||||
value={state.python}
|
||||
options={options}
|
||||
disabled={disabled || state.running}
|
||||
onChange={(value) => session.selectPython(value)}
|
||||
/>
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
title="Refresh Python interpreters"
|
||||
disabled={disabled || state.running}
|
||||
onClick={() => invoke(session.detectEnvironments())}
|
||||
/>
|
||||
<span className={styles.kernel} role="status" title={state.error ?? undefined}>
|
||||
{state.running && <Spinner size={12} />}
|
||||
{tx(state.running ? "busy" : state.kernel)}
|
||||
</span>
|
||||
<span className={styles.spacer} />
|
||||
<IconButton
|
||||
icon="undo"
|
||||
title="Undo"
|
||||
disabled={historyDisabled || (!state.snapshot?.canUndo && !state.pending)}
|
||||
onClick={() => invoke(session.undo())}
|
||||
/>
|
||||
<IconButton
|
||||
icon="redo"
|
||||
title="Redo"
|
||||
disabled={historyDisabled || !state.snapshot?.canRedo}
|
||||
onClick={() => invoke(session.redo())}
|
||||
/>
|
||||
<IconButton icon="save" title="Save" disabled={disabled} onClick={() => invoke(session.save())} />
|
||||
<IconButton
|
||||
icon="refresh"
|
||||
title="Reload Notebook from Disk"
|
||||
disabled={disabled}
|
||||
onClick={() => setReloadOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
{state.error && (
|
||||
<div role="alert" className={styles.error}>
|
||||
<div className={styles.errorMessage}>
|
||||
{state.error}
|
||||
{/ipykernel/i.test(state.error) && (
|
||||
<span className={styles.kernelHelp}>
|
||||
{tx(
|
||||
"Install ipykernel in the selected Python environment, then restart the kernel. No packages are installed automatically.",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{state.pending > 0 && (
|
||||
<details>
|
||||
<summary>{tx("Uncommitted source edits")}</summary>
|
||||
{session.pendingSources().map(({ cell, source }) => (
|
||||
<pre key={cell}>{source}</pre>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
{state.pending > 0 && (
|
||||
<Button size="small" onClick={() => invoke(session.flush())}>
|
||||
Retry Pending Edits
|
||||
</Button>
|
||||
)}
|
||||
{!state.snapshot && (
|
||||
<Button size="small" onClick={() => invoke(session.load())}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{state.loading && !state.snapshot ? (
|
||||
<EmptyState title="Loading notebook…">
|
||||
<Spinner />
|
||||
</EmptyState>
|
||||
) : !state.snapshot ? (
|
||||
<EmptyState title="Notebook unavailable">
|
||||
{tx("Notebook editing and execution require the desktop app.")}
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div
|
||||
ref={scroll}
|
||||
className={styles.scroll}
|
||||
onScroll={(event) => {
|
||||
session.scrollTop = event.currentTarget.scrollTop;
|
||||
}}
|
||||
>
|
||||
{cells.length === 0 ? (
|
||||
<EmptyState
|
||||
title="This notebook has no cells"
|
||||
actions={
|
||||
<Button
|
||||
icon="add"
|
||||
disabled={disabled}
|
||||
onClick={() => session.edit({ type: "insertCell", after: null, cellType: "code" })}
|
||||
>
|
||||
Add Code Cell
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.cellList} style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((item) => {
|
||||
const cell = cells[item.index]!;
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={item.index}
|
||||
className={styles.virtualCell}
|
||||
style={{ transform: `translateY(${item.start}px)` }}
|
||||
>
|
||||
<NotebookCellView
|
||||
session={session}
|
||||
cell={cell}
|
||||
index={item.index}
|
||||
cells={cells}
|
||||
selected={active?.id === cell.id}
|
||||
frozen={disabled}
|
||||
running={state.running}
|
||||
onSelect={() => choose(cell.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.addBar}>
|
||||
<Button
|
||||
size="small"
|
||||
icon="add"
|
||||
disabled={disabled || state.running}
|
||||
onClick={() => session.edit({ type: "insertCell", after: cells.at(-1)?.id ?? null, cellType: "code" })}
|
||||
>
|
||||
Add Code Cell
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon="add"
|
||||
disabled={disabled || state.running}
|
||||
onClick={() =>
|
||||
session.edit({ type: "insertCell", after: cells.at(-1)?.id ?? null, cellType: "markdown" })
|
||||
}
|
||||
>
|
||||
Add Markdown Cell
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Dialog
|
||||
open={reloadOpen}
|
||||
onClose={() => setReloadOpen(false)}
|
||||
title={tx("Reload Notebook from Disk")}
|
||||
icon="warning"
|
||||
buttons={[
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
{
|
||||
label: "Discard Changes and Reload",
|
||||
role: "default",
|
||||
onClick: () => {
|
||||
setReloadOpen(false);
|
||||
invoke(session.discard().finally(() => session.cancelClose()));
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{tx("Discard all unsaved notebook changes and stop execution? This cannot be undone.")}
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotebookCellView({
|
||||
session,
|
||||
cell,
|
||||
index,
|
||||
cells,
|
||||
selected,
|
||||
frozen,
|
||||
running,
|
||||
onSelect,
|
||||
}: {
|
||||
session: NotebookSession;
|
||||
cell: NotebookCell;
|
||||
index: number;
|
||||
cells: NotebookCell[];
|
||||
selected: boolean;
|
||||
frozen: boolean;
|
||||
running: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(session.markdownEditing.has(cell.id));
|
||||
const known = cell.cellType === "code" || cell.cellType === "markdown";
|
||||
const structuralDisabled = frozen || running;
|
||||
return (
|
||||
<section
|
||||
className={`${styles.cell} ${selected ? styles.selected : ""}`}
|
||||
onFocusCapture={onSelect}
|
||||
onClick={onSelect}
|
||||
aria-label={`${tx("Cell")} ${index + 1}: ${cell.cellType}`}
|
||||
>
|
||||
<div className={styles.cellToolbar}>
|
||||
<span className={styles.executionCount}>
|
||||
{cell.cellType === "code" ? `[${cell.executionCount ?? " "}]` : `${index + 1}`}
|
||||
</span>
|
||||
<Select
|
||||
aria-label={tx("Cell type")}
|
||||
variant="ghost"
|
||||
value={cell.cellType}
|
||||
disabled={structuralDisabled || (!known && cell.cellType !== "raw")}
|
||||
options={[
|
||||
{ value: "code", label: "Code" },
|
||||
{ value: "markdown", label: "Markdown" },
|
||||
{ value: "raw", label: "Raw" },
|
||||
...(!["code", "markdown", "raw"].includes(cell.cellType)
|
||||
? [{ value: cell.cellType, label: cell.cellType }]
|
||||
: []),
|
||||
]}
|
||||
onChange={(value) => session.edit({ type: "setCellType", cell: cell.id, cellType: value })}
|
||||
/>
|
||||
{cell.cellType === "code" && (
|
||||
<IconButton
|
||||
icon="run"
|
||||
size="small"
|
||||
title="Run Cell"
|
||||
disabled={frozen || running}
|
||||
onClick={() => {
|
||||
void session.execute([cell.id]).catch((error) => session.report(error));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cell.cellType === "markdown" && (
|
||||
<IconButton
|
||||
icon={editing ? "eye" : "edit"}
|
||||
size="small"
|
||||
title={editing ? "Preview Markdown" : "Edit Markdown"}
|
||||
disabled={frozen}
|
||||
onClick={() => {
|
||||
if (editing) session.markdownEditing.delete(cell.id);
|
||||
else session.markdownEditing.add(cell.id);
|
||||
setEditing(!editing);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.spacer} />
|
||||
<IconButton
|
||||
icon="arrow-up"
|
||||
size="small"
|
||||
title="Move Cell Up"
|
||||
disabled={structuralDisabled || index === 0}
|
||||
onClick={() => session.edit({ type: "moveCell", cell: cell.id, before: cells[index - 1]!.id })}
|
||||
/>
|
||||
<IconButton
|
||||
icon="arrow-down"
|
||||
size="small"
|
||||
title="Move Cell Down"
|
||||
disabled={structuralDisabled || index === cells.length - 1}
|
||||
onClick={() => session.edit({ type: "moveCell", cell: cell.id, before: cells[index + 2]?.id ?? null })}
|
||||
/>
|
||||
<IconButton
|
||||
icon="add"
|
||||
size="small"
|
||||
title="Insert Code Cell Below"
|
||||
disabled={structuralDisabled}
|
||||
onClick={() => session.edit({ type: "insertCell", after: cell.id, cellType: "code" })}
|
||||
/>
|
||||
<IconButton
|
||||
icon="delete"
|
||||
size="small"
|
||||
title="Delete Cell"
|
||||
disabled={structuralDisabled}
|
||||
onClick={() => session.edit({ type: "deleteCell", cell: cell.id })}
|
||||
/>
|
||||
</div>
|
||||
{!known ? (
|
||||
<div className={styles.rawCell}>
|
||||
<p className={styles.notice}>{tx("Raw or unknown cell; original content is preserved read-only.")}</p>
|
||||
<pre>{cell.source}</pre>
|
||||
</div>
|
||||
) : cell.cellType === "markdown" && !editing ? (
|
||||
<div
|
||||
onDoubleClick={() => {
|
||||
if (!frozen) {
|
||||
session.markdownEditing.add(cell.id);
|
||||
setEditing(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{cell.source ? (
|
||||
<MarkdownCell source={cell.source} attachments={cell.attachments} path={session.path} />
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={frozen}
|
||||
onClick={() => {
|
||||
session.markdownEditing.add(cell.id);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
Edit Markdown
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<CellEditor session={session} id={cell.id} source={cell.source} type={cell.cellType} frozen={frozen} />
|
||||
)}
|
||||
{cell.cellType === "code" && <CellOutputs outputs={cell.outputs} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createEvent, fireEvent, render } from "@testing-library/react";
|
||||
|
||||
const openExternalUrl = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const sanitizedSvgUrl = vi.hoisted(() => vi.fn((_source: string) => "data:image/svg+xml,clean"));
|
||||
vi.mock("@/platform/openUrl", () => ({ openExternalUrl }));
|
||||
vi.mock("./security", async (original) => {
|
||||
const actual = await original<typeof import("./security")>();
|
||||
return {
|
||||
...actual,
|
||||
imageMimeUrl: (bundle: Record<string, unknown>) =>
|
||||
typeof bundle["image/svg+xml"] === "string" && bundle["image/svg+xml"]
|
||||
? sanitizedSvgUrl(bundle["image/svg+xml"])
|
||||
: actual.imageMimeUrl(bundle),
|
||||
};
|
||||
});
|
||||
|
||||
const { CellOutputs, MarkdownCell } = await import("./OutputView");
|
||||
const PNG = "iVBORw0KGgo=";
|
||||
|
||||
describe("notebook Markdown preview", () => {
|
||||
it("leaves native interactions such as <details> alone and only claims links", () => {
|
||||
const { container } = render(
|
||||
<MarkdownCell source={"<details><summary>More</summary>body</details>"} attachments={{}} path="/p/a.ipynb" />,
|
||||
);
|
||||
const summary = container.querySelector("summary")!;
|
||||
const click = createEvent.click(summary);
|
||||
fireEvent(summary, click);
|
||||
expect(click.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("opens web links with the system handler", () => {
|
||||
openExternalUrl.mockClear();
|
||||
const { container } = render(
|
||||
<MarkdownCell source="[docs](https://example.com/docs)" attachments={{}} path="/p/a.ipynb" />,
|
||||
);
|
||||
const link = container.querySelector("a[data-notebook-href]")!;
|
||||
const click = createEvent.click(link);
|
||||
fireEvent(link, click);
|
||||
expect(click.defaultPrevented).toBe(true);
|
||||
expect(openExternalUrl).toHaveBeenCalledWith("https://example.com/docs");
|
||||
});
|
||||
|
||||
it("keeps the rendered preview across snapshots with equal attachments", () => {
|
||||
const attachments = { "a.png": { "image/png": PNG } };
|
||||
const source = "text ";
|
||||
const { container, rerender } = render(
|
||||
<MarkdownCell source={source} attachments={attachments} path="/p/a.ipynb" />,
|
||||
);
|
||||
const paragraph = container.querySelector("p")!;
|
||||
rerender(<MarkdownCell source={source} attachments={structuredClone(attachments)} path="/p/a.ipynb" />);
|
||||
expect(container.querySelector("p")).toBe(paragraph);
|
||||
rerender(<MarkdownCell source={source} attachments={{ "a.png": { "image/png": "AAAA" } }} path="/p/a.ipynb" />);
|
||||
expect(container.querySelector("p")).not.toBe(paragraph);
|
||||
expect(container.querySelector("img")!.getAttribute("src")).toBe("data:image/png;base64,AAAA");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Markdown destination", ""],
|
||||
["reference definition", "![a][r]\n\n[r]: attachment:a.png"],
|
||||
["raw HTML", '<img src="attachment:a.png" alt="a">'],
|
||||
])("resolves an entity-encoded attachment in a %s", (_kind, source) => {
|
||||
const { container } = render(
|
||||
<MarkdownCell source={source} attachments={{ "a.png": { "image/png": PNG } }} path="/p/a.ipynb" />,
|
||||
);
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toBe(`data:image/png;base64,${PNG}`);
|
||||
});
|
||||
|
||||
it.each(["__proto__", "constructor"])("resolves an attachment named %s from parsed notebook JSON", (name) => {
|
||||
const attachments = JSON.parse(`{${JSON.stringify(name)}:{"image/png":"${PNG}"}}`);
|
||||
const { container } = render(
|
||||
<MarkdownCell source={``} attachments={attachments} path="/p/a.ipynb" />,
|
||||
);
|
||||
expect(container.querySelector("img")?.getAttribute("src")).toBe(`data:image/png;base64,${PNG}`);
|
||||
});
|
||||
|
||||
it("does not resolve inherited members as attachments", () => {
|
||||
const { container } = render(
|
||||
<MarkdownCell source="" attachments={{}} path="/p/a.ipynb" />,
|
||||
);
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.textContent?.trim()).toBe("a");
|
||||
});
|
||||
|
||||
it("does not rebuild when only an unreferenced attachment changes", () => {
|
||||
const source = "";
|
||||
const { container, rerender } = render(
|
||||
<MarkdownCell source={source} attachments={{ "a.png": { "image/png": PNG } }} path="/p/a.ipynb" />,
|
||||
);
|
||||
const image = container.querySelector("img")!;
|
||||
rerender(
|
||||
<MarkdownCell
|
||||
source={source}
|
||||
attachments={{ "a.png": { "image/png": PNG }, "b.png": { "image/png": "AAAA" } }}
|
||||
path="/p/a.ipynb"
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector("img")).toBe(image);
|
||||
});
|
||||
|
||||
it("ignores attachment identity when the source references none", () => {
|
||||
const { container, rerender } = render(<MarkdownCell source="plain" attachments={{}} path="/p/a.ipynb" />);
|
||||
const paragraph = container.querySelector("p")!;
|
||||
rerender(<MarkdownCell source="plain" attachments={{}} path="/p/a.ipynb" />);
|
||||
expect(container.querySelector("p")).toBe(paragraph);
|
||||
});
|
||||
});
|
||||
|
||||
describe("notebook image outputs", () => {
|
||||
it("does not re-sanitize an unchanged SVG when a fresh snapshot arrives", () => {
|
||||
sanitizedSvgUrl.mockClear();
|
||||
const output = () => [{ output_type: "display_data", data: { "image/svg+xml": "<svg></svg>" }, metadata: {} }];
|
||||
const { rerender } = render(<CellOutputs outputs={output()} />);
|
||||
rerender(<CellOutputs outputs={output()} />);
|
||||
expect(sanitizedSvgUrl).toHaveBeenCalledTimes(1);
|
||||
rerender(<CellOutputs outputs={[{ output_type: "display_data", data: { "image/svg+xml": "<svg><g/></svg>" } }]} />);
|
||||
expect(sanitizedSvgUrl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useLayoutEffect, useMemo, useRef } from "react";
|
||||
import Anser from "anser";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { openExternalUrl } from "@/platform/openUrl";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { resolveLink } from "@/features/viewers/markdown/render";
|
||||
import {
|
||||
imageMimeUrl,
|
||||
markdownAttachmentNames,
|
||||
markdownRoot,
|
||||
resolveMarkdownAttachments,
|
||||
staticOutputDocument,
|
||||
} from "./security";
|
||||
import { mimeText, object, own } from "./outputs";
|
||||
import styles from "./Notebook.module.css";
|
||||
|
||||
const PREVIEW_LIMIT = 200_000;
|
||||
function AnsiText({ text, error = false }: { text: string; error?: boolean }) {
|
||||
const entries = useMemo(() => Anser.ansiToJson(text.slice(0, PREVIEW_LIMIT), { json: true }), [text]);
|
||||
return (
|
||||
<>
|
||||
<pre className={error ? styles.errorOutput : styles.textOutput}>
|
||||
{entries.map((entry, index) => (
|
||||
<span
|
||||
key={index}
|
||||
style={{
|
||||
color: entry.fg ? `rgb(${entry.fg})` : undefined,
|
||||
backgroundColor: entry.bg ? `rgb(${entry.bg})` : undefined,
|
||||
fontWeight: entry.decorations?.includes("bold") ? 700 : undefined,
|
||||
fontStyle: entry.decorations?.includes("italic") ? "italic" : undefined,
|
||||
textDecoration: entry.decorations?.includes("underline") ? "underline" : undefined,
|
||||
}}
|
||||
>
|
||||
{entry.content}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
{text.length > PREVIEW_LIMIT && (
|
||||
<p className={styles.notice}>{tx("Output preview truncated; full output is retained in the notebook.")}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StaticHtml({ html, title }: { html: string; title: string }) {
|
||||
const srcDoc = useMemo(() => staticOutputDocument(html), [html]);
|
||||
return (
|
||||
<iframe className={styles.outputFrame} sandbox="" referrerPolicy="no-referrer" srcDoc={srcDoc} title={title} />
|
||||
);
|
||||
}
|
||||
|
||||
// Every full snapshot deserializes fresh bundles; memoize on content so an
|
||||
// unrelated edit does not re-run SVG sanitization.
|
||||
function useImageUrl(png: string, jpeg: string, svg: string) {
|
||||
return useMemo(() => imageMimeUrl({ "image/png": png, "image/jpeg": jpeg, "image/svg+xml": svg }), [png, jpeg, svg]);
|
||||
}
|
||||
|
||||
function MimeBundle({ value }: { value: unknown }) {
|
||||
const bundle = object(value);
|
||||
const image = useImageUrl(
|
||||
mimeText(bundle["image/png"]),
|
||||
mimeText(bundle["image/jpeg"]),
|
||||
mimeText(bundle["image/svg+xml"]),
|
||||
);
|
||||
const html = mimeText(bundle["text/html"]);
|
||||
const plain = mimeText(bundle["text/plain"]);
|
||||
const javascript = mimeText(bundle["application/javascript"]) || mimeText(bundle["text/javascript"]);
|
||||
const supported = image || html || plain;
|
||||
return (
|
||||
<>
|
||||
{image ? (
|
||||
<img className={styles.outputImage} src={image} alt={plain || tx("Notebook image output")} loading="lazy" />
|
||||
) : html ? (
|
||||
<StaticHtml html={html} title={tx("Static notebook HTML output")} />
|
||||
) : plain ? (
|
||||
<AnsiText text={plain} />
|
||||
) : null}
|
||||
{javascript && (
|
||||
<>
|
||||
<p className={styles.notice}>{tx("JavaScript output is inert. Interactive widgets are not supported.")}</p>
|
||||
<StaticHtml
|
||||
title={tx("Inert JavaScript source")}
|
||||
html={`<pre>${javascript.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}</pre>`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!supported && !javascript && (
|
||||
<p className={styles.notice}>
|
||||
{tx("Unsupported output MIME type; original data is preserved.")} {Object.keys(bundle).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CellOutputs({ outputs }: { outputs: unknown[] }) {
|
||||
if (!outputs.length) return null;
|
||||
return (
|
||||
<div className={styles.outputs} aria-label={tx("Cell outputs")}>
|
||||
{outputs.map((value, index) => {
|
||||
const output = object(value);
|
||||
switch (output.output_type) {
|
||||
case "stream":
|
||||
return <AnsiText key={index} text={mimeText(output.text)} error={output.name === "stderr"} />;
|
||||
case "error":
|
||||
return (
|
||||
<AnsiText
|
||||
key={index}
|
||||
error
|
||||
text={
|
||||
Array.isArray(output.traceback)
|
||||
? output.traceback.map(mimeText).join("\n")
|
||||
: `${mimeText(output.ename)}: ${mimeText(output.evalue)}`
|
||||
}
|
||||
/>
|
||||
);
|
||||
case "display_data":
|
||||
case "execute_result":
|
||||
return <MimeBundle key={index} value={output.data} />;
|
||||
default:
|
||||
return (
|
||||
<p key={index} className={styles.notice}>
|
||||
{tx("Unsupported output type; original data is preserved.")} {String(output.output_type ?? "unknown")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The only attachment fields `imageMimeUrl` reads, for the names a preview references. */
|
||||
function referencedImages(names: string[], attachments: unknown) {
|
||||
// Prototype-free, so a valid attachment named `__proto__` stays an own key.
|
||||
const images: Record<string, Record<string, string>> = Object.create(null);
|
||||
for (const name of names) {
|
||||
const bundle = object(own(attachments, name));
|
||||
images[name] = {
|
||||
"image/png": mimeText(bundle["image/png"]),
|
||||
"image/jpeg": mimeText(bundle["image/jpeg"]),
|
||||
"image/svg+xml": mimeText(bundle["image/svg+xml"]),
|
||||
};
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
export function MarkdownCell({ source, attachments, path }: { source: string; attachments: unknown; path: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const parsed = useMemo(() => markdownRoot(source), [source]);
|
||||
const names = useMemo(() => markdownAttachmentNames(parsed), [parsed]);
|
||||
// Full snapshots carry fresh attachment objects. Rebuilding on identity would
|
||||
// re-render every visible preview per commit and drop a reader's selection, so
|
||||
// key on the image data of referenced attachments only.
|
||||
const attachmentKey = names.length ? JSON.stringify(referencedImages(names, attachments)) : "";
|
||||
useLayoutEffect(() => {
|
||||
const root = ref.current;
|
||||
if (!root) return;
|
||||
const fragment = parsed.cloneNode(true) as HTMLElement;
|
||||
resolveMarkdownAttachments(fragment, attachmentKey ? JSON.parse(attachmentKey) : null);
|
||||
root.replaceChildren(...fragment.childNodes);
|
||||
return () => root.replaceChildren();
|
||||
}, [parsed, attachmentKey]);
|
||||
const openLink = (linkElement: Element) => {
|
||||
try {
|
||||
const link = resolveLink(path, linkElement.getAttribute("data-notebook-href") ?? "");
|
||||
if (link?.kind === "file")
|
||||
void editorService()
|
||||
.openFile(link.path, {
|
||||
focus: true,
|
||||
...(link.line !== undefined ? { selection: { line: link.line, col: 0 } } : {}),
|
||||
})
|
||||
.catch((error) => notifications().notify({ level: "error", title: String(error) }));
|
||||
else if (link?.kind === "external")
|
||||
void openExternalUrl(link.url).catch((error) =>
|
||||
notifications().notify({ level: "error", title: String(error) }),
|
||||
);
|
||||
} catch {
|
||||
/* Malformed notebook URLs are inert. */
|
||||
}
|
||||
};
|
||||
// Only links are claimed; <details>, text selection and other defaults stay native.
|
||||
const linkOf = (target: EventTarget) => (target instanceof Element ? target.closest("a[data-notebook-href]") : null);
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={styles.markdown}
|
||||
onClick={(event) => {
|
||||
const link = linkOf(event.target);
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
openLink(link);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
const link = linkOf(event.target);
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openLink(link);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { NotebookSnapshot } from "@/bindings";
|
||||
import { basename } from "@/platform/project";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import {
|
||||
registerEditorKind,
|
||||
registerFileEditorProvider,
|
||||
type EditorDocumentLifecycle,
|
||||
} from "@/workbench/contributions";
|
||||
import { isTauri } from "@/platform/ipc";
|
||||
import { ProviderAcquisitions } from "@/workbench/providerAcquisition";
|
||||
import { closeNotebook, existingNotebookSession, notebookBackend, notebookSession } from "./session";
|
||||
|
||||
const acquisitions = new ProviderAcquisitions<NotebookSnapshot>(
|
||||
(snapshot) => existingNotebookSession(snapshot.path)?.getState().snapshot?.id === snapshot.id,
|
||||
(snapshot) => notebookBackend.close(snapshot.id),
|
||||
);
|
||||
|
||||
// Lifecycle calls must not create sessions for a tab which has never loaded.
|
||||
const document: EditorDocumentLifecycle = {
|
||||
isDirty: (input) => !!input.path && !!existingNotebookSession(input.path)?.isDirty(),
|
||||
flush: async (input) => {
|
||||
if (input.path) await existingNotebookSession(input.path)?.flush();
|
||||
},
|
||||
save: async (input) => {
|
||||
if (input.path) await existingNotebookSession(input.path)?.save();
|
||||
},
|
||||
prepareClose: async (input) => {
|
||||
if (input.path) await existingNotebookSession(input.path)?.prepareClose();
|
||||
},
|
||||
cancelClose: (input) => {
|
||||
if (input.path) existingNotebookSession(input.path)?.cancelClose();
|
||||
},
|
||||
discard: async (input) => {
|
||||
if (input.path) await existingNotebookSession(input.path)?.discard();
|
||||
},
|
||||
close: async (input) => {
|
||||
if (input.path) await closeNotebook(input.path);
|
||||
},
|
||||
undo: async (input) => {
|
||||
if (input.path) await existingNotebookSession(input.path)?.undo();
|
||||
},
|
||||
redo: async (input) => {
|
||||
if (input.path) await existingNotebookSession(input.path)?.redo();
|
||||
},
|
||||
};
|
||||
|
||||
export function activate(): () => void {
|
||||
const disposers = [
|
||||
registerEditorKind({
|
||||
kind: "notebook",
|
||||
restorable: true,
|
||||
document,
|
||||
component: lazyComponent(() => import("./NotebookEditor").then((m) => m.NotebookEditor)),
|
||||
title: (input) => basename(input.path ?? input.key),
|
||||
icon: () => "file",
|
||||
}),
|
||||
registerFileEditorProvider({
|
||||
id: "notebook",
|
||||
order: 5,
|
||||
resolve: async (path, context) => {
|
||||
if (!/\.ipynb$/i.test(path)) return null;
|
||||
let canonical = path;
|
||||
if (isTauri()) {
|
||||
try {
|
||||
// Resolve aliases before creating a tab/session, so the workbench's
|
||||
// last-reference lifecycle and our edit queue use the SAME identity.
|
||||
const lease = await acquisitions.acquire(() => notebookBackend.open(path));
|
||||
const snapshot = lease.value;
|
||||
canonical = snapshot.path;
|
||||
context.onAdmission(() => {
|
||||
// Do not reload/replace an already admitted shared edit queue.
|
||||
const session = existingNotebookSession(canonical);
|
||||
const current = session?.getState().snapshot;
|
||||
if (current && current.id !== snapshot.id)
|
||||
throw new Error("The notebook changed while opening the editor");
|
||||
if (!current) void (session ?? notebookSession(canonical)).load(snapshot);
|
||||
lease.admit();
|
||||
}, lease.rollback);
|
||||
} catch {
|
||||
// Invalid/unavailable notebooks still open the notebook error surface,
|
||||
// never silently fall through to an editable raw JSON document.
|
||||
}
|
||||
}
|
||||
return { kind: "notebook", key: `notebook:${canonical}`, path: canonical };
|
||||
},
|
||||
}),
|
||||
];
|
||||
return () => disposers.reverse().forEach((dispose) => dispose());
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { NotebookOutputEvent, NotebookSnapshot } from "@/bindings";
|
||||
import { OutputReducer } from "./outputs";
|
||||
|
||||
function notebook(): NotebookSnapshot {
|
||||
return {
|
||||
id: "document-a",
|
||||
path: "/p/example.ipynb",
|
||||
version: 1,
|
||||
dirty: false,
|
||||
nbformat: 4,
|
||||
nbformatMinor: 5,
|
||||
metadata: {},
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
cells: [
|
||||
{
|
||||
id: "cell-a",
|
||||
cellType: "code",
|
||||
source: "print('old')",
|
||||
metadata: {},
|
||||
attachments: {},
|
||||
executionCount: 1,
|
||||
outputs: [{ output_type: "stream", name: "stdout", text: "old\n" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function event(
|
||||
kind: string,
|
||||
version: number,
|
||||
content: NotebookOutputEvent["content"],
|
||||
patch: Partial<NotebookOutputEvent> = {},
|
||||
): NotebookOutputEvent {
|
||||
return {
|
||||
id: "document-a",
|
||||
path: "/p/example.ipynb",
|
||||
cell: "cell-a",
|
||||
msgId: "request-a",
|
||||
kind,
|
||||
version,
|
||||
dirty: true,
|
||||
changedCells: ["cell-a"],
|
||||
content,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("notebook output transitions", () => {
|
||||
it("defers wait-clear without changing bytes and reconciles its consuming output exactly once", () => {
|
||||
const reducer = new OutputReducer();
|
||||
const original = notebook();
|
||||
const waiting = reducer.reduce(original, event("clear_output", 1, { wait: true })).snapshot;
|
||||
expect(waiting.cells[0]!.outputs).toEqual(original.cells[0]!.outputs);
|
||||
const consumed = reducer.reduce(waiting, event("stream", 2, { name: "stdout", text: "new" }));
|
||||
expect(consumed.refresh).toBe(true);
|
||||
expect(consumed.snapshot.cells[0]!.outputs).toEqual(original.cells[0]!.outputs);
|
||||
const recovered = {
|
||||
...original,
|
||||
version: 2,
|
||||
dirty: true,
|
||||
cells: [
|
||||
{
|
||||
...original.cells[0]!,
|
||||
outputs: [{ output_type: "stream", name: "stdout", text: "new" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const second = reducer.reduce(recovered, event("stream", 3, { name: "stdout", text: " output\n" })).snapshot;
|
||||
expect(second.cells[0]!.outputs).toEqual([{ output_type: "stream", name: "stdout", text: "new output\n" }]);
|
||||
expect(second.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it("coalesces only adjacent streams of the same name, preserving stderr order", () => {
|
||||
const reducer = new OutputReducer();
|
||||
let state = notebook();
|
||||
state = reducer.reduce(state, event("clear_output", 2, { wait: false })).snapshot;
|
||||
state = reducer.reduce(state, event("stream", 3, { name: "stderr", text: "warning\n" })).snapshot;
|
||||
state = reducer.reduce(state, event("stream", 4, { name: "stdout", text: "result\n" })).snapshot;
|
||||
expect(state.cells[0]!.outputs).toEqual([
|
||||
{ output_type: "stream", name: "stderr", text: "warning\n" },
|
||||
{ output_type: "stream", name: "stdout", text: "result\n" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires authoritative reconciliation for cross-cell displays without duplicating an output", () => {
|
||||
const reducer = new OutputReducer();
|
||||
const state = notebook();
|
||||
const result = reducer.reduce(
|
||||
state,
|
||||
event(
|
||||
"update_display_data",
|
||||
2,
|
||||
{ data: { "text/plain": "updated" }, transient: { display_id: "display-a" } },
|
||||
{ changedCells: ["cell-a", "earlier-cell"] },
|
||||
),
|
||||
);
|
||||
expect(result.refresh).toBe(true);
|
||||
expect(result.snapshot.cells[0]!.outputs).toEqual(state.cells[0]!.outputs);
|
||||
expect(result.snapshot.version).toBe(1);
|
||||
});
|
||||
|
||||
it("does not clear twice after recovering a stream revision gap", () => {
|
||||
const reducer = new OutputReducer();
|
||||
let state = notebook();
|
||||
reducer.reduce(state, event("clear_output", 1, { wait: true }));
|
||||
expect(reducer.reduce(state, event("stream", 4, { name: "stdout", text: "recovered" })).refresh).toBe(true);
|
||||
state = {
|
||||
...state,
|
||||
version: 4,
|
||||
cells: [{ ...state.cells[0]!, outputs: [{ output_type: "stream", name: "stdout", text: "recovered" }] }],
|
||||
};
|
||||
state = reducer.reduce(state, event("stream", 5, { name: "stdout", text: "+next" })).snapshot;
|
||||
expect(state.cells[0]!.outputs).toEqual([{ output_type: "stream", name: "stdout", text: "recovered+next" }]);
|
||||
});
|
||||
|
||||
it("does not erase already-reconciled output when a snapshot overtakes a deferred clear", () => {
|
||||
const reducer = new OutputReducer();
|
||||
const original = notebook();
|
||||
reducer.reduce(original, event("clear_output", 1, { wait: true }));
|
||||
const recovered = {
|
||||
...original,
|
||||
version: 2,
|
||||
cells: [
|
||||
{
|
||||
...original.cells[0]!,
|
||||
outputs: [{ output_type: "stream", name: "stdout", text: "new" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const next = reducer.reduce(recovered, event("stream", 3, { name: "stdout", text: "+next" }));
|
||||
expect(next.refresh).toBe(true);
|
||||
expect(next.snapshot.cells[0]!.outputs).toEqual([{ output_type: "stream", name: "stdout", text: "new" }]);
|
||||
});
|
||||
|
||||
it("repairs an overtaken clear and a lost transient clear rather than appending to old output", () => {
|
||||
const reducer = new OutputReducer();
|
||||
const acknowledged = { ...notebook(), version: 2 };
|
||||
reducer.reduce(acknowledged, event("clear_output", 1, { wait: true }));
|
||||
expect(reducer.reduce(acknowledged, event("stream", 3, { name: "stdout", text: "replacement" })).refresh).toBe(
|
||||
true,
|
||||
);
|
||||
reducer.reset();
|
||||
reducer.recover();
|
||||
expect(reducer.reduce(acknowledged, event("stream", 3, { name: "stdout", text: "replacement" })).refresh).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("changes only the execution count on execute_input, matching the backend commit", () => {
|
||||
const original = notebook();
|
||||
const next = new OutputReducer().reduce(original, event("execute_input", 2, { execution_count: 2 })).snapshot;
|
||||
expect(next.cells[0]!.executionCount).toBe(2);
|
||||
expect(next.cells[0]!.outputs).toEqual(original.cells[0]!.outputs);
|
||||
});
|
||||
|
||||
it("drops old incarnations and duplicate revisions, and removes transient display ids", () => {
|
||||
const reducer = new OutputReducer();
|
||||
const state = notebook();
|
||||
const wrong = reducer.reduce(state, event("stream", 2, { text: "stale" }, { id: "old-document" }));
|
||||
expect(wrong.snapshot).toBe(state);
|
||||
const display = event("display_data", 2, {
|
||||
data: { "text/plain": "hello" },
|
||||
metadata: {},
|
||||
transient: { display_id: "temporary" },
|
||||
});
|
||||
const result = reducer.reduce(state, display).snapshot;
|
||||
expect(result.cells[0]!.outputs.at(-1)).toEqual({
|
||||
output_type: "display_data",
|
||||
data: { "text/plain": "hello" },
|
||||
metadata: {},
|
||||
});
|
||||
expect(reducer.reduce(result, display).snapshot).toBe(result);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { NotebookSnapshot, NotebookOutputEvent } from "@/bindings";
|
||||
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
export function object(value: unknown): JsonObject {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : {};
|
||||
}
|
||||
/** Notebook keys such as `__proto__` or `constructor` name data, never inherited members. */
|
||||
export function own(value: unknown, key: string): unknown {
|
||||
const record = object(value);
|
||||
return Object.hasOwn(record, key) ? record[key] : undefined;
|
||||
}
|
||||
export function mimeText(value: unknown): string {
|
||||
return typeof value === "string"
|
||||
? value
|
||||
: Array.isArray(value)
|
||||
? value.filter((v) => typeof v === "string").join("")
|
||||
: "";
|
||||
}
|
||||
|
||||
/** Transient protocol state belongs to an execution, never to persisted MIME bundles. */
|
||||
export class OutputReducer {
|
||||
private waiting = new Set<string>();
|
||||
private recovering = false;
|
||||
reset() {
|
||||
this.waiting.clear();
|
||||
this.recovering = false;
|
||||
}
|
||||
/** A lost Channel event can hide a transient wait-clear, not just a byte revision. */
|
||||
recover() {
|
||||
this.recovering = true;
|
||||
}
|
||||
|
||||
reduce(snapshot: NotebookSnapshot, event: NotebookOutputEvent): { snapshot: NotebookSnapshot; refresh: boolean } {
|
||||
if (event.id !== snapshot.id) return { snapshot, refresh: false };
|
||||
const content = object(event.content);
|
||||
// A source acknowledgement may overtake a transient backend wait-clear.
|
||||
if (event.kind === "clear_output" && content.wait === true) this.waiting.add(event.cell);
|
||||
if (event.version <= snapshot.version) return { snapshot, refresh: false };
|
||||
const producesOutput = ["stream", "display_data", "execute_result", "error", "update_display_data"].includes(
|
||||
event.kind,
|
||||
);
|
||||
// A full snapshot may already include the output that consumed this clear.
|
||||
// Reconcile instead of clearing locally a second time after that snapshot.
|
||||
const deferredClear = producesOutput && this.waiting.delete(event.cell);
|
||||
const refresh =
|
||||
this.recovering ||
|
||||
deferredClear ||
|
||||
event.kind === "update_display_data" ||
|
||||
event.version > snapshot.version + 1 ||
|
||||
event.changedCells.some((id) => id !== event.cell);
|
||||
// A gap or cross-cell display update needs backend display-id knowledge. Do not
|
||||
// advance the local revision until get() has repaired the authoritative state.
|
||||
if (refresh) {
|
||||
if (event.kind === "clear_output" && content.wait === true) this.waiting.add(event.cell);
|
||||
else if (producesOutput || (event.kind === "clear_output" && content.wait !== true))
|
||||
this.waiting.delete(event.cell);
|
||||
return { snapshot, refresh: true };
|
||||
}
|
||||
const cells = snapshot.cells.map((cell) => {
|
||||
if (cell.id !== event.cell) return cell;
|
||||
let outputs: unknown[] = cell.outputs;
|
||||
let executionCount = cell.executionCount;
|
||||
if (event.kind === "clear_output") {
|
||||
if (content.wait === true) this.waiting.add(cell.id);
|
||||
else {
|
||||
this.waiting.delete(cell.id);
|
||||
outputs = [];
|
||||
}
|
||||
} else if (event.kind === "execute_input") {
|
||||
executionCount = typeof content.execution_count === "number" ? content.execution_count : null;
|
||||
} else if (["stream", "display_data", "execute_result", "error"].includes(event.kind)) {
|
||||
// Strip transient from the UI copy too; authoritative persistence is backend-owned.
|
||||
const { transient: _transient, ...body } = content;
|
||||
const last = object(outputs.at(-1));
|
||||
if (event.kind === "stream" && last.output_type === "stream" && last.name === body.name) {
|
||||
outputs = [...outputs.slice(0, -1), { ...last, text: mimeText(last.text) + mimeText(body.text) }];
|
||||
} else {
|
||||
outputs = [...outputs, { ...body, output_type: event.kind }];
|
||||
}
|
||||
}
|
||||
return { ...cell, outputs: outputs as typeof cell.outputs, executionCount };
|
||||
});
|
||||
return { snapshot: { ...snapshot, cells, version: event.version, dirty: event.dirty }, refresh: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownRoot, resolveMarkdownAttachments, sanitizedSvgUrl, staticOutputDocument } from "./security";
|
||||
|
||||
const parse = (html: string) => new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
describe("static notebook output sanitization", () => {
|
||||
it("retains table content while removing executable, navigation and external-resource surfaces", () => {
|
||||
const document = parse(
|
||||
staticOutputDocument(`
|
||||
<meta http-equiv="refresh" content="0;url=https://attacker.invalid">
|
||||
<base href="https://attacker.invalid"><script>window.__TAURI_INTERNALS__.invoke('x')</script>
|
||||
<form action="https://attacker.invalid"><input name="secret"><button>submit</button></form>
|
||||
<iframe src="https://attacker.invalid"></iframe><object data="https://attacker.invalid"></object>
|
||||
<a href="https://attacker.invalid" target="_top" ping="https://attacker.invalid">link</a>
|
||||
<img src="https://attacker.invalid/a.png" srcset="https://attacker.invalid/b.png 2x" onerror="alert(1)">
|
||||
<div style="background-image:image-set('https://attacker.invalid/x')">safe text</div>
|
||||
<table><thead><tr><th>name</th><th>value</th></tr></thead><tbody><tr><td>alpha</td><td>42</td></tr></tbody></table>
|
||||
`),
|
||||
);
|
||||
expect(document.querySelector("tbody tr")?.textContent).toBe("alpha42");
|
||||
expect(document.querySelector("script,base,form,input,button,iframe,object")).toBeNull();
|
||||
expect(document.querySelector("meta[http-equiv=refresh]")).toBeNull();
|
||||
expect(document.querySelector("[href],[target],[srcset],[onerror],[ping]")).toBeNull();
|
||||
expect(document.querySelector("img")?.hasAttribute("src")).toBe(false);
|
||||
expect(document.querySelector("body div")?.hasAttribute("style")).toBe(false);
|
||||
});
|
||||
|
||||
it("retains safe SVG chart paint, local definitions and raster images but removes active/external SVG", () => {
|
||||
const url = sanitizedSvgUrl(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">
|
||||
<script>alert(1)</script><foreignObject><div>host markup</div></foreignObject>
|
||||
<defs><linearGradient id="paint"><stop offset="0" stop-color="red"/></linearGradient></defs>
|
||||
<path id="chart" d="M0 0L10 10" style="fill:none;stroke:#123456;stroke-width:2" onclick="alert(1)"/>
|
||||
<rect fill="url(#paint)" width="10" height="10"/>
|
||||
<use id="local-marker" href="#chart"/>
|
||||
<use href="https://attacker.invalid/a.svg#x"/><image href="data:image/png;base64,aGVsbG8="/>
|
||||
<path id="external" fill="url(https://attacker.invalid/paint)"/>
|
||||
<animate attributeName="href" to="https://attacker.invalid"/>
|
||||
</svg>`);
|
||||
const xml = decodeURIComponent(url.slice(url.indexOf(",") + 1));
|
||||
const document = new DOMParser().parseFromString(xml, "image/svg+xml");
|
||||
expect(document.querySelector("script,foreignObject,animate,style")).toBeNull();
|
||||
expect(document.querySelector('[href^="https:"],[href^="http:"]')).toBeNull();
|
||||
expect(document.querySelector("#chart")?.getAttribute("stroke")).toBe("#123456");
|
||||
expect(document.querySelector("#chart")?.getAttribute("fill")).toBe("none");
|
||||
expect(document.querySelector("rect")?.getAttribute("fill")).toBe("url(#paint)");
|
||||
expect(document.querySelector("#local-marker")?.getAttribute("href")).toBe("#chart");
|
||||
expect(document.querySelector("#external")?.hasAttribute("fill")).toBe(false);
|
||||
expect(document.querySelector("image")?.getAttribute("href")).toBe("data:image/png;base64,aGVsbG8=");
|
||||
expect(document.querySelector("[onclick],[style]")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves only embedded image attachments and makes Markdown file links editor-controlled", () => {
|
||||
const root = resolveMarkdownAttachments(
|
||||
markdownRoot(
|
||||
`\n\n\n\n[local](./other.py#L8)\n\n<script>alert(1)</script><div style="position:fixed">text</div>`,
|
||||
),
|
||||
{
|
||||
"chart.png": { "image/png": "aGVsbG8=" },
|
||||
},
|
||||
);
|
||||
const images = root.querySelectorAll("img");
|
||||
expect(images[0]?.getAttribute("src")).toBe("data:image/png;base64,aGVsbG8=");
|
||||
expect(images[1]?.hasAttribute("src")).toBe(false);
|
||||
const link = root.querySelector("a");
|
||||
expect(link?.hasAttribute("href")).toBe(false);
|
||||
expect(link?.getAttribute("data-notebook-href")).toBe("./other.py#L8");
|
||||
expect(root.querySelector("script,[style]")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { Marked } from "marked";
|
||||
import { mimeText, object, own } from "./outputs";
|
||||
|
||||
export const OUTPUT_CSP =
|
||||
"default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; script-src 'none'; connect-src 'none'; form-action 'none'; base-uri 'none'; frame-src 'none'; object-src 'none'; font-src 'none'; media-src 'none'; navigate-to 'none'";
|
||||
const HTML_TAGS = [
|
||||
"div",
|
||||
"span",
|
||||
"p",
|
||||
"br",
|
||||
"hr",
|
||||
"pre",
|
||||
"code",
|
||||
"samp",
|
||||
"kbd",
|
||||
"b",
|
||||
"strong",
|
||||
"i",
|
||||
"em",
|
||||
"u",
|
||||
"s",
|
||||
"small",
|
||||
"sub",
|
||||
"sup",
|
||||
"blockquote",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"dl",
|
||||
"dt",
|
||||
"dd",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"table",
|
||||
"caption",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tfoot",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"colgroup",
|
||||
"col",
|
||||
"img",
|
||||
"a",
|
||||
"details",
|
||||
"summary",
|
||||
];
|
||||
const RASTER = /^data:image\/(?:png|jpeg);base64,[a-z0-9+/=\s]+$/i;
|
||||
const SVG_TAGS: Record<string, true> = {
|
||||
svg: true,
|
||||
g: true,
|
||||
defs: true,
|
||||
path: true,
|
||||
rect: true,
|
||||
circle: true,
|
||||
ellipse: true,
|
||||
line: true,
|
||||
polyline: true,
|
||||
polygon: true,
|
||||
text: true,
|
||||
tspan: true,
|
||||
title: true,
|
||||
desc: true,
|
||||
clippath: true,
|
||||
mask: true,
|
||||
lineargradient: true,
|
||||
radialgradient: true,
|
||||
stop: true,
|
||||
use: true,
|
||||
symbol: true,
|
||||
pattern: true,
|
||||
image: true,
|
||||
};
|
||||
const SVG_ATTRS: Record<string, true> = {
|
||||
xmlns: true,
|
||||
viewbox: true,
|
||||
width: true,
|
||||
height: true,
|
||||
x: true,
|
||||
y: true,
|
||||
x1: true,
|
||||
x2: true,
|
||||
y1: true,
|
||||
y2: true,
|
||||
cx: true,
|
||||
cy: true,
|
||||
r: true,
|
||||
rx: true,
|
||||
ry: true,
|
||||
d: true,
|
||||
points: true,
|
||||
transform: true,
|
||||
id: true,
|
||||
fill: true,
|
||||
"fill-opacity": true,
|
||||
"fill-rule": true,
|
||||
stroke: true,
|
||||
"stroke-width": true,
|
||||
"stroke-opacity": true,
|
||||
"stroke-linecap": true,
|
||||
"stroke-linejoin": true,
|
||||
"stroke-dasharray": true,
|
||||
"stroke-dashoffset": true,
|
||||
opacity: true,
|
||||
"clip-path": true,
|
||||
"clip-rule": true,
|
||||
clippathunits: true,
|
||||
mask: true,
|
||||
maskunits: true,
|
||||
gradientunits: true,
|
||||
gradienttransform: true,
|
||||
offset: true,
|
||||
"stop-color": true,
|
||||
"stop-opacity": true,
|
||||
"font-family": true,
|
||||
"font-size": true,
|
||||
"font-weight": true,
|
||||
"font-style": true,
|
||||
"text-anchor": true,
|
||||
"dominant-baseline": true,
|
||||
dx: true,
|
||||
dy: true,
|
||||
preserveaspectratio: true,
|
||||
patternunits: true,
|
||||
patterntransform: true,
|
||||
href: true,
|
||||
"xlink:href": true,
|
||||
};
|
||||
|
||||
/** Image-only SVG: no foreign HTML, animation, scripts, CSS, or external references. */
|
||||
export function sanitizedSvgUrl(source: string): string {
|
||||
const clean = DOMPurify.sanitize(source, {
|
||||
USE_PROFILES: { svg: true, svgFilters: false },
|
||||
ADD_TAGS: ["use"],
|
||||
RETURN_DOM_FRAGMENT: true,
|
||||
});
|
||||
const svg = clean.querySelector("svg");
|
||||
if (!svg) return "";
|
||||
for (const node of [svg, ...svg.querySelectorAll("*")]) {
|
||||
if (!Object.hasOwn(SVG_TAGS, node.localName.toLowerCase())) {
|
||||
node.remove();
|
||||
continue;
|
||||
}
|
||||
// Matplotlib uses presentation CSS. Convert only safe paint/text properties
|
||||
// to SVG attributes before dropping style itself.
|
||||
const presentation = node.getAttribute("style");
|
||||
if (presentation)
|
||||
for (const declaration of presentation.split(";")) {
|
||||
const colon = declaration.indexOf(":");
|
||||
if (colon < 0) continue;
|
||||
const name = declaration.slice(0, colon).trim().toLowerCase();
|
||||
const value = declaration.slice(colon + 1).trim();
|
||||
if (
|
||||
/^(?:fill(?:-opacity|-rule)?|stroke(?:-width|-opacity|-linecap|-linejoin|-dasharray|-dashoffset)?|opacity|font-size|font-weight|font-style|font-family|text-anchor|stop-color|stop-opacity)$/.test(
|
||||
name,
|
||||
)
|
||||
)
|
||||
node.setAttribute(name, value);
|
||||
}
|
||||
for (const attr of [...node.attributes]) {
|
||||
const name = attr.name.toLowerCase();
|
||||
const value = attr.value.trim();
|
||||
if (
|
||||
!Object.hasOwn(SVG_ATTRS, name) ||
|
||||
/[\p{Cc}\\]/u.test(value) ||
|
||||
((name === "href" || name === "xlink:href") &&
|
||||
!/^#[\w.-]+$/.test(value) &&
|
||||
!(node.localName.toLowerCase() === "image" && RASTER.test(value))) ||
|
||||
(/url\s*\(/i.test(value) && !/^url\(\s*#[\w.-]+\s*\)$/i.test(value))
|
||||
)
|
||||
node.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(svg))}`;
|
||||
}
|
||||
|
||||
function htmlRoot(html: string, markdown = false): HTMLElement {
|
||||
const root = DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: HTML_TAGS,
|
||||
ALLOWED_ATTR: [
|
||||
"src",
|
||||
"alt",
|
||||
"title",
|
||||
"width",
|
||||
"height",
|
||||
"colspan",
|
||||
"rowspan",
|
||||
"scope",
|
||||
"align",
|
||||
...(markdown ? ["href"] : ["style"]),
|
||||
],
|
||||
// Attachment references are resolved by us after sanitization. No arbitrary
|
||||
// URI survives the explicit src allowlist below.
|
||||
ADD_URI_SAFE_ATTR: ["src"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOW_ARIA_ATTR: false,
|
||||
RETURN_DOM: true,
|
||||
}) as HTMLElement;
|
||||
for (const node of root.querySelectorAll("*")) {
|
||||
// Style attributes are iframe-only. Strip URL-bearing declarations even
|
||||
// though the child CSP also blocks their network requests.
|
||||
const style = node.getAttribute("style");
|
||||
if (style && /url\s*\(|image-set\s*\(|src\s*\(|https?:|\/\/|@|\\|expression\s*\(|behavior|binding/i.test(style))
|
||||
node.removeAttribute("style");
|
||||
const src = node.getAttribute("src");
|
||||
if (src && !RASTER.test(src) && !(markdown && src.startsWith("attachment:"))) node.removeAttribute("src");
|
||||
if (!markdown) node.removeAttribute("href");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
export function staticOutputDocument(html: string): string {
|
||||
const root = htmlRoot(html);
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${OUTPUT_CSP}"><style>body{margin:8px;font:13px system-ui,sans-serif;color:#222;background:white;overflow-wrap:anywhere}table{border-collapse:collapse;max-width:100%}th,td{padding:4px 8px;border:1px solid #ddd;text-align:left}pre{white-space:pre-wrap}img{max-width:100%;height:auto}</style></head><body>${root.innerHTML}</body></html>`;
|
||||
}
|
||||
|
||||
const markdown = new Marked({ gfm: true });
|
||||
/**
|
||||
* Sanitized Markdown with inert links; link clicks are handled by the editor and
|
||||
* `attachment:` images are left for `resolveMarkdownAttachments`.
|
||||
*/
|
||||
export function markdownRoot(source: string): HTMLElement {
|
||||
const root = htmlRoot(markdown.parse(source, { async: false }) as string, true);
|
||||
for (const link of root.querySelectorAll("a[href]")) {
|
||||
const href = link.getAttribute("href")!;
|
||||
link.removeAttribute("href");
|
||||
link.setAttribute("data-notebook-href", href);
|
||||
link.setAttribute("role", "link");
|
||||
link.setAttribute("tabindex", "0");
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function attachmentName(img: Element): string | null | undefined {
|
||||
const src = img.getAttribute("src");
|
||||
if (!src?.startsWith("attachment:")) return undefined;
|
||||
try {
|
||||
return decodeURIComponent(src.slice("attachment:".length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Names referenced after Markdown entity and URI decoding, which a source-text search would miss. */
|
||||
export function markdownAttachmentNames(root: HTMLElement): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const img of root.querySelectorAll("img")) {
|
||||
const name = attachmentName(img);
|
||||
if (name) names.add(name);
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
/** Host Markdown has no arbitrary CSS or remote images: only embedded attachments resolve. */
|
||||
export function resolveMarkdownAttachments(root: HTMLElement, attachments: unknown): HTMLElement {
|
||||
for (const img of root.querySelectorAll("img")) {
|
||||
const name = attachmentName(img);
|
||||
if (name === undefined) continue;
|
||||
if (name === null) {
|
||||
img.removeAttribute("src");
|
||||
continue;
|
||||
}
|
||||
const data = object(own(attachments, name));
|
||||
const url = imageMimeUrl(data);
|
||||
if (url) img.setAttribute("src", url);
|
||||
else img.replaceWith(document.createTextNode(img.getAttribute("alt") || name));
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
export function imageMimeUrl(bundle: Record<string, unknown>): string {
|
||||
for (const mime of ["image/png", "image/jpeg"]) {
|
||||
const value = mimeText(bundle[mime]).replace(/\s/g, "");
|
||||
if (value && /^[a-z0-9+/=]+$/i.test(value)) return `data:${mime};base64,${value}`;
|
||||
}
|
||||
const svg = mimeText(bundle["image/svg+xml"]);
|
||||
return svg ? sanitizedSvgUrl(svg) : "";
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type * as TauriCore from "@tauri-apps/api/core";
|
||||
import type { IdeEvent, NotebookSnapshot } from "@/bindings";
|
||||
import { IpcError } from "@/platform/ipc";
|
||||
import type * as Ipc from "@/platform/ipc";
|
||||
import { NotebookSession, notebookBackend } from "./session";
|
||||
|
||||
type KernelEvent = Extract<IdeEvent, { type: "notebookKernel" }>;
|
||||
const kernelListeners = vi.hoisted(() => new Set<(event: KernelEvent) => void>());
|
||||
vi.mock("@/platform/ipc", async (original) => {
|
||||
const actual = await original<typeof Ipc>();
|
||||
return {
|
||||
...actual,
|
||||
onIdeEvent: (_type: string, listener: (event: KernelEvent) => void) => {
|
||||
kernelListeners.add(listener);
|
||||
return () => kernelListeners.delete(listener);
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@tauri-apps/api/core", async (original) => ({
|
||||
...(await original<typeof TauriCore>()),
|
||||
Channel: class {
|
||||
onmessage = (_event: unknown) => {};
|
||||
},
|
||||
}));
|
||||
afterEach(() => kernelListeners.clear());
|
||||
|
||||
function kernelFailure(message: string) {
|
||||
for (const listener of kernelListeners)
|
||||
listener({ type: "notebookKernel", id: "document-a", path: "/p/a.ipynb", state: "dead", message });
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function snapshot(): NotebookSnapshot {
|
||||
return {
|
||||
id: "document-a",
|
||||
path: "/p/a.ipynb",
|
||||
version: 1,
|
||||
dirty: false,
|
||||
nbformat: 4,
|
||||
nbformatMinor: 5,
|
||||
metadata: {},
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
cells: [
|
||||
{
|
||||
id: "cell-a",
|
||||
cellType: "code",
|
||||
source: "original",
|
||||
metadata: {},
|
||||
attachments: {},
|
||||
executionCount: null,
|
||||
outputs: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
function model() {
|
||||
let document = snapshot();
|
||||
const backend: typeof notebookBackend = {
|
||||
...notebookBackend,
|
||||
open: async () => structuredClone(document),
|
||||
get: async () => structuredClone(document),
|
||||
environments: async () => [[], null],
|
||||
close: async () => null,
|
||||
interrupt: async () => null,
|
||||
cancel: async () => {},
|
||||
edit: async (_id, version, edit) => {
|
||||
if (version !== document.version) throw new IpcError({ kind: "outOfSync", message: "The document changed" });
|
||||
if (edit.type !== "setSource") throw new Error("This regression model accepts source edits only");
|
||||
document = {
|
||||
...document,
|
||||
version: document.version + 1,
|
||||
dirty: true,
|
||||
canUndo: true,
|
||||
cells: document.cells.map((cell) => (cell.id === edit.cell ? { ...cell, source: edit.source } : cell)),
|
||||
};
|
||||
return structuredClone(document);
|
||||
},
|
||||
save: async () => {
|
||||
document = { ...document, dirty: false };
|
||||
return structuredClone(document);
|
||||
},
|
||||
reload: async () => {
|
||||
document = snapshot();
|
||||
return structuredClone(document);
|
||||
},
|
||||
};
|
||||
return {
|
||||
backend,
|
||||
read: () => document,
|
||||
replace: (next: NotebookSnapshot) => {
|
||||
document = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("shared notebook mutation session", () => {
|
||||
it("retires event listeners even when the backend project disappears during close", async () => {
|
||||
const m = model();
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
const kernel = session.getState().kernel;
|
||||
const failure = new IpcError({ kind: "notFound", message: "The backend project was replaced" });
|
||||
m.backend.close = async () => {
|
||||
throw failure;
|
||||
};
|
||||
await expect(session.close()).rejects.toBe(failure);
|
||||
kernelFailure("Late failure from the retired kernel");
|
||||
expect(session.getState().error).toBeNull();
|
||||
expect(session.getState().kernel).toBe(kernel);
|
||||
});
|
||||
|
||||
it("still releases the backend document when interrupting a running batch fails during close", async () => {
|
||||
const m = model();
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
const running = deferred<NotebookSnapshot>();
|
||||
const retired = new IpcError({ kind: "notFound", message: "notebook project has closed" });
|
||||
const started = deferred<void>();
|
||||
m.backend.execute = () => {
|
||||
started.resolve();
|
||||
return running.promise;
|
||||
};
|
||||
m.backend.interrupt = async () => {
|
||||
running.reject(retired);
|
||||
throw retired;
|
||||
};
|
||||
const closed = vi.fn(async () => null);
|
||||
m.backend.close = closed;
|
||||
const execution = session.execute().catch(() => {});
|
||||
await started.promise;
|
||||
await session.close();
|
||||
await execution;
|
||||
expect(closed).toHaveBeenCalledWith("document-a");
|
||||
});
|
||||
|
||||
it("retains rapid typing over an older acknowledgement and serializes the latest intent", async () => {
|
||||
const m = model();
|
||||
const started = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
const edit = m.backend.edit;
|
||||
let first = true;
|
||||
m.backend.edit = async (...args) => {
|
||||
if (first) {
|
||||
first = false;
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
}
|
||||
return edit(...args);
|
||||
};
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "first" });
|
||||
await started.promise;
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "second" });
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "latest typing" });
|
||||
const visible: string[] = [];
|
||||
const unsubscribe = session.subscribe(() => visible.push(session.cells()[0]!.source));
|
||||
expect(session.cells()[0]!.source).toBe("latest typing");
|
||||
expect(session.isDirty()).toBe(true);
|
||||
release.resolve();
|
||||
await session.flush();
|
||||
expect(visible.every((source) => source === "latest typing")).toBe(true);
|
||||
expect(m.read().cells[0]!.source).toBe("latest typing");
|
||||
expect(m.read().version).toBe(3);
|
||||
await session.save();
|
||||
expect(session.isDirty()).toBe(false);
|
||||
unsubscribe();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("rebases stable-cell source intent after an OutOfSync response", async () => {
|
||||
const m = model();
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
m.replace({ ...snapshot(), version: 4, cells: [{ ...snapshot().cells[0]!, source: "other split changed this" }] });
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "retained user intent" });
|
||||
await session.flush();
|
||||
expect(m.read().cells[0]!.source).toBe("retained user intent");
|
||||
expect(session.getState().snapshot?.version).toBe(5);
|
||||
expect(session.getState().pending).toBe(0);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("orders typing after an in-flight undo so its response cannot overwrite the new source", async () => {
|
||||
const m = model();
|
||||
const started = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
m.backend.undo = async () => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
const current = m.read();
|
||||
const undone = {
|
||||
...current,
|
||||
version: current.version + 1,
|
||||
canRedo: true,
|
||||
cells: current.cells.map((cell) => ({ ...cell, source: "original" })),
|
||||
};
|
||||
m.replace(undone);
|
||||
return structuredClone(undone);
|
||||
};
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "before undo" });
|
||||
const undo = session.undo();
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "immediately after undo" });
|
||||
await started.promise;
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "typing after undo" });
|
||||
expect(session.cells()[0]!.source).toBe("typing after undo");
|
||||
release.resolve();
|
||||
await undo;
|
||||
await session.flush();
|
||||
expect(m.read().cells[0]!.source).toBe("typing after undo");
|
||||
expect(session.cells()[0]!.source).toBe("typing after undo");
|
||||
expect(m.read().version).toBe(4);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("keeps failed and subsequent source visible/dirty until an explicit retry succeeds", async () => {
|
||||
const m = model();
|
||||
const edit = m.backend.edit;
|
||||
let unavailable = true;
|
||||
m.backend.edit = async (...args) => {
|
||||
if (unavailable) throw new Error("Backend disconnected");
|
||||
return edit(...args);
|
||||
};
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "unsent draft" });
|
||||
await expect(session.flush()).rejects.toThrow("Backend disconnected");
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "more typing after failure" });
|
||||
expect(session.cells()[0]!.source).toBe("more typing after failure");
|
||||
expect(session.pendingSources()).toEqual([{ cell: "cell-a", source: "more typing after failure" }]);
|
||||
expect(session.isDirty()).toBe(true);
|
||||
expect(m.read().cells[0]!.source).toBe("original");
|
||||
unavailable = false;
|
||||
await session.save();
|
||||
expect(m.read().cells[0]!.source).toBe("more typing after failure");
|
||||
expect(session.isDirty()).toBe(false);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("freezes edit admission during a close decision and resumes only after cancellation", async () => {
|
||||
const m = model();
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "checkpoint" });
|
||||
await session.prepareClose();
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "must not enter closing document" });
|
||||
expect(session.cells()[0]!.source).toBe("checkpoint");
|
||||
session.cancelClose();
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "resumed" });
|
||||
await session.flush();
|
||||
expect(m.read().cells[0]!.source).toBe("resumed");
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("retains a kernel failure arriving before a late source acknowledgement and subsequent undo", async () => {
|
||||
const m = model();
|
||||
const started = deferred<void>();
|
||||
const release = deferred<void>();
|
||||
const edit = m.backend.edit;
|
||||
m.backend.edit = async (...args) => {
|
||||
started.resolve();
|
||||
await release.promise;
|
||||
return edit(...args);
|
||||
};
|
||||
m.backend.undo = async () => {
|
||||
const current = m.read();
|
||||
const undone = {
|
||||
...current,
|
||||
version: current.version + 1,
|
||||
cells: current.cells.map((cell) => ({ ...cell, source: "original" })),
|
||||
};
|
||||
m.replace(undone);
|
||||
return structuredClone(undone);
|
||||
};
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "pending draft" });
|
||||
await started.promise;
|
||||
const failure = "Kernel process exited";
|
||||
kernelFailure(failure);
|
||||
release.resolve();
|
||||
await session.flush();
|
||||
expect(session.cells()[0]!.source).toBe("pending draft");
|
||||
expect(session.getState().pending).toBe(0);
|
||||
expect(session.getState().error).toBe(failure);
|
||||
await session.undo();
|
||||
expect(session.cells()[0]!.source).toBe("original");
|
||||
expect(session.getState().error).toBe(failure);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("retains an external save conflict through editing until save succeeds", async () => {
|
||||
const m = model();
|
||||
const save = m.backend.save;
|
||||
const conflict = new Error("File changed on disk");
|
||||
m.backend.save = async () => {
|
||||
throw conflict;
|
||||
};
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
await expect(session.save()).rejects.toBe(conflict);
|
||||
session.report(conflict); // The editor's promise catch must preserve ownership.
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "draft after conflict" });
|
||||
await session.flush();
|
||||
expect(m.read().cells[0]!.source).toBe("draft after conflict");
|
||||
expect(session.isDirty()).toBe(true);
|
||||
expect(session.getState().error).toBe(conflict.message);
|
||||
m.backend.save = save;
|
||||
await session.save();
|
||||
expect(session.isDirty()).toBe(false);
|
||||
expect(session.getState().error).toBeNull();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("clears a recovered mutation failure on source retry without requiring save", async () => {
|
||||
const m = model();
|
||||
const edit = m.backend.edit;
|
||||
const failure = new Error("Edit transport unavailable");
|
||||
m.backend.edit = async () => {
|
||||
throw failure;
|
||||
};
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "retained draft" });
|
||||
await expect(session.save()).rejects.toBe(failure);
|
||||
session.report(failure);
|
||||
expect(session.getState().error).toBe(failure.message);
|
||||
expect(session.getState().pending).toBe(1);
|
||||
expect(session.cells()[0]!.source).toBe("retained draft");
|
||||
m.backend.edit = edit;
|
||||
await session.flush();
|
||||
expect(m.read().cells[0]!.source).toBe("retained draft");
|
||||
expect(session.getState().pending).toBe(0);
|
||||
expect(session.isDirty()).toBe(true);
|
||||
expect(session.getState().error).toBeNull();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("reveals a retained save conflict after a newer mutation failure recovers", async () => {
|
||||
const m = model();
|
||||
const conflict = new Error("External save conflict");
|
||||
m.backend.save = async () => {
|
||||
throw conflict;
|
||||
};
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
await expect(session.save()).rejects.toBe(conflict);
|
||||
const edit = m.backend.edit;
|
||||
const mutation = new Error("Edit rejected");
|
||||
m.backend.edit = async () => {
|
||||
throw mutation;
|
||||
};
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "draft" });
|
||||
await expect(session.flush()).rejects.toBe(mutation);
|
||||
expect(session.getState().error).toBe(mutation.message);
|
||||
m.backend.edit = edit;
|
||||
await session.flush();
|
||||
expect(session.cells()[0]!.source).toBe("draft");
|
||||
expect(session.getState().error).toBe(conflict.message);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("retains execution failure across editing and save, then clears it after successful execution", async () => {
|
||||
const m = model();
|
||||
const failure = new Error("Selected interpreter lacks ipykernel");
|
||||
m.backend.execute = async () => {
|
||||
throw failure;
|
||||
};
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
await expect(session.execute()).rejects.toBe(failure);
|
||||
session.report(failure);
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "print(42)" });
|
||||
await session.save();
|
||||
expect(m.read().cells[0]!.source).toBe("print(42)");
|
||||
expect(session.getState().error).toBe(failure.message);
|
||||
m.backend.execute = async () => structuredClone(m.read());
|
||||
await session.execute();
|
||||
expect(session.getState().running).toBe(false);
|
||||
expect(session.getState().error).toBeNull();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("recovers kernel failure on restart without erasing a save conflict", async () => {
|
||||
const m = model();
|
||||
const conflict = new Error("External save conflict");
|
||||
m.backend.save = async () => {
|
||||
throw conflict;
|
||||
};
|
||||
m.backend.restart = async () => structuredClone(m.read());
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
await expect(session.save()).rejects.toBe(conflict);
|
||||
kernelFailure("Kernel died");
|
||||
await session.restart();
|
||||
expect(session.getState().kernel).toBe("idle");
|
||||
expect(session.getState().frozen).toBe(false);
|
||||
expect(session.getState().error).toBe(conflict.message);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("retains environment discovery failure through editing, save, execution and restart until discovery recovers", async () => {
|
||||
const m = model();
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
const environments = m.backend.environments;
|
||||
const failure = new Error("Environment discovery unavailable");
|
||||
m.backend.environments = async () => {
|
||||
throw failure;
|
||||
};
|
||||
await session.detectEnvironments();
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "draft" });
|
||||
await session.save();
|
||||
m.backend.execute = async () => structuredClone(m.read());
|
||||
await session.execute();
|
||||
m.backend.restart = async () => structuredClone(m.read());
|
||||
await session.restart();
|
||||
expect(session.getState().error).toBe(failure.message);
|
||||
m.backend.environments = environments;
|
||||
await session.detectEnvironments();
|
||||
expect(session.getState().error).toBeNull();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("recovers reload and document failures after discard without clearing a kernel failure", async () => {
|
||||
const m = model();
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
const conflict = new Error("External save conflict");
|
||||
m.backend.save = async () => {
|
||||
throw conflict;
|
||||
};
|
||||
await expect(session.save()).rejects.toBe(conflict);
|
||||
const edit = m.backend.edit;
|
||||
m.backend.edit = async () => {
|
||||
throw new Error("Edit unavailable");
|
||||
};
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "discarded draft" });
|
||||
await expect(session.flush()).rejects.toThrow("Edit unavailable");
|
||||
const reload = m.backend.reload;
|
||||
const failure = new Error("Reload unavailable");
|
||||
m.backend.reload = async () => {
|
||||
throw failure;
|
||||
};
|
||||
await expect(session.discard()).rejects.toBe(failure);
|
||||
session.report(failure);
|
||||
kernelFailure("Kernel is still dead");
|
||||
m.backend.reload = reload;
|
||||
m.backend.edit = edit;
|
||||
await session.discard();
|
||||
expect(session.cells()[0]!.source).toBe("original");
|
||||
expect(session.getState().pending).toBe(0);
|
||||
expect(session.isDirty()).toBe(false);
|
||||
expect(session.getState().error).toBe("Kernel is still dead");
|
||||
session.cancelClose();
|
||||
m.backend.restart = async () => structuredClone(m.read());
|
||||
await session.restart();
|
||||
expect(session.getState().error).toBeNull();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
it("retains restart failure across editing and clears it only after a successful restart", async () => {
|
||||
const m = model();
|
||||
const failure = new Error("Kernel restart failed");
|
||||
m.backend.restart = async () => {
|
||||
throw failure;
|
||||
};
|
||||
const session = new NotebookSession("/p/a.ipynb", m.backend, true);
|
||||
await session.load();
|
||||
await expect(session.restart()).rejects.toBe(failure);
|
||||
session.report(failure);
|
||||
expect(session.getState().frozen).toBe(false);
|
||||
session.edit({ type: "setSource", cell: "cell-a", source: "editable after failed restart" });
|
||||
await session.flush();
|
||||
expect(session.cells()[0]!.source).toBe("editable after failed restart");
|
||||
expect(session.getState().error).toBe(failure.message);
|
||||
m.backend.restart = async () => structuredClone(m.read());
|
||||
await session.restart();
|
||||
expect(session.getState().error).toBeNull();
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,509 @@
|
||||
import { Channel } from "@tauri-apps/api/core";
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import type { NotebookSnapshot, NotebookEdit, NotebookOutputEvent, Sdk, RequestId } from "@/bindings";
|
||||
import { call, commands, IpcError, isTauri, nextRequestId, onIdeEvent } from "@/platform/ipc";
|
||||
import { notifyEditorDocumentChanged } from "@/workbench/contributions";
|
||||
import { OutputReducer } from "./outputs";
|
||||
|
||||
export const notebookBackend = {
|
||||
open: (path: string) => call(commands.notebookOpen(path)),
|
||||
get: (id: string) => call(commands.notebookGet(id)),
|
||||
edit: (id: string, version: number, edit: NotebookEdit) => call(commands.notebookEdit(id, version, edit)),
|
||||
save: (id: string) => call(commands.notebookSave(id)),
|
||||
reload: (id: string) => call(commands.notebookReload(id)),
|
||||
undo: (id: string) => call(commands.notebookUndo(id)),
|
||||
redo: (id: string) => call(commands.notebookRedo(id)),
|
||||
close: (id: string) => call(commands.notebookClose(id)),
|
||||
interrupt: (id: string) => call(commands.notebookInterrupt(id)),
|
||||
restart: (id: string) => call(commands.notebookRestart(id)),
|
||||
execute: (
|
||||
id: string,
|
||||
cells: string[],
|
||||
python: string | null,
|
||||
request: RequestId,
|
||||
output: Channel<NotebookOutputEvent>,
|
||||
) => call(commands.notebookExecute(id, cells, python, request, output)),
|
||||
environments: () => Promise.all([call(commands.sdkDetect("python")), call(commands.sdkPythonEffective())]),
|
||||
cancel: (request: RequestId) => commands.cancelRequest(request),
|
||||
};
|
||||
|
||||
type Pending = { edit: NotebookEdit | { type: "undo" } | { type: "redo" }; committed: boolean };
|
||||
type ErrorOwner = "load" | "mutation" | "save" | "execution" | "kernel" | "environment" | "reload" | "other";
|
||||
|
||||
const PYTHON_KEY = "openidea.notebook.python:";
|
||||
function savedPython(path: string): string | null {
|
||||
try {
|
||||
return globalThis.localStorage?.getItem(PYTHON_KEY + path) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export interface NotebookViewState {
|
||||
snapshot: NotebookSnapshot | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
running: boolean;
|
||||
frozen: boolean;
|
||||
kernel: string;
|
||||
python: string | null;
|
||||
environments: Sdk[];
|
||||
pending: number;
|
||||
}
|
||||
|
||||
/** One session/queue per file; tabs and mounted React views do not own its lifetime. */
|
||||
export class NotebookSession {
|
||||
private state: NotebookViewState = {
|
||||
snapshot: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
running: false,
|
||||
frozen: false,
|
||||
kernel: "idle",
|
||||
python: null,
|
||||
environments: [],
|
||||
pending: 0,
|
||||
};
|
||||
private listeners = new Set<() => void>();
|
||||
private loading: Promise<void> | null = null;
|
||||
private pending: Pending[] = [];
|
||||
private pumping: Promise<void> | null = null;
|
||||
private blocked = false;
|
||||
private disposed = false;
|
||||
private authoritativeVersion = -1;
|
||||
private executionCancelled = false;
|
||||
private generation = 0;
|
||||
private execution: Promise<void> | null = null;
|
||||
private request: RequestId | null = null;
|
||||
private refreshing: Promise<void> | null = null;
|
||||
private refreshAgain = false;
|
||||
private output = new OutputReducer();
|
||||
private stopEvents: (() => void) | null = null;
|
||||
private errors = new Map<ErrorOwner, { cause: unknown; message: string }>();
|
||||
// EditorState contains selection and syntax state, but deliberately no local history.
|
||||
readonly editors = new Map<string, EditorState>();
|
||||
readonly markdownEditing = new Set<string>();
|
||||
selectedCell: string | null = null;
|
||||
scrollTop = 0;
|
||||
|
||||
constructor(
|
||||
readonly path: string,
|
||||
private backend = notebookBackend,
|
||||
private desktop = isTauri(),
|
||||
) {
|
||||
this.state = { ...this.state, python: savedPython(path) };
|
||||
}
|
||||
subscribe = (listener: () => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
getState = () => this.state;
|
||||
isDirty = () => this.pending.length > 0 || !!this.state.snapshot?.dirty;
|
||||
private publish(patch: Partial<NotebookViewState> = {}) {
|
||||
const error = [...this.errors.values()].at(-1)?.message ?? null;
|
||||
this.state = { ...this.state, ...patch, error, pending: this.pending.length };
|
||||
this.listeners.forEach((listener) => listener());
|
||||
notifyEditorDocumentChanged();
|
||||
}
|
||||
report(error: unknown) {
|
||||
this.reportOperation(error, "other");
|
||||
}
|
||||
private reportOperation(error: unknown, owner: ErrorOwner) {
|
||||
// UI promise catches and operations waiting on flush re-report the same
|
||||
// failure. Keep its original owner so only the real recovery clears it.
|
||||
if ([...this.errors.values()].some((entry) => entry.cause === error)) return;
|
||||
this.errors.delete(owner);
|
||||
this.errors.set(owner, { cause: error, message: error instanceof Error ? error.message : String(error) });
|
||||
this.publish();
|
||||
}
|
||||
private recover(...owners: ErrorOwner[]) {
|
||||
for (const owner of owners) this.errors.delete(owner);
|
||||
this.publish();
|
||||
}
|
||||
private accept(snapshot: NotebookSnapshot) {
|
||||
if (this.disposed) return;
|
||||
const current = this.state.snapshot;
|
||||
if (current && (current.id !== snapshot.id || snapshot.version < this.authoritativeVersion)) return;
|
||||
this.authoritativeVersion = snapshot.version;
|
||||
if (current && current.version > snapshot.version) {
|
||||
// Only Channel output reductions can be newer than the latest full
|
||||
// snapshot. Take acknowledged user fields/order, retain newer outputs.
|
||||
const outputCells = new Map(current.cells.map((cell) => [cell.id, cell]));
|
||||
snapshot = {
|
||||
...snapshot,
|
||||
version: current.version,
|
||||
dirty: current.dirty,
|
||||
cells: snapshot.cells.map((cell) => {
|
||||
const newer = outputCells.get(cell.id);
|
||||
return newer && cell.cellType === "code"
|
||||
? { ...cell, outputs: newer.outputs, executionCount: newer.executionCount }
|
||||
: cell;
|
||||
}),
|
||||
};
|
||||
}
|
||||
this.publish({ snapshot });
|
||||
const live = new Set(snapshot.cells.map((cell) => cell.id));
|
||||
for (const id of this.editors.keys()) if (!live.has(id)) this.editors.delete(id);
|
||||
}
|
||||
load(opened?: NotebookSnapshot): Promise<void> {
|
||||
if (this.loading) return this.loading;
|
||||
if (this.disposed) return Promise.reject(new Error("Notebook is closed"));
|
||||
this.publish({ loading: true });
|
||||
this.loading = (async () => {
|
||||
if (!this.desktop) throw new Error("Notebook editing and execution require the desktop app.");
|
||||
const snapshot = opened ?? (await this.backend.open(this.path));
|
||||
// Closing a still-loading tab must release the real backend document too.
|
||||
if (this.disposed) {
|
||||
await this.backend.close(snapshot.id);
|
||||
return;
|
||||
}
|
||||
this.accept(snapshot);
|
||||
this.recover("load");
|
||||
this.stopEvents = onIdeEvent("notebookKernel", (event) => {
|
||||
if (this.disposed || event.path !== snapshot.path || event.id !== snapshot.id) return;
|
||||
this.publish({ kernel: event.state });
|
||||
if (event.message) this.reportOperation(event.message, "kernel");
|
||||
});
|
||||
void this.detectEnvironments();
|
||||
})()
|
||||
.catch((error) => {
|
||||
this.reportOperation(error, "load");
|
||||
this.loading = null;
|
||||
throw error;
|
||||
})
|
||||
.finally(() => this.publish({ loading: false }));
|
||||
return this.loading;
|
||||
}
|
||||
async detectEnvironments() {
|
||||
try {
|
||||
const [environments, effective] = await this.backend.environments();
|
||||
if (!this.disposed) {
|
||||
this.publish({ environments, python: this.state.python ?? effective });
|
||||
this.recover("environment");
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.disposed) this.reportOperation(error, "environment");
|
||||
}
|
||||
}
|
||||
selectPython(python: string) {
|
||||
if (this.state.running || this.state.frozen) return;
|
||||
try {
|
||||
globalThis.localStorage?.setItem(PYTHON_KEY + this.path, python);
|
||||
} catch {
|
||||
/* Interpreter preference is optional when storage is unavailable. */
|
||||
}
|
||||
this.publish({ python });
|
||||
}
|
||||
pendingSources() {
|
||||
const sources = new Map<string, string>();
|
||||
for (const { edit } of this.pending) if (edit.type === "setSource") sources.set(edit.cell, edit.source);
|
||||
return [...sources].map(([cell, source]) => ({ cell, source }));
|
||||
}
|
||||
|
||||
/** Pending source is overlaid, never replaced by an older IPC response. */
|
||||
cells() {
|
||||
const cells = this.state.snapshot?.cells ?? [];
|
||||
return cells.map((cell) => {
|
||||
let source = cell.source;
|
||||
for (const item of this.pending)
|
||||
if (item.edit.type === "setSource" && item.edit.cell === cell.id) source = item.edit.source;
|
||||
return source === cell.source ? cell : { ...cell, source };
|
||||
});
|
||||
}
|
||||
edit(edit: NotebookEdit) {
|
||||
if (this.disposed || this.state.frozen || !this.state.snapshot) return;
|
||||
if (this.state.running && edit.type !== "setSource") {
|
||||
this.reportOperation(new Error("Stop execution before changing notebook structure."), "mutation");
|
||||
return;
|
||||
}
|
||||
const last = this.pending.at(-1);
|
||||
// Never mutate an in-flight intent; only coalesce an unsent tail.
|
||||
if (
|
||||
edit.type === "setSource" &&
|
||||
last &&
|
||||
!last.committed &&
|
||||
last !== this.inflight &&
|
||||
last.edit.type === "setSource" &&
|
||||
last.edit.cell === edit.cell
|
||||
)
|
||||
last.edit = edit;
|
||||
else this.pending.push({ edit, committed: false });
|
||||
this.publish();
|
||||
if (!this.blocked) void this.pump().catch(() => {});
|
||||
}
|
||||
private inflight: Pending | null = null;
|
||||
private pump(): Promise<void> {
|
||||
if (this.pumping) return this.pumping;
|
||||
this.pumping = Promise.resolve()
|
||||
.then(async () => {
|
||||
while (this.pending.length && !this.disposed) {
|
||||
const item = this.pending[0]!;
|
||||
this.inflight = item;
|
||||
let snapshot = this.state.snapshot!;
|
||||
if (!item.committed) {
|
||||
try {
|
||||
const result =
|
||||
item.edit.type === "undo" || item.edit.type === "redo"
|
||||
? await this.backend[item.edit.type](snapshot.id)
|
||||
: await this.backend.edit(snapshot.id, snapshot.version, item.edit);
|
||||
item.committed = true;
|
||||
this.accept(result);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof IpcError) ||
|
||||
error.kind !== "outOfSync" ||
|
||||
item.edit.type === "undo" ||
|
||||
item.edit.type === "redo"
|
||||
)
|
||||
throw error;
|
||||
snapshot = await this.backend.get(snapshot.id);
|
||||
this.accept(snapshot);
|
||||
// Semantic stable-id rebase, once. Missing/deleted targets remain pending
|
||||
// with an actionable backend error rather than silently dropping user intent.
|
||||
const result = await this.backend.edit(snapshot.id, snapshot.version, item.edit);
|
||||
item.committed = true;
|
||||
this.accept(result);
|
||||
}
|
||||
}
|
||||
this.pending.shift();
|
||||
this.recover("mutation");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.blocked = true;
|
||||
this.reportOperation(error, "mutation");
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.inflight = null;
|
||||
this.pumping = null;
|
||||
});
|
||||
return this.pumping;
|
||||
}
|
||||
async flush() {
|
||||
await this.load();
|
||||
this.blocked = false;
|
||||
await this.pump();
|
||||
if (this.pending.length)
|
||||
throw new Error("Notebook edits have not been committed. Retry edits or discard explicitly.");
|
||||
}
|
||||
async save() {
|
||||
try {
|
||||
await this.flush();
|
||||
this.accept(await this.backend.save(this.state.snapshot!.id));
|
||||
this.recover("save");
|
||||
} catch (error) {
|
||||
this.reportOperation(error, "save");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private async history(direction: "undo" | "redo") {
|
||||
if (this.state.running || this.state.frozen) throw new Error("Stop execution before undo or redo.");
|
||||
if (!this.state.snapshot) await this.load();
|
||||
if (this.state.running || this.state.frozen || this.disposed) throw new Error("Notebook is busy or closing.");
|
||||
// History is a queue barrier: later typing must commit AFTER undo/redo, not
|
||||
// race it and get overwritten by the history response.
|
||||
this.pending.push({ edit: { type: direction }, committed: false });
|
||||
this.publish();
|
||||
this.blocked = false;
|
||||
await this.pump();
|
||||
}
|
||||
undo = () => this.history("undo");
|
||||
redo = () => this.history("redo");
|
||||
|
||||
private refresh(): Promise<void> {
|
||||
this.refreshAgain = true;
|
||||
if (this.refreshing) return this.refreshing;
|
||||
this.refreshing = (async () => {
|
||||
while (this.refreshAgain && !this.disposed) {
|
||||
this.refreshAgain = false;
|
||||
const id = this.state.snapshot!.id;
|
||||
this.accept(await this.backend.get(id));
|
||||
}
|
||||
})().finally(() => {
|
||||
this.refreshing = null;
|
||||
});
|
||||
return this.refreshing;
|
||||
}
|
||||
execute(cells?: string[]): Promise<void> {
|
||||
if (this.state.running || this.state.frozen || this.disposed)
|
||||
return Promise.reject(new Error("Notebook is busy or closing."));
|
||||
this.publish({ running: true });
|
||||
this.executionCancelled = false;
|
||||
const generation = ++this.generation;
|
||||
this.output.reset();
|
||||
this.execution = (async () => {
|
||||
await this.flush();
|
||||
if (this.state.frozen || this.executionCancelled || generation !== this.generation) return;
|
||||
const snapshot = this.state.snapshot!;
|
||||
const ids = cells ?? snapshot.cells.filter((cell) => cell.cellType === "code").map((cell) => cell.id);
|
||||
if (!ids.length) return;
|
||||
const request = nextRequestId();
|
||||
this.request = request;
|
||||
const channel = new Channel<NotebookOutputEvent>();
|
||||
let buffered: NotebookOutputEvent[] = [];
|
||||
let frame: number | null = null;
|
||||
let overflow = false;
|
||||
channel.onmessage = (event) => {
|
||||
if (this.disposed || generation !== this.generation || event.id !== snapshot.id) return;
|
||||
// Bound retained Channel work. A flood recovers from backend state instead
|
||||
// of retaining an unbounded browser queue or losing authoritative output.
|
||||
if (buffered.length < 256 && !overflow) buffered.push(event);
|
||||
else {
|
||||
overflow = true;
|
||||
buffered = [];
|
||||
this.output.recover();
|
||||
}
|
||||
if (frame !== null) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
if (this.disposed || generation !== this.generation) {
|
||||
buffered = [];
|
||||
return;
|
||||
}
|
||||
let current = this.state.snapshot!;
|
||||
let refresh = overflow;
|
||||
overflow = false;
|
||||
for (const item of buffered) {
|
||||
const result = this.output.reduce(current, item);
|
||||
current = result.snapshot;
|
||||
refresh ||= result.refresh;
|
||||
}
|
||||
buffered = [];
|
||||
if (current !== this.state.snapshot) this.publish({ snapshot: current });
|
||||
if (refresh) void this.refresh().catch((error) => this.reportOperation(error, "execution"));
|
||||
});
|
||||
};
|
||||
try {
|
||||
const result = await this.backend.execute(snapshot.id, ids, this.state.python, request, channel);
|
||||
if (generation === this.generation) this.accept(result);
|
||||
} finally {
|
||||
if (generation === this.generation && !this.disposed) await this.refresh();
|
||||
}
|
||||
if (generation === this.generation && !this.executionCancelled) this.recover("execution", "kernel");
|
||||
})()
|
||||
.catch((error) => {
|
||||
this.reportOperation(error, "execution");
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
this.request = null;
|
||||
this.execution = null;
|
||||
this.publish({ running: false });
|
||||
});
|
||||
return this.execution;
|
||||
}
|
||||
async interrupt() {
|
||||
this.executionCancelled = true;
|
||||
if (!this.state.snapshot) return;
|
||||
// Batch cancellation precedes interrupt in the host; cancelRequest is an
|
||||
// additional route for requests still waiting on kernel admission/startup.
|
||||
try {
|
||||
if (this.request !== null) await this.backend.cancel(this.request);
|
||||
} finally {
|
||||
await this.backend.interrupt(this.state.snapshot.id);
|
||||
}
|
||||
}
|
||||
private async drain() {
|
||||
const execution = this.execution;
|
||||
if (!execution) return;
|
||||
try {
|
||||
await this.interrupt();
|
||||
} finally {
|
||||
await execution.catch(() => {});
|
||||
}
|
||||
}
|
||||
async restart() {
|
||||
if (this.state.frozen) throw new Error("Notebook is closing.");
|
||||
this.publish({ frozen: true });
|
||||
++this.generation;
|
||||
try {
|
||||
await this.drain();
|
||||
await this.flush();
|
||||
this.output.reset();
|
||||
this.accept(await this.backend.restart(this.state.snapshot!.id));
|
||||
this.publish({ kernel: "idle" });
|
||||
this.recover("execution", "kernel");
|
||||
} catch (error) {
|
||||
this.reportOperation(error, "kernel");
|
||||
throw error;
|
||||
} finally {
|
||||
this.publish({ frozen: false });
|
||||
}
|
||||
}
|
||||
async prepareClose() {
|
||||
this.publish({ frozen: true });
|
||||
if (this.loading) await this.loading.catch(() => {});
|
||||
// An invalid/unavailable file never acquired a backend document.
|
||||
if (!this.state.snapshot) return;
|
||||
try {
|
||||
await this.flush();
|
||||
} finally {
|
||||
await this.drain();
|
||||
}
|
||||
}
|
||||
cancelClose() {
|
||||
if (!this.disposed) this.publish({ frozen: false });
|
||||
}
|
||||
async discard() {
|
||||
this.publish({ frozen: true });
|
||||
++this.generation;
|
||||
try {
|
||||
await this.drain();
|
||||
if (this.pumping) await this.pumping.catch(() => {});
|
||||
const snapshot = this.state.snapshot;
|
||||
if (!snapshot) return;
|
||||
const reloaded = await this.backend.reload(snapshot.id);
|
||||
this.pending = [];
|
||||
this.blocked = false;
|
||||
this.editors.clear();
|
||||
this.output.reset();
|
||||
this.accept(reloaded);
|
||||
this.recover("mutation", "save", "reload");
|
||||
} catch (error) {
|
||||
this.reportOperation(error, "reload");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async close() {
|
||||
this.publish({ frozen: true });
|
||||
++this.generation;
|
||||
try {
|
||||
// The host close interrupts, drains and shuts down authoritatively, and a
|
||||
// retired project already rejects interrupt. Never let that skip release.
|
||||
await this.drain().catch(() => {});
|
||||
if (this.pumping) await this.pumping.catch(() => {});
|
||||
if (this.loading) await this.loading.catch(() => {});
|
||||
const snapshot = this.state.snapshot;
|
||||
if (snapshot) await this.backend.close(snapshot.id);
|
||||
} finally {
|
||||
this.disposed = true;
|
||||
this.stopEvents?.();
|
||||
this.editors.clear();
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sessions = new Map<string, NotebookSession>();
|
||||
export function notebookSession(path: string): NotebookSession {
|
||||
let session = sessions.get(path);
|
||||
if (!session) {
|
||||
session = new NotebookSession(path);
|
||||
sessions.set(path, session);
|
||||
}
|
||||
return session;
|
||||
}
|
||||
export function existingNotebookSession(path: string) {
|
||||
return sessions.get(path);
|
||||
}
|
||||
export async function closeNotebook(path: string) {
|
||||
const session = sessions.get(path);
|
||||
if (!session) return;
|
||||
try {
|
||||
await session.close();
|
||||
} finally {
|
||||
if (sessions.get(path) === session) sessions.delete(path);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
import { registerActions } from "@/platform/actions";
|
||||
import { disconnectRemote } from "@/platform/remote";
|
||||
import { registerOverlay } from "@/workbench/contributions";
|
||||
import { maybeEditorService, registerOverlay } from "@/workbench/contributions";
|
||||
import { confirmLeaveProject } from "@/platform/project";
|
||||
import { ConnectHost } from "./ConnectDialog";
|
||||
import { reloadEnvironment } from "./environment";
|
||||
import { closeConnectDialog, openConnectDialog } from "./model";
|
||||
@@ -29,7 +30,15 @@ export function activate(): () => void {
|
||||
category: "File",
|
||||
keywords: ["ssh", "remote", "close"],
|
||||
when: "projectOpen",
|
||||
run: () => disconnectRemote(),
|
||||
run: async () => {
|
||||
if (!(await confirmLeaveProject())) return;
|
||||
try {
|
||||
await disconnectRemote();
|
||||
} catch (e) {
|
||||
maybeEditorService()?.cancelClose?.();
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remote.reloadEnvironment",
|
||||
|
||||
@@ -127,7 +127,7 @@ function RecentFilesDialog() {
|
||||
};
|
||||
|
||||
// IDEA: Delete closes the selected editor (and drops it from the list) or hides the tool window.
|
||||
const removeSelected = () => {
|
||||
const removeSelected = async () => {
|
||||
if (effectiveColumn === "tools") {
|
||||
const tw = tools[tIndex]?.tw;
|
||||
if (tw) toolWindowService().hide(tw.id);
|
||||
@@ -135,8 +135,10 @@ function RecentFilesDialog() {
|
||||
}
|
||||
const item = files[fIndex];
|
||||
if (!item) return;
|
||||
if (item.editor) maybeEditorService()?.closeInput?.(item.path);
|
||||
else maybeEditorService()?.closePath?.(item.path);
|
||||
const closed = item.editor
|
||||
? await maybeEditorService()?.closeInput?.(item.path)
|
||||
: await maybeEditorService()?.closePath?.(item.path);
|
||||
if (closed === false) return;
|
||||
if (!switcher) void call(commands.removeRecent(item.path)).catch(() => {});
|
||||
setRemoved((r) => new Set(r).add(item.path));
|
||||
setFileIndex(fIndex);
|
||||
@@ -192,7 +194,7 @@ function RecentFilesDialog() {
|
||||
// In Recent Files Backspace edits the speed search text first.
|
||||
if (e.key === "Delete" || (e.key === "Backspace" && !query)) {
|
||||
e.preventDefault();
|
||||
removeSelected();
|
||||
void removeSelected();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab" || ((e.key === "ArrowLeft" || e.key === "ArrowRight") && !query)) {
|
||||
|
||||
@@ -280,7 +280,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
setResolved(true);
|
||||
await refreshNow();
|
||||
// Like IDEA, applying the result closes the merge editor.
|
||||
editorService().closeInput?.(input.key);
|
||||
await editorService().closeInput?.(input.key);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const { provideStartupLaunch, useProject } = await import("@/platform/project");
|
||||
|
||||
beforeEach(() => {
|
||||
openProject.mockReset();
|
||||
useProject.setState({ project: null });
|
||||
useProject.setState({ project: null, lightEdit: false });
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem("openidea.session.openProject", "/p/last");
|
||||
_resetSessionForTests();
|
||||
@@ -46,12 +46,21 @@ describe("reopenLastProject", () => {
|
||||
expect(openProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves files opened at launch in LightEdit", async () => {
|
||||
provideStartupLaunch(Promise.resolve().then(() => useProject.setState({ lightEdit: true })));
|
||||
await reopenLastProject();
|
||||
expect(openProject).not.toHaveBeenCalled();
|
||||
useProject.setState({ lightEdit: false });
|
||||
});
|
||||
it.each(["last session", "window session"])(
|
||||
"keeps launched loose files instead of restoring the %s project",
|
||||
async (remembered) => {
|
||||
if (remembered === "window session") sessionStorage.setItem("openidea.window.project", "/p/window");
|
||||
provideStartupLaunch(
|
||||
Promise.resolve().then(() => {
|
||||
useProject.setState({ lightEdit: true });
|
||||
}),
|
||||
);
|
||||
await reopenLastProject();
|
||||
expect(openProject).not.toHaveBeenCalled();
|
||||
expect(useProject.getState().lightEdit).toBe(true);
|
||||
expect(useProject.getState().project).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("reopens the last project when the launch named none", async () => {
|
||||
provideStartupLaunch(Promise.resolve());
|
||||
|
||||
@@ -87,7 +87,10 @@ let attempted = false;
|
||||
export async function reopenLastProject(): Promise<void> {
|
||||
if (attempted || !isTauri()) return;
|
||||
attempted = true;
|
||||
if (useProject.getState().project) return;
|
||||
// An explicit launch wins over both this window's and the last session's project.
|
||||
await startupLaunchHandled();
|
||||
const startup = useProject.getState();
|
||||
if (startup.project || startup.lightEdit) return;
|
||||
// A reloaded webview gets back its own project, whatever window it is.
|
||||
const own = windowProject();
|
||||
if (own) {
|
||||
@@ -96,7 +99,6 @@ export async function reopenLastProject(): Promise<void> {
|
||||
}
|
||||
// Secondary windows open the project they were created for (features/app/windows.ts).
|
||||
if (getCurrentWindow().label !== "main") return;
|
||||
if (useProject.getState().project) return;
|
||||
const path = lastSessionProject();
|
||||
if (!path) return;
|
||||
let settings: Settings | null = null;
|
||||
@@ -106,9 +108,8 @@ export async function reopenLastProject(): Promise<void> {
|
||||
/* use the local preference */
|
||||
}
|
||||
if (!reopenEnabled(settings, useSession.getState().reopenPref)) return;
|
||||
// A project or files passed on the command line (or from Finder) win over the last session's.
|
||||
await startupLaunchHandled();
|
||||
if (useProject.getState().project || useProject.getState().lightEdit) return;
|
||||
const current = useProject.getState();
|
||||
if (current.project || current.lightEdit) return;
|
||||
await reopen(path);
|
||||
}
|
||||
|
||||
|
||||
@@ -4765,5 +4765,51 @@
|
||||
"No matching fonts. Enter a family name manually.": "没有匹配的字体。可手动输入字体家族名称。",
|
||||
"This name was not found on this desktop. The saved preference is kept.": "此桌面未发现该字体,已保留保存的设置。",
|
||||
"No system fonts were returned. Bundled fonts and manual input are available.": "未发现系统字体。可选择内置字体或手动输入。",
|
||||
"Type a family name or search installed fonts.": "输入字体家族名称或搜索已安装字体。"
|
||||
"Type a family name or search installed fonts.": "输入字体家族名称或搜索已安装字体。",
|
||||
"Add Code Cell": "添加代码单元格",
|
||||
"Add Markdown Cell": "添加 Markdown 单元格",
|
||||
"Cell": "单元格",
|
||||
"Cell outputs": "单元格输出",
|
||||
"Cell type": "单元格类型",
|
||||
"Delete Cell": "删除单元格",
|
||||
"Discard Changes and Reload": "放弃更改并重新加载",
|
||||
"Discard all unsaved notebook changes and stop execution? This cannot be undone.": "放弃笔记本的所有未保存更改并停止执行?此操作无法撤消。",
|
||||
"Edit Markdown": "编辑 Markdown",
|
||||
"Inert JavaScript source": "不执行的 JavaScript 源代码",
|
||||
"Insert Code Cell Below": "在下方插入代码单元格",
|
||||
"Install ipykernel in the selected Python environment, then restart the kernel. No packages are installed automatically.": "请在所选 Python 环境中安装 ipykernel,然后重启内核。不会自动安装任何软件包。",
|
||||
"Interrupt Kernel": "中断内核",
|
||||
"JavaScript output is inert. Interactive widgets are not supported.": "JavaScript 输出不会执行。不支持交互式组件。",
|
||||
"Loading notebook…": "正在加载笔记本…",
|
||||
"Markdown cell source": "Markdown 单元格源代码",
|
||||
"Move Cell Down": "下移单元格",
|
||||
"Move Cell Up": "上移单元格",
|
||||
"Notebook Python interpreter": "笔记本 Python 解释器",
|
||||
"Notebook editing and execution require the desktop app.": "编辑和执行笔记本需要使用桌面应用。",
|
||||
"Notebook image output": "笔记本图像输出",
|
||||
"Notebook path is missing": "缺少笔记本路径",
|
||||
"Notebook toolbar": "笔记本工具栏",
|
||||
"Notebook unavailable": "笔记本不可用",
|
||||
"Output preview truncated; full output is retained in the notebook.": "输出预览已截断;完整输出仍保留在笔记本中。",
|
||||
"Preview Markdown": "预览 Markdown",
|
||||
"Python cell source": "Python 单元格源代码",
|
||||
"Raw": "原始内容",
|
||||
"Raw or unknown cell; original content is preserved read-only.": "原始或未知类型的单元格;原始内容以只读方式保留。",
|
||||
"Redo": "重做",
|
||||
"Refresh Python interpreters": "刷新 Python 解释器",
|
||||
"Reload Notebook from Disk": "从磁盘重新加载笔记本",
|
||||
"Restart Kernel": "重启内核",
|
||||
"Retry Pending Edits": "重试待提交更改",
|
||||
"Run All": "运行全部",
|
||||
"Run Cell": "运行单元格",
|
||||
"Select Python interpreter": "选择 Python 解释器",
|
||||
"Static notebook HTML output": "笔记本静态 HTML 输出",
|
||||
"This notebook has no cells": "此笔记本没有单元格",
|
||||
"Uncommitted source edits": "未提交的源代码更改",
|
||||
"Unsupported output MIME type; original data is preserved.": "不支持此输出 MIME 类型;原始数据已保留。",
|
||||
"Unsupported output type; original data is preserved.": "不支持此输出类型;原始数据已保留。",
|
||||
"busy": "忙碌",
|
||||
"dead": "已终止",
|
||||
"idle": "空闲",
|
||||
"starting": "正在启动"
|
||||
}
|
||||
@@ -118,6 +118,53 @@ export interface EditorKindContribution {
|
||||
* `data` must be plain JSON). The editor closes tabs whose target is gone itself.
|
||||
*/
|
||||
restorable?: boolean;
|
||||
/**
|
||||
* The editor owns a document with unsaved state (a notebook): it takes part in
|
||||
* the dirty mark, Save All, autosave and the close prompts. Tabs with equal
|
||||
* `input.key` (splits) share one document.
|
||||
*/
|
||||
document?: EditorDocumentLifecycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Document lifecycle of a custom editor kind, keyed by its tabs' input. The
|
||||
* workbench calls it whatever React has mounted: hidden tabs keep their
|
||||
* document. Call `notifyEditorDocumentChanged` whenever `isDirty` may change.
|
||||
*/
|
||||
export interface EditorDocumentLifecycle {
|
||||
/** Unsaved changes, pending edits not committed yet included. */
|
||||
isDirty(input: EditorInput): boolean;
|
||||
/** Commits pending edits; rejects when they can't be. */
|
||||
flush(input: EditorInput): Promise<void>;
|
||||
/** Flushes and saves; rejects on failure (the document stays dirty and open). */
|
||||
save(input: EditorInput): Promise<void>;
|
||||
/**
|
||||
* Before a close decision: stops admitting edits and runs, flushes, and
|
||||
* interrupts and drains execution, so `isDirty` holds until `cancelClose`.
|
||||
*/
|
||||
prepareClose(input: EditorInput): Promise<void>;
|
||||
/** The close didn't happen (Cancel, a failed save, a tab of it opened again): edits and runs resume. */
|
||||
cancelClose(input: EditorInput): void;
|
||||
/** Don't Save: drops unsaved changes without saving them. */
|
||||
discard(input: EditorInput): Promise<void>;
|
||||
/** Releases the document (and its kernel…) once no tab shows it any more. Called once per open. */
|
||||
close(input: EditorInput): Promise<void>;
|
||||
/** Edit › Undo / Redo while the editor has focus. */
|
||||
undo?(input: EditorInput): Promise<void>;
|
||||
redo?(input: EditorInput): Promise<void>;
|
||||
}
|
||||
|
||||
/** Bumped by `notifyEditorDocumentChanged`; selectors reading `isDirty` re-run on it. */
|
||||
export const useEditorDocuments = create<{ revision: number }>(() => ({ revision: 0 }));
|
||||
|
||||
/** A custom editor document's dirty state (or pending edits) may have changed: tab marks and counts refresh. */
|
||||
export function notifyEditorDocumentChanged() {
|
||||
useEditorDocuments.setState((s) => ({ revision: s.revision + 1 }));
|
||||
}
|
||||
|
||||
/** Changes with every `notifyEditorDocumentChanged`, for components that read `isDirty`. */
|
||||
export function useEditorDocumentRevision(): number {
|
||||
return useEditorDocuments((s) => s.revision);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,11 +354,23 @@ export const provideProjectPopup = (component: ComponentType<ProjectPopupProps>)
|
||||
// editor kind (images, hex view) before the text editor. A provider may probe
|
||||
// the file (size, binary content); the first non-null answer wins.
|
||||
|
||||
/** Resources acquired while resolving belong to the request until its tab is admitted. */
|
||||
export interface FileEditorResolutionContext {
|
||||
/** Commit is synchronous: no close/project transition may interleave with tab insertion. */
|
||||
onAdmission(commit: () => void, rollback: () => Promise<void>): void;
|
||||
}
|
||||
|
||||
export interface FileEditorResolution {
|
||||
input: EditorInput;
|
||||
admit(): void;
|
||||
rollback(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface FileEditorProvider {
|
||||
id: string;
|
||||
/** Lower asks first. Default 100. */
|
||||
order?: number;
|
||||
resolve(path: string): EditorInput | null | Promise<EditorInput | null>;
|
||||
resolve(path: string, context: FileEditorResolutionContext): EditorInput | null | Promise<EditorInput | null>;
|
||||
}
|
||||
|
||||
const fileEditorProviders = new Map<string, FileEditorProvider>();
|
||||
@@ -324,15 +383,39 @@ export function registerFileEditorProvider(p: FileEditorProvider): () => void {
|
||||
}
|
||||
|
||||
/** The non-text editor input for `path`, or null for the text editor. Provider failures fall through. */
|
||||
export async function resolveFileEditor(path: string): Promise<EditorInput | null> {
|
||||
export async function resolveFileEditor(path: string): Promise<FileEditorResolution | null> {
|
||||
const providers = [...fileEditorProviders.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
|
||||
for (const p of providers) {
|
||||
const acquisitions: Array<{ commit: () => void; rollback: () => Promise<void> }> = [];
|
||||
let admitted = false;
|
||||
const rollback = async () => {
|
||||
if (admitted) return;
|
||||
const results = await Promise.allSettled(acquisitions.splice(0).map((a) => Promise.resolve().then(a.rollback)));
|
||||
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected");
|
||||
if (errors.length)
|
||||
throw new AggregateError(
|
||||
errors.map((r) => r.reason),
|
||||
"File editor acquisition cleanup failed",
|
||||
);
|
||||
};
|
||||
try {
|
||||
const input = await p.resolve(path);
|
||||
if (input) return input;
|
||||
const input = await p.resolve(path, {
|
||||
onAdmission: (commit, release) => acquisitions.push({ commit, rollback: release }),
|
||||
});
|
||||
if (input)
|
||||
return {
|
||||
input,
|
||||
admit: () => {
|
||||
for (const acquisition of acquisitions) acquisition.commit();
|
||||
admitted = true;
|
||||
acquisitions.length = 0;
|
||||
},
|
||||
rollback,
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn(`file editor provider ${p.id} failed`, e);
|
||||
}
|
||||
await rollback();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -412,7 +495,7 @@ export interface EditorService {
|
||||
activePath(): string | null;
|
||||
/** Doc id + cursor of the focused text editor. */
|
||||
activeCursor(): { doc: number; path: string; line: number; col: number; offset: number } | null;
|
||||
/** Save all dirty documents. */
|
||||
/** Save all dirty documents; rejects with the failures once every save was tried. */
|
||||
saveAll(): Promise<void>;
|
||||
/** Move keyboard focus to the active editor (used by Escape from tool windows). */
|
||||
focus?(): void;
|
||||
@@ -424,12 +507,37 @@ export interface EditorService {
|
||||
activeInput?(): EditorInput | null;
|
||||
/** Calls `fn` whenever the active tab changes; returns the unsubscribe. */
|
||||
onActiveInputChange?(fn: (input: EditorInput | null) => void): () => void;
|
||||
/** Closes every tab showing `path`. */
|
||||
closePath?(path: string): void;
|
||||
/** Closes the tab of the input with this `key` (e.g. a merge editor once applied). */
|
||||
closeInput?(key: string): void;
|
||||
/**
|
||||
* Closes every tab showing `path`. Tabs without a custom document close at
|
||||
* once; unsaved custom documents are asked about first. Resolves false when
|
||||
* the user kept them (Cancel, a failed save).
|
||||
*/
|
||||
closePath?(path: string): Promise<boolean>;
|
||||
/** Closes the tab of the input with this `key` (e.g. a merge editor once applied), like `closePath`. */
|
||||
closeInput?(key: string): Promise<boolean>;
|
||||
/** Reformat Code on the whole open document at `path` (quietly); whether anything changed. */
|
||||
reformat?(path: string): Promise<boolean>;
|
||||
/**
|
||||
* Window / project close: prepares every open custom editor document for the
|
||||
* Save / Don't Save / Cancel decision (see `EditorCloseSession`).
|
||||
*/
|
||||
prepareClose?(): Promise<EditorCloseSession>;
|
||||
/** Backend project/window transition failed after authorization: resume its prepared documents. */
|
||||
cancelClose?(): void;
|
||||
/** Edit › Undo / Redo for a focused custom editor that has its own history; false when none takes it. */
|
||||
documentHistory?(command: "undo" | "redo"): boolean;
|
||||
}
|
||||
|
||||
/** Custom editor documents held still for a window / project close decision. */
|
||||
export interface EditorCloseSession {
|
||||
/** Documents with unsaved changes. */
|
||||
dirty: number;
|
||||
/** Don't Save: drops their unsaved changes. */
|
||||
discard(): Promise<void>;
|
||||
/** The close didn't happen (Cancel, a failed save): the documents resume. */
|
||||
cancel(): void;
|
||||
/** The close goes ahead: they stay held until their tabs close (they resume if the project stays open). */
|
||||
commit(): void;
|
||||
}
|
||||
|
||||
export interface ToolWindowService {
|
||||
|
||||
@@ -27,12 +27,52 @@ describe("file editor providers", () => {
|
||||
resolve: (p) => (p.endsWith(".png") ? { kind: "image", key: p, path: p } : null),
|
||||
}),
|
||||
];
|
||||
expect((await resolveFileEditor("/a.png"))?.kind).toBe("image");
|
||||
expect((await resolveFileEditor("/a.txt"))?.kind).toBe("late");
|
||||
expect((await resolveFileEditor("/a.png"))?.input.kind).toBe("image");
|
||||
expect((await resolveFileEditor("/a.txt"))?.input.kind).toBe("late");
|
||||
disposers.forEach((d) => d());
|
||||
expect(await resolveFileEditor("/a.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("releases a failed provider's acquisition before trying the next provider", async () => {
|
||||
const live = new Set(["failed", "winner"]);
|
||||
const disposers = [
|
||||
registerFileEditorProvider({
|
||||
id: "acquired-failure",
|
||||
order: 1,
|
||||
resolve: (_path, context) => {
|
||||
context.onAdmission(
|
||||
() => {},
|
||||
async () => {
|
||||
live.delete("failed");
|
||||
},
|
||||
);
|
||||
throw new Error("probe failed after acquisition");
|
||||
},
|
||||
}),
|
||||
registerFileEditorProvider({
|
||||
id: "acquired-winner",
|
||||
order: 2,
|
||||
resolve: (_path, context) => {
|
||||
context.onAdmission(
|
||||
() => {},
|
||||
async () => {
|
||||
live.delete("winner");
|
||||
},
|
||||
);
|
||||
return { kind: "viewer", key: "winner" };
|
||||
},
|
||||
}),
|
||||
];
|
||||
try {
|
||||
const resolution = (await resolveFileEditor("/a"))!;
|
||||
expect(live).toEqual(new Set(["winner"]));
|
||||
await resolution.rollback();
|
||||
expect(live).toEqual(new Set());
|
||||
} finally {
|
||||
disposers.forEach((dispose) => dispose());
|
||||
}
|
||||
});
|
||||
|
||||
it("finds the text editor wrapper of a path", () => {
|
||||
const dispose = registerTextEditorWrapper({ id: "md", matches: (p) => p.endsWith(".md"), component: () => null });
|
||||
const wrappers = useTextEditorWrappers.getState().wrappers;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { call, commands } from "@/platform/ipc";
|
||||
import { confirmLeaveProject, useProject } from "@/platform/project";
|
||||
import { connectRemote } from "@/platform/remote";
|
||||
import { ask } from "./messages";
|
||||
import { maybeEditorService } from "./contributions";
|
||||
|
||||
/** "Open project in": `new` | `same` | `ask`. */
|
||||
export function openPolicy(): "ask" | "new" | "same" {
|
||||
@@ -62,6 +63,11 @@ export async function openRemoteProject(
|
||||
return true;
|
||||
}
|
||||
if (!(await confirmLeaveProject())) return false;
|
||||
await here();
|
||||
return true;
|
||||
try {
|
||||
await here();
|
||||
return true;
|
||||
} catch (e) {
|
||||
maybeEditorService()?.cancelClose?.();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ProviderAcquisitions } from "./providerAcquisition";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const owned = new Set<string>();
|
||||
const closed: string[] = [];
|
||||
const pool = new ProviderAcquisitions<{ id: string }>(
|
||||
(value) => owned.has(value.id),
|
||||
async (value) => {
|
||||
closed.push(value.id);
|
||||
},
|
||||
);
|
||||
return { owned, closed, pool };
|
||||
}
|
||||
|
||||
describe("provider incarnation ownership", () => {
|
||||
it("does not close an admitted alias when another resolution is abandoned", async () => {
|
||||
const { pool, owned, closed } = fixture();
|
||||
const first = await pool.acquire(async () => ({ id: "shared" }));
|
||||
const response = deferred<{ id: string }>();
|
||||
const started = deferred<void>();
|
||||
const opening = pool.acquire(() => {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
});
|
||||
await started.promise;
|
||||
const rollback = first.rollback();
|
||||
response.resolve({ id: "shared" });
|
||||
const second = await opening;
|
||||
// The unresolved acquisition now owns a lease, even before tab admission.
|
||||
await rollback;
|
||||
expect(closed).toEqual([]);
|
||||
owned.add("shared");
|
||||
second.admit();
|
||||
expect(closed).toEqual([]);
|
||||
});
|
||||
|
||||
it("releases a shared backend once when every alias is abandoned", async () => {
|
||||
const { pool, closed } = fixture();
|
||||
const a = await pool.acquire(async () => ({ id: "shared" }));
|
||||
const b = await pool.acquire(async () => ({ id: "shared" }));
|
||||
await Promise.all([a.rollback(), b.rollback()]);
|
||||
await a.rollback();
|
||||
expect(closed).toEqual(["shared"]);
|
||||
});
|
||||
|
||||
it("never releases an existing session or a replacement incarnation", async () => {
|
||||
const { pool, owned, closed } = fixture();
|
||||
owned.add("existing");
|
||||
const existing = await pool.acquire(async () => ({ id: "existing" }));
|
||||
await existing.rollback();
|
||||
const old = await pool.acquire(async () => ({ id: "old-project" }));
|
||||
owned.add("new-project");
|
||||
const replacement = await pool.acquire(async () => ({ id: "new-project" }));
|
||||
replacement.admit();
|
||||
await old.rollback();
|
||||
expect(closed).toEqual(["old-project"]);
|
||||
expect(owned).toEqual(new Set(["existing", "new-project"]));
|
||||
});
|
||||
|
||||
it("waits for abandoned cleanup before reopening the backend path", async () => {
|
||||
const closed = deferred<void>();
|
||||
const closing = deferred<void>();
|
||||
let incarnation = "old";
|
||||
const pool = new ProviderAcquisitions<{ id: string }>(
|
||||
() => false,
|
||||
async () => {
|
||||
closing.resolve();
|
||||
await closed.promise;
|
||||
incarnation = "new";
|
||||
},
|
||||
);
|
||||
const old = await pool.acquire(async () => ({ id: incarnation }));
|
||||
const rollback = old.rollback();
|
||||
await closing.promise;
|
||||
const reopening = pool.acquire(async () => ({ id: incarnation }));
|
||||
closed.resolve();
|
||||
await rollback;
|
||||
const fresh = await reopening;
|
||||
expect(fresh.value.id).toBe("new");
|
||||
fresh.admit();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/** Resolver leases protect shared backend incarnations before their tabs own them. */
|
||||
export class ProviderAcquisitions<T extends { id: string }> {
|
||||
private pending = 0;
|
||||
private idle: Array<() => void> = [];
|
||||
private resources = new Map<string, { value: T; leases: number; closing?: Promise<unknown> }>();
|
||||
private closing: Promise<unknown> = Promise.resolve();
|
||||
|
||||
constructor(
|
||||
private owned: (value: T) => boolean,
|
||||
private close: (value: T) => Promise<unknown>,
|
||||
) {}
|
||||
|
||||
async acquire(open: () => Promise<T>) {
|
||||
// Do not let a new open reuse an incarnation whose rollback is in flight.
|
||||
let closing: Promise<unknown>;
|
||||
do {
|
||||
closing = this.closing;
|
||||
await closing;
|
||||
} while (closing !== this.closing);
|
||||
++this.pending;
|
||||
let value: T;
|
||||
try {
|
||||
value = await open();
|
||||
const resource = this.resources.get(value.id);
|
||||
if (resource) ++resource.leases;
|
||||
else this.resources.set(value.id, { value, leases: 1 });
|
||||
} finally {
|
||||
if (--this.pending === 0) this.idle.splice(0).forEach((resolve) => resolve());
|
||||
}
|
||||
let settled = false;
|
||||
const release = async (admitted: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const resource = this.resources.get(value.id)!;
|
||||
--resource.leases;
|
||||
if (!admitted && this.pending) await new Promise<void>((resolve) => this.idle.push(resolve));
|
||||
// An unresolved alias may have acquired this same id; an admitted session
|
||||
// (including one that predated this request) owns it independently.
|
||||
if (resource.leases || this.owned(value)) {
|
||||
if (!resource.leases) this.resources.delete(value.id);
|
||||
return;
|
||||
}
|
||||
if (admitted) {
|
||||
this.resources.delete(value.id);
|
||||
return;
|
||||
}
|
||||
if (!resource.closing) {
|
||||
resource.closing = this.close(value).finally(() => {
|
||||
if (this.resources.get(value.id) === resource) this.resources.delete(value.id);
|
||||
});
|
||||
this.closing = Promise.all([this.closing, resource.closing]).catch(() => {});
|
||||
}
|
||||
await resource.closing;
|
||||
};
|
||||
return {
|
||||
value,
|
||||
admit: () => {
|
||||
void release(true);
|
||||
},
|
||||
rollback: () => release(false),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { NOTIFICATIONS_TOOL_WINDOW } from "./Notifications";
|
||||
import { activateToolWindow } from "./toolwindows/store";
|
||||
import { toggleTheme } from "./theme";
|
||||
import { cancelTask, canCancel, useTasks } from "./tasks";
|
||||
import { showStatusInfo } from "./contributions";
|
||||
import { maybeEditorService, showStatusInfo } from "./contributions";
|
||||
|
||||
export async function openProjectDialog() {
|
||||
if (!isTauri()) {
|
||||
@@ -30,6 +30,7 @@ export async function openProjectDialog() {
|
||||
await executeAction("file.openProjectPath", path);
|
||||
return;
|
||||
}
|
||||
if (!(await confirmLeaveProject())) return;
|
||||
try {
|
||||
await openProject(path);
|
||||
} catch (e) {
|
||||
@@ -66,7 +67,13 @@ export function registerShellActions(): () => void {
|
||||
category: "File",
|
||||
when: "projectOpen",
|
||||
run: async () => {
|
||||
if (useProject.getState().project && (await confirmLeaveProject())) await closeProject();
|
||||
if (!useProject.getState().project || !(await confirmLeaveProject())) return;
|
||||
try {
|
||||
await closeProject();
|
||||
} catch (e) {
|
||||
maybeEditorService()?.cancelClose?.();
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Заблокувати користувача