Implement back and forth navigation (#9301)

- Closes enso-org/cloud-v2#940
This commit is contained in:
Sergei Garin 2024-03-22 16:36:08 +04:00 committed by GitHub
parent c983d081d9
commit 6c1ba64671
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 310 additions and 31 deletions

View File

@ -0,0 +1,4 @@
<svg style="transform: rotate(180deg)" height="24" width="24" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
viewBox="0 0 24 24" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" >
<path d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@ -1,4 +1,4 @@
<svg height="24" width="24" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
viewBox="0 0 24 24" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
viewBox="0 0 24 24" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" >
<path d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 267 B

After

Width:  |  Height:  |  Size: 269 B

View File

@ -306,6 +306,7 @@ class App {
}
const window = new electron.BrowserWindow(windowPreferences)
window.setMenuBarVisibility(false)
if (this.args.groups.debug.options.devTools.value) {
window.webContents.openDevTools()
}
@ -397,6 +398,15 @@ class App {
}
}
)
// Handling navigation events from renderer process
electron.ipcMain.on(ipc.Channel.goBack, () => {
this.window?.webContents.goBack()
})
electron.ipcMain.on(ipc.Channel.goForward, () => {
this.window?.webContents.goForward()
})
}
/** The server port. In case the server was not started, the port specified in the configuration

View File

@ -23,6 +23,8 @@ export enum Channel {
saveAccessToken = 'save-access-token',
/** Channel for importing a project or project bundle from the given path. */
importProjectFromPath = 'import-project-from-path',
goBack = 'go-back',
goForward = 'go-forward',
/** Channel for selecting files and directories using the system file browser. */
openFileBrowser = 'open-file-browser',
}

View File

@ -21,6 +21,8 @@ const AUTHENTICATION_API_KEY = 'authenticationApi'
* window. */
const FILE_BROWSER_API_KEY = 'fileBrowserApi'
const NAVIGATION_API_KEY = 'navigationApi'
// =============================
// === importProjectFromPath ===
// =============================
@ -37,6 +39,15 @@ const BACKEND_API = {
}
electron.contextBridge.exposeInMainWorld(BACKEND_API_KEY, BACKEND_API)
electron.contextBridge.exposeInMainWorld(NAVIGATION_API_KEY, {
goBack: () => {
electron.ipcRenderer.send(ipc.Channel.goBack)
},
goForward: () => {
electron.ipcRenderer.send(ipc.Channel.goForward)
},
})
electron.ipcRenderer.on(
ipc.Channel.importProjectFromPath,
(_event, projectPath: string, projectId: string) => {

View File

@ -140,7 +140,7 @@ export interface AppProps {
export default function App(props: AppProps) {
// This is a React component even though it does not contain JSX.
// eslint-disable-next-line no-restricted-syntax
const Router = detect.isOnElectron() ? router.MemoryRouter : router.BrowserRouter
const Router = detect.isOnElectron() ? router.HashRouter : router.BrowserRouter
// Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`.
// Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider`
// will redirect the user between the login/register pages and the dashboard.
@ -186,6 +186,7 @@ function AppRouter(props: AppProps) {
window.navigate = navigate
}
const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings())
React.useEffect(() => {
const savedInputBindings = localStorage.get('inputBindings')
if (savedInputBindings != null) {
@ -203,6 +204,7 @@ function AppRouter(props: AppProps) {
}
}
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
const inputBindings = React.useMemo(() => {
const updateLocalStorage = () => {
localStorage.set(
@ -250,11 +252,14 @@ function AppRouter(props: AppProps) {
},
}
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
const mainPageUrl = getMainPageUrl()
const authService = React.useMemo(() => {
const authConfig = { navigate, ...props }
return authServiceModule.initAuthService(authConfig)
}, [props, /* should never change */ navigate])
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
const registerAuthEventListener = authService?.registerAuthEventListener ?? null
const initialBackend: Backend = isAuthenticationDisabled
@ -262,6 +267,7 @@ function AppRouter(props: AppProps) {
: // This is safe, because the backend is always set by the authentication flow.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
null!
React.useEffect(() => {
let isClick = false
const onMouseDown = () => {
@ -283,6 +289,7 @@ function AppRouter(props: AppProps) {
}
}
}
const onSelectStart = () => {
isClick = false
}
@ -295,6 +302,7 @@ function AppRouter(props: AppProps) {
document.removeEventListener('selectstart', onSelectStart)
}
}, [])
const routes = (
<router.Routes>
<React.Fragment>

View File

@ -4,6 +4,8 @@ import * as React from 'react'
import CrossIcon from 'enso-assets/cross.svg'
import TickIcon from 'enso-assets/tick.svg'
import * as eventCalback from '#/hooks/eventCallbackHooks'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import SvgMask from '#/components/SvgMask'
@ -38,6 +40,10 @@ export default function EditableSpan(props: EditableSpanProps) {
const inputRef = React.useRef<HTMLInputElement>(null)
const cancelled = React.useRef(false)
// Making sure that the event callback is stable.
// to prevent the effect from re-running.
const onCancelEventCallback = eventCalback.useEventCallback(onCancel)
React.useEffect(() => {
setIsSubmittable(checkSubmittable?.(inputRef.current?.value ?? '') ?? true)
// This effect MUST only run on mount.
@ -48,7 +54,7 @@ export default function EditableSpan(props: EditableSpanProps) {
if (editable) {
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
cancelEditName: () => {
onCancel()
onCancelEventCallback()
cancelled.current = true
inputRef.current?.blur()
},
@ -56,7 +62,7 @@ export default function EditableSpan(props: EditableSpanProps) {
} else {
return
}
}, [editable, onCancel, /* should never change */ inputBindings])
}, [editable, /* should never change */ inputBindings, onCancelEventCallback])
React.useEffect(() => {
cancelled.current = false

View File

@ -35,20 +35,24 @@ export default function SvgMask(props: SvgMaskProps) {
...(style ?? {}),
backgroundColor: color ?? 'currentcolor',
mask: urlSrc,
maskPosition: 'center',
maskRepeat: 'no-repeat',
maskSize: 'contain',
// The names come from a third-party API and cannot be changed.
// eslint-disable-next-line @typescript-eslint/naming-convention
/* eslint-disable @typescript-eslint/naming-convention */
WebkitMask: urlSrc,
WebkitMaskPosition: 'center',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskSize: 'contain',
/* eslint-enable @typescript-eslint/naming-convention */
}}
className={`inline-block ${onClick != null ? 'cursor-pointer' : ''} ${
className ?? 'h-max w-max'
}`}
onClick={onClick}
onDragStart={event => {
event.preventDefault()
}}
>
{/* This is required for this component to have the right size. */}
<img alt={alt} src={src} className="transparent" />
<img alt={alt} src={src} className="transparent" draggable={false} />
</div>
)
}

View File

@ -5,6 +5,8 @@ import AddFolderIcon from 'enso-assets/add_folder.svg'
import AddKeyIcon from 'enso-assets/add_key.svg'
import AddNetworkIcon from 'enso-assets/add_network.svg'
import AppDownloadIcon from 'enso-assets/app_download.svg'
import ArrowLeftIcon from 'enso-assets/arrow_left.svg'
import ArrowRightIcon from 'enso-assets/arrow_right.svg'
import CameraIcon from 'enso-assets/camera.svg'
import CloseIcon from 'enso-assets/close.svg'
import CloudToIcon from 'enso-assets/cloud_to.svg'
@ -101,4 +103,16 @@ export const BINDINGS = inputBindings.defineBindings({
bindings: ['Mod+Shift+PointerMain'],
rebindable: false,
},
goBack: {
name: 'Go Back',
bindings: detect.isOnMacOS() ? ['Mod+ArrowLeft', 'Mod+['] : ['Alt+ArrowLeft'],
rebindable: true,
icon: ArrowLeftIcon,
},
goForward: {
name: 'Go Forward',
bindings: detect.isOnMacOS() ? ['Mod+ArrowRight', 'Mod+]'] : ['Alt+ArrowRight'],
rebindable: true,
icon: ArrowRightIcon,
},
})

View File

@ -0,0 +1,23 @@
/**
* @file useEventCallback shim
*/
import * as React from 'react'
import * as syncRef from '#/hooks/syncRefHooks'
/**
* useEvent shim.
* @see https://github.com/reactjs/rfcs/pull/220
* @see https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md#internal-implementation
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useEventCallback<Func extends (...args: any[]) => unknown>(callback: Func) {
const callbackRef = syncRef.useSyncRef(callback)
// Make sure that the value of `this` provided for the call to fn is not `ref`
// This type assertion is safe, because it's a transparent wrapper around the original callback
// we mute react-hooks/exhaustive-deps because we don't need to update the callback when the callbackRef changes(it never does)
// eslint-disable-next-line react-hooks/exhaustive-deps, no-restricted-syntax
return React.useCallback(((...args) => callbackRef.current.apply(void 0, args)) as Func, [])
}

View File

@ -0,0 +1,80 @@
/**
* @file
*
* Search params state hook store a value in the URL search params.
*/
import * as React from 'react'
import * as reactRouterDom from 'react-router-dom'
import * as eventCallback from '#/hooks/eventCallbackHooks'
import * as lazyMemo from '#/hooks/useLazyMemoHooks'
import * as safeJsonParse from '#/utilities/safeJsonParse'
/**
* The return type of the `useSearchParamsState` hook.
*/
type SearchParamsStateReturnType<T> = Readonly<
[value: T, setValue: (nextValue: React.SetStateAction<T>) => void, clear: () => void]
>
/**
* Hook that synchronize a state in the URL search params. It returns the value, a setter and a clear function.
* @param key - The key to store the value in the URL search params.
* @param defaultValue - The default value to use if the key is not present in the URL search params.
* @param predicate - A function to check if the value is of the right type.
*/
export function useSearchParamsState<T = unknown>(
key: string,
defaultValue: T | (() => T),
predicate: (unknown: unknown) => unknown is T = (unknown): unknown is T => true
): SearchParamsStateReturnType<T> {
const [searchParams, setSearchParams] = reactRouterDom.useSearchParams()
const lazyDefaultValueInitializer = lazyMemo.useLazyMemoHooks(defaultValue, [])
const predicateEventCallback = eventCallback.useEventCallback(predicate)
const clear = eventCallback.useEventCallback((replace: boolean = false) => {
searchParams.delete(key)
setSearchParams(searchParams, { replace })
})
const rawValue = React.useMemo<T>(() => {
const maybeValue = searchParams.get(key)
const defaultValueFrom = lazyDefaultValueInitializer()
return maybeValue != null
? safeJsonParse.safeJsonParse(maybeValue, defaultValueFrom, (unknown): unknown is T => true)
: defaultValueFrom
}, [key, lazyDefaultValueInitializer, searchParams])
const isValueValid = predicateEventCallback(rawValue)
const value = isValueValid ? rawValue : lazyDefaultValueInitializer()
if (!isValueValid) {
clear(true)
}
/**
* Set the value in the URL search params. If the next value is the same as the default value, it will remove the key from the URL search params.
* Function reference is always the same.
* @param nextValue - The next value to set.
* @returns void
*/
const setValue = eventCallback.useEventCallback((nextValue: React.SetStateAction<T>) => {
if (nextValue instanceof Function) {
nextValue = nextValue(value)
}
if (nextValue === lazyDefaultValueInitializer()) {
clear()
} else {
searchParams.set(key, JSON.stringify(nextValue))
setSearchParams(searchParams)
}
})
return [value, setValue, clear]
}

View File

@ -0,0 +1,17 @@
/**
* @file useSyncRef.ts
*
* A hook that returns a ref object whose `current` property is always in sync with the provided value.
*/
import * as React from 'react'
/**
* A hook that returns a ref object whose `current` property is always in sync with the provided value.
*/
export function useSyncRef<T>(value: T): React.MutableRefObject<T> {
const ref = React.useRef(value)
ref.current = value
return ref
}

View File

@ -0,0 +1,29 @@
/**
* @file
*
* A hook that returns a memoized function that will only be called once
*/
import * as React from 'react'
const UNSET_VALUE = Symbol('unset')
/**
* A hook that returns a memoized function that will only be called once
*/
export function useLazyMemoHooks<T>(factory: T | (() => T), deps: React.DependencyList): () => T {
return React.useMemo(() => {
let cachedValue: T | typeof UNSET_VALUE = UNSET_VALUE
return (): T => {
if (cachedValue === UNSET_VALUE) {
cachedValue = factory instanceof Function ? factory() : factory
}
return cachedValue
}
// We assume that the callback should change only when
// the deps change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
}

View File

@ -5,7 +5,6 @@ import Home2Icon from 'enso-assets/home2.svg'
import RecentIcon from 'enso-assets/recent.svg'
import Trash2Icon from 'enso-assets/trash2.svg'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider'
import type * as assetEvent from '#/events/assetEvent'
@ -46,6 +45,7 @@ interface InternalCategorySwitcherItemProps {
function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
const { category, isCurrent, onClick } = props
const { onDragOver, onDrop } = props
return (
<button
disabled={isCurrent}
@ -89,11 +89,6 @@ export interface CategorySwitcherProps {
export default function CategorySwitcher(props: CategorySwitcherProps) {
const { category, setCategory, dispatchAssetEvent } = props
const { unsetModal } = modalProvider.useSetModal()
const { localStorage } = localStorageProvider.useLocalStorage()
React.useEffect(() => {
localStorage.set('driveCategory', category)
}, [category, /* should never change */ localStorage])
return (
<div className="flex w-full flex-col gap-sidebar-section-heading">

View File

@ -5,7 +5,9 @@ import * as common from 'enso-common'
import * as appUtils from '#/appUtils'
import * as eventCallback from '#/hooks/eventCallbackHooks'
import * as navigateHooks from '#/hooks/navigateHooks'
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
@ -120,8 +122,10 @@ export default function Drive(props: DriveProps) {
const { localStorage } = localStorageProvider.useLocalStorage()
const [canDownload, setCanDownload] = React.useState(false)
const [didLoadingProjectManagerFail, setDidLoadingProjectManagerFail] = React.useState(false)
const [category, setCategory] = React.useState(
() => localStorage.get('driveCategory') ?? Category.home
const [category, setCategory] = searchParamsState.useSearchParamsState(
'driveCategory',
() => localStorage.get('driveCategory') ?? Category.home,
(value): value is Category => array.includes(Object.values(Category), value)
)
const [newLabelNames, setNewLabelNames] = React.useState(new Set<backendModule.LabelName>())
const [deletedLabelNames, setDeletedLabelNames] = React.useState(
@ -145,6 +149,11 @@ export default function Drive(props: DriveProps) {
? DriveStatus.notEnabled
: DriveStatus.ok
const onSetCategory = eventCallback.useEventCallback((value: Category) => {
setCategory(value)
localStorage.set('driveCategory', value)
})
React.useEffect(() => {
const onProjectManagerLoadingFailed = () => {
setDidLoadingProjectManagerFail(true)
@ -379,7 +388,7 @@ export default function Drive(props: DriveProps) {
<div className="flex w-drive-sidebar flex-col gap-drive-sidebar py-drive-sidebar-y">
<CategorySwitcher
category={category}
setCategory={setCategory}
setCategory={onSetCategory}
dispatchAssetEvent={dispatchAssetEvent}
/>
<Labels

View File

@ -1,6 +1,8 @@
/** @file Settings screen. */
import * as React from 'react'
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
@ -14,13 +16,19 @@ import SettingsSidebar from '#/layouts/SettingsSidebar'
import * as backendModule from '#/services/Backend'
import * as array from '#/utilities/array'
// ================
// === Settings ===
// ================
/** Settings screen. */
export default function Settings() {
const [settingsTab, setSettingsTab] = React.useState(SettingsTab.account)
const [settingsTab, setSettingsTab] = searchParamsState.useSearchParamsState(
'SettingsTab',
SettingsTab.account,
(value): value is SettingsTab => array.includes(Object.values(SettingsTab), value)
)
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const [organization, setOrganization] = React.useState<backendModule.OrganizationInfo>(() => ({

View File

@ -2,7 +2,10 @@
* interactive components. */
import * as React from 'react'
import * as detect from 'enso-common/src/detect'
import * as eventHooks from '#/hooks/eventHooks'
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
@ -122,7 +125,14 @@ export default function Dashboard(props: DashboardProps) {
const inputBindings = inputBindingsProvider.useInputBindings()
const [initialized, setInitialized] = React.useState(false)
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
const [page, setPage] = React.useState(() => localStorage.get('page') ?? pageSwitcher.Page.drive)
// 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)
)
const [queuedAssetEvents, setQueuedAssetEvents] = React.useState<assetEvent.AssetEvent[]>([])
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
const [labels, setLabels] = React.useState<backendModule.Label[]>([])
@ -163,7 +173,7 @@ export default function Dashboard(props: DashboardProps) {
if (query.query !== '') {
setPage(pageSwitcher.Page.drive)
}
}, [query])
}, [query, setPage])
React.useEffect(() => {
let currentBackend = backend
@ -327,14 +337,24 @@ export default function Dashboard(props: DashboardProps) {
}
},
}),
[
inputBindings,
/* should never change */ modalRef,
/* should never change */ localStorage,
/* should never change */ updateModal,
]
[inputBindings, modalRef, localStorage, updateModal, setPage]
)
React.useEffect(() => {
if (detect.isOnElectron()) {
// We want to handle the back and forward buttons in electron the same way as in the browser.
// eslint-disable-next-line no-restricted-syntax
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
goBack: () => {
window.navigationApi.goBack()
},
goForward: () => {
window.navigationApi.goForward()
},
})
}
}, [inputBindings])
const setBackendType = React.useCallback(
(newBackendType: backendModule.BackendType) => {
if (newBackendType !== backend.type) {
@ -398,7 +418,7 @@ export default function Dashboard(props: DashboardProps) {
})
}
},
[backend, projectStartupInfo?.project.projectId, session.accessToken]
[backend, projectStartupInfo?.project.projectId, session.accessToken, setPage]
)
const doCloseEditor = React.useCallback((closingProject: backendModule.ProjectAsset) => {
@ -420,7 +440,7 @@ export default function Dashboard(props: DashboardProps) {
setPage(pageSwitcher.Page.drive)
}
setProjectStartupInfo(null)
}, [page])
}, [page, setPage])
return (
<>

View File

@ -27,7 +27,7 @@ export interface LocalStorageProviderProps extends Readonly<React.PropsWithChild
/** A React Provider that lets components get the shortcut registry. */
export default function LocalStorageProvider(props: LocalStorageProviderProps) {
const { children } = props
const [localStorage] = React.useState(() => new LocalStorage())
const localStorage = React.useMemo(() => new LocalStorage(), [])
return (
<LocalStorageContext.Provider value={{ localStorage }}>{children}</LocalStorageContext.Provider>

View File

@ -0,0 +1,25 @@
/**
* @file
*
* A utility function to safely parse a JSON string.
* returns the default value if the JSON string is invalid.
* Also provides a type for the parsed JSON.
*/
/**
* Safely parse a JSON string.
* Parse the JSON string and return the default value if the JSON string is invalid.
* Or if the parsed JSON does not match the type assertion.
*/
export function safeJsonParse<T = unknown>(
value: string,
defaultValue: T,
predicate: (parsed: unknown) => parsed is T
): T {
try {
const parsed: unknown = JSON.parse(value)
return predicate(parsed) ? parsed : defaultValue
} catch {
return defaultValue
}
}

View File

@ -55,6 +55,19 @@ interface AuthenticationApi {
readonly saveAccessToken: (accessToken: SaveAccessTokenPayload | null) => void
}
// ======================
// === Navigation API ===
// ======================
/** `window.navigationApi` is a context bridge to the main process, when we're running in an
* Electron context. It contains navigation-related functionality. */
interface NavigationApi {
/** Go back in the navigation history. */
readonly goBack: () => void
/** Go forward in the navigation history. */
readonly goForward: () => void
}
// =====================================
// === Global namespace augmentation ===
// =====================================
@ -67,6 +80,7 @@ declare global {
readonly enso?: AppRunner & Enso
readonly backendApi?: BackendApi
readonly authenticationApi: AuthenticationApi
readonly navigationApi: NavigationApi
}
namespace NodeJS {