diff --git a/app/ide-desktop/lib/dashboard/src/layouts/Drive.tsx b/app/ide-desktop/lib/dashboard/src/layouts/Drive.tsx
index 09736e75c4c..1b76be099ae 100644
--- a/app/ide-desktop/lib/dashboard/src/layouts/Drive.tsx
+++ b/app/ide-desktop/lib/dashboard/src/layouts/Drive.tsx
@@ -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
())
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) {
array.includes(Object.values(SettingsTab), value)
+ )
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const [organization, setOrganization] = React.useState(() => ({
diff --git a/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx b/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx
index f5f0956fb48..9db17cd334e 100644
--- a/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx
+++ b/app/ide-desktop/lib/dashboard/src/pages/dashboard/Dashboard.tsx
@@ -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([])
const [query, setQuery] = React.useState(() => AssetQuery.fromString(''))
const [labels, setLabels] = React.useState([])
@@ -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 (
<>
diff --git a/app/ide-desktop/lib/dashboard/src/providers/LocalStorageProvider.tsx b/app/ide-desktop/lib/dashboard/src/providers/LocalStorageProvider.tsx
index 358a779b1a9..463247f54b1 100644
--- a/app/ide-desktop/lib/dashboard/src/providers/LocalStorageProvider.tsx
+++ b/app/ide-desktop/lib/dashboard/src/providers/LocalStorageProvider.tsx
@@ -27,7 +27,7 @@ export interface LocalStorageProviderProps extends Readonly new LocalStorage())
+ const localStorage = React.useMemo(() => new LocalStorage(), [])
return (
{children}
diff --git a/app/ide-desktop/lib/dashboard/src/utilities/safeJsonParse.ts b/app/ide-desktop/lib/dashboard/src/utilities/safeJsonParse.ts
new file mode 100644
index 00000000000..a5ed069be26
--- /dev/null
+++ b/app/ide-desktop/lib/dashboard/src/utilities/safeJsonParse.ts
@@ -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(
+ 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
+ }
+}
diff --git a/app/ide-desktop/lib/types/globals.d.ts b/app/ide-desktop/lib/types/globals.d.ts
index 741ad763399..29a8326d13a 100644
--- a/app/ide-desktop/lib/types/globals.d.ts
+++ b/app/ide-desktop/lib/types/globals.d.ts
@@ -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 {