feat(gui): one-click Close in its own bottom sidebar section

Close is now a direct shutdown trigger: visiting the Close page (the
sidebar entry) fires shutdown_app() immediately — no confirm step, no
intermediate body. The farewell overlay paints and os._exit(0) lands
~1s later from a daemon thread.

Layout: Close moved into its own bottom-of-sidebar section so the
destructive action is visually separated from Account/Activate.

- New shutdown_app() in components/_legacy.py replaces quit_button.
  os._exit thread is skipped when "pytest" is in sys.modules so the
  test suite doesn't suicide on rendering 99_Close.
- pages/99_Close.py shrinks to set_page_config + chrome + shutdown_app.
- app.py nav grows a new "Close" section header (new
  nav.section_close key in en/es packs) pinned at the bottom of the
  navigation dict.

Tests updated:
- TestQuitButtonRenders → TestClosePageShutsDownImmediately.
  Assert the shutdown caption renders + no confirm button exists.
- test_smoke EXPECTED_SUBSTRINGS["99_Close"] now pins
  "Shutting down" / "Cerrando" (the visible page body) instead of
  the removed page title.

2008 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 20:17:14 +00:00
parent ff2eaeb6c4
commit c568aec8a7
8 changed files with 59 additions and 63 deletions

View File

@@ -198,12 +198,17 @@ def _build_navigation() -> dict[str, list]:
) )
account_header = _t("nav.section_account") or "Account" account_header = _t("nav.section_account") or "Account"
close_header = _t("nav.section_close") or "Close"
# Close lives in its own section pinned at the very bottom of the
# sidebar — its own click is the shutdown, so we visually separate
# it from the navigable pages above to reduce mis-click risk.
return { return {
"": [home], "": [home],
section_label("cleaners"): by_section["cleaners"], section_label("cleaners"): by_section["cleaners"],
section_label("transformations"): by_section["transformations"], section_label("transformations"): by_section["transformations"],
section_label("automations"): by_section["automations"], section_label("automations"): by_section["automations"],
account_header: [activate, close], account_header: [activate],
close_header: [close],
} }

View File

@@ -47,7 +47,7 @@ from .activation import ( # noqa: F401 re-exported
__all__ = [ __all__ = [
# Shared chrome / pickup # Shared chrome / pickup
"hide_streamlit_chrome", "hide_streamlit_chrome",
"quit_button", "shutdown_app",
"pickup_or_upload", "pickup_or_upload",
# License gate + activation form # License gate + activation form
"render_activation_form", "render_activation_form",

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import io import io
import os import os
import sys
import threading import threading
import time import time
from typing import Optional from typing import Optional
@@ -186,37 +187,38 @@ def _farewell_script() -> str:
) )
def quit_button(label: str | None = None, *, key: str = "quit_app_button") -> None: def shutdown_app() -> None:
"""Render a Quit button that terminates the Streamlit server. """Terminate the Streamlit server immediately, no confirm.
Designed to be called from a page whose mere act of rendering means
the user wants to quit (e.g., the sidebar Close entry). Schedules
``os._exit(0)`` on a daemon thread so the process terminates after
the farewell overlay has had a chance to paint, then injects the
overlay JS and short-circuits the rest of the page via ``st.stop``.
Streamlit has no first-class shutdown hook, and signalling the Streamlit has no first-class shutdown hook, and signalling the
process (SIGTERM/SIGINT) does not reliably terminate it — Streamlit process (SIGTERM/SIGINT) does not reliably terminate it — Streamlit
installs its own handlers and the tornado/asyncio loop swallows or installs its own handlers and the tornado/asyncio loop swallows or
defers the signal, so the browser sees the websocket drop while the defers the signal, so the browser sees the websocket drop while the
python process stays alive. We schedule ``os._exit(0)`` on a daemon python process stays alive. ``os._exit`` is the only reliable kill.
thread to hard-kill the process, and inject a small JS shim that
either closes the tab (when allowed) or replaces the top frame with The hard-exit thread is skipped under pytest so the test suite does
a self-contained "App closed" page so the user never sees not suicide when a test renders this page. The overlay + caption
Streamlit's red connection-error overlay. still render so test assertions about content work.
""" """
if label is None: if not st.session_state.get("_app_shutting_down"):
label = _t("quit.button")
if st.session_state.get("_app_shutting_down"):
from streamlit.components.v1 import html as _components_html
_components_html(_farewell_script(), height=0)
st.success(_t("quit.shutting_down"))
st.stop()
if st.button(label, key=key, type="secondary"):
st.session_state["_app_shutting_down"] = True st.session_state["_app_shutting_down"] = True
if "pytest" not in sys.modules:
def _hard_exit() -> None: def _hard_exit() -> None:
time.sleep(1.0) time.sleep(1.0)
os._exit(0) os._exit(0)
threading.Thread(target=_hard_exit, daemon=True).start() threading.Thread(target=_hard_exit, daemon=True).start()
st.rerun()
from streamlit.components.v1 import html as _components_html
_components_html(_farewell_script(), height=0)
st.success(_t("quit.shutting_down"))
st.stop()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -1,9 +1,9 @@
"""Close — shut the DataTools server down cleanly. """Close — shut the DataTools server down immediately on visit.
Lives in the sidebar nav alongside the tool pages so users have a Sidebar nav entry. Clicking it routes here, and the page's render
discoverable way to terminate the local Streamlit process without fires :func:`shutdown_app` directly — there is no confirm step. The
having to Ctrl+C in the shell. An explicit confirm step prevents an browser sees the farewell overlay; the Python process terminates a
accidental sidebar click from killing a session mid-work. moment later via ``os._exit``.
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,7 +17,7 @@ _project_root = Path(__file__).resolve().parent.parent.parent.parent
if str(_project_root) not in sys.path: if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root)) sys.path.insert(0, str(_project_root))
from src.gui.components import hide_streamlit_chrome, quit_button from src.gui.components import hide_streamlit_chrome, shutdown_app
from src.i18n import t from src.i18n import t
st.set_page_config( st.set_page_config(
@@ -25,14 +25,6 @@ st.set_page_config(
page_icon="🛑", page_icon="🛑",
layout="wide", layout="wide",
) )
hide_streamlit_chrome() hide_streamlit_chrome()
st.title(t("close_page.title")) shutdown_app()
st.caption(t("close_page.caption"))
st.divider()
st.markdown(t("close_page.body"))
st.write("")
quit_button(label=t("close_page.button"), key="quit_app_button_page")

View File

@@ -148,6 +148,7 @@
"home_page_title": "Home", "home_page_title": "Home",
"section_account": "Account", "section_account": "Account",
"activate_title": "Activate", "activate_title": "Activate",
"close_title": "Close" "close_title": "Close",
"section_close": "Close"
} }
} }

View File

@@ -148,6 +148,7 @@
"home_page_title": "Inicio", "home_page_title": "Inicio",
"section_account": "Cuenta", "section_account": "Cuenta",
"activate_title": "Activar", "activate_title": "Activar",
"close_title": "Cerrar" "close_title": "Cerrar",
"section_close": "Cerrar"
} }
} }

View File

@@ -122,38 +122,33 @@ class TestLocalizedChrome:
# Quit / Close page # Quit / Close page
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class TestQuitButtonRenders: class TestClosePageShutsDownImmediately:
"""The Close page must show the localized title, body, and the """The Close page no longer renders a confirm button — visiting the
Close-the-app button. We don't actually click the button — that page IS the close action. Under pytest the ``os._exit`` thread is
would call ``os._exit(0)`` and kill the test process. We only skipped (so the test runner doesn't suicide), but the farewell
assert the button is present and its label is localized.""" success caption still renders and we assert against it."""
def test_close_page_english(self, app_factory): def test_english_close_renders_shutdown_message(self, app_factory):
app = app_factory("99_Close") app = app_factory("99_Close")
app.run() app.run()
text = collected_text(app) text = collected_text(app)
assert "Close DataTools" in text assert "Shutting down" in text or "Goodbye" in text, (
f"Close page missing shutdown caption; got:\n{text[:300]}"
)
# No confirm button — the page is the action.
labels = [b.label for b in app.button] labels = [b.label for b in app.button]
assert any("Close the app" in lbl for lbl in labels), ( assert not any("Close the app" in lbl for lbl in labels), (
f"Close-the-app button missing; buttons: {labels}" f"Close page should not render a confirm button; got: {labels}"
) )
def test_close_page_spanish(self, app_factory): def test_spanish_close_renders_localized_shutdown_message(self, app_factory):
app = app_factory("99_Close") app = app_factory("99_Close")
with_language(app, "es") with_language(app, "es")
app.run() app.run()
text = collected_text(app) text = collected_text(app)
assert "Cerrar DataTools" in text # ``quit.shutting_down`` in the es pack.
labels = [b.label for b in app.button] assert "Cerrando" in text or "Apagando" in text or "Adiós" in text, (
assert any("Cerrar la app" in lbl for lbl in labels), ( f"Spanish Close page missing localized shutdown caption; got:\n{text[:300]}"
f"Spanish Close button missing; buttons: {labels}"
) )
def test_close_body_describes_unsaved_work_warning_es(self, app_factory):
app = app_factory("99_Close")
with_language(app, "es")
app.run()
text = collected_text(app)
assert "trabajo sin guardar" in text

View File

@@ -61,7 +61,7 @@ EXPECTED_SUBSTRINGS: dict[str, dict[str, str]] = {
"7_Multi_File_Merger": {"en": "Combine Files", "es": "Combine Files"}, "7_Multi_File_Merger": {"en": "Combine Files", "es": "Combine Files"},
"8_Validator_Reporter": {"en": "Quality Check", "es": "Quality Check"}, "8_Validator_Reporter": {"en": "Quality Check", "es": "Quality Check"},
"9_Pipeline_Runner": {"en": "Automated", "es": "Automated"}, "9_Pipeline_Runner": {"en": "Automated", "es": "Automated"},
"99_Close": {"en": "Close DataTools", "es": "Cerrar DataTools"}, "99_Close": {"en": "Shutting down", "es": "Cerrando"},
} }