From 49de6d09f4997c44d50bb929108daadeff5ee8f9 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 | 17 ++- src/dialogs/keyboardShortcutsHelp.tsx | 147 ++++++++++++++++++++++ src/files-breadcrumbs.tsx | 20 ++- src/files-card-body.tsx | 171 +++++++++++++++++++------- src/header.tsx | 17 ++- src/menu.tsx | 37 +++--- test/check-application | 113 ++++++++++++++++- test/reference | 2 +- 9 files changed, 516 insertions(+), 68 deletions(-) create mode 100644 src/dialogs/keyboardShortcutsHelp.tsx diff --git a/src/app.scss b/src/app.scss index 265445e1..2e6f1f23 100644 --- a/src/app.scss +++ b/src/app.scss @@ -260,3 +260,63 @@ padding-inline-start: var(--pf-v5-global--spacer--sm); } } + +.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 a9d9e8bf..27b57ef7 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -31,7 +31,7 @@ import cockpit from "cockpit"; import { FsInfoClient, FileInfo } from "cockpit/fsinfo.ts"; import { EmptyStatePanel } from "cockpit-components-empty-state"; import { WithDialogs } from "dialogs"; -import { usePageLocation } from "hooks"; +import { useInit, usePageLocation } from "hooks"; import { superuser } from "superuser"; import { FilesBreadcrumbs } from "./files-breadcrumbs.tsx"; @@ -145,6 +145,21 @@ export const Application = () => { [options, path] ); + useInit(() => { + const onKeyboardNav = (e: KeyboardEvent) => { + if (e.key === "L" && e.ctrlKey && !e.altKey) { + e.preventDefault(); + document.dispatchEvent(new Event("manual-change-dir")); + } + }; + + 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 00000000..8dbebe1b --- /dev/null +++ b/src/dialogs/keyboardShortcutsHelp.tsx @@ -0,0 +1,147 @@ +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 { DialogResult, Dialogs } from 'dialogs'; + +const _ = cockpit.gettext; + +const KeyboardShortcutsHelp = ({ dialogResult } : { dialogResult: DialogResult }) => { + 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 ( + dialogResult.resolve()} + footer={footer} + isOpen + > + + + {_("Navigation")} + + {navShortcuts.map(toDescriptionListGroups)} + + + + {_("Editing")} + + {editShortcuts.map(toDescriptionListGroups)} + + + + + ); +}; + +export function showKeyboardShortcuts(dialogs: Dialogs) { + dialogs.run(KeyboardShortcutsHelp, {}); +} diff --git a/src/files-breadcrumbs.tsx b/src/files-breadcrumbs.tsx index c008d1e9..6044a3bf 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"; @@ -254,6 +254,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" || @@ -269,11 +282,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 1b213d25..1a2328de 100644 --- a/src/files-card-body.tsx +++ b/src/files-card-body.tsx @@ -31,15 +31,17 @@ import { SortByDirection, Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/re import cockpit from "cockpit"; import { ContextMenu } from "cockpit-components-context-menu"; import { EmptyStatePanel } from "cockpit-components-empty-state"; +import { dirname } from "cockpit-path.ts"; import { useDialogs } from "dialogs"; import * as timeformat from "timeformat"; import { FolderFileInfo, useFilesContext } from "./app.tsx"; import { get_permissions } from "./common.ts"; import { confirm_delete } from "./dialogs/delete.tsx"; +import { show_create_directory_dialog } from "./dialogs/mkdir.tsx"; +import { show_rename_dialog } from "./dialogs/rename.tsx"; import { Sort, filterColumnMapping, filterColumns } from "./header.tsx"; -import { get_menu_items } from "./menu.tsx"; - +import { get_menu_items, pasteFromClipboard } from "./menu.tsx"; import "./files-card-body.scss"; const _ = cockpit.gettext; @@ -156,6 +158,7 @@ export const FilesCardBody = ({ }) => { const [boxPerRow, setBoxPerRow] = useState(0); const dialogs = useDialogs(); + const { addAlert, cwdInfo } = useFilesContext(); const sortedFiles = useMemo(() => { return files @@ -187,6 +190,13 @@ export const FilesCardBody = ({ } }, [path]); + const goUpOneDir = useCallback(() => { + if (path !== "/") { + const newPath = dirname(path); + cockpit.location.go("/", { path: encodeURIComponent(newPath) }); + } + }, [path]); + useEffect(() => { calculateBoxPerRow(); window.onresize = calculateBoxPerRow; @@ -263,47 +273,119 @@ 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": + // Keep standard text editing behavior by excluding input fields + if (e.ctrlKey && !e.shiftKey && !e.altKey && !(e.target instanceof HTMLInputElement)) { + 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)) { + e.preventDefault(); + setClipboard(selected.map(s => path + s.name)); + } + break; + + case "v": + // Keep standard text editing behavior by excluding input fields + 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; } }; @@ -336,7 +418,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 3f240226..ff8810b7 100644 --- a/src/header.tsx +++ b/src/header.tsx @@ -34,6 +34,7 @@ import { KebabDropdown } from "cockpit-components-dropdown"; import { useDialogs } from "dialogs"; import { FolderFileInfo, useFilesContext } from "./app.tsx"; +import { showKeyboardShortcuts } from "./dialogs/keyboardShortcutsHelp.tsx"; import { get_menu_items } from "./menu.tsx"; import { UploadButton } from "./upload-button.tsx"; @@ -154,7 +155,19 @@ export const FilesCardHeader = ({ const menuItems = get_menu_items( path, selected, setSelected, clipboard, setClipboard, cwdInfo, addAlert, dialogs - ).map((option, i) => { + ); + + // This button only needs to be in the global menu + menuItems.push( + { type: "divider" }, + { + id: "shortcuts-help", + title: _("Show keyboard shortcuts"), + onClick: () => showKeyboardShortcuts(dialogs) + } + ); + + const dropdownItems = menuItems.map((option, i) => { if (option.type === 'divider') return ; return ( @@ -196,7 +209,7 @@ export const FilesCardHeader = ({ path={path} /> diff --git a/src/menu.tsx b/src/menu.tsx index cf2e8a98..56b965ae 100644 --- a/src/menu.tsx +++ b/src/menu.tsx @@ -45,6 +45,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>, @@ -61,21 +82,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 be36bfdf..fa233d9b 100755 --- a/test/check-application +++ b/test/check-application @@ -814,6 +814,9 @@ class TestFiles(testlib.MachineCase): b.mouse("[data-item='delete2']", "click", ctrlKey=True) b.wait_visible("[data-item='delete1'].row-selected") b.wait_visible("[data-item='delete2'].row-selected") + b.focus("#files-card-parent") + # For strange reasons ctrlKey remains pressed after the b.mouse() above (spotted in firefox) + b.key("Control") b.key("Delete") b.wait_in_text("h1.pf-v5-c-modal-box__title", "Delete 2 items?") b.assert_pixels(".pf-v5-c-modal-box", "delete-modal") @@ -1174,6 +1177,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("\uE032") # 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("\uE032") # F2 + b.wait_not_present(".pf-v5-c-modal-box__title-text") + def testHiddenItems(self) -> None: b = self.browser m = self.machine @@ -1451,7 +1468,18 @@ class TestFiles(testlib.MachineCase): self.enter_files() # Check control-clicking - m.execute("touch /home/admin/file1 && touch /home/admin/file2") + m.execute(""" + runuser -u admin touch /home/admin/file1 /home/admin/file2 + """) + + # Select all keybind + b.wait_visible("[data-item='file1']") + b.wait_visible("[data-item='file2']") + b.eval_js("window.focus()") + b.key("a", modifiers=["Control"]) + b.wait_visible("[data-item='file1'].row-selected") + b.wait_visible("[data-item='file2'].row-selected") + b.click("[data-item='file1']") b.mouse("[data-item='file2']", "click", ctrlKey=True) b.wait_visible("[data-item='file1'].row-selected") @@ -1502,14 +1530,31 @@ 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.click("#dropdown-menu") + b.click("#shortcuts-help") + b.wait_in_text(".shortcuts-dialog .pf-v5-c-modal-box__title-text", "Keyboard shortcuts") + b.assert_pixels(".shortcuts-dialog", "shortcuts-help-menu") + 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") @@ -1518,6 +1563,26 @@ 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=["Alt"]) + self.assert_last_breadcrumb("testdir") + b.key("ArrowUp", modifiers=["Alt"]) + b.wait_visible("[data-item='testdir']") + + # Manually edit path + b.key("L", modifiers=["Control"]) + 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.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") @@ -1599,6 +1664,52 @@ 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()") + b.mouse("[data-item='newdir']", "dblclick") + b.click("[data-item='copyDir']") + b.key("c", modifiers=["Control"]) + m.execute("runuser -u admin mkdir -p /home/admin/kbdCopy") + b.go("/files#/?path=/home/admin") + self.assert_last_breadcrumb("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=["Control"]) + 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=["Control"]) + 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=["Control"]) + 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=["Control"]) + b.go("/files#/?path=/home/admin/kbdCopy") + self.assert_last_breadcrumb("kbdCopy") + b.key("v", modifiers=["Control"]) + 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: diff --git a/test/reference b/test/reference index 9eea21ab..3604d52f 160000 --- a/test/reference +++ b/test/reference @@ -1 +1 @@ -Subproject commit 9eea21abef9becad74211071b100a0c899a7336b +Subproject commit 3604d52f83a03997f6967325d5c5606796bba07f