mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 13:02:07 +03:00
Roll back last VCS snapshot (#4050)
Fixes #5001 This PR implements reverting the current project state to the last state saved into the VCS. This action is performed on `ctrl+r`. https://user-images.githubusercontent.com/117099775/216645556-1bf34ee7-fdb4-4833-bcad-670d688a3199.mp4 # Important Notes * Currently on `vcs/restore` all expressions are invalidated and all nodes are re-executed. This is tracked in [task](https://www.pivotaltracker.com/n/projects/2539304/stories/184368950).
This commit is contained in:
parent
1bc27501e6
commit
725b3da486
@ -89,6 +89,7 @@
|
||||
- [Internal components (private API) are not displayed in the component
|
||||
browser.][4085]
|
||||
- [The correct default visualisation for tables is shown on new nodes.][4120]
|
||||
- [Added restoring of last project snapshot on shortcut.][4050]
|
||||
- [Added contextual suggestions to argument dropdowns][4072]. Dropdowns will now
|
||||
contain suggestions which are based on evaluated data.
|
||||
|
||||
@ -470,6 +471,7 @@
|
||||
[4085]: https://github.com/enso-org/enso/pull/4085
|
||||
[4097]: https://github.com/enso-org/enso/pull/4097
|
||||
[4120]: https://github.com/enso-org/enso/pull/4120
|
||||
[4050]: https://github.com/enso-org/enso/pull/4050
|
||||
[4072]: https://github.com/enso-org/enso/pull/4072
|
||||
[5646]: https://github.com/enso-org/enso/pull/5646
|
||||
|
||||
|
@ -183,11 +183,11 @@ trait API {
|
||||
#[MethodInput=GetComponentGroups, rpc_name="executionContext/getComponentGroups"]
|
||||
fn get_component_groups(&self, context_id: ContextId) -> response::GetComponentGroups;
|
||||
|
||||
/// Initialize VCS at the specified root.
|
||||
/// Initialize the VCS at the specified root.
|
||||
#[MethodInput=VcsInitInput, rpc_name="vcs/init"]
|
||||
fn init_vcs(&self, root: Path) -> ();
|
||||
|
||||
/// Save project to VCS at the specified root.
|
||||
/// Save the project to the VCS at the specified root.
|
||||
#[MethodInput=VcsWriteInput, rpc_name="vcs/save"]
|
||||
fn save_vcs(&self, root: Path, name: Option<String>) -> response::SaveVcs;
|
||||
|
||||
@ -195,9 +195,15 @@ trait API {
|
||||
#[MethodInput=VcsListInput, rpc_name="vcs/list"]
|
||||
fn list_vcs(&self, root: Path, limit: Option<usize>) -> response::ListVcs;
|
||||
|
||||
/// Returns the current status of the changes made to the project.
|
||||
/// Returns the current status of the VCS, containing changes made to the project since the last
|
||||
/// VCS snapshot.
|
||||
#[MethodInput=VcsStatusInput, rpc_name="vcs/status"]
|
||||
fn vcs_status(&self, root: Path) -> response::VcsStatus;
|
||||
|
||||
/// Restore the project from the VCS at the specified root. The project is restored to the last
|
||||
/// VCS snapshot if no `commit_id` is provided.
|
||||
#[MethodInput=VcsRestoreInput, rpc_name="vcs/restore"]
|
||||
fn restore_vcs(&self, root: Path, commit_id: Option<String>) -> response::RestoreVcs;
|
||||
}}
|
||||
|
||||
|
||||
|
@ -133,3 +133,11 @@ pub struct VcsStatus {
|
||||
pub changed: Vec<Path>,
|
||||
pub last_save: SaveVcs,
|
||||
}
|
||||
|
||||
/// Response of `vcs_restore` method.
|
||||
#[derive(Hash, Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(missing_docs)]
|
||||
pub struct RestoreVcs {
|
||||
pub changed: Vec<Path>,
|
||||
}
|
||||
|
@ -120,6 +120,11 @@ pub enum Notification {
|
||||
#[serde(rename = "text/autoSave")]
|
||||
TextAutoSave(TextAutoSave),
|
||||
|
||||
/// This is a notification sent from the server to the clients to inform them of any changes
|
||||
/// made to files that they have open.
|
||||
#[serde(rename = "text/didChange")]
|
||||
TextDidChange(FileEditList),
|
||||
|
||||
/// Sent from the server to the client to inform about new information for certain expressions
|
||||
/// becoming available. This notification is superseded by executionContext/expressionUpdates.
|
||||
#[serde(rename = "executionContext/expressionValuesComputed")]
|
||||
@ -682,6 +687,15 @@ pub struct FileEdit {
|
||||
pub new_version: Sha3_224,
|
||||
}
|
||||
|
||||
/// A list of file edits.
|
||||
#[derive(Hash, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[allow(missing_docs)]
|
||||
pub struct FileEditList {
|
||||
pub edits: Vec<FileEdit>,
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ========================
|
||||
// === ExecutionContext ===
|
||||
|
@ -982,7 +982,8 @@ impl Handle {
|
||||
let module_sub = self.module.subscribe().map(|notification| match notification.kind {
|
||||
model::module::NotificationKind::Invalidate
|
||||
| model::module::NotificationKind::CodeChanged { .. }
|
||||
| model::module::NotificationKind::MetadataChanged => Notification::Invalidate,
|
||||
| model::module::NotificationKind::MetadataChanged
|
||||
| model::module::NotificationKind::Reloaded => Notification::Invalidate,
|
||||
});
|
||||
let db_sub = self.suggestion_db.subscribe().map(|notification| match notification {
|
||||
model::suggestion_database::Notification::Updated => Notification::PortsUpdate,
|
||||
|
@ -244,6 +244,19 @@ impl Project {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Restores the state of the project to the last snapshot saved to the VCS.
|
||||
#[profile(Detail)]
|
||||
pub fn restore_project_snapshot(&self) -> impl Future<Output = FallibleResult> {
|
||||
let project_root_id = self.model.project_content_root_id();
|
||||
let path_segments: [&str; 0] = [];
|
||||
let root_path = Path::new(project_root_id, &path_segments);
|
||||
let language_server = self.model.json_rpc();
|
||||
async move {
|
||||
language_server.restore_vcs(&root_path, &None).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -118,7 +118,8 @@ impl Handle {
|
||||
) -> Option<Notification> {
|
||||
match notification.kind {
|
||||
model::module::NotificationKind::Invalidate
|
||||
| model::module::NotificationKind::CodeChanged { .. } => Some(Notification::Invalidate),
|
||||
| model::module::NotificationKind::CodeChanged { .. }
|
||||
| model::module::NotificationKind::Reloaded => Some(Notification::Invalidate),
|
||||
model::module::NotificationKind::MetadataChanged => None,
|
||||
}
|
||||
}
|
||||
|
@ -285,6 +285,10 @@ pub enum NotificationKind {
|
||||
},
|
||||
/// The metadata (e.g. some node's position) has been changed.
|
||||
MetadataChanged,
|
||||
/// The source file has been modified on the language server side, the module content is
|
||||
/// reloaded to be in sync with the language server file content. This occurs after a
|
||||
/// `vcs/restore` request when the project state is rolled back to a previous snapshot.
|
||||
Reloaded,
|
||||
}
|
||||
|
||||
/// Notification about change in module content.
|
||||
|
@ -124,6 +124,16 @@ impl Module {
|
||||
pub fn id(&self) -> model::module::Id {
|
||||
self.path.id()
|
||||
}
|
||||
|
||||
/// Get the module's content.
|
||||
pub fn content(&self) -> &RefCell<Content> {
|
||||
&self.content
|
||||
}
|
||||
|
||||
/// Publish a notification about changes in the module's content.
|
||||
pub fn notify(&self, notification: Notification) {
|
||||
self.notifications.notify(notification);
|
||||
}
|
||||
}
|
||||
|
||||
impl model::module::API for Module {
|
||||
|
@ -18,6 +18,7 @@ use double_representation::definition::DefinitionInfo;
|
||||
use double_representation::graph::Id;
|
||||
use double_representation::import;
|
||||
use engine_protocol::language_server;
|
||||
use engine_protocol::language_server::FileEdit;
|
||||
use engine_protocol::language_server::TextEdit;
|
||||
use engine_protocol::types::Sha3_224;
|
||||
use enso_text::text;
|
||||
@ -191,6 +192,57 @@ impl Module {
|
||||
let language_server = language_server::Connection::new_mock_rc(client);
|
||||
Rc::new(Module { model, language_server })
|
||||
}
|
||||
|
||||
/// Reload text file from the language server.
|
||||
pub async fn reload_text_file_from_ls(&self, parser: &Parser) -> FallibleResult {
|
||||
let file_path = self.path();
|
||||
self.language_server.client.close_text_file(file_path).await?;
|
||||
let opened = self.language_server.client.open_text_file(file_path).await?;
|
||||
self.set_module_content_from_ls(opened.content.into(), parser).await
|
||||
}
|
||||
|
||||
/// Apply text changes received from the language server.
|
||||
pub async fn apply_text_change_from_ls(
|
||||
&self,
|
||||
edits: Vec<TextEdit>,
|
||||
parser: &Parser,
|
||||
) -> FallibleResult {
|
||||
let mut content: text::Rope = self.serialized_content()?.content.into();
|
||||
for TextEdit { range, text } in edits {
|
||||
let start = content.location_of_utf16_code_unit_location_snapped(range.start.into());
|
||||
let end = content.location_of_utf16_code_unit_location_snapped(range.end.into());
|
||||
let start = <Byte as enso_text::FromInContextSnapped<&text::Rope, Location<Byte>>>::from_in_context_snapped(&content, start);
|
||||
let end = <Byte as enso_text::FromInContextSnapped<&text::Rope, Location<Byte>>>::from_in_context_snapped(&content, end);
|
||||
let range = Range { start, end };
|
||||
let change = TextChange { range, text };
|
||||
content.apply_change(change);
|
||||
}
|
||||
self.set_module_content_from_ls(content, parser).await
|
||||
}
|
||||
|
||||
/// Update the module with content received from the language server. This function takes the
|
||||
/// file content as reloaded from the language server, or reconstructed from a `text/didChange`
|
||||
/// notification. It parses the new file content and updates the module with the parsed content.
|
||||
/// The module content changes during parsing, and the language server is notified of this
|
||||
/// change. Lastly, a notification of `NotificationKind::Reloaded` is emitted to synchronize the
|
||||
/// notification handler.
|
||||
async fn set_module_content_from_ls(
|
||||
&self,
|
||||
content: text::Rope,
|
||||
parser: &Parser,
|
||||
) -> FallibleResult {
|
||||
let transaction = self.undo_redo_repository().transaction("Setting module's content");
|
||||
transaction.fill_content(self.id(), self.content().borrow().clone());
|
||||
let parsed_source = parser.parse_with_metadata(content.to_string());
|
||||
let source = parsed_source.serialize()?;
|
||||
self.content().replace(parsed_source);
|
||||
let summary = ContentSummary::new(&content);
|
||||
let change = TextEdit::from_prefix_postfix_differences(&content, &source.content);
|
||||
self.notify_language_server(&summary, &source, vec![change]).await?;
|
||||
let notification = Notification::new(source, NotificationKind::Reloaded);
|
||||
self.notify(notification);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl API for Module {
|
||||
@ -382,6 +434,7 @@ impl Module {
|
||||
let notify_ls = self.notify_language_server(&summary.summary, &new_file, edits);
|
||||
profiler::await_!(notify_ls, _profiler)
|
||||
}
|
||||
NotificationKind::Reloaded => Ok(ParsedContentSummary::from_source(&new_file)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -478,7 +531,7 @@ impl Module {
|
||||
edits: Vec<TextEdit>,
|
||||
) -> impl Future<Output = FallibleResult<ParsedContentSummary>> + 'static {
|
||||
let summary = ParsedContentSummary::from_source(new_file);
|
||||
let edit = language_server::types::FileEdit {
|
||||
let edit = FileEdit {
|
||||
edits,
|
||||
path: self.path().file_path().clone(),
|
||||
old_version: ls_content.digest.clone(),
|
||||
@ -662,6 +715,17 @@ pub mod test {
|
||||
let end_of_file = code_so_far.utf16_code_unit_location_of_location(end_of_file_bytes);
|
||||
TextRange { start: Position { line: 0, character: 0 }, end: end_of_file.into() }
|
||||
}
|
||||
|
||||
/// Update the language server file content with a `TextEdit`.
|
||||
fn update_ls_content(&self, edit: &TextEdit) {
|
||||
let old_content = self.current_ls_content.get();
|
||||
let new_content = apply_edit(old_content, edit);
|
||||
let new_version =
|
||||
Sha3_224::from_parts(new_content.iter_chunks(..).map(|s| s.as_bytes()));
|
||||
debug!("Updated content:\n===\n{new_content}\n===");
|
||||
self.current_ls_content.set(new_content);
|
||||
self.current_ls_version.set(new_version);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_edit(code: impl Into<text::Rope>, edit: &TextEdit) -> text::Rope {
|
||||
@ -736,6 +800,69 @@ pub mod test {
|
||||
Runner::run(test);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn handling_language_server_file_changes() {
|
||||
// The test starts with code as below, followed by a full invalidation replacing the whole
|
||||
// AST to print "Test". Then the file is changed on the language server side, simulating
|
||||
// what would happen if a snapshot is restored from the VCS. The applied `TextEdit` is then
|
||||
// used to update the local module content independently and synchronize it with the
|
||||
// language server.
|
||||
let initial_code = "main =\n println \"Hello World!\"";
|
||||
let mut data = crate::test::mock::Unified::new();
|
||||
data.set_code(initial_code);
|
||||
let test = |_runner: &mut Runner| {
|
||||
let module_path = data.module_path.clone();
|
||||
let edit_handler = Rc::new(LsClientSetup::new(module_path, initial_code));
|
||||
let mut fixture = data.fixture_customize(|data, client, _| {
|
||||
data.expect_opening_module(client);
|
||||
data.expect_closing_module(client);
|
||||
// Opening module and metadata generation.
|
||||
edit_handler.expect_full_invalidation(client);
|
||||
// Explicit AST update.
|
||||
edit_handler.expect_some_edit(client, |edit| {
|
||||
assert!(edit.edits.last().map_or(false, |edit| edit.text.contains("Test")));
|
||||
Ok(())
|
||||
});
|
||||
// Expect an edit that changes metadata due to parsing of the reloaded module
|
||||
// content.
|
||||
edit_handler.expect_some_edit(client, |edits| {
|
||||
let edit_code = &edits.edits[0];
|
||||
// The metadata changed is on line 5.
|
||||
assert_eq!(edit_code.range.start.line, 5);
|
||||
assert_eq!(edit_code.range.end.line, 5);
|
||||
Ok(())
|
||||
});
|
||||
});
|
||||
|
||||
let parser = data.parser.clone();
|
||||
let module = fixture.synchronized_module();
|
||||
|
||||
let new_content = "main =\n println \"Test\"".to_string();
|
||||
let new_ast = parser.parse_module(new_content, default()).unwrap();
|
||||
module.update_ast(new_ast).unwrap();
|
||||
fixture.run_until_stalled();
|
||||
// Replace `Test` with `Test 2` on the language server side and provide a `FileEditList`
|
||||
// containing the file changes.
|
||||
let edit = TextEdit {
|
||||
text: "Test 2".into(),
|
||||
range: TextRange {
|
||||
start: Position { line: 1, character: 13 },
|
||||
end: Position { line: 1, character: 17 },
|
||||
},
|
||||
};
|
||||
edit_handler.update_ls_content(&edit);
|
||||
fixture.run_until_stalled();
|
||||
module
|
||||
.apply_text_change_from_ls(vec![edit], &parser)
|
||||
.boxed_local()
|
||||
.expect_ready()
|
||||
.unwrap();
|
||||
fixture.run_until_stalled();
|
||||
};
|
||||
|
||||
Runner::run(test);
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn handling_notification_after_failure() {
|
||||
let initial_code = "main =\n println \"Hello World!\"";
|
||||
|
@ -19,6 +19,7 @@ use engine_protocol::language_server::response;
|
||||
use engine_protocol::language_server::CapabilityRegistration;
|
||||
use engine_protocol::language_server::ContentRoot;
|
||||
use engine_protocol::language_server::ExpressionUpdates;
|
||||
use engine_protocol::language_server::FileEditList;
|
||||
use engine_protocol::language_server::MethodPointer;
|
||||
use engine_protocol::project_manager;
|
||||
use engine_protocol::project_manager::MissingComponentAction;
|
||||
@ -161,9 +162,9 @@ impl ContentRoots {
|
||||
|
||||
|
||||
|
||||
// ========================
|
||||
// === VCS status check ===
|
||||
// ========================
|
||||
// =============================
|
||||
// === VCS status and reload ===
|
||||
// =============================
|
||||
|
||||
/// Check whether the current state of the project differs from the most recent snapshot in the VCS,
|
||||
/// and emit a notification.
|
||||
@ -184,6 +185,24 @@ async fn check_vcs_status_and_notify(
|
||||
status
|
||||
}
|
||||
|
||||
/// Apply file changes to module files based on a `FileEditList` structure, e.g. from a
|
||||
/// `text/didChange` notification when files are reloaded from the VCS.
|
||||
#[profile(Detail)]
|
||||
async fn update_modules_on_file_change(
|
||||
changes: FileEditList,
|
||||
parser: Parser,
|
||||
module_registry: Rc<model::registry::Registry<module::Path, module::Synchronized>>,
|
||||
) -> FallibleResult {
|
||||
for file_edit in changes.edits {
|
||||
let file_path = file_edit.path.clone();
|
||||
let module_path = module::Path::from_file_path(file_path).unwrap();
|
||||
if let Some(module) = module_registry.get(&module_path).await? {
|
||||
module.apply_text_change_from_ls(file_edit.edits, &parser).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =============
|
||||
@ -457,8 +476,10 @@ impl Project {
|
||||
let publisher = self.notifications.clone_ref();
|
||||
let project_root_id = self.project_content_root_id();
|
||||
let language_server = self.json_rpc().clone_ref();
|
||||
let parser = self.parser().clone_ref();
|
||||
let weak_suggestion_db = Rc::downgrade(&self.suggestion_db);
|
||||
let weak_content_roots = Rc::downgrade(&self.content_roots);
|
||||
let weak_module_registry = Rc::downgrade(&self.module_registry);
|
||||
let execution_update_handler = self.execution_update_handler();
|
||||
move |event| {
|
||||
debug!("Received an event from the json-rpc protocol: {event:?}");
|
||||
@ -488,6 +509,18 @@ impl Project {
|
||||
}
|
||||
});
|
||||
}
|
||||
Event::Notification(Notification::TextDidChange(changes)) => {
|
||||
let parser = parser.clone();
|
||||
if let Some(module_registry) = weak_module_registry.upgrade() {
|
||||
executor::global::spawn(async move {
|
||||
let status =
|
||||
update_modules_on_file_change(changes, parser, module_registry);
|
||||
if let Err(err) = status.await {
|
||||
error!("Error while applying file changes to modules: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Event::Notification(Notification::ExpressionUpdates(updates)) => {
|
||||
let ExpressionUpdates { context_id, updates } = updates;
|
||||
let execution_update = ExecutionUpdate::ExpressionUpdates(updates);
|
||||
|
@ -110,7 +110,12 @@ where K: Clone + Eq + Hash
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(&self, key: &K) -> Result<Option<Rc<V>>, LoadingError> {
|
||||
/// Get item under the key.
|
||||
///
|
||||
/// This functions return handle to item under `key` if it's already loaded. If it is in
|
||||
/// loading state (because another task is loading it asynchronously), it will be wait for that
|
||||
/// loading to finish.
|
||||
pub async fn get(&self, key: &K) -> FallibleResult<Option<Rc<V>>> {
|
||||
loop {
|
||||
let entry = self.registry.borrow_mut().get(key);
|
||||
match entry {
|
||||
|
@ -180,6 +180,18 @@ impl Model {
|
||||
})
|
||||
}
|
||||
|
||||
fn restore_project_snapshot(&self) {
|
||||
let controller = self.controller.clone_ref();
|
||||
let breadcrumbs = self.view.graph().model.breadcrumbs.clone_ref();
|
||||
executor::global::spawn(async move {
|
||||
if let Err(err) = controller.restore_project_snapshot().await {
|
||||
error!("Error while restoring project snapshot: {err}");
|
||||
} else {
|
||||
breadcrumbs.set_project_changed(false);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn set_project_changed(&self, changed: bool) {
|
||||
self.view.graph().model.breadcrumbs.set_project_changed(changed);
|
||||
}
|
||||
@ -268,6 +280,7 @@ impl Project {
|
||||
view.values_updated <+ values_computed;
|
||||
|
||||
eval_ view.save_project_snapshot(model.save_project_snapshot());
|
||||
eval_ view.restore_project_snapshot(model.restore_project_snapshot());
|
||||
|
||||
eval_ view.execution_context_interrupt(model.execution_context_interrupt());
|
||||
|
||||
@ -330,6 +343,7 @@ impl Project {
|
||||
NotificationKind::Invalidate
|
||||
| NotificationKind::CodeChanged { .. }
|
||||
| NotificationKind::MetadataChanged => model.set_project_changed(true),
|
||||
NotificationKind::Reloaded => model.set_project_changed(false),
|
||||
}
|
||||
futures::future::ready(())
|
||||
});
|
||||
|
@ -79,6 +79,8 @@ ensogl::define_endpoints! {
|
||||
toggle_style(),
|
||||
/// Saves a snapshot of the current state of the project to the VCS.
|
||||
save_project_snapshot(),
|
||||
/// Restores the state of the project to the last snapshot saved to the VCS.
|
||||
restore_project_snapshot(),
|
||||
/// Undo the last user's action.
|
||||
undo(),
|
||||
/// Redo the last undone action.
|
||||
@ -753,6 +755,7 @@ impl application::View for View {
|
||||
(Press, "open_dialog_shown", "escape", "close_open_dialog"),
|
||||
(Press, "", "cmd alt shift t", "toggle_style"),
|
||||
(Press, "", "cmd s", "save_project_snapshot"),
|
||||
(Press, "", "cmd r", "restore_project_snapshot"),
|
||||
(Press, "", "cmd z", "undo"),
|
||||
(Press, "", "cmd y", "redo"),
|
||||
(Press, "", "cmd shift z", "redo"),
|
||||
|
@ -179,6 +179,10 @@ class Parser {
|
||||
throw new ParserError("Could not deserialize metadata.", error)
|
||||
}.merge
|
||||
(input, idmap, metadata)
|
||||
case arr: Array[_] =>
|
||||
throw new ParserError(
|
||||
s"Could not not deserialize metadata (found ${arr.length - 1} metadata sections)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user