diff --git a/src/dialogs/editor.scss b/src/dialogs/editor.scss
new file mode 100644
index 00000000..3c9f5ca0
--- /dev/null
+++ b/src/dialogs/editor.scss
@@ -0,0 +1,47 @@
+.file-editor-modal {
+ block-size: 100%;
+
+ &.is-modified {
+ // Add a background highlight on the modal itself
+ &::after {
+ background: linear-gradient(to bottom, var(--pf-v5-global--warning-color--100), transparent 0.33em);
+ content: '';
+ inset: 0;
+ pointer-events: none;
+ position: absolute;
+ }
+
+ // Add a circle to indicate that there are changes
+ .pf-v5-c-modal-box__title-text::after {
+ aspect-ratio: 1;
+ background: var(--pf-v5-global--warning-color--100);
+ block-size: 1cap;
+ border-radius: 0.5cap;
+ content: '';
+ display: inline-block;
+ margin-inline-start: 0.5cap;
+ }
+ }
+}
+
+.file-editor {
+ block-size: 100%;
+
+ textarea {
+ font-family: monospace;
+ hyphens: none;
+ white-space: wrap break-spaces;
+ }
+}
+
+.file-editor-alert {
+ margin-block-end: var(--pf-v5-global--spacer--md);
+}
+
+.file-editor-title-label {
+ margin-inline-start: var(--pf-v5-global--spacer--sm);
+
+ .pf-v5-theme-dark & {
+ background-color: var(--pf-v5-global--BackgroundColor--100);
+ }
+}
diff --git a/src/dialogs/editor.tsx b/src/dialogs/editor.tsx
new file mode 100644
index 00000000..522fb002
--- /dev/null
+++ b/src/dialogs/editor.tsx
@@ -0,0 +1,258 @@
+/*
+ * This file is part of Cockpit.
+ *
+ * Copyright (C) 2024 Red Hat, Inc.
+ *
+ * Cockpit is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation; either version 2.1 of the License, or
+ * (at your option) any later version.
+ *
+ * Cockpit is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with Cockpit; If not, see .
+ */
+
+import React from 'react';
+
+import { Alert, AlertActionLink } from '@patternfly/react-core/dist/esm/components/Alert';
+import { Button } from '@patternfly/react-core/dist/esm/components/Button';
+import { Label } from '@patternfly/react-core/dist/esm/components/Label';
+import { Modal, ModalVariant } from '@patternfly/react-core/dist/esm/components/Modal';
+import { TextArea } from '@patternfly/react-core/dist/esm/components/TextArea';
+import { Stack } from '@patternfly/react-core/dist/esm/layouts/Stack';
+import { debounce } from "throttle-debounce";
+
+import cockpit from 'cockpit';
+import { EventEmitter } from 'cockpit/event.ts';
+import type { Dialogs, DialogResult } from 'dialogs';
+
+import "./editor.scss";
+
+const _ = cockpit.gettext;
+
+// 1MB
+export const MAX_EDITOR_FILE_SIZE = 1000000;
+
+class EditorState {
+ /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+ error: any | null = null; // if there is an error to show
+ modified: boolean = false; // if there are unsaved changes
+ saving: boolean = false; // saving in progress?
+ tag_at_load: string | null = null; // the one we loaded
+ tag_now: string | null = null; // the one on disk
+ content: string = '';
+ writable: boolean = false;
+}
+
+/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
+function translate_error(error: any) {
+ if (error.problem === "not-found") {
+ return _("The file has been removed on disk");
+ } else if (error?.problem === 'change-conflict') {
+ return _("The existing file changed unexpectedly");
+ } else {
+ return cockpit.message(error);
+ }
+}
+
+class Editor extends EventEmitter<{ updated(state: EditorState): void }> {
+ file: cockpit.FileHandle;
+ state: EditorState;
+
+ update(updates: Partial) {
+ Object.assign(this.state, updates);
+ this.emit('updated', { ...this.state });
+ }
+
+ modify(content: string) {
+ this.update({ content, modified: true });
+ }
+
+ load_file() {
+ // Can't do this async because we can't get the tag via await
+ this.file.read()
+ .then(((content: string, tag: string) => {
+ this.update({ content, tag_now: tag, tag_at_load: tag, error: null });
+ }) as any /* eslint-disable-line @typescript-eslint/no-explicit-any */)
+ .catch(error => this.update({ error }));
+ }
+
+ constructor(filename: string) {
+ super();
+ this.file = cockpit.file(filename, { max_read_size: MAX_EDITOR_FILE_SIZE, superuser: "try" });
+ this.state = new EditorState();
+
+ this.load_file();
+
+ cockpit.spawn(['test', '-w', filename], { superuser: "try" })
+ .then(() => this.update({ writable: true }))
+ .catch(() => this.update({ writable: false }));
+
+ const handle_watch = debounce(500, (_content: string | null, tag_now: string | null) => {
+ this.update({ tag_now });
+ });
+
+ this.file.watch(handle_watch, { read: false });
+ }
+
+ async save() {
+ if (!this.state.tag_now) {
+ console.error("Unable to save as 'tag_now' is not initialised");
+ return;
+ }
+
+ try {
+ const content = this.state.content;
+ const content_with_nl = content && !content.endsWith('\n') ? content + '\n' : content;
+
+ this.update({ saving: true });
+ const tag = await this.file.replace(content_with_nl, this.state.tag_now);
+ this.update({ saving: false, modified: false, tag_now: tag, tag_at_load: tag });
+ } catch (exc: any /* eslint-disable-line @typescript-eslint/no-explicit-any */) {
+ this.update({ error: exc, saving: false });
+ }
+ }
+
+ close() {
+ this.file.close();
+ }
+}
+
+export const EditFileModal = ({ dialogResult, path } : {
+ dialogResult: DialogResult,
+ path: string
+}) => {
+ const [last_tag, setLastTag] = React.useState(null);
+ const [state, setState] = React.useState(new EditorState());
+ const [editor, setEditor] = React.useState(null);
+
+ React.useEffect(() => {
+ const editor = new Editor(path);
+ editor.on('updated', setState);
+ setEditor(editor);
+ return () => {
+ editor.close();
+ };
+ }, [path]);
+
+ const { modified } = state;
+ React.useEffect(() => {
+ const before_unload = (event: BeforeUnloadEvent) => {
+ event.preventDefault();
+
+ // Included for legacy support, e.g. Chrome/Edge < 119
+ event.returnValue = true;
+ };
+ if (modified) {
+ window.addEventListener('beforeunload', before_unload);
+ return () => {
+ window.removeEventListener('beforeunload', before_unload);
+ };
+ }
+ }, [modified]);
+
+ const handleEscape = (event: KeyboardEvent) => {
+ if (state?.modified) {
+ event.preventDefault();
+ } else {
+ dialogResult.resolve();
+ }
+ };
+
+ /* Translators: This is the title of a modal dialog. $0 represents a filename. */
+ let title = <>{cockpit.format(state?.writable ? _("Edit “$0”") : _("View “$0”"), path)}>;
+ if (!state.writable) {
+ // TODO: dark mode and lack of spacing
+ title = (<>{title}>);
+ }
+
+ // File has changed on disk while editing.
+ const change_conflict = state.tag_now !== state.tag_at_load;
+ const file_removed = state.tag_now === "-";
+
+ return (
+ dialogResult.resolve()}
+ variant={ModalVariant.large}
+ className={`file-editor-modal ${modified ? 'is-modified' : ''}`}
+ onEscapePress={handleEscape}
+ footer={
+ <>
+ {change_conflict && !file_removed &&
+ }
+ {state?.writable && (!change_conflict || file_removed) &&
+ }
+
+ >
+ }
+ >
+
+ {state.tag_now === state.tag_at_load && state.error !== null &&
+ }
+ {state.tag_now !== state.tag_at_load && last_tag !== state.tag_now &&
+
+ {!file_removed &&
+ editor && editor.load_file()}>
+ {_("Reload")}
+ }
+ setLastTag(state.tag_now)}>
+ {_("Ignore")}
+
+ >
+ }
+ />}
+
+
+ );
+};
+
+export function edit_file(dialogs: Dialogs, path: string) {
+ dialogs.run(EditFileModal, { path });
+}
diff --git a/src/menu.tsx b/src/menu.tsx
index e837324e..6316011f 100644
--- a/src/menu.tsx
+++ b/src/menu.tsx
@@ -28,6 +28,7 @@ import type { Dialogs } from 'dialogs';
import type { FolderFileInfo } from "./app";
import { basename } from "./common.ts";
import { confirm_delete } from './dialogs/delete.tsx';
+import { edit_file, MAX_EDITOR_FILE_SIZE } from './dialogs/editor.tsx';
import { show_create_directory_dialog } from './dialogs/mkdir.tsx';
import { edit_permissions } from './dialogs/permissions.jsx';
import { show_rename_dialog } from './dialogs/rename.tsx';
@@ -90,38 +91,54 @@ export function get_menu_items(
}
);
} else if (selected.length === 1) {
+ const item = selected[0];
+ // Only allow code, text and unknown file types as we detect things by
+ // extensions, so not allowing unknown file types would disallow one
+ // from editing for example /etc/hostname
+ const allowed_edit_types = ["code-file", "text-file", "file"];
+ if (item.type === 'reg' &&
+ allowed_edit_types.includes(item?.category?.class || "") &&
+ item.size !== undefined && item.size < MAX_EDITOR_FILE_SIZE)
+ menuItems.push(
+ {
+ id: "open-file",
+ title: _("Open text file"),
+ onClick: () => edit_file(dialogs, path + item.name)
+ },
+ { type: "divider" },
+ );
menuItems.push(
{
id: "copy-item",
title: _("Copy"),
- onClick: () => setClipboard([path + selected[0].name]),
+ onClick: () => setClipboard([path + item.name])
},
{ type: "divider" },
{
id: "edit-permissions",
title: _("Edit permissions"),
- onClick: () => edit_permissions(dialogs, selected[0], path)
+ onClick: () => edit_permissions(dialogs, item, path)
},
{
id: "rename-item",
title: _("Rename"),
- onClick: () => show_rename_dialog(dialogs, path, selected[0])
+ onClick: () => show_rename_dialog(dialogs, path, item)
},
{ type: "divider" },
{
id: "delete-item",
title: _("Delete"),
className: "pf-m-danger",
- onClick: () => confirm_delete(dialogs, path, selected, setSelected)
+ onClick: () => confirm_delete(dialogs, path, [item], setSelected)
},
);
- if (selected[0].type === "reg")
+ if (item.type === "reg")
menuItems.push(
{ type: "divider" },
{
id: "download-item",
title: _("Download"),
- onClick: () => downloadFile(path, selected[0])
+ onClick: () => downloadFile(path, item)
}
);
} else if (selected.length > 1) {
diff --git a/test/check-application b/test/check-application
index 11c6280d..77871eb5 100755
--- a/test/check-application
+++ b/test/check-application
@@ -2141,6 +2141,161 @@ class TestFiles(testlib.MachineCase):
b.click(".pf-v5-c-alert__action button")
b.wait_not_present(".pf-v5-c-alert__action")
+ def testEditor(self) -> None:
+ b = self.browser
+ m = self.machine
+
+ self.enter_files()
+
+ def open_editor(file: str) -> None:
+ b.mouse(f"[data-item='{file}']", "contextmenu")
+ b.click(".contextMenu button:contains('Open text file')")
+
+ def validate_content(file: str, content: str) -> None:
+ open_editor(file)
+ b.wait_val(".file-editor-modal textarea", content)
+ b.click(".pf-v5-c-button.pf-m-link")
+ b.wait_not_present(".file-editor-modal")
+
+ # validate on disk
+ self.assertEqual(m.execute(f"cat /home/admin/{file}"), content)
+
+ m.execute("""
+ runuser -u admin -- bash -c "echo test > /home/admin/test.txt"
+ runuser -u admin -- bash -c "echo planes > /home/admin/notes.txt"
+ runuser -u admin -- truncate -s 2M /home/admin/big.txt
+ runuser -u admin -- touch /home/admin/archive.tar.gz
+ """)
+
+ # Don't allow opening big text files
+ b.mouse("[data-item='big.txt']", "contextmenu")
+ b.wait_not_in_text(".contextMenu", "Open text file")
+ b.click("#files-card-parent tbody")
+ b.wait_not_present(".contextMenu")
+
+ # A tarball cannot be edited
+ b.mouse("[data-item='archive.tar.gz']", "contextmenu")
+ b.wait_not_in_text(".contextMenu", "Open text file")
+ b.click("#files-card-parent tbody")
+ b.wait_not_present(".contextMenu")
+
+ # Opening an test file, editing and closing (discard)
+ open_editor("test.txt")
+ b.wait_text(".pf-v5-c-modal-box__title", "Edit “/home/admin/test.txt”")
+ b.wait_text(".file-editor-modal textarea", "test\n")
+
+ # Save button is disabled when not editing yet
+ b.wait_visible(".pf-v5-c-modal-box__footer .pf-m-primary.pf-m-disabled")
+ b.set_input_text(".file-editor-modal textarea", "foobar", append=True, value_check=False)
+ b.wait_val(".file-editor-modal textarea", "test\nfoobar")
+
+ b.assert_pixels(".file-editor-modal", "editor-modal-changed")
+ b.wait_visible(".pf-v5-c-modal-box__footer .pf-m-primary:not(:disabled)")
+
+ b.click(".pf-v5-c-button.pf-m-link")
+ b.wait_not_present(".file-editor-modal")
+ self.assertEqual(m.execute("cat /home/admin/test.txt"), "test\n")
+
+ self.assert_owner('/home/admin/test.txt', 'admin:admin')
+
+ # Opening a test file, add text and save
+ open_editor("test.txt")
+ b.set_input_text(".file-editor-modal textarea", "foobar", append=True, value_check=False)
+ b.wait_val(".file-editor-modal textarea", "test\nfoobar")
+ b.wait_visible(".file-editor-modal.is-modified")
+ b.click(".pf-v5-c-button.pf-m-primary")
+ # Saving resets modified state so should not be shown
+ b.wait_not_present(".file-editor-modal.is-modified")
+
+ b.click(".pf-v5-c-button.pf-m-link")
+ b.wait_not_present(".file-editor-modal")
+
+ validate_content("test.txt", "test\nfoobar\n")
+ self.assert_owner('/home/admin/test.txt', 'admin:admin')
+
+ # Opening a test file, edit text and someone else changes it while we are editing
+ open_editor("test.txt")
+ b.set_input_text(".file-editor-modal textarea", "roll\n", append=True, value_check=False)
+ b.wait_val(".file-editor-modal textarea", "test\nfoobar\nroll\n")
+ b.wait_visible(".pf-v5-c-modal-box__footer .pf-m-primary:not(:disabled)")
+
+ m.execute("runuser -u admin echo 'testing' > /home/admin/test.txt")
+ b.wait_in_text(".pf-v5-c-alert__title", "The file has changed on disk")
+
+ # Abort, reload
+ b.click(".file-editor-modal button:contains('Reload')")
+ b.wait_val(".file-editor-modal textarea", "testing\n")
+
+ # Let someone else edit again and overwrite
+ b.set_input_text(".file-editor-modal textarea", "roll\n", append=True, value_check=False)
+ b.wait_val(".file-editor-modal textarea", "testing\nroll\n")
+ m.execute("echo 'testing' > /home/admin/test.txt")
+ b.wait_in_text(".pf-v5-c-alert__title", "The file has changed on disk")
+
+ b.click(".pf-v5-c-modal-box__footer .pf-m-warning")
+ b.wait_not_present(".pf-v5-c-alert__title")
+ b.click(".pf-v5-c-button.pf-m-link")
+ b.wait_not_present(".file-editor-modal")
+
+ validate_content("test.txt", "testing\nroll\n")
+ self.assert_owner('/home/admin/test.txt', 'admin:admin')
+
+ # Change ownership during editing
+ open_editor("test.txt")
+ b.wait_val(".file-editor-modal textarea", "testing\nroll\n")
+ m.execute("chown root: /home/admin/test.txt")
+ b.set_input_text(".file-editor-modal textarea", "reset")
+ b.click(".pf-v5-c-modal-box__footer .pf-m-primary")
+ b.wait_in_text(".pf-v5-c-alert.pf-m-warning", "The file has changed on disk")
+
+ b.click(".pf-v5-c-modal-box__footer .pf-v5-c-button.pf-m-link")
+ b.wait_not_present(".file-editor-modal")
+
+ # Emptying a file does not remove the file
+ open_editor("notes.txt")
+ b.wait_val(".file-editor-modal textarea", "planes\n")
+ b.set_input_text(".file-editor-modal textarea", "")
+ b.click(".pf-v5-c-modal-box__footer .pf-m-primary")
+ b.wait_visible(".pf-v5-c-modal-box__footer .pf-m-primary:disabled")
+
+ b.click(".pf-v5-c-modal-box__footer .pf-v5-c-button.pf-m-link")
+ b.wait_not_present(".file-editor-modal")
+
+ validate_content("notes.txt", "")
+
+ # Remove file during editing
+ open_editor("notes.txt")
+ b.wait_val(".file-editor-modal textarea", "")
+ b.set_input_text(".file-editor-modal textarea", "reset")
+ m.execute("rm /home/admin/notes.txt")
+ b.click(".pf-v5-c-modal-box__footer .pf-m-primary")
+ b.wait_in_text(".pf-v5-c-alert.pf-m-warning", "The file has been removed on disk")
+
+ # Save, file was gone but we write it again
+ b.click(".pf-v5-c-modal-box__footer .pf-v5-c-button.pf-m-primary")
+ # Saving resets modified state so should not be shown
+ b.wait_not_present(".file-editor-modal.is-modified")
+
+ b.click(".pf-v5-c-modal-box__footer .pf-v5-c-button.pf-m-link")
+ b.wait_not_present(".file-editor-modal")
+
+ validate_content("notes.txt", "reset\n")
+
+ # As unprivileged user
+ b.drop_superuser()
+
+ # View a file
+ m.execute("echo 'this is a config file' > /etc/cockpit-files-test.cfg")
+ self.addCleanup(m.execute, "rm /etc/cockpit-files-test.cfg")
+ b.go("/files#/?path=/etc")
+ self.assert_last_breadcrumb("etc")
+ open_editor("cockpit-files-test.cfg")
+ b.wait_text(".pf-v5-c-modal-box__title", "View “/etc/cockpit-files-test.cfg”Read-only")
+ b.assert_pixels(".file-editor-modal", "editor-modal-read-only")
+
+ b.click(".pf-v5-c-modal-box__footer button.pf-m-secondary")
+ b.wait_not_present(".file-editor-modal")
+
if __name__ == '__main__':
testlib.test_main()
diff --git a/test/reference b/test/reference
index 3ea201d1..5725d82c 160000
--- a/test/reference
+++ b/test/reference
@@ -1 +1 @@
-Subproject commit 3ea201d1f6c7d0f598211965f7be7ebc4ef4085d
+Subproject commit 5725d82c53c4cf279e2b0252ee783361dd88772f