diff --git a/src/gui/_home.py b/src/gui/_home.py
index 34e6548..7e74be4 100644
--- a/src/gui/_home.py
+++ b/src/gui/_home.py
@@ -18,8 +18,41 @@ from __future__ import annotations
import streamlit as st
+class _StashedUpload:
+ """Duck-types Streamlit's ``UploadedFile`` so ``_run_analysis_on_upload``
+ accepts entries restored from session-state without changes. Exposes
+ ``.name``, ``.size``, and ``.getvalue()`` — the contract used by the
+ analyzer's read path.
+ """
+
+ __slots__ = ("name", "size", "_data")
+
+ def __init__(self, name: str, data: bytes) -> None:
+ self.name = name
+ self.size = len(data)
+ self._data = data
+
+ def getvalue(self) -> bytes:
+ return self._data
+
+
def _home_page() -> None:
- """Render the home page — multi-file upload + per-file analysis."""
+ """Render the home page — multi-file upload + per-file analysis.
+
+ Uploaded files live in ``st.session_state["home_uploads"]`` (a
+ dict keyed by filename), NOT in the widget's transient state.
+ Streamlit's ``st.file_uploader`` widget gets unmounted when the
+ user navigates away to a tool page, and its ``UploadedFile``
+ objects don't always re-attach on remount — so we capture the
+ bytes into our own session-state stash on first sight and treat
+ that stash as the source of truth for everything downstream
+ (active-file pickup, analysis, findings rendering).
+
+ Removing a file: per-row "✕" buttons next to each uploaded
+ filename. Clearing findings: the "Clear results" button only
+ wipes the analysis cache, not the upload stash — the files
+ persist until the user explicitly removes them.
+ """
from src.gui.components import hide_streamlit_chrome, render_findings_panel
from src.gui.components._legacy import _run_analysis_on_upload
from src.i18n import t
@@ -38,45 +71,98 @@ def _home_page() -> None:
st.markdown(f"### {t('upload.heading')}")
st.caption(t("upload.intro_multi"))
- uploaded_files = st.file_uploader(
+ # Source of truth for uploaded files. dict[name -> {"bytes", "size"}].
+ home_uploads: dict = st.session_state.setdefault("home_uploads", {})
+
+ # File uploader — for ADDING new files only. On every render we
+ # merge widget-returned files INTO home_uploads but never remove
+ # via the widget. (Widget state can return ``[]`` after navigation,
+ # which we deliberately don't treat as "user cleared their files".)
+ new_files = st.file_uploader(
t("upload.uploader_label_multi"),
type=["csv", "tsv", "xlsx", "xls"],
accept_multiple_files=True,
key="home_upload",
help=t("upload.uploader_help"),
)
+ if new_files:
+ changed = False
+ for f in new_files:
+ if f.name not in home_uploads:
+ home_uploads[f.name] = {
+ "bytes": f.getvalue(),
+ "size": f.size,
+ }
+ changed = True
+ if changed:
+ st.session_state["home_uploads"] = home_uploads
- if not uploaded_files:
+ # Persistent file list with per-file remove buttons. We render this
+ # ourselves rather than trusting Streamlit's widget chrome because
+ # the widget's "✕" only mutates widget-state, leaving home_uploads
+ # out of sync.
+ if home_uploads:
+ st.markdown("**Uploaded files**")
+ for name in list(home_uploads.keys()):
+ col_file, col_remove = st.columns([12, 1])
+ col_file.markdown(
+ f"📄 `{name}` "
+ f""
+ f"({home_uploads[name]['size']:,} bytes)",
+ unsafe_allow_html=True,
+ )
+ if col_remove.button(
+ "✕",
+ key=f"_home_remove_{name}",
+ help=f"Remove {name}",
+ ):
+ 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()
+
+ if not home_uploads:
st.info(t("upload.empty_state"))
return
- # Keep tool pages working: they consume a single ``home_uploaded_*``
- # set via ``pickup_or_upload``. Expose the first uploaded file as
- # the "active" upload for that contract; the rest live alongside
- # for per-file analysis on this page.
- first = uploaded_files[0]
+ # Expose the first uploaded file via the singular ``home_uploaded_*``
+ # session keys so tool pages reached via "Open " still find an
+ # active upload through ``pickup_or_upload``.
+ first_name = next(iter(home_uploads))
+ first_meta = home_uploads[first_name]
if (
- st.session_state.get("home_uploaded_name") != first.name
- or st.session_state.get("home_uploaded_size") != first.size
+ st.session_state.get("home_uploaded_name") != first_name
+ or st.session_state.get("home_uploaded_size") != first_meta["size"]
):
- st.session_state["home_uploaded_name"] = first.name
- st.session_state["home_uploaded_size"] = first.size
- st.session_state["home_uploaded_bytes"] = first.getvalue()
+ st.session_state["home_uploaded_name"] = first_name
+ st.session_state["home_uploaded_size"] = first_meta["size"]
+ st.session_state["home_uploaded_bytes"] = first_meta["bytes"]
- # Per-file findings live in a dict so removing a file from the
- # uploader (Streamlit's "x" button) drops its results too. We only
- # re-analyze files we haven't already analyzed in this session.
+ # Findings cache — drop entries whose underlying file is no longer
+ # in the stash (e.g. user just clicked "✕").
findings_by_file: dict = st.session_state.setdefault(
"home_findings_by_file", {}
)
- current_names = {f.name for f in uploaded_files}
findings_by_file = {
name: result for name, result in findings_by_file.items()
- if name in current_names
+ if name in home_uploads
}
st.session_state["home_findings_by_file"] = findings_by_file
- pending = [f for f in uploaded_files if f.name not in findings_by_file]
+ pending = [name for name in home_uploads if name not in findings_by_file]
col_run, col_clear, _ = st.columns([1, 1, 4])
with col_run:
@@ -101,31 +187,28 @@ def _home_page() -> None:
if run_clicked:
progress = st.progress(0.0, text=t("upload.scanning"))
- for i, f in enumerate(pending, start=1):
- findings_by_file[f.name] = _run_analysis_on_upload(f)
- progress.progress(i / len(pending), text=f"{f.name}")
+ for i, name in enumerate(pending, start=1):
+ stashed = _StashedUpload(name, home_uploads[name]["bytes"])
+ findings_by_file[name] = _run_analysis_on_upload(stashed)
+ progress.progress(i / len(pending), text=name)
st.session_state["home_findings_by_file"] = findings_by_file
progress.empty()
st.rerun()
if findings_by_file:
st.divider()
- # Preserve uploader order so the user sees results in the same
- # order they appear in the file list above. Each file's findings
- # render via ``render_findings_panel`` so the per-tool grouping
- # (and the "Open " jump link under each group) is kept —
- # that's how the user reaches the cleaner that fixes a specific
- # finding without hunting through the sidebar.
- for f in uploaded_files:
- if f.name not in findings_by_file:
+ # Preserve the upload-stash order so the user sees results in
+ # the same order they appear in the file list above.
+ for name in home_uploads:
+ if name not in findings_by_file:
continue
- findings = findings_by_file[f.name]
+ findings = findings_by_file[name]
with st.container(border=True):
if not findings:
- st.markdown(f"### 📄 {f.name}")
+ st.markdown(f"### 📄 {name}")
st.success(t("findings.none"))
else:
- render_findings_panel(findings, header=f"📄 {f.name}")
+ render_findings_panel(findings, header=f"📄 {name}")
st.divider()
st.caption(t("chrome.footer"))