Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
top.
- [Context menu for connections][14325].
- [Warnings and Errors no longer become transparent][14388]
- ["Delete and Connect Around" option in node's menu][14403]

[13685]: https://github.com/enso-org/enso/pull/13685
[13658]: https://github.com/enso-org/enso/pull/13658
Expand All @@ -49,6 +50,7 @@
[14267]: https://github.com/enso-org/enso/pull/14267
[14325]: https://github.com/enso-org/enso/pull/14325
[14388]: https://github.com/enso-org/enso/pull/14388
[14403]: https://github.com/enso-org/enso/pull/14403

#### Enso Standard Library

Expand Down
2 changes: 1 addition & 1 deletion app/electron-client/tests/electronTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ export async function createNewComponent(page: Page) {
* Open new component browser based on the name of referenced parent component
*/
export async function openComponentBrowser(page: Page, parentComponent: string) {
await page.getByText(parentComponent, { exact: true }).click({ button: 'right' })
await page.getByText(parentComponent, { exact: true }).click()
await page.keyboard.press('Enter')
}

Expand Down
34 changes: 34 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))

// === Node creation ===

const { place: nodePlacement, collapse: collapsedNodePlacement } = usePlacement(
Expand Down Expand Up @@ -338,10 +341,41 @@ const actionHandlers = registerHandlers({
graphStore.db.nodeIdToNode.get.bind(graphStore.db.nodeIdToNode),
),
),
() => detachInfo.value.ok && detachInfo.value.value.length > 0,
{
collapseNodes,
copyNodesToClipboard,
deleteNodes: (nodes) => graphStore.deleteNodes(nodes.map(nodeId)),
deleteAndConnectAround: (nodes) => {
return module.value.edit(async (edit) => {
if (!detachInfo.value.ok) return detachInfo.value
const reconnectResults = await Promise.all(
detachInfo.value.value.map(async ({ port, ident }) => {
const result = await graphStore.updatePortValue(
port,
Ast.Ident.new(edit, ident),
edit,
)
if (!result.ok) {
result.error.log('Failed to connect around')
}
return result
}),
)
if (reconnectResults.some((result) => !result.ok)) {
toasts.userActionFailed.show(
'Errors occurred while connecting around removed components.',
)
}
for (const node of nodes) {
// We cannot call graphStore.deleteNodes, because it bases on the graphDb
// which is not updated with reconnections above.
const outerAst = edit.getVersion(node.outerAst)
if (outerAst.isStatement()) Ast.deleteFromParentBlock(outerAst)
}
return Ok()
})
},
},
),
})
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,143 @@
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 }
expectError?: boolean
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 },
expectError: true,
},
{
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, expectError, 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))
const analyzed = analyzeConnectAround(selected, {
db: graphDb,
pickInCodeOrder: (set) => {
expect([...set.values()]).toEqual([...selected.values()])
return [...set.values()]
},
})
if (expectError) {
expect(analyzed.ok).toBeFalsy()
} else {
assert(analyzed.ok)
for (const { port, ident } of analyzed.value) {
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])
}
})
}
},
)
56 changes: 26 additions & 30 deletions app/gui/src/project-view/components/GraphEditor/collapsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,37 +65,33 @@ export function prepareCollapsedInfo(
const leaves = new Set(selected)
const inputSet: Set<Identifier> = new Set()
let output: Output | null = null
for (const [targetExprId, sourceExprIds] of graphDb.connections.allReverse()) {
const targetNode = graphDb.getExpressionNodeId(targetExprId)
for (const {
sourceExprId,
sourceNode,
targetNode,
nodeWithSource,
identifier,
} of graphDb.iterateConnections()) {
if (targetNode == null) continue
for (const sourceExprId of sourceExprIds) {
const sourceNode = graphDb.getPatternExpressionNodeId(sourceExprId)
// Sometimes the connection source is in expression, not pattern; for example, when its
// lambda.
const nodeWithSource = sourceNode ?? graphDb.getExpressionNodeId(sourceExprId)
// 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)
const stringIdentifier = graphDb.getOutputPortIdentifier(sourceExprId)
if (stringIdentifier == null)
throw new Error(`Connection starting from (${sourceExprId}) has no identifier.`)
const identifier = unwrap(tryIdentifier(stringIdentifier))
if (sourceNode != null) {
leaves.delete(sourceNode)
}
if (!startsInside && endsInside) {
inputSet.add(identifier)
} else if (startsInside && !endsInside) {
assert(sourceNode != null) // No lambda argument set inside node should be visible outside.
if (output == null) {
output = { node: sourceNode, identifier }
} else if (output.identifier == identifier) {
// Ignore duplicate usage of the same identifier.
} else {
return Err(
`More than one output from collapsed function: ${identifier} and ${output.identifier}. Collapsing is not supported.`,
)
}
const startsInside = nodeWithSource != null && selected.has(nodeWithSource)
const endsInside = selected.has(targetNode)
if (sourceNode != null) {
leaves.delete(sourceNode)
}
if (identifier == null)
throw new Error(`Connection starting from (${sourceExprId}) has no identifier.`)
if (!startsInside && endsInside) {
inputSet.add(identifier)
} else if (startsInside && !endsInside) {
assert(sourceNode != null) // No lambda argument set inside node should be visible outside.
if (output == null) {
output = { node: sourceNode, identifier }
} else if (output.identifier == identifier) {
// Ignore duplicate usage of the same identifier.
} else {
return Err(
`More than one output from collapsed function: ${identifier} and ${output.identifier}. Collapsing is not supported.`,
)
}
}
}
Expand Down
59 changes: 59 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,59 @@
import type { GraphStore } from '$/providers/openedProjects/graph'
import type { NodeId } from '$/providers/openedProjects/graph/graphDatabase'
import { Err, Ok } from 'enso-common/src/utilities/data/result'
import { set } from 'lib0'
import { isIdentifier, type AstId, type Identifier } from 'ydoc-shared/ast'

/**
* 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. Returns Err If any connection could not be subsituted this way (e.g. no connection to self-port).
*/
export function analyzeConnectAround(
selected: Set<NodeId>,
graph: Pick<GraphStore, 'db' | 'pickInCodeOrder'>,
) {
const mainSourceIdentifier = new Map<NodeId, Identifier>()
const result: { port: AstId; ident: Identifier }[] = []

for (const selectedNode of graph.pickInCodeOrder(selected)) {
const selfPort = graph.db.nodeIdToNode.get(selectedNode)?.primaryApplication.selfArgument
if (!selfPort) continue
const mainSourceId = set.first(graph.db.connections.reverseLookup(selfPort))
if (!mainSourceId) continue
const mainSourceNode = graph.db.getPatternExpressionNodeId(mainSourceId)
if (mainSourceNode && selected.has(mainSourceNode)) {
const ident = mainSourceIdentifier.get(mainSourceNode)
if (ident) {
mainSourceIdentifier.set(selectedNode, ident)
}
} else {
const ident = graph.db.getOutputPortIdentifier(mainSourceId)
if (ident != null && isIdentifier(ident)) mainSourceIdentifier.set(selectedNode, ident)
}
}

for (const {
targetExprId,
sourceNode,
targetNode,
nodeWithSource,
} of graph.db.iterateConnections()) {
if (targetNode == null || sourceNode == null) continue

// 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 = mainSourceIdentifier.get(sourceNode)
if (mainSource == null) {
// Do not allow the action if any port would miss its source.
return Err(`No self-port route for port ${targetExprId}`)
}
result.push({ port: targetExprId, ident: mainSource })
}
}

return Ok(result)
}
Loading
Loading