Skip to content

Commit

Permalink
Add a simple text file editor
Browse files Browse the repository at this point in the history
The design is a bit unusual in comparison with our other components, but
keeping track of the complex state interactions is difficult enough on
its own, without getting React involved, so we use a separate class.

Co-Authored-By: Garrett LeSage <[email protected]>
Co-Authored-By: Jelle van der Waa <[email protected]>
  • Loading branch information
3 people committed Sep 4, 2024
1 parent 24062ea commit 3632e90
Show file tree
Hide file tree
Showing 5 changed files with 484 additions and 7 deletions.
47 changes: 47 additions & 0 deletions src/dialogs/editor.scss
Original file line number Diff line number Diff line change
@@ -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);
}
}
258 changes: 258 additions & 0 deletions src/dialogs/editor.tsx
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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<string>;
state: EditorState;

update(updates: Partial<EditorState>) {
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<void>,
path: string
}) => {
const [last_tag, setLastTag] = React.useState<string | null>(null);
const [state, setState] = React.useState(new EditorState());
const [editor, setEditor] = React.useState<Editor | null>(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}<Label className="file-editor-title-label" variant="filled">{_("Read-only")}</Label></>);
}

// File has changed on disk while editing.
const change_conflict = state.tag_now !== state.tag_at_load;
const file_removed = state.tag_now === "-";

return (
<Modal
position="top"
// @ts-expect-error incorrect PatternFly typing https://github.com/patternfly/patternfly-react/issues/10361
title={title}
isOpen
onClose={() => dialogResult.resolve()}
variant={ModalVariant.large}
className={`file-editor-modal ${modified ? 'is-modified' : ''}`}
onEscapePress={handleEscape}
footer={
<>
{change_conflict && !file_removed &&
<Button
variant="warning"
onClick={() => editor && editor.save()}
>
{_("Overwrite")}
</Button>}
{state?.writable && (!change_conflict || file_removed) &&
<Button
variant="primary"
isDisabled={
!editor ||
state.saving ||
!modified ||
!state.writable
}
onClick={() => editor && editor.save()}
>
{_("Save")}
</Button>}
<Button variant={state.writable ? "link" : "secondary"} onClick={() => dialogResult.resolve()}>
{modified ? _("Cancel") : _("Close")}
</Button>
</>
}
>
<Stack>
{state.tag_now === state.tag_at_load && state.error !== null &&
<Alert
className="file-editor-alert"
variant="danger"
title={translate_error(state.error)}
isInline
/>}
{state.tag_now !== state.tag_at_load && last_tag !== state.tag_now &&
<Alert
className="file-editor-alert"
isInline
variant="warning"
title={file_removed
? _("The file has been removed on disk")
: _("The file has changed on disk")}
actionLinks={
<>
{!file_removed &&
<AlertActionLink onClick={() => editor && editor.load_file()}>
{_("Reload")}
</AlertActionLink>}
<AlertActionLink onClick={() => setLastTag(state.tag_now)}>
{_("Ignore")}
</AlertActionLink>
</>
}
/>}
<TextArea
id="editor-text-area"
className="file-editor"
isDisabled={!state.writable}
value={state.content}
onChange={(_ev, content) => editor && editor.modify(content)}
/>
</Stack>
</Modal>
);
};

export function edit_file(dialogs: Dialogs, path: string) {
dialogs.run(EditFileModal, { path });
}
29 changes: 23 additions & 6 deletions src/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit 3632e90

Please sign in to comment.