Skip to content

Commit

Permalink
Implement back and forth navigation (#9301)
Browse files Browse the repository at this point in the history
  • Loading branch information
MrFlashAccount authored Mar 22, 2024
1 parent c983d08 commit 6c1ba64
Show file tree
Hide file tree
Showing 20 changed files with 310 additions and 31 deletions.
4 changes: 4 additions & 0 deletions app/ide-desktop/lib/assets/arrow_left.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions app/ide-desktop/lib/assets/arrow_right.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions app/ide-desktop/lib/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions app/ide-desktop/lib/client/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
11 changes: 11 additions & 0 deletions app/ide-desktop/lib/client/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const AUTHENTICATION_API_KEY = 'authenticationApi'
* window. */
const FILE_BROWSER_API_KEY = 'fileBrowserApi'

const NAVIGATION_API_KEY = 'navigationApi'

// =============================
// === importProjectFromPath ===
// =============================
Expand All @@ -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) => {
Expand Down
10 changes: 9 additions & 1 deletion app/ide-desktop/lib/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -203,6 +204,7 @@ function AppRouter(props: AppProps) {
}
}
}, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw])

const inputBindings = React.useMemo(() => {
const updateLocalStorage = () => {
localStorage.set(
Expand Down Expand Up @@ -250,18 +252,22 @@ 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
? new LocalBackend(projectManagerUrl)
: // 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 = () => {
Expand All @@ -283,6 +289,7 @@ function AppRouter(props: AppProps) {
}
}
}

const onSelectStart = () => {
isClick = false
}
Expand All @@ -295,6 +302,7 @@ function AppRouter(props: AppProps) {
document.removeEventListener('selectstart', onSelectStart)
}
}, [])

const routes = (
<router.Routes>
<React.Fragment>
Expand Down
10 changes: 8 additions & 2 deletions app/ide-desktop/lib/dashboard/src/components/EditableSpan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand All @@ -48,15 +54,15 @@ export default function EditableSpan(props: EditableSpanProps) {
if (editable) {
return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
cancelEditName: () => {
onCancel()
onCancelEventCallback()
cancelled.current = true
inputRef.current?.blur()
},
})
} else {
return
}
}, [editable, onCancel, /* should never change */ inputBindings])
}, [editable, /* should never change */ inputBindings, onCancelEventCallback])

React.useEffect(() => {
cancelled.current = false
Expand Down
14 changes: 9 additions & 5 deletions app/ide-desktop/lib/dashboard/src/components/SvgMask.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
)
}
14 changes: 14 additions & 0 deletions app/ide-desktop/lib/dashboard/src/configurations/inputBindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
},
})
23 changes: 23 additions & 0 deletions app/ide-desktop/lib/dashboard/src/hooks/eventCallbackHooks.ts
Original file line number Diff line number Diff line change
@@ -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, [])
}
80 changes: 80 additions & 0 deletions app/ide-desktop/lib/dashboard/src/hooks/searchParamsStateHooks.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 17 additions & 0 deletions app/ide-desktop/lib/dashboard/src/hooks/syncRefHooks.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 29 additions & 0 deletions app/ide-desktop/lib/dashboard/src/hooks/useLazyMemoHooks.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 6c1ba64

Please sign in to comment.