Add atomic writes, magic string constants, unit tests, type hints, and fix navigation

- save_json() now writes to a temp file then uses os.replace() for atomic writes
- Replace hardcoded "batch_data", "history_tree", "prompt_history", "sequence_number"
  strings with constants (KEY_BATCH_DATA, etc.) across all modules
- Add 29 unit tests for history_tree, utils, and json_loader
- Add type hints to public functions in utils.py, json_loader.py, history_tree.py
- Remove ALLOWED_BASE_DIR restriction that blocked navigating outside app CWD
- Fix path text input not updating on navigation by using session state key
- Add unpin button () for removing pinned folders

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 12:44:31 +01:00
parent 326ae25ab2
commit b02bf124fb
15 changed files with 368 additions and 124 deletions

67
app.py
View File

@@ -5,7 +5,8 @@ from pathlib import Path
# --- Import Custom Modules ---
from utils import (
load_config, save_config, load_snippets, save_snippets,
load_json, save_json, generate_templates, DEFAULTS, ALLOWED_BASE_DIR
load_json, save_json, generate_templates, DEFAULTS, ALLOWED_BASE_DIR,
KEY_BATCH_DATA, KEY_PROMPT_HISTORY,
)
from tab_single import render_single_editor
from tab_batch import render_batch_processor
@@ -47,37 +48,51 @@ with st.sidebar:
st.header("📂 Navigator")
# --- Path Navigator ---
new_path = st.text_input("Current Path", value=str(st.session_state.current_dir))
# Sync widget key with current_dir so the text input always reflects the actual path
if "nav_path_input" not in st.session_state:
st.session_state.nav_path_input = str(st.session_state.current_dir)
new_path = st.text_input("Current Path", key="nav_path_input")
if new_path != str(st.session_state.current_dir):
p = Path(new_path).resolve()
if p.exists() and p.is_dir():
# Restrict navigation to the allowed base directory
try:
p.relative_to(ALLOWED_BASE_DIR)
except ValueError:
st.error(f"Access denied: path must be under {ALLOWED_BASE_DIR}")
else:
st.session_state.current_dir = p
st.session_state.config['last_dir'] = str(p)
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'])
st.rerun()
elif new_path.strip():
st.error(f"Path does not exist or is not a directory: {new_path}")
# --- Favorites System ---
pin_col, unpin_col = st.columns(2)
with pin_col:
if st.button("📌 Pin Folder", use_container_width=True):
if str(st.session_state.current_dir) not in st.session_state.config['favorites']:
st.session_state.config['favorites'].append(str(st.session_state.current_dir))
save_config(st.session_state.current_dir, st.session_state.config['favorites'])
st.rerun()
# --- Favorites System ---
if st.button("📌 Pin Current Folder"):
if str(st.session_state.current_dir) not in st.session_state.config['favorites']:
st.session_state.config['favorites'].append(str(st.session_state.current_dir))
save_config(st.session_state.current_dir, st.session_state.config['favorites'])
favorites = st.session_state.config['favorites']
if favorites:
fav_selection = st.radio(
"Jump to:",
["Select..."] + favorites,
index=0,
label_visibility="collapsed"
)
if fav_selection != "Select..." and fav_selection != str(st.session_state.current_dir):
st.session_state.current_dir = Path(fav_selection)
st.session_state.nav_path_input = fav_selection
st.rerun()
fav_selection = st.radio(
"Jump to:",
["Select..."] + st.session_state.config['favorites'],
index=0,
label_visibility="collapsed"
)
if fav_selection != "Select..." and fav_selection != str(st.session_state.current_dir):
st.session_state.current_dir = Path(fav_selection)
st.rerun()
# Unpin buttons for each favorite
for fav in favorites:
fc1, fc2 = st.columns([4, 1])
fc1.caption(fav)
if fc2.button("", key=f"unpin_{fav}"):
st.session_state.config['favorites'].remove(fav)
save_config(st.session_state.current_dir, st.session_state.config['favorites'])
st.rerun()
st.markdown("---")
@@ -123,7 +138,7 @@ with st.sidebar:
if not new_filename.endswith(".json"): new_filename += ".json"
path = st.session_state.current_dir / new_filename
if is_batch:
data = {"batch_data": []}
data = {KEY_BATCH_DATA: []}
else:
data = DEFAULTS.copy()
if "vace" in new_filename: data.update({"frame_to_skip": 81, "vace schedule": 1, "video file path": ""})
@@ -163,7 +178,7 @@ if selected_file_name:
st.session_state.edit_history_idx = None
# --- AUTO-SWITCH TAB LOGIC ---
is_batch = "batch_data" in data or isinstance(data, list)
is_batch = KEY_BATCH_DATA in data or isinstance(data, list)
if is_batch:
st.session_state.active_tab_name = "🚀 Batch Processor"
else: