-
Notifications
You must be signed in to change notification settings - Fork 23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement viewer/editor for text files #509
base: main
Are you sure you want to change the base?
Implement viewer/editor for text files #509
Conversation
I did some research regarding editing and my conclusions are:
Design is here: #471 (comment) |
lol totally missed #107 sorry! |
I think it's fine to just show them, provided they work properly. They're useful when having wrapping on (which is necessary when in a browser, as to avoid having to scroll horizontally). Without line numbers, you're not sure if a line is a wrapped one or two separate lines, which could break a configuration. Without wrapping, you can't see the context of a long line. So I'd suggest wrapping and line numbers (that support wrapping), without toggles. |
Here are the mockups (same as in the mockup discussion): They're subject to change, of course. I guess we should decide on whether we want to go with this implementation, the one in #107, or another. |
85b891a
to
9f5f0be
Compare
2722475
to
1e1ebc5
Compare
I've updated the textarea with line numbers demo @ https://codepen.io/garrett/pen/qBGPzWZ |
I played with this a bit and read the code. After understanding what it does I set out to try breaking it, and came up with this: Screencast.from.2024-06-18.17-05-45.webmThis is caused by your line-computation code assuming that a word can only be split on a space character, whereas the browser is happy to wrap after a hyphen. We could add hyphens, but then we'd have to worry about unicode hyphens. And other things that a browser may or may not do. It feels a bit too cat-and-mouse for my tastes. Another approach is to actually ask the browser how it would wrap a given block of text with the given styling options. https://github.com/component/textarea-caret-position is a evil bit of work that does exactly that. I don't think the approach they take would be directly useful to us but I could see some sort of a cached form of it where we measure each (logical) line for its physical height... that way on typing we'd only have to recompute a single line... It all seems a bit complicated, though. I'm starting to think we should punt on line numbers for the time being... |
Right, agreed. The demo was intended to see if we could use a text area with line numbers and how well it'd work. It could also create an invisible element with the same formatting (font, width, Not sure if this would be better than the text caret method or not, but we could combine ideas or even possibly clone the textarea in a fragment and manipulate the selection in the copy perhaps (if we can measure the size of the selection somehow). But, yeah, stuff for later. For now? Textarea without anything fancy. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't tested it but it seems good. I think to land this we need:
- Tests
- Only open files which are < 1MB (We obviously can't edit big files easy, load them in the browser and crash)
My one open question is, does fsreplace1 retain file permissions? If an admin edits /etc/fstab does that save as admin:admin or whatever it was?
src/dialogs/editor.tsx
Outdated
Modal, ModalVariant, | ||
Stack, | ||
TextArea, | ||
} from '@patternfly/react-core'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will need to be converted to esm
imports.
|
||
React.useEffect(() => { | ||
const before_unload = (event: BeforeUnloadEvent) => { | ||
event.preventDefault(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this lacks things for older browsers, everywhere in Cockpit we this as well.
// Included for legacy support, e.g. Chrome/Edge < 119
event.returnValue = true;
const before_unload = (event: BeforeUnloadEvent) => { | ||
event.preventDefault(); | ||
}; | ||
if (modified) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So this seems to add the beforeunload every time modified
is set and EditFileModal
is re-rendered?
This feels rather over-engineerd with useEffect
. We could useInit
and then either check in the eventlistener if the file was modified and then call preventDefault()
if needed. (Or just always it really doesn't hurt that much)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Especially as we already have modified
src/dialogs/editor.tsx
Outdated
id='editor-text-area' | ||
className='file-editor' | ||
value={content || ''} | ||
onChange={modify} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it probably makes sense to also disabled the textarea when there is an error isDisabled={error !== null}
file permissions are not retained:
Edit and save
Coreutils does this correctly.
The main use-case editing config files does not work: |
When you're not allowed to edit a file but you are able to view it still, why is the text area still interactive? It should be marked as read-only. https://www.patternfly.org/components/forms/text-area/#read-only ...And why does it have a star in the title? And what's with the error message? What action? It really should say it more straightforward. Does it mean that you're not allowed to edit the file? That shouldn't be an error, but some information; it shoulnd't be an alert. |
1e1ebc5
to
b719d1d
Compare
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. Thanks to Garrett for help with the styling.
b719d1d
to
0bf271a
Compare
Since last round:
|
class EditorState { | ||
error: string | 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 8 added lines are not executed by any test.
update(updates: Partial<EditorState>) { | ||
Object.assign(this.state, updates); | ||
this.emit('updated', { ...this.state }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 3 added lines are not executed by any test.
modify(content: string) { | ||
this.update({ content, modified: true }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 2 added lines are not executed by any test.
this.update({ content, modified: true }); | ||
} | ||
|
||
load_file() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
this.file.read() | ||
.then(((content: string, tag: string) => { | ||
this.update({ content, tag_now: tag, tag_at_load: tag }); | ||
}) as any /* eslint-disable-line @typescript-eslint/no-explicit-any */) | ||
.catch(error => { | ||
this.update({ error: cockpit.message(error) }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 6 added lines are not executed by any test.
<AlertActionLink onClick={() => editor && editor.load_file()}> | ||
{_("Reload file (abandon our changes)")} | ||
</AlertActionLink> | ||
<AlertActionLink onClick={() => editor && editor.save()}> | ||
{_("Overwrite with our changes")} | ||
</AlertActionLink> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 6 added lines are not executed by any test.
<TextArea | ||
id='editor-text-area' | ||
className='file-editor' | ||
isDisabled={!state.writable} | ||
value={state.content} | ||
onChange={(_ev, content) => editor && editor.modify(content)} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 6 added lines are not executed by any test.
</Stack> | ||
</Modal> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 2 added lines are not executed by any test.
export function edit_file(dialogs: Dialogs, path: string) { | ||
dialogs.run(EditFileModal, { path }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These 2 added lines are not executed by any test.
{ | ||
id: "open-file", | ||
title: _("Open text file"), | ||
onClick: () => edit_file(dialogs, `${currentPath}${item.name}`) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This added line is not executed by any test.
We probably want these two CSS lines to make the text editor more reliable for editing files: textarea {
white-space-collapse: break-spaces;
hyphens: none;
/* ... */
} (...this also happens to make my numbers demo more reliable too, but is useful with a plain textarea too.) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was curious and had a first look 😁
|
||
load_file() { | ||
// Can't do this async because we can't get the tag via await | ||
this.file.read() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The code below limits this to 1 MB, but for belt-and-suspenders this should get a max_read_size
. The default 16 MB is way too high for this purpose.
this.file.watch((_content, tag_now) => { | ||
this.update({ tag_now }); | ||
}, { read: false }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This needs a debounce
treatment (I recommend the usual 500 ms). These watch events often fire in batches, and we don't want to load the file and re-render the editor several times.
if (!this.state.tag_now) | ||
return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this a "Should Not Happen"™ situation? then please console.error
so that it breaks our tests when it runs into that. Or does that happen for creating a new file? (IIRC we didn't want to support that). But empty files have a -
tag, so this smells more like "not yet initialized"?
const tag = await this.file.replace(this.state.content, 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: cockpit.message(exc), saving: false }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's two common error cases here, right? access-denied
, which cockpit.message() translates to "Not permitted to perform this action.". That's a bit confusing for the case of "You don't have permissions to edit this file. This should already be intercepted by the test -w
state, so maybe that's just a corner case.
The other is "the file changed underneath your edit", and I don't think we have a matching error code, at least none of the ones that cockpit.message() knows about is appropriate. So this needs some fine tuning.
variant={ModalVariant.large} | ||
className='file-editor-modal' | ||
footer={ | ||
state?.writable && |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The dialog should have a "Close" button for non-writable files.
{_("Reload file (abandon our changes)")} | ||
</AlertActionLink> | ||
<AlertActionLink onClick={() => editor && editor.save()}> | ||
{_("Overwrite with our changes")} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same
title="The file has changed on disk" | ||
actionLinks={ | ||
<> | ||
<AlertActionLink onClick={() => editor && editor.load_file()}> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The buttons shouldn't even appear (or be isDisabled
) if the editor component is initialized asynchronously here. An enabled button should never do a no-op on click.
(Aside from that, in other situations this is better written as editor?.load_file()
, but it doesn't apply here)
@@ -91,38 +92,49 @@ export function get_menu_items( | |||
} | |||
); | |||
} else if (selected.length === 1) { | |||
const item = selected[0]; | |||
|
|||
if (item.type === 'reg' && item.size && item.size < 1000000) // 1MB |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bikeshedding: This simple editor isn't really suitable for such large files, especially if they are binary. This feels more like in the "kB" magnitude.
menuItems.push( | ||
{ | ||
id: "open-file", | ||
title: _("Open text file"), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How do you know it's a text file? There's no check if it's a binary file. That would be a good idea, but other than that it feels less confusing to just say "Edit file"?
if (item.type === 'reg' && item.size && item.size < 1000000) // 1MB | ||
menuItems.push( | ||
{ | ||
id: "open-file", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And dito here -- this is "edit". There's other ways how you can "open" a file (image viewer, run executable, render HTML in a new tab, etc.)
This can give us a bit of a model for playing around with.