+
diff --git a/app/gui/src/components/CommandPalette.vue b/app/gui/src/components/CommandPalette.vue
new file mode 100644
index 000000000000..aa76c64e3474
--- /dev/null
+++ b/app/gui/src/components/CommandPalette.vue
@@ -0,0 +1,293 @@
+
+
+
+
+
+
+
diff --git a/app/gui/src/dashboard/configurations/inputBindings.ts b/app/gui/src/dashboard/configurations/inputBindings.ts
index fc6b90846b7b..f970d2644ce1 100644
--- a/app/gui/src/dashboard/configurations/inputBindings.ts
+++ b/app/gui/src/dashboard/configurations/inputBindings.ts
@@ -1,77 +1,163 @@
/** @file Shortcuts for the dashboard application. */
+import { SETTINGS_TAB_DATA } from '#/layouts/Settings/data'
import * as inputBindings from '#/utilities/inputBindings'
import * as detect from 'enso-common/src/detect'
/** The type of the keybind and mousebind namespace for the dashboard. */
export type DashboardBindingNamespace = ReturnType
+/**
+ * The categories of dashboard bindings.
+ * These are used to group bindings in the UI.
+ */
+export type DashboardBindingCategory = (typeof CATEGORIES)[number]
+
/** The nameof a dashboard binding */
export type DashboardBindingKey = keyof typeof BINDINGS
/** Create a keybind and mousebind namespace. */
export function createBindings() {
- return inputBindings.defineBindingNamespace('dashboard', BINDINGS)
+ return inputBindings.defineBindingNamespace('dashboard', BINDINGS, CATEGORIES)
}
-export const BINDINGS = inputBindings.defineBindings({
- settings: { bindings: ['Mod+,'], icon: 'settings' },
- open: { bindings: ['Enter'], icon: 'open' },
- run: { bindings: ['Shift+Enter'], icon: 'workflow_play' },
- close: { bindings: [], icon: 'close' },
- uploadToCloud: { bindings: [], icon: 'cloud_to' },
- downloadToLocal: { bindings: [], icon: 'cloud_from' },
- exportArchive: { bindings: [], icon: 'data_download' },
- rename: { bindings: ['Mod+R'], icon: 'edit' },
- edit: { bindings: ['Mod+E'], icon: 'edit' },
- delete: { bindings: ['OsDelete'], icon: 'trash', color: 'rgb(243 24 10 / 0.87)' },
- undelete: { bindings: ['Mod+R'], icon: 'untrash' },
- share: { bindings: ['Mod+Enter'], icon: 'people' },
- label: { bindings: ['Mod+L'], icon: 'tag' },
- duplicate: { bindings: ['Mod+D'], icon: 'duplicate' },
- copy: { bindings: ['Mod+C'], icon: 'copy' },
- copyAsPath: { bindings: ['Mod+Shift+C'], icon: 'copy_as_path' },
- cut: { bindings: ['Mod+X'], icon: 'scissors' },
- paste: { bindings: ['Mod+V'], icon: 'paste' },
- download: { bindings: ['Mod+Shift+S'], icon: 'data_download' },
- uploadFiles: { bindings: ['Mod+U'], icon: 'data_upload' },
- newProject: { bindings: ['Mod+N'], icon: 'graph_add' },
- newFolder: { bindings: ['Mod+Shift+N'], icon: 'folder_add' },
- // FIXME [sb]: Platform detection should be handled directly in `shortcuts.ts`.
- newSecret: {
- bindings: !detect.isOnMacOS() ? ['Mod+Alt+N'] : ['Mod+Alt+N', 'Mod+Alt+~'],
- icon: 'key_add',
- },
- newCredential: { bindings: [], icon: 'credential_add' },
- newDatalink: {
- bindings: !detect.isOnMacOS() ? ['Mod+Alt+Shift+N'] : ['Mod+Alt+Shift+N', 'Mod+Alt+Shift+~'],
- icon: 'connector_add',
- },
- useInNewProject: { bindings: ['Mod+P'], icon: 'graph_add' },
- openInFileBrowser: { bindings: ['Mod+Shift+O'], icon: 'open_in_file_browser' },
- signOut: { bindings: [], icon: 'logout', color: 'rgb(243 24 10 / 0.87)' },
- // These should not appear in any menus.
- closeModal: { bindings: ['Escape'], rebindable: false },
- cancelEditName: { bindings: ['Escape'], rebindable: false },
- downloadApp: { bindings: [], icon: 'data_download', rebindable: false },
- cancelCut: { bindings: ['Escape'], rebindable: false },
- // TODO: support handlers for double click; make single click handlers not work on double click events
- // [MouseAction.open]: [mousebind(MouseAction.open, [], MouseButton.left, 2)],
- // [MouseAction.run]: [mousebind(MouseAction.run, ['Shift'], MouseButton.left, 2)],
- selectAdditional: { bindings: ['Mod+PointerMain'], rebindable: false },
- selectRange: { bindings: ['Shift+PointerMain'], rebindable: false },
- selectAdditionalRange: { bindings: ['Mod+Shift+PointerMain'], rebindable: false },
- goBack: {
- bindings: detect.isOnMacOS() ? ['Mod+ArrowLeft', 'Mod+['] : ['Alt+ArrowLeft'],
- rebindable: true,
- icon: 'arrow_left',
+const BINDINGS_AND_CATEGORIES = inputBindings.defineBindings(
+ [
+ 'help',
+ 'other',
+ 'fileManagement',
+ 'editing',
+ 'collaboration',
+ 'settings',
+ 'navigation',
+ 'developer',
+ ],
+ {
+ settings: { bindings: ['Mod+,'], icon: 'settings', category: 'navigation' },
+ open: { bindings: ['Enter'], icon: 'open', category: 'other' },
+ run: { bindings: ['Shift+Enter'], icon: 'workflow_play', category: 'other' },
+ close: { bindings: [], icon: 'close', category: 'other' },
+ uploadToCloud: { bindings: [], icon: 'cloud_to', category: 'fileManagement' },
+ downloadToLocal: { bindings: [], icon: 'cloud_from', category: 'fileManagement' },
+ exportArchive: { bindings: [], icon: 'data_download', category: 'fileManagement' },
+ rename: { bindings: ['Mod+R'], icon: 'edit', category: 'editing' },
+ edit: { bindings: ['Mod+E'], icon: 'edit', category: 'editing' },
+ delete: {
+ bindings: ['OsDelete'],
+ icon: 'trash',
+ color: 'rgb(243 24 10 / 0.87)',
+ category: 'fileManagement',
+ },
+ undelete: { bindings: ['Mod+R'], icon: 'untrash', category: 'fileManagement' },
+ share: { bindings: ['Mod+Enter'], icon: 'people', category: 'collaboration' },
+ label: { bindings: ['Mod+L'], icon: 'tag', category: 'collaboration' },
+ duplicate: { bindings: ['Mod+D'], icon: 'duplicate', category: 'fileManagement' },
+ copy: { bindings: ['Mod+C'], icon: 'copy', category: 'fileManagement' },
+ copyAsPath: { bindings: ['Mod+Shift+C'], icon: 'copy_as_path', category: 'fileManagement' },
+ cut: { bindings: ['Mod+X'], icon: 'scissors', category: 'fileManagement' },
+ paste: { bindings: ['Mod+V'], icon: 'paste', category: 'fileManagement' },
+ download: { bindings: ['Mod+Shift+S'], icon: 'data_download', category: 'fileManagement' },
+ uploadFiles: { bindings: ['Mod+U'], icon: 'data_upload', category: 'fileManagement' },
+ newProject: { bindings: ['Mod+N'], icon: 'graph_add', category: 'fileManagement' },
+ newFolder: { bindings: ['Mod+Shift+N'], icon: 'folder_add', category: 'fileManagement' },
+ // FIXME [sb]: Platform detection should be handled directly in `shortcuts.ts`.
+ newSecret: {
+ bindings: !detect.isOnMacOS() ? ['Mod+Alt+N'] : ['Mod+Alt+N', 'Mod+Alt+~'],
+ icon: 'key_add',
+ category: 'fileManagement',
+ },
+ newCredential: { bindings: [], icon: 'credential_add', category: 'fileManagement' },
+ newDatalink: {
+ bindings: !detect.isOnMacOS() ? ['Mod+Alt+Shift+N'] : ['Mod+Alt+Shift+N', 'Mod+Alt+Shift+~'],
+ icon: 'connector_add',
+ category: 'fileManagement',
+ },
+ useInNewProject: { bindings: ['Mod+P'], icon: 'graph_add', category: 'fileManagement' },
+ openInFileBrowser: {
+ bindings: ['Mod+Shift+O'],
+ icon: 'open_in_file_browser',
+ category: 'other',
+ },
+ signOut: { bindings: [], icon: 'logout', color: 'rgb(243 24 10 / 0.87)', category: 'other' },
+ // These should not appear in any menus.
+ closeModal: { bindings: ['Escape'], rebindable: false, category: 'other' },
+ cancelEditName: { bindings: ['Escape'], rebindable: false, category: 'editing' },
+ downloadApp: { bindings: [], icon: 'data_download', rebindable: false, category: 'other' },
+ cancelCut: { bindings: ['Escape'], rebindable: false, category: 'fileManagement' },
+ // TODO: support handlers for double click; make single click handlers not work on double click events
+ // [MouseAction.open]: [mousebind(MouseAction.open, [], MouseButton.left, 2)],
+ // [MouseAction.run]: [mousebind(MouseAction.run, ['Shift'], MouseButton.left, 2)],
+ selectAdditional: { bindings: ['Mod+PointerMain'], rebindable: false, category: 'other' },
+ selectRange: { bindings: ['Shift+PointerMain'], rebindable: false, category: 'other' },
+ selectAdditionalRange: {
+ bindings: ['Mod+Shift+PointerMain'],
+ rebindable: false,
+ category: 'other',
+ },
+ goBack: {
+ bindings: detect.isOnMacOS() ? ['Mod+ArrowLeft', 'Mod+['] : ['Alt+ArrowLeft'],
+ rebindable: true,
+ icon: 'arrow_left',
+ category: 'navigation',
+ },
+ goForward: {
+ bindings: detect.isOnMacOS() ? ['Mod+ArrowRight', 'Mod+]'] : ['Alt+ArrowRight'],
+ rebindable: true,
+ icon: 'arrow_right',
+ category: 'navigation',
+ },
+ upgradePlan: { bindings: [], icon: 'data_upload', category: 'other' },
+ aboutThisApp: { bindings: ['Mod+/'], icon: 'enso_logo', category: 'help' },
+ toggleEnsoDevtools: {
+ bindings: [],
+ rebindable: false,
+ icon: 'enso_logo',
+ category: 'developer',
+ },
+ goToAccountSettings: {
+ bindings: [],
+ icon: SETTINGS_TAB_DATA.account.icon,
+ category: 'settings',
+ },
+ goToOrganizationSettings: {
+ bindings: [],
+ icon: SETTINGS_TAB_DATA.organization.icon,
+ category: 'settings',
+ },
+ goToLocalSettings: { bindings: [], icon: SETTINGS_TAB_DATA.local.icon, category: 'settings' },
+ goToBillingAndPlansSettings: {
+ bindings: [],
+ icon: SETTINGS_TAB_DATA['billing-and-plans'].icon,
+ category: 'settings',
+ },
+ goToMembersSettings: {
+ bindings: [],
+ icon: SETTINGS_TAB_DATA.members.icon,
+ category: 'settings',
+ },
+ goToUserGroupsSettings: {
+ bindings: [],
+ icon: SETTINGS_TAB_DATA['user-groups'].icon,
+ category: 'settings',
+ },
+ goToKeyboardShortcutsSettings: {
+ bindings: [],
+ icon: SETTINGS_TAB_DATA['keyboard-shortcuts'].icon,
+ category: 'settings',
+ },
+ goToActivityLogSettings: {
+ bindings: [],
+ icon: SETTINGS_TAB_DATA['activity-log'].icon,
+ category: 'settings',
+ },
+ copyId: {
+ bindings: [],
+ rebindable: false,
+ icon: 'copy_as_path',
+ color: 'rgb(73 159 75)',
+ category: 'developer',
+ },
},
- goForward: {
- bindings: detect.isOnMacOS() ? ['Mod+ArrowRight', 'Mod+]'] : ['Alt+ArrowRight'],
- rebindable: true,
- icon: 'arrow_right',
- },
- upgradePlan: { bindings: [], rebindable: true, icon: 'data_upload' },
- aboutThisApp: { bindings: ['Mod+/'], rebindable: true, icon: 'enso_logo' },
- ensoDevtools: { bindings: [], rebindable: false, icon: 'enso_logo' },
- copyId: { bindings: [], rebindable: false, icon: 'copy_as_path', color: 'rgb(73 159 75)' },
-})
+)
+
+export const BINDINGS = BINDINGS_AND_CATEGORIES.bindings
+export const CATEGORIES = BINDINGS_AND_CATEGORIES.categories
diff --git a/app/gui/src/dashboard/hooks/menuHooks.ts b/app/gui/src/dashboard/hooks/menuHooks.ts
index 867528d39167..85e00ed8c45b 100644
--- a/app/gui/src/dashboard/hooks/menuHooks.ts
+++ b/app/gui/src/dashboard/hooks/menuHooks.ts
@@ -1,26 +1,79 @@
/** @file Hooks for menus. */
-import type { MenuEntryProps } from '#/components/MenuEntry'
+import { actionToTextId, type MenuEntryProps } from '#/components/MenuEntry'
import type { DashboardBindingKey } from '#/configurations/inputBindings'
import { useBindingFocusScope } from '#/providers/BindingFocusScopeProvider'
import { useInputBindings } from '#/providers/InputBindingsProvider'
import { DEFAULT_HANDLER } from '#/utilities/inputBindings'
-import { useEffect, useRef } from 'react'
+import { unsafeEntries } from '#/utilities/object'
+import type { Action } from '$/providers/actions'
+import { useActionsStore, useText } from '$/providers/react'
+import type { Icon } from '@/util/iconMetadata/iconName'
+import { useEffect, useRef, useState } from 'react'
+import { ref } from 'vue'
+
+/** Bind global actions given a list of handlers. */
+export function useBindGlobalActions(actions: Partial void>>) {
+ const inputBindings = useInputBindings()
+ const { bindGlobalActions } = useActionsStore()
+ const { getText } = useText()
+ const actionsRef = ref([])
+
+ useEffect(() => {
+ actionsRef.value = unsafeEntries(actions).flatMap(([action, doAction]) => {
+ if (!doAction) return []
+ const metadata = inputBindings.metadata[action]
+ return [
+ {
+ name: getText(actionToTextId(action)),
+ category: getText(`${metadata.category}BindingCategory`),
+ doAction,
+ shortcuts: metadata.bindings,
+ // eslint-disable-next-line no-restricted-syntax
+ icon: metadata.icon as Icon | undefined,
+ },
+ ]
+ })
+ }, [actions, actionsRef, bindGlobalActions, getText, inputBindings.metadata])
+
+ useEffect(() => bindGlobalActions(actionsRef), [actionsRef, bindGlobalActions])
+}
/** A hook to provide an input handler. */
export function useMenuEntries(entries: readonly (MenuEntryProps | false | null | undefined)[]) {
const inputBindings = useInputBindings()
const bindingFocusScope = useBindingFocusScope()
+ const { getText } = useText()
+ const { bindGlobalActions } = useActionsStore()
+
const entriesByActionRef = useRef>>({})
+ const [actionsRef] = useState(() => ref([]))
useEffect(() => {
for (const entry of entries) {
- if (entry == null || entry === false) {
- continue
- }
+ if (entry == null || entry === false) continue
entriesByActionRef.current[entry.action] = entry
}
})
+ useEffect(() => {
+ actionsRef.value = entries.flatMap((entry) => {
+ if (entry == null || entry === false || entry.isDisabled === true) return []
+ const metadata = inputBindings.metadata[entry.action]
+ return [
+ {
+ name: getText(actionToTextId(entry.action)),
+ category: getText(`${metadata.category}BindingCategory`),
+ doAction: entry.doAction,
+ shortcuts: metadata.bindings,
+ // eslint-disable-next-line no-restricted-syntax
+ icon: (entry.icon ?? metadata.icon) as Icon | undefined,
+ },
+ ]
+ })
+ }, [actionsRef, bindGlobalActions, entries, getText, inputBindings.metadata])
+
+ useEffect(() => bindGlobalActions(actionsRef), [actionsRef, bindGlobalActions])
+
useEffect(
() =>
inputBindings.attach(bindingFocusScope.current ?? document.body, 'keydown', {
diff --git a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx
index 74f6761f5444..cb0c44e6d076 100644
--- a/app/gui/src/dashboard/layouts/AssetContextMenu.tsx
+++ b/app/gui/src/dashboard/layouts/AssetContextMenu.tsx
@@ -30,8 +30,7 @@ import * as backendModule from '#/services/Backend'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
import { useMutationCallback } from '#/utilities/tanstackQuery'
-import * as authProvider from '$/providers/react'
-import { useBackends, useText } from '$/providers/react'
+import { useBackends, useFullUserSession, useRouter, useText } from '$/providers/react'
import * as featureFlagsProvider from '$/providers/react/featureFlags'
import type { RightPanelData } from '$/providers/rightPanel'
import {
@@ -67,11 +66,12 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
const isCloud = categoryModule.isCloudCategory(category)
+ const { router } = useRouter()
const { localCategories } = useCategories()
const getAsset = useGetAsset()
const canRunProjects = useCanRunProjects()
- const { user } = authProvider.useFullUserSession()
+ const { user } = useFullUserSession()
const { localBackend } = useBackends()
const { getText } = useText()
const openProjectNatively = projectHooks.useOpenProjectNatively()
@@ -154,11 +154,17 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
asset.projectState.openedBy != null &&
asset.projectState.openedBy !== user.email
+ const goToDrive = async () => {
+ if (router.currentRoute.value.path === '/drive') return
+ await router.push({ ...router.currentRoute.value, path: '/drive' })
+ }
+
const pasteMenuEntry = defineMenuEntry(
hasPasteData &&
canPaste && {
action: 'paste',
doAction: () => {
+ void goToDrive()
const directoryId =
asset.type === backendModule.AssetType.directory ? asset.id : asset.parentId
doPaste(directoryId, directoryId)
@@ -173,6 +179,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
color: 'accent',
action: 'copyId',
doAction: () => {
+ void goToDrive()
void copyMutation.mutateAsync(asset.id)
},
},
@@ -187,6 +194,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
action: 'undelete',
label: getText('restoreFromTrashShortcut'),
doAction: () => {
+ void goToDrive()
void restoreAssetsMutation({
ids: [asset.id],
parentId: null,
@@ -197,6 +205,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
action: 'delete',
label: getText('deleteForeverShortcut'),
doAction: () => {
+ void goToDrive()
setModal(
{
+ void goToDrive()
void newProject({ templateName: asset.title, ensoPath: asset.ensoPath }, asset.parentId)
},
},
@@ -228,6 +238,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
isDisabled: !canRunProjects.locally[backend.type],
tooltip: disabledTooltip,
doAction: () => {
+ void goToDrive()
void openProjectLocally(asset, backend.type)
},
},
@@ -238,6 +249,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
isDisabled: !canRunProjects.natively[backend.type],
tooltip: disabledTooltip,
doAction: () => {
+ void goToDrive()
void openProjectNatively(asset, backend.type)
},
},
@@ -247,6 +259,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
!isOtherUserUsingProject && {
action: 'close',
doAction: () => {
+ void goToDrive()
void closeProject({
id: asset.id,
title: asset.title,
@@ -258,6 +271,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
isCloud && {
action: 'label',
doAction: () => {
+ void goToDrive()
setModal()
},
},
@@ -268,6 +282,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
action: 'uploadToCloud',
feature: 'uploadToCloud',
doAction: () => {
+ void goToDrive()
void uploadFileToCloudMutation(localBackend, {
assets: [asset],
targetDirectoryId: user.rootDirectoryId,
@@ -279,11 +294,25 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
localBackend != null && {
action: 'downloadToLocal',
doAction: () => {
+ void goToDrive()
void uploadFileToLocal([asset])
},
},
- { action: 'copy', doAction: doCopy },
- !isRunningProject && !isOtherUserUsingProject && { action: 'cut', doAction: doCut },
+ {
+ action: 'copy',
+ doAction: () => {
+ void goToDrive()
+ doCopy()
+ },
+ },
+ !isRunningProject &&
+ !isOtherUserUsingProject && {
+ action: 'cut',
+ doAction: () => {
+ void goToDrive()
+ doCut()
+ },
+ },
pasteMenuEntry,
(isCloud ?
asset.type !== backendModule.AssetType.directory
@@ -291,6 +320,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
isDisabled: asset.type === backendModule.AssetType.secret,
action: 'download',
doAction: () => {
+ void goToDrive()
void downloadAssetsMutation({
ids: [{ id: asset.id, title: asset.title }],
targetDirectoryId:
@@ -304,6 +334,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
!isOtherUserUsingProject && {
action: 'rename',
doAction: () => {
+ void goToDrive()
setRowState(object.merger({ isEditingName: true }))
},
},
@@ -312,6 +343,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
canEditThisAsset && {
action: 'edit',
doAction: () => {
+ void goToDrive()
rightPanel.setTemporaryTab('settings')
rightPanel.updateContext('drive', (ctx) => {
ctx.category = category
@@ -329,12 +361,14 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
asset.type === backendModule.AssetType.project && {
action: 'duplicate',
doAction: () => {
+ void goToDrive()
void copyAssetsMutation([[asset.id], asset.parentId])
},
},
{
action: 'exportArchive',
doAction: () => {
+ void goToDrive()
void exportArchive()
},
},
@@ -345,6 +379,7 @@ export const AssetContextMenu = React.forwardRef(function AssetContextMenu(
action: 'delete',
label: isCloud ? getText('moveToTrashShortcut') : getText('deleteShortcut'),
doAction: () => {
+ void goToDrive()
const textId = isCloud ? 'trashTheAssetTypeTitle' : 'deleteTheAssetTypeTitle'
setModal(
{
+ void goToDrive()
systemApi.showItemInFolder(encodedEnsoPath)
},
},
encodedEnsoPath != null && {
action: 'copyAsPath',
doAction: () => {
+ void goToDrive()
void copyMutation.mutateAsync(encodedEnsoPath)
},
},
diff --git a/app/gui/src/dashboard/layouts/AssetsTable.tsx b/app/gui/src/dashboard/layouts/AssetsTable.tsx
index 0b4425b3db90..3d53f247b47e 100644
--- a/app/gui/src/dashboard/layouts/AssetsTable.tsx
+++ b/app/gui/src/dashboard/layouts/AssetsTable.tsx
@@ -74,7 +74,6 @@ import {
BackendType,
IS_OPENING_OR_OPENED,
isAssetCredential,
- isDirectoryId,
LabelName,
type AnyAsset,
} from '#/services/Backend'
@@ -225,27 +224,9 @@ function AssetsTable(props: AssetsTableProps) {
const updateSecretMutation = useMutationCallback(backendMutationOptions(backend, 'updateSecret'))
const paste = usePaste(category)
- const isSingleSelectedDirectoryItem = useStore(
- driveStore,
- (state) => {
- const selectedIds = state.selectedIds
-
- if (selectedIds.size !== 1) {
- return false
- }
-
- const firstId = Array.from(selectedIds).values().next().value
-
- if (firstId == null) {
- return false
- }
-
- const isDirectory = isDirectoryId(firstId)
-
- return isDirectory
- },
- { unsafeEnableTransition: true },
- )
+ const isSingleSelectedItem = useStore(driveStore, (state) => state.selectedIds.size === 1, {
+ unsafeEnableTransition: true,
+ })
const { data: users } = useQuery(backendQueryOptions(backend, 'listUsers', []))
const { data: userGroups } = useQuery(backendQueryOptions(backend, 'listUserGroups', []))
@@ -797,7 +778,7 @@ function AssetsTable(props: AssetsTableProps) {
})
const contextMenu =
- isSingleSelectedDirectoryItem ? null : (
+ isSingleSelectedItem ? null : (
{
+ if (router.currentRoute.value.path === '/drive') return
+ await router.push({ ...router.currentRoute.value, path: '/drive' })
+ }
+
const copyIdsMenuEntry = defineMenuEntry(
showDeveloperIds && {
action: 'copyId',
color: 'accent',
doAction: () => {
+ void goToDrive()
copyMutation.mutate(selectedAssets.map((asset) => asset.id).join('\n'))
},
},
@@ -171,6 +178,7 @@ export const AssetsTableContextMenu = React.forwardRef(function AssetsTableConte
hasPasteData && {
action: 'paste',
doAction: () => {
+ void goToDrive()
const selected = selectedAssets[0]
if (selected?.type === backendModule.AssetType.directory) {
doPaste(selected.id, selected.id)
@@ -192,6 +200,7 @@ export const AssetsTableContextMenu = React.forwardRef(function AssetsTableConte
action: 'undelete',
label: getText('restoreFromTrashShortcut'),
doAction: () => {
+ void goToDrive()
void restoreAssets({
ids: selectedAssets.map((asset) => asset.id),
parentId: null,
@@ -202,6 +211,7 @@ export const AssetsTableContextMenu = React.forwardRef(function AssetsTableConte
action: 'delete',
label: getText('deleteForeverShortcut'),
doAction: () => {
+ void goToDrive()
const asset = selectedAssets[0]
const soleAssetName = asset?.title ?? '(unknown)'
setModal(
@@ -229,6 +239,7 @@ export const AssetsTableContextMenu = React.forwardRef(function AssetsTableConte
action: 'uploadToCloud',
feature: 'uploadToCloud',
doAction: () => {
+ void goToDrive()
void uploadFilesToCloudCallback()
},
},
@@ -236,23 +247,34 @@ export const AssetsTableContextMenu = React.forwardRef(function AssetsTableConte
canDownloadAllProjectsToLocal && {
action: 'downloadToLocal',
doAction: () => {
+ void goToDrive()
void downloadFilesToLocalCallback()
},
},
selectedAssets.length !== 0 && {
action: 'exportArchive',
doAction: () => {
+ void goToDrive()
void exportArchive()
},
},
selectedAssets.length !== 0 && isCloud && { action: 'copy', doAction: doCopy },
- selectedAssets.length !== 0 && { action: 'cut', doAction: doCut },
+ selectedAssets.length !== 0 && {
+ action: 'cut',
+ doAction: () => {
+ void goToDrive()
+ doCut()
+ },
+ },
pasteAllMenuEntry,
...globalContextMenuEntries,
selectedAssets.length !== 0 && {
action: 'delete',
label: isCloud ? getText('moveToTrashShortcut') : getText('deleteShortcut'),
- doAction: doDeleteAll,
+ doAction: () => {
+ void goToDrive()
+ doDeleteAll()
+ },
},
copyIdsMenuEntry,
],
diff --git a/app/gui/src/dashboard/layouts/CategorySwitcher.tsx b/app/gui/src/dashboard/layouts/CategorySwitcher.tsx
index e1f7aa5885ae..f624439c4379 100644
--- a/app/gui/src/dashboard/layouts/CategorySwitcher.tsx
+++ b/app/gui/src/dashboard/layouts/CategorySwitcher.tsx
@@ -307,10 +307,8 @@ function CategorySwitcher(props: CategorySwitcherProps) {
className="my-auto opacity-0 transition-opacity group-hover:opacity-100"
onPress={() => {
void router.push({
- query: {
- [`${SEARCH_PARAMS_PREFIX}SettingsTab`]: JSON.stringify('local'),
- [`${SEARCH_PARAMS_PREFIX}page`]: JSON.stringify('settings'),
- },
+ path: '/settings',
+ query: { [`${SEARCH_PARAMS_PREFIX}SettingsTab`]: JSON.stringify('local') },
})
}}
/>
diff --git a/app/gui/src/dashboard/layouts/Settings/Sidebar.tsx b/app/gui/src/dashboard/layouts/Settings/Sidebar.tsx
index ac2ad7dbdd7c..6624fd073dc0 100644
--- a/app/gui/src/dashboard/layouts/Settings/Sidebar.tsx
+++ b/app/gui/src/dashboard/layouts/Settings/Sidebar.tsx
@@ -54,18 +54,11 @@ function SettingsSidebar(props: SettingsSidebarProps) {
icon={tabData.icon}
label={getText(tabData.nameId)}
isActive={tabData.settingsTab === tab}
- onPress={() =>
- tabData.onPress ?
- tabData.onPress(context)
- // even though this function returns void, we don't want to
- // complicate things by returning only in case of custom onPress
- // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
- : (() => {
- if (tab !== tabData.settingsTab) {
- setTab(tabData.settingsTab)
- }
- })()
- }
+ onPress={() => {
+ if (tab !== tabData.settingsTab) {
+ setTab(tabData.settingsTab)
+ }
+ }}
/>
))}
diff --git a/app/gui/src/dashboard/layouts/Settings/data.tsx b/app/gui/src/dashboard/layouts/Settings/data.tsx
index 5890f5a53a4d..7b6f630a4f28 100644
--- a/app/gui/src/dashboard/layouts/Settings/data.tsx
+++ b/app/gui/src/dashboard/layouts/Settings/data.tsx
@@ -1,11 +1,9 @@
/** @file Metadata for rendering each settings section. */
-import ComputerIcon from '#/assets/computer.svg'
import { Button } from '#/components/Button'
import type { TSchema } from '#/components/Form'
import type { ComboBoxProps } from '#/components/Inputs/ComboBox'
import { actionToTextId } from '#/components/MenuEntry'
import { Text } from '#/components/Text'
-import type { SvgUseIcon } from '#/components/types'
import { BINDINGS } from '#/configurations/inputBindings'
import type { PaywallFeatureName } from '#/hooks/billing'
import type { ToastAndLogCallback } from '#/hooks/toastAndLogHooks'
@@ -24,8 +22,10 @@ import {
import type LocalBackend from '#/services/LocalBackend'
import type RemoteBackend from '#/services/RemoteBackend'
import { pick, unsafeEntries } from '#/utilities/object'
+import { useMutationCallback } from '#/utilities/tanstackQuery'
import { PASSWORD_REGEX } from '#/utilities/validation'
import type { GetText } from '$/providers/text'
+import type { Icon } from '@/util/iconMetadata/iconName'
import { getLocalTimeZone, now } from '@internationalized/date'
import type { QueryClient } from '@tanstack/react-query'
import type { TextId } from 'enso-common/src/text'
@@ -321,7 +321,7 @@ export const SETTINGS_TAB_DATA: Readonly localBackend != null,
sections: [
{
@@ -430,26 +430,49 @@ export const SETTINGS_TAB_DATA: Readonly
user.isOrganizationAdmin && organization?.subscription != null,
- sections: [],
- onPress: (context) =>
- context.queryClient
- .getMutationCache()
- .build(context.queryClient, {
- mutationKey: ['billing', 'customerPortalSession'],
- mutationFn: () =>
- context.backend
- .createCustomerPortalSession()
- .then((url) => {
- if (url != null) {
- window.open(url, '_blank')?.focus()
- }
+ sections: [
+ {
+ nameId: 'billingAndPlansSettingsSection',
+ entries: [
+ {
+ type: 'custom',
+ aliasesId: 'billingAndPlansSettingsCustomEntryAliases',
+ render: (context) => {
+ // This is a React component, so we can use hooks.
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const openCustomerPortalSession = useMutationCallback({
+ mutationKey: ['billing', 'customerPortalSession'],
+ mutationFn: () =>
+ context.backend.createCustomerPortalSession().then(
+ (url) => {
+ if (url != null) {
+ window.open(url, '_blank')?.focus()
+ }
+ },
+ (error) => {
+ context.toastAndLog('arbitraryErrorTitle', error)
+ throw error
+ },
+ ),
})
- .catch((err) => {
- context.toastAndLog('arbitraryErrorTitle', err)
- throw err
- }),
- })
- .execute({} satisfies unknown),
+
+ return (
+
+
+
+ )
+ },
+ },
+ ],
+ },
+ ],
},
[SettingsTabType.members]: {
nameId: 'membersSettingsTab',
@@ -670,7 +693,7 @@ export interface SettingsSectionData {
export interface SettingsTabData {
readonly nameId: TextId & `${string}SettingsTab`
readonly settingsTab: SettingsTabType
- readonly icon: SvgUseIcon | (string & {})
+ readonly icon: Icon
readonly visible?: (context: SettingsContext) => boolean
readonly organizationOnly?: true
/**
@@ -679,7 +702,6 @@ export interface SettingsTabData {
*/
readonly feature?: PaywallFeatureName
readonly sections: readonly SettingsSectionData[]
- readonly onPress?: (context: SettingsContext) => Promise | void
}
/** Metadata describing a settings tab section. */
diff --git a/app/gui/src/dashboard/layouts/useGlobalContextMenuEntries.tsx b/app/gui/src/dashboard/layouts/useGlobalContextMenuEntries.tsx
index a72aa9c61691..a892e69cfd8c 100644
--- a/app/gui/src/dashboard/layouts/useGlobalContextMenuEntries.tsx
+++ b/app/gui/src/dashboard/layouts/useGlobalContextMenuEntries.tsx
@@ -13,6 +13,7 @@ import type Backend from '#/services/Backend'
import { BackendType, type DirectoryId } from '#/services/Backend'
import { useMutationCallback } from '#/utilities/tanstackQuery'
import { useStore } from '#/utilities/zustand'
+import { useRouter } from '$/providers/react'
import { readUserSelectedFile } from 'enso-common/src/utilities/file'
/** Props for a {@link GlobalContextMenuEntries}. */
@@ -30,6 +31,7 @@ export function useGlobalContextMenuEntries(options: GlobalContextMenuEntriesOpt
const isCloud = backend.type === BackendType.remote
+ const { router } = useRouter()
const driveStore = useDriveStore()
const hasPasteData = useStore(
driveStore,
@@ -50,28 +52,37 @@ export function useGlobalContextMenuEntries(options: GlobalContextMenuEntriesOpt
uploadFilesRaw(files, directoryId ?? currentDirectoryId),
)
+ const goToDrive = async () => {
+ if (router.currentRoute.value.path === '/drive') return
+ await router.push({ ...router.currentRoute.value, path: '/drive' })
+ }
+
return defineMenuEntries([
{
action: 'uploadFiles',
doAction: () => {
+ void goToDrive()
void readUserSelectedFile().then((files) => uploadFiles(Array.from(files)))
},
},
{
action: 'newProject',
doAction: () => {
+ void goToDrive()
void newProject()
},
},
{
action: 'newFolder',
doAction: () => {
+ void goToDrive()
void newFolder()
},
},
isCloud && {
action: 'newSecret',
doAction: () => {
+ void goToDrive()
setModal(
{
@@ -86,6 +97,7 @@ export function useGlobalContextMenuEntries(options: GlobalContextMenuEntriesOpt
isCloud && {
action: 'newCredential',
doAction: () => {
+ void goToDrive()
setModal(
@@ -100,6 +112,7 @@ export function useGlobalContextMenuEntries(options: GlobalContextMenuEntriesOpt
isCloud && {
action: 'newDatalink',
doAction: () => {
+ void goToDrive()
setModal(
{
@@ -120,6 +133,7 @@ export function useGlobalContextMenuEntries(options: GlobalContextMenuEntriesOpt
directoryId == null && {
action: 'paste',
doAction: () => {
+ void goToDrive()
doPaste(currentDirectoryId, currentDirectoryId)
},
},
diff --git a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx
index d3e7b972da66..3e9149a15635 100644
--- a/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx
+++ b/app/gui/src/dashboard/pages/dashboard/Dashboard.tsx
@@ -3,9 +3,12 @@
* interactive components.
*/
import Page from '#/components/Page'
+import { backendQueryOptions } from '#/hooks/backendHooks'
import { usePaywall } from '#/hooks/billing'
+import { useBindGlobalActions } from '#/hooks/menuHooks'
import * as projectHooks from '#/hooks/projectHooks'
import { CategoriesProvider } from '#/layouts/Drive/Categories'
+import SettingsTabType from '#/layouts/Settings/TabType'
import { setDriveLocation } from '#/providers/DriveProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as modalProvider from '#/providers/ModalProvider'
@@ -15,13 +18,15 @@ import { baseName } from '#/utilities/fileInfo'
import { STATIC_QUERY_OPTIONS } from '#/utilities/reactQuery'
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import { vueComponent } from '#/utilities/vue'
+import { SEARCH_PARAMS_PREFIX } from '$/appUtils'
import AppContainerInnerVue from '$/components/AppContainer/AppContainerInner.vue'
-import { useBackends, useConfig, useFullUserSession } from '$/providers/react'
+import { useBackends, useConfig, useFullUserSession, useRouter } from '$/providers/react'
import { useVueValue } from '$/providers/react/common'
import { useLaunchedProjects } from '$/providers/react/container'
-import { usePrefetchQuery } from '@tanstack/react-query'
+import { usePrefetchQuery, useQuery } from '@tanstack/react-query'
import * as detect from 'enso-common/src/detect'
import * as React from 'react'
+import type { Router } from 'vue-router'
/** Dashboard properties */
export interface DashboardProps {
@@ -53,14 +58,26 @@ function fileURLToPath(url: string): string | null {
}
}
+/** Navigate to a specific settings tab. */
+function goToSettingsTab(router: Router, tab: SettingsTabType) {
+ void router.push({
+ path: '/settings',
+ query: { [`${SEARCH_PARAMS_PREFIX}SettingsTab`]: JSON.stringify(tab) },
+ })
+}
+
/** The component that contains the entire UI. */
export function Dashboard(props: DashboardProps) {
- const { localBackend } = useBackends()
+ const { remoteBackend, localBackend } = useBackends()
const inputBindings = inputBindingsProvider.useInputBindings()
const config = useConfig()
+ const { router } = useRouter()
const initialProjectNameRaw = useVueValue(
React.useCallback(() => config.params.startup.project, [config]),
)
+ const { data: organization = null } = useQuery(
+ backendQueryOptions(remoteBackend, 'getOrganization', []),
+ )
const initialLocalProjectPath = fileURLToPath(initialProjectNameRaw)
const launchedProjects = useLaunchedProjects()
const openProjectLocally = projectHooks.useOpenProjectLocally()
@@ -71,6 +88,11 @@ export function Dashboard(props: DashboardProps) {
(lp) => lp.state === 'launched' && lp.hybrid?.cloudProjectId === props.projectToOpen?.asset.id,
)
+ const closeProject = projectHooks.useCloseProject()
+ const closeAllProjects = projectHooks.useCloseAllProjects()
+ const { user } = useFullUserSession()
+ const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
+
usePrefetchQuery({
queryKey: ['loadInitialProject'],
networkMode: 'always',
@@ -146,33 +168,68 @@ export function Dashboard(props: DashboardProps) {
}
}, [openProjectLocally])
- React.useEffect(() => {
- if (detect.isOnElectron()) {
+ const inputBindingHandlers = React.useMemo(() => {
+ const hasOrganization = backendModule.isUserOnPlanWithMultipleSeats(user)
+
+ return inputBindings.defineHandlers({
// We want to handle the back and forward buttons in electron the same way as in the browser.
- return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
+ ...(detect.isOnElectron() && {
goBack: () => {
window.navigationApi.goBack()
},
goForward: () => {
window.navigationApi.goForward()
},
- })
- }
- }, [inputBindings])
+ goToAccountSettings: () => {
+ goToSettingsTab(router, SettingsTabType.account)
+ },
+ ...(hasOrganization && {
+ goToOrganizationSettings: () => {
+ goToSettingsTab(router, SettingsTabType.organization)
+ },
+ }),
+ ...(localBackend && {
+ goToLocalSettings: () => {
+ goToSettingsTab(router, SettingsTabType.local)
+ },
+ }),
+ ...(user.isOrganizationAdmin &&
+ organization?.subscription != null && {
+ goToBillingAndPlansSettings: () => {
+ goToSettingsTab(router, SettingsTabType.billingAndPlans)
+ },
+ }),
+ ...(hasOrganization && {
+ goToMembersSettings: () => {
+ goToSettingsTab(router, SettingsTabType.members)
+ },
+ }),
+ ...(hasOrganization && {
+ goToUserGroupsSettings: () => {
+ goToSettingsTab(router, SettingsTabType.userGroups)
+ },
+ }),
+ goToKeyboardShortcutsSettings: () => {
+ goToSettingsTab(router, SettingsTabType.keyboardShortcuts)
+ },
+ ...(hasOrganization && {
+ goToActivityLogSettings: () => {
+ goToSettingsTab(router, SettingsTabType.activityLog)
+ },
+ }),
+ }),
+ closeModal: () => modalProvider.unsetModal(),
+ })
+ }, [inputBindings, localBackend, organization?.subscription, router, user])
+
+ useBindGlobalActions(inputBindingHandlers)
React.useEffect(
() =>
- inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
- closeModal: () => modalProvider.unsetModal(),
- }),
- [inputBindings],
+ inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', inputBindingHandlers),
+ [inputBindings, inputBindingHandlers],
)
- const closeProject = projectHooks.useCloseProject()
- const closeAllProjects = projectHooks.useCloseAllProjects()
- const { user } = useFullUserSession()
- const { isFeatureUnderPaywall } = usePaywall({ plan: user.plan })
-
return (
diff --git a/app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBarToolbar.tsx b/app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBarToolbar.tsx
index 4695d84cd641..58bb218fefe9 100644
--- a/app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBarToolbar.tsx
+++ b/app/gui/src/dashboard/pages/dashboard/Drive/DriveBar/DriveBarToolbar.tsx
@@ -33,12 +33,10 @@ import UpsertDatalinkModal from '#/modals/UpsertDatalinkModal'
import UpsertSecretModal from '#/modals/UpsertSecretModal'
import { useExportArchive } from '#/pages/useExportArchive'
import { useCanDownload, useDriveStore, usePasteData } from '#/providers/DriveProvider'
-import { useInputBindings } from '#/providers/InputBindingsProvider'
import { unsetModal } from '#/providers/ModalProvider'
import type Backend from '#/services/Backend'
import { BackendType, isDirectoryId, isProjectId, type CredentialConfig } from '#/services/Backend'
import type AssetQuery from '#/utilities/AssetQuery'
-import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
import { useMutationCallback } from '#/utilities/tanstackQuery'
import { useText } from '$/providers/react'
import { useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
@@ -62,7 +60,6 @@ export function DriveBarToolbar(props: DriveBarToolbarProps) {
const { category, associatedBackend: backend } = useCategoriesAPI()
const { getText } = useText()
const driveStore = useDriveStore()
- const inputBindings = useInputBindings()
const createAssetButtonsRef = React.useRef(null)
const isCloud = backend.type === BackendType.remote
const { isOffline } = useOffline()
@@ -107,26 +104,6 @@ export function DriveBarToolbar(props: DriveBarToolbarProps) {
mutationFn: async () => await newProjectRaw({}, currentDirectoryId),
})
- const attachEventListeners = useEventCallback(() =>
- inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', {
- ...(isCloud ?
- {
- newFolder: () => {
- void newFolder(currentDirectoryId)
- },
- }
- : {}),
- newProject: () => {
- void newProjectMutation()
- },
- uploadFiles: () => {
- void readUserSelectedFile().then((files) => uploadFiles(Array.from(files)))
- },
- }),
- )
-
- React.useEffect(() => attachEventListeners(), [attachEventListeners])
-
const newProject = useEventCallback(async () => {
await newProjectMutation()
})
diff --git a/app/gui/src/dashboard/pages/dashboard/UserBar/UserMenu.tsx b/app/gui/src/dashboard/pages/dashboard/UserBar/UserMenu.tsx
index bd56d6ef440a..09ba6d71250d 100644
--- a/app/gui/src/dashboard/pages/dashboard/UserBar/UserMenu.tsx
+++ b/app/gui/src/dashboard/pages/dashboard/UserBar/UserMenu.tsx
@@ -56,7 +56,7 @@ export function UserMenu(props: UserMenuProps) {
},
user.isEnsoTeamMember &&
IS_DEV_MODE && {
- action: 'ensoDevtools',
+ action: 'toggleEnsoDevtools',
doAction: () => {
toggleEnsoDevtools()
},
diff --git a/app/gui/src/dashboard/pages/dashboard/components/Label.tsx b/app/gui/src/dashboard/pages/dashboard/components/Label.tsx
index 8535a5d418c8..204f83c215f9 100644
--- a/app/gui/src/dashboard/pages/dashboard/components/Label.tsx
+++ b/app/gui/src/dashboard/pages/dashboard/components/Label.tsx
@@ -29,7 +29,6 @@ interface InternalLabelProps extends Readonly {
readonly label?: BackendLabel
readonly onPress?: (label?: BackendLabel) => void
readonly onDelete?: () => Promise | void
- readonly onContextMenu?: (event: MouseEvent) => void
readonly onDragStart?: (event: DragEvent) => void
}
@@ -39,7 +38,7 @@ export default forwardRef(function Label(
ref: ForwardedRef,
) {
const { active = false, isDisabled = false, color, draggable, title } = props
- const { onPress, onDragStart, onContextMenu, label, onDelete } = props
+ const { onPress, onDragStart, label, onDelete } = props
const { children: childrenRaw } = props
const isLight = color.lightness > MAXIMUM_LIGHTNESS_FOR_DARK_COLORS
@@ -73,7 +72,6 @@ export default forwardRef(function Label(
style={{ backgroundColor: lChColorToCssColor(color) }}
onClick={onClick}
onDragStart={onDragStartStableCallback}
- onContextMenu={onContextMenu}
>
{typeof childrenRaw !== 'string' ?
childrenRaw
diff --git a/app/gui/src/dashboard/pages/dashboard/components/column/components.tsx b/app/gui/src/dashboard/pages/dashboard/components/column/components.tsx
index 0f30bc8deab7..4158af5ea6cb 100644
--- a/app/gui/src/dashboard/pages/dashboard/components/column/components.tsx
+++ b/app/gui/src/dashboard/pages/dashboard/components/column/components.tsx
@@ -1,13 +1,11 @@
/** @file Components for column cells. */
import DotsIcon from '#/assets/dots.svg'
import { Button } from '#/components/Button'
-import { ContextMenu, type ContextMenuApi } from '#/components/ContextMenu'
import { Dialog, Popover } from '#/components/Dialog'
import { Text } from '#/components/Text'
import { backendMutationOptions } from '#/hooks/backendHooks'
import { useEventCallback } from '#/hooks/eventCallbackHooks'
import { useMeasureCallback } from '#/hooks/measureHooks'
-import { useMenuEntries } from '#/hooks/menuHooks'
import { useToastAndLog } from '#/hooks/toastAndLogHooks'
import ManageLabelsModal from '#/modals/ManageLabelsModal'
import type { AssetColumnProps, AssetNameColumnProps } from '#/pages/dashboard/components/column'
@@ -18,7 +16,6 @@ import ProjectNameColumn from '#/pages/dashboard/components/column/ProjectNameCo
import SecretNameColumn from '#/pages/dashboard/components/column/SecretNameColumn'
import Label from '#/pages/dashboard/components/Label'
import PermissionDisplay from '#/pages/dashboard/components/PermissionDisplay'
-import { BindingFocusScopeContext } from '#/providers/BindingFocusScopeProvider'
import { unsetModal } from '#/providers/ModalProvider'
import {
AssetType,
@@ -33,7 +30,7 @@ import { PermissionAction } from '#/utilities/permissions'
import { useMutationCallback } from '#/utilities/tanstackQuery'
import { useText } from '$/providers/react'
import { toReadableIsoString } from 'enso-common/src/utilities/data/dateTime'
-import { forwardRef, useRef, useState, type ForwardedRef } from 'react'
+import { useRef, useState } from 'react'
export { PathColumn } from './PathColumn'
/** A column listing the labels on this asset. */
@@ -153,54 +150,20 @@ function LabelInColumn(props: LabelInColumnProps) {
const { label, color = FALLBACK_COLOR, doDelete } = props
const { getText } = useText()
- const labelRef = useRef(null)
- const contextMenuRef = useRef(null)
return (
-
-
-
-
+
)
}
-/**
- * A context menu in a {@link LabelInColumn}. Necessary for `useMenuEntries` to pick up
- * the new `BindingFocusScope`.
- */
-const LabelInColumnContextMenu = forwardRef(function LabelInColumnContextMenu(
- props: Pick,
- ref: ForwardedRef,
-) {
- const { label, doDelete } = props
-
- const { getText } = useText()
-
- const entries = useMenuEntries([
- {
- action: 'delete',
- label: getText('removeLabelShortcut'),
- doAction: () => {
- doDelete(label)
- },
- },
- ])
-
- return
-})
-
/** A column displaying the time at which the asset was last modified. */
export function ModifiedColumn(props: AssetColumnProps) {
const { item } = props
diff --git a/app/gui/src/dashboard/providers/InputBindingsProvider.tsx b/app/gui/src/dashboard/providers/InputBindingsProvider.tsx
index 1ce3c0bebe61..bd51273f22d7 100644
--- a/app/gui/src/dashboard/providers/InputBindingsProvider.tsx
+++ b/app/gui/src/dashboard/providers/InputBindingsProvider.tsx
@@ -80,14 +80,7 @@ export default function InputBindingsProvider(props: InputBindingsProviderProps)
)
}
return {
- /** Transparently pass through `handler()`. */
- get handler() {
- return inputBindingsRaw.handler.bind(inputBindingsRaw)
- },
- /** Transparently pass through `attach()`. */
- get attach() {
- return inputBindingsRaw.attach.bind(inputBindingsRaw)
- },
+ ...inputBindingsRaw,
reset: (bindingKey: inputBindingsModule.DashboardBindingKey) => {
inputBindingsRaw.reset(bindingKey)
updateLocalStorage()
@@ -104,14 +97,6 @@ export default function InputBindingsProvider(props: InputBindingsProviderProps)
get metadata() {
return inputBindingsRaw.metadata
},
- /** Transparently pass through `register()`. */
- get register() {
- return inputBindingsRaw.unregister.bind(inputBindingsRaw)
- },
- /** Transparently pass through `unregister()`. */
- get unregister() {
- return inputBindingsRaw.unregister.bind(inputBindingsRaw)
- },
}
})
diff --git a/app/gui/src/dashboard/utilities/inputBindings.ts b/app/gui/src/dashboard/utilities/inputBindings.ts
index a15700cc3458..9c0586d75476 100644
--- a/app/gui/src/dashboard/utilities/inputBindings.ts
+++ b/app/gui/src/dashboard/utilities/inputBindings.ts
@@ -339,8 +339,9 @@ type AutocompleteKeybinds = {
}
/** A list of keybinds, with metadata describing its purpose. */
-export interface KeybindsWithMetadata {
+export interface KeybindsWithMetadata {
readonly bindings: readonly [] | readonly string[]
+ readonly category: Category
readonly description?: string
readonly icon?: string
readonly color?: string
@@ -355,8 +356,12 @@ export interface KeybindsWithMetadata {
* This type SHOULD NOT be explicitly written - it is only exported to suppress TypeScript
* errors.
*/
-export interface AutocompleteKeybindsWithMetadata {
+export interface AutocompleteKeybindsWithMetadata<
+ T extends KeybindsWithMetadata,
+ Category extends string,
+> {
readonly bindings: AutocompleteKeybinds
+ readonly category: Category
readonly description?: string
readonly icon?: SvgUseIcon
readonly color?: string
@@ -365,7 +370,7 @@ export interface AutocompleteKeybindsWithMetadata | readonly [] | readonly string[]
/**
* A helper type used to autocomplete and validate an object containing actions and their
@@ -373,12 +378,13 @@ type KeybindValue = KeybindsWithMetadata | readonly [] | readonly string[]
*/
// `never extends T ? Result : InferenceSource` is a trick to unify `T` with the actual type of the
// argument.
-type Keybinds> =
+type Keybinds, Category extends string> =
never extends T ?
{
[K in keyof T]: T[K] extends readonly string[] ? AutocompleteKeybinds
- : T[K] extends KeybindsWithMetadata ? AutocompleteKeybindsWithMetadata
- : ['error...', T]
+ : T[K] extends KeybindsWithMetadata ?
+ AutocompleteKeybindsWithMetadata
+ : KeybindsWithMetadata
}
: T
@@ -386,7 +392,7 @@ const DEFINED_NAMESPACES = new Map<
string,
// This is SAFE, as the value is only being stored for bookkeeping purposes.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- ReturnType>>
+ ReturnType, string>>
>()
export const DEFAULT_HANDLER = Symbol('default handler')
@@ -448,9 +454,13 @@ export const DEFAULT_HANDLER = Symbol('default handler')
* useEvent(window, 'keydown', graphBindingsHandler)
* ```
*/
-export function defineBindingNamespace>(
+export function defineBindingNamespace<
+ T extends Record,
+ Category extends string,
+>(
namespace: string,
- originalBindings: Keybinds,
+ originalBindings: Keybinds,
+ _categories: readonly Category[] | [],
) {
/** The name of a binding in this set of keybinds. */
type BindingKey = string & keyof T
@@ -467,7 +477,7 @@ export function defineBindingNamespace>(
bindings as Readonly>
// This non-null assertion is SAFE, as it is immediately assigned by `rebuildMetadata()`.
- let metadata!: Record
+ let metadata!: Record>
const rebuildMetadata = () => {
// This is SAFE, as this type is a direct mapping from `bindingsAsRecord`, which has `BindingKey`
// as its keys.
@@ -484,7 +494,7 @@ export function defineBindingNamespace>(
return [name, structuredClone(info)]
}
}),
- ) as Record
+ ) as Record>
}
const rebuildLookups = () => {
@@ -581,6 +591,19 @@ export function defineBindingNamespace>(
}
}
+ const defineHandlers = <
+ Handlers extends Partial<
+ // This MUST be `void` to allow implicit returns.
+ Record<
+ BindingKey | typeof DEFAULT_HANDLER,
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
+ (event: Event, matchingBindings: Set) => boolean | void
+ >
+ >,
+ >(
+ handlers: Handlers,
+ ) => handlers
+
const attach = <
EventName extends string,
Event extends
@@ -642,6 +665,7 @@ export function defineBindingNamespace>(
const result = {
/** Return an event handler that handles a native keyboard, mouse or pointer event. */
handler,
+ defineHandlers,
/**
* Attach an event listener to an {@link EventTarget} and return a function to detach the
* listener.
@@ -686,9 +710,15 @@ export function defineBindingNamespace>(
/**
* A function to define a bindings object that can be passed to {@link defineBindingNamespace}.
* Useful when wanting to create reusable keybind definitions, or non-global keybind definitions.
+ * @param categories - The categories of the bindings. Order will be preserved in the UI.
+ * @param bindings - The bindings to define.
+ * @returns An object containing the categories and bindings.
*/
-export function defineBindings>(bindings: Keybinds) {
- return bindings
+export function defineBindings, Category extends string>(
+ categories: readonly Category[] | [],
+ bindings: Keybinds,
+) {
+ return { categories, bindings }
}
/** A type predicate that narrows the potential child of the array. */
diff --git a/app/gui/src/project-view/bindings.ts b/app/gui/src/project-view/bindings.ts
index ce8d24279db3..72da43fb174e 100644
--- a/app/gui/src/project-view/bindings.ts
+++ b/app/gui/src/project-view/bindings.ts
@@ -91,6 +91,10 @@ export const gridBindings = defineKeybinds('grid', {
'grid.pasteCells': ['Mod+V'],
})
+export const commandPaletteBindings = defineKeybinds('command-palette', {
+ 'commandPalette.open': ['Mod+K'],
+})
+
// === Mouse bindings ===
export const textEditorsBindings = defineKeybinds('text-editors', {
diff --git a/app/gui/src/project-view/providers/action.ts b/app/gui/src/project-view/providers/action.ts
index ac67bd1ca932..bcafeeeeaa03 100644
--- a/app/gui/src/project-view/providers/action.ts
+++ b/app/gui/src/project-view/providers/action.ts
@@ -1,6 +1,7 @@
import {
appBindings,
appContainerBindings,
+ commandPaletteBindings,
componentBrowserBindings,
documentationEditorFormatBindings,
graphBindings,
@@ -271,6 +272,14 @@ const displayableActions = {
icon: 'fullscreen',
description: 'Fullscreen',
},
+
+ // === Command Palette ===
+
+ 'commandPalette.open': {
+ icon: 'code',
+ description: 'Open Command Palette',
+ shortcut: commandPaletteBindings.bindings['commandPalette.open'],
+ },
} satisfies Record
export type DisplayableActionName = keyof typeof displayableActions
const undisplayableActions = {
@@ -412,6 +421,7 @@ export function registerHandlers
-/** This list is non-exhaustive. It is intentionally */
+/** This list is non-exhaustive. It is intentionally limited to keys found on most keyboards. */
const allKeys = [
'Escape',
'Enter',
diff --git a/app/gui/src/providers/actions.ts b/app/gui/src/providers/actions.ts
new file mode 100644
index 000000000000..9455a8ee584a
--- /dev/null
+++ b/app/gui/src/providers/actions.ts
@@ -0,0 +1,65 @@
+import { Icon } from '@/util/iconMetadata/iconName'
+import { createGlobalState } from '@vueuse/core'
+import { go } from 'fuzzysort'
+import { MaybeRef, ref, Ref, toValue } from 'vue'
+
+export interface Action {
+ /** The name of the action. */
+ name: string
+ /** The category of the action. */
+ category: string
+ /** The function to execute when the action is triggered. */
+ doAction: () => void
+ shortcuts: readonly string[]
+ /** The icon associated with the action, if any. */
+ icon: Icon | undefined
+}
+
+export interface ActionWithHighlight extends Action {
+ /** Highlighted fields of the action. */
+ highlighted: {
+ /** The highlighted name of the action. */
+ name: string
+ }
+}
+
+/** A mapping from action names to {@link Action}s. */
+export type ActionsNamespace = Record
+
+/** The interface exposed by {@link createActionsStore}. */
+export interface ActionsStore extends ReturnType {}
+
+type ActionsNamespaceRef = Ref
+
+function createActionsStore() {
+ const actions = ref(new Set())
+
+ const bindGlobalActions = (newActionsNamespace: ActionsNamespaceRef) => {
+ actions.value.add(newActionsNamespace)
+
+ return () => {
+ actions.value.delete(newActionsNamespace)
+ }
+ }
+
+ const findActions = (query: MaybeRef): readonly ActionWithHighlight[] => {
+ const queryValue = toValue(query)
+ const matches = go(
+ queryValue,
+ [...actions.value].flatMap((ref) =>
+ Array.isArray(ref.value) ? ref.value : Object.values(ref.value),
+ ),
+ { keys: ['name', 'category'], all: true },
+ )
+ return matches.map((match) => ({
+ ...match.obj,
+ highlighted: {
+ name: match[0]?.highlight('', '') || match.obj.name,
+ },
+ }))
+ }
+
+ return { bindGlobalActions, findActions }
+}
+
+export const useActionsStore = createGlobalState(createActionsStore)
diff --git a/app/gui/src/providers/react/globalProvider.tsx b/app/gui/src/providers/react/globalProvider.tsx
index 57877dbb2998..c5162c422740 100644
--- a/app/gui/src/providers/react/globalProvider.tsx
+++ b/app/gui/src/providers/react/globalProvider.tsx
@@ -1,9 +1,11 @@
import LocalStorage from '#/utilities/LocalStorage'
+import { ActionsStore, useActionsStore } from '$/providers/actions'
import { AuthStore, useAuth } from '$/providers/auth'
import { BackendsStore, useBackends } from '$/providers/backends'
import { useHttpClient } from '$/providers/httpClient'
import { QueryParams, useQueryParams } from '$/providers/queryParams'
import {
+ ActionsContext,
ConfigContext,
HTTPClientContext,
LocalStorageContext,
@@ -33,6 +35,7 @@ interface ContextsForReactProviderProps {
session: SessionStore
auth: AuthStore
queryParams: QueryParams
+ actionsStore: ActionsStore
}
/**
@@ -54,6 +57,7 @@ export const ContextsForReactProvider = reactComponent(
session,
auth,
queryParams,
+ actionsStore,
} = props
return (
@@ -65,7 +69,9 @@ export const ContextsForReactProvider = reactComponent(
- {children}
+
+ {children}
+
@@ -94,6 +100,7 @@ export const ContextsForReactProvider = reactComponent(
session: useSession(),
auth: useAuth(),
queryParams: useQueryParams(),
+ actionsStore: useActionsStore(),
})
// Avoid annoying warning about __veauryInjectedProps__ property. Returning a function here
// avoids the code path that assigns that property to overwrite a computed value with constant.
diff --git a/app/gui/src/providers/react/index.ts b/app/gui/src/providers/react/index.ts
index 3c2816fccd86..8d21bc8f245a 100644
--- a/app/gui/src/providers/react/index.ts
+++ b/app/gui/src/providers/react/index.ts
@@ -1,4 +1,5 @@
import LocalStorage from '#/utilities/LocalStorage'
+import { ActionsStore } from '$/providers/actions'
import { SessionStore } from '$/providers/session'
import { TextStore } from '$/providers/text'
import { GuiConfig } from '@/providers/guiConfig'
@@ -25,3 +26,6 @@ export const useLocalStorage = useInReactFunction(LocalStorageContext)
export const SessionContext = react.createContext(null)
export const useSession = useInReactFunction(SessionContext)
+
+export const ActionsContext = react.createContext(null)
+export const useActionsStore = useInReactFunction(ActionsContext)
diff --git a/app/ide-desktop/client/src/index.ts b/app/ide-desktop/client/src/index.ts
index e34aabcf0ba7..f0d563e1e95a 100644
--- a/app/ide-desktop/client/src/index.ts
+++ b/app/ide-desktop/client/src/index.ts
@@ -618,11 +618,11 @@ class App {
// Handling navigation events from renderer process
electron.ipcMain.on(ipc.Channel.goBack, () => {
- this.window?.webContents.goBack()
+ this.window?.webContents.navigationHistory.goBack()
})
electron.ipcMain.on(ipc.Channel.goForward, () => {
- this.window?.webContents.goForward()
+ this.window?.webContents.navigationHistory.goForward()
})
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 301eaefc3255..131fc0c8f4c5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -287,6 +287,9 @@ importers:
events:
specifier: ^3.3.0
version: 3.3.0
+ fuzzysort:
+ specifier: 3.1.0
+ version: 3.1.0
hash-sum:
specifier: ^2.0.0
version: 2.0.0
@@ -5107,6 +5110,9 @@ packages:
functions-have-names@1.2.3:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
+ fuzzysort@3.1.0:
+ resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==}
+
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@@ -11301,11 +11307,11 @@ snapshots:
vite: 7.1.2(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0)
vue: 3.5.13(typescript@5.7.2)
- '@vitest/browser@3.2.4(playwright@1.50.1)(vite@7.1.2(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0))(vitest@3.2.4)':
+ '@vitest/browser@3.2.4(playwright@1.54.1)(vite@6.2.5(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0))(vitest@3.2.4)':
dependencies:
'@testing-library/dom': 10.4.0
'@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0)
- '@vitest/mocker': 3.2.4(vite@7.1.2(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0))
+ '@vitest/mocker': 3.2.4(vite@6.2.5(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0))
'@vitest/utils': 3.2.4
magic-string: 0.30.17
sirv: 3.0.1
@@ -11313,7 +11319,7 @@ snapshots:
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/browser@3.2.4)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.37.0)(yaml@2.7.0)
ws: 8.18.0
optionalDependencies:
- playwright: 1.50.1
+ playwright: 1.54.1
transitivePeerDependencies:
- bufferutil
- msw
@@ -11337,15 +11343,6 @@ snapshots:
optionalDependencies:
vite: 6.2.5(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0)
- '@vitest/mocker@3.2.4(vite@7.1.2(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0))':
- dependencies:
- '@vitest/spy': 3.2.4
- estree-walker: 3.0.3
- magic-string: 0.30.17
- optionalDependencies:
- vite: 7.1.2(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0)
- optional: true
-
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
@@ -13409,6 +13406,8 @@ snapshots:
functions-have-names@1.2.3: {}
+ fuzzysort@3.1.0: {}
+
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
@@ -16415,7 +16414,7 @@ snapshots:
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 24.2.1
- '@vitest/browser': 3.2.4(playwright@1.50.1)(vite@7.1.2(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0))(vitest@3.2.4)
+ '@vitest/browser': 3.2.4(playwright@1.54.1)(vite@6.2.5(@types/node@24.2.1)(jiti@1.21.7)(terser@5.37.0)(yaml@2.7.0))(vitest@3.2.4)
jsdom: 26.1.0
transitivePeerDependencies:
- jiti