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")} + + + } + />} +