From 8cc244e8be4b14109225a44efd8ce477e1650bbe Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 23 Feb 2026 15:52:48 +0100 Subject: [PATCH] Add case-insensitive path resolution for Current Path input Walks each path component and matches against actual directory entries when an exact match fails on Linux. Widget resyncs to the corrected canonical path. Co-Authored-By: Claude Opus 4.6 --- app.py | 5 +++-- tests/test_utils.py | 35 ++++++++++++++++++++++++++++++++++- utils.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index ac674d5..79a3753 100644 --- a/app.py +++ b/app.py @@ -7,6 +7,7 @@ from utils import ( load_config, save_config, load_snippets, save_snippets, load_json, save_json, generate_templates, DEFAULTS, ALLOWED_BASE_DIR, KEY_BATCH_DATA, KEY_PROMPT_HISTORY, KEY_SEQUENCE_NUMBER, + resolve_path_case_insensitive, ) from tab_single import render_single_editor from tab_batch import render_batch_processor @@ -54,8 +55,8 @@ with st.sidebar: def _on_path_change(): new_path = st.session_state.nav_path_input - p = Path(new_path).resolve() - if p.exists() and p.is_dir(): + p = resolve_path_case_insensitive(new_path) + if p is not None and p.is_dir(): st.session_state.current_dir = p st.session_state.config['last_dir'] = str(p) save_config(st.session_state.current_dir, st.session_state.config['favorites']) diff --git a/tests/test_utils.py b/tests/test_utils.py index b38ad9b..2f3bc6f 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -10,7 +10,7 @@ import sys from unittest.mock import MagicMock sys.modules.setdefault("streamlit", MagicMock()) -from utils import load_json, save_json, get_file_mtime, ALLOWED_BASE_DIR, DEFAULTS +from utils import load_json, save_json, get_file_mtime, ALLOWED_BASE_DIR, DEFAULTS, resolve_path_case_insensitive def test_load_json_valid(tmp_path): @@ -66,3 +66,36 @@ def test_get_file_mtime_missing(tmp_path): def test_allowed_base_dir_is_set(): assert ALLOWED_BASE_DIR is not None assert isinstance(ALLOWED_BASE_DIR, Path) + + +class TestResolvePathCaseInsensitive: + def test_exact_match(self, tmp_path): + d = tmp_path / "MyFolder" + d.mkdir() + result = resolve_path_case_insensitive(str(d)) + assert result == d.resolve() + + def test_wrong_case_single_component(self, tmp_path): + d = tmp_path / "MyFolder" + d.mkdir() + wrong = tmp_path / "myfolder" + result = resolve_path_case_insensitive(str(wrong)) + assert result == d.resolve() + + def test_wrong_case_nested(self, tmp_path): + d = tmp_path / "Parent" / "Child" + d.mkdir(parents=True) + wrong = tmp_path / "parent" / "CHILD" + result = resolve_path_case_insensitive(str(wrong)) + assert result == d.resolve() + + def test_no_match_returns_none(self, tmp_path): + result = resolve_path_case_insensitive(str(tmp_path / "nonexistent")) + assert result is None + + def test_file_path(self, tmp_path): + f = tmp_path / "Data.json" + f.write_text("{}") + wrong = tmp_path / "data.JSON" + result = resolve_path_case_insensitive(str(wrong)) + assert result == f.resolve() diff --git a/utils.py b/utils.py index 7b07d4f..7140f01 100644 --- a/utils.py +++ b/utils.py @@ -60,6 +60,40 @@ SNIPPETS_FILE = Path(".editor_snippets.json") # No restriction on directory navigation ALLOWED_BASE_DIR = Path("/").resolve() +def resolve_path_case_insensitive(path: str | Path) -> Path | None: + """Resolve a path with case-insensitive component matching on Linux. + + Walks each component of the path and matches against actual directory + entries when an exact match fails. Returns the corrected Path, or None + if no match is found. + """ + p = Path(path) + if p.exists(): + return p.resolve() + + # Start from the root / anchor + parts = p.resolve().parts # resolve to get absolute parts + built = Path(parts[0]) # root "/" + for component in parts[1:]: + candidate = built / component + if candidate.exists(): + built = candidate + continue + # Case-insensitive scan of the parent directory + try: + lower = component.lower() + match = next( + (entry for entry in built.iterdir() if entry.name.lower() == lower), + None, + ) + except PermissionError: + return None + if match is None: + return None + built = match + return built.resolve() + + def load_config(): """Loads the main editor configuration (Favorites, Last Dir, Servers).""" if CONFIG_FILE.exists():