mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 23:22:14 +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"
|
<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" />
|
<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)
|
const window = new electron.BrowserWindow(windowPreferences)
|
||||||
window.setMenuBarVisibility(false)
|
window.setMenuBarVisibility(false)
|
||||||
|
|
||||||
if (this.args.groups.debug.options.devTools.value) {
|
if (this.args.groups.debug.options.devTools.value) {
|
||||||
window.webContents.openDevTools()
|
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
|
/** 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',
|
saveAccessToken = 'save-access-token',
|
||||||
/** Channel for importing a project or project bundle from the given path. */
|
/** Channel for importing a project or project bundle from the given path. */
|
||||||
importProjectFromPath = 'import-project-from-path',
|
importProjectFromPath = 'import-project-from-path',
|
||||||
|
goBack = 'go-back',
|
||||||
|
goForward = 'go-forward',
|
||||||
/** Channel for selecting files and directories using the system file browser. */
|
/** Channel for selecting files and directories using the system file browser. */
|
||||||
openFileBrowser = 'open-file-browser',
|
openFileBrowser = 'open-file-browser',
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,8 @@ const AUTHENTICATION_API_KEY = 'authenticationApi'
|
|||||||
* window. */
|
* window. */
|
||||||
const FILE_BROWSER_API_KEY = 'fileBrowserApi'
|
const FILE_BROWSER_API_KEY = 'fileBrowserApi'
|
||||||
|
|
||||||
|
const NAVIGATION_API_KEY = 'navigationApi'
|
||||||
|
|
||||||
// =============================
|
// =============================
|
||||||
// === importProjectFromPath ===
|
// === importProjectFromPath ===
|
||||||
// =============================
|
// =============================
|
||||||
@ -37,6 +39,15 @@ const BACKEND_API = {
|
|||||||
}
|
}
|
||||||
electron.contextBridge.exposeInMainWorld(BACKEND_API_KEY, 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(
|
electron.ipcRenderer.on(
|
||||||
ipc.Channel.importProjectFromPath,
|
ipc.Channel.importProjectFromPath,
|
||||||
(_event, projectPath: string, projectId: string) => {
|
(_event, projectPath: string, projectId: string) => {
|
||||||
|
@ -140,7 +140,7 @@ export interface AppProps {
|
|||||||
export default function App(props: AppProps) {
|
export default function App(props: AppProps) {
|
||||||
// This is a React component even though it does not contain JSX.
|
// This is a React component even though it does not contain JSX.
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// 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`.
|
// Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`.
|
||||||
// Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider`
|
// 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.
|
// will redirect the user between the login/register pages and the dashboard.
|
||||||
@ -186,6 +186,7 @@ function AppRouter(props: AppProps) {
|
|||||||
window.navigate = navigate
|
window.navigate = navigate
|
||||||
}
|
}
|
||||||
const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings())
|
const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings())
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const savedInputBindings = localStorage.get('inputBindings')
|
const savedInputBindings = localStorage.get('inputBindings')
|
||||||
if (savedInputBindings != null) {
|
if (savedInputBindings != null) {
|
||||||
@ -203,6 +204,7 @@ function AppRouter(props: AppProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
|
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
|
||||||
|
|
||||||
const inputBindings = React.useMemo(() => {
|
const inputBindings = React.useMemo(() => {
|
||||||
const updateLocalStorage = () => {
|
const updateLocalStorage = () => {
|
||||||
localStorage.set(
|
localStorage.set(
|
||||||
@ -250,11 +252,14 @@ function AppRouter(props: AppProps) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
|
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])
|
||||||
|
|
||||||
const mainPageUrl = getMainPageUrl()
|
const mainPageUrl = getMainPageUrl()
|
||||||
|
|
||||||
const authService = React.useMemo(() => {
|
const authService = React.useMemo(() => {
|
||||||
const authConfig = { navigate, ...props }
|
const authConfig = { navigate, ...props }
|
||||||
return authServiceModule.initAuthService(authConfig)
|
return authServiceModule.initAuthService(authConfig)
|
||||||
}, [props, /* should never change */ navigate])
|
}, [props, /* should never change */ navigate])
|
||||||
|
|
||||||
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
|
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
|
||||||
const registerAuthEventListener = authService?.registerAuthEventListener ?? null
|
const registerAuthEventListener = authService?.registerAuthEventListener ?? null
|
||||||
const initialBackend: Backend = isAuthenticationDisabled
|
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.
|
: // This is safe, because the backend is always set by the authentication flow.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
null!
|
null!
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let isClick = false
|
let isClick = false
|
||||||
const onMouseDown = () => {
|
const onMouseDown = () => {
|
||||||
@ -283,6 +289,7 @@ function AppRouter(props: AppProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSelectStart = () => {
|
const onSelectStart = () => {
|
||||||
isClick = false
|
isClick = false
|
||||||
}
|
}
|
||||||
@ -295,6 +302,7 @@ function AppRouter(props: AppProps) {
|
|||||||
document.removeEventListener('selectstart', onSelectStart)
|
document.removeEventListener('selectstart', onSelectStart)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const routes = (
|
const routes = (
|
||||||
<router.Routes>
|
<router.Routes>
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
|
@ -4,6 +4,8 @@ import * as React from 'react'
|
|||||||
import CrossIcon from 'enso-assets/cross.svg'
|
import CrossIcon from 'enso-assets/cross.svg'
|
||||||
import TickIcon from 'enso-assets/tick.svg'
|
import TickIcon from 'enso-assets/tick.svg'
|
||||||
|
|
||||||
|
import * as eventCalback from '#/hooks/eventCallbackHooks'
|
||||||
|
|
||||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||||
|
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
@ -38,6 +40,10 @@ export default function EditableSpan(props: EditableSpanProps) {
|
|||||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const cancelled = React.useRef(false)
|
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(() => {
|
React.useEffect(() => {
|
||||||
setIsSubmittable(checkSubmittable?.(inputRef.current?.value ?? '') ?? true)
|
setIsSubmittable(checkSubmittable?.(inputRef.current?.value ?? '') ?? true)
|
||||||
// This effect MUST only run on mount.
|
// This effect MUST only run on mount.
|
||||||
@ -48,7 +54,7 @@ export default function EditableSpan(props: EditableSpanProps) {
|
|||||||
if (editable) {
|
if (editable) {
|
||||||
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
|
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
|
||||||
cancelEditName: () => {
|
cancelEditName: () => {
|
||||||
onCancel()
|
onCancelEventCallback()
|
||||||
cancelled.current = true
|
cancelled.current = true
|
||||||
inputRef.current?.blur()
|
inputRef.current?.blur()
|
||||||
},
|
},
|
||||||
@ -56,7 +62,7 @@ export default function EditableSpan(props: EditableSpanProps) {
|
|||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [editable, onCancel, /* should never change */ inputBindings])
|
}, [editable, /* should never change */ inputBindings, onCancelEventCallback])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
cancelled.current = false
|
cancelled.current = false
|
||||||
|
@ -35,20 +35,24 @@ export default function SvgMask(props: SvgMaskProps) {
|
|||||||
...(style ?? {}),
|
...(style ?? {}),
|
||||||
backgroundColor: color ?? 'currentcolor',
|
backgroundColor: color ?? 'currentcolor',
|
||||||
mask: urlSrc,
|
mask: urlSrc,
|
||||||
|
maskPosition: 'center',
|
||||||
|
maskRepeat: 'no-repeat',
|
||||||
|
maskSize: 'contain',
|
||||||
// The names come from a third-party API and cannot be changed.
|
// 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,
|
WebkitMask: urlSrc,
|
||||||
|
WebkitMaskPosition: 'center',
|
||||||
|
WebkitMaskRepeat: 'no-repeat',
|
||||||
|
WebkitMaskSize: 'contain',
|
||||||
|
/* eslint-enable @typescript-eslint/naming-convention */
|
||||||
}}
|
}}
|
||||||
className={`inline-block ${onClick != null ? 'cursor-pointer' : ''} ${
|
className={`inline-block ${onClick != null ? 'cursor-pointer' : ''} ${
|
||||||
className ?? 'h-max w-max'
|
className ?? 'h-max w-max'
|
||||||
}`}
|
}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onDragStart={event => {
|
|
||||||
event.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* This is required for this component to have the right size. */}
|
{/* 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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,8 @@ import AddFolderIcon from 'enso-assets/add_folder.svg'
|
|||||||
import AddKeyIcon from 'enso-assets/add_key.svg'
|
import AddKeyIcon from 'enso-assets/add_key.svg'
|
||||||
import AddNetworkIcon from 'enso-assets/add_network.svg'
|
import AddNetworkIcon from 'enso-assets/add_network.svg'
|
||||||
import AppDownloadIcon from 'enso-assets/app_download.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 CameraIcon from 'enso-assets/camera.svg'
|
||||||
import CloseIcon from 'enso-assets/close.svg'
|
import CloseIcon from 'enso-assets/close.svg'
|
||||||
import CloudToIcon from 'enso-assets/cloud_to.svg'
|
import CloudToIcon from 'enso-assets/cloud_to.svg'
|
||||||
@ -101,4 +103,16 @@ export const BINDINGS = inputBindings.defineBindings({
|
|||||||
bindings: ['Mod+Shift+PointerMain'],
|
bindings: ['Mod+Shift+PointerMain'],
|
||||||
rebindable: false,
|
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 RecentIcon from 'enso-assets/recent.svg'
|
||||||
import Trash2Icon from 'enso-assets/trash2.svg'
|
import Trash2Icon from 'enso-assets/trash2.svg'
|
||||||
|
|
||||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
|
||||||
import type * as assetEvent from '#/events/assetEvent'
|
import type * as assetEvent from '#/events/assetEvent'
|
||||||
@ -46,6 +45,7 @@ interface InternalCategorySwitcherItemProps {
|
|||||||
function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
|
function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
|
||||||
const { category, isCurrent, onClick } = props
|
const { category, isCurrent, onClick } = props
|
||||||
const { onDragOver, onDrop } = props
|
const { onDragOver, onDrop } = props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
disabled={isCurrent}
|
disabled={isCurrent}
|
||||||
@ -89,11 +89,6 @@ export interface CategorySwitcherProps {
|
|||||||
export default function CategorySwitcher(props: CategorySwitcherProps) {
|
export default function CategorySwitcher(props: CategorySwitcherProps) {
|
||||||
const { category, setCategory, dispatchAssetEvent } = props
|
const { category, setCategory, dispatchAssetEvent } = props
|
||||||
const { unsetModal } = modalProvider.useSetModal()
|
const { unsetModal } = modalProvider.useSetModal()
|
||||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
localStorage.set('driveCategory', category)
|
|
||||||
}, [category, /* should never change */ localStorage])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col gap-sidebar-section-heading">
|
<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 appUtils from '#/appUtils'
|
||||||
|
|
||||||
|
import * as eventCallback from '#/hooks/eventCallbackHooks'
|
||||||
import * as navigateHooks from '#/hooks/navigateHooks'
|
import * as navigateHooks from '#/hooks/navigateHooks'
|
||||||
|
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||||
|
|
||||||
import * as authProvider from '#/providers/AuthProvider'
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
@ -120,8 +122,10 @@ export default function Drive(props: DriveProps) {
|
|||||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||||
const [canDownload, setCanDownload] = React.useState(false)
|
const [canDownload, setCanDownload] = React.useState(false)
|
||||||
const [didLoadingProjectManagerFail, setDidLoadingProjectManagerFail] = React.useState(false)
|
const [didLoadingProjectManagerFail, setDidLoadingProjectManagerFail] = React.useState(false)
|
||||||
const [category, setCategory] = React.useState(
|
const [category, setCategory] = searchParamsState.useSearchParamsState(
|
||||||
() => localStorage.get('driveCategory') ?? Category.home
|
'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 [newLabelNames, setNewLabelNames] = React.useState(new Set<backendModule.LabelName>())
|
||||||
const [deletedLabelNames, setDeletedLabelNames] = React.useState(
|
const [deletedLabelNames, setDeletedLabelNames] = React.useState(
|
||||||
@ -145,6 +149,11 @@ export default function Drive(props: DriveProps) {
|
|||||||
? DriveStatus.notEnabled
|
? DriveStatus.notEnabled
|
||||||
: DriveStatus.ok
|
: DriveStatus.ok
|
||||||
|
|
||||||
|
const onSetCategory = eventCallback.useEventCallback((value: Category) => {
|
||||||
|
setCategory(value)
|
||||||
|
localStorage.set('driveCategory', value)
|
||||||
|
})
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const onProjectManagerLoadingFailed = () => {
|
const onProjectManagerLoadingFailed = () => {
|
||||||
setDidLoadingProjectManagerFail(true)
|
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">
|
<div className="flex w-drive-sidebar flex-col gap-drive-sidebar py-drive-sidebar-y">
|
||||||
<CategorySwitcher
|
<CategorySwitcher
|
||||||
category={category}
|
category={category}
|
||||||
setCategory={setCategory}
|
setCategory={onSetCategory}
|
||||||
dispatchAssetEvent={dispatchAssetEvent}
|
dispatchAssetEvent={dispatchAssetEvent}
|
||||||
/>
|
/>
|
||||||
<Labels
|
<Labels
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
/** @file Settings screen. */
|
/** @file Settings screen. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
|
||||||
|
|
||||||
import * as authProvider from '#/providers/AuthProvider'
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
|
|
||||||
@ -14,13 +16,19 @@ import SettingsSidebar from '#/layouts/SettingsSidebar'
|
|||||||
|
|
||||||
import * as backendModule from '#/services/Backend'
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
|
import * as array from '#/utilities/array'
|
||||||
|
|
||||||
// ================
|
// ================
|
||||||
// === Settings ===
|
// === Settings ===
|
||||||
// ================
|
// ================
|
||||||
|
|
||||||
/** Settings screen. */
|
/** Settings screen. */
|
||||||
export default function Settings() {
|
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 { type: sessionType, user } = authProvider.useNonPartialUserSession()
|
||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const [organization, setOrganization] = React.useState<backendModule.OrganizationInfo>(() => ({
|
const [organization, setOrganization] = React.useState<backendModule.OrganizationInfo>(() => ({
|
||||||
|
@ -2,7 +2,10 @@
|
|||||||
* interactive components. */
|
* interactive components. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import * as detect from 'enso-common/src/detect'
|
||||||
|
|
||||||
import * as eventHooks from '#/hooks/eventHooks'
|
import * as eventHooks from '#/hooks/eventHooks'
|
||||||
|
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
|
||||||
|
|
||||||
import * as authProvider from '#/providers/AuthProvider'
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
@ -122,7 +125,14 @@ export default function Dashboard(props: DashboardProps) {
|
|||||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||||
const [initialized, setInitialized] = React.useState(false)
|
const [initialized, setInitialized] = React.useState(false)
|
||||||
const [isHelpChatOpen, setIsHelpChatOpen] = 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 [queuedAssetEvents, setQueuedAssetEvents] = React.useState<assetEvent.AssetEvent[]>([])
|
||||||
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
|
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
|
||||||
const [labels, setLabels] = React.useState<backendModule.Label[]>([])
|
const [labels, setLabels] = React.useState<backendModule.Label[]>([])
|
||||||
@ -163,7 +173,7 @@ export default function Dashboard(props: DashboardProps) {
|
|||||||
if (query.query !== '') {
|
if (query.query !== '') {
|
||||||
setPage(pageSwitcher.Page.drive)
|
setPage(pageSwitcher.Page.drive)
|
||||||
}
|
}
|
||||||
}, [query])
|
}, [query, setPage])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let currentBackend = backend
|
let currentBackend = backend
|
||||||
@ -327,14 +337,24 @@ export default function Dashboard(props: DashboardProps) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[
|
[inputBindings, modalRef, localStorage, updateModal, setPage]
|
||||||
inputBindings,
|
|
||||||
/* should never change */ modalRef,
|
|
||||||
/* should never change */ localStorage,
|
|
||||||
/* should never change */ updateModal,
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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(
|
const setBackendType = React.useCallback(
|
||||||
(newBackendType: backendModule.BackendType) => {
|
(newBackendType: backendModule.BackendType) => {
|
||||||
if (newBackendType !== backend.type) {
|
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) => {
|
const doCloseEditor = React.useCallback((closingProject: backendModule.ProjectAsset) => {
|
||||||
@ -420,7 +440,7 @@ export default function Dashboard(props: DashboardProps) {
|
|||||||
setPage(pageSwitcher.Page.drive)
|
setPage(pageSwitcher.Page.drive)
|
||||||
}
|
}
|
||||||
setProjectStartupInfo(null)
|
setProjectStartupInfo(null)
|
||||||
}, [page])
|
}, [page, setPage])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -27,7 +27,7 @@ export interface LocalStorageProviderProps extends Readonly<React.PropsWithChild
|
|||||||
/** A React Provider that lets components get the shortcut registry. */
|
/** A React Provider that lets components get the shortcut registry. */
|
||||||
export default function LocalStorageProvider(props: LocalStorageProviderProps) {
|
export default function LocalStorageProvider(props: LocalStorageProviderProps) {
|
||||||
const { children } = props
|
const { children } = props
|
||||||
const [localStorage] = React.useState(() => new LocalStorage())
|
const localStorage = React.useMemo(() => new LocalStorage(), [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LocalStorageContext.Provider value={{ localStorage }}>{children}</LocalStorageContext.Provider>
|
<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
|
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 ===
|
// === Global namespace augmentation ===
|
||||||
// =====================================
|
// =====================================
|
||||||
@ -67,6 +80,7 @@ declare global {
|
|||||||
readonly enso?: AppRunner & Enso
|
readonly enso?: AppRunner & Enso
|
||||||
readonly backendApi?: BackendApi
|
readonly backendApi?: BackendApi
|
||||||
readonly authenticationApi: AuthenticationApi
|
readonly authenticationApi: AuthenticationApi
|
||||||
|
readonly navigationApi: NavigationApi
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace NodeJS {
|
namespace NodeJS {
|
||||||
|
Loading…
Reference in New Issue
Block a user