diff --git a/api/workspace.py b/api/workspace.py index 5c0ba580..8009a562 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -1280,6 +1280,8 @@ def list_dir(workspace: Path, rel: str='.'): with follow_symlinks=False (else None); ``reachable`` is False when a follow_symlinks=True stat raised (broken target or symlink loop).""" if is_symlink: + # Keep the transport rank aligned with _sort_key_de/_sort_key_p. + workspace_sort_rank = 0 if raw_link is None: return # A symlink whose follow-stat raised (ELOOP / broken target) can never @@ -1328,6 +1330,7 @@ def list_dir(workspace: Path, rel: str='.'): 'path': display_path, 'type': 'symlink', 'is_dir': False, + 'workspace_sort_rank': workspace_sort_rank, 'target_outside_workspace': True, 'mtime_ns': mtime_ns, 'birthtime_ns': _birthtime_ns(lstat_result) if lstat_result is not None else None, @@ -1341,6 +1344,7 @@ def list_dir(workspace: Path, rel: str='.'): 'type': 'symlink', 'target': str(link_target), 'is_dir': is_dir, + 'workspace_sort_rank': workspace_sort_rank, 'target_outside_workspace': False, 'mtime_ns': mtime_ns, 'birthtime_ns': _birthtime_ns(lstat_result) if lstat_result is not None else None, @@ -1357,6 +1361,7 @@ def list_dir(workspace: Path, rel: str='.'): entry_path = rel + '/' + name if lstat_result is not None: is_file = stat.S_ISREG(lstat_result.st_mode) + workspace_sort_rank = 2 if is_file else 1 size = lstat_result.st_size if is_file else None mtime_ns = lstat_result.st_mtime_ns is_dir_entry = stat.S_ISDIR(lstat_result.st_mode) @@ -1364,6 +1369,7 @@ def list_dir(workspace: Path, rel: str='.'): size = None mtime_ns = None is_dir_entry = False + workspace_sort_rank = 1 entries.append({ 'name': name, 'path': entry_path, @@ -1371,6 +1377,7 @@ def list_dir(workspace: Path, rel: str='.'): 'size': size, 'mtime_ns': mtime_ns, 'birthtime_ns': _birthtime_ns(lstat_result) if lstat_result is not None else None, + 'workspace_sort_rank': workspace_sort_rank, }) if _DIR_FD_OK: diff --git a/static/ui.js b/static/ui.js index cdbca24f..698a6b94 100644 --- a/static/ui.js +++ b/static/ui.js @@ -18759,10 +18759,8 @@ function _normalizeWorkspaceSortKey(value){ return WORKSPACE_SORT_KEYS.includes(value)?value:WORKSPACE_SORT_DEFAULT; } function _workspaceEntryRank(item){ - const type=item&&item.type; - if(type==='symlink') return 0; - if(type==='dir') return 1; - return 2; + const rank=parseInt(item&&item.workspace_sort_rank,10); + return rank===0||rank===1||rank===2?rank:1; } function _workspaceEntryTimestampKey(item,field){ const raw=item&&item[field]; diff --git a/tests/test_issue6066_workspace_sort.py b/tests/test_issue6066_workspace_sort.py index 4f02c172..6ce3b630 100644 --- a/tests/test_issue6066_workspace_sort.py +++ b/tests/test_issue6066_workspace_sort.py @@ -2,6 +2,7 @@ import hashlib import json +import os import subprocess from pathlib import Path @@ -22,6 +23,31 @@ def test_list_dir_emits_birthtime_ns(tmp_path): assert {entry["type"] for entry in entries} == {"file", "dir"} assert all("birthtime_ns" in entry for entry in entries) assert all(isinstance(entry["birthtime_ns"], (int, type(None))) for entry in entries) + assert {entry["workspace_sort_rank"] for entry in entries} == {1, 2} + + +def test_list_dir_emits_server_partition_rank_for_special_entries(tmp_path, monkeypatch): + if not hasattr(os, "mkfifo"): + pytest.skip("FIFO creation is unavailable on this platform") + try: + os.mkfifo(tmp_path / "fifo") + (tmp_path / "directory").mkdir() + (tmp_path / "file.txt").write_text("x", encoding="utf-8") + link_target = tmp_path / "file.txt" + (tmp_path / "link").symlink_to(link_target) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"special entry creation unavailable: {exc}") + + modes = [False] + if workspace_api._DIR_FD_OK: + modes.append(True) + for use_dir_fd in modes: + monkeypatch.setattr(workspace_api, "_DIR_FD_OK", use_dir_fd) + entries = {entry["name"]: entry for entry in workspace_api.list_dir(tmp_path, ".")} + assert entries["link"]["workspace_sort_rank"] == 0 + assert entries["directory"]["workspace_sort_rank"] == 1 + assert entries["fifo"]["workspace_sort_rank"] == 1 + assert entries["file.txt"]["workspace_sort_rank"] == 2 def test_birthtime_ns_platform_matrix(monkeypatch): @@ -105,7 +131,7 @@ function _visibleWorkspaceEntries(entries){return S.showHiddenWorkspaceFiles?ent def test_sort_modified_desc_within_rank(): result = _run_sort_harness(""" S.workspaceSortKey='modified-desc'; -const input=[{name:'file-old',type:'file',mtime_ns:1},{name:'dir',type:'dir',mtime_ns:2},{name:'file-new',type:'file',mtime_ns:3},{name:'link',type:'symlink',mtime_ns:0}]; +const input=[{name:'file-old',type:'file',workspace_sort_rank:2,mtime_ns:1},{name:'dir',type:'dir',workspace_sort_rank:1,mtime_ns:2},{name:'file-new',type:'file',workspace_sort_rank:2,mtime_ns:3},{name:'link',type:'symlink',workspace_sort_rank:0,mtime_ns:0}]; const out=_workspaceEntriesForRender(input).map(x=>x.name); if(JSON.stringify(out)!=='[\"link\",\"dir\",\"file-new\",\"file-old\"]')process.exit(1); """) @@ -115,7 +141,7 @@ if(JSON.stringify(out)!=='[\"link\",\"dir\",\"file-new\",\"file-old\"]')process. def test_sort_name_desc_within_rank(): result = _run_sort_harness(""" S.workspaceSortKey='name-desc'; -const input=[{name:'a',type:'file'},{name:'dir',type:'dir'},{name:'b',type:'file'},{name:'link',type:'symlink'}]; +const input=[{name:'a',type:'file',workspace_sort_rank:2},{name:'dir',type:'dir',workspace_sort_rank:1},{name:'b',type:'file',workspace_sort_rank:2},{name:'link',type:'symlink',workspace_sort_rank:0}]; const out=_workspaceEntriesForRender(input).map(x=>x.name); if(JSON.stringify(out)!=='[\"link\",\"dir\",\"b\",\"a\"]')process.exit(1); """) @@ -133,8 +159,15 @@ if(out!==input||out[0]!==input[0])process.exit(1); def test_rank_grouping_holds_all_keys(): result = _run_sort_harness(""" -const input=[{name:'s',type:'symlink',mtime_ns:1},{name:'d',type:'dir',mtime_ns:2},{name:'f',type:'file',mtime_ns:3}]; -for(const key of WORKSPACE_SORT_KEYS){S.workspaceSortKey=key;const ranks=_workspaceEntriesForRender(input).map(_workspaceEntryRank);if(ranks.some((x,i)=>i&&xx.name); if(JSON.stringify(out)!=='[\"new\",\"zero\",\"missing\",\"undefined\"]')process.exit(1); """) @@ -152,7 +185,7 @@ if(JSON.stringify(out)!=='[\"new\",\"zero\",\"missing\",\"undefined\"]')process. def test_large_nanosecond_strings_sort_exactly(): result = _run_sort_harness(""" S.workspaceSortKey='modified-desc'; -const input=[{name:'older',type:'file',mtime_ns:'1752598800000000000'},{name:'newer',type:'file',mtime_ns:'1752598800000000001'}]; +const input=[{name:'older',type:'file',workspace_sort_rank:2,mtime_ns:'1752598800000000000'},{name:'newer',type:'file',workspace_sort_rank:2,mtime_ns:'1752598800000000001'}]; const out=_workspaceEntriesForRender(input).map(x=>x.name); if(JSON.stringify(out)!=='[\"newer\",\"older\"]')process.exit(1); """) @@ -162,7 +195,7 @@ if(JSON.stringify(out)!=='[\"newer\",\"older\"]')process.exit(1); def test_signed_nanosecond_values_sort_exactly(): result = _run_sort_harness(""" S.workspaceSortKey='modified-desc'; -const input=[{name:'negative-string',type:'file',mtime_ns:'-2'},{name:'zero',type:'file',mtime_ns:0},{name:'negative-number',type:'file',mtime_ns:-1}]; +const input=[{name:'negative-string',type:'file',workspace_sort_rank:2,mtime_ns:'-2'},{name:'zero',type:'file',workspace_sort_rank:2,mtime_ns:0},{name:'negative-number',type:'file',workspace_sort_rank:2,mtime_ns:-1}]; const out=_workspaceEntriesForRender(input).map(x=>x.name); if(JSON.stringify(out)!=='[\"zero\",\"negative-number\",\"negative-string\"]')process.exit(1); """) @@ -172,7 +205,7 @@ if(JSON.stringify(out)!=='[\"zero\",\"negative-number\",\"negative-string\"]')pr def test_signed_zero_and_leading_zero_strings_compare_equal(): result = _run_sort_harness(""" S.workspaceSortKey='modified-desc'; -const input=[{name:'zero',type:'file',mtime_ns:'0'},{name:'negative-zero',type:'file',mtime_ns:'-0'},{name:'positive-zero',type:'file',mtime_ns:'+0'},{name:'leading-zero',type:'file',mtime_ns:'000'},{name:'negative-leading-zero',type:'file',mtime_ns:'-00'}]; +const input=[{name:'zero',type:'file',workspace_sort_rank:2,mtime_ns:'0'},{name:'negative-zero',type:'file',workspace_sort_rank:2,mtime_ns:'-0'},{name:'positive-zero',type:'file',workspace_sort_rank:2,mtime_ns:'+0'},{name:'leading-zero',type:'file',workspace_sort_rank:2,mtime_ns:'000'},{name:'negative-leading-zero',type:'file',workspace_sort_rank:2,mtime_ns:'-00'}]; if(['0','-0','+0','000','-00'].some(value=>_workspaceEntryTimestampKey({mtime_ns:value},'mtime_ns')!=='0'))process.exit(1); const out=_workspaceEntriesForRender(input).map(x=>x.name); if(JSON.stringify(out)!=='[\"zero\",\"negative-zero\",\"positive-zero\",\"leading-zero\",\"negative-leading-zero\"]')process.exit(1); @@ -210,7 +243,7 @@ def test_created_pref_survives_unavailable(): const mk=()=>({hidden:true,attrs:{},setAttribute(k,v){this.attrs[k]=String(v);},removeAttribute(k){delete this.attrs[k];}}); const ind=mk(),dot=mk(); S.showHiddenWorkspaceFiles=false;S._workspaceBirthtimeSeen=false;S.workspaceSortKey='created-desc'; -const input=[{name:'b',type:'file',birthtime_ns:null},{name:'a',type:'file',birthtime_ns:null}]; +const input=[{name:'b',type:'file',workspace_sort_rank:2,birthtime_ns:null},{name:'a',type:'file',workspace_sort_rank:2,birthtime_ns:null}]; _noteWorkspaceBirthtimeSupport(input); const out=_workspaceEntriesForRender(input).map(x=>x.name); _syncWorkspacePrefsIndicators(ind,dot); @@ -243,7 +276,7 @@ const row={attrs:{},classList:{toggle(){}},setAttribute(k,v){this.attrs[k]=Strin _workspacePrefsMenu={querySelectorAll(sel){return sel==='.workspace-prefs-item--radio'?[row]:[];}}; S.session={workspace:'/one'}; _syncWorkspaceBirthtimeSupportScope(S.session.workspace); -_noteWorkspaceBirthtimeSupport([{name:'seen',type:'file',birthtime_ns:'1752598800000000001'}]); +_noteWorkspaceBirthtimeSupport([{name:'seen',type:'file',workspace_sort_rank:2,birthtime_ns:'1752598800000000001'}]); if(_workspaceCreatedSortAvailable()!==true)process.exit(1); S.session={workspace:'/two'}; _syncWorkspaceBirthtimeSupportScope(S.session.workspace); diff --git a/tests/test_issue6066_workspace_sort_api_payload.py b/tests/test_issue6066_workspace_sort_api_payload.py index e7e06894..a535bf73 100644 --- a/tests/test_issue6066_workspace_sort_api_payload.py +++ b/tests/test_issue6066_workspace_sort_api_payload.py @@ -9,6 +9,7 @@ def test_browser_workspace_entries_stringify_nanosecond_fields(): "name": "newer.txt", "path": "newer.txt", "type": "file", + "workspace_sort_rank": 2, "mtime_ns": 1_752_598_800_000_000_001, "birthtime_ns": 1_752_598_800_000_000_000, }, @@ -16,6 +17,7 @@ def test_browser_workspace_entries_stringify_nanosecond_fields(): "name": "missing.txt", "path": "missing.txt", "type": "file", + "workspace_sort_rank": 1, "mtime_ns": None, "birthtime_ns": None, }, @@ -27,5 +29,7 @@ def test_browser_workspace_entries_stringify_nanosecond_fields(): assert payload[0]["birthtime_ns"] == "1752598800000000000" assert payload[1]["mtime_ns"] is None assert payload[1]["birthtime_ns"] is None + assert payload[0]["workspace_sort_rank"] == 2 + assert payload[1]["workspace_sort_rank"] == 1 assert entries[0]["mtime_ns"] == 1_752_598_800_000_000_001 assert entries[0]["birthtime_ns"] == 1_752_598_800_000_000_000 diff --git a/tests/test_workspace_dir_signature.py b/tests/test_workspace_dir_signature.py index 696acf75..2392c790 100644 --- a/tests/test_workspace_dir_signature.py +++ b/tests/test_workspace_dir_signature.py @@ -24,3 +24,11 @@ def test_directory_signature_can_be_computed_from_supplied_entries(tmp_path): entries = list_dir(tmp_path, ".") assert dir_signature(tmp_path, ".", entries) == dir_signature(tmp_path, ".", entries) + + +def test_directory_signature_ignores_workspace_sort_rank(tmp_path): + (tmp_path / "alpha.txt").write_text("one", encoding="utf-8") + entries = list_dir(tmp_path, ".") + changed = [dict(entry, workspace_sort_rank=1) for entry in entries] + + assert dir_signature(tmp_path, ".", entries) == dir_signature(tmp_path, ".", changed)