feat(workspace): sort selector in file-tree kebab menu (#6066) - #6091
8 commits merged into
Conversation
|
| Filename | Overview |
|---|---|
| static/ui.js | Adds sort key constants, big-integer string comparator, _workspaceEntriesForRender chokepoint, birthtime availability tracking, and _buildWorkspacePrefsMenu sort radio group; both render call sites updated; aria-checked and indicator dot both use _effectiveWorkspaceSortKey (not the raw stored key). |
| api/workspace.py | Adds _birthtime_ns(), _browser_timestamp_ns(), and serialize_workspace_entries_for_browser(); emits birthtime_ns on all three list_dir entry shapes from the existing lstat result; dir_signature unchanged; platform/Python-version matrix handled correctly. |
| api/routes.py | Routes serialize entries through serialize_workspace_entries_for_browser before JSON serialization; dir_signature still receives the original integer entries; escape-list-dir endpoint also covered; no mutation of source dicts. |
| static/workspace.js | One-line addition: calls _syncWorkspaceBirthtimeSupportScope on root/refresh loads so stale created-sort availability is cleared when the workspace or profile changes. |
| static/i18n.js | Six new keys added to all 15 locale blocks; English baseline in non-en per existing convention; count equality with workspace_show_hidden_files verified by test. |
| static/style.css | Seven-line addition for radio group container, group label, radio item, disabled state, and separator; uses only existing design tokens. |
| tests/test_issue6066_workspace_sort.py | Comprehensive backend + Node harness coverage: platform stat shapes, escape-symlink birthtime provenance, dir_signature immutability, all sort keys, rank grouping, null/zero/negative/large timestamp ordering, signed-zero normalization, workspace-switch availability reset, and open-menu state reconciliation. |
| tests/test_issue6066_workspace_sort_api_payload.py | Verifies serialize_workspace_entries_for_browser converts large integer nanosecond values to exact decimal strings and does not mutate original entry dicts. |
| tests/test_issue6066_workspace_sort_layout.py | Playwright layout coverage at 1280/1024x600/480x320; DE and RU locale proofs; menu repositioning after created-sort availability flip on an already-open menu near the viewport edge. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant FS as Filesystem
participant WS as workspace.py (list_dir)
participant RT as routes.py
participant UI as ui.js (renderFileTree)
participant LS as localStorage
FS->>WS: "os.lstat(follow_symlinks=False)"
WS->>WS: _birthtime_ns(lstat_result)
WS-->>RT: "entries [{mtime_ns: int, birthtime_ns: int|None}]"
RT->>RT: serialize_workspace_entries_for_browser(entries)
Note over RT: converts int ns to exact decimal strings
RT-->>UI: "JSON {entries: [{mtime_ns: str, birthtime_ns: str|null}]}"
UI->>UI: _noteWorkspaceBirthtimeSupport(S.entries)
UI->>LS: getItem('hermes-workspace-sort-key')
LS-->>UI: stored sort key
UI->>UI: _effectiveWorkspaceSortKey()
UI->>UI: _workspaceEntriesForRender(entries)
UI->>UI: _renderTreeItems(container, sorted, depth)
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant FS as Filesystem
participant WS as workspace.py (list_dir)
participant RT as routes.py
participant UI as ui.js (renderFileTree)
participant LS as localStorage
FS->>WS: "os.lstat(follow_symlinks=False)"
WS->>WS: _birthtime_ns(lstat_result)
WS-->>RT: "entries [{mtime_ns: int, birthtime_ns: int|None}]"
RT->>RT: serialize_workspace_entries_for_browser(entries)
Note over RT: converts int ns to exact decimal strings
RT-->>UI: "JSON {entries: [{mtime_ns: str, birthtime_ns: str|null}]}"
UI->>UI: _noteWorkspaceBirthtimeSupport(S.entries)
UI->>LS: getItem('hermes-workspace-sort-key')
LS-->>UI: stored sort key
UI->>UI: _effectiveWorkspaceSortKey()
UI->>UI: _workspaceEntriesForRender(entries)
UI->>UI: _renderTreeItems(container, sorted, depth)
Reviews (2): Last reviewed commit: "fix(workspace): align sort menu state wi..." | Re-trigger Greptile
🎬 Cutter preview — PR #6091
|
|
Reading the diff at The inconsistencyThree consumers derive the sort key, but from two different sources: // 18799 — resolver: falls back to name-asc when birthtime unavailable
function _effectiveWorkspaceSortKey(){
const key=_normalizeWorkspaceSortKey(S.workspaceSortKey);
return key==='created-desc'&&!_workspaceCreatedSortAvailable()?WORKSPACE_SORT_DEFAULT:key;
}
// 18844 (_syncWorkspaceSortMenuState) and 18938 (_buildWorkspacePrefsMenu)
const active=_normalizeWorkspaceSortKey(S.workspaceSortKey);So when Why not greptile's suggestionGreptile proposed marking Suggested fixDrive const active=_effectiveWorkspaceSortKey();Now Everything else here looks solid — the string-decimal comparator handling |
|
Thanks, you're right about the last mismatch.
|
🔬 Gate certification — GREEN ✅ (workspace file-tree sort selector — engineering-clean; parks for @nesquena visual sign-off, crown-jewel workspace pane)Certified head: The featureA "Sort by" selector added to the workspace file-tree kebab (⋮) menu: Name (A→Z) / Name (Z→A) / Date created (newest first) / Date modified (newest first), plus the existing Show-hidden toggle. Scope: Security / correctness — verified
What I ran
Screenshots (sent to Telegram)
DispositionGREEN — engineering-ship-ready at |
|
Thanks @rodboev — the UX side is approved (independent design review passed: placement in the panel kebab is the right home, matches VS Code/JetBrains view-menu convention, disabled "Date created" with inline reason is the correct pattern, ARIA One real correctness regression to fix before merge, though — reproduced with a live FIFO: Special-file (FIFO / socket / device node) partition divergence on non-default sorts. The rank-preservation claim holds for symlink / dir / regular-file, but misses the special-file case:
Net: on any non-default sort, a FIFO/socket/device node is moved out of the dir partition (where the server put it) into the regular-file partition. Reproduced: Default sort is unaffected (it's a pass-through), and the full suite stays green because no fixture creates a special file — the defect lives on a path the tests can't currently reach. Fix (mirrors your own "mirror the server's key rather than reinvent it" approach):
Design review (UX) is already a pass, so this is the only blocker. Happy to take the follow-up myself if you'd prefer — just say the word. |
…esquena#6066) The frontend re-derived partition rank from the serialized type, so FIFO, socket, and device entries moved into the regular-file partition on non-default sorts. Emit workspace_sort_rank from list_dir, consume it in the rank helper, and keep it out of dir_signature.
|
Fixed in 1c64e52.
|
Gate certification: PASSCertified contributor head: Integration tested: rebased gate head This was a semantic rebase rather than a byte-identical one. Review
Verification
Visual gate dispositionThe prior head Execution noteOne initial Node parse attempt used the wrong sandbox binary path. I then mistakenly ran a parse-only host check, and separately ran the ruff-forward helper outside the wrapper. Neither host result is counted. I reran the identical Node and ruff checks through the CLEAN no-network sandbox with reviewer-staged trusted binaries; those sandboxed reruns are the evidence above. ResultPASS. This exact contributor head is technically gate-clean and should move to Priority Queue T1. GitHub reports the contributor head as |
bc6e01a
|
Shipped in exp-v0.52.194 — thanks @rodboev! 🎉 The workspace file-tree "Sort by" menu (Name A→Z / Z→A, Date created, Date modified) + the relocated "Show hidden files" toggle are live on the experimental channel. Gate: Codex/warmup gate-pass, Fable UX SHIP, ESLint + ruff + browser-smoke clean, full 5-shard suite green (only the 2 known box-baseline env flakes, unrelated), maintainer visual approval. One fast-follow tracked: translating the 6 new i18n keys ( |

Thinking Path
list_dirreturns, andlist_dirsorts by name only (api/workspace.py:1344-1355). Past a screen of files, "the one I just edited" is only findable by scrolling. The panel kebab already exists as the home for workspace prefs and already persists one of them, so both the affordance and the persistence pattern were already built — only the sort concern was missing._visibleWorkspaceEntries(static/ui.js:18752) has exactly two consumers — the top-level render (:19042) and the expanded-children render (:19430) — and those are the only two places a level's rows are built. Sorting at either call site alone would give a sorted root and an unsorted subdirectory, so both now route through one composed_workspaceEntriesForRender, and_visibleWorkspaceEntriesstays a pure filter so the feat(workspace): hide (or dim) .DS_Store / Thumbs.db / .git/ / __pycache__ etc. in the file tree by default, with a 'Show hidden files' toggle #1793 hidden-files behavior and its test are untouched._sort_key_dereturns(not is_link, is_file, name.lower()), which orders symlinks, then non-regular non-links, then regular files.api/workspace.pyemitsworkspace_sort_rankas 0, 1, or 2 from the same link-local classification, and_workspaceEntryRankconsumes only those exact numeric values. Special entries serialized astype: 'file'therefore stay in rank 1.Name (A → Z)returns the filtered list untouched rather than re-sorting to an equivalent order, so the default path is a literal no-op and the server's ordering is never re-litigated in JS.Date createdcould not be renderer-only. Entries carrymtime_nsand nothing else time-related (api/workspace.py:1297,:1309,:1335), solist_dirnow emitsbirthtime_nsfrom thefollow_symlinks=Falsestat it already performs (:1374), with no extra syscall. Linux returnsNonerather thanctime, which is inode-change time there, and the UI disables the option with a reason instead of showing a field that would silently lie. The final patch stringifies nanosecond fields at the route layer and compares signed decimal keys exactly, so newest-first sorting neither collapses distinct >2^53 timestamps nor classifies negative integer values as missing; equivalent zero encodings such as0,-0,+0,000, and-00now normalize to one exact zero key instead of drifting into a separate signed bucket.dir_signature(api/workspace.py:1433) also reads these entry dicts, and it does not gainbirthtime_ns. Birthtime is immutable per inode, so it adds no change-detection signal beyond the name/type/size/mtime/target already hashed, and adding it would perturb every stored signature once and force a spurious refresh for every open workspace on upgrade.What Changed
static/ui.js: added theSort byradio group to_buildWorkspacePrefsMenuabove the existing checkbox; added the exact signed timestamp-key comparator and_workspaceEntriesForRender; routed both render call sites (:19181,:19569) through the new chokepoint; consumes the transportedworkspace_sort_rankwithouttypeinference; clears created-sort availability on the no-workspace render path; extracted the kebab dot into_syncWorkspacePrefsIndicators; tracked created-sort availability per workspace; and reconciled the disabled created row's input, ARIA, class, and.workspace-prefs-metawhile an open menu observes support changes.api/workspace.py: emitsworkspace_sort_rankon every entry shape, including special files and display-only escape symlinks, from the same classification used by the server ordering key.dir_signatureremains unchanged.static/workspace.js: reset workspace birthtime availability on root and refresh loads so switching profiles or workspaces clears stale created-sort support before the next render.api/workspace.py: added_birthtime_ns()and emittedbirthtime_nson all threelist_direntry shapes, always from the link-local stat.dir_signatureunchanged.api/routes.py: route-serializedmtime_ns/birthtime_nsas exact decimal strings for browser consumers, leaving the Python-sidelist_diranddir_signaturecontracts on integer nanoseconds.static/style.css: styles for the radio group, its label, the separator, and the disabled row — existing tokens only, no media queries.static/i18n.js: six new keys in all 15 locale blocks, English baseline in non-en.tests/test_issue6066_workspace_sort.py: backend birthtime and partition-rank behavior across platform stat shapes, signature-unchanged checks, exact signed timestamp ordering, strict malformed-rank fallback, workspace-switch and no-workspace availability resets, open-menu metadata reconciliation, and a Node harness over the extracted sort helpers.tests/test_issue6066_workspace_sort_layout.py: Playwright checks that the grown menu lays out at 1280 / 1024x600 / 480x320, stays inside the viewport when the unavailable-created explanation appears or disappears on an already-open menu, and still covers DE/RU.Why It Matters
Finding a recently-edited file in a workspace no longer means scrolling an alphabetical list; the preference persists across reloads and the kebab dot shows at a glance that a non-default order is active. Directories and symlinks keep their existing grouping at every level, so the default view is byte-for-byte what it is today.
Verification
The focused workspace-sort, payload, cruft-filter, and directory-signature tests pass, with the platform-specific FIFO case skipped on this Windows host because FIFO creation is unavailable. The Node harness covers all four keys, rank grouping, special-file rank 1 despite
type: 'file', strict malformed-rank fallback, null and missing timestamps, signed values, exact decimal-string ordering, effective menu state, and created-sort availability resets when the workspace changes or disappears. Backend coverage uses real temporary directories and symlinks, checks the link-local timestamp on the display-only escape row, preservesdir_signature, and verifies exact browser serialization without mutating the source entries. CI runs the full matrix on Python 3.11, 3.12, and 3.13.Risks / Follow-ups
birthtime_nscommit can be dropped without touching the rest — say the word and I'll re-push without it. It's included because the thread already settled the fallback question (Noneon Linux, UI disables the option), which was the open decision.st_ctime_nson Windows only. The thread's rule is "never fall back toctime", which is right on POSIX wherectimeis inode-change time. On Windowsst_ctimeis creation time (as the issue body notes), and on Python 3.11 — the CI floor — neitherst_birthtime_nsnorst_birthtimeexists there, so the suggestedgetattr(lst, 'st_birthtime_ns', None)alone would leave "Date created" permanently disabled for Windows users on 3.11._birthtime_nstherefore readsst_ctime_nsundersys.platform == 'win32'and only there. If you'd rather take the dead option on 3.11 than the platform branch, that's a one-line removal.list_dircaps at 200 entries (api/workspace.py:1385,:1428) and the cap is applied after the name sort, so past 200 the client receives the alphabetically-first 200. Sorting client-side over that window is therefore accurate only up to 200 entries — beyond it, "newest first" ranks a name-biased sample, and the payload carries no truncation signal for the UI to warn from. This PR sorts the window it's given rather than changing a shared endpoint's cap or contract under a sort-selector change. Happy to file a follow-up for a truncation flag or a server-side sort parameter if that's the direction you want.(not is_link, is_file, name.lower())does). The new ranking mirrors that exactly rather than quietly normalizing it, so nothing reorders today — but if the intent was dirs-first-including-symlinked-dirs, that's a separate change worth its own issue.workspace_sort_rankandbirthtime_nsto the display-only escape-symlink row from feat(workspace-tree): surface escape-target symlinks as display-only rows #4581. Both are derived from the link's ownfollow_symlinks=Falsestat, so they disclose nothing about the target; the resolved path, target-derivedis_dir, and target size are still withheld and navigation is still blocked bysafe_resolve_ws/open_anchored_fd. Covered by a test that plants a real escaping symlink and asserts the link-local timestamp, partition rank, and continued absence of target fields.Contract Routing
Task type: user-visible workspace preference feature plus product-semantics regression coverage.
Touched areas: workspace file-tree ordering, the workspace prefs kebab menu, and the
/api/listmetadata payload consumed by that menu.Relevant public docs:
AGENTS.mdCONTRIBUTING.mddocs/CONTRACTS.mddocs/UIUX-GUIDE.mdScope boundaries: keep the existing symlink/dir/file grouping, keep
dir_signatureunchanged, keep the hidden-files contract intact, and add no new inline workspace chrome outside the existing kebab menu.Evidence needed before claiming done: focused backend and UI regression coverage, exact metadata payload proof, layout proof with before/after screenshots, and CI confirmation on the upstream matrix.
Screenshots
Before, the workspace panel kebab only exposes the hidden-files preference.
After, the same menu adds the Sort by group and shows a non-default sort active from the kebab itself.
Upstream
Closes #6066.
Thanks to @nesquena-hermes for tracing both the list payload and the kebab wiring in the issue thread — the
birthtime_ns-from-the-existing-stat route, theNone-not-ctimedecision, the global-pref call, and the sort-within-the-server's-partitions note all came from there and shaped this patch.Model Used
GPT 5 via Codex CLI