mirror of
https://github.com/zed-industries/zed.git
synced 2026-08-21 06:54:45 +00:00
repl: Save notebooks through the project instead of the local filesystem (#62602)
Some checks are pending
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
Some checks are pending
run_tests / orchestrate (push) Waiting to run
run_tests / check_style (push) Waiting to run
run_tests / clippy_windows (push) Blocked by required conditions
run_tests / clippy_linux (push) Blocked by required conditions
run_tests / clippy_mac (push) Blocked by required conditions
run_tests / clippy_mac_x86_64 (push) Blocked by required conditions
run_tests / run_tests_windows (push) Blocked by required conditions
run_tests / run_tests_linux (push) Blocked by required conditions
run_tests / run_tests_mac (push) Blocked by required conditions
run_tests / miri_scheduler (push) Blocked by required conditions
Congratsbot / check-author (push) Waiting to run
Congratsbot / congrats (push) Blocked by required conditions
deploy_nightly_docs / deploy_docs (push) Waiting to run
run_tests / doctests (push) Blocked by required conditions
run_tests / check_workspace_binaries (push) Blocked by required conditions
run_tests / build_visual_tests_binary (push) Blocked by required conditions
run_tests / check_wasm (push) Blocked by required conditions
run_tests / check_dependencies (push) Blocked by required conditions
run_tests / check_docs (push) Blocked by required conditions
run_tests / check_licenses (push) Blocked by required conditions
run_tests / check_scripts (push) Blocked by required conditions
run_tests / check_postgres_and_protobuf_migrations (push) Blocked by required conditions
run_tests / extension_tests (push) Blocked by required conditions
run_tests / tests_pass (push) Blocked by required conditions
Closes #59001 Saving a notebook wrote the file with `project.fs().atomic_write(abs_path, ...)`. `Project::fs()` is always the client's own filesystem, so for a remote project the remote path was resolved against the local machine: on Windows the drive letter got prepended and the atomic-write temp file used a backslash, producing `C:/data/project/src_example\.tmpPuzw3d3` and `os error 3`. The same thing fails on macOS/Linux clients with `os error 2`. Both `save` and `save_as` now go through the project's buffer machinery instead: open the buffer for the notebook's `ProjectPath`, set its text to the serialized notebook, then `save_buffer` / `save_buffer_as`. Writes are routed over the remote connection for remote projects, and the open buffer stays in sync locally. `save_as` also updates the item's project path and entry id, since `save_buffer_as` moves the buffer to the new path and otherwise the next save would write back to the old file. Added a test that edits a cell, saves, and checks that the notebook buffer held by the project reflects the saved file. It fails against the old implementation. Release Notes: - N/A --------- Co-authored-by: Finn Evers <finn.evers@outlook.de>
This commit is contained in:
parent
f4199ae04c
commit
bc538def45
1 changed files with 149 additions and 32 deletions
|
|
@ -109,6 +109,11 @@ pub struct NotebookEditor {
|
|||
kernel_picker_handle: PopoverMenuHandle<Picker<KernelPickerDelegate>>,
|
||||
}
|
||||
|
||||
enum SaveDestination {
|
||||
CurrentPath,
|
||||
NewPath(ProjectPath),
|
||||
}
|
||||
|
||||
impl NotebookEditor {
|
||||
pub fn new(
|
||||
project: Entity<Project>,
|
||||
|
|
@ -337,6 +342,56 @@ impl NotebookEditor {
|
|||
cx.notify();
|
||||
}
|
||||
|
||||
fn save_impl(
|
||||
&mut self,
|
||||
destination: SaveDestination,
|
||||
project: Entity<Project>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let notebook = self.to_notebook(cx);
|
||||
let project_path = self.notebook_item.read(cx).project_path.clone();
|
||||
|
||||
self.mark_as_saved(cx);
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
let json =
|
||||
serde_json::to_string_pretty(¬ebook).context("Failed to serialize notebook")?;
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| project.open_buffer(project_path, cx))
|
||||
.await?;
|
||||
buffer.update(cx, |buffer, cx| buffer.set_text(json, cx));
|
||||
|
||||
match destination {
|
||||
SaveDestination::CurrentPath => {
|
||||
project
|
||||
.update(cx, |project, cx| project.save_buffer(buffer, cx))
|
||||
.await
|
||||
}
|
||||
SaveDestination::NewPath(new_path) => {
|
||||
project
|
||||
.update(cx, |project, cx| {
|
||||
project.save_buffer_as(buffer, new_path.clone(), cx)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// The buffer now lives at the new path, so the notebook has
|
||||
// to follow it or the next save writes to the old file.
|
||||
let entry_id = project.read_with(cx, |project, cx| {
|
||||
project.entry_for_path(&new_path, cx).map(|entry| entry.id)
|
||||
});
|
||||
this.update(cx, |this, cx| {
|
||||
this.notebook_item.update(cx, |notebook_item, _| {
|
||||
notebook_item.project_path = new_path;
|
||||
if let Some(entry_id) = entry_id {
|
||||
notebook_item.id = entry_id;
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn launch_kernel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
let spec = self.kernel_specification.clone().or_else(|| {
|
||||
ReplStore::global(cx)
|
||||
|
|
@ -1578,7 +1633,6 @@ impl Focusable for NotebookEditor {
|
|||
|
||||
// Intended to be a NotebookBuffer
|
||||
pub struct NotebookItem {
|
||||
path: PathBuf,
|
||||
project_path: ProjectPath,
|
||||
languages: Arc<LanguageRegistry>,
|
||||
// Raw notebook data
|
||||
|
|
@ -1595,7 +1649,6 @@ impl project::ProjectItem for NotebookItem {
|
|||
) -> Option<Task<anyhow::Result<Entity<Self>>>> {
|
||||
let path = path.clone();
|
||||
let project = project.clone();
|
||||
let fs = project.read(cx).fs().clone();
|
||||
let languages = project.read(cx).languages().clone();
|
||||
|
||||
// For single-file worktrees the relative path is empty, so fall back
|
||||
|
|
@ -1609,9 +1662,6 @@ impl project::ProjectItem for NotebookItem {
|
|||
|
||||
if is_notebook {
|
||||
Some(cx.spawn(async move |cx| {
|
||||
let abs_path =
|
||||
abs_path.with_context(|| format!("finding the absolute path of {path:?}"))?;
|
||||
|
||||
// todo: watch for changes to the file
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| project.open_buffer(path.clone(), cx))
|
||||
|
|
@ -1668,7 +1718,6 @@ impl project::ProjectItem for NotebookItem {
|
|||
.context("Entry not found")?;
|
||||
|
||||
Ok(cx.new(|_| NotebookItem {
|
||||
path: abs_path,
|
||||
project_path: path,
|
||||
languages,
|
||||
notebook,
|
||||
|
|
@ -1862,18 +1911,7 @@ impl Item for NotebookEditor {
|
|||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let notebook = self.to_notebook(cx);
|
||||
let path = self.notebook_item.read(cx).path.clone();
|
||||
let fs = project.read(cx).fs().clone();
|
||||
|
||||
self.mark_as_saved(cx);
|
||||
|
||||
cx.spawn(async move |_this, _cx| {
|
||||
let json =
|
||||
serde_json::to_string_pretty(¬ebook).context("Failed to serialize notebook")?;
|
||||
fs.atomic_write(path, json).await?;
|
||||
Ok(())
|
||||
})
|
||||
self.save_impl(SaveDestination::CurrentPath, project, cx)
|
||||
}
|
||||
|
||||
fn save_as(
|
||||
|
|
@ -1883,20 +1921,7 @@ impl Item for NotebookEditor {
|
|||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<()>> {
|
||||
let notebook = self.to_notebook(cx);
|
||||
let fs = project.read(cx).fs().clone();
|
||||
|
||||
let abs_path = project.read(cx).absolute_path(&path, cx);
|
||||
|
||||
self.mark_as_saved(cx);
|
||||
|
||||
cx.spawn(async move |_this, _cx| {
|
||||
let abs_path = abs_path.context("Failed to get absolute path")?;
|
||||
let json =
|
||||
serde_json::to_string_pretty(¬ebook).context("Failed to serialize notebook")?;
|
||||
fs.atomic_write(abs_path, json).await?;
|
||||
Ok(())
|
||||
})
|
||||
self.save_impl(SaveDestination::NewPath(path), project, cx)
|
||||
}
|
||||
|
||||
fn reload(
|
||||
|
|
@ -2247,4 +2272,96 @@ mod tests {
|
|||
assert_eq!(item.notebook.cells.len(), 1);
|
||||
});
|
||||
}
|
||||
|
||||
/// Notebooks must be saved through the project rather than through the
|
||||
/// client's own filesystem, otherwise a remote notebook's path is resolved
|
||||
/// against the local machine and the save fails.
|
||||
#[gpui::test]
|
||||
async fn test_save_goes_through_the_project(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
||||
editor::init(cx);
|
||||
});
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
path!("/notebooks"),
|
||||
json!({ "test.ipynb": NOTEBOOK_WITH_ONE_CODE_CELL }),
|
||||
)
|
||||
.await;
|
||||
|
||||
let project = Project::test(fs.clone(), [path!("/notebooks").as_ref()], cx).await;
|
||||
cx.update(|cx| ReplStore::init(fs.clone(), cx));
|
||||
|
||||
let project_path = project.read_with(cx, |project, cx| ProjectPath {
|
||||
worktree_id: project.worktrees(cx).next().unwrap().read(cx).id(),
|
||||
path: rel_path("test.ipynb").into(),
|
||||
});
|
||||
|
||||
let notebook_item = cx
|
||||
.update(|cx| {
|
||||
NotebookItem::try_open(&project, &project_path, cx)
|
||||
.expect("ipynb files should be openable as notebooks")
|
||||
})
|
||||
.await
|
||||
.expect("notebook should parse");
|
||||
|
||||
// Held across the save: a save that bypasses the project writes the file
|
||||
// behind this buffer's back, leaving it stale.
|
||||
let buffer = project
|
||||
.update(cx, |project, cx| {
|
||||
project.open_buffer(project_path.clone(), cx)
|
||||
})
|
||||
.await
|
||||
.expect("notebook buffer should open");
|
||||
|
||||
// Rendering the notebook animates the kernel status icon, which makes
|
||||
// `run_until_parked` spin forever; only the editor entity is needed here.
|
||||
let cx = cx.add_empty_window();
|
||||
let notebook_editor = cx.update(|window, cx| {
|
||||
cx.new(|cx| NotebookEditor::new(project.clone(), notebook_item, window, cx))
|
||||
});
|
||||
|
||||
let cell_editor = notebook_editor.read_with(cx, |notebook_editor, cx| {
|
||||
let cell_id = notebook_editor
|
||||
.cell_order
|
||||
.first()
|
||||
.expect("notebook has one cell");
|
||||
let Some(Cell::Code(cell)) = notebook_editor.cell_map.get(cell_id) else {
|
||||
panic!("expected a code cell");
|
||||
};
|
||||
cell.read(cx).editor().clone()
|
||||
});
|
||||
cell_editor.update_in(cx, |cell_editor, window, cx| {
|
||||
cell_editor.set_text("print('goodbye')", window, cx);
|
||||
});
|
||||
|
||||
notebook_editor
|
||||
.update_in(cx, |notebook_editor, window, cx| {
|
||||
notebook_editor.save(SaveOptions::default(), project.clone(), window, cx)
|
||||
})
|
||||
.await
|
||||
.expect("saving the notebook should succeed");
|
||||
|
||||
let saved = String::from_utf8(
|
||||
fs.read_file_sync(path!("/notebooks/test.ipynb"))
|
||||
.expect("notebook should still exist"),
|
||||
)
|
||||
.expect("notebook should be valid UTF-8");
|
||||
assert!(
|
||||
saved.contains("print('goodbye')"),
|
||||
"the edited cell should be written to the notebook, got: {saved}"
|
||||
);
|
||||
|
||||
buffer.read_with(cx, |buffer, _| {
|
||||
assert_eq!(
|
||||
buffer.text(),
|
||||
saved,
|
||||
"the project's buffer should hold the saved notebook"
|
||||
);
|
||||
assert!(!buffer.is_dirty(), "saving should leave the buffer clean");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue