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:
Galin Bajlekov 2023-02-16 19:14:34 +01:00 committed by GitHub
parent 1bc27501e6
commit 725b3da486
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 255 additions and 10 deletions

View File

@ -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

View File

@ -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;
}}

View File

@ -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>,
}

View File

@ -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 ===

View File

@ -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,

View File

@ -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(())
}
}
}

View File

@ -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,
}
}

View File

@ -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.

View File

@ -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 {

View File

@ -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!\"";

View File

@ -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);

View File

@ -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 {

View File

@ -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(())
});

View File

@ -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"),

View File

@ -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)"
)
}
}