diff --git a/app/ide-desktop/lib/assets/cross.svg b/app/ide-desktop/lib/assets/cross.svg index 9c90f46ac77d..8e7072587d77 100644 --- a/app/ide-desktop/lib/assets/cross.svg +++ b/app/ide-desktop/lib/assets/cross.svg @@ -2,5 +2,5 @@ + fill="#3e515fe5" fill-opacity="0.66" /> \ No newline at end of file diff --git a/app/ide-desktop/lib/assets/keyboard_shortcuts.svg b/app/ide-desktop/lib/assets/keyboard_shortcuts.svg new file mode 100644 index 000000000000..c21ad89b5f9c --- /dev/null +++ b/app/ide-desktop/lib/assets/keyboard_shortcuts.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/ide-desktop/lib/assets/reload_in_circle.svg b/app/ide-desktop/lib/assets/reload_in_circle.svg new file mode 100644 index 000000000000..81d6ce3b3bab --- /dev/null +++ b/app/ide-desktop/lib/assets/reload_in_circle.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/app/ide-desktop/lib/assets/tick.svg b/app/ide-desktop/lib/assets/tick.svg index 6d0455cac8b4..f28f2c5e71d8 100644 --- a/app/ide-desktop/lib/assets/tick.svg +++ b/app/ide-desktop/lib/assets/tick.svg @@ -1,5 +1,5 @@ + fill="#3e515fe5" fill-opacity="0.66" /> \ No newline at end of file diff --git a/app/ide-desktop/lib/dashboard/.prettierrc.cjs b/app/ide-desktop/lib/dashboard/.prettierrc.cjs index a3c7a74a8aa0..07f5f9a4bdea 100644 --- a/app/ide-desktop/lib/dashboard/.prettierrc.cjs +++ b/app/ide-desktop/lib/dashboard/.prettierrc.cjs @@ -25,6 +25,8 @@ module.exports = { '^#[/]App', '^#[/]appUtils', '', + '^#[/]configurations[/]', + '', '^#[/]data[/]', '', '^#[/]hooks[/]', @@ -39,6 +41,8 @@ module.exports = { '', '^#[/]components[/]', '', + '^#[/]modals[/]', + '', '^#[/]services[/]', '', '^#[/]utilities[/]', diff --git a/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts b/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts index a5e2cd7ce1a7..e87140d7ddbc 100644 --- a/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts @@ -32,13 +32,7 @@ test.test('upload file', async ({ page }) => { const fileChooser = await fileChooserPromise const name = 'foo.txt' const content = 'hello world' - await fileChooser.setFiles([ - { - name, - buffer: Buffer.from(content), - mimeType: 'text/plain', - }, - ]) + await fileChooser.setFiles([{ name, buffer: Buffer.from(content), mimeType: 'text/plain' }]) await test.expect(assetRows).toHaveCount(1) await test.expect(assetRows.nth(0)).toBeVisible() diff --git a/app/ide-desktop/lib/dashboard/src/App.tsx b/app/ide-desktop/lib/dashboard/src/App.tsx index 139411eb1b54..8737fe0069b6 100644 --- a/app/ide-desktop/lib/dashboard/src/App.tsx +++ b/app/ide-desktop/lib/dashboard/src/App.tsx @@ -42,16 +42,18 @@ import * as detect from 'enso-common/src/detect' import * as appUtils from '#/appUtils' +import * as inputBindingsModule from '#/configurations/inputBindings' + import * as navigateHooks from '#/hooks/navigateHooks' import AuthProvider, * as authProvider from '#/providers/AuthProvider' import BackendProvider from '#/providers/BackendProvider' -import LocalStorageProvider from '#/providers/LocalStorageProvider' +import InputBindingsProvider from '#/providers/InputBindingsProvider' +import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider' import LoggerProvider from '#/providers/LoggerProvider' import type * as loggerProvider from '#/providers/LoggerProvider' import ModalProvider from '#/providers/ModalProvider' import SessionProvider from '#/providers/SessionProvider' -import ShortcutManagerProvider from '#/providers/ShortcutManagerProvider' import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration' import EnterOfflineMode from '#/pages/authentication/EnterOfflineMode' @@ -66,10 +68,39 @@ import Subscribe from '#/pages/subscribe/Subscribe' import type Backend from '#/services/Backend' import LocalBackend from '#/services/LocalBackend' -import ShortcutManager, * as shortcutManagerModule from '#/utilities/ShortcutManager' +import LocalStorage from '#/utilities/LocalStorage' import * as authServiceModule from '#/authentication/service' +// ============================ +// === Global configuration === +// ============================ + +declare module '#/utilities/LocalStorage' { + /** */ + interface LocalStorageData { + readonly inputBindings: Partial< + Readonly> + > + } +} + +LocalStorage.registerKey('inputBindings', { + tryParse: value => + typeof value !== 'object' || value == null + ? null + : Object.fromEntries( + // This is SAFE, as it is a readonly upcast. + // eslint-disable-next-line no-restricted-syntax + Object.entries(value as Readonly>).flatMap(kv => { + const [k, v] = kv + return Array.isArray(v) && v.every((item): item is string => typeof item === 'string') + ? [[k, v]] + : [] + }) + ), +}) + // ====================== // === getMainPageUrl === // ====================== @@ -113,8 +144,9 @@ 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 - /** 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. */ + // 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. return ( <> - + + + ) @@ -145,32 +179,67 @@ export default function App(props: AppProps) { function AppRouter(props: AppProps) { const { logger, supportsLocalBackend, isAuthenticationDisabled, shouldShowDashboard } = props const { onAuthenticated, projectManagerUrl } = props + const { localStorage } = localStorageProvider.useLocalStorage() const navigate = navigateHooks.useNavigate() if (detect.IS_DEV_MODE) { // @ts-expect-error This is used exclusively for debugging. window.navigate = navigate } - const [shortcutManager] = React.useState(() => ShortcutManager.createWithDefaults()) + const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings()) React.useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - const isTargetEditable = - event.target instanceof HTMLInputElement || - (event.target instanceof HTMLElement && event.target.isContentEditable) - const shouldHandleEvent = isTargetEditable - ? !shortcutManagerModule.isTextInputEvent(event) - : true - if (shouldHandleEvent && shortcutManager.handleKeyboardEvent(event)) { - event.preventDefault() - // This is required to prevent the event from propagating to the event handler - // that focuses the search input. - event.stopImmediatePropagation() + const savedInputBindings = localStorage.get('inputBindings') + for (const k in savedInputBindings) { + // This is UNSAFE, hence the `?? []` below. + // eslint-disable-next-line no-restricted-syntax + const bindingKey = k as inputBindingsModule.DashboardBindingKey + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + for (const oldBinding of inputBindingsRaw.metadata[bindingKey].bindings ?? []) { + inputBindingsRaw.delete(bindingKey, oldBinding) + } + for (const newBinding of savedInputBindings[bindingKey] ?? []) { + inputBindingsRaw.add(bindingKey, newBinding) } } - document.body.addEventListener('keydown', onKeyDown) - return () => { - document.body.removeEventListener('keydown', onKeyDown) + }, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw]) + const inputBindings = React.useMemo(() => { + const updateLocalStorage = () => { + localStorage.set( + 'inputBindings', + Object.fromEntries( + Object.entries(inputBindingsRaw.metadata).map(kv => { + const [k, v] = kv + return [k, v.bindings] + }) + ) + ) } - }, [shortcutManager]) + return { + /** Transparently pass through `handler()`. */ + get handler() { + return inputBindingsRaw.handler + }, + /** Transparently pass through `attach()`. */ + get attach() { + return inputBindingsRaw.attach + }, + reset: (bindingKey: inputBindingsModule.DashboardBindingKey) => { + inputBindingsRaw.reset(bindingKey) + updateLocalStorage() + }, + add: (bindingKey: inputBindingsModule.DashboardBindingKey, binding: string) => { + inputBindingsRaw.add(bindingKey, binding) + updateLocalStorage() + }, + delete: (bindingKey: inputBindingsModule.DashboardBindingKey, binding: string) => { + inputBindingsRaw.delete(bindingKey, binding) + updateLocalStorage() + }, + /** Transparently pass through `metadata`. */ + get metadata() { + return inputBindingsRaw.metadata + }, + } + }, [/* should never change */ localStorage, /* should never change */ inputBindingsRaw]) const mainPageUrl = getMainPageUrl() const authService = React.useMemo(() => { const authConfig = { navigate, ...props } @@ -242,9 +311,7 @@ function AppRouter(props: AppProps) { ) let result = routes - result = ( - {result} - ) + result = {result} result = {result} result = ( ) result = {result} - /** {@link BackendProvider} depends on {@link LocalStorageProvider}. */ - result = {result} result = ( (null) const cancelled = React.useRef(false) @@ -44,8 +44,8 @@ export default function EditableSpan(props: EditableSpanProps) { React.useEffect(() => { if (editable) { - return shortcutManager.registerKeyboardHandlers({ - [shortcutManagerModule.KeyboardAction.cancelEditName]: () => { + return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', { + cancelEditName: () => { onCancel() cancelled.current = true inputRef.current?.blur() @@ -54,7 +54,7 @@ export default function EditableSpan(props: EditableSpanProps) { } else { return } - }, [editable, shortcutManager, onCancel]) + }, [editable, onCancel, /* should never change */ inputBindings]) React.useEffect(() => { cancelled.current = false @@ -86,21 +86,12 @@ export default function EditableSpan(props: EditableSpanProps) { event.currentTarget.form?.requestSubmit() } }} + onContextMenu={event => { + event.stopPropagation() + }} onKeyDown={event => { - if ( - !event.isPropagationStopped() && - ((event.ctrlKey && - !event.shiftKey && - !event.altKey && - !event.metaKey && - /^[xcvzy]$/.test(event.key)) || - (event.ctrlKey && - event.shiftKey && - !event.altKey && - !event.metaKey && - /[Z]/.test(event.key))) - ) { - // This is an event that will be handled by the input. + if (event.key !== 'Escape') { + // The input may handle the event. event.stopPropagation() } }} diff --git a/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx b/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx index 6052d9399820..8ea009c07b38 100644 --- a/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx @@ -1,12 +1,16 @@ /** @file An entry in a menu. */ import * as React from 'react' -import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider' +import BlankIcon from 'enso-assets/blank.svg' -import KeyboardShortcut from '#/components/dashboard/keyboardShortcut' +import type * as inputBindings from '#/configurations/inputBindings' + +import * as inputBindingsProvider from '#/providers/InputBindingsProvider' + +import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut' import SvgMask from '#/components/SvgMask' -import * as shortcutManagerModule from '#/utilities/ShortcutManager' +import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' // ================= // === MenuEntry === @@ -15,7 +19,9 @@ import * as shortcutManagerModule from '#/utilities/ShortcutManager' /** Props for a {@link MenuEntry}. */ export interface MenuEntryProps { readonly hidden?: boolean - readonly action: shortcutManagerModule.KeyboardAction + readonly action: inputBindings.DashboardBindingKey + /** Overrides the text for the menu entry. */ + readonly label?: string /** When true, the button is not clickable. */ readonly disabled?: boolean readonly title?: string @@ -25,19 +31,22 @@ export interface MenuEntryProps { /** An item in a menu. */ export default function MenuEntry(props: MenuEntryProps) { - const { hidden = false, action, disabled = false, title, paddingClassName, doAction } = props - const { shortcutManager } = shortcutManagerProvider.useShortcutManager() - const info = shortcutManager.keyboardShortcutInfo[action] + const { hidden = false, action, label, disabled = false, title, paddingClassName } = props + const { doAction } = props + const inputBindings = inputBindingsProvider.useInputBindings() + const info = inputBindings.metadata[action] React.useEffect(() => { - // This is slower than registering every shortcut in the context menu at once. - if (!disabled) { - return shortcutManager.registerKeyboardHandlers({ + // This is slower (but more convenient) than registering every shortcut in the context menu + // at once. + if (disabled) { + return + } else { + return inputBindings.attach(sanitizedEventTargets.document.body, 'keydown', { [action]: doAction, }) - } else { - return } - }, [disabled, shortcutManager, action, doAction]) + }, [disabled, inputBindings, action, doAction]) + return hidden ? null : ( diff --git a/app/ide-desktop/lib/dashboard/src/components/SvgMask.tsx b/app/ide-desktop/lib/dashboard/src/components/SvgMask.tsx index ac1900345a28..5e418082bc93 100644 --- a/app/ide-desktop/lib/dashboard/src/components/SvgMask.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/SvgMask.tsx @@ -12,6 +12,9 @@ export interface SvgMaskProps { readonly src: string readonly title?: string readonly style?: React.CSSProperties + // Allowing `undefined` is fine here as this prop has a fallback. + // eslint-disable-next-line no-restricted-syntax + readonly color?: string | undefined // Allowing `undefined` is fine here as this prop is being transparently passed through to the // underlying `div`. // eslint-disable-next-line no-restricted-syntax @@ -21,7 +24,7 @@ export interface SvgMaskProps { /** Use an SVG as a mask. This lets the SVG use the text color (`currentColor`). */ export default function SvgMask(props: SvgMaskProps) { - const { alt, src, title, style, className, onClick } = props + const { alt, src, title, style, color, className, onClick } = props const urlSrc = `url(${JSON.stringify(src)})` return ( @@ -29,7 +32,7 @@ export default function SvgMask(props: SvgMaskProps) { title={title} style={{ ...(style ?? {}), - backgroundColor: 'currentcolor', + backgroundColor: color ?? 'currentcolor', mask: urlSrc, // The names come from a third-party API and cannot be changed. // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx index d09c3fffefad..61afc1b7fd36 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx @@ -14,8 +14,8 @@ import * as modalProvider from '#/providers/ModalProvider' import AssetEventType from '#/events/AssetEventType' import AssetListEventType from '#/events/AssetListEventType' -import AssetContextMenu from '#/layouts/dashboard/AssetContextMenu' -import type * as assetsTable from '#/layouts/dashboard/AssetsTable' +import AssetContextMenu from '#/layouts/AssetContextMenu' +import type * as assetsTable from '#/layouts/AssetsTable' import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils' import * as columnModule from '#/components/dashboard/column' @@ -403,10 +403,7 @@ export default function AssetRow(props: AssetRowProps) { resourceId: asset.id, userSubjects: [userInfo.id], }) - dispatchAssetListEvent({ - type: AssetListEventType.delete, - key: item.key, - }) + dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) } catch (error) { setInsertionVisibility(Visibility.visible) toastAndLog(null, error) diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow/assetRowUtils.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow/assetRowUtils.tsx index 568f86155bbd..32b3d751e196 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow/assetRowUtils.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow/assetRowUtils.tsx @@ -1,5 +1,5 @@ /** @file Utilities related to `AssetRow`s. */ -import type * as assetsTable from '#/layouts/dashboard/AssetsTable' +import type * as assetsTable from '#/layouts/AssetsTable' import * as set from '#/utilities/set' diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/DataLinkNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/DataLinkNameColumn.tsx index cba02c1d9a8e..437142c4e8e8 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/DataLinkNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/DataLinkNameColumn.tsx @@ -8,7 +8,7 @@ import * as setAssetHooks from '#/hooks/setAssetHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as backendProvider from '#/providers/BackendProvider' -import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider' +import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import AssetEventType from '#/events/AssetEventType' import AssetListEventType from '#/events/AssetListEventType' @@ -21,7 +21,6 @@ import * as backendModule from '#/services/Backend' import * as eventModule from '#/utilities/event' import * as indent from '#/utilities/indent' import * as object from '#/utilities/object' -import * as shortcutManagerModule from '#/utilities/ShortcutManager' import Visibility from '#/utilities/visibility' // ===================== @@ -39,7 +38,7 @@ export default function DataLinkNameColumn(props: DataLinkNameColumnProps) { const { assetEvents, dispatchAssetListEvent, setIsAssetPanelTemporarilyVisible } = state const toastAndLog = toastAndLogHooks.useToastAndLog() const { backend } = backendProvider.useBackend() - const { shortcutManager } = shortcutManagerProvider.useShortcutManager() + const inputBindings = inputBindingsProvider.useInputBindings() const asset = item.item if (asset.type !== backendModule.AssetType.dataLink) { // eslint-disable-next-line no-restricted-syntax @@ -111,6 +110,12 @@ export default function DataLinkNameColumn(props: DataLinkNameColumnProps) { } }) + const handleClick = inputBindings.handler({ + editName: () => { + setRowState(object.merger({ isEditingName: true })) + }, + }) + return (
{ - if ( - eventModule.isSingleClick(event) && - (selected || - shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event)) - ) { + if (handleClick(event)) { + // Already handled. + } else if (eventModule.isSingleClick(event) && selected) { setRowState(object.merger({ isEditingName: true })) } else if (eventModule.isDoubleClick(event)) { event.stopPropagation() diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/DirectoryNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/DirectoryNameColumn.tsx index cc63ca8ff181..750345f26150 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/DirectoryNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/DirectoryNameColumn.tsx @@ -9,7 +9,7 @@ import * as setAssetHooks from '#/hooks/setAssetHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as backendProvider from '#/providers/BackendProvider' -import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider' +import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import AssetEventType from '#/events/AssetEventType' import AssetListEventType from '#/events/AssetListEventType' @@ -23,7 +23,6 @@ import * as backendModule from '#/services/Backend' import * as eventModule from '#/utilities/event' import * as indent from '#/utilities/indent' import * as object from '#/utilities/object' -import * as shortcutManagerModule from '#/utilities/ShortcutManager' import * as string from '#/utilities/string' import Visibility from '#/utilities/visibility' @@ -43,7 +42,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { const { doToggleDirectoryExpansion } = state const toastAndLog = toastAndLogHooks.useToastAndLog() const { backend } = backendProvider.useBackend() - const { shortcutManager } = shortcutManagerProvider.useShortcutManager() + const inputBindings = inputBindingsProvider.useInputBindings() const asset = item.item if (asset.type !== backendModule.AssetType.directory) { // eslint-disable-next-line no-restricted-syntax @@ -123,6 +122,12 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { } }) + const handleClick = inputBindings.handler({ + editName: () => { + setRowState(object.merger({ isEditingName: true })) + }, + }) + return (
{ - if ( + if (handleClick(event)) { + // Already handled. + } else if ( eventModule.isSingleClick(event) && - ((selected && selectedKeys.current.size === 1) || - shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event)) + selected && + selectedKeys.current.size === 1 ) { event.stopPropagation() setRowState(object.merger({ isEditingName: true })) diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/FileNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/FileNameColumn.tsx index 0198e946df86..b223fb913f0f 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/FileNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/FileNameColumn.tsx @@ -6,7 +6,7 @@ import * as setAssetHooks from '#/hooks/setAssetHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as backendProvider from '#/providers/BackendProvider' -import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider' +import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import AssetEventType from '#/events/AssetEventType' import AssetListEventType from '#/events/AssetListEventType' @@ -21,7 +21,6 @@ import * as eventModule from '#/utilities/event' import * as fileIcon from '#/utilities/fileIcon' import * as indent from '#/utilities/indent' import * as object from '#/utilities/object' -import * as shortcutManagerModule from '#/utilities/ShortcutManager' import Visibility from '#/utilities/visibility' // ================ @@ -39,7 +38,7 @@ export default function FileNameColumn(props: FileNameColumnProps) { const { nodeMap, assetEvents, dispatchAssetListEvent } = state const toastAndLog = toastAndLogHooks.useToastAndLog() const { backend } = backendProvider.useBackend() - const { shortcutManager } = shortcutManagerProvider.useShortcutManager() + const inputBindings = inputBindingsProvider.useInputBindings() const asset = item.item if (asset.type !== backendModule.AssetType.file) { // eslint-disable-next-line no-restricted-syntax @@ -113,6 +112,12 @@ export default function FileNameColumn(props: FileNameColumnProps) { } }) + const handleClick = inputBindings.handler({ + editName: () => { + setRowState(object.merger({ isEditingName: true })) + }, + }) + return (
{ - if ( - eventModule.isSingleClick(event) && - (selected || - shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event)) - ) { + if (handleClick(event)) { + // Already handled. + } else if (eventModule.isSingleClick(event) && selected) { setRowState(object.merger({ isEditingName: true })) } }} diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/keyboardShortcut.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/KeyboardShortcut.tsx similarity index 62% rename from app/ide-desktop/lib/dashboard/src/components/dashboard/keyboardShortcut.tsx rename to app/ide-desktop/lib/dashboard/src/components/dashboard/KeyboardShortcut.tsx index 1bffe6f43972..e853b31cdd3d 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/keyboardShortcut.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/KeyboardShortcut.tsx @@ -8,11 +8,13 @@ import ShiftKeyIcon from 'enso-assets/shift_key.svg' import WindowsKeyIcon from 'enso-assets/windows_key.svg' import * as detect from 'enso-common/src/detect' -import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider' +import type * as dashboardInputBindings from '#/configurations/inputBindings' + +import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import SvgMask from '#/components/SvgMask' -import * as shortcutManagerModule from '#/utilities/ShortcutManager' +import * as inputBindingsModule from '#/utilities/inputBindings' // ======================== // === KeyboardShortcut === @@ -25,7 +27,7 @@ const ICON_STYLE = { width: ICON_SIZE_PX, height: ICON_SIZE_PX } /** Icons for modifier keys (if they exist). */ const MODIFIER_MAPPINGS: Readonly< - Record>> + Record>> > = { // The names are intentionally not in `camelCase`, as they are case-sensitive. /* eslint-disable @typescript-eslint/naming-convention */ @@ -57,22 +59,34 @@ const MODIFIER_MAPPINGS: Readonly< /* eslint-enable @typescript-eslint/naming-convention */ } -/** Props for a {@link KeyboardShortcut} */ -export interface KeyboardShortcutProps { - readonly action: shortcutManagerModule.KeyboardAction +/** Props for a {@link KeyboardShortcut}, specifying the keyboard action. */ +export interface KeyboardShortcutActionProps { + readonly action: dashboardInputBindings.DashboardBindingKey +} + +/** Props for a {@link KeyboardShortcut}, specifying the shortcut string. */ +export interface KeyboardShortcutShortcutProps { + readonly shortcut: string } +/** Props for a {@link KeyboardShortcut}. */ +export type KeyboardShortcutProps = KeyboardShortcutActionProps | KeyboardShortcutShortcutProps + /** A visual representation of a keyboard shortcut. */ export default function KeyboardShortcut(props: KeyboardShortcutProps) { - const { action } = props - const { shortcutManager } = shortcutManagerProvider.useShortcutManager() - const shortcut = shortcutManager.keyboardShortcuts[action][0] - if (shortcut == null) { + const inputBindings = inputBindingsProvider.useInputBindings() + const shortcutString = + 'shortcut' in props ? props.shortcut : inputBindings.metadata[props.action].bindings[0] + if (shortcutString == null) { return null } else { + const shortcut = inputBindingsModule.decomposeKeybindString(shortcutString) + const modifiers = [...shortcut.modifiers] + .sort(inputBindingsModule.compareModifiers) + .map(inputBindingsModule.toModifierKey) return (
- {shortcutManagerModule.getModifierKeysOfShortcut(shortcut).map( + {modifiers.map( modifier => MODIFIER_MAPPINGS[detect.platform()][modifier] ?? ( @@ -80,7 +94,9 @@ export default function KeyboardShortcut(props: KeyboardShortcutProps) { ) )} - {shortcut.key} + + {shortcut.key === ' ' ? 'Space' : shortcut.key} +
) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx index c4b39fd41761..ed40bb6723f4 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx @@ -9,7 +9,7 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as authProvider from '#/providers/AuthProvider' import * as backendProvider from '#/providers/BackendProvider' -import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider' +import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import AssetEventType from '#/events/AssetEventType' import AssetListEventType from '#/events/AssetListEventType' @@ -25,7 +25,6 @@ import * as eventModule from '#/utilities/event' import * as indent from '#/utilities/indent' import * as object from '#/utilities/object' import * as permissions from '#/utilities/permissions' -import * as shortcutManagerModule from '#/utilities/ShortcutManager' import * as string from '#/utilities/string' import * as validation from '#/utilities/validation' import Visibility from '#/utilities/visibility' @@ -47,7 +46,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { const toastAndLog = toastAndLogHooks.useToastAndLog() const { backend } = backendProvider.useBackend() const { user } = authProvider.useNonPartialUserSession() - const { shortcutManager } = shortcutManagerProvider.useShortcutManager() + const inputBindings = inputBindingsProvider.useInputBindings() const asset = item.item if (asset.type !== backendModule.AssetType.project) { // eslint-disable-next-line no-restricted-syntax @@ -241,6 +240,28 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { } }) + const handleClick = inputBindings.handler({ + open: () => { + dispatchAssetEvent({ + type: AssetEventType.openProject, + id: asset.id, + shouldAutomaticallySwitchPage: true, + runInBackground: false, + }) + }, + run: () => { + dispatchAssetEvent({ + type: AssetEventType.openProject, + id: asset.id, + shouldAutomaticallySwitchPage: false, + runInBackground: true, + }) + }, + editName: () => { + setRowState(object.merger({ isEditingName: true })) + }, + }) + return (
{ if (rowState.isEditingName || isOtherUserUsingProject) { // The project should neither be edited nor opened in these cases. - } else if ( - shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.open, event) - ) { - // It is a double click; open the project. - dispatchAssetEvent({ - type: AssetEventType.openProject, - id: asset.id, - shouldAutomaticallySwitchPage: true, - runInBackground: false, - }) - } else if ( - shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.run, event) - ) { - dispatchAssetEvent({ - type: AssetEventType.openProject, - id: asset.id, - shouldAutomaticallySwitchPage: false, - runInBackground: true, - }) + } else if (handleClick(event)) { + // Already handled. } else if ( !isRunning && eventModule.isSingleClick(event) && - ((selected && selectedKeys.current.size === 1) || - shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event)) + selected && + selectedKeys.current.size === 1 ) { setRowState(object.merger({ isEditingName: true })) } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/SecretNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/SecretNameColumn.tsx index 8998874fcb9d..d8dbc70be74c 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/SecretNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/SecretNameColumn.tsx @@ -8,23 +8,22 @@ import * as setAssetHooks from '#/hooks/setAssetHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as backendProvider from '#/providers/BackendProvider' +import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import * as modalProvider from '#/providers/ModalProvider' -import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider' import AssetEventType from '#/events/AssetEventType' import AssetListEventType from '#/events/AssetListEventType' -import UpsertSecretModal from '#/layouts/dashboard/UpsertSecretModal' - import type * as column from '#/components/dashboard/column' import SvgMask from '#/components/SvgMask' +import UpsertSecretModal from '#/modals/UpsertSecretModal' + import * as backendModule from '#/services/Backend' import * as eventModule from '#/utilities/event' import * as indent from '#/utilities/indent' import * as object from '#/utilities/object' -import * as shortcutManagerModule from '#/utilities/ShortcutManager' import Visibility from '#/utilities/visibility' // ===================== @@ -43,7 +42,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { const toastAndLog = toastAndLogHooks.useToastAndLog() const { setModal } = modalProvider.useSetModal() const { backend } = backendProvider.useBackend() - const { shortcutManager } = shortcutManagerProvider.useShortcutManager() + const inputBindings = inputBindingsProvider.useInputBindings() const asset = item.item if (asset.type !== backendModule.AssetType.secret) { // eslint-disable-next-line no-restricted-syntax @@ -107,6 +106,12 @@ export default function SecretNameColumn(props: SecretNameColumnProps) { } }) + const handleClick = inputBindings.handler({ + editName: () => { + setRowState(object.merger({ isEditingName: true })) + }, + }) + return (
{ - if ( - eventModule.isSingleClick(event) && - (selected || - shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event)) - ) { + if (handleClick(event)) { + // Already handled. + } else if (eventModule.isSingleClick(event) && selected) { setRowState(object.merger({ isEditingName: true })) } else if (eventModule.isDoubleClick(event)) { event.stopPropagation() diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column.ts b/app/ide-desktop/lib/dashboard/src/components/dashboard/column.ts index 59ee46de6909..0d107e79c2d5 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column.ts +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column.ts @@ -1,5 +1,5 @@ /** @file Column types and column display modes. */ -import type * as assetsTable from '#/layouts/dashboard/AssetsTable' +import type * as assetsTable from '#/layouts/AssetsTable' import * as columnUtils from '#/components/dashboard/column/columnUtils' import DocsColumn from '#/components/dashboard/column/DocsColumn' diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx index 1722c6254432..e61e2aedf0bd 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/LabelsColumn.tsx @@ -9,8 +9,7 @@ import * as authProvider from '#/providers/AuthProvider' import * as backendProvider from '#/providers/BackendProvider' import * as modalProvider from '#/providers/ModalProvider' -import Category from '#/layouts/dashboard/CategorySwitcher/Category' -import ManageLabelsModal from '#/layouts/dashboard/ManageLabelsModal' +import Category from '#/layouts/CategorySwitcher/Category' import ContextMenu from '#/components/ContextMenu' import ContextMenus from '#/components/ContextMenus' @@ -19,12 +18,13 @@ import Label from '#/components/dashboard/Label' import * as labelUtils from '#/components/dashboard/Label/labelUtils' import MenuEntry from '#/components/MenuEntry' +import ManageLabelsModal from '#/modals/ManageLabelsModal' + import type * as backendModule from '#/services/Backend' import * as assetQuery from '#/utilities/AssetQuery' import * as object from '#/utilities/object' import * as permissions from '#/utilities/permissions' -import * as shortcutManager from '#/utilities/ShortcutManager' import * as uniqueString from '#/utilities/uniqueString' // ==================== @@ -100,7 +100,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) { setModal( - + ) diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx index 2849324aaba2..e752d9fabb43 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/column/SharedWithColumn.tsx @@ -8,12 +8,13 @@ import * as modalProvider from '#/providers/ModalProvider' import AssetEventType from '#/events/AssetEventType' -import Category from '#/layouts/dashboard/CategorySwitcher/Category' -import ManagePermissionsModal from '#/layouts/dashboard/ManagePermissionsModal' +import Category from '#/layouts/CategorySwitcher/Category' import type * as column from '#/components/dashboard/column' import PermissionDisplay from '#/components/dashboard/PermissionDisplay' +import ManagePermissionsModal from '#/modals/ManagePermissionsModal' + import type * as backendModule from '#/services/Backend' import * as object from '#/utilities/object' diff --git a/app/ide-desktop/lib/dashboard/src/configurations/inputBindings.ts b/app/ide-desktop/lib/dashboard/src/configurations/inputBindings.ts new file mode 100644 index 000000000000..156874ac0535 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/configurations/inputBindings.ts @@ -0,0 +1,104 @@ +/** @file Shortcuts for the dashboard application. */ + +import AddConnectorIcon from 'enso-assets/add_connector.svg' +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 CameraIcon from 'enso-assets/camera.svg' +import CloseIcon from 'enso-assets/close.svg' +import CloudToIcon from 'enso-assets/cloud_to.svg' +import CopyIcon from 'enso-assets/copy.svg' +import DataDownloadIcon from 'enso-assets/data_download.svg' +import DataUploadIcon from 'enso-assets/data_upload.svg' +import DuplicateIcon from 'enso-assets/duplicate.svg' +import OpenIcon from 'enso-assets/open.svg' +import PasteIcon from 'enso-assets/paste.svg' +import PenIcon from 'enso-assets/pen.svg' +import PeopleIcon from 'enso-assets/people.svg' +import Play2Icon from 'enso-assets/play2.svg' +import ScissorsIcon from 'enso-assets/scissors.svg' +import SettingsIcon from 'enso-assets/settings.svg' +import SignInIcon from 'enso-assets/sign_in.svg' +import SignOutIcon from 'enso-assets/sign_out.svg' +import TagIcon from 'enso-assets/tag.svg' +import TrashIcon from 'enso-assets/trash.svg' +import UntrashIcon from 'enso-assets/untrash.svg' +import * as detect from 'enso-common/src/detect' + +import * as inputBindings from '#/utilities/inputBindings' + +export type * from '#/utilities/inputBindings' + +// ====================== +// === Input bindings === +// ====================== + +/** The type of the keybind and mousebind namespace for the dashboard. */ +export interface DashboardBindingNamespace extends ReturnType {} + +/** 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) +} + +export const BINDINGS = inputBindings.defineBindings({ + settings: { name: 'Settings', bindings: ['Mod+,'], icon: SettingsIcon }, + open: { name: 'Open', bindings: ['Enter'], icon: OpenIcon }, + run: { name: 'Run', bindings: ['Shift+Enter'], icon: Play2Icon }, + close: { name: 'Close', bindings: [], icon: CloseIcon }, + uploadToCloud: { name: 'Upload To Cloud', bindings: [], icon: CloudToIcon }, + rename: { name: 'Rename', bindings: ['Mod+R'], icon: PenIcon }, + edit: { name: 'Edit', bindings: ['Mod+E'], icon: PenIcon }, + snapshot: { name: 'Snapshot', bindings: ['Mod+S'], icon: CameraIcon }, + delete: { + name: 'Delete', + bindings: ['OsDelete'], + icon: TrashIcon, + color: 'rgb(243 24 10 / 0.87)', + }, + undelete: { name: 'Undelete', bindings: ['Mod+R'], icon: UntrashIcon }, + share: { name: 'Share', bindings: ['Mod+Enter'], icon: PeopleIcon }, + label: { name: 'Label', bindings: ['Mod+L'], icon: TagIcon }, + duplicate: { name: 'Duplicate', bindings: ['Mod+D'], icon: DuplicateIcon }, + copy: { name: 'Copy', bindings: ['Mod+C'], icon: CopyIcon }, + cut: { name: 'Cut', bindings: ['Mod+X'], icon: ScissorsIcon }, + paste: { name: 'Paste', bindings: ['Mod+V'], icon: PasteIcon }, + download: { name: 'Download', bindings: ['Mod+Shift+S'], icon: DataDownloadIcon }, + uploadFiles: { name: 'Upload Files', bindings: ['Mod+U'], icon: DataUploadIcon }, + uploadProjects: { name: 'Upload Projects', bindings: ['Mod+U'], icon: DataUploadIcon }, + newProject: { name: 'New Project', bindings: ['Mod+N'], icon: AddNetworkIcon }, + newFolder: { name: 'New Folder', bindings: ['Mod+Shift+N'], icon: AddFolderIcon }, + // FIXME [sb]: Platform detection should be handled directly in `shortcuts.ts`. + newSecret: { + name: 'New Secret', + bindings: !detect.isOnMacOS() ? ['Mod+Alt+N'] : ['Mod+Alt+N', 'Mod+Alt+~'], + icon: AddKeyIcon, + }, + newDataLink: { + name: 'New Data Link', + bindings: !detect.isOnMacOS() ? ['Mod+Alt+Shift+N'] : ['Mod+Alt+Shift+N', 'Mod+Alt+Shift+~'], + icon: AddConnectorIcon, + }, + signIn: { name: 'Login', bindings: [], icon: SignInIcon }, + signOut: { name: 'Logout', bindings: [], icon: SignOutIcon, color: 'rgb(243 24 10 / 0.87)' }, + // These should not appear in any menus. + closeModal: { name: 'Close Modal', bindings: ['Escape'], rebindable: false }, + cancelEditName: { name: 'Cancel Editing', bindings: ['Escape'], rebindable: false }, + downloadApp: { name: 'Download App', bindings: [], icon: AppDownloadIcon, rebindable: false }, + cancelCut: { name: 'Cancel Cut', 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)], + editName: { name: 'Edit Name', bindings: ['Mod+PointerMain'], rebindable: false }, + selectAdditional: { name: 'Select Additional', bindings: ['Mod+PointerMain'], rebindable: false }, + selectRange: { name: 'Select Range', bindings: ['Shift+PointerMain'], rebindable: false }, + selectAdditionalRange: { + name: 'Select Additional Range', + bindings: ['Mod+Shift+PointerMain'], + rebindable: false, + }, +}) diff --git a/app/ide-desktop/lib/dashboard/src/layouts/dashboard/AssetContextMenu.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx similarity index 87% rename from app/ide-desktop/lib/dashboard/src/layouts/dashboard/AssetContextMenu.tsx rename to app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx index 37e434b69662..975e6c0b72b7 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/dashboard/AssetContextMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx @@ -13,26 +13,26 @@ import * as modalProvider from '#/providers/ModalProvider' import AssetEventType from '#/events/AssetEventType' import AssetListEventType from '#/events/AssetListEventType' -import Category from '#/layouts/dashboard/CategorySwitcher/Category' -import GlobalContextMenu from '#/layouts/dashboard/GlobalContextMenu' -import ManageLabelsModal from '#/layouts/dashboard/ManageLabelsModal' -import ManagePermissionsModal from '#/layouts/dashboard/ManagePermissionsModal' -import UpsertSecretModal from '#/layouts/dashboard/UpsertSecretModal' +import Category from '#/layouts/CategorySwitcher/Category' +import GlobalContextMenu from '#/layouts/GlobalContextMenu' import ContextMenu from '#/components/ContextMenu' import ContextMenus from '#/components/ContextMenus' import ContextMenuSeparator from '#/components/ContextMenuSeparator' import type * as assetRow from '#/components/dashboard/AssetRow' -import ConfirmDeleteModal from '#/components/dashboard/ConfirmDeleteModal' import MenuEntry from '#/components/MenuEntry' +import ConfirmDeleteModal from '#/modals/ConfirmDeleteModal' +import ManageLabelsModal from '#/modals/ManageLabelsModal' +import ManagePermissionsModal from '#/modals/ManagePermissionsModal' +import UpsertSecretModal from '#/modals/UpsertSecretModal' + import * as backendModule from '#/services/Backend' import RemoteBackend from '#/services/RemoteBackend' import HttpClient from '#/utilities/HttpClient' import * as object from '#/utilities/object' import * as permissions from '#/utilities/permissions' -import * as shortcutManager from '#/utilities/ShortcutManager' // ======================== // === AssetContextMenu === @@ -104,7 +104,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {