From a39560b4db97f4c72101b26cccf198a753fed1b7 Mon Sep 17 00:00:00 2001 From: Ilya Bogdanov Date: Mon, 30 Dec 2024 19:54:38 +0400 Subject: [PATCH 01/12] Fix redo stack loss when editing text literals (#11908) Fixes #11542 https://github.com/user-attachments/assets/b40b669b-57bf-4341-b328-4a4e53dff202 --- CHANGELOG.md | 2 ++ .../components/GraphEditor/widgets/WidgetText.vue | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b5e023d18db..eedf4f283812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,13 @@ - A constructor or type definition with a single inline argument definition was previously allowed to use spaces in the argument definition without parentheses. [This is now a syntax error.][11856] +- [Redo stack is no longer lost when interacting with text literals][11908]. - Symetric, transitive and reflexive [equality for intersection types][11897] [11777]: https://github.com/enso-org/enso/pull/11777 [11600]: https://github.com/enso-org/enso/pull/11600 [11856]: https://github.com/enso-org/enso/pull/11856 +[11908]: https://github.com/enso-org/enso/pull/11908 [11897]: https://github.com/enso-org/enso/pull/11897 # Next Release diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetText.vue b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetText.vue index 98437034d4aa..27c95fef1689 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetText.vue +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetText.vue @@ -15,7 +15,12 @@ const graph = useGraphStore() const input = ref>() const widgetRoot = ref() +const previousValue = ref() + const editing = WidgetEditHandler.New('WidgetText', props.input, { + start() { + previousValue.value = textContents.value + }, cancel() { editedContents.value = textContents.value input.value?.blur() @@ -34,8 +39,11 @@ const editing = WidgetEditHandler.New('WidgetText', props.input, { function accepted() { editing.end() if (props.input.value instanceof Ast.TextLiteral) { + if (previousValue.value === editedContents.value) return const edit = graph.startEdit() - edit.getVersion(props.input.value).setRawTextContent(editedContents.value) + const value = edit.getVersion(props.input.value) + if (value.rawTextContent === editedContents.value) return + value.setRawTextContent(editedContents.value) props.onUpdate({ edit }) } else { let value: Ast.Owned From ea2d4e5cd0f1c844b5ffbddac6440e54af11ea99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Wa=C5=9Bko?= Date: Mon, 30 Dec 2024 22:19:52 +0100 Subject: [PATCH 02/12] Fix type signature for Integer.parse to get correct widget (#11954) --- distribution/lib/Standard/Base/0.0.0-dev/src/Data/Numbers.enso | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Numbers.enso b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Numbers.enso index 042662fe7b1d..100c1dab3081 100644 --- a/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Numbers.enso +++ b/distribution/lib/Standard/Base/0.0.0-dev/src/Data/Numbers.enso @@ -1287,7 +1287,7 @@ type Integer Parse the text "20220216" into an integer number. Integer.parse "20220216" - parse text:Text (radix=10:Integer) -> Integer ! Number_Parse_Error = integer_parse text radix + parse text:Text (radix:Integer = 10) -> Integer ! Number_Parse_Error = integer_parse text radix ## PRIVATE fits_in_long self -> Boolean = From bc6a8aa2f9b44bdc266b4d0a449a135fa121a6a1 Mon Sep 17 00:00:00 2001 From: Dmitry Bushev Date: Wed, 1 Jan 2025 15:57:01 +0300 Subject: [PATCH 03/12] Register type definitions lazily (#11938) close #10923 Changelog: - update: `registerTypeDefinitions` registers constructors lazily # Important Notes The improvement of time spent in the `registerTypeDefinitions` method (highlighted): #### before ![2024-12-23-192336_1193x245_scrot](https://github.com/user-attachments/assets/0bfd6784-61e6-4d8a-8e47-1972f58bb05c) #### after ![2024-12-23-192401_1197x253_scrot](https://github.com/user-attachments/assets/2fff5b28-f2fe-44c7-b92d-40a11a9f216d) --- .../enso/interpreter/runtime/data/Type.java | 35 ++-- .../runtime/data/atom/AtomConstructor.java | 195 ++++++++++++++---- .../runtime/util/CachingSupplier.java | 12 ++ .../interpreter/runtime/IrToTruffle.scala | 188 +++++++++-------- 4 files changed, 292 insertions(+), 138 deletions(-) diff --git a/engine/runtime/src/main/java/org/enso/interpreter/runtime/data/Type.java b/engine/runtime/src/main/java/org/enso/interpreter/runtime/data/Type.java index 754218d22988..76740d14916d 100644 --- a/engine/runtime/src/main/java/org/enso/interpreter/runtime/data/Type.java +++ b/engine/runtime/src/main/java/org/enso/interpreter/runtime/data/Type.java @@ -26,6 +26,7 @@ import org.enso.interpreter.runtime.data.vector.ArrayLikeHelpers; import org.enso.interpreter.runtime.library.dispatch.TypesLibrary; import org.enso.interpreter.runtime.scope.ModuleScope; +import org.enso.interpreter.runtime.util.CachingSupplier; import org.enso.pkg.QualifiedName; @ExportLibrary(TypesLibrary.class) @@ -217,21 +218,25 @@ public void generateGetters(EnsoLanguage language) { var roots = AtomConstructor.collectFieldAccessors(language, this); roots.forEach( (name, node) -> { - var schemaBldr = - FunctionSchema.newBuilder() - .argumentDefinitions( - new ArgumentDefinition( - 0, - Constants.Names.SELF_ARGUMENT, - null, - null, - ArgumentDefinition.ExecutionMode.EXECUTE)); - if (isProjectPrivate) { - schemaBldr.projectPrivate(); - } - var funcSchema = schemaBldr.build(); - var f = new Function(node.getCallTarget(), null, funcSchema); - definitionScope.registerMethod(this, name, f); + var functionSupplier = + CachingSupplier.wrap( + () -> { + var schemaBldr = + FunctionSchema.newBuilder() + .argumentDefinitions( + new ArgumentDefinition( + 0, + Constants.Names.SELF_ARGUMENT, + null, + null, + ArgumentDefinition.ExecutionMode.EXECUTE)); + if (isProjectPrivate) { + schemaBldr.projectPrivate(); + } + var funcSchema = schemaBldr.build(); + return new Function(node.getCallTarget(), null, funcSchema); + }); + definitionScope.registerMethod(this, name, functionSupplier); }); } diff --git a/engine/runtime/src/main/java/org/enso/interpreter/runtime/data/atom/AtomConstructor.java b/engine/runtime/src/main/java/org/enso/interpreter/runtime/data/atom/AtomConstructor.java index c14a9ff7d995..db9f212e5dfd 100644 --- a/engine/runtime/src/main/java/org/enso/interpreter/runtime/data/atom/AtomConstructor.java +++ b/engine/runtime/src/main/java/org/enso/interpreter/runtime/data/atom/AtomConstructor.java @@ -17,6 +17,7 @@ import java.util.TreeMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; import org.enso.compiler.context.LocalScope; import org.enso.interpreter.EnsoLanguage; import org.enso.interpreter.node.ExpressionNode; @@ -33,6 +34,7 @@ import org.enso.interpreter.runtime.data.Type; import org.enso.interpreter.runtime.library.dispatch.TypesLibrary; import org.enso.interpreter.runtime.scope.ModuleScope; +import org.enso.interpreter.runtime.util.CachingSupplier; import org.enso.pkg.QualifiedName; /** @@ -47,10 +49,13 @@ public final class AtomConstructor extends EnsoObject { private final Module definitionModule; private final boolean builtin; private @CompilerDirectives.CompilationFinal Atom cachedInstance; + private @CompilerDirectives.CompilationFinal(dimensions = 1) String[] fieldNames; + private @CompilerDirectives.CompilationFinal Supplier constructorFunctionSupplier; private @CompilerDirectives.CompilationFinal Function constructorFunction; private @CompilerDirectives.CompilationFinal Function accessor; private final Lock layoutsLock = new ReentrantLock(); + private @CompilerDirectives.CompilationFinal Supplier boxedLayoutSupplier; private @CompilerDirectives.CompilationFinal Layout boxedLayout; private Layout[] unboxingLayouts = new Layout[0]; @@ -90,13 +95,102 @@ public AtomConstructor(String name, Module definitionModule, Type type, boolean * @return {@code true} if {@link #initializeFields} method has already been called */ public boolean isInitialized() { - return constructorFunction != null; + return accessor != null; } boolean isBuiltin() { return builtin; } + /** + * Create new builder required for initialization of the atom constructor. + * + * @param section the source section + * @param localScope the description of the local scope + * @param assignments the expressions that evaluate and assign constructor arguments to local vars + * @param varReads the expressions that read field values from local vars + * @param annotations the list of attached annotations + * @param args the list of argument definitions + */ + public static InitializationBuilder newInitializationBuilder( + SourceSection section, + LocalScope localScope, + ExpressionNode[] assignments, + ExpressionNode[] varReads, + Annotation[] annotations, + ArgumentDefinition[] args) { + return new InitializationBuilder(section, localScope, assignments, varReads, annotations, args); + } + + /** Builder required for initialization of the atom constructor. */ + public static final class InitializationBuilder { + + private final SourceSection section; + private final LocalScope localScope; + private final ExpressionNode[] assignments; + private final ExpressionNode[] varReads; + private final Annotation[] annotations; + private final ArgumentDefinition[] args; + + /** + * Create new builder required for initialization of the atom constructor. + * + * @param section the source section + * @param localScope the description of the local scope + * @param assignments the expressions that evaluate and assign constructor arguments to local + * vars + * @param varReads the expressions that read field values from local vars + * @param annotations the list of attached annotations + * @param args the list of argument definitions + */ + InitializationBuilder( + SourceSection section, + LocalScope localScope, + ExpressionNode[] assignments, + ExpressionNode[] varReads, + Annotation[] annotations, + ArgumentDefinition[] args) { + this.section = section; + this.localScope = localScope; + this.assignments = assignments; + this.varReads = varReads; + this.annotations = annotations; + this.args = args; + } + + private SourceSection getSection() { + return section; + } + + private LocalScope getLocalScope() { + return localScope; + } + + private ExpressionNode[] getAssignments() { + return assignments; + } + + private ExpressionNode[] getVarReads() { + return varReads; + } + + private Annotation[] getAnnotations() { + return annotations; + } + + private ArgumentDefinition[] getArgs() { + return args; + } + } + + /** + * The result of this atom constructor initialization. + * + * @param constructorFunction the atom constructor function + * @param layout the atom layout + */ + private record InitializationResult(Function constructorFunction, Layout layout) {} + /** * Generates a constructor function for this {@link AtomConstructor}. Note that such manually * constructed argument definitions must not have default arguments. @@ -106,49 +200,63 @@ boolean isBuiltin() { public AtomConstructor initializeFields( EnsoLanguage language, ModuleScope.Builder scopeBuilder, ArgumentDefinition... args) { ExpressionNode[] reads = new ExpressionNode[args.length]; + String[] fieldNames = new String[args.length]; for (int i = 0; i < args.length; i++) { reads[i] = ReadArgumentNode.build(i, null); + fieldNames[i] = args[i].getName(); } - return initializeFields( - language, - null, - LocalScope.empty(), - scopeBuilder, - new ExpressionNode[0], - reads, - new Annotation[0], - args); + + var builder = + newInitializationBuilder( + null, LocalScope.empty(), new ExpressionNode[0], reads, new Annotation[0], args); + return initializeFields(language, scopeBuilder, CachingSupplier.forValue(builder), fieldNames); } /** * Sets the fields of this {@link AtomConstructor} and generates a constructor function. * - * @param localScope a description of the local scope - * @param assignments the expressions that evaluate and assign constructor arguments to local vars + * @param language the language implementation * @param scopeBuilder the module scope's builder where the accessor should be registered at - * @param varReads the expressions that read field values from local vars + * @param initializationBuilderSupplier the function supplying the parts required for + * initialization + * @param fieldNames the argument names * @return {@code this}, for convenience */ public AtomConstructor initializeFields( EnsoLanguage language, - SourceSection section, - LocalScope localScope, ModuleScope.Builder scopeBuilder, - ExpressionNode[] assignments, - ExpressionNode[] varReads, - Annotation[] annotations, - ArgumentDefinition... args) { + Supplier initializationBuilderSupplier, + String[] fieldNames) { CompilerDirectives.transferToInterpreterAndInvalidate(); - assert boxedLayout == null : "Don't initialize twice: " + this.name; - if (args.length == 0) { + assert accessor == null : "Don't initialize twice: " + this.name; + this.fieldNames = fieldNames; + if (fieldNames.length == 0) { cachedInstance = BoxingAtom.singleton(this); } else { cachedInstance = null; } - boxedLayout = Layout.createBoxed(args); - this.constructorFunction = - buildConstructorFunction( - language, section, localScope, scopeBuilder, assignments, varReads, annotations, args); + CachingSupplier initializationResultSupplier = + CachingSupplier.wrap( + () -> { + var builder = initializationBuilderSupplier.get(); + var constructorFunction = + buildConstructorFunction( + language, + builder.getSection(), + builder.getLocalScope(), + scopeBuilder, + builder.getAssignments(), + builder.getVarReads(), + builder.getAnnotations(), + builder.getArgs()); + var layout = Layout.createBoxed(builder.getArgs()); + return new InitializationResult(constructorFunction, layout); + }); + this.boxedLayoutSupplier = + initializationResultSupplier.map(initializationResult -> initializationResult.layout); + this.constructorFunctionSupplier = + initializationResultSupplier.map( + initializationResult -> initializationResult.constructorFunction); this.accessor = generateQualifiedAccessor(language, scopeBuilder); return this; } @@ -244,7 +352,7 @@ public ModuleScope getDefinitionScope() { * @return the number of args expected by the constructor. */ public int getArity() { - return constructorFunction.getSchema().getArgumentsCount(); + return fieldNames.length; } /** @@ -279,6 +387,10 @@ public String toString() { * @return the constructor function of this constructor. */ public Function getConstructorFunction() { + if (constructorFunction == null) { + CompilerDirectives.transferToInterpreterAndInvalidate(); + constructorFunction = constructorFunctionSupplier.get(); + } return constructorFunction; } @@ -323,9 +435,10 @@ public static Map collectFieldAccessors(EnsoLanguage language, // take just the first one. var moduleScope = constructors.iterator().next().getDefinitionScope(); for (var cons : constructors) { - for (var field : cons.getFields()) { - var items = names.computeIfAbsent(field.getName(), (k) -> new ArrayList<>()); - items.add(new GetFieldWithMatchNode.GetterPair(cons, field.getPosition())); + final var fieldNames = cons.getFieldNames(); + for (var i = 0; i < fieldNames.length; i++) { + var items = names.computeIfAbsent(fieldNames[i], (k) -> new ArrayList<>()); + items.add(new GetFieldWithMatchNode.GetterPair(cons, i)); } } for (var entry : names.entrySet()) { @@ -342,11 +455,10 @@ public static Map collectFieldAccessors(EnsoLanguage language, } } else if (constructors.size() == 1) { var cons = constructors.toArray(AtomConstructor[]::new)[0]; - for (var field : cons.getFields()) { - var node = - new GetFieldNode( - language, field.getPosition(), type, field.getName(), cons.getDefinitionScope()); - roots.put(field.getName(), node); + final var fieldNames = cons.getFieldNames(); + for (var i = 0; i < fieldNames.length; i++) { + var node = new GetFieldNode(language, i, type, fieldNames[i], cons.getDefinitionScope()); + roots.put(fieldNames[i], node); } } return roots; @@ -357,6 +469,10 @@ final Layout[] getUnboxingLayouts() { } final Layout getBoxedLayout() { + if (boxedLayout == null) { + CompilerDirectives.transferToInterpreterAndInvalidate(); + boxedLayout = boxedLayoutSupplier.get(); + } return boxedLayout; } @@ -448,7 +564,16 @@ public QualifiedName getQualifiedTypeName() { * @return the fields defined by this constructor. */ public ArgumentDefinition[] getFields() { - return constructorFunction.getSchema().getArgumentInfos(); + return getConstructorFunction().getSchema().getArgumentInfos(); + } + + /** + * Names of this constructor fields. + * + * @return the field names defined by this constructor. + */ + public String[] getFieldNames() { + return fieldNames; } /** diff --git a/engine/runtime/src/main/java/org/enso/interpreter/runtime/util/CachingSupplier.java b/engine/runtime/src/main/java/org/enso/interpreter/runtime/util/CachingSupplier.java index 4f5f17d287c6..feaa10d4de63 100644 --- a/engine/runtime/src/main/java/org/enso/interpreter/runtime/util/CachingSupplier.java +++ b/engine/runtime/src/main/java/org/enso/interpreter/runtime/util/CachingSupplier.java @@ -1,6 +1,7 @@ package org.enso.interpreter.runtime.util; import com.oracle.truffle.api.CompilerDirectives; +import java.util.function.Function; import java.util.function.Supplier; public final class CachingSupplier implements Supplier { @@ -55,6 +56,17 @@ public T get() { } } + /** + * Transform the result of this supplier by applying a mapping function {@code f}. + * + * @param f the mapping function + * @return the supplier providing the value with the mapping function applied + * @param the result type + */ + public CachingSupplier map(Function f) { + return wrap(() -> f.apply(get())); + } + /** * Returns a supplier that always returns {@code null} when its {@link Supplier#get()} method is * called. diff --git a/engine/runtime/src/main/scala/org/enso/interpreter/runtime/IrToTruffle.scala b/engine/runtime/src/main/scala/org/enso/interpreter/runtime/IrToTruffle.scala index c77edf10c495..de8730c67113 100644 --- a/engine/runtime/src/main/scala/org/enso/interpreter/runtime/IrToTruffle.scala +++ b/engine/runtime/src/main/scala/org/enso/interpreter/runtime/IrToTruffle.scala @@ -1,6 +1,5 @@ package org.enso.interpreter.runtime -import java.util.logging.Level import com.oracle.truffle.api.source.{Source, SourceSection} import com.oracle.truffle.api.interop.InteropLibrary import org.enso.compiler.pass.analyse.FramePointer @@ -107,6 +106,9 @@ import org.enso.interpreter.runtime.scope.{ImportExportScope, ModuleScope} import org.enso.interpreter.{Constants, EnsoLanguage} import java.math.BigInteger +import java.util.function.Supplier +import java.util.logging.Level + import scala.annotation.tailrec import scala.collection.mutable import scala.collection.mutable.ArrayBuffer @@ -265,101 +267,111 @@ class IrToTruffle( atomCons: AtomConstructor, atomDefn: Definition.Data ): Unit = { - val scopeInfo = rootScopeInfo("atom definition", atomDefn) - - def dataflowInfo() = atomDefn.unsafeGetMetadata( - DataflowAnalysis, - "No dataflow information associated with an atom." - ) - def frameInfo() = atomDefn.unsafeGetMetadata( - FramePointerAnalysis, - "Method definition missing frame information." - ) - val localScope = new LocalScope( - None, - () => scopeInfo().graph, - () => scopeInfo().graph.rootScope, - dataflowInfo, - frameInfo - ) - - val argFactory = - new DefinitionArgumentProcessor( - scope = localScope, - initialName = "Type " + tpDef.name.name - ) - val argDefs = - new Array[ArgumentDefinition](atomDefn.arguments.size) - val argumentExpressions = - new ArrayBuffer[(RuntimeExpression, RuntimeExpression)] - - for (idx <- atomDefn.arguments.indices) { - val unprocessedArg = atomDefn.arguments(idx) - val checkNode = checkAsTypes(unprocessedArg) - val arg = argFactory.run(unprocessedArg, idx, checkNode) - val fp = unprocessedArg - .unsafeGetMetadata( + val initializationBuilderSupplier + : Supplier[AtomConstructor.InitializationBuilder] = + () => { + val scopeInfo = rootScopeInfo("atom definition", atomDefn) + + def dataflowInfo() = atomDefn.unsafeGetMetadata( + DataflowAnalysis, + "No dataflow information associated with an atom." + ) + def frameInfo() = atomDefn.unsafeGetMetadata( FramePointerAnalysis, - "No frame pointer on an argument definition." + "Method definition missing frame information." ) - .asInstanceOf[FramePointer] - val slotIdx = fp.frameSlotIdx() - argDefs(idx) = arg - val readArgNoCheck = - ReadArgumentNode.build( - idx, - arg.getDefaultValue.orElse(null) + val localScope = new LocalScope( + None, + () => scopeInfo().graph, + () => scopeInfo().graph.rootScope, + dataflowInfo, + frameInfo ) - val readArg = TypeCheckValueNode.wrap(readArgNoCheck, checkNode) - val assignmentArg = AssignmentNode.build(readArg, slotIdx) - val argRead = - ReadLocalVariableNode.build(new FramePointer(0, slotIdx)) - argumentExpressions.append((assignmentArg, argRead)) - } - val (assignments, reads) = argumentExpressions.unzip - // build annotations - val annotations = atomDefn.annotations.map { annotation => - val scopeElements = Seq( - tpDef.name.name, - atomDefn.name.name, - annotation.name - ) - val scopeName = - scopeElements.mkString(Constants.SCOPE_SEPARATOR) - val expressionProcessor = new ExpressionProcessor( - scopeName, - () => scopeInfo().graph, - () => scopeInfo().graph.rootScope, - dataflowInfo, - atomDefn.name.name, - frameInfo - ) - val expressionNode = - expressionProcessor.run(annotation.expression, true) - val closureName = s"" - val closureRootNode = ClosureRootNode.build( - language, - expressionProcessor.scope, - scopeBuilder.asModuleScope(), - expressionNode, - makeSection(scopeBuilder.getModule, annotation.location), - closureName, - true, - false - ) - new RuntimeAnnotation(annotation.name, closureRootNode) - } + val argFactory = + new DefinitionArgumentProcessor( + scope = localScope, + initialName = "Type " + tpDef.name.name + ) + val argDefs = + new Array[ArgumentDefinition](atomDefn.arguments.size) + val argumentExpressions = + new ArrayBuffer[(RuntimeExpression, RuntimeExpression)] + + for (idx <- atomDefn.arguments.indices) { + val unprocessedArg = atomDefn.arguments(idx) + val checkNode = checkAsTypes(unprocessedArg) + val arg = argFactory.run(unprocessedArg, idx, checkNode) + val fp = unprocessedArg + .unsafeGetMetadata( + FramePointerAnalysis, + "No frame pointer on an argument definition." + ) + .asInstanceOf[FramePointer] + val slotIdx = fp.frameSlotIdx() + argDefs(idx) = arg + val readArgNoCheck = + ReadArgumentNode.build( + idx, + arg.getDefaultValue.orElse(null) + ) + val readArg = TypeCheckValueNode.wrap(readArgNoCheck, checkNode) + val assignmentArg = AssignmentNode.build(readArg, slotIdx) + val argRead = + ReadLocalVariableNode.build(new FramePointer(0, slotIdx)) + argumentExpressions.append((assignmentArg, argRead)) + } + + val (assignments, reads) = argumentExpressions.unzip + // build annotations + val annotations = atomDefn.annotations.map { annotation => + val scopeElements = Seq( + tpDef.name.name, + atomDefn.name.name, + annotation.name + ) + val scopeName = + scopeElements.mkString(Constants.SCOPE_SEPARATOR) + val expressionProcessor = new ExpressionProcessor( + scopeName, + () => scopeInfo().graph, + () => scopeInfo().graph.rootScope, + dataflowInfo, + atomDefn.name.name, + frameInfo + ) + val expressionNode = + expressionProcessor.run(annotation.expression, true) + val closureName = s"" + val closureRootNode = ClosureRootNode.build( + language, + expressionProcessor.scope, + scopeBuilder.asModuleScope(), + expressionNode, + makeSection(scopeBuilder.getModule, annotation.location), + closureName, + true, + false + ) + new RuntimeAnnotation(annotation.name, closureRootNode) + } + + AtomConstructor.newInitializationBuilder( + makeSection(scopeBuilder.getModule, atomDefn.location), + localScope, + assignments.toArray, + reads.toArray, + annotations.toArray, + argDefs + ) + } if (!atomCons.isInitialized) { + val fieldNames = atomDefn.arguments.map(_.name.name).toArray atomCons.initializeFields( language, - makeSection(scopeBuilder.getModule, atomDefn.location), - localScope, scopeBuilder, - assignments.toArray, - reads.toArray, - annotations.toArray, - argDefs: _* + initializationBuilderSupplier, + fieldNames ) } } From 1f763f7800c747a734074687a74671ac921a5d1a Mon Sep 17 00:00:00 2001 From: Hubert Plociniczak Date: Thu, 2 Jan 2025 09:43:21 +0100 Subject: [PATCH 04/12] Disable visualizations for subexpressions (#11949) The possibility of attaching visualizations to subexpressions meant that we (currently) have to invalidate caches for their parent expression every time a request comes. That was an acceptable cost when an expression is relatively fast to compute but unacceptable when dealing with slow computations that would have to be repeated. Since currently attaching visualizations is not used at all and we can rely on caching RHS and self, it is _safe_ to disable it. An observable pattern is better suited for visualizations and would mitigate this problem by design, something that we planned for a while in #10525 Should help with long running visualizations in #11882. Partial visualization results should be addressed on GUI side. # Important Notes For the example in #11882 we would at least re-read the large file at least twice, which adds 40-60seconds to the overall startup. Exchanges before the change: ![Screenshot from 2024-12-27 16-52-46](https://github.com/user-attachments/assets/63e7a6db-73f8-48dd-a24a-a44e197e4ee6) Responses after the change: ![Screenshot from 2024-12-30 12-18-28](https://github.com/user-attachments/assets/08020b1c-58f0-4c0f-b06d-1d904373b946) Results in the same (final) data: ![Screenshot from 2024-12-30 12-24-02](https://github.com/user-attachments/assets/280f6ef5-6691-4744-b67a-2a0898f55b8b) --- .../instrument/execution/JobExecutionEngine.scala | 4 +++- .../enso/interpreter/instrument/job/EnsureCompiledJob.scala | 2 ++ .../interpreter/instrument/job/ProgramExecutionSupport.scala | 3 ++- .../interpreter/instrument/job/UpsertVisualizationJob.scala | 5 +++-- .../test/instrument/RuntimeVisualizationsTest.scala | 3 ++- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/JobExecutionEngine.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/JobExecutionEngine.scala index d5b528a11ee8..a375e5fc62f3 100644 --- a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/JobExecutionEngine.scala +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/execution/JobExecutionEngine.scala @@ -96,6 +96,7 @@ final class JobExecutionEngine( assertInJvm(timeSinceRequestedToCancel > 0) val timeToCancel = forceInterruptTimeout - timeSinceRequestedToCancel + assertInJvm(timeToCancel > 0) logger.log( Level.FINEST, "About to wait {}ms to cancel job {}", @@ -107,7 +108,8 @@ final class JobExecutionEngine( runningJob.future.get(timeToCancel, TimeUnit.MILLISECONDS) logger.log( Level.FINEST, - "Job {} finished within the allocated soft-cancel time" + "Job {} finished within the allocated soft-cancel time", + runningJob.id ) } catch { case _: TimeoutException => diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/EnsureCompiledJob.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/EnsureCompiledJob.scala index 7cbcb769e856..ad3486452306 100644 --- a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/EnsureCompiledJob.scala +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/EnsureCompiledJob.scala @@ -484,6 +484,8 @@ class EnsureCompiledJob( ) invalidatedVisualizations.foreach { visualization => UpsertVisualizationJob.upsertVisualization(visualization) + // Cache invalidation disabled because of #11882 + // ctx.state.executionHooks.add(InvalidateCaches(visualization.expressionId)) } if (invalidatedVisualizations.nonEmpty) { ctx.executionService.getLogger.log( diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/ProgramExecutionSupport.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/ProgramExecutionSupport.scala index 53d89c8eaa7d..a59b6ebae530 100644 --- a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/ProgramExecutionSupport.scala +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/ProgramExecutionSupport.scala @@ -94,9 +94,9 @@ object ProgramExecutionSupport { val onComputedValueCallback: Consumer[ExpressionValue] = { value => if (callStack.isEmpty) { - logger.log(Level.FINEST, s"ON_COMPUTED ${value.getExpressionId}") if (VisualizationResult.isInterruptedException(value.getValue)) { + logger.log(Level.FINEST, s"ON_INTERRUPTED ${value.getExpressionId}") value.getValue match { case e: AbstractTruffleException => sendInterruptedExpressionUpdate( @@ -110,6 +110,7 @@ object ProgramExecutionSupport { case _ => } } + logger.log(Level.FINEST, s"ON_COMPUTED ${value.getExpressionId}") sendExpressionUpdate(contextId, executionFrame.syncState, value) sendVisualizationUpdates( contextId, diff --git a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/UpsertVisualizationJob.scala b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/UpsertVisualizationJob.scala index 00045611a94d..e8da273a04b3 100644 --- a/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/UpsertVisualizationJob.scala +++ b/engine/runtime-instrument-common/src/main/scala/org/enso/interpreter/instrument/job/UpsertVisualizationJob.scala @@ -121,6 +121,8 @@ class UpsertVisualizationJob( ) None case None => + // Caching disabled due to #11882 + // ctx.state.executionHooks.add(InvalidateCaches(expressionId)) Some(Executable(config.executionContextId, stack)) } } @@ -160,7 +162,7 @@ class UpsertVisualizationJob( object UpsertVisualizationJob { /** Invalidate caches for a particular expression id. */ - sealed private case class InvalidateCaches( + sealed case class InvalidateCaches( expressionId: Api.ExpressionId )(implicit ctx: RuntimeContext) extends Runnable { @@ -511,7 +513,6 @@ object UpsertVisualizationJob { arguments ) setCacheWeights(visualization) - ctx.state.executionHooks.add(InvalidateCaches(expressionId)) ctx.contextManager.upsertVisualization( visualizationConfig.executionContextId, visualization diff --git a/engine/runtime-integration-tests/src/test/scala/org/enso/interpreter/test/instrument/RuntimeVisualizationsTest.scala b/engine/runtime-integration-tests/src/test/scala/org/enso/interpreter/test/instrument/RuntimeVisualizationsTest.scala index 1e8db46f8890..c164a536f09b 100644 --- a/engine/runtime-integration-tests/src/test/scala/org/enso/interpreter/test/instrument/RuntimeVisualizationsTest.scala +++ b/engine/runtime-integration-tests/src/test/scala/org/enso/interpreter/test/instrument/RuntimeVisualizationsTest.scala @@ -4419,7 +4419,8 @@ class RuntimeVisualizationsTest extends AnyFlatSpec with Matchers { new String(data1, StandardCharsets.UTF_8) shouldEqual "C" } - it should "emit visualization update for the target of a method call (subexpression) with IdMap" in withContext() { + // Attaching visualizations to subexpressions is currently disabled, see #11882 + ignore should "emit visualization update for the target of a method call (subexpression) with IdMap" in withContext() { context => val contextId = UUID.randomUUID() val requestId = UUID.randomUUID() From a1e9b3eea71488e47cce2872f81ef9a4bab5dd9c Mon Sep 17 00:00:00 2001 From: Adam Obuchowicz Date: Thu, 2 Jan 2025 12:16:34 +0100 Subject: [PATCH 05/12] Fix node being selected after deleting node or connection (#11902) Fixes #11895 --- CHANGELOG.md | 10 ++++++---- .../GraphEditor/ComponentWidgetTree.vue | 4 +++- .../components/GraphEditor/GraphEdges.vue | 2 +- .../GraphEditor/widgets/WidgetCheckbox.vue | 3 ++- .../widgets/WidgetCloudBrowser.vue | 1 + .../GraphEditor/widgets/WidgetFileBrowser.vue | 1 + .../GraphEditor/widgets/WidgetFunction.vue | 12 ++++++++--- .../GraphEditor/widgets/WidgetNumber.vue | 1 + .../GraphEditor/widgets/WidgetSelection.vue | 20 +++++++++++++++---- .../GraphEditor/widgets/WidgetTableEditor.vue | 1 + .../WidgetTableEditor/tableInputArgument.ts | 16 +++++++-------- .../GraphEditor/widgets/WidgetText.vue | 3 ++- .../GraphEditor/widgets/WidgetVector.vue | 1 + .../project-view/providers/widgetRegistry.ts | 5 +++++ .../src/project-view/stores/graph/index.ts | 11 ++++++++-- 15 files changed, 66 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eedf4f283812..17a6da35566a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Next Next Release +# Next Release #### Enso IDE @@ -6,9 +6,13 @@ GeoMap visualization][11889]. - [Round ‘Add component’ button under the component menu replaced by a small button protruding from the output port.][11836]. +- [Fixed nodes being selected after deleting other nodes or connections.][11902] +- [Redo stack is no longer lost when interacting with text literals][11908]. [11889]: https://github.com/enso-org/enso/pull/11889 [11836]: https://github.com/enso-org/enso/pull/11836 +[11902]: https://github.com/enso-org/enso/pull/11902 +[11908]: https://github.com/enso-org/enso/pull/11908 #### Enso Language & Runtime @@ -17,16 +21,14 @@ - A constructor or type definition with a single inline argument definition was previously allowed to use spaces in the argument definition without parentheses. [This is now a syntax error.][11856] -- [Redo stack is no longer lost when interacting with text literals][11908]. - Symetric, transitive and reflexive [equality for intersection types][11897] [11777]: https://github.com/enso-org/enso/pull/11777 [11600]: https://github.com/enso-org/enso/pull/11600 [11856]: https://github.com/enso-org/enso/pull/11856 -[11908]: https://github.com/enso-org/enso/pull/11908 [11897]: https://github.com/enso-org/enso/pull/11897 -# Next Release +# Enso 2024.4 #### Enso IDE diff --git a/app/gui/src/project-view/components/GraphEditor/ComponentWidgetTree.vue b/app/gui/src/project-view/components/GraphEditor/ComponentWidgetTree.vue index c29bf0eb7d65..bd8ca327a577 100644 --- a/app/gui/src/project-view/components/GraphEditor/ComponentWidgetTree.vue +++ b/app/gui/src/project-view/components/GraphEditor/ComponentWidgetTree.vue @@ -45,7 +45,9 @@ function selectNode() { } function handleWidgetUpdates(update: WidgetUpdate) { - selectNode() + if (update.directInteraction) { + selectNode() + } const edit = update.edit ?? graph.startEdit() if (update.portUpdate) { const { origin } = update.portUpdate diff --git a/app/gui/src/project-view/components/GraphEditor/GraphEdges.vue b/app/gui/src/project-view/components/GraphEditor/GraphEdges.vue index 389a66bee9ce..05b1e6d8969b 100644 --- a/app/gui/src/project-view/components/GraphEditor/GraphEdges.vue +++ b/app/gui/src/project-view/components/GraphEditor/GraphEdges.vue @@ -74,7 +74,7 @@ interaction.setWhen(() => graph.mouseEditedEdge != null, editingEdge) function disconnectEdge(target: PortId) { graph.edit((edit) => { - if (!graph.updatePortValue(edit, target, undefined)) { + if (!graph.updatePortValue(edit, target, undefined, false)) { if (isAstId(target)) { console.warn(`Failed to disconnect edge from port ${target}, falling back to direct edit.`) edit.replaceValue(target, Ast.Wildcard.new(edit)) diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetCheckbox.vue b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetCheckbox.vue index b0231f95b1d0..22c56841c7cf 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetCheckbox.vue +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetCheckbox.vue @@ -43,7 +43,7 @@ const value = computed({ value ? ('True' as Identifier) : ('False' as Identifier), ) if (requiresImport) graph.addMissingImports(edit, theImport) - props.onUpdate({ edit }) + props.onUpdate({ edit, directInteraction: true }) } else { graph.addMissingImports(edit, theImport) props.onUpdate({ @@ -52,6 +52,7 @@ const value = computed({ value: value ? 'True' : 'False', origin: props.input.portId, }, + directInteraction: true, }) } }, diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetCloudBrowser.vue b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetCloudBrowser.vue index 0631f6a68b39..f9c31d25378a 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetCloudBrowser.vue +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetCloudBrowser.vue @@ -20,6 +20,7 @@ const item: CustomDropdownItem = { onPathSelected: (path: string) => { props.onUpdate({ portUpdate: { value: Ast.TextLiteral.new(path), origin: props.input.portId }, + directInteraction: true, }) close() }, diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFileBrowser.vue b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFileBrowser.vue index fabd686032e6..854bf534ce50 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFileBrowser.vue +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFileBrowser.vue @@ -106,6 +106,7 @@ const onClick = async () => { value, origin: props.input.portId, }, + directInteraction: true, }) } } diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFunction.vue b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFunction.vue index 0c1d8d2fd3d8..90b241ceda64 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFunction.vue +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFunction.vue @@ -80,7 +80,11 @@ function handleArgUpdate(update: WidgetUpdate): boolean { console.error('Tried to set metadata on arg placeholder. This is not implemented yet!') return false } - const { value, origin } = update.portUpdate + const { + portUpdate: { value, origin }, + directInteraction, + } = update + const edit = update.edit ?? graph.startEdit() // Find the updated argument by matching origin port/expression with the appropriate argument. // We are interested only in updates at the top level of the argument AST. Updates from nested @@ -107,7 +111,7 @@ function handleArgUpdate(update: WidgetUpdate): boolean { edit .getVersion(argApp.appTree) .updateValue((oldAppTree) => Ast.App.new(edit, oldAppTree, name, newArg)) - props.onUpdate({ edit }) + props.onUpdate({ edit, directInteraction }) return true } else if (value == null && argApp?.argument instanceof ArgumentAst) { /* Case: Removing existing argument. */ @@ -148,6 +152,7 @@ function handleArgUpdate(update: WidgetUpdate): boolean { value: func, origin: argApp.appTree.id, }, + directInteraction, }) return true } else if (argApp.appTree instanceof Ast.OprApp) { @@ -162,6 +167,7 @@ function handleArgUpdate(update: WidgetUpdate): boolean { value: lhs, origin: argApp.appTree.id, }, + directInteraction, }) } return true @@ -184,7 +190,7 @@ function handleArgUpdate(update: WidgetUpdate): boolean { } else { appTree.update((appTree) => appTree.function.take()) } - props.onUpdate({ edit }) + props.onUpdate({ edit, directInteraction }) return true } else { // Process an argument to the right of the removed argument. diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetNumber.vue b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetNumber.vue index 4ac31aa8f426..d145b0edc338 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetNumber.vue +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetNumber.vue @@ -13,6 +13,7 @@ const inputComponent = ref>() function setValue(value: string | undefined) { props.onUpdate({ portUpdate: { value, origin: props.input.portId }, + directInteraction: true, }) } diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetSelection.vue b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetSelection.vue index 628a78f541b5..a26f03ea769f 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetSelection.vue +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetSelection.vue @@ -289,7 +289,10 @@ const dropDownInteraction = WidgetEditHandler.New('WidgetSelection', props.input ) { dropDownInteraction.end() if (editedWidget.value) - props.onUpdate({ portUpdate: { origin: props.input.portId, value: editedValue.value } }) + props.onUpdate({ + portUpdate: { origin: props.input.portId, value: editedValue.value }, + directInteraction: false, + }) } else if (isMulti.value) { // In multi-select mode the children contain actual values; when a dropdown click occurs, // we allow the event to propagate so the child widget can commit before the dropdown-toggle occurs. @@ -372,22 +375,31 @@ function toggleVectorValue(vector: Ast.MutableVector, value: string, previousSta function expressionTagClicked(tag: ExpressionTag, previousState: boolean) { const edit = graph.startEdit() + const directInteraction = true const tagValue = resolveTagExpression(edit, tag) if (isMulti.value) { const inputValue = editedValue.value ?? props.input.value if (inputValue instanceof Ast.Vector) { toggleVectorValue(edit.getVersion(inputValue), tagValue, previousState) - props.onUpdate({ edit }) + props.onUpdate({ edit, directInteraction }) } else { const vector = Ast.Vector.new( edit, inputValue instanceof Ast.Ast ? [edit.take(inputValue.id)] : [], ) toggleVectorValue(vector, tagValue, previousState) - props.onUpdate({ edit, portUpdate: { value: vector, origin: props.input.portId } }) + props.onUpdate({ + edit, + portUpdate: { value: vector, origin: props.input.portId }, + directInteraction, + }) } } else { - props.onUpdate({ edit, portUpdate: { value: tagValue, origin: props.input.portId } }) + props.onUpdate({ + edit, + portUpdate: { value: tagValue, origin: props.input.portId }, + directInteraction, + }) } } diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor.vue b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor.vue index c9b9dbecbd85..25715158ed8a 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor.vue +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor.vue @@ -166,6 +166,7 @@ const clientBounds = computed({ metadataKey: 'WidgetTableEditor', metadata: { size: { x: value.width / graphNav.scale, y: value.height / graphNav.scale } }, }, + directInteraction: false, }) }, }) diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/tableInputArgument.ts b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/tableInputArgument.ts index 37f92833fc7f..c7be71d8c4b9 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/tableInputArgument.ts +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/tableInputArgument.ts @@ -263,7 +263,7 @@ export function useTableInputArgument( const edit = graph.startEdit() fixColumns(edit) removeRow(edit, node.data.index) - onUpdate({ edit }) + onUpdate({ edit, directInteraction: true }) }, } @@ -273,7 +273,7 @@ export function useTableInputArgument( const edit = graph.startEdit() fixColumns(edit) removeColumn(edit, colId) - onUpdate({ edit }) + onUpdate({ edit, directInteraction: true }) }, }) @@ -293,7 +293,7 @@ export function useTableInputArgument( const edit = graph.startEdit() fixColumns(edit) addColumn(edit, `${DEFAULT_COLUMN_PREFIX}${columns.value.length + 1}`) - onUpdate({ edit }) + onUpdate({ edit, directInteraction: true }) }, }, mainMenuItems: ['autoSizeThis', 'autoSizeAll'], @@ -351,7 +351,7 @@ export function useTableInputArgument( if (astId != null) edit.replaceValue(astId, newValueAst) else edit.getVersion(col.data).set(data.index, newValueAst) } - onUpdate({ edit }) + onUpdate({ edit, directInteraction: true }) return true }, headerComponentParams: { @@ -361,7 +361,7 @@ export function useTableInputArgument( const edit = graph.startEdit() fixColumns(edit) edit.getVersion(col.name).setRawTextContent(newName) - onUpdate({ edit }) + onUpdate({ edit, directInteraction: true }) }, }, }, @@ -429,7 +429,7 @@ export function useTableInputArgument( const fromIndex = iter.find(columns.enumerate(), ([, ast]) => ast?.id === colId)?.[0] if (fromIndex != null) { columns.move(fromIndex, toIndex - 1) - onUpdate({ edit }) + onUpdate({ edit, directInteraction: true }) } } @@ -449,7 +449,7 @@ export function useTableInputArgument( const editedCol = edit.getVersion(col.data) editedCol.move(rowIndex, overIndex) } - onUpdate({ edit }) + onUpdate({ edit, directInteraction: true }) } function pasteFromClipboard(data: string[][], focusedCell: { rowIndex: number; colId: string }) { @@ -511,7 +511,7 @@ export function useTableInputArgument( modifiedColumnsAst, ) } - onUpdate({ edit }) + onUpdate({ edit, directInteraction: true }) return { rows: actuallyPastedRowsEnd - focusedCell.rowIndex, columns: actuallyPastedColsEnd - focusedColIndex, diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetText.vue b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetText.vue index 27c95fef1689..6837b90746a4 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetText.vue +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetText.vue @@ -44,7 +44,7 @@ function accepted() { const value = edit.getVersion(props.input.value) if (value.rawTextContent === editedContents.value) return value.setRawTextContent(editedContents.value) - props.onUpdate({ edit }) + props.onUpdate({ edit, directInteraction: true }) } else { let value: Ast.Owned if (inputTextLiteral.value) { @@ -58,6 +58,7 @@ function accepted() { value, origin: props.input.portId, }, + directInteraction: true, }) } } diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetVector.vue b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetVector.vue index 00fd62003fb0..64a40b5cce8b 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetVector.vue +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetVector.vue @@ -39,6 +39,7 @@ const value = computed({ const newAst = Ast.Vector.build(value, (element, tempModule) => tempModule.copy(element)) props.onUpdate({ portUpdate: { value: newAst, origin: props.input.portId }, + directInteraction: true, }) }, }) diff --git a/app/gui/src/project-view/providers/widgetRegistry.ts b/app/gui/src/project-view/providers/widgetRegistry.ts index 2e185242cf6b..864fc118bf6f 100644 --- a/app/gui/src/project-view/providers/widgetRegistry.ts +++ b/app/gui/src/project-view/providers/widgetRegistry.ts @@ -171,6 +171,11 @@ export interface WidgetUpdate { | { value: Ast.Owned | string | undefined } | { metadataKey: string; metadata: unknown } ) + /** + * Set to true if the updated is caused by direct interaction with the origin widget - a usual case. + * An example if _nondirect_ interaction is an update of a port connected to a removed node). + */ + directInteraction: boolean } /** diff --git a/app/gui/src/project-view/stores/graph/index.ts b/app/gui/src/project-view/stores/graph/index.ts index 4655fa48850c..c00bebc5d7a6 100644 --- a/app/gui/src/project-view/stores/graph/index.ts +++ b/app/gui/src/project-view/stores/graph/index.ts @@ -335,7 +335,7 @@ export const [provideGraphStore, useGraphStore] = createContextStore( // Skip ports on already deleted nodes. if (nodeId && deletedNodes.has(nodeId)) continue - updatePortValue(edit, usage, undefined) + updatePortValue(edit, usage, undefined, false) } const outerAst = edit.getVersion(node.outerAst) if (outerAst.isStatement()) Ast.deleteFromParentBlock(outerAst) @@ -577,6 +577,8 @@ export const [provideGraphStore, useGraphStore] = createContextStore( * Emit a value update to a port view under specific ID. Returns `true` if the port view is * registered and the update was emitted, or `false` otherwise. * + * The properties are analogous to {@link WidgetUpdate fields}. + * * NOTE: If this returns `true,` The update handlers called `graph.commitEdit` on their own. * Therefore, the passed in `edit` should not be modified afterward, as it is already committed. */ @@ -584,10 +586,15 @@ export const [provideGraphStore, useGraphStore] = createContextStore( edit: MutableModule, id: PortId, value: Ast.Owned | undefined, + directInteraction: boolean = true, ): boolean { const update = getPortPrimaryInstance(id)?.onUpdate if (!update) return false - update({ edit, portUpdate: { value, origin: id } }) + update({ + edit, + portUpdate: { value, origin: id }, + directInteraction, + }) return true } From d12c66e0ad7eb3e712a9f1ca836ac8c606c17ae9 Mon Sep 17 00:00:00 2001 From: Adam Obuchowicz Date: Thu, 2 Jan 2025 14:45:39 +0100 Subject: [PATCH 06/12] Fix wrong version in changelog (#11959) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17a6da35566a..5c83b1cfb59d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ [11856]: https://github.com/enso-org/enso/pull/11856 [11897]: https://github.com/enso-org/enso/pull/11897 -# Enso 2024.4 +# Enso 2024.5 #### Enso IDE From c499f9c405d2dfbc2ab8970a8ffcada775804efe Mon Sep 17 00:00:00 2001 From: Kaz Wesley Date: Thu, 2 Jan 2025 06:43:13 -0800 Subject: [PATCH 07/12] Fix missing placeholders (#11894) Show placeholder for any argument that doesn't have a default value, including collapsed function args; add integration test for collapsed function arg placeholders. Fixes #11347. --- .../collapsingAndEntering.spec.ts | 33 ++++++++++++++++++- .../project-view/expressionUpdates.ts | 3 +- app/gui/src/project-view/util/callTree.ts | 8 +++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/app/gui/integration-test/project-view/collapsingAndEntering.spec.ts b/app/gui/integration-test/project-view/collapsingAndEntering.spec.ts index 20ce3cdc97ae..a38ec0f5ff97 100644 --- a/app/gui/integration-test/project-view/collapsingAndEntering.spec.ts +++ b/app/gui/integration-test/project-view/collapsingAndEntering.spec.ts @@ -2,7 +2,7 @@ import { test, type Page } from '@playwright/test' import * as actions from './actions' import { expect } from './customExpect' import { mockCollapsedFunctionInfo } from './expressionUpdates' -import { CONTROL_KEY } from './keyboard' +import { CONTROL_KEY, DELETE_KEY } from './keyboard' import * as locate from './locate' import { edgesFromNode, edgesToNode } from './locate' import { mockSuggestion } from './suggestionUpdates' @@ -200,6 +200,37 @@ test('Input node is not collapsed', async ({ page }) => { await expect(locate.outputNode(page)).toHaveCount(1) }) +test('Collapsed call shows argument placeholders', async ({ page }) => { + await actions.goToGraph(page) + await mockCollapsedFunctionInfo(page, 'final', 'func1', [0]) + await mockSuggestion(page, { + type: 'method', + module: 'local.Mock_Project.Main', + name: 'func1', + arguments: [ + { + name: 'arg1', + reprType: 'Standard.Base.Any.Any', + isSuspended: false, + hasDefault: false, + defaultValue: null as any, + tagValues: null as any, + }, + ], + selfType: 'local.Mock_Project.Main', + returnType: 'Standard.Base.Any.Any', + isStatic: true, + documentation: '', + annotations: [], + }) + const collapsedCallComponent = locate.graphNodeByBinding(page, 'final') + await locate.graphNodeByBinding(page, 'prod').click() + await page.keyboard.press(DELETE_KEY) + await expect(await edgesToNode(page, collapsedCallComponent)).toHaveCount(0) + await expect(locate.selectedNodes(page)).toHaveCount(0) + await expect(collapsedCallComponent.locator('.WidgetArgumentName .name')).toHaveText('arg1') +}) + async function expectInsideMain(page: Page) { await actions.expectNodePositionsInitialized(page, -16) await expect(locate.graphNode(page)).toHaveCount(MAIN_FILE_NODES) diff --git a/app/gui/integration-test/project-view/expressionUpdates.ts b/app/gui/integration-test/project-view/expressionUpdates.ts index 5846ef888f14..733c9896037f 100644 --- a/app/gui/integration-test/project-view/expressionUpdates.ts +++ b/app/gui/integration-test/project-view/expressionUpdates.ts @@ -8,6 +8,7 @@ export async function mockCollapsedFunctionInfo( page: Page, expression: ExpressionLocator, functionName: string, + notAppliedArguments: number[] = [], ) { await mockMethodCallInfo(page, expression, { methodPointer: { @@ -15,7 +16,7 @@ export async function mockCollapsedFunctionInfo( definedOnType: 'local.Mock_Project.Main', name: functionName, }, - notAppliedArguments: [], + notAppliedArguments, }) } diff --git a/app/gui/src/project-view/util/callTree.ts b/app/gui/src/project-view/util/callTree.ts index 087ded4b9163..af374226c358 100644 --- a/app/gui/src/project-view/util/callTree.ts +++ b/app/gui/src/project-view/util/callTree.ts @@ -120,9 +120,13 @@ export class ArgumentPlaceholder extends Argument { return this.argInfo.defaultValue } - /** TODO: Add docs */ + /** Whether the argument should be hidden when the component isn't currently focused for editing. */ override get hideByDefault(): boolean { - return !isRequiredArgument(this.argInfo) && this.dynamicConfig?.display !== DisplayMode.Always + return ( + this.argInfo.hasDefault && + !isRequiredArgument(this.argInfo) && + this.dynamicConfig?.display !== DisplayMode.Always + ) } } From f7ca795dbe0348bab0628d0adc1dcd75eaa31b76 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 3 Jan 2025 00:57:25 +1000 Subject: [PATCH 08/12] Fix build error caused by Sentry Vite plugin (#11961) Fix parsing error causing Sentry builds to fail # Important Notes None --- app/gui/vite.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/gui/vite.config.ts b/app/gui/vite.config.ts index 9b5c30722d92..decff30fc04a 100644 --- a/app/gui/vite.config.ts +++ b/app/gui/vite.config.ts @@ -77,7 +77,6 @@ export default defineConfig({ sentryVitePlugin({ org: process.env.ENSO_CLOUD_SENTRY_ORGANIZATION, project: process.env.ENSO_CLOUD_SENTRY_PROJECT, - reactComponentAnnotation: { enabled: true }, ...(process.env.ENSO_VERSION != null ? { release: { name: process.env.ENSO_VERSION } } : {}), From 6bd8cb49616ce12cf9f87467322b0c9a5fd01f02 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 3 Jan 2025 01:23:32 +1000 Subject: [PATCH 09/12] Allow renaming secrets (#11948) - Close https://github.com/enso-org/cloud-v2/issues/1597 - Allow renaming secrets: - Make name column editable - Make name field in secret form (in sidebar) editable - Sync values from secret form to row heading (syncing from row heading to secret form is a bit harder in the current architecture) # Important Notes None --- app/common/src/services/Backend.ts | 3 +- .../components/dashboard/SecretNameColumn.tsx | 41 ++++++++++++++----- .../src/dashboard/layouts/AssetProperties.tsx | 4 +- app/gui/src/dashboard/layouts/AssetsTable.tsx | 8 +++- .../dashboard/modals/UpsertSecretModal.tsx | 32 +++++++-------- 5 files changed, 55 insertions(+), 33 deletions(-) diff --git a/app/common/src/services/Backend.ts b/app/common/src/services/Backend.ts index 15e01e9a15fd..452d08ef633e 100644 --- a/app/common/src/services/Backend.ts +++ b/app/common/src/services/Backend.ts @@ -1290,7 +1290,8 @@ export interface CreateSecretRequestBody { /** HTTP request body for the "update secret" endpoint. */ export interface UpdateSecretRequestBody { - readonly value: string + readonly title: string | null + readonly value: string | null } /** HTTP request body for the "create datalink" endpoint. */ diff --git a/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx b/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx index ba03a2c1b0e8..4efeb3503160 100644 --- a/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx +++ b/app/gui/src/dashboard/components/dashboard/SecretNameColumn.tsx @@ -8,14 +8,15 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as modalProvider from '#/providers/ModalProvider' -import * as ariaComponents from '#/components/AriaComponents' import type * as column from '#/components/dashboard/column' import SvgMask from '#/components/SvgMask' import UpsertSecretModal from '#/modals/UpsertSecretModal' -import type * as backendModule from '#/services/Backend' +import * as backendModule from '#/services/Backend' +import EditableSpan from '#/components/EditableSpan' +import { useText } from '#/providers/TextProvider' import * as eventModule from '#/utilities/event' import * as indent from '#/utilities/indent' import * as object from '#/utilities/object' @@ -37,12 +38,18 @@ export interface SecretNameColumnProps extends column.AssetColumnProps { */ export default function SecretNameColumn(props: SecretNameColumnProps) { const { item, selected, state, rowState, setRowState, isEditable, depth } = props - const { backend } = state + const { backend, nodeMap } = state const toastAndLog = toastAndLogHooks.useToastAndLog() + const { getText } = useText() const { setModal } = modalProvider.useSetModal() const updateSecretMutation = useMutation(backendMutationOptions(backend, 'updateSecret')) + const doRename = async (newTitle: string) => { + await updateSecretMutation.mutateAsync([item.id, { title: newTitle, value: null }, item.title]) + setIsEditing(false) + } + const setIsEditing = (isEditingName: boolean) => { if (isEditable) { setRowState(object.merger({ isEditingName })) @@ -69,9 +76,9 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { { + doCreate={async (title, value) => { try { - await updateSecretMutation.mutateAsync([item.id, { value }, item.title]) + await updateSecretMutation.mutateAsync([item.id, { title, value }, item.title]) } catch (error) { toastAndLog(null, error) } @@ -82,14 +89,28 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { }} > - {/* Secrets cannot be renamed. */} - { + setIsEditing(false) + }} + schema={(z) => + z.refine( + (value) => + backendModule.isNewTitleUnique( + item, + value, + nodeMap.current.get(item.parentId)?.children?.map((child) => child.item), + ), + { message: getText('nameShouldBeUnique') }, + ) + } > {item.title} - + ) } diff --git a/app/gui/src/dashboard/layouts/AssetProperties.tsx b/app/gui/src/dashboard/layouts/AssetProperties.tsx index 1ebcf95ea778..32d6266609d3 100644 --- a/app/gui/src/dashboard/layouts/AssetProperties.tsx +++ b/app/gui/src/dashboard/layouts/AssetProperties.tsx @@ -356,8 +356,8 @@ function AssetPropertiesInternal(props: AssetPropertiesInternalProps) { canCancel={false} id={item.id} name={item.title} - doCreate={async (name, value) => { - await updateSecretMutation.mutateAsync([item.id, { value }, name]) + doCreate={async (title, value) => { + await updateSecretMutation.mutateAsync([item.id, { title, value }, title]) }} /> diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx index 2b1e2fc59144..a8829822c218 100644 --- a/app/gui/src/dashboard/layouts/AssetsTable.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx @@ -948,9 +948,13 @@ function AssetsTable(props: AssetsTableProps) { { + doCreate={async (title, value) => { try { - await updateSecretMutation.mutateAsync([id, { value }, item.item.title]) + await updateSecretMutation.mutateAsync([ + id, + { title, value }, + item.item.title, + ]) } catch (error) { toastAndLog(null, error) } diff --git a/app/gui/src/dashboard/modals/UpsertSecretModal.tsx b/app/gui/src/dashboard/modals/UpsertSecretModal.tsx index cfd150460a3d..ba3477feb056 100644 --- a/app/gui/src/dashboard/modals/UpsertSecretModal.tsx +++ b/app/gui/src/dashboard/modals/UpsertSecretModal.tsx @@ -27,40 +27,36 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) { const { getText } = useText() const isCreatingSecret = id == null - const isNameEditable = nameRaw == null const form = Form.useForm({ method: 'dialog', schema: (z) => - z.object({ name: z.string().min(1, getText('emptyStringError')), value: z.string() }), - defaultValues: { name: nameRaw ?? '', value: '' }, - onSubmit: async ({ name, value }) => { - await doCreate(name, value) + z.object({ title: z.string().min(1, getText('emptyStringError')), value: z.string() }), + defaultValues: { title: nameRaw ?? '', value: '' }, + onSubmit: async ({ title, value }) => { + await doCreate(title, value) + form.reset({ title, value }) }, }) const content = (
- {isNameEditable && ( - - )} + From 8054ff77fe9d9ab7503d83e4339c3da661cfce67 Mon Sep 17 00:00:00 2001 From: Hubert Plociniczak Date: Thu, 2 Jan 2025 16:29:42 +0100 Subject: [PATCH 10/12] Include jline in non-linux setup (#11958) Loading jline appears to be broken on non-linux system. Native image configs appear to be one location where there is a difference. --- .../org/enso/runner/proxy-config.json | 2 +- .../org/enso/runner/reflect-config.json | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/engine/runner/src/main/resources/META-INF/native-image/org/enso/runner/proxy-config.json b/engine/runner/src/main/resources/META-INF/native-image/org/enso/runner/proxy-config.json index 0ad5407a5810..50cb79000427 100644 --- a/engine/runner/src/main/resources/META-INF/native-image/org/enso/runner/proxy-config.json +++ b/engine/runner/src/main/resources/META-INF/native-image/org/enso/runner/proxy-config.json @@ -1,5 +1,5 @@ [ { - "interfaces":["org.jline.terminal.impl.jna.linux.CLibrary"] + "interfaces":["org.jline.terminal.impl.jna.linux.CLibrary", "org.jline.terminal.impl.jna.osx.CLibrary", "org.jline.terminal.impl.jna.win.CLibrary"] } ] diff --git a/engine/runner/src/main/resources/META-INF/native-image/org/enso/runner/reflect-config.json b/engine/runner/src/main/resources/META-INF/native-image/org/enso/runner/reflect-config.json index 7a18744c81e6..6bbb3c167f64 100644 --- a/engine/runner/src/main/resources/META-INF/native-image/org/enso/runner/reflect-config.json +++ b/engine/runner/src/main/resources/META-INF/native-image/org/enso/runner/reflect-config.json @@ -3436,6 +3436,22 @@ "name":"org.jline.terminal.impl.jna.linux.CLibrary$winsize", "allDeclaredFields":true }, +{ + "name":"org.jline.terminal.impl.jna.osx.CLibrary$termios", + "allDeclaredFields":true +}, +{ + "name":"org.jline.terminal.impl.jna.osx.CLibrary$winsize", + "allDeclaredFields":true +}, +{ + "name":"org.jline.terminal.impl.jna.windows.CLibrary$termios", + "allDeclaredFields":true +}, +{ + "name":"org.jline.terminal.impl.jna.windows.CLibrary$winsize", + "allDeclaredFields":true +}, { "name":"org.openide.util.RequestProcessor" }, From 77ca61fe265f86e831c4db9c6c3f5758ec29320b Mon Sep 17 00:00:00 2001 From: Kaz Wesley Date: Thu, 2 Jan 2025 09:23:14 -0800 Subject: [PATCH 11/12] Refactoring in preparation for normalized qualified names (#11927) * Refactoring in preparation for normalized qualified names - `SuggestionUpdateProcessor` will simplify adding more contextual information to the update application process. - Mock suggestion data is constructed using `lsUpdate` so that the mocking logic doesn't have to be kept consistent with the `lsUpdate` implementation. - When uploading, use `ExternalId` to identify function; it's simpler than comparing stack items. - Correct some language server definitions that can be `null` in practice. - Add a test covering *current* behavior wrt. #11815; following PR will update expected results. --- .../components/CodeEditor/tooltips.ts | 2 +- .../__tests__/component.test.ts | 5 +- .../__tests__/filtering.test.ts | 8 +- .../components/ComponentBrowser/component.ts | 8 +- .../project-view/components/GraphEditor.vue | 3 +- .../components/GraphEditor/GraphNodes.vue | 9 +- .../components/GraphEditor/upload.ts | 39 +- .../__tests__/widgetFunctionCallInfo.test.ts | 4 +- .../__tests__/tableInputArgument.test.ts | 2 +- app/gui/src/project-view/stores/awareness.ts | 4 +- .../stores/graph/__tests__/imports.test.ts | 2 +- .../stores/graph/graphDatabase.ts | 11 +- .../src/project-view/stores/graph/imports.ts | 4 +- .../__tests__/lsUpdate.test.ts | 126 +++- .../suggestionDatabase/documentation.ts | 5 +- .../stores/suggestionDatabase/entry.ts | 134 +--- .../stores/suggestionDatabase/index.ts | 91 +-- .../stores/suggestionDatabase/lsUpdate.ts | 713 +++++++++--------- .../suggestionDatabase/mockSuggestion.ts | 140 ++++ .../util/__tests__/callTree.test.ts | 4 +- app/gui/src/project-view/util/ast/abstract.ts | 6 +- app/gui/src/project-view/util/callTree.ts | 2 +- app/gui/src/project-view/util/data/array.ts | 2 +- app/gui/src/project-view/util/ensoTypes.ts | 4 + .../src/languageServerTypes/suggestions.ts | 20 +- 25 files changed, 712 insertions(+), 636 deletions(-) create mode 100644 app/gui/src/project-view/stores/suggestionDatabase/mockSuggestion.ts create mode 100644 app/gui/src/project-view/util/ensoTypes.ts diff --git a/app/gui/src/project-view/components/CodeEditor/tooltips.ts b/app/gui/src/project-view/components/CodeEditor/tooltips.ts index 1f69dfa026a1..0c9cfb609b4d 100644 --- a/app/gui/src/project-view/components/CodeEditor/tooltips.ts +++ b/app/gui/src/project-view/components/CodeEditor/tooltips.ts @@ -69,7 +69,7 @@ export function ensoHoverTooltip( nodeId, syntax: syn.name, graphDb: graphStore.db, - suggestionDbStore: suggestionDbStore, + suggestionDbStore, }) }) } diff --git a/app/gui/src/project-view/components/ComponentBrowser/__tests__/component.test.ts b/app/gui/src/project-view/components/ComponentBrowser/__tests__/component.test.ts index 65e778d4b31c..4666bf30e75b 100644 --- a/app/gui/src/project-view/components/ComponentBrowser/__tests__/component.test.ts +++ b/app/gui/src/project-view/components/ComponentBrowser/__tests__/component.test.ts @@ -1,5 +1,3 @@ -import { expect, test } from 'vitest' - import { compareSuggestions, labelOfEntry, @@ -14,9 +12,10 @@ import { makeModule, makeModuleMethod, makeStaticMethod, -} from '@/stores/suggestionDatabase/entry' +} from '@/stores/suggestionDatabase/mockSuggestion' import { allRanges } from '@/util/data/range' import shuffleSeed from 'shuffle-seed' +import { expect, test } from 'vitest' test.each([ [makeModuleMethod('Standard.Base.Data.read'), 'Data.read'], diff --git a/app/gui/src/project-view/components/ComponentBrowser/__tests__/filtering.test.ts b/app/gui/src/project-view/components/ComponentBrowser/__tests__/filtering.test.ts index 8780dca65d02..621acf811ca6 100644 --- a/app/gui/src/project-view/components/ComponentBrowser/__tests__/filtering.test.ts +++ b/app/gui/src/project-view/components/ComponentBrowser/__tests__/filtering.test.ts @@ -1,8 +1,6 @@ -import { expect, test } from 'vitest' - import { Filtering, type MatchResult } from '@/components/ComponentBrowser/filtering' +import { entryQn, SuggestionEntry } from '@/stores/suggestionDatabase/entry' import { - entryQn, makeConstructor, makeFunction, makeLocal, @@ -10,9 +8,9 @@ import { makeModule, makeModuleMethod, makeStaticMethod, - SuggestionEntry, -} from '@/stores/suggestionDatabase/entry' +} from '@/stores/suggestionDatabase/mockSuggestion' import { qnLastSegment, QualifiedName } from '@/util/qualifiedName' +import { expect, test } from 'vitest' import { Opt } from 'ydoc-shared/util/data/opt' test.each([ diff --git a/app/gui/src/project-view/components/ComponentBrowser/component.ts b/app/gui/src/project-view/components/ComponentBrowser/component.ts index 3b40ee410031..d05afea3744a 100644 --- a/app/gui/src/project-view/components/ComponentBrowser/component.ts +++ b/app/gui/src/project-view/components/ComponentBrowser/component.ts @@ -8,11 +8,11 @@ import { import { compareOpt } from '@/util/compare' import { isSome } from '@/util/data/opt' import { Range } from '@/util/data/range' +import { ANY_TYPE_QN } from '@/util/ensoTypes' import { displayedIconOf } from '@/util/getIconName' import type { Icon } from '@/util/iconName' import type { QualifiedName } from '@/util/qualifiedName' import { qnLastSegmentIndex, tryQualifiedName } from '@/util/qualifiedName' -import { unwrap } from 'ydoc-shared/util/data/result' interface ComponentLabelInfo { label: string @@ -109,14 +109,12 @@ export function makeComponent({ id, entry, match }: ComponentInfo): Component { } } -const ANY_TYPE = unwrap(tryQualifiedName('Standard.Base.Any.Any')) - /** Create {@link Component} list from filtered suggestions. */ export function makeComponentList(db: SuggestionDb, filtering: Filtering): Component[] { function* matchSuggestions() { // All types are descendants of `Any`, so we can safely prepopulate it here. // This way, we will use it even when `selfArg` is not a valid qualified name. - const additionalSelfTypes: QualifiedName[] = [ANY_TYPE] + const additionalSelfTypes: QualifiedName[] = [ANY_TYPE_QN] if (filtering.selfArg?.type === 'known') { const maybeName = tryQualifiedName(filtering.selfArg.typename) if (maybeName.ok) populateAdditionalSelfTypes(db, additionalSelfTypes, maybeName.value) @@ -140,7 +138,7 @@ export function makeComponentList(db: SuggestionDb, filtering: Filtering): Compo function populateAdditionalSelfTypes(db: SuggestionDb, list: QualifiedName[], name: QualifiedName) { let entry = db.getEntryByQualifiedName(name) // We don’t need to add `Any` to the list, because the caller already did that. - while (entry != null && entry.parentType != null && entry.parentType !== ANY_TYPE) { + while (entry != null && entry.parentType != null && entry.parentType !== ANY_TYPE_QN) { list.push(entry.parentType) entry = db.getEntryByQualifiedName(entry.parentType) } diff --git a/app/gui/src/project-view/components/GraphEditor.vue b/app/gui/src/project-view/components/GraphEditor.vue index 093244559f95..6b42c5843f09 100644 --- a/app/gui/src/project-view/components/GraphEditor.vue +++ b/app/gui/src/project-view/components/GraphEditor.vue @@ -580,6 +580,7 @@ async function handleFileDrop(event: DragEvent) { if (!event.dataTransfer?.items) return ;[...event.dataTransfer.items].forEach(async (item, index) => { if (item.kind === 'file') { + if (!graphStore.methodAst.ok) return const file = item.getAsFile() if (!file) return const clientPos = new Vec2(event.clientX, event.clientY) @@ -591,7 +592,7 @@ async function handleFileDrop(event: DragEvent) { pos, projectStore.isOnLocalBackend, event.shiftKey, - projectStore.executionContext.getStackTop(), + graphStore.methodAst.value.externalId, ) const uploadResult = await uploader.upload() if (uploadResult.ok) { diff --git a/app/gui/src/project-view/components/GraphEditor/GraphNodes.vue b/app/gui/src/project-view/components/GraphEditor/GraphNodes.vue index a8398cc5a04a..b091c5309cbb 100644 --- a/app/gui/src/project-view/components/GraphEditor/GraphNodes.vue +++ b/app/gui/src/project-view/components/GraphEditor/GraphNodes.vue @@ -12,8 +12,7 @@ import { useProjectStore } from '@/stores/project' import type { AstId } from '@/util/ast/abstract' import type { Vec2 } from '@/util/data/vec2' import { set } from 'lib0' -import { computed, shallowRef, toRaw } from 'vue' -import { stackItemsEqual } from 'ydoc-shared/languageServerTypes' +import { computed, shallowRef } from 'vue' const emit = defineEmits<{ nodeOutputPortDoubleClick: [portId: AstId] @@ -48,9 +47,9 @@ useEvent(window, 'keydown', displacingWithArrows.events.keydown) const uploadingFiles = computed<[FileName, File][]>(() => { const uploads = [...projectStore.awareness.allUploads()] - if (uploads.length == 0) return [] - const currentStackItem = toRaw(projectStore.executionContext.getStackTop()) - return uploads.filter(([, file]) => stackItemsEqual(file.stackItem, currentStackItem)) + if (uploads.length == 0 || !graphStore.methodAst.ok) return [] + const currentMethod = graphStore.methodAst.value.externalId + return uploads.filter(([, file]) => file.method === currentMethod) }) const graphNodeSelections = shallowRef() diff --git a/app/gui/src/project-view/components/GraphEditor/upload.ts b/app/gui/src/project-view/components/GraphEditor/upload.ts index be9e90d4196a..d35a42917970 100644 --- a/app/gui/src/project-view/components/GraphEditor/upload.ts +++ b/app/gui/src/project-view/components/GraphEditor/upload.ts @@ -4,11 +4,11 @@ import { Vec2 } from '@/util/data/vec2' import type { DataServer } from '@/util/net/dataServer' import { Keccak, sha3_224 as SHA3 } from '@noble/hashes/sha3' import type { Hash } from '@noble/hashes/utils' -import { markRaw, toRaw } from 'vue' import { escapeTextLiteral } from 'ydoc-shared/ast/text' import type { LanguageServer } from 'ydoc-shared/languageServer' -import type { Path, StackItem, Uuid } from 'ydoc-shared/languageServerTypes' +import type { Path, Uuid } from 'ydoc-shared/languageServerTypes' import { Err, Ok, type Result } from 'ydoc-shared/util/data/result' +import { type ExternalId } from 'ydoc-shared/yjsModel' // === Constants === @@ -45,7 +45,6 @@ export interface UploadResult { export class Uploader { private checksum: Hash private uploadedBytes: bigint - private stackItem: StackItem private awareness: Awareness private projectFiles: ProjectFiles @@ -60,11 +59,10 @@ export class Uploader { private position: Vec2, private isOnLocalBackend: boolean, private disableDirectRead: boolean, - stackItem: StackItem, + private readonly method: ExternalId, ) { this.checksum = SHA3.create() this.uploadedBytes = BigInt(0) - this.stackItem = markRaw(toRaw(stackItem)) this.awareness = projectStore.awareness this.projectFiles = useProjectFiles(projectStore) } @@ -81,16 +79,17 @@ export class Uploader { position: Vec2, isOnLocalBackend: boolean, disableDirectRead: boolean, - stackItem: StackItem, + method: ExternalId, ): Uploader { - return new Uploader( - projectStore, - file, - position, - isOnLocalBackend, - disableDirectRead, - stackItem, - ) + return new Uploader(projectStore, file, position, isOnLocalBackend, disableDirectRead, method) + } + + private progressUpdate(sizePercentage: number) { + return { + sizePercentage, + position: this.position, + method: this.method, + } } /** Start the upload process */ @@ -111,11 +110,7 @@ export class Uploader { if (!dataDirExists.ok) return dataDirExists const name = await this.projectFiles.pickUniqueName(dataDirPath, this.file.name) if (!name.ok) return name - this.awareness.addOrUpdateUpload(name.value, { - sizePercentage: 0, - position: this.position, - stackItem: this.stackItem, - }) + this.awareness.addOrUpdateUpload(name.value, this.progressUpdate(0)) const remotePath: Path = { rootId, segments: [DATA_DIR_NAME, name.value] } const cleanup = this.cleanup.bind(this, name.value) const writableStream = new WritableStream({ @@ -131,11 +126,7 @@ export class Uploader { this.uploadedBytes += BigInt(chunk.length) const bytes = Number(this.uploadedBytes) const sizePercentage = Math.round((bytes / this.file.size) * 100) - this.awareness.addOrUpdateUpload(name.value, { - sizePercentage, - position: this.position, - stackItem: this.stackItem, - }) + this.awareness.addOrUpdateUpload(name.value, this.progressUpdate(sizePercentage)) }, close: cleanup, abort: async (reason: string) => { diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFunction/__tests__/widgetFunctionCallInfo.test.ts b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFunction/__tests__/widgetFunctionCallInfo.test.ts index 95584d69df8f..40e05890db11 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFunction/__tests__/widgetFunctionCallInfo.test.ts +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetFunction/__tests__/widgetFunctionCallInfo.test.ts @@ -1,13 +1,13 @@ import { WidgetInput } from '@/providers/widgetRegistry' import { parseWithSpans } from '@/stores/graph/__tests__/graphDatabase.test' import type { NodeVisualizationConfiguration } from '@/stores/project/executionContext' +import { entryMethodPointer } from '@/stores/suggestionDatabase/entry' import { - entryMethodPointer, makeArgument, makeConstructor, makeMethod, makeStaticMethod, -} from '@/stores/suggestionDatabase/entry' +} from '@/stores/suggestionDatabase/mockSuggestion' import { assert } from '@/util/assert' import { Ast } from '@/util/ast' import { expect, test } from 'vitest' diff --git a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/__tests__/tableInputArgument.test.ts b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/__tests__/tableInputArgument.test.ts index 758357a8dd99..03818aa88f13 100644 --- a/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/__tests__/tableInputArgument.test.ts +++ b/app/gui/src/project-view/components/GraphEditor/widgets/WidgetTableEditor/__tests__/tableInputArgument.test.ts @@ -10,7 +10,7 @@ import { import { MenuItem } from '@/components/shared/AgGridTableView.vue' import { WidgetInput } from '@/providers/widgetRegistry' import { SuggestionDb } from '@/stores/suggestionDatabase' -import { makeType } from '@/stores/suggestionDatabase/entry' +import { makeType } from '@/stores/suggestionDatabase/mockSuggestion' import { assert } from '@/util/assert' import { Ast } from '@/util/ast' import { GetContextMenuItems, GetMainMenuItems } from 'ag-grid-enterprise' diff --git a/app/gui/src/project-view/stores/awareness.ts b/app/gui/src/project-view/stores/awareness.ts index 99b38f0be721..bfe988019a1d 100644 --- a/app/gui/src/project-view/stores/awareness.ts +++ b/app/gui/src/project-view/stores/awareness.ts @@ -1,7 +1,7 @@ import { Vec2 } from '@/util/data/vec2' import { reactive } from 'vue' import { Awareness as YjsAwareness } from 'y-protocols/awareness' -import type { StackItem } from 'ydoc-shared/languageServerTypes' +import { type ExternalId } from 'ydoc-shared/yjsModel' import * as Y from 'yjs' // === Public types === @@ -10,7 +10,7 @@ export type FileName = string export interface UploadingFile { sizePercentage: number - stackItem: StackItem + method: ExternalId position: Vec2 } diff --git a/app/gui/src/project-view/stores/graph/__tests__/imports.test.ts b/app/gui/src/project-view/stores/graph/__tests__/imports.test.ts index c7f83afa5e10..c37e61d16013 100644 --- a/app/gui/src/project-view/stores/graph/__tests__/imports.test.ts +++ b/app/gui/src/project-view/stores/graph/__tests__/imports.test.ts @@ -16,7 +16,7 @@ import { makeModule, makeStaticMethod, makeType, -} from '@/stores/suggestionDatabase/entry' +} from '@/stores/suggestionDatabase/mockSuggestion' import { Ast } from '@/util/ast' import { unwrap } from '@/util/data/result' import { tryIdentifier, tryQualifiedName } from '@/util/qualifiedName' diff --git a/app/gui/src/project-view/stores/graph/graphDatabase.ts b/app/gui/src/project-view/stores/graph/graphDatabase.ts index e7f08f39b292..36d62a0f764a 100644 --- a/app/gui/src/project-view/stores/graph/graphDatabase.ts +++ b/app/gui/src/project-view/stores/graph/graphDatabase.ts @@ -27,7 +27,14 @@ import { } from '@/util/reactivity' import * as objects from 'enso-common/src/utilities/data/object' import * as set from 'lib0/set' -import { reactive, ref, shallowReactive, type Ref, type WatchStopHandle } from 'vue' +import { + reactive, + ref, + shallowReactive, + type DeepReadonly, + type Ref, + type WatchStopHandle, +} from 'vue' import { type SourceDocument } from 'ydoc-shared/ast/sourceDocument' import { methodPointerEquals, @@ -60,7 +67,7 @@ export class GraphDb { /** TODO: Add docs */ constructor( private suggestionDb: SuggestionDb, - private groups: Ref, + private groups: Ref>, private valuesRegistry: ComputedValueRegistry, ) {} diff --git a/app/gui/src/project-view/stores/graph/imports.ts b/app/gui/src/project-view/stores/graph/imports.ts index d2118ad3eb32..a57503645828 100644 --- a/app/gui/src/project-view/stores/graph/imports.ts +++ b/app/gui/src/project-view/stores/graph/imports.ts @@ -1,7 +1,7 @@ import { SuggestionDb } from '@/stores/suggestionDatabase' import { SuggestionKind, type SuggestionEntry } from '@/stores/suggestionDatabase/entry' import { Ast } from '@/util/ast' -import { MutableModule, parseIdent, parseIdents, parseQualifiedName } from '@/util/ast/abstract' +import { astToQualifiedName, MutableModule, parseIdent, parseIdents } from '@/util/ast/abstract' import { unwrap } from '@/util/data/result' import { qnLastSegment, @@ -24,7 +24,7 @@ export function recognizeImport(ast: Ast.Import): Import | null { const all = ast.all const hiding = ast.hiding const moduleAst = from ?? import_ - const module = moduleAst ? parseQualifiedName(moduleAst) : null + const module = moduleAst ? astToQualifiedName(moduleAst) : null if (!module) return null if (all) { const except = (hiding != null ? parseIdents(hiding) : []) ?? [] diff --git a/app/gui/src/project-view/stores/suggestionDatabase/__tests__/lsUpdate.test.ts b/app/gui/src/project-view/stores/suggestionDatabase/__tests__/lsUpdate.test.ts index 17e4b91bfbb7..2e7710cd9605 100644 --- a/app/gui/src/project-view/stores/suggestionDatabase/__tests__/lsUpdate.test.ts +++ b/app/gui/src/project-view/stores/suggestionDatabase/__tests__/lsUpdate.test.ts @@ -1,16 +1,25 @@ import { SuggestionDb, type Group } from '@/stores/suggestionDatabase' import { SuggestionKind, entryQn, type SuggestionEntry } from '@/stores/suggestionDatabase/entry' -import { applyUpdates } from '@/stores/suggestionDatabase/lsUpdate' +import { SuggestionUpdateProcessor } from '@/stores/suggestionDatabase/lsUpdate' import { unwrap } from '@/util/data/result' import { parseDocs } from '@/util/docParser' import { tryIdentifier, tryQualifiedName, type QualifiedName } from '@/util/qualifiedName' import { expect, test } from 'vitest' import * as lsTypes from 'ydoc-shared/languageServerTypes/suggestions' +import { type SuggestionsDatabaseUpdate } from 'ydoc-shared/languageServerTypes/suggestions' + +function applyUpdates( + db: SuggestionDb, + updates: SuggestionsDatabaseUpdate[], + { groups }: { groups: Group[] }, +) { + new SuggestionUpdateProcessor(groups).applyUpdates(db, updates) +} test('Adding suggestion database entries', () => { const test = new Fixture() const db = new SuggestionDb() - applyUpdates(db, test.addUpdatesForExpected(), test.groups) + applyUpdates(db, test.addUpdatesForExpected(), test.suggestionContext) test.check(db) }) @@ -24,23 +33,26 @@ test('Entry qualified names', () => { expect(entryQn(db.get(5)!)).toStrictEqual('Standard.Base.Type.static_method') expect(entryQn(db.get(6)!)).toStrictEqual('Standard.Base.function') expect(entryQn(db.get(7)!)).toStrictEqual('Standard.Base.local') + expect(entryQn(db.get(8)!)).toStrictEqual('local.Mock_Project.collapsed') }) test('Qualified name indexing', () => { const test = new Fixture() const db = new SuggestionDb() - applyUpdates(db, test.addUpdatesForExpected(), test.groups) - for (let i = 1; i <= 7; i++) { - const qName = entryQn(db.get(i)!) - expect(db.nameToId.lookup(qName)).toEqual(new Set([i])) - expect(db.nameToId.reverseLookup(i)).toEqual(new Set([qName])) + const addUpdates = test.addUpdatesForExpected() + applyUpdates(db, addUpdates, test.suggestionContext) + for (const { id } of addUpdates) { + const qName = entryQn(db.get(id)!) + expect(db.nameToId.lookup(qName)).toEqual(new Set([id])) + expect(db.nameToId.reverseLookup(id)).toEqual(new Set([qName])) } }) test('Parent-children indexing', () => { const test = new Fixture() const db = new SuggestionDb() - applyUpdates(db, test.addUpdatesForExpected(), test.groups) + const initialAddUpdates = test.addUpdatesForExpected() + applyUpdates(db, initialAddUpdates, test.suggestionContext) // Parent lookup. expect(db.childIdToParentId.lookup(1)).toEqual(new Set([])) expect(db.childIdToParentId.lookup(2)).toEqual(new Set([1])) @@ -49,6 +61,7 @@ test('Parent-children indexing', () => { expect(db.childIdToParentId.lookup(5)).toEqual(new Set([2])) expect(db.childIdToParentId.lookup(6)).toEqual(new Set([1])) expect(db.childIdToParentId.lookup(7)).toEqual(new Set([1])) + expect(db.childIdToParentId.lookup(8)).toEqual(new Set([])) // Children lookup. expect(db.childIdToParentId.reverseLookup(1)).toEqual(new Set([2, 6, 7])) @@ -58,12 +71,14 @@ test('Parent-children indexing', () => { expect(db.childIdToParentId.reverseLookup(5)).toEqual(new Set([])) expect(db.childIdToParentId.reverseLookup(6)).toEqual(new Set([])) expect(db.childIdToParentId.reverseLookup(7)).toEqual(new Set([])) + expect(db.childIdToParentId.reverseLookup(8)).toEqual(new Set([])) // Add new entry. + const newEntryId = initialAddUpdates[initialAddUpdates.length - 1]!.id + 1 const modifications: lsTypes.SuggestionsDatabaseUpdate[] = [ { type: 'Add', - id: 8, + id: newEntryId, suggestion: { type: 'method', module: 'Standard.Base', @@ -77,22 +92,22 @@ test('Parent-children indexing', () => { }, }, ] - applyUpdates(db, modifications, test.groups) - expect(db.childIdToParentId.lookup(8)).toEqual(new Set([2])) - expect(db.childIdToParentId.reverseLookup(8)).toEqual(new Set([])) - expect(db.childIdToParentId.reverseLookup(2)).toEqual(new Set([3, 4, 5, 8])) + applyUpdates(db, modifications, test.suggestionContext) + expect(db.childIdToParentId.lookup(newEntryId)).toEqual(new Set([2])) + expect(db.childIdToParentId.reverseLookup(newEntryId)).toEqual(new Set([])) + expect(db.childIdToParentId.reverseLookup(2)).toEqual(new Set([3, 4, 5, newEntryId])) // Remove entry. const modifications2: lsTypes.SuggestionsDatabaseUpdate[] = [{ type: 'Remove', id: 3 }] - applyUpdates(db, modifications2, test.groups) + applyUpdates(db, modifications2, test.suggestionContext) expect(db.childIdToParentId.lookup(3)).toEqual(new Set([])) - expect(db.childIdToParentId.reverseLookup(2)).toEqual(new Set([4, 5, 8])) + expect(db.childIdToParentId.reverseLookup(2)).toEqual(new Set([4, 5, newEntryId])) // Modify entry. Moving new method from `Standard.Base.Type` to `Standard.Base`. - db.get(8)!.memberOf = 'Standard.Base' as QualifiedName - expect(db.childIdToParentId.reverseLookup(1)).toEqual(new Set([2, 6, 7, 8])) - expect(db.childIdToParentId.lookup(8)).toEqual(new Set([1])) - expect(db.childIdToParentId.reverseLookup(8)).toEqual(new Set([])) + db.get(newEntryId)!.memberOf = 'Standard.Base' as QualifiedName + expect(db.childIdToParentId.reverseLookup(1)).toEqual(new Set([2, 6, 7, newEntryId])) + expect(db.childIdToParentId.lookup(newEntryId)).toEqual(new Set([1])) + expect(db.childIdToParentId.reverseLookup(newEntryId)).toEqual(new Set([])) expect(db.childIdToParentId.reverseLookup(2)).toEqual(new Set([4, 5])) }) @@ -137,7 +152,7 @@ test("Modifying suggestion entries' fields", () => { test.expectedStaticMethod.memberOf = unwrap(tryQualifiedName('Standard.Base2.Type')) test.expectedFunction.scope = scope2 - applyUpdates(db, modifications, test.groups) + applyUpdates(db, modifications, test.suggestionContext) test.check(db) }) @@ -166,7 +181,7 @@ test("Unsetting suggestion entries' fields", () => { test.expectedMethod.documentation = [] delete test.expectedMethod.groupIndex - applyUpdates(db, modifications, test.groups) + applyUpdates(db, modifications, test.suggestionContext) test.check(db) }) @@ -177,7 +192,7 @@ test('Removing entries from database', () => { { type: 'Remove', id: 6 }, ] const db = test.createDbWithExpected() - applyUpdates(db, update, test.groups) + applyUpdates(db, update, test.suggestionContext) expect(db.get(1)).toStrictEqual(test.expectedModule) expect(db.get(2)).toBeUndefined() expect(db.get(3)).toStrictEqual(test.expectedCon) @@ -185,6 +200,7 @@ test('Removing entries from database', () => { expect(db.get(5)).toStrictEqual(test.expectedStaticMethod) expect(db.get(6)).toBeUndefined() expect(db.get(7)).toStrictEqual(test.expectedLocal) + expect(db.get(8)).toStrictEqual(test.expectedLocalStaticMethod) }) test('Adding new argument', () => { @@ -205,7 +221,7 @@ test('Adding new argument', () => { test.expectedCon.arguments = [test.arg1, newArg] test.expectedStaticMethod.arguments = [test.arg1, newArg, test.arg2] - applyUpdates(db, modifications, test.groups) + applyUpdates(db, modifications, test.suggestionContext) test.check(db) }) @@ -250,7 +266,7 @@ test('Modifying arguments', () => { const db = test.createDbWithExpected() test.expectedStaticMethod.arguments = [newArg1, newArg2] - applyUpdates(db, modifications, test.groups) + applyUpdates(db, modifications, test.suggestionContext) test.check(db) }) @@ -264,15 +280,18 @@ test('Removing Arguments', () => { test.expectedMethod.arguments = [] test.expectedStaticMethod.arguments = [test.arg1] - applyUpdates(db, update, test.groups) + applyUpdates(db, update, test.suggestionContext) test.check(db) }) class Fixture { - groups: Group[] = [ - { name: 'Test1', project: unwrap(tryQualifiedName('Standard.Base')) }, - { name: 'Test2', project: unwrap(tryQualifiedName('Standard.Base')) }, - ] + suggestionContext = { + groups: [ + { name: 'Test1', project: unwrap(tryQualifiedName('Standard.Base')) }, + { name: 'Test2', project: unwrap(tryQualifiedName('Standard.Base')) }, + ], + currentProject: 'local.Mock_Project' as QualifiedName, + } arg1 = { name: 'a', reprType: 'Any', @@ -394,6 +413,29 @@ class Fixture { scope: this.scope, annotations: [], } + expectedLocalStaticMethod: SuggestionEntry = { + kind: SuggestionKind.Method, + arguments: [ + { + name: 'a', + reprType: 'Standard.Base.Any.Any', + isSuspended: false, + hasDefault: false, + defaultValue: null, + tagValues: null, + }, + ], + annotations: [], + name: unwrap(tryIdentifier('collapsed')), + definedIn: unwrap(tryQualifiedName('local.Mock_Project')), + documentation: [{ Tag: { tag: 'Icon', body: 'group' } }, { Paragraph: { body: '' } }], + iconName: 'group', + aliases: [], + isPrivate: false, + isUnstable: false, + memberOf: unwrap(tryQualifiedName('local.Mock_Project')), + returnType: 'Standard.Base.Any.Any', + } addUpdatesForExpected(): lsTypes.SuggestionsDatabaseUpdate[] { return [ @@ -490,6 +532,30 @@ class Fixture { documentation: this.localDocs, }, }, + { + type: 'Add', + id: 8, + suggestion: { + type: 'method', + module: 'local.Mock_Project.Main', + name: 'collapsed', + arguments: [ + { + name: 'a', + reprType: 'Standard.Base.Any.Any', + isSuspended: false, + hasDefault: false, + defaultValue: null, + tagValues: null, + }, + ], + selfType: 'local.Mock_Project.Main', + returnType: 'Standard.Base.Any.Any', + isStatic: true, + documentation: ' ICON group', + annotations: [], + }, + }, ] } @@ -502,6 +568,7 @@ class Fixture { db.set(5, structuredClone(this.expectedStaticMethod)) db.set(6, structuredClone(this.expectedFunction)) db.set(7, structuredClone(this.expectedLocal)) + db.set(8, structuredClone(this.expectedLocalStaticMethod)) return db } @@ -513,5 +580,6 @@ class Fixture { expect(db.get(5)).toStrictEqual(this.expectedStaticMethod) expect(db.get(6)).toStrictEqual(this.expectedFunction) expect(db.get(7)).toStrictEqual(this.expectedLocal) + expect(db.get(8)).toStrictEqual(this.expectedLocalStaticMethod) } } diff --git a/app/gui/src/project-view/stores/suggestionDatabase/documentation.ts b/app/gui/src/project-view/stores/suggestionDatabase/documentation.ts index c92631f03748..93c25504e4e5 100644 --- a/app/gui/src/project-view/stores/suggestionDatabase/documentation.ts +++ b/app/gui/src/project-view/stores/suggestionDatabase/documentation.ts @@ -4,6 +4,7 @@ import { isSome, type Opt } from '@/util/data/opt' import { parseDocs, type Doc } from '@/util/docParser' import type { Icon } from '@/util/iconName' import { type QualifiedName } from '@/util/qualifiedName' +import { type DeepReadonly } from 'vue' export interface DocumentationData { documentation: Doc.Section[] @@ -31,7 +32,7 @@ export function tagValue(doc: Doc.Section[], tag: string): Opt { export function getGroupIndex( groupName: string, entryModule: QualifiedName, - groups: Group[], + groups: DeepReadonly, ): Opt { let normalized: string if (groupName.indexOf('.') >= 0) { @@ -48,7 +49,7 @@ export function getGroupIndex( export function documentationData( documentation: Opt, definedIn: QualifiedName, - groups: Group[], + groups: DeepReadonly, ): DocumentationData { const parsed = documentation != null ? parseDocs(documentation) : [] const groupName = tagValue(parsed, 'Group') diff --git a/app/gui/src/project-view/stores/suggestionDatabase/entry.ts b/app/gui/src/project-view/stores/suggestionDatabase/entry.ts index 956b4d73e7e2..c9ee80d34293 100644 --- a/app/gui/src/project-view/stores/suggestionDatabase/entry.ts +++ b/app/gui/src/project-view/stores/suggestionDatabase/entry.ts @@ -1,21 +1,13 @@ -import { assert } from '@/util/assert' import type { Doc } from '@/util/docParser' import type { Icon } from '@/util/iconName' import type { IdentifierOrOperatorIdentifier, QualifiedName } from '@/util/qualifiedName' -import { - isIdentifierOrOperatorIdentifier, - isQualifiedName, - qnJoin, - qnLastSegment, - qnParent, - qnSegments, - qnSplit, -} from '@/util/qualifiedName' +import { qnJoin, qnParent, qnSegments } from '@/util/qualifiedName' import type { MethodPointer } from 'ydoc-shared/languageServerTypes' import type { SuggestionEntryArgument, SuggestionEntryScope, } from 'ydoc-shared/languageServerTypes/suggestions' + export type { SuggestionEntryArgument, SuggestionEntryScope, @@ -119,128 +111,6 @@ export function suggestionDocumentationUrl(entry: SuggestionEntry): string | und return segments.join('/') } -function makeSimpleEntry( - kind: SuggestionKind, - definedIn: QualifiedName, - name: IdentifierOrOperatorIdentifier, - returnType: QualifiedName, -): SuggestionEntry { - return { - kind, - definedIn, - name, - isPrivate: false, - isUnstable: false, - aliases: [], - arguments: [], - returnType, - documentation: [], - annotations: [], - } -} - -/** TODO: Add docs */ -export function makeModule(fqn: string): SuggestionEntry { - assert(isQualifiedName(fqn)) - return makeSimpleEntry(SuggestionKind.Module, fqn, qnLastSegment(fqn), fqn) -} - -/** TODO: Add docs */ -export function makeType(fqn: string): SuggestionEntry { - assert(isQualifiedName(fqn)) - const [definedIn, name] = qnSplit(fqn) - assert(definedIn != null) - return makeSimpleEntry(SuggestionKind.Type, definedIn, name, fqn) -} - -/** TODO: Add docs */ -export function makeConstructor(fqn: string): SuggestionEntry { - assert(isQualifiedName(fqn)) - const [type, name] = qnSplit(fqn) - assert(type != null) - const definedIn = qnParent(type) - assert(definedIn != null) - return { - memberOf: type, - ...makeSimpleEntry(SuggestionKind.Constructor, definedIn, name, type), - } -} - -/** TODO: Add docs */ -export function makeMethod(fqn: string, returnType: string = 'Any'): SuggestionEntry { - assert(isQualifiedName(fqn)) - assert(isQualifiedName(returnType)) - const [type, name] = qnSplit(fqn) - assert(type != null) - const definedIn = qnParent(type) - assert(definedIn != null) - return { - memberOf: type, - selfType: type, - ...makeSimpleEntry(SuggestionKind.Method, definedIn, name, returnType), - } -} - -/** TODO: Add docs */ -export function makeStaticMethod(fqn: string, returnType: string = 'Any'): SuggestionEntry { - assert(isQualifiedName(fqn)) - assert(isQualifiedName(returnType)) - const [type, name] = qnSplit(fqn) - assert(type != null) - const definedIn = qnParent(type) - assert(definedIn != null) - return { - memberOf: type, - ...makeSimpleEntry(SuggestionKind.Method, definedIn, name, returnType), - } -} - -/** TODO: Add docs */ -export function makeModuleMethod(fqn: string, returnType: string = 'Any'): SuggestionEntry { - assert(isQualifiedName(fqn)) - assert(isQualifiedName(returnType)) - const [definedIn, name] = qnSplit(fqn) - assert(definedIn != null) - return { - memberOf: definedIn, - ...makeSimpleEntry(SuggestionKind.Method, definedIn, name, returnType), - } -} - -/** TODO: Add docs */ -export function makeFunction( - definedIn: string, - name: string, - returnType: string = 'Any', -): SuggestionEntry { - assert(isQualifiedName(definedIn)) - assert(isIdentifierOrOperatorIdentifier(name)) - assert(isQualifiedName(returnType)) - return makeSimpleEntry(SuggestionKind.Function, definedIn, name, returnType) -} - -/** TODO: Add docs */ -export function makeLocal( - definedIn: string, - name: string, - returnType: string = 'Any', -): SuggestionEntry { - assert(isQualifiedName(definedIn)) - assert(isIdentifierOrOperatorIdentifier(name)) - assert(isQualifiedName(returnType)) - return makeSimpleEntry(SuggestionKind.Local, definedIn, name, returnType) -} - -/** TODO: Add docs */ -export function makeArgument(name: string, type: string = 'Any'): SuggestionEntryArgument { - return { - name, - reprType: type, - isSuspended: false, - hasDefault: false, - } -} - /** `true` if calling the function without providing a value for this argument will result in an error. */ export function isRequiredArgument(info: SuggestionEntryArgument) { return !!info.defaultValue?.startsWith('Missing_Argument.') diff --git a/app/gui/src/project-view/stores/suggestionDatabase/index.ts b/app/gui/src/project-view/stores/suggestionDatabase/index.ts index 868fb9ec2a53..290c6b4c6b6d 100644 --- a/app/gui/src/project-view/stores/suggestionDatabase/index.ts +++ b/app/gui/src/project-view/stores/suggestionDatabase/index.ts @@ -1,7 +1,7 @@ import { createContextStore } from '@/providers' import { type ProjectStore } from '@/stores/project' import { entryQn, type SuggestionEntry, type SuggestionId } from '@/stores/suggestionDatabase/entry' -import { applyUpdates, entryFromLs } from '@/stores/suggestionDatabase/lsUpdate' +import { SuggestionUpdateProcessor } from '@/stores/suggestionDatabase/lsUpdate' import { ReactiveDb, ReactiveIndex } from '@/util/database/reactiveDb' import { AsyncQueue } from '@/util/net' import { @@ -11,7 +11,7 @@ import { tryQualifiedName, type QualifiedName, } from '@/util/qualifiedName' -import { markRaw, proxyRefs, ref, type Ref } from 'vue' +import { markRaw, proxyRefs, readonly, ref } from 'vue' import { LanguageServer } from 'ydoc-shared/languageServer' import type { MethodPointer } from 'ydoc-shared/languageServerTypes' import * as lsTypes from 'ydoc-shared/languageServerTypes/suggestions' @@ -79,18 +79,17 @@ class Synchronizer { constructor( projectStore: ProjectStore, public entries: SuggestionDb, - public groups: Ref, + updateProcessor: Promise, ) { const lsRpc = projectStore.lsRpcConnection const initState = exponentialBackoff(() => lsRpc.acquireCapability('search/receivesSuggestionsDatabaseUpdates', {}), - ).then((capability) => { + ).then(async (capability) => { if (!capability.ok) { capability.error.log('Will not receive database updates') } - this.#setupUpdateHandler(lsRpc) - this.#loadGroups(lsRpc, projectStore.firstExecution) - return Synchronizer.loadDatabase(entries, lsRpc, groups.value) + this.#setupUpdateHandler(lsRpc, await updateProcessor) + return Synchronizer.loadDatabase(entries, lsRpc, await updateProcessor) }) this.queue = new AsyncQueue(initState) @@ -99,7 +98,7 @@ class Synchronizer { static async loadDatabase( entries: SuggestionDb, lsRpc: LanguageServer, - groups: Group[], + updateProcessor: SuggestionUpdateProcessor, ): Promise<{ currentVersion: number }> { const initialDb = await exponentialBackoff(() => lsRpc.getSuggestionsDatabase()) if (!initialDb.ok) { @@ -109,7 +108,7 @@ class Synchronizer { return { currentVersion: 0 } } for (const lsEntry of initialDb.value.entries) { - const entry = entryFromLs(lsEntry.suggestion, groups) + const entry = updateProcessor.entryFromLs(lsEntry.suggestion) if (!entry.ok) { entry.error.log() console.error(`Skipping entry ${lsEntry.id}, the suggestion database will be incomplete!`) @@ -120,7 +119,7 @@ class Synchronizer { return { currentVersion: initialDb.value.currentVersion } } - #setupUpdateHandler(lsRpc: LanguageServer) { + #setupUpdateHandler(lsRpc: LanguageServer, updateProcessor: SuggestionUpdateProcessor) { lsRpc.on('search/suggestionsDatabaseUpdates', (param) => { this.queue.pushTask(async ({ currentVersion }) => { // There are rare cases where the database is updated twice in quick succession, with the @@ -140,33 +139,30 @@ class Synchronizer { ) return { currentVersion } } else { - applyUpdates(this.entries, param.updates, this.groups.value) + updateProcessor.applyUpdates(this.entries, param.updates) return { currentVersion: param.currentVersion } } }) }) } +} - async #loadGroups(lsRpc: LanguageServer, firstExecution: Promise) { - this.queue.pushTask(async ({ currentVersion }) => { - await firstExecution - const groups = await exponentialBackoff(() => lsRpc.getComponentGroups()) - if (!groups.ok) { - if (!lsRpc.isDisposed) { - groups.error.log('Cannot read component groups. Continuing without groups') - } - return { currentVersion } - } - this.groups.value = groups.value.componentGroups.map( - (group): Group => ({ - name: group.name, - ...(group.color ? { color: group.color } : {}), - project: group.library as QualifiedName, - }), - ) - return { currentVersion } - }) +async function loadGroups(lsRpc: LanguageServer, firstExecution: Promise) { + await firstExecution + const groups = await exponentialBackoff(() => lsRpc.getComponentGroups()) + if (!groups.ok) { + if (!lsRpc.isDisposed) { + groups.error.log('Cannot read component groups. Continuing without groups') + } + return [] } + return groups.value.componentGroups.map( + (group): Group => ({ + name: group.name, + ...(group.color ? { color: group.color } : {}), + project: group.library as QualifiedName, + }), + ) } /** {@link useSuggestionDbStore} composable object */ @@ -177,23 +173,32 @@ export const [provideSuggestionDbStore, useSuggestionDbStore] = createContextSto const entries = new SuggestionDb() const groups = ref([]) + const updateProcessor = loadGroups( + projectStore.lsRpcConnection, + projectStore.firstExecution, + ).then((loadedGroups) => { + groups.value = loadedGroups + return new SuggestionUpdateProcessor(loadedGroups) + }) + /** Add an entry to the suggestion database. */ function mockSuggestion(entry: lsTypes.SuggestionEntry) { const id = Math.max(...entries.nameToId.reverse.keys()) + 1 - applyUpdates( - entries, - [ - { - type: 'Add', - id, - suggestion: entry, - }, - ], - groups.value, - ) + new SuggestionUpdateProcessor([]).applyUpdates(entries, [ + { + type: 'Add', + id, + suggestion: entry, + }, + ]) } - const _synchronizer = new Synchronizer(projectStore, entries, groups) - return proxyRefs({ entries: markRaw(entries), groups, _synchronizer, mockSuggestion }) + const _synchronizer = new Synchronizer(projectStore, entries, updateProcessor) + return proxyRefs({ + entries: markRaw(entries), + groups: readonly(groups), + _synchronizer, + mockSuggestion, + }) }, ) diff --git a/app/gui/src/project-view/stores/suggestionDatabase/lsUpdate.ts b/app/gui/src/project-view/stores/suggestionDatabase/lsUpdate.ts index acb7a3bbf5e2..ab7c01494f12 100644 --- a/app/gui/src/project-view/stores/suggestionDatabase/lsUpdate.ts +++ b/app/gui/src/project-view/stores/suggestionDatabase/lsUpdate.ts @@ -7,14 +7,11 @@ import { SuggestionKind, type SuggestionEntry, type SuggestionEntryArgument, - type SuggestionEntryScope, type Typename, } from '@/stores/suggestionDatabase/entry' import { assert, assertNever } from '@/util/assert' import { type Opt } from '@/util/data/opt' import { Err, Ok, withContext, type Result } from '@/util/data/result' -import type { Doc } from '@/util/docParser' -import type { Icon } from '@/util/iconName' import { normalizeQualifiedName, qnJoin, @@ -24,404 +21,402 @@ import { type IdentifierOrOperatorIdentifier, type QualifiedName, } from '@/util/qualifiedName' +import { type ToValue } from '@/util/reactivity' +import { toValue, type DeepReadonly } from 'vue' import * as lsTypes from 'ydoc-shared/languageServerTypes/suggestions' +import { + SuggestionArgumentUpdate, + SuggestionsDatabaseUpdate, +} from 'ydoc-shared/languageServerTypes/suggestions' -interface UnfinishedEntry { +interface UnfinishedEntry extends Partial { kind: SuggestionKind - definedIn?: QualifiedName - memberOf?: QualifiedName - isPrivate?: boolean - isUnstable?: boolean - name?: IdentifierOrOperatorIdentifier - aliases?: string[] - selfType?: Typename - arguments?: SuggestionEntryArgument[] - returnType?: Typename - parentType?: QualifiedName - reexportedIn?: QualifiedName - documentation?: Doc.Section[] - scope?: SuggestionEntryScope - iconName?: Icon - groupIndex?: number | undefined - annotations?: string[] } -function setLsName( - entry: UnfinishedEntry, - name: string, -): entry is UnfinishedEntry & { name: IdentifierOrOperatorIdentifier } { - const ident = tryIdentifierOrOperatorIdentifier(name) - if (!ident.ok) return false - entry.name = ident.value - return true -} +/** Interprets language server messages to create and update suggestion database entries. */ +export class SuggestionUpdateProcessor { + /** Constructor. */ + constructor(private readonly groups: ToValue>) {} -function setLsModule( - entry: UnfinishedEntry & { name: IdentifierOrOperatorIdentifier }, - module: string, -): entry is UnfinishedEntry & { name: IdentifierOrOperatorIdentifier; definedIn: QualifiedName } { - const qn = tryQualifiedName(module) - if (!qn.ok) return false - const normalizedQn = normalizeQualifiedName(qn.value) - entry.definedIn = normalizedQn - switch (entry.kind) { - case SuggestionKind.Module: - entry.name = qnLastSegment(normalizedQn) - entry.returnType = normalizedQn - break - case SuggestionKind.Type: - entry.returnType = qnJoin(normalizedQn, entry.name) - break + private setLsName( + entry: UnfinishedEntry, + name: string, + ): entry is UnfinishedEntry & { name: IdentifierOrOperatorIdentifier } { + const ident = tryIdentifierOrOperatorIdentifier(name) + if (!ident.ok) return false + entry.name = ident.value + return true } - return true -} -function setAsOwner(entry: UnfinishedEntry, type: string) { - const qn = tryQualifiedName(type) - if (qn.ok) { - entry.memberOf = normalizeQualifiedName(qn.value) - } else { - delete entry.memberOf + private setLsModule( + entry: UnfinishedEntry & { name: IdentifierOrOperatorIdentifier }, + module: string, + ): entry is UnfinishedEntry & { name: IdentifierOrOperatorIdentifier; definedIn: QualifiedName } { + const qn = tryQualifiedName(module) + if (!qn.ok) return false + const normalizedQn = normalizeQualifiedName(qn.value) + entry.definedIn = normalizedQn + switch (entry.kind) { + case SuggestionKind.Module: + entry.name = qnLastSegment(normalizedQn) + entry.returnType = normalizedQn + break + case SuggestionKind.Type: + entry.returnType = qnJoin(normalizedQn, entry.name) + break + } + return true } -} -function setLsSelfType(entry: UnfinishedEntry, selfType: Typename, isStaticParam?: boolean) { - const isStatic = isStaticParam ?? entry.selfType == null - if (!isStatic) entry.selfType = selfType - setAsOwner(entry, selfType) -} + private setAsOwner(entry: UnfinishedEntry, type: string) { + const qn = tryQualifiedName(type) + if (qn.ok) { + entry.memberOf = normalizeQualifiedName(qn.value) + } else { + delete entry.memberOf + } + } -function setLsReturnType( - entry: UnfinishedEntry, - returnType: Typename, -): asserts entry is UnfinishedEntry & { returnType: Typename } { - entry.returnType = returnType - if (entry.kind == SuggestionKind.Constructor) { - setAsOwner(entry, returnType) + private setLsSelfType(entry: UnfinishedEntry, selfType: Typename, isStaticParam?: boolean) { + const isStatic = isStaticParam ?? entry.selfType == null + if (!isStatic) entry.selfType = selfType + this.setAsOwner(entry, selfType) } -} -function setLsReexported( - entry: UnfinishedEntry, - reexported: string, -): entry is UnfinishedEntry & { reexprotedIn: QualifiedName } { - const qn = tryQualifiedName(reexported) - if (!qn.ok) return false - entry.reexportedIn = normalizeQualifiedName(qn.value) - return true -} + private setLsReturnType( + entry: UnfinishedEntry, + returnType: Typename, + ): asserts entry is UnfinishedEntry & { returnType: Typename } { + entry.returnType = returnType + if (entry.kind == SuggestionKind.Constructor) { + this.setAsOwner(entry, returnType) + } + } -function setLsParentType( - entry: UnfinishedEntry, - parentType: string, -): entry is UnfinishedEntry & { parentType: QualifiedName } { - const qn = tryQualifiedName(parentType) - if (!qn.ok) return false - entry.parentType = normalizeQualifiedName(qn.value) - return true -} + private setLsReexported( + entry: UnfinishedEntry, + reexported: string, + ): entry is UnfinishedEntry & { reexprotedIn: QualifiedName } { + const qn = tryQualifiedName(reexported) + if (!qn.ok) return false + entry.reexportedIn = normalizeQualifiedName(qn.value) + return true + } -function setLsDocumentation( - entry: UnfinishedEntry & { definedIn: QualifiedName }, - documentation: Opt, - groups: Group[], -): asserts entry is UnfinishedEntry & { definedIn: QualifiedName } & DocumentationData { - const data = documentationData(documentation, entry.definedIn, groups) - Object.assign(entry, data) - // Removing optional fields. I don't know a better way to do this. - if (data.groupIndex == null) delete entry.groupIndex - if (data.iconName == null) delete entry.iconName -} + private setLsParentType( + entry: UnfinishedEntry, + parentType: string, + ): entry is UnfinishedEntry & { parentType: QualifiedName } { + const qn = tryQualifiedName(parentType) + if (!qn.ok) return false + entry.parentType = normalizeQualifiedName(qn.value) + return true + } + + private setLsDocumentation( + entry: UnfinishedEntry & { definedIn: QualifiedName }, + documentation: Opt, + ): asserts entry is UnfinishedEntry & { definedIn: QualifiedName } & DocumentationData { + const data = documentationData(documentation, entry.definedIn, toValue(this.groups)) + Object.assign(entry, data) + // Removing optional fields. I don't know a better way to do this. + if (data.groupIndex == null) delete entry.groupIndex + if (data.iconName == null) delete entry.iconName + } -/** TODO: Add docs */ -export function entryFromLs( - lsEntry: lsTypes.SuggestionEntry, - groups: Group[], -): Result { - return withContext( - () => `when creating entry`, - () => { - switch (lsEntry.type) { - case 'function': { - const entry = { - kind: SuggestionKind.Function, - annotations: [], + /** Create a suggestion DB entry from data provided by the given language server. */ + entryFromLs(lsEntry: lsTypes.SuggestionEntry): Result { + return withContext( + () => `when creating entry`, + () => { + switch (lsEntry.type) { + case 'function': { + const entry = { + kind: SuggestionKind.Function, + annotations: [], + } + if (!this.setLsName(entry, lsEntry.name)) return Err('Invalid name') + if (!this.setLsModule(entry, lsEntry.module)) return Err('Invalid module name') + this.setLsReturnType(entry, lsEntry.returnType) + this.setLsDocumentation(entry, lsEntry.documentation) + return Ok({ + scope: lsEntry.scope, + arguments: lsEntry.arguments, + ...entry, + }) } - if (!setLsName(entry, lsEntry.name)) return Err('Invalid name') - if (!setLsModule(entry, lsEntry.module)) return Err('Invalid module name') - setLsReturnType(entry, lsEntry.returnType) - setLsDocumentation(entry, lsEntry.documentation, groups) - return Ok({ - scope: lsEntry.scope, - arguments: lsEntry.arguments, - ...entry, - }) - } - case 'module': { - const entry = { - kind: SuggestionKind.Module, - name: 'MODULE' as IdentifierOrOperatorIdentifier, - arguments: [], - returnType: '', - annotations: [], + case 'module': { + const entry = { + kind: SuggestionKind.Module, + name: 'MODULE' as IdentifierOrOperatorIdentifier, + arguments: [], + returnType: '', + annotations: [], + } + if (!this.setLsModule(entry, lsEntry.module)) return Err('Invalid module name') + if (lsEntry.reexport != null && !this.setLsReexported(entry, lsEntry.reexport)) + return Err('Invalid reexported module name') + this.setLsDocumentation(entry, lsEntry.documentation) + assert(entry.returnType !== '') // Should be overwriten + return Ok(entry) } - if (!setLsModule(entry, lsEntry.module)) return Err('Invalid module name') - if (lsEntry.reexport != null && !setLsReexported(entry, lsEntry.reexport)) - return Err('Invalid reexported module name') - setLsDocumentation(entry, lsEntry.documentation, groups) - assert(entry.returnType !== '') // Should be overwriten - return Ok(entry) - } - case 'type': { - const entry = { - kind: SuggestionKind.Type, - returnType: '', - annotations: [], + case 'type': { + const entry = { + kind: SuggestionKind.Type, + returnType: '', + annotations: [], + } + if (!this.setLsName(entry, lsEntry.name)) return Err('Invalid name') + if (!this.setLsModule(entry, lsEntry.module)) return Err('Invalid module name') + if (lsEntry.reexport != null && !this.setLsReexported(entry, lsEntry.reexport)) + return Err('Invalid reexported module name') + if (lsEntry.parentType != null && !this.setLsParentType(entry, lsEntry.parentType)) + return Err('Invalid parent type') + this.setLsDocumentation(entry, lsEntry.documentation) + assert(entry.returnType !== '') // Should be overwriten + return Ok({ + arguments: lsEntry.params, + ...entry, + }) } - if (!setLsName(entry, lsEntry.name)) return Err('Invalid name') - if (!setLsModule(entry, lsEntry.module)) return Err('Invalid module name') - if (lsEntry.reexport != null && !setLsReexported(entry, lsEntry.reexport)) - return Err('Invalid reexported module name') - if (lsEntry.parentType != null && !setLsParentType(entry, lsEntry.parentType)) - return Err('Invalid parent type') - setLsDocumentation(entry, lsEntry.documentation, groups) - assert(entry.returnType !== '') // Should be overwriten - return Ok({ - arguments: lsEntry.params, - ...entry, - }) - } - case 'constructor': { - const entry = { kind: SuggestionKind.Constructor } - if (!setLsName(entry, lsEntry.name)) return Err('Invalid name') - if (!setLsModule(entry, lsEntry.module)) return Err('Invalid module name') - if (lsEntry.reexport != null && !setLsReexported(entry, lsEntry.reexport)) - return Err('Invalid reexported module name') - setLsDocumentation(entry, lsEntry.documentation, groups) - setLsReturnType(entry, lsEntry.returnType) - return Ok({ - arguments: lsEntry.arguments, - annotations: lsEntry.annotations, - ...entry, - }) - } - case 'method': { - const entry = { kind: SuggestionKind.Method } - if (!setLsName(entry, lsEntry.name)) return Err('Invalid name') - if (!setLsModule(entry, lsEntry.module)) return Err('Invalid module name') - if (lsEntry.reexport != null && !setLsReexported(entry, lsEntry.reexport)) - return Err('Invalid reexported module name') - setLsDocumentation(entry, lsEntry.documentation, groups) - setLsSelfType(entry, lsEntry.selfType, lsEntry.isStatic) - setLsReturnType(entry, lsEntry.returnType) - return Ok({ - arguments: lsEntry.arguments, - annotations: lsEntry.annotations, - ...entry, - }) - } - case 'local': { - const entry = { - kind: SuggestionKind.Local, - arguments: [], - annotations: [], + case 'constructor': { + const entry = { kind: SuggestionKind.Constructor } + if (!this.setLsName(entry, lsEntry.name)) return Err('Invalid name') + if (!this.setLsModule(entry, lsEntry.module)) return Err('Invalid module name') + if (lsEntry.reexport != null && !this.setLsReexported(entry, lsEntry.reexport)) + return Err('Invalid reexported module name') + this.setLsDocumentation(entry, lsEntry.documentation) + this.setLsReturnType(entry, lsEntry.returnType) + return Ok({ + arguments: lsEntry.arguments, + annotations: lsEntry.annotations, + ...entry, + }) } - if (!setLsName(entry, lsEntry.name)) return Err('Invalid name') - if (!setLsModule(entry, lsEntry.module)) return Err('Invalid module name') - setLsReturnType(entry, lsEntry.returnType) - setLsDocumentation(entry, lsEntry.documentation, groups) - return Ok({ - scope: lsEntry.scope, - ...entry, - }) - } - default: - assertNever(lsEntry) - } - }, - ) -} - -function applyFieldUpdate( - name: K, - update: { [P in K]?: lsTypes.FieldUpdate }, - updater: (newValue: T) => R, -): Result> { - const field = update[name] - if (field == null) return Ok(null) - return withContext( - () => `when handling field "${name}" update`, - () => { - switch (field.tag) { - case 'Set': - if (field.value != null) { - return Ok(updater(field.value)) - } else { - return Err('Received "Set" update with no value') + case 'method': { + const entry = { kind: SuggestionKind.Method } + if (!this.setLsName(entry, lsEntry.name)) return Err('Invalid name') + if (!this.setLsModule(entry, lsEntry.module)) return Err('Invalid module name') + if (lsEntry.reexport != null && !this.setLsReexported(entry, lsEntry.reexport)) + return Err('Invalid reexported module name') + this.setLsDocumentation(entry, lsEntry.documentation) + this.setLsSelfType(entry, lsEntry.selfType, lsEntry.isStatic) + this.setLsReturnType(entry, lsEntry.returnType) + return Ok({ + arguments: lsEntry.arguments, + annotations: lsEntry.annotations, + ...entry, + }) } - case 'Remove': - return Err(`Received "Remove" for non-optional field`) - default: - return Err(`Received field update with unknown value`) - } - }, - ) -} + case 'local': { + const entry = { + kind: SuggestionKind.Local, + arguments: [], + annotations: [], + } + if (!this.setLsName(entry, lsEntry.name)) return Err('Invalid name') + if (!this.setLsModule(entry, lsEntry.module)) return Err('Invalid module name') + this.setLsReturnType(entry, lsEntry.returnType) + this.setLsDocumentation(entry, lsEntry.documentation) + return Ok({ + scope: lsEntry.scope, + ...entry, + }) + } + default: + assertNever(lsEntry) + } + }, + ) + } -function applyPropertyUpdate( - name: K, - obj: { [P in K]: T }, - update: { [P in K]?: lsTypes.FieldUpdate }, -): Result { - const apply = applyFieldUpdate(name, update, (newValue) => { - obj[name] = newValue - }) - if (!apply.ok) return apply - return Ok(undefined) -} + private applyFieldUpdate( + name: K, + update: { [P in K]?: lsTypes.FieldUpdate }, + updater: (newValue: T) => R, + ): Result> { + const field = update[name] + if (field == null) return Ok(null) + return withContext( + () => `when handling field "${name}" update`, + () => { + switch (field.tag) { + case 'Set': + if (field.value != null) { + return Ok(updater(field.value)) + } else { + return Err('Received "Set" update with no value') + } + case 'Remove': + return Err(`Received "Remove" for non-optional field`) + default: + return Err(`Received field update with unknown value`) + } + }, + ) + } -function applyOptPropertyUpdate( - name: K, - obj: { [P in K]?: T }, - update: { [P in K]?: lsTypes.FieldUpdate }, -) { - const field = update[name] - switch (field?.tag) { - case 'Set': - obj[name] = field.value - break - case 'Remove': - delete obj[name] - break + private applyPropertyUpdate( + name: K, + obj: { [P in K]: T }, + update: { [P in K]?: lsTypes.FieldUpdate }, + ): Result { + const apply = this.applyFieldUpdate(name, update, (newValue) => { + obj[name] = newValue + }) + if (!apply.ok) return apply + return Ok() } -} -function applyArgumentsUpdate( - args: SuggestionEntryArgument[], - update: lsTypes.SuggestionArgumentUpdate, -): Result { - switch (update.type) { - case 'Add': { - args.splice(update.index, 0, update.argument) - return Ok(undefined) - } - case 'Remove': { - args.splice(update.index, 1) - return Ok(undefined) - } - case 'Modify': { - return withContext( - () => `when modifying argument with index ${update.index}`, - () => { - const arg = args[update.index] - if (arg == null) return Err(`Wrong argument index ${update.index}`) - const nameUpdate = applyPropertyUpdate('name', arg, update) - if (!nameUpdate.ok) return nameUpdate - const typeUpdate = applyFieldUpdate('reprType', update, (type) => { - arg.reprType = type - }) - if (!typeUpdate.ok) return typeUpdate - const isSuspendedUpdate = applyPropertyUpdate('isSuspended', arg, update) - if (!isSuspendedUpdate.ok) return isSuspendedUpdate - const hasDefaultUpdate = applyPropertyUpdate('hasDefault', arg, update) - if (!hasDefaultUpdate.ok) return hasDefaultUpdate - applyOptPropertyUpdate('defaultValue', arg, update) - return Ok(undefined) - }, - ) + private applyOptPropertyUpdate( + name: K, + obj: { [P in K]?: T }, + update: { [P in K]?: lsTypes.FieldUpdate }, + ) { + const field = update[name] + switch (field?.tag) { + case 'Set': + obj[name] = field.value + break + case 'Remove': + delete obj[name] + break } } -} -/** TODO: Add docs */ -export function applyUpdate( - entries: SuggestionDb, - update: lsTypes.SuggestionsDatabaseUpdate, - groups: Group[], -): Result { - switch (update.type) { - case 'Add': { - return withContext( - () => `when adding new entry ${JSON.stringify(update)}`, - () => { - const newEntry = entryFromLs(update.suggestion, groups) - if (!newEntry.ok) return newEntry - entries.set(update.id, newEntry.value) - return Ok(undefined) - }, - ) - } - case 'Remove': { - if (!entries.delete(update.id)) { - return Err(`Received "Remove" suggestion database update for non-existing id ${update.id}.`) + private applyArgumentsUpdate( + args: SuggestionEntryArgument[], + update: lsTypes.SuggestionArgumentUpdate, + ): Result { + switch (update.type) { + case 'Add': { + args.splice(update.index, 0, update.argument) + return Ok() + } + case 'Remove': { + args.splice(update.index, 1) + return Ok() + } + case 'Modify': { + return withContext( + () => `when modifying argument with index ${update.index}`, + () => { + const arg = args[update.index] + if (arg == null) return Err(`Wrong argument index ${update.index}`) + return this.modifyArgument(arg, update) + }, + ) } - return Ok(undefined) } - case 'Modify': { - return withContext( - () => `when modifying entry to ${JSON.stringify(update)}`, - () => { - const entry = entries.get(update.id) - if (entry == null) { - return Err(`Entry with id ${update.id} does not exist.`) - } + } - for (const argumentUpdate of update.arguments ?? []) { - const updateResult = applyArgumentsUpdate(entry.arguments, argumentUpdate) - if (!updateResult.ok) return updateResult - } + private modifyArgument( + arg: SuggestionEntryArgument, + update: SuggestionArgumentUpdate.Modify, + ): Result { + const nameUpdate = this.applyPropertyUpdate('name', arg, update) + if (!nameUpdate.ok) return nameUpdate + const typeUpdate = this.applyFieldUpdate('reprType', update, (type) => { + arg.reprType = type + }) + if (!typeUpdate.ok) return typeUpdate + const isSuspendedUpdate = this.applyPropertyUpdate('isSuspended', arg, update) + if (!isSuspendedUpdate.ok) return isSuspendedUpdate + const hasDefaultUpdate = this.applyPropertyUpdate('hasDefault', arg, update) + if (!hasDefaultUpdate.ok) return hasDefaultUpdate + this.applyOptPropertyUpdate('defaultValue', arg, update) + return Ok() + } - const moduleUpdate = applyFieldUpdate('module', update, (module) => - setLsModule(entry, module), + private applyUpdate( + entries: SuggestionDb, + update: lsTypes.SuggestionsDatabaseUpdate, + ): Result { + switch (update.type) { + case 'Add': { + return withContext( + () => `when adding new entry ${JSON.stringify(update)}`, + () => { + const newEntry = this.entryFromLs(update.suggestion) + if (!newEntry.ok) return newEntry + entries.set(update.id, newEntry.value) + return Ok() + }, + ) + } + case 'Remove': { + if (!entries.delete(update.id)) { + return Err( + `Received "Remove" suggestion database update for non-existing id ${update.id}.`, ) - if (!moduleUpdate.ok) return moduleUpdate - if (moduleUpdate.value === false) return Err('Invalid module name') + } + return Ok() + } + case 'Modify': { + return withContext( + () => `when modifying entry to ${JSON.stringify(update)}`, + () => { + const entry = entries.get(update.id) + if (entry == null) return Err(`Entry with id ${update.id} does not exist.`) + return this.modifyEntry(entry, update) + }, + ) + } + } + } - const selfTypeUpdate = applyFieldUpdate('selfType', update, (selfType) => - setLsSelfType(entry, selfType), - ) - if (!selfTypeUpdate.ok) return selfTypeUpdate + private modifyEntry( + entry: SuggestionEntry, + update: SuggestionsDatabaseUpdate.Modify, + ): Result { + for (const argumentUpdate of update.arguments ?? []) { + const updateResult = this.applyArgumentsUpdate(entry.arguments, argumentUpdate) + if (!updateResult.ok) return updateResult + } - const returnTypeUpdate = applyFieldUpdate('returnType', update, (returnType) => { - setLsReturnType(entry, returnType) - }) - if (!returnTypeUpdate.ok) return returnTypeUpdate + const moduleUpdate = this.applyFieldUpdate('module', update, (module) => + this.setLsModule(entry, module), + ) + if (!moduleUpdate.ok) return moduleUpdate + if (moduleUpdate.value === false) return Err('Invalid module name') - if (update.documentation != null) - setLsDocumentation(entry, update.documentation.value, groups) + const selfTypeUpdate = this.applyFieldUpdate('selfType', update, (selfType) => + this.setLsSelfType(entry, selfType), + ) + if (!selfTypeUpdate.ok) return selfTypeUpdate - applyOptPropertyUpdate('scope', entry, update) + const returnTypeUpdate = this.applyFieldUpdate('returnType', update, (returnType) => { + this.setLsReturnType(entry, returnType) + }) + if (!returnTypeUpdate.ok) return returnTypeUpdate - if (update.reexport != null) { - if (update.reexport.value != null) { - const reexport = tryQualifiedName(update.reexport.value) - if (!reexport.ok) return reexport - entry.reexportedIn = reexport.value - } else { - delete entry.reexportedIn - } - } + if (update.documentation != null) this.setLsDocumentation(entry, update.documentation.value) + + this.applyOptPropertyUpdate('scope', entry, update) - return Ok(undefined) - }, - ) + if (update.reexport != null) { + if (update.reexport.value != null) { + const reexport = tryQualifiedName(update.reexport.value) + if (!reexport.ok) return reexport + entry.reexportedIn = reexport.value + } else { + delete entry.reexportedIn + } } + + return Ok() } -} -/** TODO: Add docs */ -export function applyUpdates( - entries: SuggestionDb, - updates: lsTypes.SuggestionsDatabaseUpdate[], - groups: Group[], -) { - for (const update of updates) { - const updateResult = applyUpdate(entries, update, groups) - if (!updateResult.ok) { - updateResult.error.log() - if (entries.get(update.id) != null) { - console.error(`Removing entry ${update.id}, because its state is unclear`) - entries.delete(update.id) + /** Update a suggestion database according to information provided by the language server. */ + applyUpdates(entries: SuggestionDb, updates: lsTypes.SuggestionsDatabaseUpdate[]) { + for (const update of updates) { + const updateResult = this.applyUpdate(entries, update) + if (!updateResult.ok) { + updateResult.error.log() + if (entries.get(update.id) != null) { + console.error(`Removing entry ${update.id}, because its state is unclear`) + entries.delete(update.id) + } } } } diff --git a/app/gui/src/project-view/stores/suggestionDatabase/mockSuggestion.ts b/app/gui/src/project-view/stores/suggestionDatabase/mockSuggestion.ts new file mode 100644 index 000000000000..9d27452f905e --- /dev/null +++ b/app/gui/src/project-view/stores/suggestionDatabase/mockSuggestion.ts @@ -0,0 +1,140 @@ +import { + type SuggestionEntry, + type SuggestionEntryArgument, +} from '@/stores/suggestionDatabase/entry' +import { SuggestionUpdateProcessor } from '@/stores/suggestionDatabase/lsUpdate' +import { ANY_TYPE_QN } from '@/util/ensoTypes' +import { isQualifiedName, qnParent, qnSplit } from '@/util/qualifiedName' +import * as lsTypes from 'ydoc-shared/languageServerTypes/suggestions' +import { assert } from 'ydoc-shared/util/assert' +import { unwrap } from 'ydoc-shared/util/data/result' + +function makeEntry(lsEntry: lsTypes.SuggestionEntry) { + return unwrap(new SuggestionUpdateProcessor([]).entryFromLs(lsEntry)) +} + +const EMPTY_SCOPE = { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } } + +/** Mock a module suggestion entry. */ +export function makeModule(fqn: string): SuggestionEntry { + return makeEntry({ + type: 'module', + module: fqn, + }) +} + +/** Mock a type suggestion entry. */ +export function makeType(fqn: string): SuggestionEntry { + assert(isQualifiedName(fqn)) + const [definedIn, name] = qnSplit(fqn) + assert(definedIn != null) + return makeEntry({ + type: 'type', + module: definedIn, + name, + params: [], + }) +} + +/** Mock a type constructor suggestion entry. */ +export function makeConstructor(fqn: string): SuggestionEntry { + assert(isQualifiedName(fqn)) + const [type, name] = qnSplit(fqn) + assert(type != null) + const definedIn = qnParent(type) + assert(definedIn != null) + return makeEntry({ + type: 'constructor', + name, + module: definedIn, + arguments: [], + returnType: type, + annotations: [], + }) +} + +/** Mock a type method suggestion entry. */ +export function makeMethod( + fqn: string, + returnType: string = ANY_TYPE_QN, + isStatic: boolean = false, +): SuggestionEntry { + assert(isQualifiedName(fqn)) + const [type, name] = qnSplit(fqn) + assert(type != null) + const definedIn = qnParent(type) + assert(definedIn != null) + return makeEntry({ + type: 'method', + name, + module: definedIn, + arguments: [], + selfType: type, + returnType, + isStatic, + annotations: [], + }) +} + +/** Mock a static type method suggestion entry. */ +export function makeStaticMethod(fqn: string, returnType: string = ANY_TYPE_QN): SuggestionEntry { + return makeMethod(fqn, returnType, true) +} + +/** Mock a module method suggestion entry. */ +export function makeModuleMethod(fqn: string, returnType: string = ANY_TYPE_QN): SuggestionEntry { + assert(isQualifiedName(fqn)) + const [module, name] = qnSplit(fqn) + assert(module != null) + return makeEntry({ + type: 'method', + name, + module, + arguments: [], + selfType: module, + returnType, + isStatic: true, + annotations: [], + }) +} + +/** Mock a function suggestion entry. */ +export function makeFunction( + definedIn: string, + name: string, + returnType: string = ANY_TYPE_QN, +): SuggestionEntry { + return makeEntry({ + type: 'function', + name, + module: definedIn, + arguments: [], + returnType, + scope: EMPTY_SCOPE, + }) +} + +/** Mock a local variable suggestion entry. */ +export function makeLocal( + definedIn: string, + name: string, + returnType: string = ANY_TYPE_QN, +): SuggestionEntry { + return makeEntry({ + type: 'local', + name, + module: definedIn, + returnType, + scope: EMPTY_SCOPE, + }) +} + +/** Mock a suggestion entry argument specification. */ +export function makeArgument(name: string, type: string = ANY_TYPE_QN): SuggestionEntryArgument { + return { + name, + reprType: type, + isSuspended: false, + hasDefault: false, + } +} diff --git a/app/gui/src/project-view/util/__tests__/callTree.test.ts b/app/gui/src/project-view/util/__tests__/callTree.test.ts index 73131960ca89..362f5f6e1af1 100644 --- a/app/gui/src/project-view/util/__tests__/callTree.test.ts +++ b/app/gui/src/project-view/util/__tests__/callTree.test.ts @@ -2,6 +2,7 @@ import * as widgetCfg from '@/providers/widgetRegistry/configuration' import { GraphDb } from '@/stores/graph/graphDatabase' import { ComputedValueRegistry, type ExpressionInfo } from '@/stores/project/computedValueRegistry' import { SuggestionDb } from '@/stores/suggestionDatabase' +import { type SuggestionEntry } from '@/stores/suggestionDatabase/entry' import { makeArgument, makeConstructor, @@ -9,8 +10,7 @@ import { makeModule, makeModuleMethod, makeType, - type SuggestionEntry, -} from '@/stores/suggestionDatabase/entry' +} from '@/stores/suggestionDatabase/mockSuggestion' import { Ast } from '@/util/ast' import type { AstId } from '@/util/ast/abstract' import { diff --git a/app/gui/src/project-view/util/ast/abstract.ts b/app/gui/src/project-view/util/ast/abstract.ts index 9dd919ce9b8f..cffd99e12fab 100644 --- a/app/gui/src/project-view/util/ast/abstract.ts +++ b/app/gui/src/project-view/util/ast/abstract.ts @@ -182,8 +182,8 @@ export function parseIdents(ast: Ast): IdentifierOrOperatorIdentifier[] | null { return unrollOprChain(ast, ',') } -/** TODO: Add docs */ -export function parseQualifiedName(ast: Ast): QualifiedName | null { +/** If the syntax tree represents a valid qualified name, return an equivalent {@link QualifiedName}. */ +export function astToQualifiedName(ast: Ast): QualifiedName | null { const idents = unrollPropertyAccess(ast) return idents && normalizeQualifiedName(qnFromSegments(idents)) } @@ -223,7 +223,7 @@ export function substituteQualifiedName( to: QualifiedName, ) { if (expr instanceof MutablePropertyAccess || expr instanceof MutableIdent) { - const qn = parseQualifiedName(expr) + const qn = astToQualifiedName(expr) if (qn === pattern) { expr.updateValue(() => parseExpression(to, expr.module)!) } else if (qn && qn.startsWith(pattern)) { diff --git a/app/gui/src/project-view/util/callTree.ts b/app/gui/src/project-view/util/callTree.ts index af374226c358..86cece474e0a 100644 --- a/app/gui/src/project-view/util/callTree.ts +++ b/app/gui/src/project-view/util/callTree.ts @@ -117,7 +117,7 @@ export class ArgumentPlaceholder extends Argument { /** TODO: Add docs */ get value(): WidgetInputValue { - return this.argInfo.defaultValue + return this.argInfo.defaultValue === null ? undefined : this.argInfo.defaultValue } /** Whether the argument should be hidden when the component isn't currently focused for editing. */ diff --git a/app/gui/src/project-view/util/data/array.ts b/app/gui/src/project-view/util/data/array.ts index 987fcd76811f..fdf7c6d88c69 100644 --- a/app/gui/src/project-view/util/data/array.ts +++ b/app/gui/src/project-view/util/data/array.ts @@ -7,7 +7,7 @@ export type NonEmptyArray = [T, ...T[]] /** An equivalent of `Array.prototype.findIndex` method, but returns null instead of -1. */ export function findIndexOpt( - arr: T[], + arr: ReadonlyArray, pred: (elem: T, index: number) => boolean, ): number | null { const index = arr.findIndex(pred) diff --git a/app/gui/src/project-view/util/ensoTypes.ts b/app/gui/src/project-view/util/ensoTypes.ts new file mode 100644 index 000000000000..0cac09b91af6 --- /dev/null +++ b/app/gui/src/project-view/util/ensoTypes.ts @@ -0,0 +1,4 @@ +import { tryQualifiedName } from '@/util/qualifiedName' +import { unwrap } from 'ydoc-shared/util/data/result' + +export const ANY_TYPE_QN = unwrap(tryQualifiedName('Standard.Base.Any.Any')) diff --git a/app/ydoc-shared/src/languageServerTypes/suggestions.ts b/app/ydoc-shared/src/languageServerTypes/suggestions.ts index 44513d873a33..e75ec896844b 100644 --- a/app/ydoc-shared/src/languageServerTypes/suggestions.ts +++ b/app/ydoc-shared/src/languageServerTypes/suggestions.ts @@ -13,9 +13,9 @@ export interface SuggestionEntryArgument { /** Indicates whether the argument has default value. */ hasDefault: boolean /** Optional default value. */ - defaultValue?: string + defaultValue?: string | null /** Optional list of possible values that this argument takes. */ - tagValues?: string[] + tagValues?: string[] | null } export interface Position { @@ -218,11 +218,11 @@ export interface FieldUpdate { } export type SuggestionArgumentUpdate = - | suggestionArgumentUpdateVariant.Add - | suggestionArgumentUpdateVariant.Remove - | suggestionArgumentUpdateVariant.Modify + | SuggestionArgumentUpdate.Add + | SuggestionArgumentUpdate.Remove + | SuggestionArgumentUpdate.Modify -namespace suggestionArgumentUpdateVariant { +export namespace SuggestionArgumentUpdate { export interface Add { type: 'Add' /** The position of the argument. */ @@ -261,11 +261,11 @@ namespace suggestionArgumentUpdateVariant { } export type SuggestionsDatabaseUpdate = - | suggestionDatabaseUpdateVariant.Add - | suggestionDatabaseUpdateVariant.Remove - | suggestionDatabaseUpdateVariant.Modify + | SuggestionsDatabaseUpdate.Add + | SuggestionsDatabaseUpdate.Remove + | SuggestionsDatabaseUpdate.Modify -namespace suggestionDatabaseUpdateVariant { +export namespace SuggestionsDatabaseUpdate { export interface Add { type: 'Add' /** Suggestion entry id. */ From 21531e3c3f61bf19e74953738c72910112943f24 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Fri, 3 Jan 2025 18:26:48 +1000 Subject: [PATCH 12/12] Edit text for "delete asset" modal (#11857) - Close https://github.com/enso-org/cloud-v2/issues/1626 - Remove repetition of "are you sure" - Add clarification that hard delete from trash cannot be undone # Important Notes None --- app/common/src/text/english.json | 4 +- app/common/src/text/index.ts | 5 +- .../dashboard/actions/BaseActions.ts | 73 +++++++++++++++--- .../dashboard/actions/DrivePageActions.ts | 8 ++ .../integration-test/dashboard/actions/api.ts | 75 ++++++++++++++++++- .../dashboard/actions/contextMenuActions.ts | 15 ++-- .../integration-test/dashboard/delete.spec.ts | 44 +++++++++++ .../dashboard/layouts/AssetContextMenu.tsx | 3 +- .../layouts/AssetsTableContextMenu.tsx | 2 +- app/gui/src/dashboard/layouts/DriveBar.tsx | 4 +- .../dashboard/modals/ConfirmDeleteModal.tsx | 19 ++++- 11 files changed, 221 insertions(+), 31 deletions(-) diff --git a/app/common/src/text/english.json b/app/common/src/text/english.json index 35c1aea3cf49..2a8daff68ada 100644 --- a/app/common/src/text/english.json +++ b/app/common/src/text/english.json @@ -411,6 +411,7 @@ "thisFolderFailedToFetch": "This folder failed to fetch.", "yourTrashIsEmpty": "Your trash is empty.", "deleteTheAssetTypeTitle": "delete the $0 '$1'", + "deleteTheAssetTypeTitleForever": "permanently delete the $0 '$1'", "trashTheAssetTypeTitle": "move the $0 '$1' to Trash", "notImplemetedYet": "Not implemented yet.", "newLabelButtonLabel": "New label", @@ -456,7 +457,8 @@ "youHaveNoRecentProjects": "You have no recent projects. Switch to another category to create a project.", "youHaveNoFiles": "This folder is empty. You can create a project using the buttons above.", "placeholderChatPrompt": "Login or register to access live chat with our support team.", - "confirmPrompt": "Are you sure you want to $0?", + "confirmPrompt": "Do you really want to $0?", + "thisOperationCannotBeUndone": "This operation is final and cannot be undone.", "couldNotInviteUser": "Could not invite user $0", "inviteFormSeatsLeft": "You have $0 seats left on your plan. Upgrade to invite more", "inviteFormSeatsLeftError": "You have exceed the number of seats on your plan by $0", diff --git a/app/common/src/text/index.ts b/app/common/src/text/index.ts index 31a63e87bdbb..55ebb239a7e3 100644 --- a/app/common/src/text/index.ts +++ b/app/common/src/text/index.ts @@ -3,10 +3,6 @@ import { unsafeKeys } from '../utilities/data/object' import ENGLISH from './english.json' with { type: 'json' } -// ============= -// === Types === -// ============= - /** Possible languages in which to display text. */ export enum Language { english = 'english', @@ -46,6 +42,7 @@ interface PlaceholderOverrides { readonly confirmPrompt: [action: string] readonly trashTheAssetTypeTitle: [assetType: string, assetName: string] readonly deleteTheAssetTypeTitle: [assetType: string, assetName: string] + readonly deleteTheAssetTypeTitleForever: [assetType: string, assetName: string] readonly couldNotInviteUser: [userEmail: string] readonly filesWithoutConflicts: [fileCount: number] readonly projectsWithoutConflicts: [projectCount: number] diff --git a/app/gui/integration-test/dashboard/actions/BaseActions.ts b/app/gui/integration-test/dashboard/actions/BaseActions.ts index 56cfa3562134..77c71501dc22 100644 --- a/app/gui/integration-test/dashboard/actions/BaseActions.ts +++ b/app/gui/integration-test/dashboard/actions/BaseActions.ts @@ -1,10 +1,10 @@ /** @file The base class from which all `Actions` classes are derived. */ import { expect, test, type Locator, type Page } from '@playwright/test' -import type { AutocompleteKeybind } from '#/utilities/inputBindings' +import type { AutocompleteKeybind, ModifierKey } from '#/utilities/inputBindings' /** `Meta` (`Cmd`) on macOS, and `Control` on all other platforms. */ -async function modModifier(page: Page) { +export async function modModifier(page: Page) { let userAgent = '' await test.step('Detect browser OS', async () => { userAgent = await page.evaluate(() => navigator.userAgent) @@ -51,11 +51,17 @@ export default class BaseActions implements Promise { } /** - * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` - * on all other platforms. + * Return the appropriate key for a shortcut, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, + * and `Control` on all other platforms. Similarly, replace the text `Delete` with `Backspace` + * on `macOS`, and `Delete` on all other platforms. */ - static press(page: Page, keyOrShortcut: string): Promise { - return test.step(`Press '${keyOrShortcut}'`, async () => { + static async withNormalizedKey( + page: Page, + keyOrShortcut: string, + callback: (shortcut: string) => Promise, + description = 'Normalize', + ): Promise { + return test.step(`${description} '${keyOrShortcut}'`, async () => { if (/\bMod\b|\bDelete\b/.test(keyOrShortcut)) { let userAgent = '' await test.step('Detect browser OS', async () => { @@ -65,13 +71,23 @@ export default class BaseActions implements Promise { const ctrlKey = isMacOS ? 'Meta' : 'Control' const deleteKey = isMacOS ? 'Backspace' : 'Delete' const shortcut = keyOrShortcut.replace(/\bMod\b/, ctrlKey).replace(/\bDelete\b/, deleteKey) - await page.keyboard.press(shortcut) + return await callback(shortcut) } else { - await page.keyboard.press(keyOrShortcut) + return callback(keyOrShortcut) } }) } + /** Press a key or shortcut. */ + static async press(page: Page, keyOrShortcut: string) { + await BaseActions.withNormalizedKey( + page, + keyOrShortcut, + (shortcut) => page.keyboard.press(shortcut), + 'Press and release', + ) + } + /** Proxies the `then` method of the internal {@link Promise}. */ async then( onfulfilled?: (() => PromiseLike | T) | null | undefined, @@ -135,8 +151,45 @@ export default class BaseActions implements Promise { * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` * on all other platforms. */ - press(keyOrShortcut: AutocompleteKeybind) { - return this.do((page) => BaseActions.press(page, keyOrShortcut)) + press(keyOrShortcut: AutocompleteKeybind | ModifierKey) { + return this.do((page) => + BaseActions.withNormalizedKey( + page, + keyOrShortcut, + (shortcut) => page.keyboard.press(shortcut), + 'Press and release', + ), + ) + } + + /** + * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` + * on all other platforms. + */ + down(keyOrShortcut: AutocompleteKeybind | ModifierKey) { + return this.do((page) => + BaseActions.withNormalizedKey( + page, + keyOrShortcut, + (shortcut) => page.keyboard.down(shortcut), + 'Press', + ), + ) + } + + /** + * Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` + * on all other platforms. + */ + up(keyOrShortcut: AutocompleteKeybind | ModifierKey) { + return this.do((page) => + BaseActions.withNormalizedKey( + page, + keyOrShortcut, + (shortcut) => page.keyboard.up(shortcut), + 'Release', + ), + ) } /** Perform actions until a predicate passes. */ diff --git a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts index 0ecec073dc50..b31d0a05be85 100644 --- a/app/gui/integration-test/dashboard/actions/DrivePageActions.ts +++ b/app/gui/integration-test/dashboard/actions/DrivePageActions.ts @@ -375,6 +375,14 @@ export default class DrivePageActions extends PageActions { }) } + /** Clear trash. */ + clearTrash() { + return this.step('Clear trash', async (page) => { + await page.getByText(TEXT.clearTrash).click() + await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() + }) + } + /** Create a new empty project. */ newEmptyProject() { return this.step( diff --git a/app/gui/integration-test/dashboard/actions/api.ts b/app/gui/integration-test/dashboard/actions/api.ts index 980a76c3cef3..8f12be78c4a3 100644 --- a/app/gui/integration-test/dashboard/actions/api.ts +++ b/app/gui/integration-test/dashboard/actions/api.ts @@ -103,7 +103,7 @@ const INITIAL_CALLS_OBJECT = { updateDirectory: array< { directoryId: backend.DirectoryId } & backend.UpdateDirectoryRequestBody >(), - deleteAsset: array<{ assetId: backend.AssetId }>(), + deleteAsset: array<{ assetId: backend.AssetId; force: boolean }>(), undoDeleteAsset: array<{ assetId: backend.AssetId }>(), createUser: array(), createUserGroup: array(), @@ -283,6 +283,17 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return !alreadyDeleted } + const forceDeleteAsset = (assetId: backend.AssetId) => { + const hasAsset = assetMap.has(assetId) + deletedAssets.delete(assetId) + assetMap.delete(assetId) + assets.splice( + assets.findIndex((asset) => asset.id === assetId), + 1, + ) + return hasAsset + } + const undeleteAsset = (assetId: backend.AssetId) => { const wasDeleted = deletedAssets.has(assetId) deletedAssets.delete(assetId) @@ -487,7 +498,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { description: rest.description ?? '', labels: [], parentId: defaultDirectoryId, - permissions: [], + permissions: [createUserPermission(defaultUser, permissions.PermissionAction.own)], parentsPath: '', virtualParentsPath: '', }, @@ -517,6 +528,48 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return secret } + const createDatalink = (rest: Partial): backend.DatalinkAsset => { + const datalink = object.merge( + { + type: backend.AssetType.datalink, + id: backend.DatalinkId('datalink-' + uniqueString.uniqueString()), + projectState: null, + extension: null, + title: rest.title ?? '', + modifiedAt: dateTime.toRfc3339(new Date()), + description: rest.description ?? '', + labels: [], + parentId: defaultDirectoryId, + permissions: [createUserPermission(defaultUser, permissions.PermissionAction.own)], + parentsPath: '', + virtualParentsPath: '', + }, + rest, + ) + + Object.defineProperty(datalink, 'toJSON', { + value: function toJSON() { + const { parentsPath: _, virtualParentsPath: __, ...rest } = this + + return { + ...rest, + parentsPath: this.parentsPath, + virtualParentsPath: this.virtualParentsPath, + } + }, + }) + + Object.defineProperty(datalink, 'parentsPath', { + get: () => getParentPath(datalink.parentId), + }) + + Object.defineProperty(datalink, 'virtualParentsPath', { + get: () => getVirtualParentPath(datalink.parentId, datalink.title), + }) + + return datalink + } + const createLabel = (value: string, color: backend.LChColor): backend.Label => ({ id: backend.TagId('tag-' + uniqueString.uniqueString()), value: backend.LabelName(value), @@ -539,6 +592,10 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { return addAsset(createSecret(rest)) } + const addDatalink = (rest: Partial = {}) => { + return addAsset(createDatalink(rest)) + } + const addLabel = (value: string, color: backend.LChColor) => { const label = createLabel(value, color) labels.push(label) @@ -1109,6 +1166,7 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { }) await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async (route, request) => { + const force = new URL(request.url()).searchParams.get('force') === 'true' const maybeId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] if (!maybeId) return @@ -1117,9 +1175,13 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { // `DirectoryId` to make TypeScript happy. const assetId = decodeURIComponent(maybeId) as backend.DirectoryId - called('deleteAsset', { assetId }) + called('deleteAsset', { assetId, force }) - deleteAsset(assetId) + if (force) { + forceDeleteAsset(assetId) + } else { + deleteAsset(assetId) + } await route.fulfill({ status: HTTP_STATUS_NO_CONTENT }) }) @@ -1365,6 +1427,9 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { defaultUser, defaultUserId, rootDirectoryId: defaultDirectoryId, + get assetCount() { + return assetMap.size + }, goOffline: () => { isOnline = false }, @@ -1395,10 +1460,12 @@ async function mockApiInternal({ page, setupAPI }: MockParams) { createProject, createFile, createSecret, + createDatalink, addDirectory, addProject, addFile, addSecret, + addDatalink, createLabel, addLabel, setLabels, diff --git a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts index f125a3325196..a7fa28156008 100644 --- a/app/gui/integration-test/dashboard/actions/contextMenuActions.ts +++ b/app/gui/integration-test/dashboard/actions/contextMenuActions.ts @@ -12,7 +12,7 @@ export interface ContextMenuActions, Context> { readonly snapshot: () => T readonly moveNonFolderToTrash: () => T readonly moveFolderToTrash: () => T - readonly moveAllToTrash: () => T + readonly moveAllToTrash: (confirm?: boolean) => T readonly restoreFromTrash: () => T readonly restoreAllFromTrash: () => T readonly share: () => T @@ -77,13 +77,16 @@ export function contextMenuActions, Context>( // Confirm the deletion in the dialog await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() }), - moveAllToTrash: () => - step('Move all to trash (context menu)', (page) => - page + moveAllToTrash: (hasFolder = false) => + step('Move all to trash (context menu)', async (page) => { + await page .getByRole('button', { name: TEXT.moveAllToTrashShortcut }) .getByText(TEXT.moveAllToTrashShortcut) - .click(), - ), + .click() + if (hasFolder) { + await page.getByRole('button', { name: TEXT.delete }).getByText(TEXT.delete).click() + } + }), restoreFromTrash: () => step('Restore from trash (context menu)', (page) => page diff --git a/app/gui/integration-test/dashboard/delete.spec.ts b/app/gui/integration-test/dashboard/delete.spec.ts index c2bfd6d18d24..360bc8f69c9d 100644 --- a/app/gui/integration-test/dashboard/delete.spec.ts +++ b/app/gui/integration-test/dashboard/delete.spec.ts @@ -1,6 +1,7 @@ /** @file Test copying, moving, cutting and pasting. */ import { expect, test } from '@playwright/test' +import { modModifier } from 'integration-test/dashboard/actions/BaseActions' import { mockAllAndLogin, TEXT } from './actions' test('delete and restore', ({ page }) => @@ -54,3 +55,46 @@ test('delete and restore (keyboard)', ({ page }) => .driveTable.withRows(async (rows) => { await expect(rows).toHaveCount(1) })) + +test('clear trash', ({ page }) => + mockAllAndLogin({ + page, + setupAPI: (api) => { + api.addDirectory() + api.addDirectory() + api.addProject() + api.addProject() + api.addFile() + api.addSecret() + api.addDatalink() + }, + }) + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveCount(7) + }) + .driveTable.withRows(async (rows, _nonRows, _context, page) => { + const mod = await modModifier(page) + // Parallelizing this using `Promise.all` makes it inconsistent. + const rowEls = await rows.all() + for (const row of rowEls) { + await row.click({ modifiers: [mod] }) + } + }) + .driveTable.rightClickRow(0) + .contextMenu.moveAllToTrash(true) + .driveTable.expectPlaceholderRow() + .goToCategory.trash() + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveCount(7) + }) + .clearTrash() + .driveTable.expectTrashPlaceholderRow() + .goToCategory.cloud() + .expectStartModal() + .withStartModal(async (startModal) => { + await expect(startModal).toBeVisible() + }) + .close() + .driveTable.withRows(async (rows) => { + await expect(rows).toHaveCount(0) + })) diff --git a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx index 440ac70788e3..753fb7bbb945 100644 --- a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx +++ b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx @@ -209,7 +209,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { setModal( { const ids = new Set([asset.id]) dispatchAssetEvent({ type: AssetEventType.deleteForever, ids }) diff --git a/app/gui/src/dashboard/layouts/AssetsTableContextMenu.tsx b/app/gui/src/dashboard/layouts/AssetsTableContextMenu.tsx index 09c5496f9bd5..b0f43adfd313 100644 --- a/app/gui/src/dashboard/layouts/AssetsTableContextMenu.tsx +++ b/app/gui/src/dashboard/layouts/AssetsTableContextMenu.tsx @@ -155,7 +155,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp if (category.type === 'trash') { return ( - selectedKeys.size !== 0 && ( + selectedKeys.size > 1 && (