Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions app/gui/src/project-view/components/GraphEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
watch,
watchEffect,
} from 'vue'
import { analyzeConnectAround } from './GraphEditor/detaching'
import { provideRenameSchedule } from './GraphEditor/widgets/WidgetFunctionName.vue'

const keyboard = injectKeyboard()
Expand Down Expand Up @@ -193,6 +194,8 @@ watch(
() => nodeSelection.deselectAll(),
)

const detachInfo = computed(() => analyzeConnectAround(nodeSelection.selected, graphStore.db))

// === Node creation ===

const { place: nodePlacement, collapse: collapsedNodePlacement } = usePlacement(
Expand Down Expand Up @@ -338,10 +341,22 @@ const actionHandlers = registerHandlers({
graphStore.db.nodeIdToNode.get.bind(graphStore.db.nodeIdToNode),
),
),
() => detachInfo.value.length > 0,
{
collapseNodes,
copyNodesToClipboard,
deleteNodes: (nodes) => graphStore.deleteNodes(nodes.map(nodeId)),
deleteAndConnectAround: (nodes) => {
return module.value.edit(async (edit) => {
for (const { port, ident } of detachInfo.value) {
const result = await graphStore.updatePortValue(port, Ast.Ident.new(edit, ident), edit)
if (!result.ok) {
result.error.log('Failed to connect around')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should expect this case to be reachable, as we have awaited during this edit and arbitrary graph edits may have taken place since the detachInfo analysis was performed. As such, we should carefully consider error recovery. I think maybe the best we could do is: Notify the user (e.g. with a toast) that an error occurred during the operation, and they may Undo if the result is wrong.

I think I've said this before, but I really don't like the complexity that results from allowing the edit operations to be async. It makes it so much harder to avoid bugs and crashes in hard-to-predict-or-test cases where multiple operations interact.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a toast. AFAIK this async edit was needed in signature editor. I will make a task for sorting this out, as I agree async edits are very troublesome.

}
}
return graphStore.deleteNodes(nodes.map(nodeId), edit)
})
},
},
),
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ const nodeMenuActions: DisplayableActionName[] = [
'component.startEditing',
'components.copy',
'components.deleteSelected',
'components.deleteAndConnectAround',
]

onWindowBlur(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { GraphDb } from '$/providers/openedProjects/graph/graphDatabase'
import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import * as iter from 'enso-common/src/utilities/data/iter'
import { expect, test } from 'vitest'
import { watchEffect } from 'vue'
import { analyzeConnectAround } from '../detaching'

function fixture(code: string) {
const graphDb = GraphDb.Mock()
const { root, getSpan } = Ast.parseUpdatingIdMap(code)
const func = iter.first(root.statements())
assert(func instanceof Ast.MutableFunctionDef)
graphDb.updateExternalIds(root)
graphDb.updateNodes(func, { watchEffect })
graphDb.updateBindings(func, { text: code, getSpan })
return { graphDb, func }
}

interface TestCase {
description: string
funcParameters?: string[]
initialNodes: string[]
selectedNodesRange: { start: number; end: number }
changedNodes: [number, string][]
}

const cases: TestCase[] = [
{
description: 'Single node',
initialNodes: ['a = data', 'b = a.operation', 'c = 3 + b'],
selectedNodesRange: { start: 1, end: 2 },
changedNodes: [[2, 'c = 3 + a']],
},
{
description: 'Several input connections node',
initialNodes: ['a = data', 'b = data2', 'c = a.operation b', 'd = 3 + c'],
selectedNodesRange: { start: 2, end: 3 },
changedNodes: [[3, 'd = 3 + a']],
},
{
description: 'Multiple nodes',
initialNodes: [
'a = data',
'b = data2',
'c = data3',
'd = b.operation a',
'e = d.operation c',
'f = 2 + e',
],
selectedNodesRange: { start: 3, end: 5 },
changedNodes: [[5, 'f = 2 + b']],
},
{
description: 'Multiple flows',
initialNodes: [
'a = data',
'b = data2',
'c = data3',
'd = b.operation a',
'e = d.operation',
'f = c.operation b',
'g = 2 + e + f',
'h = f.write',
],
selectedNodesRange: { start: 3, end: 6 },
changedNodes: [
[6, 'g = 2 + b + c'],
[7, 'h = c.write'],
],
},
{
description: 'Detaching unavailable - no input',
initialNodes: [
'a = data',
'b = data2',
'c = data3',
'd = b.operation a',
'e = Main.collapsed',
'f = 2 + d + e',
'g = 3 + e',
],
selectedNodesRange: { start: 3, end: 5 },
changedNodes: [],
},
{
description: 'Detaching unavailable - no output',
initialNodes: [
'a = data',
'b = data2',
'c = data3',
'd = b.operation a',
'e = d.operation c',
'f = 2 + c',
],
selectedNodesRange: { start: 3, end: 5 },
changedNodes: [],
},
{
description: 'Reconnecting Input Node',
funcParameters: ['x'],
initialNodes: ['a = x.operation', 'b = a.write'],
selectedNodesRange: { start: 0, end: 1 },
changedNodes: [[1, 'b = x.write']],
},
]

test.each(cases)(
'Connecting around nodes: $description',
({ initialNodes, funcParameters, selectedNodesRange, changedNodes }) => {
const code = `main ${funcParameters?.join(' ') ?? ''} =\n ${initialNodes.join('\n ')}`
const { graphDb, func } = fixture(code)
const nodeIds = [...graphDb.nodeIdToNode.entries()]
.filter(([, node]) => node.type === 'component')
.map(([id]) => id)
const selected = new Set(nodeIds.slice(selectedNodesRange.start, selectedNodesRange.end))
for (const { port, ident } of analyzeConnectAround(selected, graphDb)) {
func.module.replace(port, Ast.Ident.new(func.module, ident))
}
const changedNodesMap = new Map(changedNodes)
nodeIds.forEach((id, index) => {
const node = graphDb.nodeIdToNode.get(id)
if (changedNodesMap.has(index)) {
expect(node?.outerAst.code()).toBe(changedNodesMap.get(index))
} else if (!selected.has(id)) {
expect(node?.outerAst.code()).toBe(initialNodes[index])
}
})
},
)
64 changes: 64 additions & 0 deletions app/gui/src/project-view/components/GraphEditor/detaching.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { GraphDb, NodeId } from '$/providers/openedProjects/graph/graphDatabase'
import { set } from 'lib0'
import { isIdentifier, type AstId, type Identifier } from 'ydoc-shared/ast'

const none: unique symbol = Symbol()
type None = typeof none

/**
* Return all changes to port values for "connecting around" operation of given selected nodes.
*
* The connections going out of the `selected` group will be reconnected to their self-port
* input connection. If any connection could not be subsituted this way (e.g. no connection to self-port),
* an empty array will be returned.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think an empty array is a bit subtle of a way to express an error condition probably needs to be reported to the user

*/
export function analyzeConnectAround(selected: Set<NodeId>, graphDb: GraphDb) {
const knownMainSourceIdentifier = new Map<NodeId, Identifier | None>()
const result: { port: AstId; ident: Identifier }[] = []

const findMainSourceOf = (selectedNode: NodeId) => {
const findResult = (): Identifier | None => {
const alreadyKnown = knownMainSourceIdentifier.get(selectedNode)
if (alreadyKnown) return alreadyKnown
const selfPort = graphDb.nodeIdToNode.get(selectedNode)?.primaryApplication.selfArgument
if (!selfPort) return none
const mainSourceId = set.first(graphDb.connections.reverseLookup(selfPort))
if (!mainSourceId) return none
const mainSourceNode = graphDb.getPatternExpressionNodeId(mainSourceId)
if (mainSourceNode && selected.has(mainSourceNode)) {
return findMainSourceOf(mainSourceNode)
} else {
const ident = graphDb.getOutputPortIdentifier(mainSourceId)
return ident != null && isIdentifier(ident) ? ident : none
}
}
const result = findResult()
knownMainSourceIdentifier.set(selectedNode, result)
return result
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kazcw I considered your comment in #14325 (comment) but sorting here would require bringing method function too (with guarantee that this is same method as graphDb), so I prefer to keep this, more self-contained solution.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think my proposed approach would introduce any greater reliance on the current method state than is already implicit in the use of graphDb.connections?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant having two arguments (code and graphDb) where the caller would responsible for making sure they refer to same method.

But now I realized, this problem may be easily avoided by providing just the graph store.


for (const [source, targets] of graphDb.connections.allForward()) {
for (const target of targets) {
// TODO[ao]: copied from collapsing.ts. Worth merging?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO in code--let's create an issue (or add it to an existing issue) if it isn't addressed in this PR

const targetNode = graphDb.getExpressionNodeId(target)
if (targetNode == null) continue
const sourceNode = graphDb.getPatternExpressionNodeId(source)
if (sourceNode == null) continue
// Sometimes the connection source is in expression, not pattern; for example, when its
// lambda.
const nodeWithSource = sourceNode ?? graphDb.getExpressionNodeId(source)
// If source is not in pattern nor expression of any node, it's a function argument.
const startsInside = nodeWithSource != null && selected.has(nodeWithSource)
const endsInside = selected.has(targetNode)
if (startsInside && !endsInside) {
const mainSource = findMainSourceOf(sourceNode)
if (mainSource == none) {
// Do not allow the action if any port would miss its source.
return []
}
result.push({ port: target, ident: mainSource })
}
}
}
return result
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ import { computed, toValue } from 'vue'
*/
export function selectionActionHandlers(
selectedNodes: ToValue<Iterable<Node>>,
detachingPossible: ToValue<boolean>,
actions: {
collapseNodes: (nodes: Node[]) => void
copyNodesToClipboard: (nodes: Node[]) => void
deleteNodes: (nodes: Node[]) => void
deleteAndConnectAround: (nodes: Node[]) => void
},
) {
const selectedNodesArray = computed(() => [...toValue(selectedNodes)])
Expand Down Expand Up @@ -48,5 +50,9 @@ export function selectionActionHandlers(
...toggledAction(),
enabled: computed(() => multipleNodesSelected.value && atLeastOneComponent.value),
},
'components.deleteAndConnectAround': {
enabled: computed(() => atLeastOneComponent.value && toValue(detachingPossible)),
action: action('deleteAndConnectAround'),
},
}
}
4 changes: 4 additions & 0 deletions app/gui/src/project-view/providers/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ const displayableActions = {
description: 'Color Selected Components',
shortcut: graphBindings.bindings['components.pickColorMulti'],
},
'components.deleteAndConnectAround': {
icon: 'graph',
description: 'Delete and Connect Around',
},

// === Component ===

Expand Down
8 changes: 5 additions & 3 deletions app/gui/src/providers/openedProjects/graph/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,8 @@ export function createGraphStore(
return edges
})

function deleteNodes(ids: Iterable<NodeId>) {
return module.edit(async (edit) => {
function deleteNodes(ids: Iterable<NodeId>, edit?: MutableModule) {
const deleteInEdit = async (edit: MutableModule) => {
const deletedNodes = new Set<NodeId>()
for (const id of ids) {
const node = db.nodeIdToNode.get(id)
Expand All @@ -330,7 +330,9 @@ export function createGraphStore(
cleanup(id)
}
return Ok()
})
}
if (edit) return deleteInEdit(edit)
else return module.edit(deleteInEdit)
}

function setNodeContent(id: NodeId, content: string, withImports?: RequiredImport[] | undefined) {
Expand Down
Loading