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,
|
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"}].
|
# Source of truth for uploaded files. dict[name -> {"bytes", "size"}].
|
||||||
home_uploads: dict = st.session_state.setdefault("home_uploads", {})
|
home_uploads: dict = st.session_state.setdefault("home_uploads", {})
|
||||||
|
|
||||||
# File uploader — syncs into home_uploads via on_change. We deliberately
|
# Streamlit's file_uploader is the only path that actually receives
|
||||||
# do NOT merge widget state into home_uploads at render time: navigation
|
# bytes from the browser, but we don't want its dropzone UI to
|
||||||
# can remount the widget with value ``[]``, and a render-time merge
|
# compete with the in-card "Add more files" button below. Park the
|
||||||
# would mistakenly leave home_uploads untouched while the user thinks
|
# whole widget off-screen via the ``dt-fileuploader-offscreen``
|
||||||
# they're looking at empty state.
|
# 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
|
# ``on_change`` fires ONLY on user-initiated value changes (uploads
|
||||||
# and the widget's built-in "✕" remove). It does NOT fire on the
|
# and the widget's built-in "✕" remove). It does NOT fire on the
|
||||||
# remount-induced reset. That lets us treat the callback as ground
|
# remount-induced reset. That lets us treat the callback as ground
|
||||||
# truth for both adds AND removes — fixing the previous bug where
|
# truth for both adds AND removes.
|
||||||
# the widget's "✕" appeared to do nothing because the file persisted
|
st.markdown(
|
||||||
# in home_uploads and immediately re-rendered in the list below.
|
'<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(
|
st.file_uploader(
|
||||||
t("upload.uploader_label_multi"),
|
t("upload.uploader_label_multi"),
|
||||||
type=["csv", "tsv", "xlsx", "xls"],
|
type=["csv", "tsv", "xlsx", "xls"],
|
||||||
@@ -211,108 +215,162 @@ def _home_page() -> None:
|
|||||||
key="home_upload",
|
key="home_upload",
|
||||||
help=t("upload.uploader_help"),
|
help=t("upload.uploader_help"),
|
||||||
on_change=_sync_uploader_to_home_uploads,
|
on_change=_sync_uploader_to_home_uploads,
|
||||||
|
label_visibility="collapsed",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Persistent file list with per-file remove buttons. We render this
|
# ``Files`` section header — count + total size on the right, or
|
||||||
# ourselves rather than trusting Streamlit's widget chrome because
|
# "No files imported yet" when empty (mockup §section-head).
|
||||||
# the widget's "✕" only mutates widget-state, leaving home_uploads
|
import hashlib
|
||||||
# out of sync.
|
n_files = len(home_uploads)
|
||||||
#
|
if n_files:
|
||||||
# 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).
|
|
||||||
total_bytes = sum(meta["size"] for meta in home_uploads.values())
|
total_bytes = sum(meta["size"] for meta in home_uploads.values())
|
||||||
if total_bytes >= 1024:
|
if total_bytes >= 1024:
|
||||||
total_label = f"{total_bytes / 1024:.1f} KB"
|
total_label = f"{total_bytes / 1024:.1f} KB"
|
||||||
else:
|
else:
|
||||||
total_label = f"{total_bytes:,} B"
|
total_label = f"{total_bytes:,} B"
|
||||||
n_files = len(home_uploads)
|
|
||||||
files_word = "file" if n_files == 1 else "files"
|
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(
|
st.markdown(
|
||||||
'<div class="dt-files-section-head">'
|
'<button class="dt-file-add" type="button">'
|
||||||
f'<h3>Imported files</h3>'
|
f'{_PLUS_SVG} Add more files'
|
||||||
f'<span class="dt-section-meta">{n_files} {files_word}'
|
'</button>',
|
||||||
f' · {_html.escape(total_label)} total</span>'
|
|
||||||
'</div>',
|
|
||||||
unsafe_allow_html=True,
|
unsafe_allow_html=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
to_remove: str | None = None
|
# Wire the in-card "Add more files" button to the off-screen
|
||||||
# Document-icon SVG used by every file row's icon chip
|
# ``stFileUploaderDropzoneInput`` (Streamlit strips inline
|
||||||
# (mockup §file-row .file-icon).
|
# ``onclick`` attributes; we have to do the binding from a real
|
||||||
_DOC_SVG = (
|
# script element, which Streamlit only ships through component
|
||||||
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor">'
|
# iframes — same pattern as the sticky footer + Upload→Import
|
||||||
'<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>'
|
# rewriter). A ``MutationObserver`` re-wires after reruns when
|
||||||
'<path d="M14 2v6h6"/>'
|
# Streamlit remounts the button.
|
||||||
'</svg>'
|
st.iframe(
|
||||||
)
|
"""
|
||||||
with st.container(border=True):
|
<script>
|
||||||
for name in list(home_uploads.keys()):
|
(function () {
|
||||||
digest = hashlib.sha1(
|
function wire(doc) {
|
||||||
name.encode("utf-8"), usedforsecurity=False,
|
var btn = doc.querySelector('button.dt-file-add');
|
||||||
).hexdigest()[:10]
|
var input = doc.querySelector('[data-testid="stFileUploaderDropzoneInput"]');
|
||||||
col_name, col_size, col_x = st.columns([8, 1.6, 0.55])
|
if (!btn || !input) return;
|
||||||
col_name.markdown(
|
if (btn.dataset.dtWired === '1') return;
|
||||||
'<div class="dt-file-row">'
|
btn.dataset.dtWired = '1';
|
||||||
f'<span class="dt-file-icon-chip">{_DOC_SVG}</span>'
|
btn.addEventListener('click', function (e) {
|
||||||
f'<span class="dt-file-name">{_html.escape(name)}</span>'
|
e.preventDefault();
|
||||||
'</div>',
|
input.click();
|
||||||
unsafe_allow_html=True,
|
});
|
||||||
)
|
}
|
||||||
col_size.markdown(
|
var doc;
|
||||||
f'<div style="text-align:right;">'
|
try { doc = window.parent.document; }
|
||||||
f'<span class="dt-file-size">'
|
catch (e) { doc = document; }
|
||||||
f'{home_uploads[name]["size"]:,} B'
|
wire(doc);
|
||||||
'</span></div>',
|
var win = doc.defaultView || window.parent || window;
|
||||||
unsafe_allow_html=True,
|
if ('MutationObserver' in win) {
|
||||||
)
|
var raf = 0;
|
||||||
if col_x.button(
|
try {
|
||||||
"✕",
|
new win.MutationObserver(function () {
|
||||||
key=f"_home_remove_{digest}",
|
if (raf) return;
|
||||||
help=f"Remove {name}",
|
raf = win.requestAnimationFrame(function () { raf = 0; wire(doc); });
|
||||||
type="tertiary",
|
}).observe(doc.body, { childList: true, subtree: true });
|
||||||
):
|
} catch (e) {}
|
||||||
to_remove = name
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
""",
|
||||||
|
height=1,
|
||||||
|
)
|
||||||
|
|
||||||
if to_remove is not None:
|
if to_remove is not None:
|
||||||
from src.audit import log_event
|
from src.audit import log_event
|
||||||
log_event(
|
log_event(
|
||||||
"upload",
|
"upload",
|
||||||
f"Removed {to_remove}",
|
f"Removed {to_remove}",
|
||||||
filename=to_remove,
|
filename=to_remove,
|
||||||
)
|
)
|
||||||
del home_uploads[to_remove]
|
del home_uploads[to_remove]
|
||||||
# Drop any findings/results tied to the removed file.
|
# Drop any findings/results tied to the removed file.
|
||||||
findings_by_file_drop = st.session_state.get(
|
findings_by_file_drop = st.session_state.get(
|
||||||
"home_findings_by_file", {}
|
"home_findings_by_file", {}
|
||||||
)
|
)
|
||||||
findings_by_file_drop.pop(to_remove, None)
|
findings_by_file_drop.pop(to_remove, None)
|
||||||
st.session_state["home_uploads"] = home_uploads
|
st.session_state["home_uploads"] = home_uploads
|
||||||
st.session_state["home_findings_by_file"] = findings_by_file_drop
|
st.session_state["home_findings_by_file"] = findings_by_file_drop
|
||||||
# If we just removed the active upload, also clear the
|
# If we just removed the active upload, also clear the
|
||||||
# singular ``home_uploaded_*`` keys so tool pages don't
|
# singular ``home_uploaded_*`` keys so tool pages don't
|
||||||
# pick up stale bytes; the next render will repopulate
|
# pick up stale bytes; the next render will repopulate
|
||||||
# them from whatever file is now first.
|
# them from whatever file is now first.
|
||||||
if st.session_state.get("home_uploaded_name") == to_remove:
|
if st.session_state.get("home_uploaded_name") == to_remove:
|
||||||
st.session_state.pop("home_uploaded_name", None)
|
st.session_state.pop("home_uploaded_name", None)
|
||||||
st.session_state.pop("home_uploaded_size", None)
|
st.session_state.pop("home_uploaded_size", None)
|
||||||
st.session_state.pop("home_uploaded_bytes", None)
|
st.session_state.pop("home_uploaded_bytes", None)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
|
|
||||||
if not home_uploads:
|
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
|
return
|
||||||
|
|
||||||
# Expose the first uploaded file via the singular ``home_uploaded_*``
|
# 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";
|
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) ---------- */
|
/* ---------- Findings — per-file group cards (mockup §findings) ---------- */
|
||||||
.dt-finding-group-head {
|
.dt-finding-group-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user