mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 16:01:30 +03:00
Improve tab bar (#10333)
- Add close button to Editor tab - Convert Settings page into a modal (not guaranteed to be the final design, but it makes sense to change it to a modal for now at least) # Important Notes None
This commit is contained in:
parent
e6c8ec7ab5
commit
b52e8eb9b0
@ -122,6 +122,13 @@ export function locateStopProjectButton(page: test.Locator | test.Page) {
|
||||
return page.getByLabel('Stop execution')
|
||||
}
|
||||
|
||||
/** Close a modal. */
|
||||
export function closeModal(page: test.Page) {
|
||||
return test.test.step('Close modal', async () => {
|
||||
await page.getByLabel('Close').click()
|
||||
})
|
||||
}
|
||||
|
||||
/** Find all labels in the labels panel (if any) on the current page. */
|
||||
export function locateLabelsPanelLabels(page: test.Page, name?: string) {
|
||||
return (
|
||||
|
@ -1,5 +1,4 @@
|
||||
/** @file The base class from which all `Actions` classes are derived. */
|
||||
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
// ====================
|
||||
@ -78,10 +77,11 @@ export default class BaseActions implements PromiseLike<void> {
|
||||
}
|
||||
|
||||
/** Return a {@link BaseActions} with the same {@link Promise} but a different type. */
|
||||
into<T extends new (page: test.Page, promise: Promise<void>) => InstanceType<T>>(
|
||||
clazz: T
|
||||
): InstanceType<T> {
|
||||
return new clazz(this.page, this.promise)
|
||||
into<
|
||||
T extends new (page: test.Page, promise: Promise<void>, ...args: Args) => InstanceType<T>,
|
||||
Args extends readonly unknown[],
|
||||
>(clazz: T, ...args: Args): InstanceType<T> {
|
||||
return new clazz(this.page, this.promise, ...args)
|
||||
}
|
||||
|
||||
/** Perform an action on the current page. This should generally be avoided in favor of using
|
||||
|
@ -3,31 +3,24 @@ import * as test from 'playwright/test'
|
||||
|
||||
import * as actions from '../actions'
|
||||
import type * as baseActions from './BaseActions'
|
||||
import BaseActions from './BaseActions'
|
||||
import * as contextMenuActions from './contextMenuActions'
|
||||
import EditorPageActions from './EditorPageActions'
|
||||
import * as goToPageActions from './goToPageActions'
|
||||
import NewDataLinkModalActions from './NewDataLinkModalActions'
|
||||
import * as openUserMenuAction from './openUserMenuAction'
|
||||
import PageActions from './PageActions'
|
||||
import StartModalActions from './StartModalActions'
|
||||
import * as userMenuActions from './userMenuActions'
|
||||
|
||||
// ========================
|
||||
// === DrivePageActions ===
|
||||
// ========================
|
||||
|
||||
/** Actions for the "drive" page. */
|
||||
export default class DrivePageActions extends BaseActions {
|
||||
export default class DrivePageActions extends PageActions {
|
||||
/** Actions for navigating to another page. */
|
||||
get goToPage(): Omit<goToPageActions.GoToPageActions, 'drive'> {
|
||||
return goToPageActions.goToPageActions(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Actions related to the User Menu. */
|
||||
get userMenu() {
|
||||
return userMenuActions.userMenuActions(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Actions related to context menus. */
|
||||
get contextMenu() {
|
||||
return contextMenuActions.contextMenuActions(this.step.bind(this))
|
||||
@ -145,11 +138,6 @@ export default class DrivePageActions extends BaseActions {
|
||||
).into(EditorPageActions)
|
||||
}
|
||||
|
||||
/** Open the User Menu. */
|
||||
openUserMenu() {
|
||||
return openUserMenuAction.openUserMenuAction(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Interact with the drive view (the main container of this page). */
|
||||
withDriveView(callback: baseActions.LocatorCallback) {
|
||||
return this.step('Interact with drive view', page => callback(actions.locateDriveView(page)))
|
||||
|
@ -1,28 +1,15 @@
|
||||
/** @file Actions for the "editor" page. */
|
||||
|
||||
import BaseActions from './BaseActions'
|
||||
import * as goToPageActions from './goToPageActions'
|
||||
import * as openUserMenuAction from './openUserMenuAction'
|
||||
import * as userMenuActions from './userMenuActions'
|
||||
import PageActions from './PageActions'
|
||||
|
||||
// =========================
|
||||
// === EditorPageActions ===
|
||||
// =========================
|
||||
|
||||
/** Actions for the "editor" page. */
|
||||
export default class EditorPageActions extends BaseActions {
|
||||
export default class EditorPageActions extends PageActions {
|
||||
/** Actions for navigating to another page. */
|
||||
get goToPage(): Omit<goToPageActions.GoToPageActions, 'editor'> {
|
||||
return goToPageActions.goToPageActions(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Actions related to the User Menu. */
|
||||
get userMenu() {
|
||||
return userMenuActions.userMenuActions(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Open the User Menu. */
|
||||
openUserMenu() {
|
||||
return openUserMenuAction.openUserMenuAction(this.step.bind(this))
|
||||
}
|
||||
}
|
||||
|
21
app/ide-desktop/lib/dashboard/e2e/actions/PageActions.ts
Normal file
21
app/ide-desktop/lib/dashboard/e2e/actions/PageActions.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/** @file Actions common to all pages. */
|
||||
import BaseActions from './BaseActions'
|
||||
import * as openUserMenuAction from './openUserMenuAction'
|
||||
import * as userMenuActions from './userMenuActions'
|
||||
|
||||
// ===================
|
||||
// === PageActions ===
|
||||
// ===================
|
||||
|
||||
/** Actions common to all pages. */
|
||||
export default class PageActions extends BaseActions {
|
||||
/** Actions related to the User Menu. */
|
||||
get userMenu() {
|
||||
return userMenuActions.userMenuActions(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Open the User Menu. */
|
||||
openUserMenu() {
|
||||
return openUserMenuAction.openUserMenuAction(this.step.bind(this))
|
||||
}
|
||||
}
|
@ -1,8 +1,6 @@
|
||||
/** @file Actions for the "settings" page. */
|
||||
import BaseActions from './BaseActions'
|
||||
import * as goToPageActions from './goToPageActions'
|
||||
import * as openUserMenuAction from './openUserMenuAction'
|
||||
import * as userMenuActions from './userMenuActions'
|
||||
import PageActions from './PageActions'
|
||||
|
||||
// ===========================
|
||||
// === SettingsPageActions ===
|
||||
@ -10,19 +8,9 @@ import * as userMenuActions from './userMenuActions'
|
||||
|
||||
// TODO: split settings page actions into different classes for each settings tab.
|
||||
/** Actions for the "settings" page. */
|
||||
export default class SettingsPageActions extends BaseActions {
|
||||
export default class SettingsPageActions extends PageActions {
|
||||
/** Actions for navigating to another page. */
|
||||
get goToPage(): Omit<goToPageActions.GoToPageActions, 'settings'> {
|
||||
get goToPage(): Omit<goToPageActions.GoToPageActions, 'drive'> {
|
||||
return goToPageActions.goToPageActions(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Actions related to the User Menu. */
|
||||
get userMenu() {
|
||||
return userMenuActions.userMenuActions(this.step.bind(this))
|
||||
}
|
||||
|
||||
/** Open the User Menu. */
|
||||
openUserMenu() {
|
||||
return openUserMenuAction.openUserMenuAction(this.step.bind(this))
|
||||
}
|
||||
}
|
||||
|
@ -34,10 +34,7 @@ export function goToPageActions(
|
||||
).into(DrivePageActions),
|
||||
editor: () =>
|
||||
step('Go to "Spatial Analysis" page', page =>
|
||||
page
|
||||
.getByRole('button')
|
||||
.filter({ has: page.getByText('Spatial Analysis') })
|
||||
.click()
|
||||
page.getByRole('button').and(page.getByLabel('Spatial Analysis')).click()
|
||||
).into(EditorPageActions),
|
||||
settings: () =>
|
||||
step('Go to "settings" page', page => BaseActions.press(page, 'Mod+,')).into(
|
||||
|
@ -4,7 +4,6 @@ import type * as test from 'playwright/test'
|
||||
import type * as baseActions from './BaseActions'
|
||||
import type BaseActions from './BaseActions'
|
||||
import LoginPageActions from './LoginPageActions'
|
||||
import SettingsPageActions from './SettingsPageActions'
|
||||
|
||||
// =======================
|
||||
// === UserMenuActions ===
|
||||
@ -13,7 +12,6 @@ import SettingsPageActions from './SettingsPageActions'
|
||||
/** Actions for the user menu. */
|
||||
export interface UserMenuActions<T extends BaseActions> {
|
||||
readonly downloadApp: (callback: (download: test.Download) => Promise<void> | void) => T
|
||||
readonly goToSettingsPage: () => SettingsPageActions
|
||||
readonly logout: () => LoginPageActions
|
||||
readonly goToLoginPage: () => LoginPageActions
|
||||
}
|
||||
@ -34,10 +32,6 @@ export function userMenuActions<T extends BaseActions>(
|
||||
await callback(await downloadPromise)
|
||||
})
|
||||
},
|
||||
goToSettingsPage: () =>
|
||||
step('Go to Settings (user menu)', page =>
|
||||
page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
|
||||
).into(SettingsPageActions),
|
||||
logout: () =>
|
||||
step('Logout (user menu)', page =>
|
||||
page.getByRole('button', { name: 'Logout' }).getByText('Logout').click()
|
||||
|
@ -17,6 +17,7 @@ test.test('members settings', async ({ page }) => {
|
||||
|
||||
const otherUserName = 'second.user_'
|
||||
const otherUser = api.addUser(otherUserName)
|
||||
// await actions.closeModal(page)
|
||||
await actions.relog({ page })
|
||||
await localActions.go(page)
|
||||
await test
|
||||
@ -24,6 +25,7 @@ test.test('members settings', async ({ page }) => {
|
||||
.toHaveText([api.currentUser()?.name ?? '', otherUserName])
|
||||
|
||||
api.deleteUser(otherUser.userId)
|
||||
// await actions.closeModal(page)
|
||||
await actions.relog({ page })
|
||||
await localActions.go(page)
|
||||
await test
|
||||
|
@ -12,6 +12,9 @@ appConfig.loadTestEnvironmentVariables()
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-magic-numbers, @typescript-eslint/strict-boolean-expressions */
|
||||
|
||||
const DEBUG = process.env.PWDEBUG === '1'
|
||||
const TIMEOUT_MS = DEBUG ? 100_000_000 : 30_000
|
||||
|
||||
export default test.defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
@ -20,9 +23,9 @@ export default test.defineConfig({
|
||||
repeatEach: process.env.CI ? 3 : 1,
|
||||
expect: {
|
||||
toHaveScreenshot: { threshold: 0 },
|
||||
timeout: 30_000,
|
||||
timeout: TIMEOUT_MS,
|
||||
},
|
||||
timeout: 30_000,
|
||||
timeout: TIMEOUT_MS,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
baseURL: 'http://localhost:8080',
|
||||
@ -30,8 +33,12 @@ export default test.defineConfig({
|
||||
launchOptions: {
|
||||
ignoreDefaultArgs: ['--headless'],
|
||||
args: [
|
||||
// Much closer to headful Chromium than classic headless.
|
||||
'--headless=new',
|
||||
...(DEBUG
|
||||
? []
|
||||
: [
|
||||
// Much closer to headful Chromium than classic headless.
|
||||
'--headless=new',
|
||||
]),
|
||||
// Required for `backdrop-filter: blur` to work.
|
||||
'--use-angle=swiftshader',
|
||||
// FIXME: `--disable-gpu` disables `backdrop-filter: blur`, which is not handled by
|
||||
|
@ -73,7 +73,7 @@ const DIALOG_STYLES = twv.tv({
|
||||
header:
|
||||
'sticky grid grid-cols-[1fr_auto_1fr] items-center border-b border-primary/10 transition-[border-color] duration-150',
|
||||
closeButton: 'col-start-1 col-end-1 mr-auto',
|
||||
heading: 'col-start-2 col-end-2 my-0',
|
||||
heading: 'col-start-2 col-end-2 my-0 text-center',
|
||||
content: 'relative flex-auto overflow-y-auto p-3.5',
|
||||
},
|
||||
})
|
||||
|
@ -140,8 +140,13 @@ export default function DateInput(props: DateInputProps) {
|
||||
</div>
|
||||
</FocusRing>
|
||||
{isPickerVisible && (
|
||||
<div className="absolute left-1/2 top-text-h mt-date-input-gap">
|
||||
<div className="relative -translate-x-1/2 rounded-2xl border border-primary/10 p-date-input shadow-soft before:absolute before:inset-0 before:rounded-2xl before:backdrop-blur-3xl">
|
||||
<div className="absolute left-1/2 top-text-h z-1 mt-date-input-gap">
|
||||
<div
|
||||
className={ariaComponents.DIALOG_BACKGROUND({
|
||||
className:
|
||||
'relative -translate-x-1/2 rounded-2xl border border-primary/10 p-date-input shadow-soft',
|
||||
})}
|
||||
>
|
||||
<div className="relative mb-date-input-gap">
|
||||
<div className="flex items-center">
|
||||
<ariaComponents.Button
|
||||
|
@ -2,7 +2,6 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import Chat from '#/layouts/Chat'
|
||||
import ChatPlaceholder from '#/layouts/ChatPlaceholder'
|
||||
@ -25,25 +24,12 @@ export interface PageProps extends Readonly<React.PropsWithChildren> {
|
||||
export default function Page(props: PageProps) {
|
||||
const { hideInfoBar = false, children, hideChat = false } = props
|
||||
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const session = authProvider.useUserSession()
|
||||
|
||||
const doCloseChat = () => {
|
||||
setIsHelpChatOpen(false)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const onClick = () => {
|
||||
if (getSelection()?.type !== 'Range') {
|
||||
unsetModal()
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', onClick)
|
||||
return () => {
|
||||
document.removeEventListener('click', onClick)
|
||||
}
|
||||
}, [unsetModal])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
|
@ -2,7 +2,6 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import * as toast from 'react-toastify'
|
||||
|
||||
import ArrowUpIcon from 'enso-assets/arrow_up.svg'
|
||||
import PlayIcon from 'enso-assets/play.svg'
|
||||
@ -32,8 +31,6 @@ import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const LOADING_MESSAGE =
|
||||
'Your environment is being created. It will take some time, please be patient.'
|
||||
/** The corresponding {@link spinner.SpinnerState} for each {@link backendModule.ProjectState},
|
||||
* when using the remote backend. */
|
||||
const REMOTE_SPINNER_STATE: Readonly<Record<backendModule.ProjectState, spinner.SpinnerState>> = {
|
||||
@ -73,8 +70,8 @@ export interface ProjectIconProps {
|
||||
readonly assetEvents: assetEvent.AssetEvent[]
|
||||
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
||||
readonly setProjectStartupInfo: (projectStartupInfo: backendModule.ProjectStartupInfo) => void
|
||||
readonly doCloseEditor: () => void
|
||||
readonly doOpenEditor: (switchPage: boolean) => void
|
||||
readonly doCloseEditor: (id: backendModule.ProjectId) => void
|
||||
readonly doOpenEditor: () => void
|
||||
}
|
||||
|
||||
/** An interactive icon indicating the status of a project. */
|
||||
@ -118,10 +115,6 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
const [shouldSwitchPage, setShouldSwitchPage] = React.useState(false)
|
||||
const doOpenEditorRef = React.useRef(doOpenEditor)
|
||||
doOpenEditorRef.current = doOpenEditor
|
||||
const toastId: toast.Id = React.useId()
|
||||
const isOpening =
|
||||
backendModule.IS_OPENING[item.projectState.type] &&
|
||||
item.projectState.type !== backendModule.ProjectState.placeholder
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
const isOtherUserUsingProject =
|
||||
isCloud && item.projectState.openedBy != null && item.projectState.openedBy !== user.email
|
||||
@ -136,6 +129,30 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
const openProjectMutate = openProjectMutation.mutateAsync
|
||||
const getProjectDetailsMutate = getProjectDetailsMutation.mutateAsync
|
||||
|
||||
const openEditorMutation = reactQuery.useMutation({
|
||||
mutationKey: ['openEditor', item.id],
|
||||
networkMode: 'always',
|
||||
mutationFn: async () => {
|
||||
const projectPromise = waitUntilProjectIsReadyMutation.mutateAsync([
|
||||
item.id,
|
||||
item.parentId,
|
||||
item.title,
|
||||
])
|
||||
if (shouldOpenWhenReady) {
|
||||
doOpenEditor()
|
||||
}
|
||||
setProjectStartupInfo({
|
||||
project: projectPromise,
|
||||
projectAsset: item,
|
||||
setProjectAsset: setItem,
|
||||
backendType: backend.type,
|
||||
accessToken: session?.accessToken ?? null,
|
||||
})
|
||||
await projectPromise
|
||||
},
|
||||
})
|
||||
const openEditorMutate = openEditorMutation.mutate
|
||||
|
||||
const openProject = React.useCallback(
|
||||
async (shouldRunInBackground: boolean) => {
|
||||
if (state !== backendModule.ProjectState.opened) {
|
||||
@ -150,6 +167,9 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
},
|
||||
item.title,
|
||||
])
|
||||
if (!shouldRunInBackground) {
|
||||
openEditorMutate()
|
||||
}
|
||||
} catch (error) {
|
||||
const project = await getProjectDetailsMutate([item.id, item.parentId, item.title])
|
||||
// `setState` is not used here as `project` contains the full state information,
|
||||
@ -166,52 +186,13 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
session,
|
||||
toastAndLog,
|
||||
openProjectMutate,
|
||||
openEditorMutate,
|
||||
getProjectDetailsMutate,
|
||||
setState,
|
||||
setItem,
|
||||
]
|
||||
)
|
||||
|
||||
const openEditorMutation = reactQuery.useMutation({
|
||||
mutationKey: ['openEditor', item.id],
|
||||
networkMode: 'always',
|
||||
mutationFn: async (abortController: AbortController) => {
|
||||
if (!isRunningInBackground && isCloud) {
|
||||
toast.toast.loading(LOADING_MESSAGE, { toastId })
|
||||
}
|
||||
const project = await waitUntilProjectIsReadyMutation.mutateAsync([
|
||||
item.id,
|
||||
item.parentId,
|
||||
item.title,
|
||||
abortController,
|
||||
])
|
||||
setProjectStartupInfo({
|
||||
project,
|
||||
projectAsset: item,
|
||||
setProjectAsset: setItem,
|
||||
backendType: backend.type,
|
||||
accessToken: session?.accessToken ?? null,
|
||||
})
|
||||
if (!abortController.signal.aborted) {
|
||||
toast.toast.dismiss(toastId)
|
||||
setState(backendModule.ProjectState.opened)
|
||||
}
|
||||
},
|
||||
})
|
||||
const openEditorMutate = openEditorMutation.mutate
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpening) {
|
||||
const abortController = new AbortController()
|
||||
openEditorMutate(abortController)
|
||||
return () => {
|
||||
abortController.abort()
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}, [isOpening, openEditorMutate])
|
||||
|
||||
React.useEffect(() => {
|
||||
// Ensure that the previous spinner state is visible for at least one frame.
|
||||
requestAnimationFrame(() => {
|
||||
@ -235,8 +216,20 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
}
|
||||
} else {
|
||||
if (backendModule.IS_OPENING_OR_OPENED[state]) {
|
||||
if (!isRunningInBackground) {
|
||||
doOpenEditor(true)
|
||||
const projectPromise = waitUntilProjectIsReadyMutation.mutateAsync([
|
||||
item.id,
|
||||
item.parentId,
|
||||
item.title,
|
||||
])
|
||||
setProjectStartupInfo({
|
||||
project: projectPromise,
|
||||
projectAsset: item,
|
||||
setProjectAsset: setItem,
|
||||
backendType: backend.type,
|
||||
accessToken: session?.accessToken ?? null,
|
||||
})
|
||||
if (!isRunningInBackground && event.shouldAutomaticallySwitchPage) {
|
||||
doOpenEditor()
|
||||
}
|
||||
} else {
|
||||
setShouldOpenWhenReady(!event.runInBackground)
|
||||
@ -266,7 +259,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
React.useEffect(() => {
|
||||
if (state === backendModule.ProjectState.opened) {
|
||||
if (shouldOpenWhenReady) {
|
||||
doOpenEditorRef.current(shouldSwitchPage)
|
||||
doOpenEditorRef.current()
|
||||
setShouldOpenWhenReady(false)
|
||||
}
|
||||
}
|
||||
@ -274,9 +267,8 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
|
||||
const closeProject = async () => {
|
||||
if (!isRunningInBackground) {
|
||||
doCloseEditor()
|
||||
doCloseEditor(item.id)
|
||||
}
|
||||
toast.toast.dismiss(toastId)
|
||||
setShouldOpenWhenReady(false)
|
||||
setState(backendModule.ProjectState.closing)
|
||||
await closeProjectMutation.mutateAsync([item.id, item.title])
|
||||
@ -371,7 +363,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
tooltipPlacement="right"
|
||||
className="h-6 border-0"
|
||||
onPress={() => {
|
||||
doOpenEditor(true)
|
||||
doOpenEditor()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -320,12 +320,8 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
assetEvents={assetEvents}
|
||||
dispatchAssetEvent={dispatchAssetEvent}
|
||||
setProjectStartupInfo={setProjectStartupInfo}
|
||||
doOpenEditor={switchPage => {
|
||||
doOpenEditor(asset, setAsset, switchPage)
|
||||
}}
|
||||
doCloseEditor={() => {
|
||||
doCloseEditor(asset)
|
||||
}}
|
||||
doOpenEditor={doOpenEditor}
|
||||
doCloseEditor={doCloseEditor}
|
||||
/>
|
||||
)}
|
||||
<EditableSpan
|
||||
|
@ -61,7 +61,7 @@ export default function AssetVersion(props: AssetVersionProps) {
|
||||
return (
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
'flex w-full flex-shrink-0 basis-0 select-none flex-row gap-4 rounded-2xl p-2',
|
||||
'flex w-full shrink-0 basis-0 select-none flex-row gap-4 rounded-2xl p-2',
|
||||
placeholder && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
|
@ -329,12 +329,8 @@ export interface AssetsTableState {
|
||||
title?: string | null,
|
||||
override?: boolean
|
||||
) => void
|
||||
readonly doOpenEditor: (
|
||||
project: backendModule.ProjectAsset,
|
||||
setProject: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
|
||||
switchPage: boolean
|
||||
) => void
|
||||
readonly doCloseEditor: (project: backendModule.ProjectAsset) => void
|
||||
readonly doOpenEditor: () => void
|
||||
readonly doCloseEditor: (projectId: backendModule.ProjectId) => void
|
||||
readonly doCopy: () => void
|
||||
readonly doCut: () => void
|
||||
readonly doPaste: (
|
||||
@ -361,7 +357,6 @@ export interface AssetsTableProps {
|
||||
readonly category: Category
|
||||
readonly setSuggestions: (suggestions: assetSearchBar.Suggestion[]) => void
|
||||
readonly initialProjectName: string | null
|
||||
readonly projectStartupInfo: backendModule.ProjectStartupInfo | null
|
||||
readonly assetListEvents: assetListEvent.AssetListEvent[]
|
||||
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
|
||||
readonly assetEvents: assetEvent.AssetEvent[]
|
||||
@ -369,21 +364,16 @@ export interface AssetsTableProps {
|
||||
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
|
||||
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
|
||||
readonly targetDirectoryNodeRef: React.MutableRefObject<assetTreeNode.AnyAssetTreeNode<backendModule.DirectoryAsset> | null>
|
||||
readonly doOpenEditor: (
|
||||
backend: Backend,
|
||||
project: backendModule.ProjectAsset,
|
||||
setProject: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
|
||||
switchPage: boolean
|
||||
) => void
|
||||
readonly doCloseEditor: (project: backendModule.ProjectAsset) => void
|
||||
readonly doOpenEditor: () => void
|
||||
readonly doCloseEditor: (projectId: backendModule.ProjectId) => void
|
||||
}
|
||||
|
||||
/** The table of project assets. */
|
||||
export default function AssetsTable(props: AssetsTableProps) {
|
||||
const { hidden, query, setQuery, setProjectStartupInfo, setCanDownload, category } = props
|
||||
const { setSuggestions, initialProjectName, projectStartupInfo } = props
|
||||
const { setSuggestions, initialProjectName } = props
|
||||
const { assetListEvents, dispatchAssetListEvent, assetEvents, dispatchAssetEvent } = props
|
||||
const { doOpenEditor: doOpenEditorRaw, doCloseEditor: doCloseEditorRaw } = props
|
||||
const { doOpenEditor, doCloseEditor } = props
|
||||
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
|
||||
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
@ -1806,26 +1796,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
})
|
||||
|
||||
const doOpenEditor = React.useCallback(
|
||||
(
|
||||
project: backendModule.ProjectAsset,
|
||||
setProject: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
|
||||
switchPage: boolean
|
||||
) => {
|
||||
doOpenEditorRaw(backend, project, setProject, switchPage)
|
||||
},
|
||||
[backend, doOpenEditorRaw]
|
||||
)
|
||||
|
||||
const doCloseEditor = React.useCallback(
|
||||
(project: backendModule.ProjectAsset) => {
|
||||
if (project.id === projectStartupInfo?.projectAsset.id) {
|
||||
doCloseEditorRaw(project)
|
||||
}
|
||||
},
|
||||
[projectStartupInfo, doCloseEditorRaw]
|
||||
)
|
||||
|
||||
const doCopy = React.useCallback(() => {
|
||||
unsetModal()
|
||||
setPasteData({ type: PasteType.copy, data: selectedKeysRef.current })
|
||||
|
@ -28,7 +28,6 @@ import * as ariaComponents from '#/components/AriaComponents'
|
||||
import * as result from '#/components/Result'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
import * as projectManager from '#/services/ProjectManager'
|
||||
|
||||
import AssetQuery from '#/utilities/AssetQuery'
|
||||
@ -69,20 +68,14 @@ export interface DriveProps {
|
||||
readonly dispatchAssetListEvent: (directoryEvent: assetListEvent.AssetListEvent) => void
|
||||
readonly assetEvents: assetEvent.AssetEvent[]
|
||||
readonly dispatchAssetEvent: (directoryEvent: assetEvent.AssetEvent) => void
|
||||
readonly projectStartupInfo: backendModule.ProjectStartupInfo | null
|
||||
readonly setProjectStartupInfo: (projectStartupInfo: backendModule.ProjectStartupInfo) => void
|
||||
readonly doOpenEditor: (
|
||||
backend: Backend,
|
||||
project: backendModule.ProjectAsset,
|
||||
setProject: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
|
||||
switchPage: boolean
|
||||
) => void
|
||||
readonly doCloseEditor: (project: backendModule.ProjectAsset) => void
|
||||
readonly doOpenEditor: () => void
|
||||
readonly doCloseEditor: (projectId: backendModule.ProjectId) => void
|
||||
}
|
||||
|
||||
/** Contains directory path and directory contents (projects, folders, secrets and files). */
|
||||
export default function Drive(props: DriveProps) {
|
||||
const { hidden, initialProjectName, projectStartupInfo } = props
|
||||
const { hidden, initialProjectName } = props
|
||||
const { assetListEvents, dispatchAssetListEvent, assetEvents, dispatchAssetEvent } = props
|
||||
const { setProjectStartupInfo, doOpenEditor, doCloseEditor, category, setCategory } = props
|
||||
|
||||
@ -337,7 +330,6 @@ export default function Drive(props: DriveProps) {
|
||||
category={category}
|
||||
setSuggestions={setSuggestions}
|
||||
initialProjectName={initialProjectName}
|
||||
projectStartupInfo={projectStartupInfo}
|
||||
assetEvents={assetEvents}
|
||||
dispatchAssetEvent={dispatchAssetEvent}
|
||||
assetListEvents={assetListEvents}
|
||||
|
@ -1,12 +1,18 @@
|
||||
/** @file The container that launches the IDE. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as gtagHooks from '#/hooks/gtagHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as errorBoundary from '#/components/ErrorBoundary'
|
||||
import * as suspense from '#/components/Suspense'
|
||||
|
||||
import type Backend from '#/services/Backend'
|
||||
import * as backendModule from '#/services/Backend'
|
||||
@ -29,14 +35,53 @@ export interface EditorProps {
|
||||
|
||||
/** The container that launches the IDE. */
|
||||
export default function Editor(props: EditorProps) {
|
||||
const { hidden, projectStartupInfo } = props
|
||||
|
||||
const editor = projectStartupInfo && (
|
||||
<EditorInternal {...props} projectStartupInfo={projectStartupInfo} />
|
||||
)
|
||||
|
||||
return hidden ? (
|
||||
<React.Suspense>
|
||||
<errorBoundary.ErrorBoundary FallbackComponent={() => null}>
|
||||
{editor}
|
||||
</errorBoundary.ErrorBoundary>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<suspense.Suspense loaderProps={{ minHeight: 'full' }}>
|
||||
<errorBoundary.ErrorBoundary>{editor}</errorBoundary.ErrorBoundary>
|
||||
</suspense.Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
// ======================
|
||||
// === EditorInternal ===
|
||||
// ======================
|
||||
|
||||
/** Props for an {@link EditorInternal}. */
|
||||
interface EditorInternalProps extends EditorProps {
|
||||
readonly projectStartupInfo: backendModule.ProjectStartupInfo
|
||||
}
|
||||
|
||||
/** An internal editor. */
|
||||
function EditorInternal(props: EditorInternalProps) {
|
||||
const { hidden, ydocUrl, projectStartupInfo, appRunner: AppRunner } = props
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { getText } = textProvider.useText()
|
||||
const gtagEvent = gtagHooks.useGtagEvent()
|
||||
const gtagEventRef = React.useRef(gtagEvent)
|
||||
gtagEventRef.current = gtagEvent
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
|
||||
const projectQuery = reactQuery.useSuspenseQuery({
|
||||
queryKey: ['editorProject'],
|
||||
queryFn: () => projectStartupInfo.project,
|
||||
staleTime: 0,
|
||||
meta: { persist: false },
|
||||
})
|
||||
const project = projectQuery.data
|
||||
|
||||
const logEvent = React.useCallback(
|
||||
(message: string, projectId?: string | null, metadata?: object | null) => {
|
||||
if (remoteBackend) {
|
||||
@ -48,30 +93,28 @@ export default function Editor(props: EditorProps) {
|
||||
|
||||
const renameProject = React.useCallback(
|
||||
(newName: string) => {
|
||||
if (projectStartupInfo != null) {
|
||||
let backend: Backend | null
|
||||
switch (projectStartupInfo.backendType) {
|
||||
case backendModule.BackendType.local:
|
||||
backend = localBackend
|
||||
break
|
||||
case backendModule.BackendType.remote:
|
||||
backend = remoteBackend
|
||||
break
|
||||
}
|
||||
const { id: projectId, parentId, title } = projectStartupInfo.projectAsset
|
||||
backend
|
||||
?.updateProject(
|
||||
projectId,
|
||||
{ projectName: newName, ami: null, ideVersion: null, parentId },
|
||||
title
|
||||
)
|
||||
.then(
|
||||
() => {
|
||||
projectStartupInfo.setProjectAsset?.(object.merger({ title: newName }))
|
||||
},
|
||||
e => toastAndLog('renameProjectError', e)
|
||||
)
|
||||
let backend: Backend | null
|
||||
switch (projectStartupInfo.backendType) {
|
||||
case backendModule.BackendType.local:
|
||||
backend = localBackend
|
||||
break
|
||||
case backendModule.BackendType.remote:
|
||||
backend = remoteBackend
|
||||
break
|
||||
}
|
||||
const { id: projectId, parentId, title } = projectStartupInfo.projectAsset
|
||||
backend
|
||||
?.updateProject(
|
||||
projectId,
|
||||
{ projectName: newName, ami: null, ideVersion: null, parentId },
|
||||
title
|
||||
)
|
||||
.then(
|
||||
() => {
|
||||
projectStartupInfo.setProjectAsset?.(object.merger({ title: newName }))
|
||||
},
|
||||
e => toastAndLog('renameProjectError', e)
|
||||
)
|
||||
},
|
||||
[remoteBackend, localBackend, projectStartupInfo, toastAndLog]
|
||||
)
|
||||
@ -85,19 +128,14 @@ export default function Editor(props: EditorProps) {
|
||||
}, [projectStartupInfo, hidden])
|
||||
|
||||
const appProps: types.EditorProps | null = React.useMemo(() => {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
if (projectStartupInfo == null) return null
|
||||
const { project } = projectStartupInfo
|
||||
const projectId = projectStartupInfo.projectAsset.id
|
||||
const jsonAddress = project.jsonAddress
|
||||
const binaryAddress = project.binaryAddress
|
||||
const ydocAddress = ydocUrl ?? ''
|
||||
if (jsonAddress == null) {
|
||||
toastAndLog('noJSONEndpointError')
|
||||
return null
|
||||
throw new Error(getText('noJSONEndpointError'))
|
||||
} else if (binaryAddress == null) {
|
||||
toastAndLog('noBinaryEndpointError')
|
||||
return null
|
||||
throw new Error(getText('noBinaryEndpointError'))
|
||||
} else {
|
||||
return {
|
||||
config: {
|
||||
@ -121,9 +159,20 @@ export default function Editor(props: EditorProps) {
|
||||
renameProject,
|
||||
}
|
||||
}
|
||||
}, [projectStartupInfo, toastAndLog, hidden, logEvent, ydocUrl, renameProject])
|
||||
}, [
|
||||
projectStartupInfo.projectAsset.id,
|
||||
project.jsonAddress,
|
||||
project.binaryAddress,
|
||||
project.packageName,
|
||||
project.name,
|
||||
ydocUrl,
|
||||
getText,
|
||||
hidden,
|
||||
logEvent,
|
||||
renameProject,
|
||||
])
|
||||
|
||||
if (projectStartupInfo == null || AppRunner == null || appProps == null) {
|
||||
if (AppRunner == null) {
|
||||
return <></>
|
||||
} else {
|
||||
// Currently the GUI component needs to be fully rerendered whenever the project is changed. Once
|
||||
|
@ -1,8 +1,7 @@
|
||||
/** @file Switcher to choose the currently visible full-screen page. */
|
||||
import * as React from 'react'
|
||||
|
||||
import DriveIcon from 'enso-assets/drive.svg'
|
||||
import WorkspaceIcon from 'enso-assets/workspace.svg'
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import type * as text from '#/text'
|
||||
|
||||
@ -14,17 +13,6 @@ import FocusArea from '#/components/styled/FocusArea'
|
||||
|
||||
import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
|
||||
// ============
|
||||
// === Page ===
|
||||
// ============
|
||||
|
||||
/** Main content of the screen. Only one should be visible at a time. */
|
||||
export enum Page {
|
||||
drive = 'drive',
|
||||
editor = 'editor',
|
||||
settings = 'settings',
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
@ -32,40 +20,34 @@ export enum Page {
|
||||
/** The corner radius of the tabs. */
|
||||
const TAB_RADIUS_PX = 24
|
||||
|
||||
const PAGE_DATA: PageUIData[] = [
|
||||
{ page: Page.drive, icon: DriveIcon, nameId: 'drivePageName' },
|
||||
{ page: Page.editor, icon: WorkspaceIcon, nameId: 'editorPageName' },
|
||||
]
|
||||
// =====================
|
||||
// === TabBarContext ===
|
||||
// =====================
|
||||
|
||||
// ==================
|
||||
// === PageUIData ===
|
||||
// ==================
|
||||
|
||||
/** Data describing how to display a button for a page. */
|
||||
interface PageUIData {
|
||||
readonly page: Page
|
||||
readonly icon: string
|
||||
readonly nameId: Extract<text.TextId, `${Page}PageName`>
|
||||
/** Context for a {@link TabBarContext}. */
|
||||
interface TabBarContextValue {
|
||||
readonly updateClipPath: (element: HTMLDivElement | null) => void
|
||||
}
|
||||
|
||||
// ====================
|
||||
// === PageSwitcher ===
|
||||
// ====================
|
||||
const TabBarContext = React.createContext<TabBarContextValue | null>(null)
|
||||
|
||||
/** Props for a {@link PageSwitcher}. */
|
||||
export interface PageSwitcherProps {
|
||||
readonly page: Page
|
||||
readonly setPage: (page: Page) => void
|
||||
readonly isEditorDisabled: boolean
|
||||
/** Custom hook to get tab bar context. */
|
||||
function useTabBarContext() {
|
||||
const context = React.useContext(TabBarContext)
|
||||
invariant(context, '`useTabBarContext` must be used inside a `<TabBar />`')
|
||||
return context
|
||||
}
|
||||
|
||||
// ==============
|
||||
// === TabBar ===
|
||||
// ==============
|
||||
|
||||
/** Props for a {@link TabBar}. */
|
||||
export interface TabBarProps extends Readonly<React.PropsWithChildren> {}
|
||||
|
||||
/** Switcher to choose the currently visible full-screen page. */
|
||||
export default function PageSwitcher(props: PageSwitcherProps) {
|
||||
const { page, setPage, isEditorDisabled } = props
|
||||
const visiblePageData = React.useMemo(
|
||||
() => PAGE_DATA.filter(pageData => (pageData.page !== Page.editor ? true : !isEditorDisabled)),
|
||||
[isEditorDisabled]
|
||||
)
|
||||
export default function TabBar(props: TabBarProps) {
|
||||
const { children } = props
|
||||
const cleanupResizeObserverRef = React.useRef(() => {})
|
||||
const backgroundRef = React.useRef<HTMLDivElement | null>(null)
|
||||
const selectedTabRef = React.useRef<HTMLDivElement | null>(null)
|
||||
@ -120,38 +102,19 @@ export default function PageSwitcher(props: PageSwitcherProps) {
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visiblePageData.every(pageData => page !== pageData.page)) {
|
||||
updateClipPath(null)
|
||||
}
|
||||
}, [page, updateClipPath, visiblePageData])
|
||||
|
||||
return (
|
||||
<div className="relative flex grow">
|
||||
<div
|
||||
ref={element => {
|
||||
backgroundRef.current = element
|
||||
updateResizeObserver(element)
|
||||
}}
|
||||
className="pointer-events-none absolute inset-0 bg-primary/5"
|
||||
/>
|
||||
<Tabs>
|
||||
{visiblePageData.map(pageData => {
|
||||
const isActive = page === pageData.page
|
||||
return (
|
||||
<Tab
|
||||
key={pageData.page}
|
||||
ref={isActive ? updateClipPath : null}
|
||||
isActive={isActive}
|
||||
onPress={() => {
|
||||
setPage(pageData.page)
|
||||
}}
|
||||
{...pageData}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Tabs>
|
||||
</div>
|
||||
<TabBarContext.Provider value={{ updateClipPath }}>
|
||||
<div className="relative flex grow">
|
||||
<div
|
||||
ref={element => {
|
||||
backgroundRef.current = element
|
||||
updateResizeObserver(element)
|
||||
}}
|
||||
className="pointer-events-none absolute inset-0 bg-primary/5"
|
||||
/>
|
||||
<Tabs>{children}</Tabs>
|
||||
</div>
|
||||
</TabBarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -162,7 +125,7 @@ export default function PageSwitcher(props: PageSwitcherProps) {
|
||||
/** Props for a {@link TabsInternal}. */
|
||||
export interface InternalTabsProps extends Readonly<React.PropsWithChildren> {}
|
||||
|
||||
/** A tab list in a {@link PageSwitcher}. */
|
||||
/** A tab list in a {@link TabBar}. */
|
||||
function TabsInternal(props: InternalTabsProps, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||
const { children } = props
|
||||
return (
|
||||
@ -186,23 +149,26 @@ const Tabs = React.forwardRef(TabsInternal)
|
||||
// ===========
|
||||
|
||||
/** Props for a {@link Tab}. */
|
||||
interface InternalTabProps extends PageUIData {
|
||||
interface InternalTabProps extends Readonly<React.PropsWithChildren> {
|
||||
readonly isActive: boolean
|
||||
readonly icon: string
|
||||
readonly labelId: text.TextId
|
||||
readonly onPress: () => void
|
||||
readonly onClose?: () => void
|
||||
}
|
||||
|
||||
/** A tab in a {@link PageSwitcher}. */
|
||||
function TabInternal(props: InternalTabProps, ref: React.ForwardedRef<HTMLDivElement>) {
|
||||
const { isActive, page, nameId, icon, onPress } = props
|
||||
/** A tab in a {@link TabBar}. */
|
||||
export function Tab(props: InternalTabProps) {
|
||||
const { isActive, icon, labelId, children, onPress, onClose } = props
|
||||
const { updateClipPath } = useTabBarContext()
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
return (
|
||||
<div
|
||||
key={page}
|
||||
ref={ref}
|
||||
ref={isActive ? updateClipPath : null}
|
||||
className={tailwindMerge.twMerge(
|
||||
'h-full transition-[padding-left]',
|
||||
page !== page && 'hover:enabled:bg-frame'
|
||||
'group relative h-full',
|
||||
!isActive && 'hover:enabled:bg-frame'
|
||||
)}
|
||||
>
|
||||
<ariaComponents.Button
|
||||
@ -211,13 +177,22 @@ function TabInternal(props: InternalTabProps, ref: React.ForwardedRef<HTMLDivEle
|
||||
icon={icon}
|
||||
isDisabled={isActive}
|
||||
isActive={isActive}
|
||||
className="flex h-full items-center gap-3 px-4"
|
||||
aria-label={getText(labelId)}
|
||||
tooltip={false}
|
||||
className={tailwindMerge.twMerge(
|
||||
'relative flex h-full items-center gap-3 px-4',
|
||||
onClose && 'pr-10'
|
||||
)}
|
||||
onPress={onPress}
|
||||
>
|
||||
{getText(nameId)}
|
||||
{children}
|
||||
</ariaComponents.Button>
|
||||
{onClose && (
|
||||
<ariaComponents.CloseButton
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onPress={onClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Tab = React.forwardRef(TabInternal)
|
@ -1,50 +0,0 @@
|
||||
/** @file The top-bar of dashboard. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
|
||||
import type * as pageSwitcher from '#/layouts/PageSwitcher'
|
||||
import PageSwitcher from '#/layouts/PageSwitcher'
|
||||
import UserBar from '#/layouts/UserBar'
|
||||
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
|
||||
// ==============
|
||||
// === TopBar ===
|
||||
// ==============
|
||||
|
||||
/** Props for a {@link TopBar}. */
|
||||
export interface TopBarProps {
|
||||
readonly page: pageSwitcher.Page
|
||||
readonly setPage: (page: pageSwitcher.Page) => void
|
||||
readonly projectAsset: backendModule.ProjectAsset | null
|
||||
readonly setProjectAsset: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>> | null
|
||||
readonly isEditorDisabled: boolean
|
||||
readonly setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
|
||||
readonly doRemoveSelf: () => void
|
||||
readonly onSignOut: () => void
|
||||
}
|
||||
|
||||
/** The {@link TopBarProps.setQuery} parameter is used to communicate with the parent component,
|
||||
* because `searchVal` may change parent component's project list. */
|
||||
export default function TopBar(props: TopBarProps) {
|
||||
const { page, setPage, projectAsset, setProjectAsset, isEditorDisabled } = props
|
||||
const { setIsHelpChatOpen, doRemoveSelf, onSignOut } = props
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<PageSwitcher page={page} setPage={setPage} isEditorDisabled={isEditorDisabled} />
|
||||
<UserBar
|
||||
backend={remoteBackend}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
setIsHelpChatOpen={setIsHelpChatOpen}
|
||||
projectAsset={projectAsset}
|
||||
setProjectAsset={setProjectAsset}
|
||||
doRemoveSelf={doRemoveSelf}
|
||||
onSignOut={onSignOut}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -12,7 +12,6 @@ import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as pageSwitcher from '#/layouts/PageSwitcher'
|
||||
import UserMenu from '#/layouts/UserMenu'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
@ -37,39 +36,34 @@ export interface UserBarProps {
|
||||
/** When `true`, the element occupies space in the layout but is not visible.
|
||||
* Defaults to `false`. */
|
||||
readonly invisible?: boolean
|
||||
readonly page: pageSwitcher.Page
|
||||
readonly setPage: (page: pageSwitcher.Page) => void
|
||||
readonly isOnEditorPage: boolean
|
||||
readonly setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
|
||||
readonly projectAsset: backendModule.ProjectAsset | null
|
||||
readonly setProjectAsset: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>> | null
|
||||
readonly doRemoveSelf: () => void
|
||||
readonly goToSettingsPage: () => void
|
||||
readonly onSignOut: () => void
|
||||
}
|
||||
|
||||
/** A toolbar containing chat and the user menu. */
|
||||
export default function UserBar(props: UserBarProps) {
|
||||
const { backend, invisible = false, page, setPage, setIsHelpChatOpen } = props
|
||||
const { projectAsset, setProjectAsset, doRemoveSelf, onSignOut } = props
|
||||
const { backend, invisible = false, isOnEditorPage, setIsHelpChatOpen } = props
|
||||
const { projectAsset, setProjectAsset, doRemoveSelf, goToSettingsPage, onSignOut } = props
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { getText } = textProvider.useText()
|
||||
|
||||
const { isFeatureUnderPaywall } = billing.usePaywall({ plan: user.plan })
|
||||
|
||||
const self =
|
||||
projectAsset?.permissions?.find(
|
||||
backendModule.isUserPermissionAnd(permissions => permissions.user.userId === user.userId)
|
||||
) ?? null
|
||||
|
||||
const shouldShowShareButton =
|
||||
backend != null &&
|
||||
page === pageSwitcher.Page.editor &&
|
||||
backend?.type === backendModule.BackendType.remote &&
|
||||
isOnEditorPage &&
|
||||
projectAsset != null &&
|
||||
setProjectAsset != null &&
|
||||
self != null
|
||||
|
||||
const shouldShowUpgradeButton = isFeatureUnderPaywall('inviteUser')
|
||||
|
||||
const shouldShowInviteButton =
|
||||
backend != null && !shouldShowShareButton && !shouldShowUpgradeButton
|
||||
|
||||
@ -141,12 +135,12 @@ export default function UserBar(props: UserBarProps) {
|
||||
buttonClassName="rounded-full after:rounded-full"
|
||||
className="h-row-h w-row-h rounded-full"
|
||||
onPress={() => {
|
||||
setModal(<UserMenu setPage={setPage} onSignOut={onSignOut} />)
|
||||
setModal(<UserMenu goToSettingsPage={goToSettingsPage} onSignOut={onSignOut} />)
|
||||
}}
|
||||
/>
|
||||
{/* Required for shortcuts to work. */}
|
||||
<div className="hidden">
|
||||
<UserMenu hidden setPage={setPage} onSignOut={onSignOut} />
|
||||
<UserMenu hidden goToSettingsPage={goToSettingsPage} onSignOut={onSignOut} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,8 +10,6 @@ import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as pageSwitcher from '#/layouts/PageSwitcher'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import MenuEntry from '#/components/MenuEntry'
|
||||
import Modal from '#/components/Modal'
|
||||
@ -31,13 +29,13 @@ import * as tailwindMerge from '#/utilities/tailwindMerge'
|
||||
export interface UserMenuProps {
|
||||
/** If `true`, disables `data-testid` because it will not be visible. */
|
||||
readonly hidden?: boolean
|
||||
readonly setPage: (page: pageSwitcher.Page) => void
|
||||
readonly goToSettingsPage: () => void
|
||||
readonly onSignOut: () => void
|
||||
}
|
||||
|
||||
/** Handling the UserMenuItem click event logic and displaying its content. */
|
||||
export default function UserMenu(props: UserMenuProps) {
|
||||
const { hidden = false, setPage, onSignOut } = props
|
||||
const { hidden = false, goToSettingsPage, onSignOut } = props
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
const { signOut } = authProvider.useAuth()
|
||||
@ -112,13 +110,7 @@ export default function UserMenu(props: UserMenuProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuEntry
|
||||
action="settings"
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
setPage(pageSwitcher.Page.settings)
|
||||
}}
|
||||
/>
|
||||
<MenuEntry action="settings" doAction={goToSettingsPage} />
|
||||
{aboutThisAppMenuEntry}
|
||||
<MenuEntry
|
||||
action="signOut"
|
||||
|
@ -2,6 +2,9 @@
|
||||
* interactive components. */
|
||||
import * as React from 'react'
|
||||
|
||||
import DriveIcon from 'enso-assets/drive.svg'
|
||||
import SettingsIcon from 'enso-assets/settings.svg'
|
||||
import WorkspaceIcon from 'enso-assets/workspace.svg'
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
@ -12,6 +15,7 @@ import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
@ -23,14 +27,14 @@ import Chat from '#/layouts/Chat'
|
||||
import ChatPlaceholder from '#/layouts/ChatPlaceholder'
|
||||
import Drive from '#/layouts/Drive'
|
||||
import Editor from '#/layouts/Editor'
|
||||
import * as pageSwitcher from '#/layouts/PageSwitcher'
|
||||
import Settings from '#/layouts/Settings'
|
||||
import TopBar from '#/layouts/TopBar'
|
||||
import * as tabBar from '#/layouts/TabBar'
|
||||
import TabBar from '#/layouts/TabBar'
|
||||
import UserBar from '#/layouts/UserBar'
|
||||
|
||||
import Page from '#/components/Page'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
import type * as projectManager from '#/services/ProjectManager'
|
||||
|
||||
import * as array from '#/utilities/array'
|
||||
@ -44,12 +48,19 @@ import type * as types from '../../../../types/types'
|
||||
// === Global configuration ===
|
||||
// ============================
|
||||
|
||||
/** Main content of the screen. Only one should be visible at a time. */
|
||||
enum TabType {
|
||||
drive = 'drive',
|
||||
editor = 'editor',
|
||||
settings = 'settings',
|
||||
}
|
||||
|
||||
declare module '#/utilities/LocalStorage' {
|
||||
/** */
|
||||
interface LocalStorageData {
|
||||
readonly isAssetPanelVisible: boolean
|
||||
readonly page: pageSwitcher.Page
|
||||
readonly projectStartupInfo: backendModule.ProjectStartupInfo
|
||||
readonly page: TabType
|
||||
readonly projectStartupInfo: backendModule.ProjectStartupInfo<backendModule.Project>
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,7 +68,7 @@ LocalStorage.registerKey('isAssetPanelVisible', {
|
||||
tryParse: value => (value === true ? value : null),
|
||||
})
|
||||
|
||||
const PAGES = Object.values(pageSwitcher.Page)
|
||||
const PAGES = Object.values(TabType)
|
||||
LocalStorage.registerKey('page', {
|
||||
tryParse: value => (array.includes(PAGES, value) ? value : null),
|
||||
})
|
||||
@ -114,6 +125,7 @@ export default function Dashboard(props: DashboardProps) {
|
||||
const session = authProvider.useNonPartialUserSession()
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const { modalRef } = modalProvider.useModalRef()
|
||||
const { updateModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
@ -126,9 +138,8 @@ export default function Dashboard(props: DashboardProps) {
|
||||
// These pages MUST be ROUTER PAGES.
|
||||
const [page, setPage] = searchParamsState.useSearchParamsState(
|
||||
'page',
|
||||
() => localStorage.get('page') ?? pageSwitcher.Page.drive,
|
||||
(value: unknown): value is pageSwitcher.Page =>
|
||||
array.includes(Object.values(pageSwitcher.Page), value)
|
||||
() => localStorage.get('page') ?? TabType.drive,
|
||||
(value: unknown): value is TabType => array.includes(Object.values(TabType), value)
|
||||
)
|
||||
const [projectStartupInfo, setProjectStartupInfo] =
|
||||
React.useState<backendModule.ProjectStartupInfo | null>(null)
|
||||
@ -167,13 +178,13 @@ export default function Dashboard(props: DashboardProps) {
|
||||
React.useEffect(() => {
|
||||
const savedProjectStartupInfo = localStorage.get('projectStartupInfo')
|
||||
if (initialProjectName != null) {
|
||||
if (page === pageSwitcher.Page.editor) {
|
||||
setPage(pageSwitcher.Page.drive)
|
||||
if (page === TabType.editor) {
|
||||
setPage(TabType.drive)
|
||||
}
|
||||
} else if (savedProjectStartupInfo != null) {
|
||||
if (savedProjectStartupInfo.backendType === backendModule.BackendType.remote) {
|
||||
if (remoteBackend != null) {
|
||||
setPage(pageSwitcher.Page.drive)
|
||||
setPage(TabType.drive)
|
||||
void (async () => {
|
||||
const abortController = new AbortController()
|
||||
setOpenProjectAbortController(abortController)
|
||||
@ -184,17 +195,19 @@ export default function Dashboard(props: DashboardProps) {
|
||||
savedProjectStartupInfo.projectAsset.title
|
||||
)
|
||||
if (backendModule.IS_OPENING_OR_OPENED[oldProject.state.type]) {
|
||||
const project = await remoteBackend.waitUntilProjectIsReady(
|
||||
const project = remoteBackend.waitUntilProjectIsReady(
|
||||
savedProjectStartupInfo.projectAsset.id,
|
||||
savedProjectStartupInfo.projectAsset.parentId,
|
||||
savedProjectStartupInfo.projectAsset.title,
|
||||
abortController
|
||||
)
|
||||
if (!abortController.signal.aborted) {
|
||||
setProjectStartupInfo(object.merge(savedProjectStartupInfo, { project }))
|
||||
if (page === pageSwitcher.Page.editor) {
|
||||
setPage(page)
|
||||
}
|
||||
setProjectStartupInfo(
|
||||
object.merge<backendModule.ProjectStartupInfo>(savedProjectStartupInfo, {
|
||||
project,
|
||||
})
|
||||
)
|
||||
if (page === TabType.editor) {
|
||||
setPage(page)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@ -214,13 +227,15 @@ export default function Dashboard(props: DashboardProps) {
|
||||
},
|
||||
savedProjectStartupInfo.projectAsset.title
|
||||
)
|
||||
const project = await localBackend.getProjectDetails(
|
||||
const project = localBackend.getProjectDetails(
|
||||
savedProjectStartupInfo.projectAsset.id,
|
||||
savedProjectStartupInfo.projectAsset.parentId,
|
||||
savedProjectStartupInfo.projectAsset.title
|
||||
)
|
||||
setProjectStartupInfo(object.merge(savedProjectStartupInfo, { project }))
|
||||
if (page === pageSwitcher.Page.editor) {
|
||||
setProjectStartupInfo(
|
||||
object.merge<backendModule.ProjectStartupInfo>(savedProjectStartupInfo, { project })
|
||||
)
|
||||
if (page === TabType.editor) {
|
||||
setPage(page)
|
||||
}
|
||||
})()
|
||||
@ -248,7 +263,9 @@ export default function Dashboard(props: DashboardProps) {
|
||||
React.useEffect(() => {
|
||||
if (initializedRef.current) {
|
||||
if (projectStartupInfo != null) {
|
||||
localStorage.set('projectStartupInfo', projectStartupInfo)
|
||||
void Promise.resolve(projectStartupInfo.project).then(project => {
|
||||
localStorage.set('projectStartupInfo', { ...projectStartupInfo, project })
|
||||
})
|
||||
} else {
|
||||
localStorage.delete('projectStartupInfo')
|
||||
}
|
||||
@ -256,9 +273,7 @@ export default function Dashboard(props: DashboardProps) {
|
||||
}, [projectStartupInfo, localStorage])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (page !== pageSwitcher.Page.settings) {
|
||||
localStorage.set('page', page)
|
||||
}
|
||||
localStorage.set('page', page)
|
||||
}, [page, localStorage])
|
||||
|
||||
React.useEffect(
|
||||
@ -268,13 +283,7 @@ export default function Dashboard(props: DashboardProps) {
|
||||
updateModal(oldModal => {
|
||||
if (oldModal == null) {
|
||||
queueMicrotask(() => {
|
||||
setPage(oldPage => {
|
||||
if (oldPage !== pageSwitcher.Page.settings) {
|
||||
return oldPage
|
||||
} else {
|
||||
return localStorage.get('page') ?? pageSwitcher.Page.drive
|
||||
}
|
||||
})
|
||||
setPage(localStorage.get('page') ?? TabType.drive)
|
||||
})
|
||||
return oldModal
|
||||
} else {
|
||||
@ -305,39 +314,26 @@ export default function Dashboard(props: DashboardProps) {
|
||||
}
|
||||
}, [inputBindings])
|
||||
|
||||
const doOpenEditor = React.useCallback(
|
||||
async (
|
||||
backend: Backend,
|
||||
newProject: backendModule.ProjectAsset,
|
||||
setProjectAsset: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
|
||||
switchPage: boolean
|
||||
) => {
|
||||
if (switchPage) {
|
||||
setPage(pageSwitcher.Page.editor)
|
||||
}
|
||||
if (projectStartupInfo?.project.projectId !== newProject.id) {
|
||||
setProjectStartupInfo({
|
||||
project: await backend.getProjectDetails(
|
||||
newProject.id,
|
||||
newProject.parentId,
|
||||
newProject.title
|
||||
),
|
||||
projectAsset: newProject,
|
||||
setProjectAsset: setProjectAsset,
|
||||
backendType: backend.type,
|
||||
accessToken: session.accessToken,
|
||||
const doOpenEditor = React.useCallback(() => {
|
||||
setPage(TabType.editor)
|
||||
}, [setPage])
|
||||
|
||||
const doCloseEditor = React.useCallback(
|
||||
(id: backendModule.ProjectId) => {
|
||||
if (id === projectStartupInfo?.projectAsset.id) {
|
||||
setProjectStartupInfo(currentInfo => {
|
||||
if (id === currentInfo?.projectAsset.id) {
|
||||
setPage(TabType.drive)
|
||||
return null
|
||||
} else {
|
||||
return currentInfo
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
[projectStartupInfo?.project.projectId, session.accessToken, setPage]
|
||||
[projectStartupInfo?.projectAsset.id, setPage]
|
||||
)
|
||||
|
||||
const doCloseEditor = React.useCallback((closingProject: backendModule.ProjectAsset) => {
|
||||
setProjectStartupInfo(oldInfo =>
|
||||
oldInfo?.projectAsset.id === closingProject.id ? null : oldInfo
|
||||
)
|
||||
}, [])
|
||||
|
||||
const doRemoveSelf = React.useCallback(() => {
|
||||
if (projectStartupInfo?.projectAsset != null) {
|
||||
const id = projectStartupInfo.projectAsset.id
|
||||
@ -347,8 +343,8 @@ export default function Dashboard(props: DashboardProps) {
|
||||
}, [projectStartupInfo?.projectAsset, dispatchAssetListEvent])
|
||||
|
||||
const onSignOut = React.useCallback(() => {
|
||||
if (page === pageSwitcher.Page.editor) {
|
||||
setPage(pageSwitcher.Page.drive)
|
||||
if (page === TabType.editor) {
|
||||
setPage(TabType.drive)
|
||||
}
|
||||
setProjectStartupInfo(null)
|
||||
}, [page, setPage])
|
||||
@ -363,22 +359,71 @@ export default function Dashboard(props: DashboardProps) {
|
||||
unsetModal()
|
||||
}}
|
||||
>
|
||||
<TopBar
|
||||
projectAsset={projectStartupInfo?.projectAsset ?? null}
|
||||
setProjectAsset={projectStartupInfo?.setProjectAsset ?? null}
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
isEditorDisabled={projectStartupInfo == null}
|
||||
setIsHelpChatOpen={setIsHelpChatOpen}
|
||||
doRemoveSelf={doRemoveSelf}
|
||||
onSignOut={onSignOut}
|
||||
/>
|
||||
<div className="flex">
|
||||
<TabBar>
|
||||
<tabBar.Tab
|
||||
isActive={page === TabType.drive}
|
||||
icon={DriveIcon}
|
||||
labelId="drivePageName"
|
||||
onPress={() => {
|
||||
setPage(TabType.drive)
|
||||
}}
|
||||
>
|
||||
{getText('drivePageName')}
|
||||
</tabBar.Tab>
|
||||
{projectStartupInfo != null && (
|
||||
<tabBar.Tab
|
||||
isActive={page === TabType.editor}
|
||||
icon={WorkspaceIcon}
|
||||
labelId="editorPageName"
|
||||
onPress={() => {
|
||||
setPage(TabType.editor)
|
||||
}}
|
||||
onClose={() => {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.closeProject,
|
||||
id: projectStartupInfo.projectAsset.id,
|
||||
})
|
||||
setPage(TabType.drive)
|
||||
}}
|
||||
>
|
||||
{projectStartupInfo.projectAsset.title}
|
||||
</tabBar.Tab>
|
||||
)}
|
||||
{page === TabType.settings && (
|
||||
<tabBar.Tab
|
||||
isActive
|
||||
icon={SettingsIcon}
|
||||
labelId="settingsPageName"
|
||||
onPress={() => {
|
||||
setPage(TabType.settings)
|
||||
}}
|
||||
onClose={() => {
|
||||
setPage(TabType.drive)
|
||||
}}
|
||||
>
|
||||
{getText('settingsPageName')}
|
||||
</tabBar.Tab>
|
||||
)}
|
||||
</TabBar>
|
||||
<UserBar
|
||||
backend={remoteBackend}
|
||||
isOnEditorPage={page === TabType.editor}
|
||||
setIsHelpChatOpen={setIsHelpChatOpen}
|
||||
projectAsset={projectStartupInfo?.projectAsset ?? null}
|
||||
setProjectAsset={projectStartupInfo?.setProjectAsset ?? null}
|
||||
doRemoveSelf={doRemoveSelf}
|
||||
goToSettingsPage={() => {
|
||||
setPage(TabType.settings)
|
||||
}}
|
||||
onSignOut={onSignOut}
|
||||
/>
|
||||
</div>
|
||||
<Drive
|
||||
category={category}
|
||||
setCategory={setCategory}
|
||||
hidden={page !== pageSwitcher.Page.drive}
|
||||
hidden={page !== TabType.drive}
|
||||
initialProjectName={initialProjectName}
|
||||
projectStartupInfo={projectStartupInfo}
|
||||
setProjectStartupInfo={setProjectStartupInfo}
|
||||
assetListEvents={assetListEvents}
|
||||
dispatchAssetListEvent={dispatchAssetListEvent}
|
||||
@ -388,13 +433,12 @@ export default function Dashboard(props: DashboardProps) {
|
||||
doCloseEditor={doCloseEditor}
|
||||
/>
|
||||
<Editor
|
||||
hidden={page !== pageSwitcher.Page.editor}
|
||||
hidden={page !== TabType.editor}
|
||||
ydocUrl={ydocUrl}
|
||||
projectStartupInfo={projectStartupInfo}
|
||||
appRunner={appRunner}
|
||||
/>
|
||||
{page === pageSwitcher.Page.settings && <Settings backend={remoteBackend} />}
|
||||
|
||||
{page === TabType.settings && <Settings backend={remoteBackend} />}
|
||||
{process.env.ENSO_CLOUD_CHAT_URL != null ? (
|
||||
<Chat
|
||||
isOpen={isHelpChatOpen}
|
||||
|
@ -292,8 +292,10 @@ export interface BackendProject extends Project {
|
||||
}
|
||||
|
||||
/** Information required to open a project. */
|
||||
export interface ProjectStartupInfo {
|
||||
readonly project: Project
|
||||
export interface ProjectStartupInfo<
|
||||
ProjectType extends Project | Promise<Project> = Project | Promise<Project>,
|
||||
> {
|
||||
readonly project: ProjectType
|
||||
readonly projectAsset: ProjectAsset
|
||||
// This MUST BE optional because it is lost when `JSON.stringify`ing to put in `localStorage`.
|
||||
readonly setProjectAsset?: React.Dispatch<React.SetStateAction<ProjectAsset>>
|
||||
|
@ -1070,7 +1070,11 @@ export default class RemoteBackend extends Backend {
|
||||
abortController: AbortController = new AbortController()
|
||||
) {
|
||||
let project = await this.getProjectDetails(projectId, directory, title)
|
||||
while (!abortController.signal.aborted && project.state.type !== backend.ProjectState.opened) {
|
||||
while (project.state.type !== backend.ProjectState.opened) {
|
||||
if (abortController.signal.aborted) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
throw new Error()
|
||||
}
|
||||
await new Promise<void>(resolve => {
|
||||
setTimeout(resolve, CHECK_STATUS_INTERVAL_MS)
|
||||
})
|
||||
|
@ -178,7 +178,7 @@
|
||||
"resend": "Resend",
|
||||
"invite": "Invite",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copied": "Copied!",
|
||||
"color": "Color",
|
||||
"labels": "Labels",
|
||||
"views": "Views",
|
||||
@ -202,7 +202,7 @@
|
||||
"keyboardShortcuts": "Keyboard Shortcuts",
|
||||
"dangerZone": "Danger Zone",
|
||||
"profilePicture": "Profile picture",
|
||||
"settingsFor": "Settings for ",
|
||||
"settingsFor": "Settings for",
|
||||
"inviteMembers": "Invite Members",
|
||||
"seatsLeft": "You have $0 / $1 seats left on your plan. Upgrade to invite more users.",
|
||||
"noSeatsLeft": "You have reached the limit of users for your plan. Upgrade to invite more users.",
|
||||
@ -464,6 +464,7 @@
|
||||
|
||||
"drivePageName": "Data Catalog",
|
||||
"editorPageName": "Spatial Analysis",
|
||||
"settingsPageName": "Settings",
|
||||
"chatButtonAltText": "Chat",
|
||||
"inviteButtonAltText": "Invite others to try Enso",
|
||||
"shareButtonAltText": "Share",
|
||||
|
2
app/ide-desktop/lib/types/globals.d.ts
vendored
2
app/ide-desktop/lib/types/globals.d.ts
vendored
@ -207,6 +207,8 @@ declare global {
|
||||
|
||||
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
||||
readonly IS_IN_PLAYWRIGHT_TEST?: `${boolean}`
|
||||
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
|
||||
readonly PWDEBUG?: '1'
|
||||
|
||||
// === Electron watch script variables ===
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user