Async execution controls (#7592)

- Closes https://github.com/enso-org/cloud-v2/issues/619
- Async execution controls

# Important Notes
There is no design for this, as such, implementation details use placeholder designs.

- The context menu uses a play icon. An icon similar in style to the "copy" icon *may* work to represent "run in background", but it may be difficult to visually represent that it is being run in the background, without obstructing it with a foreground window
- The icon for projects being run in the background have a green tint, to distinguish them from projects that will be (or are currently) opened in the editor.
- this will ***almost certainly*** need to be replaced with a proper design
- This *may* also make sense for the local backend, *however* as I don't know whether there is a way to access the completion progress of execution from the PM API, local backend support is currently *not* implemented in this PR.
- On a related note: as far as I am aware, there is also no such endpoint for the cloud backend. However, support for async execution was recently added, so I am adding the basic functionality corresponding to the `executeAsync` project state.
- Whether a project is being run in the background is currently lost on refresh. This is because the async execution state is currently not sent by the backend.
- Placeholder shortcuts have been added (Shift+Enter - Cmd+Enter is already taken by the "share" action, and shift+double click). These are totally optional, and can easily be removed.
This commit is contained in:
somebody1234 2023-09-07 22:36:03 +10:00 committed by GitHub
parent 4213bf9983
commit 58fbd8e9e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 174 additions and 79 deletions

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M4.48492 3.22332L11.5227 7.12545C12.2095 7.50624 12.2095 8.49379 11.5227 8.87458L4.48491 12.7767C3.8184 13.1462 3.00001 12.6642 3.00001 11.9021L3.00001 4.09789C3.00001 3.33578 3.8184 2.85377 4.48492 3.22332Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 356 B

View File

@ -603,6 +603,7 @@ export interface ProjectUpdateRequestBody {
/** HTTP request body for the "open project" endpoint. */
export interface OpenProjectRequestBody {
forceCreate: boolean
executeAsync: boolean
}
/** HTTP request body for the "create secret" endpoint. */

View File

@ -110,6 +110,23 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
type: assetEventModule.AssetEventType.openProject,
id: asset.id,
shouldAutomaticallySwitchPage: true,
runInBackground: false,
})
}}
/>
)}
{asset.type === backendModule.AssetType.project &&
backend.type === backendModule.BackendType.remote && (
<MenuEntry
hidden={hidden}
action={shortcuts.KeyboardAction.run}
doAction={() => {
unsetModal()
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
id: asset.id,
shouldAutomaticallySwitchPage: false,
runInBackground: true,
})
}}
/>

View File

@ -297,6 +297,7 @@ export default function AssetsTable(props: AssetsTableProps) {
type: assetEventModule.AssetEventType.openProject,
id: projectToLoad.id,
shouldAutomaticallySwitchPage: true,
runInBackground: false,
})
}
setNameOfProjectToImmediatelyOpen(null)
@ -774,6 +775,7 @@ export default function AssetsTable(props: AssetsTableProps) {
type: assetEventModule.AssetEventType.openProject,
id: projectId,
shouldAutomaticallySwitchPage: true,
runInBackground: false,
})
},
[/* should never change */ dispatchAssetEvent]

View File

@ -131,6 +131,7 @@ export default function Dashboard(props: DashboardProps) {
type: assetEventModule.AssetEventType.openProject,
id: savedProjectStartupInfo.project.projectId,
shouldAutomaticallySwitchPage: page === pageSwitcher.Page.editor,
runInBackground: false,
},
])
} else {

View File

@ -28,6 +28,8 @@ export default function Modal(props: ModalProps) {
return (
<div
style={style}
// This MUST still be z-10, unlike all other elements, because it MUST show above the
// IDE.
className={`inset-0 z-10 ${
centered ? 'fixed w-screen h-screen grid place-items-center' : ''
} ${className ?? ''}`}

View File

@ -93,7 +93,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
: function Child() {
return (
<Modal
className="fixed w-full h-full z-10"
className="fixed w-full h-full z-1"
onClick={() => {
setTheChild(null)
}}

View File

@ -27,7 +27,7 @@ import SvgMask from '../../authentication/components/svgMask'
/** The size of the icon, in pixels. */
const ICON_SIZE_PX = 24
/** The styles of the icons. */
const ICON_STYLE = { width: ICON_SIZE_PX, height: ICON_SIZE_PX } satisfies React.CSSProperties
const ICON_CLASSES = 'w-6 h-6'
const LOADING_MESSAGE =
'Your environment is being created. It will take some time, please be patient.'
/** The corresponding {@link SpinnerState} for each {@link backendModule.ProjectState},
@ -114,6 +114,9 @@ export default function ProjectIcon(props: ProjectIconProps) {
((state: spinner.SpinnerState | null) => void) | null
>(null)
const [shouldOpenWhenReady, setShouldOpenWhenReady] = React.useState(false)
const [isRunningInBackground, setIsRunningInBackground] = React.useState(
item.projectState.execute_async ?? false
)
const [shouldSwitchPage, setShouldSwitchPage] = React.useState(false)
const [toastId, setToastId] = React.useState<toast.Id | null>(null)
const [openProjectAbortController, setOpenProjectAbortController] =
@ -124,61 +127,78 @@ export default function ProjectIcon(props: ProjectIconProps) {
backend.type !== backendModule.BackendType.local &&
item.projectState.opened_by !== organization?.email
const openProject = React.useCallback(async () => {
closeProjectAbortController?.abort()
setCloseProjectAbortController(null)
setState(backendModule.ProjectState.openInProgress)
try {
switch (backend.type) {
case backendModule.BackendType.remote: {
if (!backendModule.DOES_PROJECT_STATE_INDICATE_VM_EXISTS[state]) {
setToastId(toast.toast.loading(LOADING_MESSAGE))
await backend.openProject(item.id, null, item.title)
const openProject = React.useCallback(
async (shouldRunInBackground: boolean) => {
closeProjectAbortController?.abort()
setCloseProjectAbortController(null)
setState(backendModule.ProjectState.openInProgress)
try {
switch (backend.type) {
case backendModule.BackendType.remote: {
if (!backendModule.DOES_PROJECT_STATE_INDICATE_VM_EXISTS[state]) {
setToastId(toast.toast.loading(LOADING_MESSAGE))
await backend.openProject(
item.id,
{
forceCreate: false,
executeAsync: shouldRunInBackground,
},
item.title
)
}
const abortController = new AbortController()
setOpenProjectAbortController(abortController)
await remoteBackend.waitUntilProjectIsReady(backend, item, abortController)
setToastId(null)
if (!abortController.signal.aborted) {
setState(oldState =>
oldState === backendModule.ProjectState.openInProgress
? backendModule.ProjectState.opened
: oldState
)
}
break
}
const abortController = new AbortController()
setOpenProjectAbortController(abortController)
await remoteBackend.waitUntilProjectIsReady(backend, item, abortController)
setToastId(null)
if (!abortController.signal.aborted) {
case backendModule.BackendType.local: {
await backend.openProject(
item.id,
{
forceCreate: false,
executeAsync: shouldRunInBackground,
},
item.title
)
setState(oldState =>
oldState === backendModule.ProjectState.openInProgress
? backendModule.ProjectState.opened
: oldState
)
break
}
break
}
case backendModule.BackendType.local: {
await backend.openProject(item.id, null, item.title)
setState(oldState =>
oldState === backendModule.ProjectState.openInProgress
? backendModule.ProjectState.opened
: oldState
)
break
}
} catch (error) {
const project = await backend.getProjectDetails(item.id, item.title)
setItem(oldItem => ({
...oldItem,
projectState: project.state,
}))
toastAndLog(
errorModule.tryGetMessage(error)?.slice(0, -1) ??
`Could not open project '${item.title}'`
)
setState(backendModule.ProjectState.closed)
}
} catch (error) {
const project = await backend.getProjectDetails(item.id, item.title)
setItem(oldItem => ({
...oldItem,
projectState: project.state,
}))
toastAndLog(
errorModule.tryGetMessage(error)?.slice(0, -1) ??
`Could not open project '${item.title}'`
)
setState(backendModule.ProjectState.closed)
}
}, [
state,
backend,
item,
closeProjectAbortController,
/* should never change */ toastAndLog,
/* should never change */ setState,
/* should never change */ setItem,
])
},
[
state,
backend,
item,
closeProjectAbortController,
/* should never change */ toastAndLog,
/* should never change */ setState,
/* should never change */ setItem,
]
)
React.useEffect(() => {
setItem(oldItem => ({ ...oldItem, projectState: { ...oldItem.projectState, type: state } }))
@ -230,14 +250,17 @@ export default function ProjectIcon(props: ProjectIconProps) {
}
case assetEventModule.AssetEventType.openProject: {
if (event.id !== item.id) {
setShouldOpenWhenReady(false)
if (!isOtherUserUsingProject) {
void closeProject(false)
if (!event.runInBackground && !isRunningInBackground) {
setShouldOpenWhenReady(false)
if (!isOtherUserUsingProject) {
void closeProject(false)
}
}
} else {
setShouldOpenWhenReady(true)
setShouldOpenWhenReady(!event.runInBackground)
setShouldSwitchPage(event.shouldAutomaticallySwitchPage)
void openProject()
setIsRunningInBackground(event.runInBackground)
void openProject(event.runInBackground)
}
break
}
@ -249,13 +272,15 @@ export default function ProjectIcon(props: ProjectIconProps) {
break
}
case assetEventModule.AssetEventType.cancelOpeningAllProjects: {
setShouldOpenWhenReady(false)
onSpinnerStateChange?.(null)
setOnSpinnerStateChange(null)
openProjectAbortController?.abort()
setOpenProjectAbortController(null)
if (!isOtherUserUsingProject) {
void closeProject(false)
if (!isRunningInBackground) {
setShouldOpenWhenReady(false)
onSpinnerStateChange?.(null)
setOnSpinnerStateChange(null)
openProjectAbortController?.abort()
setOpenProjectAbortController(null)
if (!isOtherUserUsingProject) {
void closeProject(false)
}
}
break
}
@ -271,9 +296,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
})
React.useEffect(() => {
if (shouldOpenWhenReady && state === backendModule.ProjectState.opened) {
openIde(shouldSwitchPage)
setShouldOpenWhenReady(false)
if (state === backendModule.ProjectState.opened) {
if (shouldOpenWhenReady) {
openIde(shouldSwitchPage)
setShouldOpenWhenReady(false)
}
}
// `openIde` is a callback, not a dependency.
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -331,7 +358,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
doOpenManually(item.id)
}}
>
<SvgMask style={ICON_STYLE} src={PlayIcon} />
<SvgMask className={ICON_CLASSES} src={PlayIcon} />
</button>
)
case backendModule.ProjectState.openInProgress:
@ -347,13 +374,16 @@ export default function ProjectIcon(props: ProjectIconProps) {
onClick={async clickEvent => {
clickEvent.stopPropagation()
unsetModal()
await closeProject()
await closeProject(!isRunningInBackground)
}}
>
<div className="relative h-0">
<div className={`relative h-0 ${isRunningInBackground ? 'text-green' : ''}`}>
<Spinner size={ICON_SIZE_PX} state={spinnerState} />
</div>
<SvgMask style={ICON_STYLE} src={StopIcon} />
<SvgMask
src={StopIcon}
className={`${ICON_CLASSES} ${isRunningInBackground ? 'text-green' : ''}`}
/>
</button>
)
case backendModule.ProjectState.opened:
@ -368,15 +398,22 @@ export default function ProjectIcon(props: ProjectIconProps) {
onClick={async clickEvent => {
clickEvent.stopPropagation()
unsetModal()
await closeProject()
await closeProject(!isRunningInBackground)
}}
>
<div className="relative h-0">
<div
className={`relative h-0 ${isRunningInBackground ? 'text-green' : ''}`}
>
<Spinner size={24} state={spinnerState} />
</div>
<SvgMask style={ICON_STYLE} src={StopIcon} />
<SvgMask
src={StopIcon}
className={`${ICON_CLASSES} ${
isRunningInBackground ? 'text-green' : ''
}`}
/>
</button>
{!isOtherUserUsingProject && (
{!isOtherUserUsingProject && !isRunningInBackground && (
<button
className="w-6 h-6"
onClick={clickEvent => {
@ -385,7 +422,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
openIde(true)
}}
>
<SvgMask style={ICON_STYLE} src={ArrowUpIcon} />
<SvgMask src={ArrowUpIcon} className={ICON_CLASSES} />
</button>
)}
</div>

View File

@ -129,6 +129,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
type: assetEventModule.AssetEventType.openProject,
id: createdProject.projectId,
shouldAutomaticallySwitchPage: true,
runInBackground: false,
})
} catch (error) {
dispatchAssetListEvent({
@ -225,12 +226,20 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
onClick={event => {
if (rowState.isEditingName || isOtherUserUsingProject) {
// The project should neither be edited nor opened in these cases.
} else if (eventModule.isDoubleClick(event)) {
} else if (shortcuts.matchesMouseAction(shortcutsModule.MouseAction.open, event)) {
// It is a double click; open the project.
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
id: asset.id,
shouldAutomaticallySwitchPage: true,
runInBackground: false,
})
} else if (shortcuts.matchesMouseAction(shortcutsModule.MouseAction.run, event)) {
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
id: asset.id,
shouldAutomaticallySwitchPage: false,
runInBackground: true,
})
} else if (
!isRunning &&

View File

@ -86,6 +86,7 @@ export interface AssetNewSecretEvent extends AssetBaseEvent<AssetEventType.newSe
export interface AssetOpenProjectEvent extends AssetBaseEvent<AssetEventType.openProject> {
id: backendModule.ProjectId
shouldAutomaticallySwitchPage: boolean
runInBackground: boolean
}
/** A signal to close the specified project. */

View File

@ -23,6 +23,7 @@ const STATUS_SERVER_ERROR = 500
/** Default HTTP body for an "open project" request. */
const DEFAULT_OPEN_PROJECT_BODY: backendModule.OpenProjectRequestBody = {
forceCreate: false,
executeAsync: false,
}
// ============================

View File

@ -16,6 +16,7 @@ import DuplicateIcon from 'enso-assets/duplicate.svg'
import OpenIcon from 'enso-assets/open.svg'
import PenIcon from 'enso-assets/pen.svg'
import PeopleIcon from 'enso-assets/people.svg'
import Play2Icon from 'enso-assets/play2.svg'
import ScissorsIcon from 'enso-assets/scissors.svg'
import SignInIcon from 'enso-assets/sign_in.svg'
import SignOutIcon from 'enso-assets/sign_out.svg'
@ -39,6 +40,9 @@ export const ICON_SIZE_PX = 16
/** All possible mouse actions for which shortcuts can be registered. */
export enum MouseAction {
open = 'open',
/** Run without opening the editor. */
run = 'run',
editName = 'edit-name',
selectAdditional = 'select-additional',
selectRange = 'select-range',
@ -48,6 +52,8 @@ export enum MouseAction {
/** All possible keyboard actions for which shortcuts can be registered. */
export enum KeyboardAction {
open = 'open',
/** Run without opening the editor. */
run = 'run',
close = 'close',
uploadToCloud = 'upload-to-cloud',
rename = 'rename',
@ -103,6 +109,7 @@ export interface KeyboardShortcut extends Modifiers {
export interface MouseShortcut extends Modifiers {
button: MouseButton
action: MouseAction
clicks: number
}
/** All possible modifier keys. */
@ -146,6 +153,7 @@ export function isTextInputEvent(event: KeyboardEvent | React.KeyboardEvent) {
function makeKeyboardActionMap<T>(make: () => T): Record<KeyboardAction, T> {
return {
[KeyboardAction.open]: make(),
[KeyboardAction.run]: make(),
[KeyboardAction.close]: make(),
[KeyboardAction.uploadToCloud]: make(),
[KeyboardAction.rename]: make(),
@ -279,7 +287,11 @@ export class ShortcutRegistry {
shortcut: MouseShortcut,
event: MouseEvent | React.MouseEvent
) {
return shortcut.button === event.button && modifiersMatchEvent(shortcut, event)
return (
shortcut.button === event.button &&
event.detail >= shortcut.clicks &&
modifiersMatchEvent(shortcut, event)
)
}
/** Return `true` if the action is being triggered by the keyboard event. */
@ -387,11 +399,13 @@ function keybind(action: KeyboardAction, modifiers: ModifierKey[], key: string):
function mousebind(
action: MouseAction,
modifiers: ModifierKey[],
button: MouseButton
button: MouseButton,
clicks: number
): MouseShortcut {
return {
button,
action,
clicks,
ctrl: modifiers.includes('Ctrl'),
alt: modifiers.includes('Alt'),
shift: modifiers.includes('Shift'),
@ -412,6 +426,7 @@ const DELETE = detect.isOnMacOS() ? 'Backspace' : 'Delete'
/** The default keyboard shortcuts. */
const DEFAULT_KEYBOARD_SHORTCUTS: Record<KeyboardAction, KeyboardShortcut[]> = {
[KeyboardAction.open]: [keybind(KeyboardAction.open, [], 'Enter')],
[KeyboardAction.run]: [keybind(KeyboardAction.run, ['Shift'], 'Enter')],
[KeyboardAction.close]: [],
[KeyboardAction.uploadToCloud]: [],
[KeyboardAction.rename]: [keybind(KeyboardAction.rename, [CTRL], 'R')],
@ -440,6 +455,7 @@ const DEFAULT_KEYBOARD_SHORTCUTS: Record<KeyboardAction, KeyboardShortcut[]> = {
/** The default UI data for every keyboard shortcut. */
const DEFAULT_KEYBOARD_SHORTCUT_INFO: Record<KeyboardAction, ShortcutInfo> = {
[KeyboardAction.open]: { name: 'Open', icon: OpenIcon },
[KeyboardAction.run]: { name: 'Run', icon: Play2Icon },
[KeyboardAction.close]: { name: 'Close', icon: CloseIcon },
[KeyboardAction.uploadToCloud]: { name: 'Upload To Cloud', icon: CloudToIcon },
[KeyboardAction.rename]: { name: 'Rename', icon: PenIcon },
@ -474,12 +490,14 @@ const DEFAULT_KEYBOARD_SHORTCUT_INFO: Record<KeyboardAction, ShortcutInfo> = {
/** The default mouse shortcuts. */
const DEFAULT_MOUSE_SHORTCUTS: Record<MouseAction, MouseShortcut[]> = {
[MouseAction.editName]: [mousebind(MouseAction.editName, [CTRL], MouseButton.left)],
[MouseAction.open]: [mousebind(MouseAction.open, [], MouseButton.left, 2)],
[MouseAction.run]: [mousebind(MouseAction.run, ['Shift'], MouseButton.left, 2)],
[MouseAction.editName]: [mousebind(MouseAction.editName, [CTRL], MouseButton.left, 1)],
[MouseAction.selectAdditional]: [
mousebind(MouseAction.selectAdditional, [CTRL], MouseButton.left),
mousebind(MouseAction.selectAdditional, [CTRL], MouseButton.left, 1),
],
[MouseAction.selectRange]: [mousebind(MouseAction.selectRange, ['Shift'], MouseButton.left)],
[MouseAction.selectRange]: [mousebind(MouseAction.selectRange, ['Shift'], MouseButton.left, 1)],
[MouseAction.selectAdditionalRange]: [
mousebind(MouseAction.selectAdditionalRange, [CTRL, 'Shift'], MouseButton.left),
mousebind(MouseAction.selectAdditionalRange, [CTRL, 'Shift'], MouseButton.left, 1),
],
}

View File

@ -35,6 +35,7 @@ export const theme = {
cloud: '#0666be',
share: '#64b526',
inversed: '#ffffff',
green: '#3e8b29',
delete: 'rgba(243, 24, 10, 0.87)',
v3: '#252423',
youtube: '#c62421',