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 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 15:52:48 +01:00
parent e841e9b76b
commit 8cc244e8be
3 changed files with 71 additions and 3 deletions

5
app.py
View File

@@ -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'])

View File

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

View File

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