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():