diff --git a/app/ide-desktop/lib/assets/arrow_left.svg b/app/ide-desktop/lib/assets/arrow_left.svg new file mode 100644 index 00000000000..7c0b1e526cd --- /dev/null +++ b/app/ide-desktop/lib/assets/arrow_left.svg @@ -0,0 +1,4 @@ + + + diff --git a/app/ide-desktop/lib/assets/arrow_right.svg b/app/ide-desktop/lib/assets/arrow_right.svg index 51797037949..fbe6cc69229 100644 --- a/app/ide-desktop/lib/assets/arrow_right.svg +++ b/app/ide-desktop/lib/assets/arrow_right.svg @@ -1,4 +1,4 @@ + viewBox="0 0 24 24" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" > - \ No newline at end of file + diff --git a/app/ide-desktop/lib/client/src/index.ts b/app/ide-desktop/lib/client/src/index.ts index e96cf4df552..0fec71a8ee7 100644 --- a/app/ide-desktop/lib/client/src/index.ts +++ b/app/ide-desktop/lib/client/src/index.ts @@ -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 diff --git a/app/ide-desktop/lib/client/src/ipc.ts b/app/ide-desktop/lib/client/src/ipc.ts index 981f2715a3d..1fcd1584434 100644 --- a/app/ide-desktop/lib/client/src/ipc.ts +++ b/app/ide-desktop/lib/client/src/ipc.ts @@ -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', } diff --git a/app/ide-desktop/lib/client/src/preload.ts b/app/ide-desktop/lib/client/src/preload.ts index 64e2aa538e8..310e5225ae1 100644 --- a/app/ide-desktop/lib/client/src/preload.ts +++ b/app/ide-desktop/lib/client/src/preload.ts @@ -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) => { diff --git a/app/ide-desktop/lib/dashboard/src/App.tsx b/app/ide-desktop/lib/dashboard/src/App.tsx index 78be6654001..be4a3631826 100644 --- a/app/ide-desktop/lib/dashboard/src/App.tsx +++ b/app/ide-desktop/lib/dashboard/src/App.tsx @@ -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 = ( diff --git a/app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx b/app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx index 05d2df0e4df..33c6720f73d 100644 --- a/app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx @@ -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(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 diff --git a/app/ide-desktop/lib/dashboard/src/components/SvgMask.tsx b/app/ide-desktop/lib/dashboard/src/components/SvgMask.tsx index d732a7f601f..47994f30d87 100644 --- a/app/ide-desktop/lib/dashboard/src/components/SvgMask.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/SvgMask.tsx @@ -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. */} - {alt} + {alt} ) } diff --git a/app/ide-desktop/lib/dashboard/src/configurations/inputBindings.ts b/app/ide-desktop/lib/dashboard/src/configurations/inputBindings.ts index 156874ac053..f739f01f5a7 100644 --- a/app/ide-desktop/lib/dashboard/src/configurations/inputBindings.ts +++ b/app/ide-desktop/lib/dashboard/src/configurations/inputBindings.ts @@ -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, + }, }) diff --git a/app/ide-desktop/lib/dashboard/src/hooks/eventCallbackHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/eventCallbackHooks.ts new file mode 100644 index 00000000000..f7aa6bad679 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/eventCallbackHooks.ts @@ -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 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, []) +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/searchParamsStateHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/searchParamsStateHooks.ts new file mode 100644 index 00000000000..a0567cb9443 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/searchParamsStateHooks.ts @@ -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 = Readonly< + [value: T, setValue: (nextValue: React.SetStateAction) => 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( + key: string, + defaultValue: T | (() => T), + predicate: (unknown: unknown) => unknown is T = (unknown): unknown is T => true +): SearchParamsStateReturnType { + 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(() => { + 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) => { + if (nextValue instanceof Function) { + nextValue = nextValue(value) + } + + if (nextValue === lazyDefaultValueInitializer()) { + clear() + } else { + searchParams.set(key, JSON.stringify(nextValue)) + setSearchParams(searchParams) + } + }) + + return [value, setValue, clear] +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/syncRefHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/syncRefHooks.ts new file mode 100644 index 00000000000..f2d165b99aa --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/syncRefHooks.ts @@ -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(value: T): React.MutableRefObject { + const ref = React.useRef(value) + ref.current = value + + return ref +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/useLazyMemoHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/useLazyMemoHooks.ts new file mode 100644 index 00000000000..9ad5a4c36b1 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/useLazyMemoHooks.ts @@ -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(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) +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/CategorySwitcher.tsx b/app/ide-desktop/lib/dashboard/src/layouts/CategorySwitcher.tsx index 879f7b1742a..85eca72d798 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/CategorySwitcher.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/CategorySwitcher.tsx @@ -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 (