Read-only mode for controllers (#6259)

Closes #6181

- Added a read-only flag for the project model (now controlled by a temporary shortcut).
- Renaming the project, editing the code, connecting and disconnecting nodes, and collapsing, and navigating through collapsed nodes are forbidden and will result in the error message in the console.
- Some of the above actions produce undesired effects on the IDE, which will be fixed later. This PR is focused on restricting actual AST modifications.
- Moving nodes (and updating metadata in general) no longer causes reevaluation


https://user-images.githubusercontent.com/6566674/231616408-4f334bb7-1985-43ba-9953-4c0998338a9b.mp4
This commit is contained in:
Ilya Bogdanov 2023-04-20 17:17:18 +03:00 committed by GitHub
parent 68119ad6bb
commit 6aba602a34
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 305 additions and 71 deletions

View File

@ -113,7 +113,7 @@ trait API {
/// have permission to edit the resources for which edits are sent. This failure may be partial, /// have permission to edit the resources for which edits are sent. This failure may be partial,
/// in that some edits are applied and others are not. /// in that some edits are applied and others are not.
#[MethodInput=ApplyTextFileEditInput, rpc_name="text/applyEdit"] #[MethodInput=ApplyTextFileEditInput, rpc_name="text/applyEdit"]
fn apply_text_file_edit(&self, edit: FileEdit) -> (); fn apply_text_file_edit(&self, edit: FileEdit, execute: bool) -> ();
/// Create a new execution context. Return capabilities executionContext/canModify and /// Create a new execution context. Return capabilities executionContext/canModify and
/// executionContext/receivesUpdates containing freshly created ContextId /// executionContext/receivesUpdates containing freshly created ContextId

View File

@ -547,7 +547,7 @@ fn test_execution_context() {
let path = main.clone(); let path = main.clone();
let edit = FileEdit { path, edits, old_version, new_version }; let edit = FileEdit { path, edits, old_version, new_version };
test_request( test_request(
|client| client.apply_text_file_edit(&edit), |client| client.apply_text_file_edit(&edit, &true),
"text/applyEdit", "text/applyEdit",
json!({ json!({
"edit" : { "edit" : {
@ -572,7 +572,8 @@ fn test_execution_context() {
], ],
"oldVersion" : "d3ee9b1ba1990fecfd794d2f30e0207aaa7be5d37d463073096d86f8", "oldVersion" : "d3ee9b1ba1990fecfd794d2f30e0207aaa7be5d37d463073096d86f8",
"newVersion" : "6a33e22f20f16642697e8bd549ff7b759252ad56c05a1b0acc31dc69" "newVersion" : "6a33e22f20f16642697e8bd549ff7b759252ad56c05a1b0acc31dc69"
} },
"execute": true
}), }),
unit_json.clone(), unit_json.clone(),
(), (),

View File

@ -44,6 +44,10 @@ pub struct NotEvaluatedYet(double_representation::node::Id);
#[fail(display = "The node {} does not resolve to a method call.", _0)] #[fail(display = "The node {} does not resolve to a method call.", _0)]
pub struct NoResolvedMethod(double_representation::node::Id); pub struct NoResolvedMethod(double_representation::node::Id);
#[allow(missing_docs)]
#[derive(Debug, Fail, Clone, Copy)]
#[fail(display = "Operation is not permitted in read only mode")]
pub struct ReadOnly;
// ==================== // ====================
@ -216,19 +220,25 @@ impl Handle {
/// This will cause pushing a new stack frame to the execution context and changing the graph /// This will cause pushing a new stack frame to the execution context and changing the graph
/// controller to point to a new definition. /// controller to point to a new definition.
/// ///
/// Fails if method graph cannot be created (see `graph_for_method` documentation). /// ### Errors
/// - Fails if method graph cannot be created (see `graph_for_method` documentation).
/// - Fails if the project is in read-only mode.
pub async fn enter_method_pointer(&self, local_call: &LocalCall) -> FallibleResult { pub async fn enter_method_pointer(&self, local_call: &LocalCall) -> FallibleResult {
debug!("Entering node {}.", local_call.call); if self.project.read_only() {
let method_ptr = &local_call.definition; Err(ReadOnly.into())
let graph = controller::Graph::new_method(&self.project, method_ptr); } else {
let graph = graph.await?; debug!("Entering node {}.", local_call.call);
self.execution_ctx.push(local_call.clone()).await?; let method_ptr = &local_call.definition;
debug!("Replacing graph with {graph:?}."); let graph = controller::Graph::new_method(&self.project, method_ptr);
self.graph.replace(graph); let graph = graph.await?;
debug!("Sending graph invalidation signal."); self.execution_ctx.push(local_call.clone()).await?;
self.notifier.publish(Notification::EnteredNode(local_call.clone())).await; debug!("Replacing graph with {graph:?}.");
self.graph.replace(graph);
debug!("Sending graph invalidation signal.");
self.notifier.publish(Notification::EnteredNode(local_call.clone())).await;
Ok(()) Ok(())
}
} }
/// Attempts to get the computed value of the specified node. /// Attempts to get the computed value of the specified node.
@ -246,15 +256,21 @@ impl Handle {
/// Leave the current node. Reverse of `enter_node`. /// Leave the current node. Reverse of `enter_node`.
/// ///
/// Fails if this execution context is already at the stack's root or if the parent graph /// ### Errors
/// - Fails if this execution context is already at the stack's root or if the parent graph
/// cannot be retrieved. /// cannot be retrieved.
/// - Fails if the project is in read-only mode.
pub async fn exit_node(&self) -> FallibleResult { pub async fn exit_node(&self) -> FallibleResult {
let frame = self.execution_ctx.pop().await?; if self.project.read_only() {
let method = self.execution_ctx.current_method(); Err(ReadOnly.into())
let graph = controller::Graph::new_method(&self.project, &method).await?; } else {
self.graph.replace(graph); let frame = self.execution_ctx.pop().await?;
self.notifier.publish(Notification::SteppedOutOfNode(frame.call)).await; let method = self.execution_ctx.current_method();
Ok(()) let graph = controller::Graph::new_method(&self.project, &method).await?;
self.graph.replace(graph);
self.notifier.publish(Notification::SteppedOutOfNode(frame.call)).await;
Ok(())
}
} }
/// Interrupt the program execution. /// Interrupt the program execution.
@ -264,9 +280,16 @@ impl Handle {
} }
/// Restart the program execution. /// Restart the program execution.
///
/// ### Errors
/// - Fails if the project is in read-only mode.
pub async fn restart(&self) -> FallibleResult { pub async fn restart(&self) -> FallibleResult {
self.execution_ctx.restart().await?; if self.project.read_only() {
Ok(()) Err(ReadOnly.into())
} else {
self.execution_ctx.restart().await?;
Ok(())
}
} }
/// Get the current call stack frames. /// Get the current call stack frames.
@ -308,15 +331,29 @@ impl Handle {
} }
/// Create connection in graph. /// Create connection in graph.
///
/// ### Errors
/// - Fails if the project is in read-only mode.
pub fn connect(&self, connection: &Connection) -> FallibleResult { pub fn connect(&self, connection: &Connection) -> FallibleResult {
self.graph.borrow().connect(connection, self) if self.project.read_only() {
Err(ReadOnly.into())
} else {
self.graph.borrow().connect(connection, self)
}
} }
/// Remove the connections from the graph. Returns an updated edge destination endpoint for /// Remove the connections from the graph. Returns an updated edge destination endpoint for
/// disconnected edge, in case it is still used as destination-only edge. When `None` is /// disconnected edge, in case it is still used as destination-only edge. When `None` is
/// returned, no update is necessary. /// returned, no update is necessary.
///
/// ### Errors
/// - Fails if the project is in read-only mode.
pub fn disconnect(&self, connection: &Connection) -> FallibleResult<Option<span_tree::Crumbs>> { pub fn disconnect(&self, connection: &Connection) -> FallibleResult<Option<span_tree::Crumbs>> {
self.graph.borrow().disconnect(connection, self) if self.project.read_only() {
Err(ReadOnly.into())
} else {
self.graph.borrow().disconnect(connection, self)
}
} }
} }
@ -368,6 +405,8 @@ pub mod tests {
use crate::model::execution_context::ExpressionId; use crate::model::execution_context::ExpressionId;
use crate::test; use crate::test;
use crate::test::mock::Fixture;
use controller::graph::SpanTree;
use engine_protocol::language_server::types::test::value_update_with_type; use engine_protocol::language_server::types::test::value_update_with_type;
use wasm_bindgen_test::wasm_bindgen_test; use wasm_bindgen_test::wasm_bindgen_test;
use wasm_bindgen_test::wasm_bindgen_test_configure; use wasm_bindgen_test::wasm_bindgen_test_configure;
@ -454,4 +493,95 @@ pub mod tests {
notifications.expect_pending(); notifications.expect_pending();
} }
// Test that moving nodes is possible in read-only mode.
#[wasm_bindgen_test]
fn read_only_mode_does_not_restrict_moving_nodes() {
use model::module::Position;
let fixture = crate::test::mock::Unified::new().fixture();
let Fixture { executed_graph, graph, .. } = fixture;
let nodes = executed_graph.graph().nodes().unwrap();
let node = &nodes[0];
let pos1 = Position::new(500.0, 250.0);
let pos2 = Position::new(300.0, 150.0);
graph.set_node_position(node.id(), pos1).unwrap();
assert_eq!(graph.node(node.id()).unwrap().position(), Some(pos1));
graph.set_node_position(node.id(), pos2).unwrap();
assert_eq!(graph.node(node.id()).unwrap().position(), Some(pos2));
}
// Test that certain actions are forbidden in read-only mode.
#[wasm_bindgen_test]
fn read_only_mode() {
fn run(code: &str, f: impl FnOnce(&Handle)) {
let mut data = crate::test::mock::Unified::new();
data.set_code(code);
let fixture = data.fixture();
fixture.read_only.set(true);
let Fixture { executed_graph, .. } = fixture;
f(&executed_graph);
}
// === Editing the node. ===
let default_code = r#"
main =
foo = 2 * 2
"#;
run(default_code, |executed| {
let nodes = executed.graph().nodes().unwrap();
let node = &nodes[0];
assert!(executed.graph().set_expression(node.info.id(), "5 * 20").is_err());
});
// === Collapsing nodes. ===
let code = r#"
main =
foo = 2
bar = foo + 6
baz = 2 + foo + bar
caz = baz / 2 * baz
"#;
run(code, |executed| {
let nodes = executed.graph().nodes().unwrap();
// Collapse two middle nodes.
let nodes_range = vec![nodes[1].id(), nodes[2].id()];
assert!(executed.graph().collapse(nodes_range, "extracted").is_err());
});
// === Connecting nodes. ===
let code = r#"
main =
2 + 2
5 * 5
"#;
run(code, |executed| {
let nodes = executed.graph().nodes().unwrap();
let sum_node = &nodes[0];
let product_node = &nodes[1];
assert_eq!(sum_node.expression().to_string(), "2 + 2");
assert_eq!(product_node.expression().to_string(), "5 * 5");
let context = &span_tree::generate::context::Empty;
let sum_tree = SpanTree::<()>::new(&sum_node.expression(), context).unwrap();
let sum_input =
sum_tree.root_ref().leaf_iter().find(|n| n.is_argument()).unwrap().crumbs;
let connection = Connection {
source: controller::graph::Endpoint::new(product_node.id(), []),
destination: controller::graph::Endpoint::new(sum_node.id(), sum_input),
};
assert!(executed.connect(&connection).is_err());
});
}
} }

View File

@ -173,7 +173,7 @@ impl Handle {
) -> FallibleResult<Self> { ) -> FallibleResult<Self> {
let ast = parser.parse(code.to_string(), id_map).try_into()?; let ast = parser.parse(code.to_string(), id_map).try_into()?;
let metadata = default(); let metadata = default();
let model = Rc::new(model::module::Plain::new(path, ast, metadata, repository)); let model = Rc::new(model::module::Plain::new(path, ast, metadata, repository, default()));
Ok(Handle { model, language_server, parser }) Ok(Handle { model, language_server, parser })
} }

View File

@ -97,6 +97,7 @@ pub struct CannotCommitExpression {
} }
// ===================== // =====================
// === Notifications === // === Notifications ===
// ===================== // =====================

View File

@ -744,7 +744,9 @@ pub mod test {
repository: Rc<model::undo_redo::Repository>, repository: Rc<model::undo_redo::Repository>,
) -> Module { ) -> Module {
let ast = parser.parse_module(&self.code, self.id_map.clone()).unwrap(); let ast = parser.parse_module(&self.code, self.id_map.clone()).unwrap();
let module = Plain::new(self.path.clone(), ast, self.metadata.clone(), repository); let path = self.path.clone();
let metadata = self.metadata.clone();
let module = Plain::new(path, ast, metadata, repository, default());
Rc::new(module) Rc::new(module)
} }
} }

View File

@ -26,6 +26,17 @@ use std::collections::hash_map::Entry;
// ==============
// === Errors ===
// ==============
#[allow(missing_docs)]
#[derive(Debug, Clone, Copy, Fail)]
#[fail(display = "Attempt to edit a read-only module")]
pub struct EditInReadOnly;
// ============== // ==============
// === Module === // === Module ===
// ============== // ==============
@ -41,6 +52,7 @@ pub struct Module {
content: RefCell<Content>, content: RefCell<Content>,
notifications: notification::Publisher<Notification>, notifications: notification::Publisher<Notification>,
repository: Rc<model::undo_redo::Repository>, repository: Rc<model::undo_redo::Repository>,
read_only: Rc<Cell<bool>>,
} }
impl Module { impl Module {
@ -50,36 +62,44 @@ impl Module {
ast: ast::known::Module, ast: ast::known::Module,
metadata: Metadata, metadata: Metadata,
repository: Rc<model::undo_redo::Repository>, repository: Rc<model::undo_redo::Repository>,
read_only: Rc<Cell<bool>>,
) -> Self { ) -> Self {
Module { Module {
content: RefCell::new(ParsedSourceFile { ast, metadata }), content: RefCell::new(ParsedSourceFile { ast, metadata }),
notifications: default(), notifications: default(),
path, path,
repository, repository,
read_only,
} }
} }
/// Replace the module's content with the new value and emit notification of given kind. /// Replace the module's content with the new value and emit notification of given kind.
/// ///
/// Fails if the `new_content` is so broken that it cannot be serialized to text. In such case /// ### Errors
/// - Fails if the `new_content` is so broken that it cannot be serialized to text. In such case
/// the module's state is guaranteed to remain unmodified and the notification will not be /// the module's state is guaranteed to remain unmodified and the notification will not be
/// emitted. /// emitted.
/// - Fails if the module is read-only. Metadata-only changes are allowed in read-only mode.
#[profile(Debug)] #[profile(Debug)]
fn set_content(&self, new_content: Content, kind: NotificationKind) -> FallibleResult { fn set_content(&self, new_content: Content, kind: NotificationKind) -> FallibleResult {
if new_content == *self.content.borrow() { if new_content == *self.content.borrow() {
debug!("Ignoring spurious update."); debug!("Ignoring spurious update.");
return Ok(()); return Ok(());
} }
trace!("Updating module's content: {kind:?}. New content:\n{new_content}"); if self.read_only.get() && kind != NotificationKind::MetadataChanged {
let transaction = self.repository.transaction("Setting module's content"); Err(EditInReadOnly.into())
transaction.fill_content(self.id(), self.content.borrow().clone()); } else {
trace!("Updating module's content: {kind:?}. New content:\n{new_content}");
let transaction = self.repository.transaction("Setting module's content");
transaction.fill_content(self.id(), self.content.borrow().clone());
// We want the line below to fail before changing state. // We want the line below to fail before changing state.
let new_file = new_content.serialize()?; let new_file = new_content.serialize()?;
let notification = Notification::new(new_file, kind); let notification = Notification::new(new_file, kind);
self.content.replace(new_content); self.content.replace(new_content);
self.notifications.notify(notification); self.notifications.notify(notification);
Ok(()) Ok(())
}
} }
/// Use `f` to update the module's content. /// Use `f` to update the module's content.

View File

@ -165,6 +165,7 @@ impl Module {
language_server: Rc<language_server::Connection>, language_server: Rc<language_server::Connection>,
parser: Parser, parser: Parser,
repository: Rc<model::undo_redo::Repository>, repository: Rc<model::undo_redo::Repository>,
read_only: Rc<Cell<bool>>,
) -> FallibleResult<Rc<Self>> { ) -> FallibleResult<Rc<Self>> {
let file_path = path.file_path().clone(); let file_path = path.file_path().clone();
info!("Opening module {file_path}"); info!("Opening module {file_path}");
@ -176,7 +177,9 @@ impl Module {
let source = parser.parse_with_metadata(opened.content); let source = parser.parse_with_metadata(opened.content);
let digest = opened.current_version; let digest = opened.current_version;
let summary = ContentSummary { digest, end_of_file }; let summary = ContentSummary { digest, end_of_file };
let model = model::module::Plain::new(path, source.ast, source.metadata, repository); let metadata = source.metadata;
let ast = source.ast;
let model = model::module::Plain::new(path, ast, metadata, repository, read_only);
let this = Rc::new(Module { model, language_server }); let this = Rc::new(Module { model, language_server });
let content = this.model.serialized_content()?; let content = this.model.serialized_content()?;
let first_invalidation = this.full_invalidation(&summary, content); let first_invalidation = this.full_invalidation(&summary, content);
@ -238,7 +241,7 @@ impl Module {
self.content().replace(parsed_source); self.content().replace(parsed_source);
let summary = ContentSummary::new(&content); let summary = ContentSummary::new(&content);
let change = TextEdit::from_prefix_postfix_differences(&content, &source.content); let change = TextEdit::from_prefix_postfix_differences(&content, &source.content);
self.notify_language_server(&summary, &source, vec![change]).await?; self.notify_language_server(&summary, &source, vec![change], true).await?;
let notification = Notification::new(source, NotificationKind::Reloaded); let notification = Notification::new(source, NotificationKind::Reloaded);
self.notify(notification); self.notify(notification);
Ok(()) Ok(())
@ -423,7 +426,8 @@ impl Module {
}; };
//id_map goes first, because code change may alter its position. //id_map goes first, because code change may alter its position.
let edits = vec![id_map_change, code_change]; let edits = vec![id_map_change, code_change];
let notify_ls = self.notify_language_server(&summary.summary, &new_file, edits); let summary = &summary.summary;
let notify_ls = self.notify_language_server(summary, &new_file, edits, true);
profiler::await_!(notify_ls, _profiler) profiler::await_!(notify_ls, _profiler)
} }
NotificationKind::MetadataChanged => { NotificationKind::MetadataChanged => {
@ -431,7 +435,8 @@ impl Module {
range: summary.metadata_engine_range().into(), range: summary.metadata_engine_range().into(),
text: new_file.metadata_slice().to_string(), text: new_file.metadata_slice().to_string(),
}]; }];
let notify_ls = self.notify_language_server(&summary.summary, &new_file, edits); let summary = &summary.summary;
let notify_ls = self.notify_language_server(summary, &new_file, edits, false);
profiler::await_!(notify_ls, _profiler) profiler::await_!(notify_ls, _profiler)
} }
NotificationKind::Reloaded => Ok(ParsedContentSummary::from_source(&new_file)), NotificationKind::Reloaded => Ok(ParsedContentSummary::from_source(&new_file)),
@ -450,7 +455,7 @@ impl Module {
debug!("Handling full invalidation: {ls_content:?}."); debug!("Handling full invalidation: {ls_content:?}.");
let range = Range::new(Location::default(), ls_content.end_of_file); let range = Range::new(Location::default(), ls_content.end_of_file);
let edits = vec![TextEdit { range: range.into(), text: new_file.content.clone() }]; let edits = vec![TextEdit { range: range.into(), text: new_file.content.clone() }];
self.notify_language_server(ls_content, &new_file, edits) self.notify_language_server(ls_content, &new_file, edits, true)
} }
fn edit_for_snipped( fn edit_for_snipped(
@ -518,7 +523,7 @@ impl Module {
.into_iter() .into_iter()
.flatten() .flatten()
.collect_vec(); .collect_vec();
self.notify_language_server(&ls_content.summary, &new_file, edits) self.notify_language_server(&ls_content.summary, &new_file, edits, true)
} }
/// This is a helper function with all common logic regarding sending the update to /// This is a helper function with all common logic regarding sending the update to
@ -529,6 +534,7 @@ impl Module {
ls_content: &ContentSummary, ls_content: &ContentSummary,
new_file: &SourceFile, new_file: &SourceFile,
edits: Vec<TextEdit>, edits: Vec<TextEdit>,
execute: bool,
) -> impl Future<Output = FallibleResult<ParsedContentSummary>> + 'static { ) -> impl Future<Output = FallibleResult<ParsedContentSummary>> + 'static {
let summary = ParsedContentSummary::from_source(new_file); let summary = ParsedContentSummary::from_source(new_file);
let edit = FileEdit { let edit = FileEdit {
@ -538,7 +544,7 @@ impl Module {
new_version: Sha3_224::new(new_file.content.as_bytes()), new_version: Sha3_224::new(new_file.content.as_bytes()),
}; };
debug!("Notifying LS with edit: {edit:#?}."); debug!("Notifying LS with edit: {edit:#?}.");
let ls_future_reply = self.language_server.client.apply_text_file_edit(&edit); let ls_future_reply = self.language_server.client.apply_text_file_edit(&edit, &execute);
async move { async move {
ls_future_reply.await?; ls_future_reply.await?;
Ok(summary) Ok(summary)
@ -634,7 +640,7 @@ pub mod test {
f: impl FnOnce(&FileEdit) -> json_rpc::Result<()> + 'static, f: impl FnOnce(&FileEdit) -> json_rpc::Result<()> + 'static,
) { ) {
let this = self.clone(); let this = self.clone();
client.expect.apply_text_file_edit(move |edits| { client.expect.apply_text_file_edit(move |edits, _execute| {
let content_so_far = this.current_ls_content.get(); let content_so_far = this.current_ls_content.get();
let result = f(edits); let result = f(edits);
let new_content = apply_edits(content_so_far, edits); let new_content = apply_edits(content_so_far, edits);
@ -755,9 +761,11 @@ pub mod test {
// * there is an initial invalidation after opening the module // * there is an initial invalidation after opening the module
// * replacing AST causes invalidation // * replacing AST causes invalidation
// * localized text edit emits similarly localized synchronization updates. // * localized text edit emits similarly localized synchronization updates.
// * modifying the code fails if the read-only mode is enabled.
let initial_code = "main =\n println \"Hello World!\""; let initial_code = "main =\n println \"Hello World!\"";
let mut data = crate::test::mock::Unified::new(); let mut data = crate::test::mock::Unified::new();
data.set_code(initial_code); data.set_code(initial_code);
let read_only: Rc<Cell<bool>> = default();
// We do actually care about sharing `data` between `test` invocations, as it stores the // We do actually care about sharing `data` between `test` invocations, as it stores the
// Parser which is time-consuming to construct. // Parser which is time-consuming to construct.
let test = |runner: &mut Runner| { let test = |runner: &mut Runner| {
@ -784,19 +792,33 @@ pub mod test {
Ok(()) Ok(())
}); });
}); });
fixture.read_only.set(read_only.get());
let parser = data.parser.clone(); let parser = data.parser.clone();
let module = fixture.synchronized_module(); let module = fixture.synchronized_module();
let new_content = "main =\n println \"Test\""; let new_content = "main =\n println \"Test\"";
let new_ast = parser.parse_module(new_content, default()).unwrap(); let new_ast = parser.parse_module(new_content, default()).unwrap();
module.update_ast(new_ast).unwrap(); let res = module.update_ast(new_ast);
if read_only.get() {
assert!(res.is_err());
} else {
assert!(res.is_ok());
}
runner.perhaps_run_until_stalled(&mut fixture); runner.perhaps_run_until_stalled(&mut fixture);
let change = TextChange { range: (20..24).into(), text: "Test 2".to_string() }; let change = TextChange { range: (20..24).into(), text: "Test 2".to_string() };
module.apply_code_change(change, &Parser::new(), default()).unwrap(); let res = module.apply_code_change(change, &Parser::new(), default());
if read_only.get() {
assert!(res.is_err());
} else {
assert!(res.is_ok());
}
runner.perhaps_run_until_stalled(&mut fixture); runner.perhaps_run_until_stalled(&mut fixture);
}; };
read_only.set(false);
Runner::run(test);
read_only.set(true);
Runner::run(test); Runner::run(test);
} }

View File

@ -39,6 +39,15 @@ pub trait API: Debug {
/// Project's qualified name /// Project's qualified name
fn qualified_name(&self) -> project::QualifiedName; fn qualified_name(&self) -> project::QualifiedName;
/// Whether the read-only mode is enabled for the project.
///
/// Read-only mode forbids certain operations, like renaming the project or editing the code
/// through the IDE.
fn read_only(&self) -> bool;
/// Set the read-only mode for the project.
fn set_read_only(&self, read_only: bool);
/// Get Language Server JSON-RPC Connection for this project. /// Get Language Server JSON-RPC Connection for this project.
fn json_rpc(&self) -> Rc<language_server::Connection>; fn json_rpc(&self) -> Rc<language_server::Connection>;
@ -267,4 +276,8 @@ pub mod test {
path.qualified_module_name(name.clone()) path.qualified_module_name(name.clone())
}); });
} }
pub fn expect_read_only(project: &mut MockAPI, read_only: Rc<Cell<bool>>) {
project.expect_read_only().returning_st(move || read_only.get());
}
} }

View File

@ -224,6 +224,11 @@ async fn update_modules_on_file_change(
#[fail(display = "Project Manager is unavailable.")] #[fail(display = "Project Manager is unavailable.")]
pub struct ProjectManagerUnavailable; pub struct ProjectManagerUnavailable;
#[allow(missing_docs)]
#[derive(Clone, Copy, Debug, Fail)]
#[fail(display = "Project renaming is not available in read-only mode.")]
pub struct RenameInReadOnly;
/// A wrapper for an error with information that user tried to open project with unsupported /// A wrapper for an error with information that user tried to open project with unsupported
/// engine's version (which is likely the cause of the problems). /// engine's version (which is likely the cause of the problems).
#[derive(Debug, Fail)] #[derive(Debug, Fail)]
@ -295,6 +300,7 @@ pub struct Project {
pub parser: Parser, pub parser: Parser,
pub notifications: notification::Publisher<model::project::Notification>, pub notifications: notification::Publisher<model::project::Notification>,
pub urm: Rc<model::undo_redo::Manager>, pub urm: Rc<model::undo_redo::Manager>,
pub read_only: Rc<Cell<bool>>,
} }
impl Project { impl Project {
@ -339,6 +345,7 @@ impl Project {
parser, parser,
notifications, notifications,
urm, urm,
read_only: default(),
}; };
let binary_handler = ret.binary_event_handler(); let binary_handler = ret.binary_event_handler();
@ -620,13 +627,13 @@ impl Project {
&self, &self,
path: module::Path, path: module::Path,
) -> impl Future<Output = FallibleResult<Rc<module::Synchronized>>> { ) -> impl Future<Output = FallibleResult<Rc<module::Synchronized>>> {
let language_server = self.language_server_rpc.clone_ref(); let ls = self.language_server_rpc.clone_ref();
let parser = self.parser.clone_ref(); let parser = self.parser.clone_ref();
let urm = self.urm(); let urm = self.urm();
let repository = urm.repository.clone_ref(); let repo = urm.repository.clone_ref();
let read_only = self.read_only.clone_ref();
async move { async move {
let module = let module = module::Synchronized::open(path, ls, parser, repo, read_only).await?;
module::Synchronized::open(path, language_server, parser, repository).await?;
urm.module_opened(module.clone()); urm.module_opened(module.clone());
Ok(module) Ok(module)
} }
@ -702,18 +709,23 @@ impl model::project::API for Project {
} }
fn rename_project(&self, name: String) -> BoxFuture<FallibleResult> { fn rename_project(&self, name: String) -> BoxFuture<FallibleResult> {
async move { if self.read_only() {
let old_name = self.properties.borrow_mut().name.project.clone_ref(); std::future::ready(Err(RenameInReadOnly.into())).boxed_local()
let referent_name = name.to_im_string(); } else {
let project_manager = self.project_manager.as_ref().ok_or(ProjectManagerUnavailable)?; async move {
let project_id = self.properties.borrow().id; let old_name = self.properties.borrow_mut().name.project.clone_ref();
let project_name = ProjectName::new_unchecked(name); let referent_name = name.to_im_string();
project_manager.rename_project(&project_id, &project_name).await?; let project_manager =
self.properties.borrow_mut().name.project = referent_name.clone_ref(); self.project_manager.as_ref().ok_or(ProjectManagerUnavailable)?;
self.execution_contexts.rename_project(old_name, referent_name); let project_id = self.properties.borrow().id;
Ok(()) let project_name = ProjectName::new_unchecked(name);
project_manager.rename_project(&project_id, &project_name).await?;
self.properties.borrow_mut().name.project = referent_name.clone_ref();
self.execution_contexts.rename_project(old_name, referent_name);
Ok(())
}
.boxed_local()
} }
.boxed_local()
} }
fn project_content_root_id(&self) -> Uuid { fn project_content_root_id(&self) -> Uuid {
@ -727,6 +739,14 @@ impl model::project::API for Project {
fn urm(&self) -> Rc<model::undo_redo::Manager> { fn urm(&self) -> Rc<model::undo_redo::Manager> {
self.urm.clone_ref() self.urm.clone_ref()
} }
fn read_only(&self) -> bool {
self.read_only.get()
}
fn set_read_only(&self, read_only: bool) {
self.read_only.set(read_only);
}
} }
@ -864,7 +884,7 @@ mod test {
let write_capability = Some(write_capability); let write_capability = Some(write_capability);
let open_response = response::OpenTextFile { content, current_version, write_capability }; let open_response = response::OpenTextFile { content, current_version, write_capability };
expect_call!(client.open_text_file(path=path.clone()) => Ok(open_response)); expect_call!(client.open_text_file(path=path.clone()) => Ok(open_response));
client.expect.apply_text_file_edit(|_| Ok(())); client.expect.apply_text_file_edit(|_, _| Ok(()));
expect_call!(client.close_text_file(path) => Ok(())); expect_call!(client.close_text_file(path) => Ok(()));
} }

View File

@ -165,9 +165,10 @@ mod test {
use super::*; use super::*;
use crate::executor::test_utils::TestWithLocalPoolExecutor; use crate::executor::test_utils::TestWithLocalPoolExecutor;
use model::module::Plain;
type ModulePath = model::module::Path; type ModulePath = model::module::Path;
type Registry = super::Registry<ModulePath, model::module::Plain>; type Registry = super::Registry<ModulePath, Plain>;
#[test] #[test]
fn getting_module() { fn getting_module() {
@ -177,7 +178,7 @@ mod test {
let ast = ast::Ast::one_line_module(line).try_into().unwrap(); let ast = ast::Ast::one_line_module(line).try_into().unwrap();
let path = ModulePath::from_mock_module_name("Test"); let path = ModulePath::from_mock_module_name("Test");
let urm = default(); let urm = default();
let state = Rc::new(model::module::Plain::new(path.clone(), ast, default(), urm)); let state = Rc::new(Plain::new(path.clone(), ast, default(), urm, default()));
let registry = Registry::default(); let registry = Registry::default();
let expected = state.clone_ref(); let expected = state.clone_ref();
@ -198,7 +199,7 @@ mod test {
let path1 = ModulePath::from_mock_module_name("Test"); let path1 = ModulePath::from_mock_module_name("Test");
let path2 = path1.clone(); let path2 = path1.clone();
let urm = default(); let urm = default();
let state1 = Rc::new(model::module::Plain::new(path1.clone_ref(), ast, default(), urm)); let state1 = Rc::new(Plain::new(path1.clone_ref(), ast, default(), urm, default()));
let state2 = state1.clone_ref(); let state2 = state1.clone_ref();
let registry1 = Rc::new(Registry::default()); let registry1 = Rc::new(Registry::default());
let registry2 = registry1.clone_ref(); let registry2 = registry1.clone_ref();

View File

@ -199,6 +199,12 @@ impl Model {
self.ide_controller.set_component_browser_private_entries_visibility(!visibility); self.ide_controller.set_component_browser_private_entries_visibility(!visibility);
} }
fn toggle_read_only(&self) {
let read_only = self.controller.model.read_only();
self.controller.model.set_read_only(!read_only);
info!("New read only state: {}.", self.controller.model.read_only());
}
fn restore_project_snapshot(&self) { fn restore_project_snapshot(&self) {
let controller = self.controller.clone_ref(); let controller = self.controller.clone_ref();
let breadcrumbs = self.view.graph().model.breadcrumbs.clone_ref(); let breadcrumbs = self.view.graph().model.breadcrumbs.clone_ref();
@ -374,6 +380,8 @@ impl Project {
eval_ view.execution_context_interrupt(model.execution_context_interrupt()); eval_ view.execution_context_interrupt(model.execution_context_interrupt());
eval_ view.execution_context_restart(model.execution_context_restart()); eval_ view.execution_context_restart(model.execution_context_restart());
eval_ view.toggle_read_only(model.toggle_read_only());
} }
let graph_controller = self.model.graph_controller.clone_ref(); let graph_controller = self.model.graph_controller.clone_ref();

View File

@ -134,7 +134,7 @@ impl Model {
match self.suggestion_for_entry_id(entry_id) { match self.suggestion_for_entry_id(entry_id) {
Ok(suggestion) => Ok(suggestion) =>
if let Err(error) = self.controller.preview_suggestion(suggestion) { if let Err(error) = self.controller.preview_suggestion(suggestion) {
warn!("Failed to preview suggestion {entry_id:?} because of error: {error:?}."); warn!("Failed to preview suggestion {entry_id:?} because of error: {error}.");
}, },
Err(err) => warn!("Error while previewing suggestion: {err}."), Err(err) => warn!("Error while previewing suggestion: {err}."),
} }

View File

@ -135,6 +135,7 @@ pub mod mock {
pub suggestions: HashMap<suggestion_database::entry::Id, suggestion_database::Entry>, pub suggestions: HashMap<suggestion_database::entry::Id, suggestion_database::Entry>,
pub context_id: model::execution_context::Id, pub context_id: model::execution_context::Id,
pub parser: parser::Parser, pub parser: parser::Parser,
pub read_only: Rc<Cell<bool>>,
code: String, code: String,
id_map: ast::IdMap, id_map: ast::IdMap,
metadata: crate::model::module::Metadata, metadata: crate::model::module::Metadata,
@ -172,6 +173,7 @@ pub mod mock {
context_id: CONTEXT_ID, context_id: CONTEXT_ID,
root_definition: definition_name(), root_definition: definition_name(),
parser: parser::Parser::new(), parser: parser::Parser::new(),
read_only: default(),
} }
} }
@ -184,8 +186,14 @@ pub mod mock {
let path = self.module_path.clone(); let path = self.module_path.clone();
let metadata = self.metadata.clone(); let metadata = self.metadata.clone();
let repository = urm.repository.clone_ref(); let repository = urm.repository.clone_ref();
let module = Rc::new(model::module::Plain::new(path, ast, metadata, repository)); let module = Rc::new(model::module::Plain::new(
urm.module_opened(module.clone()); path,
ast,
metadata,
repository,
self.read_only.clone_ref(),
));
urm.module_opened(module.clone_ref());
module module
} }
@ -242,6 +250,7 @@ pub mod mock {
// Root ID is needed to generate module path used to get the module. // Root ID is needed to generate module path used to get the module.
model::project::test::expect_root_id(&mut project, crate::test::mock::data::ROOT_ID); model::project::test::expect_root_id(&mut project, crate::test::mock::data::ROOT_ID);
model::project::test::expect_suggestion_db(&mut project, suggestion_database); model::project::test::expect_suggestion_db(&mut project, suggestion_database);
model::project::test::expect_read_only(&mut project, self.read_only.clone_ref());
let json_rpc = language_server::Connection::new_mock_rc(json_client); let json_rpc = language_server::Connection::new_mock_rc(json_client);
model::project::test::expect_json_rpc(&mut project, json_rpc); model::project::test::expect_json_rpc(&mut project, json_rpc);
let binary_rpc = binary::Connection::new_mock_rc(binary_client); let binary_rpc = binary::Connection::new_mock_rc(binary_client);
@ -301,6 +310,7 @@ pub mod mock {
position_in_code, position_in_code,
) )
.unwrap(); .unwrap();
let read_only = self.read_only.clone_ref();
executor.run_until_stalled(); executor.run_until_stalled();
Fixture { Fixture {
executor, executor,
@ -313,6 +323,7 @@ pub mod mock {
project, project,
searcher, searcher,
ide, ide,
read_only,
} }
} }
@ -360,6 +371,7 @@ pub mod mock {
pub executed_graph: controller::ExecutedGraph, pub executed_graph: controller::ExecutedGraph,
pub suggestion_db: Rc<model::SuggestionDatabase>, pub suggestion_db: Rc<model::SuggestionDatabase>,
pub project: model::Project, pub project: model::Project,
pub read_only: Rc<Cell<bool>>,
pub ide: controller::Ide, pub ide: controller::Ide,
pub searcher: controller::Searcher, pub searcher: controller::Searcher,
#[deref] #[deref]
@ -384,7 +396,8 @@ pub mod mock {
let path = self.data.module_path.clone(); let path = self.data.module_path.clone();
let ls = self.project.json_rpc(); let ls = self.project.json_rpc();
let repository = self.project.urm().repository.clone_ref(); let repository = self.project.urm().repository.clone_ref();
let module_future = model::module::Synchronized::open(path, ls, parser, repository); let ro = self.read_only.clone_ref();
let module_future = model::module::Synchronized::open(path, ls, parser, repository, ro);
// We can `expect_ready`, because in fact this is synchronous in test conditions. // We can `expect_ready`, because in fact this is synchronous in test conditions.
// (there's no real asynchronous connection beneath, just the `MockClient`) // (there's no real asynchronous connection beneath, just the `MockClient`)
let module = module_future.boxed_local().expect_ready().unwrap(); let module = module_future.boxed_local().expect_ready().unwrap();

View File

@ -240,7 +240,7 @@ async fn ls_text_protocol_test() {
let new_version = Sha3_224::new(b"Hello, world!"); let new_version = Sha3_224::new(b"Hello, world!");
let path = move_path.clone(); let path = move_path.clone();
let edit = FileEdit { path, edits, old_version, new_version: new_version.clone() }; let edit = FileEdit { path, edits, old_version, new_version: new_version.clone() };
client.apply_text_file_edit(&edit).await.expect("Couldn't apply edit."); client.apply_text_file_edit(&edit, &true).await.expect("Couldn't apply edit.");
let saving_result = client.save_text_file(&move_path, &new_version).await; let saving_result = client.save_text_file(&move_path, &new_version).await;
saving_result.expect("Couldn't save file."); saving_result.expect("Couldn't save file.");

View File

@ -103,6 +103,7 @@ ensogl::define_endpoints! {
execution_context_interrupt(), execution_context_interrupt(),
/// Restart the program execution. /// Restart the program execution.
execution_context_restart(), execution_context_restart(),
toggle_read_only(),
} }
Output { Output {
@ -689,6 +690,8 @@ impl application::View for View {
(Press, "debug_mode", DEBUG_MODE_SHORTCUT, "disable_debug_mode"), (Press, "debug_mode", DEBUG_MODE_SHORTCUT, "disable_debug_mode"),
(Press, "", "cmd shift t", "execution_context_interrupt"), (Press, "", "cmd shift t", "execution_context_interrupt"),
(Press, "", "cmd shift r", "execution_context_restart"), (Press, "", "cmd shift r", "execution_context_restart"),
// TODO(#6179): Remove this temporary shortcut when Play button is ready.
(Press, "", "ctrl shift b", "toggle_read_only"),
] ]
.iter() .iter()
.map(|(a, b, c, d)| Self::self_shortcut_when(*a, *c, *d, *b)) .map(|(a, b, c, d)| Self::self_shortcut_when(*a, *c, *d, *b))