Skip to content

Commit

Permalink
Support Mac specific keybinds
Browse files Browse the repository at this point in the history
fixes: #757

Cmd + left - back in history, browser default
Cmd + right - forward in history, browser default
Cmd + up - go up a dir
Cmd + down - go into selected dir
Cmd + shift + j - edit path
  • Loading branch information
tomasmatus committed Feb 18, 2025
1 parent 213e7a0 commit c265c93
Show file tree
Hide file tree
Showing 5 changed files with 80 additions and 33 deletions.
16 changes: 14 additions & 2 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { WithDialogs } from "dialogs";
import { useInit, usePageLocation } from "hooks";
import { superuser } from "superuser";

import { FilesContext, usePath } from "./common.ts";
import { FilesContext, testIsAppleDevice, usePath } from "./common.ts";
import type { ClipboardInfo, FolderFileInfo } from "./common.ts";
import { FilesBreadcrumbs } from "./files-breadcrumbs.tsx";
import { FilesFolderView } from "./files-folder-view.tsx";
Expand Down Expand Up @@ -113,8 +113,20 @@ export const Application = () => {
);

useInit(() => {
const isApple = testIsAppleDevice();
const whichkey = isApple ? "J" : "L";

const editPathModifiers = (e: KeyboardEvent) => {
if (isApple) {
// use both meta and ctrl key on apple devices to support external non-apple keyboards
return ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey);
} else {
return (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey);
}
};

const onKeyboardNav = (e: KeyboardEvent) => {
if (e.key === "L" && e.ctrlKey && !e.altKey) {
if (e.key === whichkey && editPathModifiers(e)) {
e.preventDefault();
document.dispatchEvent(new Event("manual-change-dir"));
}
Expand Down
4 changes: 4 additions & 0 deletions src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,7 @@ export function debug(...args: unknown[]) {
console.debug("files:", ...args);
}
}

export function testIsAppleDevice() {
return /Mac|iPhone|iPad|iPod/.test(navigator.platform);
}
28 changes: 18 additions & 10 deletions src/dialogs/keyboardShortcutsHelp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,15 @@ import { Flex, } from "@patternfly/react-core/dist/esm/layouts/Flex";
import cockpit from 'cockpit';
import { DialogResult, Dialogs } from 'dialogs';

import { testIsAppleDevice } from '../common.ts';

const _ = cockpit.gettext;

const KeyboardShortcutsHelp = ({ dialogResult } : { dialogResult: DialogResult<void> }) => {
const isApple = testIsAppleDevice();
const ctrlString = isApple ? "Command" : "Ctrl";
const altString = isApple ? "Command" : "Alt";

const footer = (
<Button variant="secondary" onClick={() => dialogResult.resolve()}>{_("Close")}</Button>
);
Expand All @@ -53,25 +59,25 @@ const KeyboardShortcutsHelp = ({ dialogResult } : { dialogResult: DialogResult<v
const navShortcuts: Array<[React.JSX.Element, string, string]> = [
[
<kbd className="keystroke" key="go-up">
<kbd className="key">Alt</kbd> + <kbd className="key">{'\u{2191}'}</kbd>
<kbd className="key">{altString}</kbd> + <kbd className="key">{'\u{2191}'}</kbd>
</kbd>,
_("Go up a directory"),
"go-up",
], [
<kbd className="keystroke" key="go-back">
<kbd className="key">Alt</kbd> + <kbd className="key">{'\u{2190}'}</kbd>
<kbd className="key">{altString}</kbd> + <kbd className="key">{'\u{2190}'}</kbd>
</kbd>,
_("Go back"),
"go-back",
], [
<kbd className="keystroke" key="go-forward">
<kbd className="key">Alt</kbd> + <kbd className="key">{'\u{2192}'}</kbd>
<kbd className="key">{altString}</kbd> + <kbd className="key">{'\u{2192}'}</kbd>
</kbd>,
_("Go forward"),
"go-forward",
], [
<kbd className="keystroke" key="activate">
<kbd className="key">Alt</kbd> + <kbd className="key">{'\u{2193}'}</kbd>
<kbd className="key">{altString}</kbd> + <kbd className="key">{'\u{2193}'}</kbd>
</kbd>,
_("Activate selected item, enter directory"),
"activate",
Expand All @@ -83,9 +89,9 @@ const KeyboardShortcutsHelp = ({ dialogResult } : { dialogResult: DialogResult<v
"activate-enter",
], [
<kbd className="keystroke" key="edit-path">
<kbd className="key">Ctrl</kbd> +
<kbd className="key">{ctrlString}</kbd> +
<kbd className="key">Shift</kbd> +
<kbd className="key">L</kbd>
<kbd className="key">{isApple ? "J" : "L"}</kbd>
</kbd>,
_("Edit path"),
"edit-path",
Expand All @@ -94,7 +100,9 @@ const KeyboardShortcutsHelp = ({ dialogResult } : { dialogResult: DialogResult<v

const editShortcuts: Array<[React.JSX.Element, string, string]> = [
[
<kbd className="key" key="rename">F2</kbd>,
<kbd className="keystroke" key="rename">
<kbd className="key">Fn</kbd> + <kbd className="key">F2</kbd>
</kbd>,
_("Rename selected file or directory"),
"rename",
], [
Expand All @@ -106,19 +114,19 @@ const KeyboardShortcutsHelp = ({ dialogResult } : { dialogResult: DialogResult<v
"mkdir",
], [
<kbd className="keystroke" key="copy">
<kbd className="key">Ctrl</kbd> + <kbd className="key">C</kbd>
<kbd className="key">{ctrlString}</kbd> + <kbd className="key">C</kbd>
</kbd>,
_("Copy selected file or directory"),
"copy",
], [
<kbd className="keystroke" key="paste">
<kbd className="key">Ctrl</kbd> + <kbd className="key">V</kbd>
<kbd className="key">{ctrlString}</kbd> + <kbd className="key">V</kbd>
</kbd>,
_("Paste file or directory"),
"paste",
], [
<kbd className="keystroke" key="select-all">
<kbd className="key">Ctrl</kbd> + <kbd className="key">A</kbd>
<kbd className="key">{ctrlString}</kbd> + <kbd className="key">A</kbd>
</kbd>,
_("Select all"),
"select-all",
Expand Down
61 changes: 42 additions & 19 deletions src/files-card-body.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import { dirname } from "cockpit-path.ts";
import { useDialogs } from "dialogs";
import * as timeformat from "timeformat";

import { get_permissions, permissionShortStr, useFilesContext } from "./common.ts";
import { get_permissions, permissionShortStr, testIsAppleDevice, useFilesContext } from "./common.ts";
import type { ClipboardInfo, FolderFileInfo } from "./common.ts";
import { confirm_delete } from "./dialogs/delete.tsx";
import { show_create_directory_dialog } from "./dialogs/mkdir.tsx";
Expand Down Expand Up @@ -207,6 +207,7 @@ export const FilesCardBody = ({

useEffect(() => {
let folderViewElem: HTMLDivElement | null = null;
const isApple = testIsAppleDevice();

const resetSelected = (e: MouseEvent) => {
if ((e.target instanceof HTMLElement)) {
Expand Down Expand Up @@ -273,14 +274,38 @@ export const FilesCardBody = ({
}
};

const hasNoKeydownModifiers = (event: KeyboardEvent) => {
const hasNoModifiers = (event: KeyboardEvent) => {
return !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey;
};

const dirNavigationModifiers = (e: KeyboardEvent) => {
if (isApple) {
// use both meta and alt key on apple devices to support external non-apple keyboards
return !e.ctrlKey && !e.shiftKey && (e.altKey || e.metaKey);
} else {
return !e.ctrlKey && !e.shiftKey && e.altKey && !e.metaKey;
}
};

// Modifiers used for copy, paste and select all shortcuts
const editingModifiers = (e: KeyboardEvent) => {
// Keep standard text editing behavior by excluding input fields
if ((e.target instanceof HTMLInputElement)) {
return false;
}

if (isApple) {
// use both meta and ctrl key on apple devices to support external non-apple keyboards
return !e.shiftKey && !e.altKey && (e.ctrlKey || e.metaKey);
} else {
return !e.shiftKey && !e.altKey && e.ctrlKey && !e.metaKey;
}
};

const onKeyboardNav = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowRight":
if (hasNoKeydownModifiers(e)) {
if (hasNoModifiers(e) && sortedFiles.length > 0) {
setSelected(_selected => {
const firstSelectedName = _selected?.[0]?.name;
const selectedIdx = sortedFiles?.findIndex(file => file.name === firstSelectedName);
Expand All @@ -294,7 +319,7 @@ export const FilesCardBody = ({
break;

case "ArrowLeft":
if (hasNoKeydownModifiers(e)) {
if (hasNoModifiers(e) && sortedFiles.length > 0) {
setSelected(_selected => {
const firstSelectedName = _selected?.[0]?.name;
const selectedIdx = sortedFiles?.findIndex(file => file.name === firstSelectedName);
Expand All @@ -308,78 +333,76 @@ export const FilesCardBody = ({
break;

case "ArrowUp":
if (e.altKey && !e.shiftKey && !e.ctrlKey) {
if (dirNavigationModifiers(e)) {
goUpOneDir();
} else if (hasNoKeydownModifiers(e)) {
} else if (hasNoModifiers(e) && sortedFiles.length > 0) {
setSelected(_selected => {
const firstSelectedName = _selected?.[0]?.name;
const selectedIdx = sortedFiles?.findIndex(file => file.name === firstSelectedName);
const newIdx = Math.max(selectedIdx - boxPerRow, 0);

return [sortedFiles[newIdx]];
});
e.preventDefault();
}
break;

case "ArrowDown":
if (e.altKey && !e.shiftKey && !e.ctrlKey && selected.length === 1) {
if (selected.length === 1 && dirNavigationModifiers(e)) {
onDoubleClickNavigate(selected[0]);
} else if (hasNoKeydownModifiers(e)) {
} else if (hasNoModifiers(e) && sortedFiles.length > 0) {
setSelected(_selected => {
const firstSelectedName = _selected?.[0]?.name;
const selectedIdx = sortedFiles?.findIndex(file => file.name === firstSelectedName);
const newIdx = Math.min(selectedIdx + boxPerRow, sortedFiles.length - 1);

return [sortedFiles[newIdx]];
});
e.preventDefault();
}
break;

case "Enter":
if (hasNoKeydownModifiers(e) && selected.length === 1) {
if (hasNoModifiers(e) && selected.length === 1) {
onDoubleClickNavigate(selected[0]);
}
break;

case "Delete":
if (hasNoKeydownModifiers(e) && selected.length !== 0) {
if (hasNoModifiers(e) && selected.length !== 0) {
confirm_delete(dialogs, path, selected, setSelected);
}
break;

case "F2":
if (hasNoKeydownModifiers(e) && selected.length === 1) {
if (hasNoModifiers(e) && selected.length === 1) {
show_rename_dialog(dialogs, path, selected[0]);
}
break;

case "a":
// Keep standard text editing behavior by excluding input fields
if (e.ctrlKey && !e.shiftKey && !e.altKey && !(e.target instanceof HTMLInputElement)) {
if (editingModifiers(e)) {
e.preventDefault();
setSelected(sortedFiles);
}
break;

case "c":
// Keep standard text editing behavior by excluding input fields
if (e.ctrlKey && !e.shiftKey && !e.altKey && !(e.target instanceof HTMLInputElement)) {
if (editingModifiers(e)) {
e.preventDefault();
setClipboard({ path, files: selected });
}
break;

case "v":
// Keep standard text editing behavior by excluding input fields
if (e.ctrlKey && !e.shiftKey && !e.altKey && clipboard.files.length > 0 &&
!(e.target instanceof HTMLInputElement)) {
if (editingModifiers(e) && clipboard.files.length > 0) {
e.preventDefault();
pasteFromClipboard(clipboard, cwdInfo, path, dialogs, addAlert);
}
break;

case "N":
if (!e.ctrlKey && !e.altKey) {
if (e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
e.preventDefault();
show_create_directory_dialog(dialogs, path);
}
Expand Down
4 changes: 2 additions & 2 deletions test/check-application
Original file line number Diff line number Diff line change
Expand Up @@ -1948,14 +1948,14 @@ class TestFiles(testlib.MachineCase):
b.wait_visible("[data-item='testdir']")

# Manually edit path
b.key("L", modifiers=["Control"])
b.key("L", modifiers=["Control", "Shift"])
b.input_text("/home/admin/anotherdir")
b.key("Enter")
self.assert_last_breadcrumb("anotherdir")
b.go("/files#/?path=/home/admin")
self.assert_last_breadcrumb("admin")

b.key("N")
b.key("N", modifiers=["Shift"])
b.set_input_text("#create-directory-input", "foodir")
b.click("button.pf-m-primary")
b.wait_visible("[data-item='foodir']")
Expand Down

0 comments on commit c265c93

Please sign in to comment.