diff --git a/src/gui/_home.py b/src/gui/_home.py index 7e74be4..317f59c 100644 --- a/src/gui/_home.py +++ b/src/gui/_home.py @@ -101,10 +101,28 @@ def _home_page() -> None: # ourselves rather than trusting Streamlit's widget chrome because # the widget's "✕" only mutates widget-state, leaving home_uploads # out of sync. + # + # Two-phase click capture pattern (avoids the "hit-or-miss" click + # losses we had previously): + # + # 1. ``st.button(key=stable_hash)`` returns True on the rerun where + # it was clicked. We use a sha1 hash of the filename as the key + # so it's identifier-safe regardless of spaces / dots / unicode + # in the file name — Streamlit's widget-identity hashing on raw + # filenames was the root cause of inconsistent removals. + # 2. Inside a single pass we collect WHICH file to remove (if any), + # then mutate state ONCE after the loop and rerun. Mutating mid + # -loop while continuing to render other buttons risked + # interleaving widget-key updates with state changes. if home_uploads: + import hashlib st.markdown("**Uploaded files**") + to_remove: str | None = None for name in list(home_uploads.keys()): - col_file, col_remove = st.columns([12, 1]) + digest = hashlib.sha1( + name.encode("utf-8"), usedforsecurity=False, + ).hexdigest()[:10] + col_file, col_remove = st.columns([8, 1]) col_file.markdown( f"📄 `{name}`   " f"" @@ -112,27 +130,32 @@ def _home_page() -> None: unsafe_allow_html=True, ) if col_remove.button( - "✕", - key=f"_home_remove_{name}", + "Remove", + key=f"_home_remove_{digest}", help=f"Remove {name}", + type="secondary", + use_container_width=True, ): - del home_uploads[name] - # Drop any findings/results tied to the removed file. - findings_by_file_drop = st.session_state.get( - "home_findings_by_file", {} - ) - findings_by_file_drop.pop(name, None) - st.session_state["home_uploads"] = home_uploads - st.session_state["home_findings_by_file"] = findings_by_file_drop - # If we just removed the active upload, also clear the - # singular ``home_uploaded_*`` keys so tool pages don't - # pick up stale bytes; the next render will repopulate - # them from whatever file is now first. - if st.session_state.get("home_uploaded_name") == name: - st.session_state.pop("home_uploaded_name", None) - st.session_state.pop("home_uploaded_size", None) - st.session_state.pop("home_uploaded_bytes", None) - st.rerun() + to_remove = name + + if to_remove is not None: + del home_uploads[to_remove] + # Drop any findings/results tied to the removed file. + findings_by_file_drop = st.session_state.get( + "home_findings_by_file", {} + ) + findings_by_file_drop.pop(to_remove, None) + st.session_state["home_uploads"] = home_uploads + st.session_state["home_findings_by_file"] = findings_by_file_drop + # If we just removed the active upload, also clear the + # singular ``home_uploaded_*`` keys so tool pages don't + # pick up stale bytes; the next render will repopulate + # them from whatever file is now first. + if st.session_state.get("home_uploaded_name") == to_remove: + st.session_state.pop("home_uploaded_name", None) + st.session_state.pop("home_uploaded_size", None) + st.session_state.pop("home_uploaded_bytes", None) + st.rerun() if not home_uploads: st.info(t("upload.empty_state"))