From eca07b33f2f22b9a3312d8bedc3b503dbdf5d57f Mon Sep 17 00:00:00 2001 From: tomasmatus Date: Tue, 18 Jun 2024 13:04:45 +0200 Subject: [PATCH] Add basic keyboard shortcuts fixes: #451 For now this adds following keybindings: - `alt + arrow up`: go up one directory - `alt + arrow down`: activate selected item, enter directory - `Enter`: activate selected item, enter directory - `ctrl + shift + L`: focus files breadcrumbs to manually edit it - `F2`: rename selected file - `shift + N`: create new directory - `ctrl + c`: copy file/directory - `ctrl + v`: paste file/directory - `ctrl + a`: select all files Alt + arrow left/right is already supported by cockpit files browser history. --- src/app.scss | 60 +++++++++ src/app.tsx | 22 ++++ src/dialogs/keyboardShortcutsHelp.tsx | 145 ++++++++++++++++++++++ src/files-breadcrumbs.tsx | 20 ++- src/files-card-body.tsx | 168 +++++++++++++++++++------- src/header.tsx | 10 ++ src/menu.tsx | 37 +++--- test/check-application | 121 ++++++++++++++++++- 8 files changed, 519 insertions(+), 64 deletions(-) create mode 100644 src/dialogs/keyboardShortcutsHelp.tsx diff --git a/src/app.scss b/src/app.scss index f6605dbcc..5d089b05a 100644 --- a/src/app.scss +++ b/src/app.scss @@ -241,3 +241,63 @@ .pf-v5-c-menu-toggle { padding-inline: var(--pf-v5-global--spacer--md) calc(var(--pf-v5-global--spacer--md) * 0.75); } + +.shortcuts-dialog { + h2 + .pf-v5-c-description-list { + margin-block-start: var(--pf-v5-global--spacer--md); + } + + .pf-v5-l-flex { + // Add standard spacing between the description lists that are in a flex + // (PF Flex does odd stuff by default) + gap: var(--pf-v5-global--spacer--lg) var(--pf-v5-global--spacer--md); + + > .pf-v5-c-content { + // Have the content prefer 20em and wrap if too narrow + flex: 1 1 20em; + } + } + + .pf-v5-c-description-list { + // PF's gap is weirdly too big; let's use the PF standard size that's used everywhere else + --pf-v5-c-content--dl--ColumnGap: var(--pf-v5-global--spacer--md); + // We're setting this up as a table on the list, so they're consistent + display: grid; + // Fixing the width to the keyboard shortcuts + grid-template-columns: auto 1fr; + // Fix PF's negative margin at the end bug (as it's handled by grid layout anyway) + margin-block-end: 0; + + .pf-v5-c-description-list__group { + // Ignore the grid of the group and use the grid from the description list, so everything lines up properly + display: contents; + } + } + + kbd { + // Description lists bold the dt; we don't want the keys too look too bold + font-weight: normal; + } + + // Style key combos + .keystroke { + display: flex; + align-items: center; + color: var(--pf-v5-global--Color--200); + font-size: var(--pf-v5-global--FontSize--xs); + gap: var(--pf-v5-global--spacer--xs); + } + + // Style individual keys + .key { + display: inline-block; + background-color: var(--pf-v5-global--BackgroundColor--200); + border-radius: var(--pf-v5-global--BorderRadius--sm); + border: 1px solid var(--pf-v5-global--BorderColor--100); + color: var(--pf-v5-global--Color--100); + padding-block: var(--pf-v5-global--spacer--xs); + padding-inline: var(--pf-v5-global--spacer--sm); + box-shadow: inset 1px 1px 0 var(--pf-v5-global--BackgroundColor--100); + white-space: nowrap; + } +} diff --git a/src/app.tsx b/src/app.tsx index caecb3e49..89f2019b6 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -145,6 +145,28 @@ export const Application = () => { [options, path] ); + useEffect(() => { + const onKeyboardNav = (e: KeyboardEvent) => { + switch (e.key) { + case "L": + if (e.ctrlKey && !e.altKey) { + e.preventDefault(); + document.dispatchEvent(new Event("manual-change-dir")); + } + break; + + default: + break; + } + }; + + document.addEventListener("keydown", onKeyboardNav); + + return () => { + document.removeEventListener("keydown", onKeyboardNav); + }; + }, []); + if (loading) return ; diff --git a/src/dialogs/keyboardShortcutsHelp.tsx b/src/dialogs/keyboardShortcutsHelp.tsx new file mode 100644 index 000000000..4c14796a2 --- /dev/null +++ b/src/dialogs/keyboardShortcutsHelp.tsx @@ -0,0 +1,145 @@ +import React from 'react'; + +import { Button } from "@patternfly/react-core/dist/esm/components/Button"; +import { + DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm +} from "@patternfly/react-core/dist/esm/components/DescriptionList/index"; +import { Modal, ModalVariant } from "@patternfly/react-core/dist/esm/components/Modal"; +import { Text, TextContent, TextVariants } from "@patternfly/react-core/dist/esm/components/Text"; +import { Flex, } from "@patternfly/react-core/dist/esm/layouts/Flex"; + +import cockpit from 'cockpit'; +import { useDialogs } from 'dialogs'; + +const _ = cockpit.gettext; + +export const KeyboardShortcutsHelp = () => { + const Dialogs = useDialogs(); + + const footer = ( + + ); + + const toDescriptionListGroups = (item: [React.JSX.Element, string, string]) => { + return ( + + + {item[0]} + + + {item[1]} + + + ); + }; + + const navShortcuts: Array<[React.JSX.Element, string, string]> = [ + [ + + Alt + {'\u{2191}'} + , + _("Go up a directory"), + "go-up", + ], [ + + Alt + {'\u{2190}'} + , + _("Go back"), + "go-back", + ], [ + + Alt + {'\u{2192}'} + , + _("Go forward"), + "go-forward", + ], [ + + Alt + {'\u{2193}'} + , + _("Activate selected item, enter directory"), + "activate", + ], [ + + Enter + , + _("Activate selected item, enter directory"), + "activate-enter", + ], [ + + Ctrl + + Shift + + L + , + _("Edit path"), + "edit-path", + ] + ]; + + const editShortcuts: Array<[React.JSX.Element, string, string]> = [ + [ + F2, + _("Rename selected file or directory"), + "rename", + ], [ + + Shift + + N + , + _("Create new directory"), + "mkdir", + ], [ + + Ctrl + C + , + _("Copy selected file or directory"), + "copy", + ], [ + + Ctrl + V + , + _("Paste file or directory"), + "paste", + ], [ + + Ctrl + A + , + _("Select all"), + "select-all", + ] + ]; + + return ( + + + + {_("Navigation")} + + {navShortcuts.map(toDescriptionListGroups)} + + + + {_("Editing")} + + {editShortcuts.map(toDescriptionListGroups)} + + + + + ); +}; diff --git a/src/files-breadcrumbs.tsx b/src/files-breadcrumbs.tsx index fe9511f42..2e97c2a33 100644 --- a/src/files-breadcrumbs.tsx +++ b/src/files-breadcrumbs.tsx @@ -16,7 +16,7 @@ * You should have received a copy of the GNU Lesser General Public License * along with Cockpit; If not, see . */ -import React from "react"; +import React, { useCallback, useEffect } from "react"; import { AlertVariant } from "@patternfly/react-core/dist/esm/components/Alert"; import { Breadcrumb, BreadcrumbItem } from "@patternfly/react-core/dist/esm/components/Breadcrumb"; @@ -252,6 +252,19 @@ export function FilesBreadcrumbs({ path }: { path: string }) { const [editMode, setEditMode] = React.useState(false); const [newPath, setNewPath] = React.useState(null); + const enableEditMode = useCallback(() => { + setEditMode(true); + setNewPath(path); + }, [path]); + + useEffect(() => { + document.addEventListener("manual-change-dir", enableEditMode); + + return () => { + document.removeEventListener("manual-change-dir", enableEditMode); + }; + }, [enableEditMode]); + const handleInputKey = (event: React.KeyboardEvent) => { // Don't propogate navigation specific events if (event.key === "ArrowDown" || event.key === "ArrowUp" || @@ -267,11 +280,6 @@ export function FilesBreadcrumbs({ path }: { path: string }) { } }; - const enableEditMode = () => { - setEditMode(true); - setNewPath(path); - }; - const changePath = () => { setEditMode(false); cockpit.assert(newPath !== null, "newPath cannot be null"); diff --git a/src/files-card-body.tsx b/src/files-card-body.tsx index b37102504..0043beca1 100644 --- a/src/files-card-body.tsx +++ b/src/files-card-body.tsx @@ -37,9 +37,10 @@ import * as timeformat from "timeformat"; import { FolderFileInfo, useFilesContext } from "./app"; import { get_permissions } from "./common"; import { confirm_delete } from "./dialogs/delete"; +import { show_create_directory_dialog } from "./dialogs/mkdir"; +import { show_rename_dialog } from "./dialogs/rename"; import { Sort, filterColumnMapping, filterColumns } from "./header"; -import { get_menu_items } from "./menu"; - +import { get_menu_items, pasteFromClipboard } from "./menu"; import "./files-card-body.scss"; const _ = cockpit.gettext; @@ -134,6 +135,7 @@ export const FilesCardBody = ({ }) => { const [boxPerRow, setBoxPerRow] = useState(0); const dialogs = useDialogs(); + const { addAlert, cwdInfo } = useFilesContext(); const sortedFiles = useMemo(() => { return files @@ -165,6 +167,14 @@ export const FilesCardBody = ({ } }, [path]); + const goUpOneDir = useCallback(() => { + if (path.length > 1) { + const pathArray = path.split('/'); + const newPath = pathArray.slice(0, pathArray.length - 2).join("/"); + cockpit.location.go("/", { path: encodeURIComponent(newPath) }); + } + }, [path]); + useEffect(() => { calculateBoxPerRow(); window.onresize = calculateBoxPerRow; @@ -241,47 +251,116 @@ export const FilesCardBody = ({ } }; + const hasNoKeydownModifiers = (event: KeyboardEvent) => { + return !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey; + }; + const onKeyboardNav = (e: KeyboardEvent) => { - if (e.key === "ArrowRight") { - setSelected(_selected => { - const firstSelectedName = _selected?.[0]?.name; - const selectedIdx = sortedFiles?.findIndex(file => file.name === firstSelectedName); - const newIdx = selectedIdx < sortedFiles.length - 1 - ? selectedIdx + 1 - : 0; - - return [sortedFiles[newIdx]]; - }); - } else if (e.key === "ArrowLeft") { - setSelected(_selected => { - const firstSelectedName = _selected?.[0]?.name; - const selectedIdx = sortedFiles?.findIndex(file => file.name === firstSelectedName); - const newIdx = selectedIdx > 0 - ? selectedIdx - 1 - : sortedFiles.length - 1; - - return [sortedFiles[newIdx]]; - }); - } else if (e.key === "ArrowUp") { - 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]]; - }); - } else if (e.key === "ArrowDown") { - 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]]; - }); - } else if (e.key === "Enter" && selected.length === 1) { - onDoubleClickNavigate(selected[0]); - } else if (e.key === "Delete" && selected.length !== 0) { - confirm_delete(dialogs, path, selected, setSelected); + switch (e.key) { + case "ArrowRight": + if (hasNoKeydownModifiers(e)) { + setSelected(_selected => { + const firstSelectedName = _selected?.[0]?.name; + const selectedIdx = sortedFiles?.findIndex(file => file.name === firstSelectedName); + const newIdx = selectedIdx < sortedFiles.length - 1 + ? selectedIdx + 1 + : 0; + + return [sortedFiles[newIdx]]; + }); + } + break; + + case "ArrowLeft": + if (hasNoKeydownModifiers(e)) { + setSelected(_selected => { + const firstSelectedName = _selected?.[0]?.name; + const selectedIdx = sortedFiles?.findIndex(file => file.name === firstSelectedName); + const newIdx = selectedIdx > 0 + ? selectedIdx - 1 + : sortedFiles.length - 1; + + return [sortedFiles[newIdx]]; + }); + } + break; + + case "ArrowUp": + if (e.altKey && !e.shiftKey && !e.ctrlKey) { + goUpOneDir(); + } else if (hasNoKeydownModifiers(e)) { + 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]]; + }); + } + break; + + case "ArrowDown": + if (e.altKey && !e.shiftKey && !e.ctrlKey && selected.length === 1) { + onDoubleClickNavigate(selected[0]); + } else if (hasNoKeydownModifiers(e)) { + 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]]; + }); + } + break; + + case "Enter": + if (hasNoKeydownModifiers(e) && selected.length === 1) { + onDoubleClickNavigate(selected[0]); + } + break; + + case "Delete": + if (hasNoKeydownModifiers(e) && selected.length !== 0) { + confirm_delete(dialogs, path, selected, setSelected); + } + break; + + case "F2": + if (hasNoKeydownModifiers(e) && selected.length === 1) { + show_rename_dialog(dialogs, path, selected[0]); + } + break; + + case "a": + if (e.ctrlKey && !e.shiftKey && !e.altKey && !(e.target instanceof HTMLInputElement)) { + e.preventDefault(); + setSelected(sortedFiles); + } + break; + + case "c": + if (e.ctrlKey && !e.shiftKey && !e.altKey && !(e.target instanceof HTMLInputElement)) { + e.preventDefault(); + setClipboard(selected.map(s => path + s.name)); + } + break; + + case "v": + if (e.ctrlKey && !e.shiftKey && !e.altKey && !(e.target instanceof HTMLInputElement)) { + e.preventDefault(); + pasteFromClipboard(clipboard, cwdInfo, path, addAlert); + } + break; + + case "N": + if (!e.ctrlKey && !e.altKey) { + e.preventDefault(); + show_create_directory_dialog(dialogs, path); + } + break; + + default: + break; } }; @@ -314,7 +393,12 @@ export const FilesCardBody = ({ selected, onDoubleClickNavigate, dialogs, + goUpOneDir, path, + addAlert, + cwdInfo, + clipboard, + setClipboard, ]); // Generic event handler to look up the corresponding `data-item` for a click event when diff --git a/src/header.tsx b/src/header.tsx index 7e773a675..ae1e75489 100644 --- a/src/header.tsx +++ b/src/header.tsx @@ -30,7 +30,9 @@ import { EyeIcon, EyeSlashIcon, GripVerticalIcon, ListIcon } from "@patternfly/r import { SortByDirection } from '@patternfly/react-table'; import cockpit from "cockpit"; +import { useDialogs } from "dialogs"; +import { KeyboardShortcutsHelp } from "./dialogs/keyboardShortcutsHelp"; import { UploadButton } from "./upload-button"; const _ = cockpit.gettext; @@ -162,12 +164,16 @@ const ViewSelector = ({ isGrid, setIsGrid, sortBy, setSortBy, showHidden, setSho }) => { const [isOpen, setIsOpen] = useState(false); const onToggleClick = (isOpen: boolean) => setIsOpen(!isOpen); + const dialogs = useDialogs(); + const onSelect = (_ev?: React.MouseEvent, itemId?: string | number) => { if (itemId === "hidden-toggle") { setShowHidden(prevShowHidden => { localStorage.setItem("files:showHiddenFiles", !showHidden ? "true" : "false"); return !prevShowHidden; }); + } else if (itemId === "shortcuts-help") { + dialogs.show(); } else { const sort = as_sort(itemId); setSortBy(sort); @@ -230,6 +236,10 @@ const ViewSelector = ({ isGrid, setIsGrid, sortBy, setSortBy, showHidden, setSho : } itemId="hidden-toggle"> {showHidden ? _("Hide hidden items") : _("Show hidden items")} + + + {_("Show keyboard shortcuts")} + ); diff --git a/src/menu.tsx b/src/menu.tsx index e79d0ce8a..938768a7f 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -44,6 +44,27 @@ type MenuItem = { type: "divider" } | { className?: string; }; +export function pasteFromClipboard( + clipboard: string[], + cwdInfo: FileInfo | null, + path: string, + addAlert: (title: string, variant: AlertVariant, key: string, detail?: string) => void, +) { + const existingFiles = clipboard.filter(sourcePath => cwdInfo?.entries?.[basename(sourcePath)]); + if (existingFiles.length > 0) { + addAlert(_("Pasting failed"), AlertVariant.danger, "paste-error", + cockpit.format(_("\"$0\" exists, not overwriting with paste."), + existingFiles.map(basename).join(", "))); + return; + } + cockpit.spawn([ + "cp", + "-R", + ...clipboard, + path + ]).catch(err => addAlert(err.message, AlertVariant.danger, `${new Date().getTime()}`)); +} + export function get_menu_items( path: string, selected: FolderFileInfo[], setSelected: React.Dispatch>, @@ -60,21 +81,7 @@ export function get_menu_items( id: "paste-item", title: _("Paste"), isDisabled: clipboard.length === 0, - onClick: () => { - const existingFiles = clipboard.filter(sourcePath => cwdInfo?.entries?.[basename(sourcePath)]); - if (existingFiles.length > 0) { - addAlert(_("Pasting failed"), AlertVariant.danger, "paste-error", - cockpit.format(_("\"$0\" exists, not overwriting with paste."), - existingFiles.map(basename).join(", "))); - return; - } - cockpit.spawn([ - "cp", - "-R", - ...clipboard, - path - ]).catch(err => addAlert(err.message, AlertVariant.danger, `${new Date().getTime()}`)); - } + onClick: () => pasteFromClipboard(clipboard, cwdInfo, path, addAlert), }, { type: "divider" }, { diff --git a/test/check-application b/test/check-application index c8403eeb1..063820700 100755 --- a/test/check-application +++ b/test/check-application @@ -1040,6 +1040,20 @@ class TestFiles(testlib.MachineCase): b.wait_not_present(".pf-v5-c-modal-box") b.wait_visible("[data-item='renamed-foo.txt']") + # Rename file using keyboard shortcut + b.click("[data-item='newfile']") + b.key("F2") + b.wait_in_text(".pf-v5-c-modal-box__title-text", "Rename newfile") + b.set_input_text("#rename-item-input", "teddybear.txt") + b.click(".pf-v5-c-button.pf-m-primary") + b.wait_visible("[data-item='teddybear.txt']") + + # Rename modal does not open when multiple files are selected + b.mouse("[data-item='dest']", "click", ctrlKey=True) + b.mouse("[data-item='new dir1']", "click", ctrlKey=True) + b.key("F2") + b.wait_not_present(".pf-v5-c-modal-box__title-text") + def testHiddenItems(self) -> None: b = self.browser m = self.machine @@ -1317,7 +1331,10 @@ class TestFiles(testlib.MachineCase): self.enter_files() # Check control-clicking - m.execute("touch /home/admin/file1 && touch /home/admin/file2") + dir_path = "/home/admin" + m.execute(f""" + runuser -u admin touch {dir_path}/file1 {dir_path}/file2 + """) b.click("[data-item='file1']") b.mouse("[data-item='file2']", "click", ctrlKey=True) b.wait_visible("[data-item='file1'].row-selected") @@ -1339,7 +1356,14 @@ class TestFiles(testlib.MachineCase): b.wait_visible("[data-item='file1'].row-selected") b.wait_text("#sidebar-card-header", "file1empty") + # Select all keybind + b.eval_js("window.focus()") + b.key("a", modifiers=2) + b.wait_visible("[data-item='file1'].row-selected") + b.wait_visible("[data-item='file2'].row-selected") + # Check context menu + b.click("[data-item='file1']") b.mouse("[data-item='file2']", "click", ctrlKey=True) b.wait_visible("[data-item='file2'].row-selected") b.wait_text("#sidebar-card-header", "admin2 items selected") @@ -1368,14 +1392,29 @@ class TestFiles(testlib.MachineCase): self.enter_files() + m.execute(""" + runuser -u admin mkdir -p /home/admin/testdir + runuser -u admin mkdir -p /home/admin/anotherdir + """) create_files = "" for i in range(0, 4): create_files += f"touch /home/admin/file{i}; " m.execute(create_files) + # view shortcuts help dialog + b.select_PF("#sort-menu-toggle", "Show keyboard shortcuts") + b.wait_in_text(".shortcuts-dialog .pf-v5-c-modal-box__title-text", "Keyboard shortcuts") + b.click(".shortcuts-dialog button.pf-m-secondary") + b.wait_not_present(".shortcuts-dialog") + # Focus the iframe for global keybindings in Files. b.eval_js("window.focus()") + # Pressing ArrowRight will select first item when nothing is selected + b.wait_visible(".pf-v5-c-table__tbody tr:nth-child(1)") + b.key("ArrowRight") + b.wait_visible(".pf-v5-c-table__tbody tr:nth-child(1).row-selected") + b.click("[data-item='file0']") b.wait_visible("[data-item='file0'].row-selected") @@ -1384,6 +1423,41 @@ class TestFiles(testlib.MachineCase): b.key("ArrowLeft") b.wait_visible("[data-item='file0'].row-selected") + # Go up and down in directory hierarchy + b.click("[data-item='testdir']") + b.key("ArrowDown", modifiers=1) + self.assert_last_breadcrumb("testdir") + b.key("ArrowUp", modifiers=1) + b.wait_visible("[data-item='testdir']") + + # Manually edit path + b.key("L", modifiers=2) + b.input_text("/home/admin/anotherdir") + b.key("Enter") + self.assert_last_breadcrumb("anotherdir") + + # Go back in history twice + # HACK: chromium doesnt understand alt + left/right when using b.key() + if b.cdp.browser.name == "firefox": + b.key("ArrowLeft", repeat=2, modifiers=1) + else: + b.eval_js("window.history.back()") + b.eval_js("window.history.back()") + self.assert_last_breadcrumb("testdir") + + # Go forward in history + # HACK: chromium doesnt understand alt + left/right when using b.key() + if b.cdp.browser.name == "firefox": + b.key("ArrowRight", repeat=1, modifiers=1) + else: + b.eval_js("window.history.forward()") + b.wait_visible("[data-item='testdir']") + + b.key("N") + b.set_input_text("#create-directory-input", "foodir") + b.click("button.pf-m-primary") + b.wait_visible("[data-item='foodir']") + # Up / Down depends on the layout, this is tested on mobile where the # width is two or three columns. b.set_layout("mobile") @@ -1467,6 +1541,51 @@ class TestFiles(testlib.MachineCase): b.wait_in_text(".pf-v5-c-alert__description", "\"newfile\" exists") b.click("li[data-location='/home/admin'] a") self.assert_last_breadcrumb("admin") + b.click(".pf-v5-c-alert__action button") + + # Copy/paste with keybinds + b.eval_js("window.focus()") + m.execute("runuser -u admin mkdir -p /home/admin/kbdCopy") + b.mouse("[data-item='newdir']", "dblclick") + b.click("[data-item='copyDir']") + b.key("c", modifiers=2) + b.go("/files#/?path=/home/admin") + b.mouse("[data-item='kbdCopy']", "dblclick") + self.assert_last_breadcrumb("kbdCopy") + b.wait_text(".pf-v5-c-empty-state__title-text", "Directory is empty") + b.key("v", modifiers=2) + b.wait_visible("[data-item='copyDir']") + b.go("/files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + m.execute("runuser -u admin echo 'keybindings good' > /home/admin/newdir/newfile") + b.mouse("[data-item='newdir']", "dblclick") + b.click("[data-item='loaded']") + b.mouse("[data-item='newfile']", "click", ctrlKey=True) + b.wait_visible("[data-item='loaded'].row-selected") + b.wait_visible("[data-item='newfile'].row-selected") + b.key("c", modifiers=2) + b.go("/files#/?path=/home/admin/kbdCopy") + self.assert_last_breadcrumb("kbdCopy") + b.wait_visible("[data-item='copyDir']") + m.execute("runuser -u admin rmdir /home/admin/kbdCopy/copyDir") + b.wait_not_present("[data-item='copyDir']") + b.key("v", modifiers=2) + b.wait_visible("[data-item='loaded']") + b.wait_visible("[data-item='newfile']") + self.assertEqual(m.execute("head -n 1 /home/admin/kbdCopy/newfile"), "keybindings good\n") + + # File already exists error with keybinds + b.go("/files#/?path=/home/admin") + self.assert_last_breadcrumb("admin") + m.execute("runuser -u admin echo 'changed' > /home/admin/newdir/newfile") + b.click("[data-item='newfile']") + b.key("c", modifiers=2) + b.mouse("[data-item='kbdCopy']", "dblclick") + self.assert_last_breadcrumb("kbdCopy") + b.key("v", modifiers=2) + b.wait_in_text("h4.pf-v5-c-alert__title", "Pasting failed") + b.wait_in_text(".pf-v5-c-alert__description", "\"newfile\" exists") + self.assertEqual(m.execute("head -n 1 /home/admin/kbdCopy/newfile"), "keybindings good\n") @testlib.skipBrowser(".upload_files() doesn't work on Firefox", "firefox") def testUpload(self) -> None: