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"))