From 249e3460f84bf542d055b9d01e28260ea7d4beb1 Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Thu, 29 Oct 2026 15:08:47 -0700 Subject: [PATCH 1/4] fix(admin): folder list reports unlistable folders and its parent A folder the server can see but not list answered "Server Error" with a logged traceback; it is now a 400 "Permission denied" keyed to the path. Folders sort ignoring case, so dc sorts next to DC, and the listing names its parent folder (null at a filesystem root). ".." stays for the current picker. Co-Authored-By: Claude Opus 5.5 --- codex/serializers/admin/libraries.py | 2 + codex/views/admin/library.py | 14 ++++- tests/test_admin_folder_list.py | 78 ++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 tests/test_admin_folder_list.py diff --git a/codex/serializers/admin/libraries.py b/codex/serializers/admin/libraries.py index 5f2873f40..e020c8edd 100644 --- a/codex/serializers/admin/libraries.py +++ b/codex/serializers/admin/libraries.py @@ -94,6 +94,8 @@ class AdminFolderListSerializer(Serializer): """Get a list of dirs.""" root_folder = CharField(read_only=True) + # Null at a filesystem root, which has no parent. + parent_folder = CharField(read_only=True, allow_null=True) folders = ListField(child=CharField(read_only=True)) diff --git a/codex/views/admin/library.py b/codex/views/admin/library.py index 353a40bdd..b15922354 100644 --- a/codex/views/admin/library.py +++ b/codex/views/admin/library.py @@ -180,7 +180,7 @@ def _get_dirs(root_path, show_hidden) -> tuple[str, ...]: continue if is_dir: subdirs.append(entry.name) - dirs.extend(sorted(subdirs)) + dirs.extend(sorted(subdirs, key=str.casefold)) return tuple(dirs) @staticmethod @@ -234,10 +234,20 @@ def get(self, *_args, **_kwargs) -> Response: dirs = self._get_dirs(root_path, show_hidden) - data = {"root_folder": str(root_path), "folders": dirs} + parent = root_path.parent + data = { + "root_folder": str(root_path), + "parent_folder": str(parent) if parent != root_path else None, + "folders": dirs, + } serializer = self.get_serializer(data) except ValidationError: raise + except PermissionError as exc: + # A folder the server may see but not list. Keyed to the path, + # like "Not a directory", so the envelope carries the reason. + reason = {"path": ["Permission denied"]} + raise ValidationError(reason) from exc except Exception as exc: logger.exception("get admin folder list view") reason = "Server Error" diff --git a/tests/test_admin_folder_list.py b/tests/test_admin_folder_list.py new file mode 100644 index 000000000..09fce2297 --- /dev/null +++ b/tests/test_admin_folder_list.py @@ -0,0 +1,78 @@ +"""The admin folder list the library folder picker browses.""" + +import shutil +from http import HTTPStatus +from pathlib import Path +from typing import Final, override +from unittest.mock import patch + +from django.contrib.auth.models import User +from django.test import Client, TestCase + +from codex.startup import init_admin_flags +from codex.views.admin.library import AdminFolderListView +from tests.tmp_dirs import tmp_dir + +_TEST_PASSWORD: Final = "test-pw-hush-F042" # noqa: S105 +TMP_DIR = tmp_dir("codex.tests.admin_folder_list") +_URL: Final = "/api/v4/admin/folders" + + +class AdminFolderListTestCase(TestCase): + """GET /admin/folders: ordering, parent folder and permission errors.""" + + @override + def setUp(self) -> None: + init_admin_flags() + for name in ("charlie", "Beta", "alpha", ".hidden"): + (TMP_DIR / name).mkdir(parents=True, exist_ok=True) + admin = User.objects.create_user( + username="admin", password=_TEST_PASSWORD, is_staff=True + ) + self.client = Client() + self.client.force_login(admin) + + @override + def tearDown(self) -> None: + shutil.rmtree(TMP_DIR, ignore_errors=True) + + def _get(self, path: str | Path, **params): + return self.client.get(_URL, {"path": str(path), **params}) + + def test_folders_sort_ignoring_case(self) -> None: + """``Beta`` sorts between ``alpha`` and ``charlie``, not first.""" + response = self._get(TMP_DIR) + assert response.status_code == HTTPStatus.OK, response.content + assert response.json()["data"]["folders"] == ["..", "alpha", "Beta", "charlie"] + + def test_hidden_folders_only_on_request(self) -> None: + response = self._get(TMP_DIR, showHidden="true") + assert response.status_code == HTTPStatus.OK, response.content + assert ".hidden" in response.json()["data"]["folders"] + + def test_parent_folder(self) -> None: + response = self._get(TMP_DIR) + body = response.json()["data"] + assert body["rootFolder"] == str(TMP_DIR.resolve()) + assert body["parentFolder"] == str(TMP_DIR.resolve().parent) + + def test_no_parent_folder_at_the_filesystem_root(self) -> None: + response = self._get("/") + assert response.status_code == HTTPStatus.OK, response.content + body = response.json()["data"] + assert body["parentFolder"] is None + assert ".." not in body["folders"] + + def test_an_unlistable_folder_is_a_400(self) -> None: + """Not a logged traceback and a "Server Error".""" + # The view's own listing, not os.scandir: that is the global os + # module, and servestatic scans the static root with it too. + with patch.object( + AdminFolderListView, + "_get_dirs", + side_effect=PermissionError("Operation not permitted"), + ): + response = self._get(TMP_DIR) + assert response.status_code == HTTPStatus.BAD_REQUEST, response.content + errors = response.json()["errors"] + assert errors[0]["detail"] == {"path": ["Permission denied"]}, errors From b9bcffaab42ceaacca59112eaecb474fcdd1ca4a Mon Sep 17 00:00:00 2001 From: AJ Slater Date: Thu, 29 Oct 2026 15:08:47 -0700 Subject: [PATCH 2/4] feat(admin): folder tree picker for Add Library Experiment. A VTreeview rooted at the server's default folder replaces the combobox picker: one click chooses and opens a folder, a Parent folder button climbs while keeping what was loaded, Enter on a typed path re-roots there, and Show Hidden Folders refetches. Folders an existing library covers are disabled and say so. Nothing is chosen on open, since the default folder is the newest library's parent. Past 200 children the rest collapse into a "type the path" leaf, which keeps a 5,000-folder directory opening in about 0.6s. The old picker stays until the go/no-go. Co-Authored-By: Claude Opus 5.5 --- .../library-create-update-inputs.vue | 24 +- .../admin/create-update-dialog/path-parent.js | 21 ++ .../server-folder-tree.vue | 257 ++++++++++++++++++ frontend/src/stores/admin.js | 19 +- frontend/src/stores/common.js | 4 +- .../tests/unit/server-folder-tree.test.js | 218 +++++++++++++++ 6 files changed, 520 insertions(+), 23 deletions(-) create mode 100644 frontend/src/components/admin/create-update-dialog/path-parent.js create mode 100644 frontend/src/components/admin/create-update-dialog/server-folder-tree.vue create mode 100644 frontend/tests/unit/server-folder-tree.test.js diff --git a/frontend/src/components/admin/create-update-dialog/library-create-update-inputs.vue b/frontend/src/components/admin/create-update-dialog/library-create-update-inputs.vue index 20dfc68c5..b885fbdac 100644 --- a/frontend/src/components/admin/create-update-dialog/library-create-update-inputs.vue +++ b/frontend/src/components/admin/create-update-dialog/library-create-update-inputs.vue @@ -1,6 +1,6 @@