feat(home): in-card "+ Add more files" replaces Streamlit's dropzone
Mockup §file-add lands as the canonical import affordance:
- Streamlit's ``st.file_uploader`` widget is still mounted (only path
that actually receives browser file events), but parked off-screen
via a new ``[data-testid="stFileUploader"] { position:absolute;
left:-10000px; … pointer-events:none }`` rule. Its hidden
``<input type="file">`` stays reachable to JavaScript.
- The Files card is now always rendered (header + bordered body).
The bottom row of the card is a ``button.dt-file-add`` styled per
mockup §file-add: dashed top border bleeding to the card edges,
surface-hover background, ``+ Add more files`` text in
``--ink-secondary``, accent-fill on hover.
- A small ``<script>`` shipped through ``st.iframe`` wires the
button: ``click → input.click()`` on the off-screen
``stFileUploaderDropzoneInput``. Streamlit's HTML sanitizer
strips inline ``onclick`` from ``unsafe_allow_html`` content, so
the binding has to come from a real script element — same pattern
the sticky footer and Upload→Import rewriter use. A
``MutationObserver`` re-wires the button when Streamlit remounts
it across reruns. The ``dataset.dtWired`` guard prevents double
binding.
Section structure also tightened to match the mockup:
- Section heading is now ``<h2>Files</h2>`` (was ``### Import one
or more files to start``) with the count + total size on the
right of the same flex row. When no files: ``No files imported
yet``. When files exist: ``1 file · 4.8 KB total``.
- Dropped the ``upload.intro_multi`` caption and the
``upload.empty_state`` info banner — the card itself plus the
in-card Add button cover both prompts.
- Empty state now ends after the Files card (no stats / no action
bar / no findings rendered) — matches mockup's single-section
empty view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
254
src/gui/_home.py
254
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 ``<input type="file">`` 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(
|
||||
'<style>[data-testid="stFileUploader"] {'
|
||||
'position:absolute!important;left:-10000px!important;'
|
||||
'width:1px!important;height:1px!important;overflow:hidden!important;'
|
||||
'pointer-events:none!important;}</style>',
|
||||
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(
|
||||
'<div class="dt-files-section-head">'
|
||||
f'<h2>Files</h2>'
|
||||
f'<span class="dt-section-meta">{meta_html}</span>'
|
||||
'</div>',
|
||||
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 = (
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">'
|
||||
'<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>'
|
||||
'<path d="M14 2v6h6"/>'
|
||||
'</svg>'
|
||||
)
|
||||
_PLUS_SVG = (
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">'
|
||||
'<path d="M12 5v14M5 12h14"/>'
|
||||
'</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(
|
||||
'<div class="dt-file-row">'
|
||||
f'<span class="dt-file-icon-chip">{_DOC_SVG}</span>'
|
||||
f'<span class="dt-file-name">{_html.escape(name)}</span>'
|
||||
'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
col_size.markdown(
|
||||
f'<div style="text-align:right;">'
|
||||
f'<span class="dt-file-size">'
|
||||
f'{home_uploads[name]["size"]:,} B'
|
||||
'</span></div>',
|
||||
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(
|
||||
'<div class="dt-files-section-head">'
|
||||
f'<h3>Imported files</h3>'
|
||||
f'<span class="dt-section-meta">{n_files} {files_word}'
|
||||
f' · {_html.escape(total_label)} total</span>'
|
||||
'</div>',
|
||||
'<button class="dt-file-add" type="button">'
|
||||
f'{_PLUS_SVG} Add more files'
|
||||
'</button>',
|
||||
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 = (
|
||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">'
|
||||
'<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>'
|
||||
'<path d="M14 2v6h6"/>'
|
||||
'</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(
|
||||
'<div class="dt-file-row">'
|
||||
f'<span class="dt-file-icon-chip">{_DOC_SVG}</span>'
|
||||
f'<span class="dt-file-name">{_html.escape(name)}</span>'
|
||||
'</div>',
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
col_size.markdown(
|
||||
f'<div style="text-align:right;">'
|
||||
f'<span class="dt-file-size">'
|
||||
f'{home_uploads[name]["size"]:,} B'
|
||||
'</span></div>',
|
||||
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(
|
||||
"""
|
||||
<script>
|
||||
(function () {
|
||||
function wire(doc) {
|
||||
var btn = doc.querySelector('button.dt-file-add');
|
||||
var input = doc.querySelector('[data-testid="stFileUploaderDropzoneInput"]');
|
||||
if (!btn || !input) return;
|
||||
if (btn.dataset.dtWired === '1') return;
|
||||
btn.dataset.dtWired = '1';
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
var doc;
|
||||
try { doc = window.parent.document; }
|
||||
catch (e) { doc = document; }
|
||||
wire(doc);
|
||||
var win = doc.defaultView || window.parent || window;
|
||||
if ('MutationObserver' in win) {
|
||||
var raf = 0;
|
||||
try {
|
||||
new win.MutationObserver(function () {
|
||||
if (raf) return;
|
||||
raf = win.requestAnimationFrame(function () { raf = 0; wire(doc); });
|
||||
}).observe(doc.body, { childList: true, subtree: true });
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
""",
|
||||
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_*``
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user