New top bar (#7392)

* wip

* wip

* New backend switcher

* New user bar (user and chat buttons)

* Page switcher

* New search bar; minor style fixes

* Address QA

* Refactor HTML `button`s into `Button` component

* Add cloud color back to Tailwind

* Fix icons shrinking

* Fix bug

---------

Co-authored-by: Paweł Buchowski <pawel.buchowski@enso.org>
This commit is contained in:
somebody1234 2023-08-01 19:32:32 +10:00 committed by GitHub
parent c1e24629b0
commit ece18537e4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 571 additions and 379 deletions

View File

@ -1,5 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="1" width="12" height="3" fill="#767676" />
<rect x="2" y="6" width="12" height="3" fill="#767676" />
<rect x="2" y="11" width="12" height="3" fill="#767676" />
</svg>

Before

Width:  |  Height:  |  Size: 289 B

View File

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.9">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M8 15C12.4183 15 16 11.6421 16 7.5C16 3.35786 12.4183 0 8 0C3.58172 0 0 3.35786 0 7.5C0 9.08162 0.52221 10.5489 1.4138 11.7585L1.12093e-05 16L4.18897 14.0959C5.32198 14.6725 6.6202 15 8 15Z"
fill="black" fill-opacity="0.6" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 440 B

View File

@ -1,4 +1,5 @@
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.5 16A2.9 2.9 0 1 1 8 10.5 4 4 0 0 1 15.5 11 2 2 0 0 1 17.5 12 1.9 1.9 0 1 1 18.5 16"
fill="#3e515fe5" />
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M2.03823 7.31894C2.37639 4.32591 4.91657 2 8 2C10.7676 2 13.0976 3.87386 13.7904 6.42207C15.1006 7.07898 16 8.43446 16 10C16 12.2091 14.2091 14 12 14H8H3.5C1.567 14 0 12.433 0 10.5C0 9.08881 0.835182 7.87268 2.03823 7.31894Z"
fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 231 B

After

Width:  |  Height:  |  Size: 413 B

View File

@ -0,0 +1,9 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0 9.5V13.5C0 14.8807 3.58172 16 8 16C12.4183 16 16 14.8807 16 13.5V9.5C16 10.8807 12.4183 12 8 12C3.58172 12 0 10.8807 0 9.5Z"
fill="black" fill-opacity="0.6" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M0 4V8C0 9.38071 3.58172 10.5 8 10.5C12.4183 10.5 16 9.38071 16 8V4C16 5.38071 12.4183 6.5 8 6.5C3.58172 6.5 0 5.38071 0 4Z"
fill="black" fill-opacity="0.6" />
<ellipse cx="8" cy="2.5" rx="8" ry="2.5" fill="black" fill-opacity="0.6" />
</svg>

After

Width:  |  Height:  |  Size: 643 B

View File

@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.8">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M11 6.5C11 8.98528 8.98528 11 6.5 11C4.01472 11 2 8.98528 2 6.5C2 4.01472 4.01472 2 6.5 2C8.98528 2 11 4.01472 11 6.5ZM9.91218 12.0334C8.9204 12.6463 7.7515 13 6.5 13C2.91015 13 0 10.0899 0 6.5C0 2.91015 2.91015 0 6.5 0C10.0899 0 13 2.91015 13 6.5C13 7.75147 12.6463 8.92033 12.0335 9.91209L15.5601 13.4387C16.1458 14.0244 16.1458 14.9742 15.5601 15.56C14.9743 16.1458 14.0245 16.1458 13.4387 15.56L9.91218 12.0334Z"
fill="black" fill-opacity="0.6" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 666 B

View File

@ -0,0 +1,4 @@
<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M13 5H0V16H5V10H8V16H13V5Z" fill="black" fill-opacity="0.6" />
<path d="M6.5 0L13 5H0L6.5 0Z" fill="black" fill-opacity="0.6" />
</svg>

After

Width:  |  Height:  |  Size: 288 B

View File

@ -1,6 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.5">
<path d="M11.4142 10L15.6569 14.2426L14.2426 15.6569L10 11.4142L11.4142 10Z" fill="#3e515fe5" />
<circle cx="7" cy="7" r="5" stroke="#3e515fe5" stroke-width="2" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 313 B

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 fill-rule="evenodd" clip-rule="evenodd"
d="M0 0H16V4H13.5V6H16V10H9V6H11.5V4H4.5V6H7V10H4.5V12H16V16H0V12H2.5V10H0V6H2.5V4H0V0Z" fill="black"
fill-opacity="0.6" />
</svg>

After

Width:  |  Height:  |  Size: 292 B

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 fill-rule="evenodd" clip-rule="evenodd"
d="M1.12012 11.776L12.7387 5.06807C12.7403 5.06838 12.742 5.06869 12.7437 5.06901L16.0071 3.18391L16.0071 5.49044L14.828 6.17119C15.5521 6.89508 16 7.89524 16 9C16 11.2091 14.2091 13 12 13H4H3.5V12.9691C3.38338 12.9545 3.26833 12.9349 3.15505 12.9106L0.00883 14.7271L0 12.4312L1.12462 11.7807C1.12312 11.7791 1.12162 11.7776 1.12012 11.776ZM11.5851 4.00268L0.29851 10.519C0.1061 10.0506 0 9.53771 0 9C0 7.51015 0.81451 6.21055 2.02253 5.52219C2.26308 2.9849 4.39974 1 7 1C9.0514 1 10.8142 2.23534 11.5851 4.00268Z"
fill="black" />
</svg>

After

Width:  |  Height:  |  Size: 699 B

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 fill-rule="evenodd" clip-rule="evenodd"
d="M7.23515 0C6.79777 0 6.41114 0.284249 6.28067 0.701725L5.73703 2.4414C5.22538 2.64991 4.74898 2.92701 4.31925 3.26128L2.5392 2.86189C2.11243 2.76613 1.67295 2.95884 1.45425 3.33763L0.689444 4.66232C0.470751 5.0411 0.523601 5.51806 0.819915 5.83978L2.05539 7.18119C2.01886 7.44891 1.99999 7.72225 1.99999 8C1.99999 8.27774 2.01886 8.55107 2.05539 8.81878L0.819912 10.1602C0.523598 10.4819 0.470747 10.9589 0.68944 11.3376L1.45425 12.6623C1.67294 13.0411 2.11243 13.2338 2.5392 13.1381L4.31922 12.7387C4.74897 13.073 5.2254 13.3501 5.73708 13.5586L6.28072 15.2983C6.41118 15.7157 6.79781 16 7.2352 16H8.76481C9.2022 16 9.58883 15.7157 9.71929 15.2983L10.2629 13.5586C10.7746 13.3501 11.251 13.073 11.6808 12.7387L13.4609 13.1381C13.8876 13.2338 14.3271 13.0411 14.5458 12.6623L15.3106 11.3376C15.5293 10.9589 15.4765 10.4819 15.1801 10.1602L13.9446 8.8187C13.9811 8.55101 14 8.27771 14 8C14 7.72228 13.9811 7.44897 13.9446 7.18128L15.1801 5.83981C15.4764 5.51809 15.5293 5.04113 15.3106 4.66234L14.5458 3.33766C14.3271 2.95887 13.8876 2.76616 13.4608 2.86191L11.6808 3.26131C11.251 2.92702 10.7746 2.6499 10.2629 2.44138L9.71925 0.701725C9.58879 0.28425 9.20216 0 8.76477 0H7.23515ZM7.99999 10C9.10456 10 9.99999 9.10457 9.99999 8C9.99999 6.89543 9.10456 6 7.99999 6C6.89542 6 5.99999 6.89543 5.99999 8C5.99999 9.10457 6.89542 10 7.99999 10Z"
fill="black" fill-opacity="0.6" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,4 +0,0 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="8" cy="8" rx="8" ry="7.5" fill="white" />
<path d="M4.17269e-05 16.5L2 10.5L5.50006 14L4.17269e-05 16.5Z" fill="white" />
</svg>

Before

Width:  |  Height:  |  Size: 245 B

View File

@ -0,0 +1,41 @@
/** @file A toolbar for displaying asset information. */
import * as React from 'react'
import DocsIcon from 'enso-assets/docs.svg'
import SettingsIcon from 'enso-assets/settings.svg'
import * as backend from '../backend'
import Button from './button'
/** Props for an {@link AssetInfoBar}. */
export interface AssetInfoBarProps {
asset: backend.Asset | null
}
/** A toolbar for displaying asset information. */
// This parameter will be used in the future.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default function AssetInfoBar(_props: AssetInfoBarProps) {
return (
<div className="flex items-center shrink-0 bg-frame-bg rounded-full gap-3 h-8 px-2">
<Button
active={false}
disabled
image={DocsIcon}
error="Not implemented yet."
onClick={() => {
// No backend support yet.
}}
/>
<Button
active={false}
disabled
image={SettingsIcon}
error="Not implemented yet."
onClick={() => {
// No backend support yet.
}}
/>
</div>
)
}

View File

@ -0,0 +1,73 @@
/** @file Switcher for choosing the project management backend. */
import * as React from 'react'
import CloudIcon from 'enso-assets/cloud.svg'
import NotCloudIcon from 'enso-assets/not_cloud.svg'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import SvgMask from '../../authentication/components/svgMask'
// =======================
// === BackendSwitcher ===
// =======================
/** Props for a {@link BackendSwitcher}. */
export interface BackendSwitcherProps {
setBackendType: (backendType: backendModule.BackendType) => void
}
/** Switcher for choosing the project management backend. */
export default function BackendSwitcher(props: BackendSwitcherProps) {
const { setBackendType } = props
const { backend } = backendProvider.useBackend()
return (
<div className="flex shrink-0 gap-px">
<div
className={`rounded-l-full px-2.5 py-1 ${
backend.type === backendModule.BackendType.remote
? 'bg-frame-selected-bg'
: 'bg-frame-bg'
}`}
>
<button
onClick={() => {
setBackendType(backendModule.BackendType.remote)
}}
disabled={backend.type === backendModule.BackendType.remote}
className={`flex items-center gap-2 ${
backend.type === backendModule.BackendType.remote
? 'text-cloud'
: 'text-black opacity-30'
}`}
>
<SvgMask src={CloudIcon} />
<span className="leading-5 h-6 py-px">Cloud</span>
</button>
</div>
<div
className={`rounded-r-full px-2.5 py-1 ${
backend.type === backendModule.BackendType.local
? 'bg-frame-selected-bg'
: 'bg-frame-bg'
}`}
>
<button
onClick={() => {
setBackendType(backendModule.BackendType.local)
}}
disabled={backend.type === backendModule.BackendType.local}
className={`flex items-center gap-2 ${
backend.type === backendModule.BackendType.local
? 'text-cloud'
: 'text-black opacity-30'
}`}
>
<SvgMask src={NotCloudIcon} />
<span className="leading-5 h-6 py-px">Local</span>
</button>
</div>
</div>
)
}

View File

@ -6,20 +6,25 @@ export interface ButtonProps {
active?: boolean
disabled?: boolean
image: string
/** A title that is only shown when `disabled` is true. */
error?: string | null
onClick: (event: React.MouseEvent) => void
}
/** A styled button. */
export default function Button(props: ButtonProps) {
const { active = false, disabled = false, image, onClick } = props
const { active = false, disabled = false, image, error, onClick } = props
return (
<img
className={`${active ? '' : 'opacity-50'} ${
disabled ? '' : 'hover:opacity-100 cursor-pointer'
<button
disabled={disabled}
{...(error != null ? { title: error } : {})}
className={`cursor-pointer disabled:cursor-default disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-100 ${
active ? '' : 'opacity-50'
}`}
src={image}
onClick={onClick}
/>
>
<img src={image} />
</button>
)
}

View File

@ -693,7 +693,7 @@ export default function Chat(props: ChatProps) {
return reactDom.createPortal(
<div
style={{ right }}
className="text-xs-mini text-chat flex flex-col fixed top-0 right-0 h-screen bg-ide-bg border-ide-bg-dark border-l-2 w-83.5 py-1 z-10"
className="text-xs text-chat flex flex-col fixed top-0 right-0 h-screen bg-ide-bg border-ide-bg-dark border-l-2 w-83.5 py-1 z-10"
>
<ChatHeader
threads={threads}

View File

@ -12,28 +12,21 @@ import * as localBackend from '../localBackend'
import * as projectManager from '../projectManager'
import * as remoteBackendModule from '../remoteBackend'
import * as shortcuts from '../shortcuts'
import * as tabModule from '../tab'
import * as authProvider from '../../authentication/providers/auth'
import * as backendProvider from '../../providers/backend'
import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal'
import * as pageSwitcher from './pageSwitcher'
import * as spinner from './spinner'
import Chat, * as chat from './chat'
import DirectoryView from './driveView'
import Ide from './ide'
import DriveView from './driveView'
import Editor from './editor'
import Templates from './templates'
import TheModal from './theModal'
import TopBar from './topBar'
// =================
// === Constants ===
// =================
/** The `id` attribute of the element into which the IDE will be rendered. */
const IDE_ELEMENT_ID = 'root'
// =================
// === Dashboard ===
// =================
@ -64,7 +57,7 @@ export default function Dashboard(props: DashboardProps) {
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
const [isHelpChatVisible, setIsHelpChatVisible] = React.useState(false)
const [loadingProjectManagerDidFail, setLoadingProjectManagerDidFail] = React.useState(false)
const [tab, setTab] = React.useState(tabModule.Tab.dashboard)
const [page, setPage] = React.useState(pageSwitcher.Page.drive)
const [project, setProject] = React.useState<backendModule.Project | null>(null)
const [nameOfProjectToImmediatelyOpen, setNameOfProjectToImmediatelyOpen] =
React.useState(initialProjectName)
@ -80,37 +73,9 @@ export default function Dashboard(props: DashboardProps) {
session.type === authProvider.UserSessionType.offline &&
backend.type === backendModule.BackendType.remote
const switchToIdeTab = React.useCallback(() => {
setTab(tabModule.Tab.ide)
React.useEffect(() => {
unsetModal()
const ideElement = document.getElementById(IDE_ELEMENT_ID)
if (ideElement) {
ideElement.style.top = ''
ideElement.style.display = 'absolute'
}
}, [/* should never change */ unsetModal])
const switchToDashboardTab = React.useCallback(() => {
setTab(tabModule.Tab.dashboard)
const ideElement = document.getElementById(IDE_ELEMENT_ID)
if (ideElement) {
ideElement.style.top = '-100vh'
ideElement.style.display = 'fixed'
}
}, [])
const toggleTab = React.useCallback(() => {
if (project != null && tab === tabModule.Tab.dashboard) {
switchToIdeTab()
} else {
switchToDashboardTab()
}
}, [
project,
tab,
/* should never change */ switchToDashboardTab,
/* should never change */ switchToIdeTab,
])
}, [page, /* should never change */ unsetModal])
React.useEffect(() => {
if (
@ -127,11 +92,14 @@ export default function Dashboard(props: DashboardProps) {
}, [])
React.useEffect(() => {
document.addEventListener('show-dashboard', switchToDashboardTab)
return () => {
document.removeEventListener('show-dashboard', switchToDashboardTab)
const goToDrive = () => {
setPage(pageSwitcher.Page.drive)
}
}, [switchToDashboardTab])
document.addEventListener('show-dashboard', goToDrive)
return () => {
document.removeEventListener('show-dashboard', goToDrive)
}
}, [])
React.useEffect(() => {
// The types come from a third-party API and cannot be changed.
@ -224,17 +192,17 @@ export default function Dashboard(props: DashboardProps) {
[directoryId, /* should never change */ dispatchAssetListEvent]
)
const doOpenIde = React.useCallback(
const openEditor = React.useCallback(
async (newProject: backendModule.ProjectAsset) => {
switchToIdeTab()
setPage(pageSwitcher.Page.editor)
if (project?.projectId !== newProject.id) {
setProject(await backend.getProjectDetails(newProject.id, newProject.title))
}
},
[backend, project?.projectId, switchToIdeTab]
[backend, project?.projectId, setPage]
)
const doCloseIde = React.useCallback(() => {
const closeEditor = React.useCallback(() => {
setProject(null)
}, [])
@ -246,8 +214,8 @@ export default function Dashboard(props: DashboardProps) {
return (
<div
className={`flex flex-col gap-2 relative select-none text-primary text-xs h-screen py-2 ${
tab === tabModule.Tab.dashboard ? '' : 'hidden'
className={`flex flex-col gap-2 relative select-none text-primary text-xs h-screen pb-2 ${
page === pageSwitcher.Page.drive ? '' : 'hidden'
}`}
onContextMenu={event => {
event.preventDefault()
@ -258,10 +226,12 @@ export default function Dashboard(props: DashboardProps) {
<TopBar
supportsLocalBackend={supportsLocalBackend}
projectName={project?.name ?? null}
tab={tab}
page={page}
setPage={setPage}
asset={null}
isEditorDisabled={project == null}
isHelpChatOpen={isHelpChatOpen}
setIsHelpChatOpen={setIsHelpChatOpen}
toggleTab={toggleTab}
setBackendType={setBackendType}
query={query}
setQuery={setQuery}
@ -290,8 +260,8 @@ export default function Dashboard(props: DashboardProps) {
) : (
<>
<Templates onTemplateClick={doCreateProject} />
<DirectoryView
tab={tab}
<DriveView
page={page}
initialProjectName={initialProjectName}
nameOfProjectToImmediatelyOpen={nameOfProjectToImmediatelyOpen}
setNameOfProjectToImmediatelyOpen={setNameOfProjectToImmediatelyOpen}
@ -301,8 +271,8 @@ export default function Dashboard(props: DashboardProps) {
dispatchAssetListEvent={dispatchAssetListEvent}
query={query}
doCreateProject={doCreateProject}
doOpenIde={doOpenIde}
doCloseIde={doCloseIde}
doOpenEditor={openEditor}
doCloseEditor={closeEditor}
appRunner={appRunner}
loadingProjectManagerDidFail={loadingProjectManagerDidFail}
isListingRemoteDirectoryWhileOffline={isListingRemoteDirectoryWhileOffline}
@ -312,7 +282,11 @@ export default function Dashboard(props: DashboardProps) {
</>
)}
<TheModal />
{project && <Ide project={project} appRunner={appRunner} />}
<Editor
visible={page === pageSwitcher.Page.editor}
project={project}
appRunner={appRunner}
/>
{/* `session.accessToken` MUST be present in order for the `Chat` component to work. */}
{isHelpChatVisible && session.accessToken != null && (
<Chat

View File

@ -9,6 +9,7 @@ import DataUploadIcon from 'enso-assets/data_upload.svg'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import Button from './button'
// ================
// === DriveBar ===
@ -51,12 +52,16 @@ export default function DriveBar(props: DriveBarProps) {
<div className="flex items-center bg-frame-bg rounded-full gap-3 h-8 px-3">
{backend.type !== backendModule.BackendType.local && (
<>
<button onClick={doCreateDirectory}>
<img src={AddFolderIcon} />
</button>
<button disabled className="opacity-50">
<img src={AddConnectorIcon} />
</button>
<Button active image={AddFolderIcon} onClick={doCreateDirectory} />
<Button
active
disabled
image={AddConnectorIcon}
error="Not implemented yet."
onClick={() => {
// No backend support yet.
}}
/>
</>
)}
<input
@ -68,20 +73,24 @@ export default function DriveBar(props: DriveBarProps) {
className="hidden"
onInput={doUploadFiles}
/>
<button
<Button
active
disabled={backend.type === backendModule.BackendType.local}
className={
backend.type === backendModule.BackendType.local ? 'opacity-50' : ''
}
image={DataUploadIcon}
error="Cannot upload files from the local backend."
onClick={() => {
uploadFilesRef.current?.click()
}}
>
<img src={DataUploadIcon} />
</button>
<button disabled className="opacity-50">
<img src={DataDownloadIcon} />
</button>
/>
<Button
active
disabled
image={DataDownloadIcon}
error="Not implemented yet."
onClick={() => {
// No backend support yet.
}}
/>
</div>
</div>
</div>

View File

@ -11,8 +11,8 @@ import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as hooks from '../../hooks'
import * as loggerProvider from '../../providers/logger'
import * as tabModule from '../tab'
import * as pageSwitcher from './pageSwitcher'
import AssetsTable from './assetsTable'
import DriveBar from './driveBar'
@ -38,7 +38,7 @@ function regexEscape(string: string) {
/** Props for a {@link DirectoryView}. */
export interface DirectoryViewProps {
tab: tabModule.Tab
page: pageSwitcher.Page
initialProjectName: string | null
nameOfProjectToImmediatelyOpen: string | null
setNameOfProjectToImmediatelyOpen: (nameOfProjectToImmediatelyOpen: string | null) => void
@ -48,8 +48,8 @@ export interface DirectoryViewProps {
dispatchAssetListEvent: (directoryEvent: assetListEventModule.AssetListEvent) => void
query: string
doCreateProject: (templateId: string | null) => void
doOpenIde: (project: backendModule.ProjectAsset) => void
doCloseIde: () => void
doOpenEditor: (project: backendModule.ProjectAsset) => void
doCloseEditor: () => void
appRunner: AppRunner | null
loadingProjectManagerDidFail: boolean
isListingRemoteDirectoryWhileOffline: boolean
@ -60,7 +60,7 @@ export interface DirectoryViewProps {
/** Contains directory path and directory contents (projects, folders, secrets and files). */
export default function DirectoryView(props: DirectoryViewProps) {
const {
tab,
page,
initialProjectName,
nameOfProjectToImmediatelyOpen,
setNameOfProjectToImmediatelyOpen,
@ -70,8 +70,8 @@ export default function DirectoryView(props: DirectoryViewProps) {
assetListEvents,
dispatchAssetListEvent,
doCreateProject,
doOpenIde,
doCloseIde,
doOpenEditor,
doCloseEditor,
appRunner,
loadingProjectManagerDidFail,
isListingRemoteDirectoryWhileOffline,
@ -246,7 +246,7 @@ export default function DirectoryView(props: DirectoryViewProps) {
React.useEffect(() => {
const onDragEnter = (event: DragEvent) => {
if (
tab === tabModule.Tab.dashboard &&
page === pageSwitcher.Page.drive &&
event.dataTransfer?.types.includes('Files') === true
) {
setIsFileBeingDragged(true)
@ -256,7 +256,7 @@ export default function DirectoryView(props: DirectoryViewProps) {
return () => {
document.body.removeEventListener('dragenter', onDragEnter)
}
}, [tab])
}, [page])
return (
<div className="flex flex-col flex-1 overflow-hidden gap-2.5 px-3.25">
@ -281,8 +281,8 @@ export default function DirectoryView(props: DirectoryViewProps) {
dispatchAssetEvent={dispatchAssetEvent}
assetListEvents={assetListEvents}
dispatchAssetListEvent={dispatchAssetListEvent}
doOpenIde={doOpenIde}
doCloseIde={doCloseIde}
doOpenIde={doOpenEditor}
doCloseIde={doCloseEditor}
/>
{isFileBeingDragged &&
directoryId != null &&

View File

@ -0,0 +1,170 @@
/** @file Container that launches the editor. */
import * as React from 'react'
import * as auth from '../../authentication/providers/auth'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import GLOBAL_CONFIG from '../../../../../../../../gui/config.yaml' assert { type: 'yaml' }
// =================
// === Constants ===
// =================
/** The `id` attribute of the element into which the IDE will be rendered. */
const IDE_ELEMENT_ID = 'root'
const IDE_CDN_URL = 'https://cdn.enso.org/ide'
const JS_EXTENSION: Record<backendModule.BackendType, string> = {
[backendModule.BackendType.remote]: '.js.gz',
[backendModule.BackendType.local]: '.js',
} as const
// =================
// === Component ===
// =================
/** Props for an {@link Editor}. */
export interface EditorProps {
visible: boolean
project: backendModule.Project | null
appRunner: AppRunner
}
/** The container that launches the IDE. */
export default function Editor(props: EditorProps) {
const { visible, project, appRunner } = props
const { backend } = backendProvider.useBackend()
const { accessToken } = auth.useNonPartialUserSession()
React.useEffect(() => {
const ideElement = document.getElementById(IDE_ELEMENT_ID)
if (ideElement) {
if (visible) {
ideElement.style.top = ''
ideElement.style.display = 'absolute'
} else {
ideElement.style.top = '-100vh'
ideElement.style.display = 'fixed'
}
}
}, [visible])
let hasEffectRun = false
React.useEffect(() => {
// This is a hack to work around the IDE WASM not playing nicely with React Strict Mode.
// This is unavoidable as the WASM must fully set up to be able to properly drop its assets,
// but React re-executes this side-effect faster tha the WASM can do so.
if (hasEffectRun) {
// eslint-disable-next-line no-restricted-syntax
return
}
// eslint-disable-next-line react-hooks/exhaustive-deps
hasEffectRun = true
if (project != null) {
void (async () => {
const ideVersion =
project.ideVersion?.value ??
(backend.type === backendModule.BackendType.remote
? await backend.listVersions({
versionType: backendModule.VersionType.ide,
default: true,
})
: null)?.[0].number.value
const engineVersion =
project.engineVersion?.value ??
(backend.type === backendModule.BackendType.remote
? await backend.listVersions({
versionType: backendModule.VersionType.backend,
default: true,
})
: null)?.[0].number.value
const jsonAddress = project.jsonAddress
const binaryAddress = project.binaryAddress
if (ideVersion == null) {
throw new Error('Could not get the IDE version of the project.')
} else if (engineVersion == null) {
throw new Error('Could not get the engine version of the project.')
} else if (jsonAddress == null) {
throw new Error("Could not get the address of the project's JSON endpoint.")
} else if (binaryAddress == null) {
throw new Error("Could not get the address of the project's binary endpoint.")
} else {
let assetsRoot: string
switch (backend.type) {
case backendModule.BackendType.remote: {
assetsRoot = `${IDE_CDN_URL}/${ideVersion}/`
break
}
case backendModule.BackendType.local: {
assetsRoot = ''
break
}
}
const runNewProject = async () => {
const engineConfig =
backend.type === backendModule.BackendType.remote
? {
rpcUrl: jsonAddress,
dataUrl: binaryAddress,
}
: {
projectManagerUrl: GLOBAL_CONFIG.projectManagerEndpoint,
}
await appRunner.runApp(
{
loader: {
assetsUrl: `${assetsRoot}dynamic-assets`,
wasmUrl: `${assetsRoot}pkg-opt.wasm`,
jsUrl: `${assetsRoot}pkg${JS_EXTENSION[backend.type]}`,
},
engine: {
...engineConfig,
preferredVersion: engineVersion,
},
startup: {
project: project.packageName,
},
},
// Here we actually need explicit undefined.
// eslint-disable-next-line no-restricted-syntax
accessToken ?? undefined
)
}
if (backend.type === backendModule.BackendType.local) {
await runNewProject()
return
} else {
const script = document.createElement('script')
script.crossOrigin = 'anonymous'
script.src = `${IDE_CDN_URL}/${engineVersion}/index.js.gz`
script.onload = async () => {
document.body.removeChild(script)
const originalUrl = window.location.href
// The URL query contains commandline options when running in the desktop,
// which will break the entrypoint for opening a fresh IDE instance.
history.replaceState(null, '', new URL('.', originalUrl))
await runNewProject()
// Restore original URL so that initialization works correctly on refresh.
history.replaceState(null, '', originalUrl)
}
document.body.appendChild(script)
const style = document.createElement('link')
style.crossOrigin = 'anonymous'
style.rel = 'stylesheet'
style.href = `${IDE_CDN_URL}/${engineVersion}/style.css`
document.body.appendChild(style)
return () => {
style.remove()
}
}
}
})()
// The backend MUST NOT be a dependency, since the IDE should only be recreated when a new
// project is opened, and a local project does not exist on the cloud and vice versa.
// eslint-disable-next-line react-hooks/exhaustive-deps
}
}, [project, /* should never change */ appRunner])
return <></>
}

View File

@ -1,152 +0,0 @@
/** @file Container that launches the IDE. */
import * as React from 'react'
import * as auth from '../../authentication/providers/auth'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import GLOBAL_CONFIG from '../../../../../../../../gui/config.yaml' assert { type: 'yaml' }
// =================
// === Constants ===
// =================
const IDE_CDN_URL = 'https://cdn.enso.org/ide'
const JS_EXTENSION: Record<backendModule.BackendType, string> = {
[backendModule.BackendType.remote]: '.js.gz',
[backendModule.BackendType.local]: '.js',
} as const
// =================
// === Component ===
// =================
/** Props for an {@link Ide}. */
export interface IdeProps {
project: backendModule.Project
appRunner: AppRunner
}
/** The container that launches the IDE. */
export default function Ide(props: IdeProps) {
const { project, appRunner } = props
const { backend } = backendProvider.useBackend()
const { accessToken } = auth.useNonPartialUserSession()
let hasEffectRun = false
React.useEffect(() => {
// This is a hack to work around the IDE WASM not playing nicely with React Strict Mode.
// This is unavoidable as the WASM must fully set up to be able to properly drop its assets,
// but React re-executes this side-effect faster tha the WASM can do so.
if (hasEffectRun) {
// eslint-disable-next-line no-restricted-syntax
return
}
// eslint-disable-next-line react-hooks/exhaustive-deps
hasEffectRun = true
void (async () => {
const ideVersion =
project.ideVersion?.value ??
(backend.type === backendModule.BackendType.remote
? await backend.listVersions({
versionType: backendModule.VersionType.ide,
default: true,
})
: null)?.[0].number.value
const engineVersion =
project.engineVersion?.value ??
(backend.type === backendModule.BackendType.remote
? await backend.listVersions({
versionType: backendModule.VersionType.backend,
default: true,
})
: null)?.[0].number.value
const jsonAddress = project.jsonAddress
const binaryAddress = project.binaryAddress
if (ideVersion == null) {
throw new Error('Could not get the IDE version of the project.')
} else if (engineVersion == null) {
throw new Error('Could not get the engine version of the project.')
} else if (jsonAddress == null) {
throw new Error("Could not get the address of the project's JSON endpoint.")
} else if (binaryAddress == null) {
throw new Error("Could not get the address of the project's binary endpoint.")
} else {
let assetsRoot: string
switch (backend.type) {
case backendModule.BackendType.remote: {
assetsRoot = `${IDE_CDN_URL}/${ideVersion}/`
break
}
case backendModule.BackendType.local: {
assetsRoot = ''
break
}
}
const runNewProject = async () => {
const engineConfig =
backend.type === backendModule.BackendType.remote
? {
rpcUrl: jsonAddress,
dataUrl: binaryAddress,
}
: {
projectManagerUrl: GLOBAL_CONFIG.projectManagerEndpoint,
}
await appRunner.runApp(
{
loader: {
assetsUrl: `${assetsRoot}dynamic-assets`,
wasmUrl: `${assetsRoot}pkg-opt.wasm`,
jsUrl: `${assetsRoot}pkg${JS_EXTENSION[backend.type]}`,
},
engine: {
...engineConfig,
preferredVersion: engineVersion,
},
startup: {
project: project.packageName,
},
},
// Here we actually need explicit undefined.
// eslint-disable-next-line no-restricted-syntax
accessToken ?? undefined
)
}
if (backend.type === backendModule.BackendType.local) {
await runNewProject()
return
} else {
const script = document.createElement('script')
script.crossOrigin = 'anonymous'
script.src = `${IDE_CDN_URL}/${engineVersion}/index.js.gz`
script.onload = async () => {
document.body.removeChild(script)
const originalUrl = window.location.href
// The URL query contains commandline options when running in the desktop,
// which will break the entrypoint for opening a fresh IDE instance.
history.replaceState(null, '', new URL('.', originalUrl))
await runNewProject()
// Restore original URL so that initialization works correctly on refresh.
history.replaceState(null, '', originalUrl)
}
document.body.appendChild(script)
const style = document.createElement('link')
style.crossOrigin = 'anonymous'
style.rel = 'stylesheet'
style.href = `${IDE_CDN_URL}/${engineVersion}/style.css`
document.body.appendChild(style)
return () => {
style.remove()
}
}
}
})()
// The backend MUST NOT be a dependency, since the IDE should only be recreated when a new
// project is opened, and a local project does not exist on the cloud and vice versa.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [project, /* should never change */ appRunner])
return <></>
}

View File

@ -0,0 +1,71 @@
/** @file Switcher to choose the currently visible full-screen page. */
import * as React from 'react'
import DriveIcon from 'enso-assets/drive.svg'
import HomeIcon from 'enso-assets/home.svg'
import NetworkIcon from 'enso-assets/network.svg'
import Button from './button'
// ====================
// === PageSwitcher ===
// ====================
/** Main content of the screen. Only one should be visible at a time. */
export enum Page {
home = 'home',
drive = 'drive',
editor = 'editor',
}
/** Error text for each page. */
const ERRORS: Record<Page, string | null> = {
[Page.home]: 'Not implemented yet.',
[Page.drive]: null,
[Page.editor]: 'No project is currently open.',
}
/** Data describing how to display a button for a pageg. */
interface PageUIData {
page: Page
icon: string
}
const PAGE_DATA: PageUIData[] = [
{ page: Page.home, icon: HomeIcon },
{ page: Page.drive, icon: DriveIcon },
{ page: Page.editor, icon: NetworkIcon },
]
/** Props for a {@link PageSwitcher}. */
export interface PageSwitcherProps {
page: Page
setPage: (page: Page) => void
isEditorDisabled: boolean
}
/** Switcher to choose the currently visible full-screen page. */
export default function PageSwitcher(props: PageSwitcherProps) {
const { page, setPage, isEditorDisabled } = props
return (
<div className="flex shrink-0 gap-4">
{PAGE_DATA.map(pageData => {
const isDisabled =
pageData.page === Page.home ||
(pageData.page === Page.editor && isEditorDisabled)
return (
<Button
key={pageData.page}
image={pageData.icon}
active={page === pageData.page}
disabled={isDisabled}
error={ERRORS[pageData.page]}
onClick={() => {
setPage(pageData.page)
}}
/>
)
})}
</div>
)
}

View File

@ -1,20 +1,14 @@
/** @file The top-bar of dashboard. */
import * as React from 'react'
import BarsIcon from 'enso-assets/bars.svg'
import CloudIcon from 'enso-assets/cloud.svg'
import ComputerIcon from 'enso-assets/computer.svg'
import DefaultUserIcon from 'enso-assets/default_user.svg'
import MagnifyingGlassIcon from 'enso-assets/magnifying_glass.svg'
import SpeechBubbleIcon from 'enso-assets/speech_bubble.svg'
import FindIcon from 'enso-assets/find.svg'
import * as backendModule from '../backend'
import * as tabModule from '../tab'
import * as backendProvider from '../../providers/backend'
import * as modalProvider from '../../providers/modal'
import UserMenu from './userMenu'
import PageSwitcher, * as pageSwitcher from './pageSwitcher'
import AssetInfoBar from './assetInfoBar'
import BackendSwitcher from './backendSwitcher'
import UserBar from './userBar'
// ==============
// === TopBar ===
@ -25,8 +19,10 @@ export interface TopBarProps {
/** Whether the application may have the local backend running. */
supportsLocalBackend: boolean
projectName: string | null
tab: tabModule.Tab
toggleTab: () => void
page: pageSwitcher.Page
setPage: (page: pageSwitcher.Page) => void
asset: backendModule.Asset | null
isEditorDisabled: boolean
setBackendType: (backendType: backendModule.BackendType) => void
isHelpChatOpen: boolean
setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
@ -39,112 +35,42 @@ export interface TopBarProps {
export default function TopBar(props: TopBarProps) {
const {
supportsLocalBackend,
projectName,
tab,
toggleTab,
page,
setPage,
asset,
isEditorDisabled,
setBackendType,
isHelpChatOpen,
setIsHelpChatOpen,
query,
setQuery,
} = props
const { backend } = backendProvider.useBackend()
const { updateModal } = modalProvider.useSetModal()
return (
<div className="flex mx-2 h-8">
{supportsLocalBackend && (
<div className="bg-gray-100 rounded-full flex flex-row flex-nowrap p-1.5">
<button
onClick={() => {
setBackendType(backendModule.BackendType.local)
}}
className={`${
backend.type === backendModule.BackendType.local
? 'bg-white shadow-soft'
: 'opacity-50'
} rounded-full px-1.5 py-1`}
>
<img src={ComputerIcon} />
</button>
<button
onClick={() => {
setBackendType(backendModule.BackendType.remote)
}}
className={`${
backend.type === backendModule.BackendType.remote
? 'bg-white shadow-soft'
: 'opacity-50'
} rounded-full px-1.5 py-1`}
>
<img src={CloudIcon} />
</button>
</div>
)}
<div
className={`flex items-center bg-label rounded-full pl-1 pr-2.5 mx-2 ${
projectName != null ? 'cursor-pointer' : 'opacity-50'
}`}
onClick={toggleTab}
>
<span
className={`opacity-50 overflow-hidden transition-width nowrap ${
tab === tabModule.Tab.dashboard ? 'm-2 w-16' : 'w-0'
}`}
>
{projectName ?? 'Dashboard'}
</span>
<div className="bg-white shadow-soft rounded-full px-1.5 py-1">
<img src={BarsIcon} />
</div>
<span
className={`opacity-50 overflow-hidden transition-width nowrap ${
tab === tabModule.Tab.ide ? 'm-2 w-16' : 'w-0'
}`}
>
{projectName ?? 'No project open'}
</span>
</div>
<div className="grow flex items-center bg-label rounded-full px-2">
<div>
<img src={MagnifyingGlassIcon} />
</div>
<div className="relative flex ml-4.75 mr-2.25 mt-2.25 h-8 gap-6">
<PageSwitcher page={page} setPage={setPage} isEditorDisabled={isEditorDisabled} />
{supportsLocalBackend && <BackendSwitcher setBackendType={setBackendType} />}
<div className="grow" />
<div className="search-bar absolute flex items-center text-primary bg-frame-bg rounded-full -translate-x-1/2 gap-2.5 left-1/2 h-8 w-98.25 px-2">
<label htmlFor="search">
<img src={FindIcon} className="opacity-80" />
</label>
<input
type="text"
size={1}
placeholder="Click here or start typing to search for projects, data connectors, users, and more ..."
id="search"
placeholder="Type to search for projects, data connectors, users, and more."
value={query}
onChange={event => {
setQuery(event.target.value)
}}
className="flex-1 mx-2 bg-transparent"
className="grow bg-transparent leading-5 h-6 py-px"
/>
</div>
<div className="grow" />
{!isHelpChatOpen && (
<div
className="flex cursor-pointer items-center bg-help rounded-full px-2.5 text-white mx-2"
onClick={() => {
setIsHelpChatOpen(true)
}}
>
<span className="whitespace-nowrap">help chat</span>
<div className="ml-2">
<img src={SpeechBubbleIcon} />
</div>
</div>
)}
{/* User profile and menu. */}
<div className="transform w-8">
<div
onClick={event => {
event.stopPropagation()
updateModal(oldModal => (oldModal?.type === UserMenu ? null : <UserMenu />))
}}
className="rounded-full w-8 h-8 bg-cover cursor-pointer"
>
<img src={DefaultUserIcon} />
</div>
<div className="flex gap-2">
<AssetInfoBar asset={asset} />
<UserBar isHelpChatOpen={isHelpChatOpen} setIsHelpChatOpen={setIsHelpChatOpen} />
</div>
</div>
)

View File

@ -0,0 +1,45 @@
/** @file A toolbar containing chat and the user menu. */
import * as React from 'react'
import ChatIcon from 'enso-assets/chat.svg'
import DefaultUserIcon from 'enso-assets/default_user.svg'
import * as modalProvider from '../../providers/modal'
import Button from './button'
import UserMenu from './userMenu'
// ===============
// === UserBar ===
// ===============
/** Props for a {@link UserBar}. */
export interface UserBarProps {
isHelpChatOpen: boolean
setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
}
/** A toolbar containing chat and the user menu. */
export default function UserBar(props: UserBarProps) {
const { isHelpChatOpen, setIsHelpChatOpen } = props
const { updateModal } = modalProvider.useSetModal()
return (
<div className="flex shrink-0 items-center bg-frame-bg rounded-full gap-3 h-8 pl-2 pr-0.75">
<Button
active={isHelpChatOpen}
image={ChatIcon}
onClick={() => {
setIsHelpChatOpen(!isHelpChatOpen)
}}
/>
<button
onClick={event => {
event.stopPropagation()
updateModal(oldModal => (oldModal?.type === UserMenu ? null : <UserMenu />))
}}
>
<img src={DefaultUserIcon} height={28} width={28} />
</button>
</div>
)
}

View File

@ -60,7 +60,7 @@ export default function UserMenu() {
return (
<div
className="absolute right-2 top-11 z-10 flex flex-col rounded-md bg-white py-1 border"
className="absolute right-4.75 top-11 z-10 flex flex-col rounded-md bg-white py-1 border"
onClick={event => {
event.stopPropagation()
}}

View File

@ -1,12 +0,0 @@
/** @file An enum specifying the main content of the screen. Only one should be visible
* at a time. */
// ===========
// === Tab ===
// ===========
/** Main content of the screen. Only one should be visible at a time. */
export enum Tab {
dashboard = 'dashboard',
ide = 'ide',
}

View File

@ -128,10 +128,18 @@ body {
.h-templates-custom {
height: 21.5rem;
@media screen and (min-width: 1771px) {
@media screen and (min-width: 110.6875rem) {
height: 11rem;
}
}
.search-bar.search-bar {
@media screen and (max-width: 59rem) {
position: unset;
transform: unset;
left: unset;
}
}
}
.pointer-events-none-recursive,

View File

@ -19,7 +19,7 @@ export const theme = {
extend: {
colors: {
/** The default color of all text. */
primary: 'rgba(0, 0, 0, 0.6)',
primary: 'rgba(0, 0, 0, 0.60)',
chat: '#484848',
'ide-bg': '#ebeef1',
'ide-bg-dark': '#d0d3d6',
@ -27,7 +27,9 @@ export const theme = {
// Should be `#3e515f14`, but `bg-opacity` does not work with RGBA.
label: '#f0f1f3',
help: '#3f68ce',
cloud: '#0666be',
'frame-bg': 'rgba(255, 255, 255, 0.40)',
'frame-selected-bg': 'rgba(255, 255, 255, 0.70)',
'tag-text': 'rgba(255, 255, 255, 0.90)',
'tag-text-2': 'rgba(0, 0, 0, 0.60)',
'permission-owner': 'rgba(236, 2, 2, 0.70)',
@ -44,12 +46,15 @@ export const theme = {
2: '2',
},
fontSize: {
'xs-mini': '0.71875rem',
xs: '0.71875rem',
vs: '0.8125rem',
},
spacing: {
'0.75': '0.1875rem',
'1.75': '0.4375rem',
'2.25': '0.5625rem',
'3.25': '0.8125rem',
'4.75': '1.1875rem',
'5.5': '1.375rem',
'9.5': '2.375rem',
'18': '4.5rem',
@ -59,6 +64,7 @@ export const theme = {
'54': '13.5rem',
'70': '17.5rem',
'83.5': '20.875rem',
'98.25': '24.5625rem',
'140': '35rem',
'10lh': '10lh',
},