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"
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 {
"": [home],
section_label("cleaners"): by_section["cleaners"],
section_label("transformations"): by_section["transformations"],
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__ = [
# Shared chrome / pickup
"hide_streamlit_chrome",
"quit_button",
"shutdown_app",
"pickup_or_upload",
# License gate + activation form
"render_activation_form",

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import io
import os
import sys
import threading
import time
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:
"""Render a Quit button that terminates the Streamlit server.
def shutdown_app() -> None:
"""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
process (SIGTERM/SIGINT) does not reliably terminate it — Streamlit
installs its own handlers and the tornado/asyncio loop swallows or
defers the signal, so the browser sees the websocket drop while the
python process stays alive. We schedule ``os._exit(0)`` on a daemon
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
a self-contained "App closed" page so the user never sees
Streamlit's red connection-error overlay.
python process stays alive. ``os._exit`` is the only reliable kill.
The hard-exit thread is skipped under pytest so the test suite does
not suicide when a test renders this page. The overlay + caption
still render so test assertions about content work.
"""
if label is None:
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"):
if not st.session_state.get("_app_shutting_down"):
st.session_state["_app_shutting_down"] = True
if "pytest" not in sys.modules:
def _hard_exit() -> None:
time.sleep(1.0)
os._exit(0)
def _hard_exit() -> None:
time.sleep(1.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
discoverable way to terminate the local Streamlit process without
having to Ctrl+C in the shell. An explicit confirm step prevents an
accidental sidebar click from killing a session mid-work.
Sidebar nav entry. Clicking it routes here, and the page's render
fires :func:`shutdown_app` directly — there is no confirm step. The
browser sees the farewell overlay; the Python process terminates a
moment later via ``os._exit``.
"""
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:
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
st.set_page_config(
@@ -25,14 +25,6 @@ st.set_page_config(
page_icon="🛑",
layout="wide",
)
hide_streamlit_chrome()
st.title(t("close_page.title"))
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")
shutdown_app()