Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Write button to component menu #11523

Merged
merged 4 commits into from
Nov 13, 2024
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
component.][11452]
- [New documentation editor provides improved Markdown editing experience, and
paves the way for new documentation features.][11469]
- ["Write" button in component menu allows to evaluate it separately from the
rest of the workflow][11523].

[11151]: https://github.com/enso-org/enso/pull/11151
[11271]: https://github.com/enso-org/enso/pull/11271
Expand All @@ -40,6 +42,7 @@
[11448]: https://github.com/enso-org/enso/pull/11448
[11452]: https://github.com/enso-org/enso/pull/11452
[11469]: https://github.com/enso-org/enso/pull/11469
[11523]: https://github.com/enso-org/enso/pull/11523

#### Enso Standard Library

Expand Down
10 changes: 10 additions & 0 deletions app/gui/src/project-view/components/CircularMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const props = defineProps<{
isEnterable: boolean
matchableNodeColors: Set<string>
documentationUrl: string | undefined
isBeingRecomputed: boolean
}>()
const emit = defineEmits<{
'update:isVisualizationEnabled': [isVisualizationEnabled: boolean]
Expand All @@ -25,6 +26,7 @@ const emit = defineEmits<{
delete: []
createNewNode: []
toggleDocPanel: []
recompute: []
}>()

const isDropdownOpened = ref(false)
Expand Down Expand Up @@ -92,6 +94,14 @@ function readableBinding(binding: keyof (typeof graphBindings)['bindings']) {
<SvgIcon name="comment" class="rowIcon" />
<span>Add Comment</span>
</MenuButton>
<MenuButton
data-testid="recompute"
:disabled="props.isBeingRecomputed"
@click.stop="closeDropdown(), emit('recompute')"
>
<SvgIcon name="workflow_play" class="rowIcon" />
<span>Write</span>
</MenuButton>
<MenuButton @click.stop="closeDropdown(), (showColorPicker = true)">
<SvgIcon name="paint_palette" class="rowIcon" />
<span>Color Component</span>
Expand Down
2 changes: 2 additions & 0 deletions app/gui/src/project-view/components/GraphEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type { NodeId } from '@/stores/graph'
import { provideGraphStore } from '@/stores/graph'
import type { RequiredImport } from '@/stores/graph/imports'
import { useProjectStore } from '@/stores/project'
import { provideNodeExecution } from '@/stores/project/nodeExecution'
import { useSettings } from '@/stores/settings'
import { provideSuggestionDbStore } from '@/stores/suggestionDatabase'
import type { SuggestionId, Typename } from '@/stores/suggestionDatabase/entry'
Expand Down Expand Up @@ -87,6 +88,7 @@ const widgetRegistry = provideWidgetRegistry(graphStore.db)
const _visualizationStore = provideVisualizationStore(projectStore)
const visible = injectVisibility()
provideFullscreenContext(rootNode)
provideNodeExecution(projectStore)
;(window as any)._mockSuggestion = suggestionDb.mockSuggestion

onMounted(() => {
Expand Down
19 changes: 19 additions & 0 deletions app/gui/src/project-view/components/GraphEditor/GraphNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { injectKeyboard } from '@/providers/keyboard'
import { useGraphStore, type Node } from '@/stores/graph'
import { asNodeId } from '@/stores/graph/graphDatabase'
import { useProjectStore } from '@/stores/project'
import { useNodeExecution } from '@/stores/project/nodeExecution'
import { suggestionDocumentationUrl } from '@/stores/suggestionDatabase/entry'
import { Ast } from '@/util/ast'
import type { AstId } from '@/util/ast/abstract'
Expand Down Expand Up @@ -79,6 +80,7 @@ const nodeSelection = injectGraphSelection(true)
const projectStore = useProjectStore()
const graph = useGraphStore()
const navigator = injectGraphNavigator(true)
const nodeExecution = useNodeExecution()

const nodeId = computed(() => asNodeId(props.node.rootExpr.externalId))
const potentialSelfArgumentId = computed(() => props.node.primarySubject)
Expand Down Expand Up @@ -409,6 +411,21 @@ watchEffect(() => {
const dataSource = computed(
() => ({ type: 'node', nodeId: props.node.rootExpr.externalId }) as const,
)

// === Recompute node expression ===

// The node is considered to be recomputing for at least this time.
const MINIMAL_EXECUTION_TIMEOUT_MS = 500
const recomputationTimeout = ref(false)
const actualRecomputationStatus = nodeExecution.isBeingRecomputed(nodeId.value)
const isBeingRecomputed = computed(
() => recomputationTimeout.value || actualRecomputationStatus.value,
)
function recomputeOnce() {
nodeExecution.recomputeOnce(nodeId.value, 'Live')
recomputationTimeout.value = true
setTimeout(() => (recomputationTimeout.value = false), MINIMAL_EXECUTION_TIMEOUT_MS)
}
</script>

<template>
Expand Down Expand Up @@ -474,6 +491,7 @@ const dataSource = computed(
:documentationUrl="documentationUrl"
:isRemovable="props.node.type === 'component'"
:isEnterable="graph.nodeCanBeEntered(nodeId)"
:isBeingRecomputed="isBeingRecomputed"
@enterNode="emit('enterNode')"
@startEditing="startEditingNode"
@startEditingComment="editingComment = true"
Expand All @@ -485,6 +503,7 @@ const dataSource = computed(
@createNewNode="setSelected(), emit('createNodes', [{ commit: false, content: undefined }])"
@toggleDocPanel="emit('toggleDocPanel')"
@click.capture="setSelected"
@recompute="recomputeOnce"
/>
<GraphVisualization
v-if="isVisualizationVisible"
Expand Down
8 changes: 4 additions & 4 deletions app/gui/src/project-view/components/RecordControl.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<script setup lang="ts">
import SvgButton from '@/components/SvgButton.vue'
import { useProjectStore } from '@/stores/project'
import { useNodeExecution } from '@/stores/project/nodeExecution'
import ControlButtons from './ControlButtons.vue'

const project = useProjectStore()
const nodeExecution = useNodeExecution()
</script>

<template>
Expand All @@ -14,7 +14,7 @@ const project = useProjectStore()
class="iconButton"
name="refresh"
draggable="false"
@click.stop="project.executionContext.recompute()"
@click.stop="nodeExecution.recomputeAll()"
/>
</template>
<template #right>
Expand All @@ -23,7 +23,7 @@ const project = useProjectStore()
class="iconButton"
name="workflow_play"
draggable="false"
@click.stop="project.executionContext.recompute('all', 'Live')"
@click.stop="nodeExecution.recomputeAll('Live')"
/>
</template>
</ControlButtons>
Expand Down
15 changes: 12 additions & 3 deletions app/gui/src/project-view/stores/project/executionContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,16 +264,25 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
this.sync()
}

/** TODO: Add docs */
/** See {@link LanguageServer.recomputeExecutionContext}. */
recompute(
expressionIds: 'all' | ExternalId[] = 'all',
invalidatedIds?: 'all' | ExternalId[],
executionEnvironment?: ExecutionEnvironment,
expressionConfigs?: {
expressionId: ExpressionId
executionEnvironment?: ExecutionEnvironment
}[],
) {
this.queue.pushTask(async (state) => {
if (state.status !== 'created') {
this.sync()
}
await this.lsRpc.recomputeExecutionContext(this.id, expressionIds, executionEnvironment)
await this.lsRpc.recomputeExecutionContext(
this.id,
invalidatedIds,
executionEnvironment,
expressionConfigs,
)
return state
})
}
Expand Down
47 changes: 47 additions & 0 deletions app/gui/src/project-view/stores/project/nodeExecution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { createContextStore } from '@/providers'
import { NodeId } from '@/stores/graph'
import { type ProjectStore } from '@/stores/project'
import { computed, reactive, ref } from 'vue'
import { ExecutionEnvironment } from 'ydoc-shared/languageServerTypes'
import { ExternalId } from 'ydoc-shared/yjsModel'

/** Allows to recompute certain expressions (usually nodes). */
export const { provideFn: provideNodeExecution, injectFn: useNodeExecution } = createContextStore(
'nodeExecution',
(projectStore: ProjectStore) => {
const recomputationInProgress = reactive(new Set<ExternalId>())
const globalRecomputationInProgress = ref(false)

/** Recompute all expressions using provided environment. */
function recomputeAll(environment?: ExecutionEnvironment) {
projectStore.executionContext.recompute('all', environment)
globalRecomputationInProgress.value = true
whenExecutionFinished(() => {
globalRecomputationInProgress.value = false
})
}

/** Recompute a specific node and its using provided environment. */
function recomputeOnce(id: ExternalId, environment: ExecutionEnvironment) {
// We don’t need to pass `invalidatedIds` when providing per-expression configs.
const invalidatedIds = undefined
const expressionConfigs = [{ expressionId: id, environment }]
projectStore.executionContext.recompute(invalidatedIds, environment, expressionConfigs)
recomputationInProgress.add(id)
whenExecutionFinished(() => {
recomputationInProgress.delete(id)
})
}

function isBeingRecomputed(id: NodeId) {
return computed(() => globalRecomputationInProgress.value || recomputationInProgress.has(id))
}

function whenExecutionFinished(f: () => void) {
projectStore.executionContext.once('executionComplete', f)
projectStore.executionContext.once('executionFailed', f)
}

return { recomputeOnce, isBeingRecomputed, recomputeAll }
},
)
2 changes: 1 addition & 1 deletion app/ide-desktop/client/tests/createNewProject.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { expect } from '@playwright/test'
import { electronTest, loginAsTestUser } from './electronTest'

electronTest('Create new project', async page => {
electronTest('Create new project', async ({ page }) => {
await loginAsTestUser(page)
await expect(page.getByRole('button', { name: 'New Project', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'New Project', exact: true }).click()
Expand Down
29 changes: 27 additions & 2 deletions app/ide-desktop/client/tests/electronTest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
/** @file Commonly used functions for electron tests */

import { _electron, expect, type Page, test } from '@playwright/test'
import fs from 'node:fs/promises'
import os from 'node:os'
import pathModule from 'node:path'

const LOADING_TIMEOUT = 10000

Expand All @@ -9,7 +12,10 @@ const LOADING_TIMEOUT = 10000
*
* Similar to playwright's test, but launches electron, and passes Page of the main window.
*/
export function electronTest(name: string, body: (page: Page) => Promise<void> | void) {
export function electronTest(
name: string,
body: (args: { page: Page; projectsDir: string }) => Promise<void> | void,
) {
test(name, async () => {
const app = await _electron.launch({
executablePath: process.env.ENSO_TEST_EXEC_PATH ?? '',
Expand All @@ -20,7 +26,8 @@ export function electronTest(name: string, body: (page: Page) => Promise<void> |
// Wait until page will be finally loaded: we expect login screen.
// There's bigger timeout, because the page may load longer on CI machines.
await expect(page.getByText('Login to your account')).toBeVisible({ timeout: LOADING_TIMEOUT })
await body(page)
const projectsDir = pathModule.join(os.tmpdir(), 'enso-test-projects', name)
await body({ page, projectsDir })
await app.close()
})
}
Expand Down Expand Up @@ -52,3 +59,21 @@ export async function loginAsTestUser(page: Page) {
}
await page.getByRole('button').click()
}

/**
* Find the most recently edited Enso project in `projectsDir` and return its absolute path.
* There can be multiple projects, as the directory can be reused by subsequent test runs.
* We precisely know the naming schema for new projects, and we use this knowledge to
* find the project that was created most recently.
*/
export async function findMostRecentlyCreatedProject(projectsDir: string): Promise<string | null> {
const dirContent = await fs.readdir(projectsDir)
const sorted = dirContent.sort((a, b) => {
// Project names always end with a number, so we can sort them by that number.
const numA = parseInt(a.match(/\d+/)![0], 10)
const numB = parseInt(b.match(/\d+/)![0], 10)
return numA - numB
})
const last = sorted.pop()
return last != null ? pathModule.join(projectsDir, last) : null
}
73 changes: 73 additions & 0 deletions app/ide-desktop/client/tests/recompute.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* @file A test for `Write` button in the node menu – check that nodes do not write
* to files unless specifically asked for.
*/

import { expect } from '@playwright/test'
import assert from 'node:assert'
import fs from 'node:fs/promises'
import pathModule from 'node:path'
import { electronTest, findMostRecentlyCreatedProject, loginAsTestUser } from './electronTest'

electronTest('Recompute', async ({ page, projectsDir }) => {
await loginAsTestUser(page)
await expect(page.getByRole('button', { name: 'New Project', exact: true })).toBeVisible()
await page.getByRole('button', { name: 'New Project', exact: true }).click()
await expect(page.locator('.GraphNode')).toHaveCount(1, { timeout: 60000 })

// We see the node type and visualization, so the engine is running the program
await expect(page.locator('.node-type')).toHaveText('Table', { timeout: 30000 })
await expect(page.locator('.TableVisualization')).toBeVisible({ timeout: 30000 })
await expect(page.locator('.TableVisualization')).toContainText('Welcome To Enso!')

const OUTPUT_FILE = 'output.txt'
const EXPECTED_OUTPUT = 'Some text'

// Create first node (text literal)
await page.locator('.PlusButton').click()
await expect(page.locator('.ComponentBrowser')).toBeVisible()
const input = page.locator('.ComponentBrowser input')
await input.fill(`'${EXPECTED_OUTPUT}'`)
await page.keyboard.press('Enter')
await expect(page.locator('.GraphNode'), {}).toHaveCount(2)

// Create second node (write)
await page.keyboard.press('Enter')
await expect(page.locator('.ComponentBrowser')).toBeVisible()
const code = `write (enso_project.root / '${OUTPUT_FILE}') on_existing_file=..Append`
await input.fill(code)
await page.keyboard.press('Enter')
await expect(page.locator('.GraphNode'), {}).toHaveCount(3)

// Check that the output file is not created yet.
const writeNode = page.locator('.GraphNode', { hasText: 'write' })
await writeNode.click()
await writeNode.getByRole('button', { name: 'Visualization' }).click()
await expect(writeNode.locator('.TableVisualization')).toContainText('output_ensodryrun')

const ourProject = await findMostRecentlyCreatedProject(projectsDir)
expect(ourProject).not.toBeNull()
assert(ourProject)
expect(await listFiles(ourProject)).not.toContain(OUTPUT_FILE)

// Press `Write once` button.
await writeNode.locator('.More').click()
await writeNode.getByTestId('recompute').click()

// Check that the output file is created and contains expected text.
await expect(writeNode.locator('.TableVisualization')).toContainText(OUTPUT_FILE)
const projectFiles = await listFiles(ourProject)
expect(projectFiles).toContain(OUTPUT_FILE)
if (projectFiles.includes(OUTPUT_FILE)) {
const content = await readFile(ourProject, OUTPUT_FILE)
expect(content).toStrictEqual(EXPECTED_OUTPUT)
}
})

async function listFiles(projectDir: string): Promise<string[]> {
return await fs.readdir(projectDir)
}

async function readFile(projectDir: string, fileName: string): Promise<string> {
return await fs.readFile(pathModule.join(projectDir, fileName), 'utf8')
}
5 changes: 5 additions & 0 deletions app/ydoc-shared/src/languageServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,11 +413,16 @@ export class LanguageServer extends ObservableV2<Notifications & TransportEvents
contextId: ContextId,
invalidatedExpressions?: 'all' | string[],
executionEnvironment?: ExecutionEnvironment,
expressionConfigs?: {
expressionId: ExpressionId
executionEnvironment?: ExecutionEnvironment
}[],
): Promise<LsRpcResult<void>> {
return this.request('executionContext/recompute', {
contextId,
invalidatedExpressions,
executionEnvironment,
expressionConfigs,
})
}

Expand Down
Loading