cockpit: Add file chooser component - #23380
Conversation
28d169e to
d344831
Compare
cockpituous
left a comment
There was a problem hiding this comment.
There are more than 10 code coverage comments, see the full report here.
696644f to
8287b22
Compare
cockpituous
left a comment
There was a problem hiding this comment.
There are more than 10 code coverage comments, see the full report here.
d58a7e0 to
e166f5c
Compare
16d5d82 to
b06c5af
Compare
cockpituous
left a comment
There was a problem hiding this comment.
There are more than 10 code coverage comments, see the full report here.
cockpituous
left a comment
There was a problem hiding this comment.
There are more than 10 code coverage comments, see the full report here.
cockpituous
left a comment
There was a problem hiding this comment.
There are more than 10 code coverage comments, see the full report here.
cockpituous
left a comment
There was a problem hiding this comment.
There are more than 10 code coverage comments, see the full report here.
cockpituous
left a comment
There was a problem hiding this comment.
There are more than 10 code coverage comments, see the full report here.
cockpituous
left a comment
There was a problem hiding this comment.
There are more than 10 code coverage comments, see the full report here.
b074b83 to
9b98b24
Compare
|
From Claude: Code Review —
|
cockpituous
left a comment
There was a problem hiding this comment.
There are more than 10 code coverage comments, see the full report here.
Fixed by skipping dangling symlinks altogether.
Changed to if (!(ex && typeof ex == "object" && "problem" in ex && ex.problem == "not-found"))We want to log everything expect a "not-found". Claude's fix would log only errors that do contain a ".problem" field. In practice it's probably the same, but...
Fixed by returning a cleanup function from
Fixed.
Fixed by .pf-v6-c-table tr.file-chooser-selected:where(.pf-v6-c-table__tr) > :where(th, td) {
background: var(--pf-t--global--color--nonstatus--blue--default);
color: var(--pf-t--global--text--color--nonstatus--on-blue--default);
}
Fixed. |
|
Second Claude round: Code Review FindingsBug:
|
Fixed with a common "readRecent" function.
Hmm, no, the user might want to try again by closing the dialog, fixing something, and then open it again. The file from the previous attempt should be listed as recent.
Ok. (I think I copied the
Yes, hmm, maybe.
This is negligible since
Our FileIcon is different: it is hollow and thus easily distinguishable from directories, which are filled. The icon is copied from cockpit-files. Maybe we should use a different name. |
cockpituous
left a comment
There was a problem hiding this comment.
There are more than 10 code coverage comments, see the full report here.
|
Third round: Bug: Prepared filter receives full path instead of basename in collection modeFile: In collection mode ( const preFiltered = withoutHidden.filter(
f => (!onlyDirectories && f.type == "dir") || dlg.values.filter.filter(f.name, f.type)
);The docs promise the filter receives "the base name of a file", so a filter like n => n.startsWith('f') would silently reject every file in Recent/collections because all paths start with '/'. Fix: use basename(f.name) when dlg.values.path == "". Bug: Text filter matched against full path but highlight shown on basename File: pkg/lib/cockpit/react/FileChooser.tsx, line 768 In collection mode f.name is a full path, but the row displays basename(f.name) and boldify highlights within that basename. If the user types "foo", the file /home/foo/bar.txt passes the filter (the directory component matches) but renders as bar.txt Bug: Sort key puts block/char devices before directories File: pkg/lib/cockpit/react/FileChooser.tsx, line 262 The intent is directories first, then files. But 'blk' < 'dir' < 'reg' in ASCII order, so block devices and character devices sort above directories. This is visible when navigating to /dev. Fix: sort explicitly — bucket 'dir' as 0 and everything else Efficiency: getFileInfos issues sequential fsinfo calls for collections File: pkg/lib/cockpit/react/FileChooser.tsx, line 372 Each call is a separate round-trip over the Cockpit transport. With up to 20 recent files this is 20 serial requests; over SSH the latency adds up visibly. The calls are independent — replace with Promise.all(paths.map(p => fsinfo(...).catch(...))). Efficiency: stdShortcuts awaits home dir and downloads dir sequentially File: pkg/lib/cockpit/react/FileChooser.tsx, line 265 getHomeDir may call cockpit.init() and getDownloadDir spawns xdg-user-dir — neither depends on the other. On a cold start this serialises an IPC call and a process spawn unnecessarily. Fix: |
Yep, fixed.
Ouch, indeed. Fixed.
Still ignoring these. |
|
Forth round: (CLaude starts to screw up markdown syntax now... hmm.) Bug:
|
This current behavior is logical, imo. However, filters don't make a lot of sense together with
Yes, fixed.
This seems to be false.
Yada yada. :-) It's good to see Claude being consistent, however. |
|
Fifth round... Now we get into actual coding style and maintainability issues... [
{
"file": "pkg/lib/cockpit/react/FileChooser.tsx",
"line": 354,
"summary": "Inline symlink resolution lacks the '.' special-case that FsInfoClient.target() handles, silently dropping any entry whose symlink target is '.'",
"failure_scenario": "A directory contains 'self' (a symlink with target='.'). entry.target is truthy so the if-block runs: info.entries['.'] is undefined (entries are keyed by filename, never by '.'), info.targets['.'] is also absent (fsinfo.ts:118
shows FsInfoClient.target() explicitly handles '.' to return the parent info, implying the server does not put '.' in the targets dict). entry becomes undefined; the if (entry && entry.type) guard drops it. The 'self' entry never appears in the
listing."
},
{
"file": "pkg/lib/cockpit/react/FileChooser.tsx",
"line": 384,
"summary": "getFileInfos serializes one cockpit bridge round-trip per path instead of running them in parallel",
"failure_scenario": "The Recent collection stores up to 20 paths (line 1090: recent.slice(0, 20)). Each fsinfo() is a separate bridge message. At 50 ms RTT on a remote host, 20 serial calls add ~1 s of latency before the Recent view renders any
entries. Promise.all(paths.map(p => fsinfo(p,...))) would cap latency at the single slowest call."
},
{
"file": "pkg/lib/cockpit/react/FileChooser.tsx",
"line": 269,
"summary": "stdShortcuts awaits getHomeDir() and getDownloadDir() sequentially even though they are independent",
"failure_scenario": "getDownloadDir() spawns 'xdg-user-dir DOWNLOAD' as a subprocess. Because it is only started after getHomeDir() resolves, every FileChooser open pays the sum of both RTTs instead of the maximum. On a slow or remote host this
doubles the visible spinner duration before the sidebar shortcuts appear."
},
{
"file": "pkg/lib/cockpit/react/FileChooser.tsx",
"line": 743,
"summary": "The 'Filesystem' shortcut literal is hardcoded independently in both header() (for narrow viewports) and sidebar() (for wide viewports), requiring two-site edits for any change",
"failure_scenario": "A maintainer adds an icon or a permission check to the Filesystem entry in sidebar() but misses the identical literal in header()'s KebabDropdown. Wide-viewport users see the updated entry; narrow/mobile users see the stale
one."
},
{
"file": "pkg/lib/cockpit/react/FileChooser.tsx",
"line": 642,
"summary": "The 'if (crumbs.length > 0)' guard is dead code: dirs always starts with ['/'] so crumbs is never empty in this branch",
"failure_scenario": "breadcrumbs() returns null early when path==='', so the else-branch only runs when path is non-empty. dirs is always ['/'].concat(...) with at least one element; the forEach always pushes at least one BreadcrumbItem. The length
},
{
"file": "pkg/lib/cockpit/react/FileChooser.tsx",
"line": 410,
"summary": "listRecent is an unnecessary async wrapper: it does nothing except return the synchronous result of readRecent()",
"failure_scenario": "A reader following the call chain from recent_collection.list traces through listRecent before reaching readRecent, expecting to find I/O or error handling that isn't there. Inline 'list: async () => readRecent(recentKey)' at
the call site (line 503) would eliminate the indirection."
},
{
"file": "pkg/lib/cockpit/react/FileChooser.tsx",
"line": 812,
"summary": "emptyState() uses magic numbers 0/1/2/3 to encode three orthogonal behaviors: whether to show a button, what label to use, and what action to take",
"failure_scenario": "A new caller adding a fourth 'clear' variant must decode the numeric convention from scratch without compiler help, and risks picking the wrong number. An optional 'action?: { label: string; onClick: () => void }' parameter
would make each call site self-documenting."
},
{
"file": "pkg/lib/cockpit/react/FileChooser.tsx",
"line": 873,
"summary": "The preFiltered condition '(!onlyDirectories && f.type==\"dir\") || filter.filter(...)' always passes directories when onlyDirectories=false, but this intent is not commented, creating a maintenance trap",
"failure_scenario": "A maintainer 'simplifies' the condition to 'filter.filter(basename(f.name), f.type)' to make it uniform. File-type filters (e.g. '*.txt') then also filter out directories, making the listing unnavigable when any prepared filter
is active. A one-line comment stating 'always show dirs for navigation' would prevent this."
},
{
"file": "pkg/lib/cockpit/react/FileChooser.tsx",
"line": 341,
"summary": "The guard treats falsy info.targets as 'Permission denied', but an incremental fsinfo update that omits the targets key would trigger this error path for an accessible directory",
"failure_scenario": "FsInfoClient merges incremental JSON patches; if a change event updates info.type and info.entries but omits info.targets (e.g., as a future server size optimization when no symlinks changed), the accumulated state has
info.targets===undefined. The check !(info.type && info.entries && undefined) is true, so callback fires with FileError('Permission denied') and the directory listing is cleared despite the directory being fully accessible."
},
{
"file": "pkg/lib/cockpit/react/FileChooser.tsx",
"line": 999,
"summary": "The FileChooserButton onClick is marked async but contains no await, unnecessarily wrapping the call in a Promise on every click",
"failure_scenario": "Every icon click creates a new Promise even though Dialogs.show() is synchronous and nothing is awaited. The async wrapper also silently converts any synchronous exception thrown inside Dialogs.show() into an unhandled Promise
rejection instead of a synchronous throw, making errors harder to observe."
}
] |
cockpituous
left a comment
There was a problem hiding this comment.
There are more than 10 code coverage comments, see the full report here.
| } catch (ex) { | ||
| console.warn("Can't determine downloads directory", String(ex)); | ||
| return null; |
There was a problem hiding this comment.
These 3 added lines are not executed by any test. Details
|
|
||
| return [ | ||
| { label: _("Home"), path: home }, | ||
| ...(dd && dd != home ? [{ label: _("Downloads"), path: dd }] : []), |
There was a problem hiding this comment.
This added line is not executed by any test. Details
| ["type", "entries", "target", "targets"], | ||
| { | ||
| follow: true, | ||
| ...(superuser ? { superuser } : { }) |
There was a problem hiding this comment.
This added line is not executed by any test. Details
| if ("message" in message && typeof message.message == "string") | ||
| callback(new FileError(message.message)); |
There was a problem hiding this comment.
These 2 added lines are not executed by any test. Details
| if (!(info.type && info.entries && info.targets)) { | ||
| callback(new FileError(_("Permission denied"))); | ||
| return; |
There was a problem hiding this comment.
These 3 added lines are not executed by any test. Details
| if (info.type != "dir") { | ||
| callback(new FileError(_("Not a directory"))); | ||
| return; |
There was a problem hiding this comment.
These 3 added lines are not executed by any test. Details
|
|
||
| for (const p of paths) { | ||
| try { | ||
| const info = await fsinfo(p, ["type"], superuser ? { superuser } : { }); |
There was a problem hiding this comment.
This added line is not executed by any test. Details
| } catch (ex) { | ||
| if (!(ex && typeof ex == "object" && "problem" in ex && ex.problem == "not-found")) | ||
| console.error("Failed to get file type:", p); |
There was a problem hiding this comment.
These 3 added lines are not executed by any test. Details
| } catch (ex) { | ||
| console.warn("Failed to parse recent files", String(ex)); |
There was a problem hiding this comment.
These 2 added lines are not executed by any test. Details
| console.warn("Failed to parse recent files", String(ex)); | ||
| } | ||
|
|
||
| return []; |
There was a problem hiding this comment.
This added line is not executed by any test. Details
Demo: https://youtu.be/5XSY9Gw7JJM
In action in c-machines: https://youtu.be/Zcinb-Ny8BQ
For directories, includes "Open in file browser": https://youtu.be/A4ls61bGjdk