Round 2 made the step prompt a blocking layer so that `j` and the `g` chords would not reach the page behind it. The dialog it sits in gave up its Tab trap because of that, while the prompt's own replacement trap covered only its own two controls — so Tab from anywhere else in the dialog (the bar's Screenshot or Stop, the dialog's own buttons) walked out behind the scrim. `layerStack` now separates the two questions it had folded into one flag: a layer can own the keyboard and still leave the Tab trap to the dialog below it. The mirror case, a dialog opening over the prompt, falls out of the same split. A send's claim is now a lease with an owner. Every progress report re-stamps it, so a send slower than the lease cannot lose it while it is still running, and only the send that took a claim can give it back. The issue_create audit row is staged inside the send and rides the same checkpoint as `issue_url`, so the trail cannot be missing for an issue that exists. The attachment-name prefix has one definition, shared by the producer and both readers. A running send is reported to the UI. A reload or a second tab used to render it exactly like a send that STOPPED, and offered a retry the server was certain to refuse; the composer now says the report is being sent, holds the draft, and puts both the retry and Create issue out. The past-sessions list says the same for the same reason — it is the list a user opens to check on a send, and both "draft" and "half sent" describe one that died. `/preview` and the retry path got a pace of their own, and the per-user byte cap took the user-scoped lock that makes it a cap instead of a guess. Verified in a real browser on localdev: Tab cycles the whole dialog with the prompt open, the prompt still swallows the page's keys, and the issue link is focusable where the composer puts it. All 24 regression tests mutation-checked.
80 lines
3.0 KiB
Python
80 lines
3.0 KiB
Python
"""What an issue from a debug session carries, as files — one definition for the zip export and the
|
|
Gitea upload, so the two cannot drift apart.
|
|
|
|
* `issue.md` — the title and the body the user wrote and reviewed in the composer (rendered there,
|
|
in their language; the server does not rewrite it);
|
|
* `client.log` / `server.log` — the session's evidence (`debugsession.bundle`) minus the rows the
|
|
user took out, one JSON object per line, browser rows and server rows apart. `.log`, not
|
|
`.jsonl`: Gitea refuses that extension (measured, plans/e16 S0);
|
|
* the screenshots, in the order they were taken.
|
|
|
|
Every row is already redacted twice (at capture, and again when stored), and nothing here adds a
|
|
field the review list did not show.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import debugsession, logsink
|
|
from app.models import DebugScreenshot, DebugSession
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class IssueFile:
|
|
name: str
|
|
data: bytes
|
|
mime: str
|
|
|
|
|
|
def title_of(session: DebugSession) -> str:
|
|
# Not a sentence of ours: the composer always saves a title in the user's language, and a
|
|
# session exported before it ever saved is named by its date alone, in no language at all.
|
|
title = ((session.draft or {}).get("title") or "").strip()
|
|
return title or f"{session.started_at:%Y-%m-%d %H:%M} · {session.id}"
|
|
|
|
|
|
def body_of(session: DebugSession) -> str:
|
|
return ((session.draft or {}).get("body") or "").strip()
|
|
|
|
|
|
def _lines(rows) -> bytes:
|
|
return "".join(
|
|
json.dumps(logsink.serialize(r), ensure_ascii=False, default=str) + "\n" for r in rows
|
|
).encode()
|
|
|
|
|
|
def files(db: Session, session: DebugSession) -> list[IssueFile]:
|
|
"""The session's issue as files: the text, the two logs, the screenshots."""
|
|
rows, _ = debugsession.bundle(db, session, drop_excluded=True)
|
|
text = f"# {title_of(session)}\n\n{body_of(session)}\n"
|
|
out = [
|
|
IssueFile("issue.md", text.encode(), "text/markdown"),
|
|
IssueFile("client.log", _lines(r for r in rows if r.source == "web"), "text/plain"),
|
|
IssueFile("server.log", _lines(r for r in rows if r.source != "web"), "text/plain"),
|
|
]
|
|
shots = db.scalars(
|
|
select(DebugScreenshot)
|
|
.where(DebugScreenshot.session_id == session.id)
|
|
.order_by(DebugScreenshot.id)
|
|
)
|
|
for n, shot in enumerate(shots, start=1):
|
|
ext = debugsession.IMAGE_TYPES[shot.mime]
|
|
name = f"{debugsession.SCREENSHOT_PREFIX}{n}.{ext}"
|
|
out.append(IssueFile(name, shot.data, shot.mime))
|
|
return out
|
|
|
|
|
|
def zipped(items: list[IssueFile]) -> bytes:
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
|
|
for item in items:
|
|
# The images are compressed already: deflating them again costs time and wins nothing.
|
|
method = zipfile.ZIP_STORED if item.mime.startswith("image/") else zipfile.ZIP_DEFLATED
|
|
z.writestr(item.name, item.data, compress_type=method)
|
|
return buf.getvalue()
|