mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 19:21:54 +03:00
Implement back and forth navigation (#9301)
- Closes enso-org/cloud-v2#940
This commit is contained in:
parent
c983d081d9
commit
6c1ba64671
4
app/ide-desktop/lib/assets/arrow_left.svg
Normal file
4
app/ide-desktop/lib/assets/arrow_left.svg
Normal 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 |
@ -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 |
@ -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
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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) => {
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
@ -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, [])
|
||||
}
|
@ -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]
|
||||
}
|
17
app/ide-desktop/lib/dashboard/src/hooks/syncRefHooks.ts
Normal file
17
app/ide-desktop/lib/dashboard/src/hooks/syncRefHooks.ts
Normal 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
|
||||
}
|
29
app/ide-desktop/lib/dashboard/src/hooks/useLazyMemoHooks.ts
Normal file
29
app/ide-desktop/lib/dashboard/src/hooks/useLazyMemoHooks.ts
Normal 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)
|
||||
}
|
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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>(() => ({
|
||||
|
@ -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 (
|
||||
<>
|
||||
|
@ -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>
|
||||
|
25
app/ide-desktop/lib/dashboard/src/utilities/safeJsonParse.ts
Normal file
25
app/ide-desktop/lib/dashboard/src/utilities/safeJsonParse.ts
Normal 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
|
||||
}
|
||||
}
|
14
app/ide-desktop/lib/types/globals.d.ts
vendored
14
app/ide-desktop/lib/types/globals.d.ts
vendored
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user