diff --git a/src/gui/_home.py b/src/gui/_home.py index 0f5d0e8..5f7e12d 100644 --- a/src/gui/_home.py +++ b/src/gui/_home.py @@ -186,24 +186,28 @@ def _home_page() -> None: unsafe_allow_html=True, ) - st.markdown(f"### {t('upload.heading')}") - st.caption(t("upload.intro_multi")) - # Source of truth for uploaded files. dict[name -> {"bytes", "size"}]. home_uploads: dict = st.session_state.setdefault("home_uploads", {}) - # File uploader — syncs into home_uploads via on_change. We deliberately - # do NOT merge widget state into home_uploads at render time: navigation - # can remount the widget with value ``[]``, and a render-time merge - # would mistakenly leave home_uploads untouched while the user thinks - # they're looking at empty state. + # Streamlit's file_uploader is the only path that actually receives + # bytes from the browser, but we don't want its dropzone UI to + # compete with the in-card "Add more files" button below. Park the + # whole widget off-screen via the ``dt-fileuploader-offscreen`` + # CSS rule (declared in ``_DESIGN_TOKENS_CSS``) while keeping the + # underlying ```` reachable to JS — the Add + # button programmatically clicks it to open the OS file picker. # # ``on_change`` fires ONLY on user-initiated value changes (uploads # and the widget's built-in "✕" remove). It does NOT fire on the # remount-induced reset. That lets us treat the callback as ground - # truth for both adds AND removes — fixing the previous bug where - # the widget's "✕" appeared to do nothing because the file persisted - # in home_uploads and immediately re-rendered in the list below. + # truth for both adds AND removes. + st.markdown( + '', + unsafe_allow_html=True, + ) st.file_uploader( t("upload.uploader_label_multi"), type=["csv", "tsv", "xlsx", "xls"], @@ -211,108 +215,162 @@ def _home_page() -> None: key="home_upload", help=t("upload.uploader_help"), on_change=_sync_uploader_to_home_uploads, + label_visibility="collapsed", ) - # 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. - # - # 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 - # Files-card head: count + total size (mockup §section-head). + # ``Files`` section header — count + total size on the right, or + # "No files imported yet" when empty (mockup §section-head). + import hashlib + n_files = len(home_uploads) + if n_files: total_bytes = sum(meta["size"] for meta in home_uploads.values()) if total_bytes >= 1024: total_label = f"{total_bytes / 1024:.1f} KB" else: total_label = f"{total_bytes:,} B" - n_files = len(home_uploads) files_word = "file" if n_files == 1 else "files" + meta_html = ( + f'{n_files} {files_word} · {_html.escape(total_label)} total' + ) + else: + meta_html = "No files imported yet" + st.markdown( + '
' + f'

Files

' + f'{meta_html}' + '
', + unsafe_allow_html=True, + ) + + # Files card — always rendered. Body is file rows (if any) + the + # in-card "Add more files" button that triggers the off-screen + # file_uploader. Two-phase click capture for the X buttons: walk + # all rows once, accumulate ``to_remove`` if any was clicked, + # then mutate state + rerun ONCE after the loop. + to_remove: str | None = None + _DOC_SVG = ( + '' + '' + '' + '' + ) + _PLUS_SVG = ( + '' + '' + '' + ) + with st.container(border=True): + for name in list(home_uploads.keys()): + digest = hashlib.sha1( + name.encode("utf-8"), usedforsecurity=False, + ).hexdigest()[:10] + col_name, col_size, col_x = st.columns([8, 1.6, 0.55]) + col_name.markdown( + '
' + f'{_DOC_SVG}' + f'{_html.escape(name)}' + '
', + unsafe_allow_html=True, + ) + col_size.markdown( + f'
' + f'' + f'{home_uploads[name]["size"]:,} B' + '
', + unsafe_allow_html=True, + ) + if col_x.button( + "✕", + key=f"_home_remove_{digest}", + help=f"Remove {name}", + type="tertiary", + ): + to_remove = name + # In-card "Add more files" — clicks the (off-screen) + # ``stFileUploaderDropzoneInput`` so the OS file picker opens. + # Inline ``onclick`` would be cleanest but Streamlit's HTML + # sanitizer strips event-handler attributes from + # ``unsafe_allow_html`` content; the wiring is done from + # ``_ADD_FILES_BUTTON_JS`` further down via ``st.iframe``. st.markdown( - '
' - f'

Imported files

' - f'{n_files} {files_word}' - f' · {_html.escape(total_label)} total' - '
', + '', unsafe_allow_html=True, ) - to_remove: str | None = None - # Document-icon SVG used by every file row's icon chip - # (mockup §file-row .file-icon). - _DOC_SVG = ( - '' - '' - '' - '' - ) - with st.container(border=True): - for name in list(home_uploads.keys()): - digest = hashlib.sha1( - name.encode("utf-8"), usedforsecurity=False, - ).hexdigest()[:10] - col_name, col_size, col_x = st.columns([8, 1.6, 0.55]) - col_name.markdown( - '
' - f'{_DOC_SVG}' - f'{_html.escape(name)}' - '
', - unsafe_allow_html=True, - ) - col_size.markdown( - f'
' - f'' - f'{home_uploads[name]["size"]:,} B' - '
', - unsafe_allow_html=True, - ) - if col_x.button( - "✕", - key=f"_home_remove_{digest}", - help=f"Remove {name}", - type="tertiary", - ): - to_remove = name + # Wire the in-card "Add more files" button to the off-screen + # ``stFileUploaderDropzoneInput`` (Streamlit strips inline + # ``onclick`` attributes; we have to do the binding from a real + # script element, which Streamlit only ships through component + # iframes — same pattern as the sticky footer + Upload→Import + # rewriter). A ``MutationObserver`` re-wires after reruns when + # Streamlit remounts the button. + st.iframe( + """ + +""", + height=1, + ) - if to_remove is not None: - from src.audit import log_event - log_event( - "upload", - f"Removed {to_remove}", - filename=to_remove, - ) - 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 to_remove is not None: + from src.audit import log_event + log_event( + "upload", + f"Removed {to_remove}", + filename=to_remove, + ) + 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")) + # Empty state — page ends cleanly after the Files card. The + # in-card "Add more files" button is the only affordance the + # user needs; the old ``upload.empty_state`` info alert was + # redundant and out of step with the mockup. return # Expose the first uploaded file via the singular ``home_uploaded_*`` diff --git a/src/gui/components/_legacy.py b/src/gui/components/_legacy.py index 8c22c1c..1c5ab98 100644 --- a/src/gui/components/_legacy.py +++ b/src/gui/components/_legacy.py @@ -520,6 +520,61 @@ div[data-testid="stContainer"][data-border="true"] { font-feature-settings: "ss02"; } +/* "+ Add more files" — last row of the files card (mockup §file-add). + The button stays in the document; ``onclick`` triggers a programmatic + click on Streamlit's (off-screen) file_uploader input so the OS file + picker opens. Negative margins bleed the button to the card edges so + the dashed top-border and corner radii match the surrounding card + chrome. */ +.dt-file-add { + display: flex !important; + align-items: center; + justify-content: center; + gap: 8px; + width: calc(100% + 2rem); + padding: 12px 16px; + background: var(--surface-hover); + border: none; + border-top: 1px dashed var(--border-strong); + border-radius: 0 0 var(--r-lg) var(--r-lg); + cursor: pointer; + font-family: var(--font-sans) !important; + font-size: 13px !important; + font-weight: 500 !important; + color: var(--ink-secondary) !important; + margin: 14px -1rem -1rem; + line-height: 1; + transition: background 0.12s ease, color 0.12s ease; +} +.dt-file-add:hover { + background: var(--accent-fill); + color: var(--accent) !important; +} +.dt-file-add svg { + width: 14px; height: 14px; + stroke-width: 2; +} + +/* Empty-state placeholder centered in the empty files card. */ +.dt-files-empty { + margin: 8px 0 4px !important; + text-align: center; + color: var(--ink-tertiary) !important; + font-size: 13px; +} + +/* Streamlit's file_uploader is rendered off-screen so the OS file + picker stays wired up to our in-card "Add more files" button — its + input element is still reachable via JS ``.click()``. */ +.dt-fileuploader-offscreen [data-testid="stFileUploader"] { + position: absolute !important; + left: -10000px !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + pointer-events: none !important; +} + /* ---------- Findings — per-file group cards (mockup §findings) ---------- */ .dt-finding-group-head { display: flex;