Compare commits
10 commits
14f824b8ef
...
10f38beafa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10f38beafa | ||
|
|
d69277d29d | ||
|
|
64c2a77dc9 | ||
|
|
afe782e826 | ||
|
|
906290f445 | ||
|
|
ebc668d28d | ||
|
|
415f8a7ddb | ||
|
|
c6a0b1a267 | ||
|
|
f7d7427d91 | ||
|
|
28f6fe1a9a |
39 changed files with 4054 additions and 618 deletions
105
CHANGELOG.md
105
CHANGELOG.md
|
|
@ -6,6 +6,111 @@ lockstep.
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Changed (0.82.0)
|
||||||
|
|
||||||
|
Workspace-switcher rebuild (persona review 2026-08-27; both
|
||||||
|
high-rated findings fixed):
|
||||||
|
|
||||||
|
- **Sealed-switch privacy race closed.** A connection switch now
|
||||||
|
runs inside an explicit switching window: the identity bar
|
||||||
|
announces the target BEFORE the client re-points, page pollers
|
||||||
|
pause, replies from the previous hub are dropped, and the sealed
|
||||||
|
context is announced only after the new hub answered healthy.
|
||||||
|
Guard: `workspace_switch_race_test`.
|
||||||
|
- **Project filter survives the sealed round trip.** Entering an
|
||||||
|
area parks the shared-hub filter; returning restores it — live
|
||||||
|
state and prefs agree again.
|
||||||
|
- **One global switcher anchor.** The switcher lives once in the
|
||||||
|
sidebar (context visible on every page, Cmd+P opens it); the four
|
||||||
|
per-page embeddings are gone. The sidebar endpoint label updates
|
||||||
|
with the workspace instead of lagging until the next health tick.
|
||||||
|
- **Stopped areas ask before starting** ("Bereich starten?") —
|
||||||
|
running areas keep switching with one click.
|
||||||
|
- **Honest scope tooltip** (the switcher applies everywhere, not
|
||||||
|
"this view"), **why-line at the aggregated sealed row** with a
|
||||||
|
jump to Settings → Security, **checkmark on the active entry**,
|
||||||
|
and the Cmd+K palette knows projects/areas (recent-use ranked,
|
||||||
|
sealed names honour the privacy setting).
|
||||||
|
- **Sealed-aware flow list.** Inside a sealed area the editor lists
|
||||||
|
the instance's own flows dir (it used to keep showing the shared
|
||||||
|
hub's files), the editor state is dropped on a connection switch,
|
||||||
|
sample flows follow the hub's FlowSummary.sample flag (chip +
|
||||||
|
collapsed "Beispiele" group, editor 0.26.0), and an empty sealed
|
||||||
|
list offers "Beispiel-Flows importieren".
|
||||||
|
- **Runs empty state names the filter** ("Keine Läufe in Projekt X"
|
||||||
|
+ show-all action) instead of claiming the feature is off.
|
||||||
|
|
||||||
|
### Changed (0.81.0)
|
||||||
|
|
||||||
|
- **Approvals explain themselves.** The pending approval card now
|
||||||
|
leads with a one-line intro strip (what this inbox is; that
|
||||||
|
Approve resumes the flow and Reject stops it with a reason) and
|
||||||
|
carries a proper ORIGIN block: flow, step, **run id** (previously
|
||||||
|
dropped at the Dart mapping layer), project, and requested-at —
|
||||||
|
so a reviewer can see where a request comes from and why. The run
|
||||||
|
id is copyable; the same facts appear in the history dialog.
|
||||||
|
Approve/Reject buttons gained tooltips. Fixed the approvals doc
|
||||||
|
drift — the guide showed `title`/`details`/`reviewer`, which the
|
||||||
|
engine never accepted; it now documents `prompt`/`show`/
|
||||||
|
`timeout_seconds`. Guard: `approvals_origin_test` renders the card
|
||||||
|
through the hermetic fake hub and pins every origin fact.
|
||||||
|
- **Update the hub straight from the runs page.** The "this view
|
||||||
|
needs a newer hub version" state now leads with an in-place
|
||||||
|
*Update hub now* button (the same `chain update apply` path as the
|
||||||
|
Diagnose page) instead of only deep-linking to Diagnose; the
|
||||||
|
Diagnose link stays as the secondary escape hatch, and a missing
|
||||||
|
local CLI falls back to it. Guard: two new `RunsLoadErrorView`
|
||||||
|
widget tests.
|
||||||
|
|
||||||
|
### Fixed (0.81.0)
|
||||||
|
|
||||||
|
Approvals usertest-panel hardening:
|
||||||
|
|
||||||
|
- **Never fake the request time.** A missing/unparseable `created_at`
|
||||||
|
now leaves the "Angefordert" line off the card instead of showing
|
||||||
|
`now` (which even drifted on every refresh). `ApprovalRecord.createdAt`
|
||||||
|
is nullable; guard in `approvals_origin_test`.
|
||||||
|
- **Copyable errors on every path.** Approve/Reject and both batch
|
||||||
|
actions now route failures through `showChainErrorSnack` instead of
|
||||||
|
a bare `SnackBar(Text(e))` — the batch path surfaces the first real
|
||||||
|
cause, not just a count.
|
||||||
|
- **Reject needs a reason.** The reject dialog leads with a
|
||||||
|
`ChainInlineHelp` strip and disables the confirm button while the
|
||||||
|
reason is empty, instead of closing silently with nothing happening.
|
||||||
|
- **Batch approve respects the no-data guard.** A bulk approve that
|
||||||
|
includes requests without `show:` data now asks for the same
|
||||||
|
conscious confirmation the single approve already required, naming
|
||||||
|
how many are data-less.
|
||||||
|
- **Plainer language.** The intro and origin glossary "Flow" as
|
||||||
|
"Vorgang (Flow)", the history detail labels the question "FRAGE"
|
||||||
|
(not "PROMPT"), and the no-data hint drops developer jargon.
|
||||||
|
- **Copy the run id in one click**, and the history payload renders
|
||||||
|
with the same JSON pretty-printing as the card.
|
||||||
|
- **A reviewer name no longer poses as a proven identity.** The hub
|
||||||
|
stores whatever `reviewer` string a client sends as `decided_by`,
|
||||||
|
so Studio's `$USER@studio` read like non-repudiation while being
|
||||||
|
an unchecked claim (legal finding of the 2026-07-26 panel). Every
|
||||||
|
value Studio writes — approvals and the `chain.reset` marker of
|
||||||
|
the audit wipe — now carries an `unverified:` marker inside the
|
||||||
|
record, so an export, a CLI reader or a database dump sees the
|
||||||
|
trust level without knowing how the deciding Studio was set up.
|
||||||
|
The inbox states before the decision who will be recorded and
|
||||||
|
what that is worth on this hub (derived from the hub's auth
|
||||||
|
policy; "unreadable" stays unreadable, never optimistic). Reading
|
||||||
|
back, a marked value shows its plain name with an "unchecked"
|
||||||
|
flag, an unmarked one (legacy row, CLI decision) is not
|
||||||
|
classified either way. The real fix is hub-side — derive
|
||||||
|
`decided_by` from the authenticated caller; the contract is in
|
||||||
|
`docs/reviewer-identity.md`, and once the hub does it the marker
|
||||||
|
disappears without a Studio release. Guards:
|
||||||
|
`reviewer_identity_test` + `approvals_reviewer_identity_test`.
|
||||||
|
- **Audit overflow-menu entries no longer run off the menu.** A
|
||||||
|
popup menu is width-capped, so the full-sentence German labels
|
||||||
|
were clipped — on the reset entry exactly the "(nur local/dev)"
|
||||||
|
that keeps it from reading as "delete evidence". Labels wrap now;
|
||||||
|
guard in `responsive_test` (the page sweep cannot see popup menus,
|
||||||
|
which is why it went unnoticed).
|
||||||
|
|
||||||
### Added (0.80.0)
|
### Added (0.80.0)
|
||||||
|
|
||||||
- **Ollama joins the host services.** Ollama-backed store entries
|
- **Ollama joins the host services.** Ollama-backed store entries
|
||||||
|
|
|
||||||
68
HANDOVER-2026-08-27.md
Normal file
68
HANDOVER-2026-08-27.md
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# HANDOVER 2026-08-27 — Switcher-Paket (Bereichs-/Projekt-UX)
|
||||||
|
|
||||||
|
Ziel der nächsten Session: das per Persona-Check (Noten 4/3/2)
|
||||||
|
definierte Umbaupaket am Stück bauen. Details + Belege:
|
||||||
|
Memory `workspace-switcher-persona-2026-08-27` lesen.
|
||||||
|
|
||||||
|
## Reihenfolge (HOCH zuerst)
|
||||||
|
|
||||||
|
1. **Umschalt-Race schließen** (Datenschützer, HOCH): In
|
||||||
|
`lib/data/workspace.dart` (`switchToSealed`, ~Z. 169–204) zeigt
|
||||||
|
der Client nach `HubService.reconnect` (Z. 188) schon auf den
|
||||||
|
Mandanten-Hub, bevor `_activeSealed` gesetzt/notifiziert ist —
|
||||||
|
2-s-Poller (`runs.dart:218`, `audit.dart:94`) können sealed-Daten
|
||||||
|
UNMARKIERT rendern. Fix: expliziter `switching`-Zustand VOR dem
|
||||||
|
Reconnect (Identity-Bar optimistisch „Wechsle in Bereich …",
|
||||||
|
Poller pausieren), Freigabe erst nach `healthy()`.
|
||||||
|
Wächter-Test (state_matrix-Stil): „Client-Endpoint ≠ angezeigter
|
||||||
|
Kontext" darf nie vorkommen.
|
||||||
|
2. **Filter-Restaurierung** (Poweruser, HOCH): `workspace.dart:202`
|
||||||
|
löscht den Projekt-Slug beim Bereichseintritt ohne Prefs-Write;
|
||||||
|
`switchToShared` (Z. 210–222) restauriert nur `_sharedEndpoint`.
|
||||||
|
Analog den Slug merken + restaurieren.
|
||||||
|
3. **Globaler Anker**: Switcher EINMAL in die Shell (Sidebar links
|
||||||
|
oben, `main.dart`), die 4 Einzel-Einbindungen raus
|
||||||
|
(`flows.dart:227`, `runs.dart:329`, `audit.dart:295`,
|
||||||
|
`approvals.dart:376`). Kontext damit auf allen Seiten sichtbar.
|
||||||
|
4. **Bestätigungsdialog** vor Auto-Start gestoppter Bereiche
|
||||||
|
(`workspace.dart:169–179`): „Bereich ‚X' starten und verbinden?"
|
||||||
|
— laufende Bereiche weiter 1 Klick.
|
||||||
|
5. **Tooltip-Lüge fixen**: `app_de.arb:1836` / `app_en.arb:1875`
|
||||||
|
„filtert diese Ansicht" → wirkt global (Flows, Läufe, Audit,
|
||||||
|
Freigaben + Stempel neuer Läufe).
|
||||||
|
6. **Aggregat erklären**: Warum-Zeile an der „N abgeschottete
|
||||||
|
Bereiche"-Zeile (`chain_workspace_switcher.dart:334–390`) +
|
||||||
|
Link auf den Settings-Schalter. Aggregation BLEIBT (bewusste
|
||||||
|
Vertraulichkeitsentscheidung, vom Datenschützer bestätigt).
|
||||||
|
7. **Poweruser-Komfort**: Cmd+K-Palette kennt Projekte/Bereiche
|
||||||
|
(`chain_search_palette.dart`), Kürzel für den Switcher, Häkchen
|
||||||
|
am aktiven Eintrag, Zuletzt-benutzt; Läufe-Leerzustand bei
|
||||||
|
aktivem Filter ehrlich („Keine Läufe in Projekt X" statt
|
||||||
|
`runsEmptyHint`, `runs.dart:357–369`).
|
||||||
|
8. **Beispiel-Chip** (offen vom 23.08.): Hub liefert
|
||||||
|
`FlowSummary.sample` (Wire-Feld 6, gepusht in fai_chain
|
||||||
|
`4cd5122`); ERST Dart-Proto-Regen im Client-SDK
|
||||||
|
(`fai_chain_client_sdk_dart`), DANN Flows-Liste: „Beispiel"-Chip
|
||||||
|
+ Beispiele eingeklappt + „Beispiele importieren"-Aktion im
|
||||||
|
leeren sealed-Zustand. Alte Hubs ohne Feld → false → keine Chips
|
||||||
|
(ehrlich). Skew-Gate (`scripts/skew-smoke.sh`) laufen lassen.
|
||||||
|
|
||||||
|
## Prüfpflichten
|
||||||
|
|
||||||
|
Studio-Regeln: Selbstverifikation per Screenshot (hell+dunkel),
|
||||||
|
l10n DE+EN, kopierbare Fehler, Inline-Hilfe für neue Flächen
|
||||||
|
(doc_help_wiring_test), Canon-Wächter. Hub-Seite ist KOMPLETT
|
||||||
|
(sealed seedet nicht mehr, Prune live auf den echten Bereichen
|
||||||
|
verifiziert, Backfill für alte Instanz-Configs in `chain project
|
||||||
|
start`).
|
||||||
|
|
||||||
|
## Fallen
|
||||||
|
|
||||||
|
- Kundenbereichs-Slugs nie in Commit-Messages — der
|
||||||
|
Security-Hook blockt (richtig so).
|
||||||
|
- `!`-Befehle des Nutzers haben 2-min-Timeout; lange Gates selbst
|
||||||
|
puschen.
|
||||||
|
- Scratchpad überlebt nicht: der hermetische OpenAI-Wire-Fake
|
||||||
|
(fake_vllm.py, chunked-Encoding-Handling!) ist weg — bei Bedarf
|
||||||
|
neu aus der Beschreibung in fai_chain-Commit `ad6c772` bzw. den
|
||||||
|
llm.rs-Unit-Tests ableiten.
|
||||||
|
|
@ -15,7 +15,9 @@ Add an approval step before:
|
||||||
- anything the operator's compliance owner asked you to gate
|
- anything the operator's compliance owner asked you to gate
|
||||||
|
|
||||||
The approval is a recorded event — every approval shows up in
|
The approval is a recorded event — every approval shows up in
|
||||||
the audit log with the reviewer, decision, and reason.
|
the audit log with the reviewer, decision, and reason. The
|
||||||
|
`prompt` is the sentence the reviewer sees; `show` selects the
|
||||||
|
data put in front of them (a prior step's output).
|
||||||
|
|
||||||
## How a flow declares one
|
## How a flow declares one
|
||||||
|
|
||||||
|
|
@ -27,9 +29,8 @@ steps:
|
||||||
- id: review
|
- id: review
|
||||||
use: system.approval@^0
|
use: system.approval@^0
|
||||||
with:
|
with:
|
||||||
title: "Review the classifier output"
|
prompt: "Review the classifier output"
|
||||||
details: "$classify.response"
|
show: "$classify.response"
|
||||||
reviewer: "$inputs.reviewer"
|
|
||||||
timeout_seconds: 600
|
timeout_seconds: 600
|
||||||
|
|
||||||
- id: write
|
- id: write
|
||||||
|
|
@ -45,8 +46,9 @@ configured `timeout_seconds` elapses.
|
||||||
|
|
||||||
## How an operator answers one
|
## How an operator answers one
|
||||||
|
|
||||||
The Approvals page lists every pending approval. Click an
|
The Approvals page lists every pending approval. Each card
|
||||||
entry, read the title + details, decide:
|
shows its origin (flow, step, run, project, requested-at), the
|
||||||
|
`prompt`, and the `show` data. Read them, then decide:
|
||||||
|
|
||||||
- **Approve** — the flow resumes from this step with the
|
- **Approve** — the flow resumes from this step with the
|
||||||
approval's `payload` as the step output.
|
approval's `payload` as the step output.
|
||||||
|
|
@ -63,7 +65,7 @@ that reflects how long a reviewer can realistically take.
|
||||||
|
|
||||||
Every approval write hits the audit log:
|
Every approval write hits the audit log:
|
||||||
|
|
||||||
- approval requested (with the title + details snapshot)
|
- approval requested (with the prompt + show-data snapshot)
|
||||||
- reviewer-decision recorded (with reason on reject)
|
- reviewer-decision recorded (with reason on reject)
|
||||||
- payload + signature on accept
|
- payload + signature on accept
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ Setze eine Freigabe vor:
|
||||||
|
|
||||||
Die Freigabe ist ein protokolliertes Ereignis — jeder
|
Die Freigabe ist ein protokolliertes Ereignis — jeder
|
||||||
Freigabe-Vorgang landet im Audit-Log mit Reviewer,
|
Freigabe-Vorgang landet im Audit-Log mit Reviewer,
|
||||||
Entscheidung und Begründung.
|
Entscheidung und Begründung. Das `prompt` ist der Satz, den der
|
||||||
|
Reviewer sieht; `show` wählt die Daten, die ihm vorgelegt
|
||||||
|
werden (der Output eines vorherigen Steps).
|
||||||
|
|
||||||
## So deklariert ein Flow eine Freigabe
|
## So deklariert ein Flow eine Freigabe
|
||||||
|
|
||||||
|
|
@ -30,9 +32,8 @@ steps:
|
||||||
- id: review
|
- id: review
|
||||||
use: system.approval@^0
|
use: system.approval@^0
|
||||||
with:
|
with:
|
||||||
title: "Klassifikations-Output prüfen"
|
prompt: "Klassifikations-Output prüfen"
|
||||||
details: "$classify.response"
|
show: "$classify.response"
|
||||||
reviewer: "$inputs.reviewer"
|
|
||||||
timeout_seconds: 600
|
timeout_seconds: 600
|
||||||
|
|
||||||
- id: write
|
- id: write
|
||||||
|
|
@ -49,8 +50,10 @@ zugehörige Freigabe-Eintrag eine `decision` erhält
|
||||||
|
|
||||||
## So beantwortet der Operator eine Freigabe
|
## So beantwortet der Operator eine Freigabe
|
||||||
|
|
||||||
Die Freigaben-Seite listet jede ausstehende Freigabe. Klick
|
Die Freigaben-Seite listet jede ausstehende Freigabe. Jede
|
||||||
auf einen Eintrag, Titel + Details lesen, entscheiden:
|
Karte zeigt ihre Herkunft (Flow, Step, Lauf, Projekt,
|
||||||
|
Anforderungszeit), das `prompt` und die `show`-Daten. Lesen,
|
||||||
|
dann entscheiden:
|
||||||
|
|
||||||
- **Freigeben** — der Flow läuft ab diesem Step weiter, das
|
- **Freigeben** — der Flow läuft ab diesem Step weiter, das
|
||||||
`payload` der Freigabe wird zum Step-Output.
|
`payload` der Freigabe wird zum Step-Output.
|
||||||
|
|
@ -69,7 +72,7 @@ realistisch brauchen darf.
|
||||||
|
|
||||||
Jeder Schreibvorgang einer Freigabe landet im Audit-Log:
|
Jeder Schreibvorgang einer Freigabe landet im Audit-Log:
|
||||||
|
|
||||||
- Freigabe angefordert (mit Titel + Details-Snapshot)
|
- Freigabe angefordert (mit Prompt + show-Daten-Snapshot)
|
||||||
- Reviewer-Entscheidung aufgezeichnet (mit Begründung bei
|
- Reviewer-Entscheidung aufgezeichnet (mit Begründung bei
|
||||||
Ablehnung)
|
Ablehnung)
|
||||||
- Payload + Signatur bei Freigabe
|
- Payload + Signatur bei Freigabe
|
||||||
|
|
|
||||||
89
docs/reviewer-identity.md
Normal file
89
docs/reviewer-identity.md
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
# Reviewer identity — what `decided_by` is worth
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
The hub copies the `reviewer` string a client sends straight into
|
||||||
|
`decided_by`:
|
||||||
|
|
||||||
|
- `DecideApproval` — who approved or rejected a paused flow step
|
||||||
|
- `ClearEventLog` — who wiped the audit log, recorded in the seeded
|
||||||
|
`chain.reset` marker
|
||||||
|
|
||||||
|
Nothing on the wire ties that string to the authenticated caller.
|
||||||
|
Studio fills it from the OS account (`$USER@studio`); any other
|
||||||
|
client can send any string at all, including someone else's name.
|
||||||
|
|
||||||
|
The legal review of the approvals page (usertest panel, 2026-07-26)
|
||||||
|
graded this HIGH: a value that reads like an identity but is an
|
||||||
|
unchecked client claim lends the audit trail a non-repudiation it
|
||||||
|
does not have. An export, a court-facing report or an auditor
|
||||||
|
reading the database sees `stefan@studio` and has no way to tell
|
||||||
|
whether the hub checked anything.
|
||||||
|
|
||||||
|
This is not a leak and not remote-exploitable on its own — the
|
||||||
|
finding is about what the record *proves*, not about access.
|
||||||
|
|
||||||
|
## What Studio does today (0.81.0)
|
||||||
|
|
||||||
|
Studio marks its own claim as a claim, **inside the recorded
|
||||||
|
value**:
|
||||||
|
|
||||||
|
```
|
||||||
|
decided_by = "unverified:stefan@studio"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `lib/data/reviewer_identity.dart` is the single place that
|
||||||
|
produces and reads that value. `ReviewerIdentity.wire()` is
|
||||||
|
idempotent, so both the page and `HubService` may normalise.
|
||||||
|
- Every write path funnels through `HubService.approve` /
|
||||||
|
`reject` / `clearEventLog`, so no surface can send a bare handle.
|
||||||
|
- The approvals inbox states before the decision who will be
|
||||||
|
recorded, what the attribution is worth on *this* hub (derived
|
||||||
|
from `AuthStatus.anonymous_allowed`), and the literal string that
|
||||||
|
will be stored.
|
||||||
|
- Reading back: a marked value is shown by its name with an
|
||||||
|
"unchecked" flag; an **unmarked** value (legacy row, a CLI
|
||||||
|
decision, or a future hub-derived identity) gets no badge at all
|
||||||
|
— Studio does not know its provenance and must not classify it.
|
||||||
|
|
||||||
|
Guards: `test/reviewer_identity_test.dart` (the value itself) and
|
||||||
|
`test/approvals_reviewer_identity_test.dart` (every surface that
|
||||||
|
writes or renders it, against the hermetic fake hub).
|
||||||
|
|
||||||
|
This is honest, but it is a *label*, not a fix. It does not stop a
|
||||||
|
client from sending `unverified:someone.else@studio`.
|
||||||
|
|
||||||
|
## The actual fix — hub side
|
||||||
|
|
||||||
|
`decided_by` must be derived server-side from the verified caller
|
||||||
|
and the client-supplied `reviewer` ignored:
|
||||||
|
|
||||||
|
1. In the `DecideApproval` and `ClearEventLog` handlers, take the
|
||||||
|
identity from the same source `_caller` comes from
|
||||||
|
(`CALLER_IDENTITY`) rather than from the request message.
|
||||||
|
- static validator: the configured token's name
|
||||||
|
- `jwt-rs256` validator: the JWT subject
|
||||||
|
- anonymous call: no identity — record `anonymous` explicitly
|
||||||
|
(never a client-supplied name), or refuse the decision on
|
||||||
|
channels where the operator requires attribution.
|
||||||
|
2. Keep accepting the request field for one release (ignored) so
|
||||||
|
older Studios and CLIs keep working; log a deprecation when it
|
||||||
|
is present and differs from the derived value.
|
||||||
|
3. Once the hub derives the value, it overwrites the field and the
|
||||||
|
`unverified:` prefix disappears from new records by itself — no
|
||||||
|
Studio release is needed to stop labelling. Studio's reader
|
||||||
|
already treats an unmarked value as "provenance unknown", so a
|
||||||
|
hub-derived value renders cleanly.
|
||||||
|
4. Old rows keep the prefix. That is correct: they *were* unchecked.
|
||||||
|
|
||||||
|
Open product decision for step 1: whether an anonymous hub may
|
||||||
|
decide approvals at all, or whether approvals require an
|
||||||
|
authenticated caller. Studio surfaces the anonymous case today; it
|
||||||
|
does not block it.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- `lib/data/reviewer_identity.dart` — the Studio-side contract
|
||||||
|
- Backlog T014, point 1 (usertest 2026-07-26, legal persona)
|
||||||
|
- Points 2–7 of that finding (hash-chain-backed history, deeplink,
|
||||||
|
sealed slugs, UTC offsets, auto-refresh) are untouched by this.
|
||||||
|
|
@ -19,8 +19,11 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:chain_studio_flow_editor/src/l10n.dart';
|
import 'package:chain_studio_flow_editor/src/l10n.dart';
|
||||||
import 'package:chain_studio_flow_editor/src/widgets/missing_modules_badge.dart';
|
import 'package:chain_studio_flow_editor/src/widgets/missing_modules_badge.dart';
|
||||||
|
|
||||||
import 'package:chain_studio/data/hub.dart' show StoreItem;
|
import 'package:chain_studio/data/hub.dart'
|
||||||
|
show ApprovalRecord, StoreItem, ThemeModeValue;
|
||||||
|
import 'package:chain_studio/data/reviewer_identity.dart';
|
||||||
import 'package:chain_studio/l10n/app_localizations.dart';
|
import 'package:chain_studio/l10n/app_localizations.dart';
|
||||||
|
import 'package:chain_studio/main.dart';
|
||||||
import 'package:chain_studio/pages/federation.dart';
|
import 'package:chain_studio/pages/federation.dart';
|
||||||
import 'package:chain_studio/widgets/chain_install_confirm.dart';
|
import 'package:chain_studio/widgets/chain_install_confirm.dart';
|
||||||
|
|
||||||
|
|
@ -200,4 +203,63 @@ void main() {
|
||||||
await _shot(tester, '$name-dark');
|
await _shot(tester, '$name-dark');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reviewer identity on the approvals inbox — the strip that names
|
||||||
|
// who the audit trail will record and says, in plain words, what
|
||||||
|
// that attribution is worth on this hub. Captured in both themes
|
||||||
|
// because the "not verified" pill and the literal recorded value
|
||||||
|
// have to stay legible in each (release gate, project CLAUDE.md).
|
||||||
|
for (final (themeName, mode) in [
|
||||||
|
('light', ThemeModeValue.light),
|
||||||
|
('dark', ThemeModeValue.dark),
|
||||||
|
]) {
|
||||||
|
testWidgets('approvals reviewer identity — $themeName', (tester) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
ReviewerIdentity.debugHandle = 'stefan@studio';
|
||||||
|
addTearDown(() => ReviewerIdentity.debugHandle = null);
|
||||||
|
final fake = installFakeHub();
|
||||||
|
fake.approvals = [
|
||||||
|
ApprovalRecord(
|
||||||
|
id: 'apr-1',
|
||||||
|
flowName: 'rechnung-klassifizieren',
|
||||||
|
stepId: 'pruefen',
|
||||||
|
prompt: 'Bitte die Klassifikation dieser Rechnung bestätigen.',
|
||||||
|
payloadPreview: '{"label":"Rechnung","betrag":"128,40 EUR"}',
|
||||||
|
createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0),
|
||||||
|
expiresAt: null,
|
||||||
|
status: 'pending',
|
||||||
|
decidedAt: null,
|
||||||
|
decidedBy: '',
|
||||||
|
reason: '',
|
||||||
|
project: 'buergeramt',
|
||||||
|
flowExecution: 'run-abc123',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
tester.view.physicalSize = const Size(1280, 900);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
// The real app shell, not a bare MaterialApp: the strip has to
|
||||||
|
// be judged inside the Chain theme it ships in.
|
||||||
|
await tester.pumpWidget(
|
||||||
|
RepaintBoundary(
|
||||||
|
key: _shotKey,
|
||||||
|
child: StudioApp(initialThemeMode: mode, initialLocale: Locale('de')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('sidebar-item-approvals')));
|
||||||
|
for (var i = 0; i < 12; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
await _shot(tester, 'approvals-reviewer-identity-$themeName');
|
||||||
|
expect(find.text('Sie entscheiden als'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.text('Wird gespeichert als: unverified:stefan@studio'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
// Long-lived timers: unmount before the harness tears down.
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(minutes: 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -262,10 +262,11 @@ void main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 09: the workspace switcher, opened (shows the demo projects;
|
// 09: the workspace switcher, opened (shows the demo projects;
|
||||||
// with the sealed seed also the shield entry).
|
// with the sealed seed also the shield entry). The switcher is
|
||||||
|
// the ONE sidebar anchor now — global on every page.
|
||||||
shell.navigateTo('audit');
|
shell.navigateTo('audit');
|
||||||
await _pumpFrames(tester);
|
await _pumpFrames(tester);
|
||||||
final switcher = find.byType(ChainWorkspaceSwitcher);
|
final switcher = find.byType(ChainWorkspaceAnchor);
|
||||||
if (switcher.evaluate().isNotEmpty) {
|
if (switcher.evaluate().isNotEmpty) {
|
||||||
await tester.tap(switcher.first);
|
await tester.tap(switcher.first);
|
||||||
await _pumpFrames(tester);
|
await _pumpFrames(tester);
|
||||||
|
|
|
||||||
193
integration_test/workspace_shots_test.dart
Normal file
193
integration_test/workspace_shots_test.dart
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
// Workspace-anchor capture harness — visual proof for the sidebar
|
||||||
|
// switcher rebuild (persona review 2026-08-27). Renders the full
|
||||||
|
// shell against the scriptable fake hub and writes a PNG per theme
|
||||||
|
// via a RepaintBoundary (driverless, headed macOS `flutter test`).
|
||||||
|
//
|
||||||
|
// flutter test integration_test/workspace_shots_test.dart -d macos
|
||||||
|
//
|
||||||
|
// Output: build/workspace-shots/<name>-<theme>.png
|
||||||
|
// (or $WORKSPACE_SHOTS_OUT).
|
||||||
|
//
|
||||||
|
// Fixture names are deliberately generic — never real client or
|
||||||
|
// area names.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint;
|
||||||
|
import 'package:chain_studio/data/hub.dart';
|
||||||
|
import 'package:chain_studio/data/sealed_areas.dart';
|
||||||
|
import 'package:chain_studio/data/sidebar_prefs.dart';
|
||||||
|
import 'package:chain_studio/data/workspace.dart';
|
||||||
|
import 'package:chain_studio/data/workspace_prefs.dart';
|
||||||
|
import 'package:chain_studio/main.dart';
|
||||||
|
import 'package:chain_studio/widgets/chain_workspace_switcher.dart';
|
||||||
|
|
||||||
|
import '../test/support/fake_hub.dart';
|
||||||
|
|
||||||
|
final GlobalKey _shotKey = GlobalKey();
|
||||||
|
|
||||||
|
String get _outDir =>
|
||||||
|
Platform.environment['WORKSPACE_SHOTS_OUT'] ?? 'build/workspace-shots';
|
||||||
|
|
||||||
|
Future<void> _shot(WidgetTester tester, String name) async {
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
await tester.pump(const Duration(milliseconds: 150));
|
||||||
|
final boundary =
|
||||||
|
_shotKey.currentContext!.findRenderObject() as RenderRepaintBoundary;
|
||||||
|
final image = await boundary.toImage(pixelRatio: 2.0);
|
||||||
|
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
|
||||||
|
image.dispose();
|
||||||
|
final file = File('$_outDir/$name.png');
|
||||||
|
file.parent.createSync(recursive: true);
|
||||||
|
file.writeAsBytesSync(bytes!.buffer.asUint8List());
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('workspace-shot: ${file.path}');
|
||||||
|
}
|
||||||
|
|
||||||
|
const _projects = [
|
||||||
|
ProjectRef(slug: 'general', name: 'General'),
|
||||||
|
ProjectRef(
|
||||||
|
slug: 'projekt-alpha',
|
||||||
|
name: 'Projekt Alpha',
|
||||||
|
color: '#8b7cf6',
|
||||||
|
isolation: 'protected',
|
||||||
|
),
|
||||||
|
ProjectRef(slug: 'projekt-beta', name: 'Projekt Beta', color: '#2e8f9e'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const _areas = [
|
||||||
|
SealedArea(
|
||||||
|
slug: 'bereich-nord',
|
||||||
|
name: 'Bereich Nord',
|
||||||
|
color: '#e0a458',
|
||||||
|
port: 51100,
|
||||||
|
running: true,
|
||||||
|
),
|
||||||
|
SealedArea(
|
||||||
|
slug: 'bereich-sued',
|
||||||
|
name: 'Bereich Süd',
|
||||||
|
color: '#c25e5e',
|
||||||
|
port: 51101,
|
||||||
|
running: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
class _FakeSealedAreas extends SealedAreaService {
|
||||||
|
_FakeSealedAreas() : super.forTest();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SealedArea>> list() async => _areas;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> boundEndpoint(String slug) async => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GatedHub extends FakeHubService {
|
||||||
|
Completer<void>? healthyGate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reconnect(
|
||||||
|
HubEndpoint endpoint, {
|
||||||
|
Object? authToken = const Object(),
|
||||||
|
bool persist = true,
|
||||||
|
}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> healthy() async {
|
||||||
|
final gate = healthyGate;
|
||||||
|
if (gate != null) await gate.future;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _boot(WidgetTester tester, _GatedHub hub, String theme) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
hub.projects = _projects;
|
||||||
|
HubService.debugSetInstance(hub);
|
||||||
|
SealedAreaService.debugSetInstance(_FakeSealedAreas());
|
||||||
|
SidebarPrefs.pinned.value = true;
|
||||||
|
Workspace.instance.debugSeed(
|
||||||
|
projects: _projects,
|
||||||
|
active: 'projekt-alpha',
|
||||||
|
sealed: _areas,
|
||||||
|
);
|
||||||
|
tester.view.physicalSize = const Size(2560, 1600);
|
||||||
|
tester.view.devicePixelRatio = 2.0;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
RepaintBoundary(
|
||||||
|
key: _shotKey,
|
||||||
|
child: StudioApp(
|
||||||
|
initialThemeMode: theme == 'dark'
|
||||||
|
? ThemeModeValue.dark
|
||||||
|
: ThemeModeValue.light,
|
||||||
|
initialLocale: const Locale('de'),
|
||||||
|
startSidebarExpanded: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _teardown(WidgetTester tester) async {
|
||||||
|
HubService.debugSetInstance(null);
|
||||||
|
SealedAreaService.debugSetInstance(null);
|
||||||
|
SidebarPrefs.pinned.value = false;
|
||||||
|
WorkspacePrefs.sealedNamesVisible.value = false;
|
||||||
|
Workspace.instance.debugSeed(projects: const [], active: '');
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(minutes: 1));
|
||||||
|
tester.view.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
for (final theme in ['light', 'dark']) {
|
||||||
|
testWidgets('anchor + menu + switching bar — $theme', (tester) async {
|
||||||
|
final hub = _GatedHub();
|
||||||
|
await _boot(tester, hub, theme);
|
||||||
|
|
||||||
|
// 01: the shell with the sidebar anchor (active project).
|
||||||
|
await _shot(tester, '01-anchor-shell-$theme');
|
||||||
|
|
||||||
|
// 02: the switcher menu — checkmark on the active project,
|
||||||
|
// aggregated sealed row with the why-line + Settings link.
|
||||||
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await _shot(tester, '02-switcher-menu-$theme');
|
||||||
|
|
||||||
|
// 03: names revealed for this menu opening.
|
||||||
|
await tester.tap(find.text('Namen einblenden'));
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
await _shot(tester, '03-switcher-menu-revealed-$theme');
|
||||||
|
await tester.tapAt(const Offset(1200, 700)); // dismiss menu
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
// 04: the switching state — identity bar announces the
|
||||||
|
// target BEFORE the connection settles (privacy race fix).
|
||||||
|
hub.healthyGate = Completer<void>();
|
||||||
|
final switching = Workspace.instance.switchToSealed(_areas.first);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await _shot(tester, '04-identity-switching-$theme');
|
||||||
|
|
||||||
|
// 05: inside the sealed area — bar + anchor agree.
|
||||||
|
hub.healthyGate!.complete();
|
||||||
|
hub.healthyGate = null;
|
||||||
|
await switching;
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await _shot(tester, '05-identity-in-area-$theme');
|
||||||
|
|
||||||
|
await Workspace.instance.switchToShared();
|
||||||
|
await _teardown(tester);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
/// Studio's own build version. Bump on every UI release so the
|
/// Studio's own build version. Bump on every UI release so the
|
||||||
/// running app self-identifies.
|
/// running app self-identifies.
|
||||||
const String kStudioVersion = '0.80.0';
|
const String kStudioVersion = '0.82.0';
|
||||||
|
|
||||||
const String kProductName = 'Ch∆In Studio';
|
const String kProductName = 'Ch∆In Studio';
|
||||||
const String kVendorName = 'Flemming.AI (F∆I)';
|
const String kVendorName = 'Flemming.AI (F∆I)';
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import 'flow_output.dart';
|
import 'flow_output.dart';
|
||||||
import 'hub_auth_token.dart';
|
import 'hub_auth_token.dart';
|
||||||
|
import 'reviewer_identity.dart';
|
||||||
export 'flow_output.dart';
|
export 'flow_output.dart';
|
||||||
|
|
||||||
class HubService {
|
class HubService {
|
||||||
|
|
@ -79,34 +80,42 @@ class HubService {
|
||||||
/// null when it cannot be determined (then we keep the default).
|
/// null when it cannot be determined (then we keep the default).
|
||||||
static ({String channel, HubEndpoint endpoint})? _discoverActiveChannel() {
|
static ({String channel, HubEndpoint endpoint})? _discoverActiveChannel() {
|
||||||
try {
|
try {
|
||||||
final home = Platform.environment['HOME'] ??
|
final home =
|
||||||
Platform.environment['USERPROFILE'];
|
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||||
if (home == null || home.isEmpty) return null;
|
if (home == null || home.isEmpty) return null;
|
||||||
final sep = Platform.pathSeparator;
|
final sep = Platform.pathSeparator;
|
||||||
final base = '$home$sep.chain';
|
final base = '$home$sep.chain';
|
||||||
final ccFile = File('$base${sep}current-channel');
|
final ccFile = File('$base${sep}current-channel');
|
||||||
final channel =
|
final channel = ccFile.existsSync()
|
||||||
ccFile.existsSync() ? ccFile.readAsStringSync().trim() : 'local';
|
? ccFile.readAsStringSync().trim()
|
||||||
|
: 'local';
|
||||||
if (channel.isEmpty) return null;
|
if (channel.isEmpty) return null;
|
||||||
// Prefer the actual bound endpoint the daemon wrote.
|
// Prefer the actual bound endpoint the daemon wrote.
|
||||||
final epFile = File('$base${sep}run$sep$channel.endpoint');
|
final epFile = File('$base${sep}run$sep$channel.endpoint');
|
||||||
if (epFile.existsSync()) {
|
if (epFile.existsSync()) {
|
||||||
final raw =
|
final raw = epFile.readAsStringSync().trim().replaceFirst(
|
||||||
epFile.readAsStringSync().trim().replaceFirst(RegExp(r'^\w+://'), '');
|
RegExp(r'^\w+://'),
|
||||||
|
'',
|
||||||
|
);
|
||||||
final i = raw.lastIndexOf(':');
|
final i = raw.lastIndexOf(':');
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
final host = raw.substring(0, i);
|
final host = raw.substring(0, i);
|
||||||
final port = int.tryParse(raw.substring(i + 1));
|
final port = int.tryParse(raw.substring(i + 1));
|
||||||
if (port != null) {
|
if (port != null) {
|
||||||
return (channel: channel, endpoint: HubEndpoint(host: host, port: port));
|
return (
|
||||||
|
channel: channel,
|
||||||
|
endpoint: HubEndpoint(host: host, port: port),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fall back to the channel's well-known default port.
|
// Fall back to the channel's well-known default port.
|
||||||
return (
|
return (
|
||||||
channel: channel,
|
channel: channel,
|
||||||
endpoint:
|
endpoint: HubEndpoint(
|
||||||
HubEndpoint(host: '127.0.0.1', port: _channelPorts[channel] ?? 50051),
|
host: '127.0.0.1',
|
||||||
|
port: _channelPorts[channel] ?? 50051,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -332,8 +341,7 @@ class HubService {
|
||||||
required String url,
|
required String url,
|
||||||
String bearerEnv = '',
|
String bearerEnv = '',
|
||||||
String pinnedPubkeyPem = '',
|
String pinnedPubkeyPem = '',
|
||||||
}) =>
|
}) => _client.addStore(
|
||||||
_client.addStore(
|
|
||||||
name: name,
|
name: name,
|
||||||
url: url,
|
url: url,
|
||||||
bearerEnv: bearerEnv,
|
bearerEnv: bearerEnv,
|
||||||
|
|
@ -642,12 +650,18 @@ class HubService {
|
||||||
/// `production`; the gRPC error surfaces as an exception so
|
/// `production`; the gRPC error surfaces as an exception so
|
||||||
/// the caller can show the operator why it was blocked.
|
/// the caller can show the operator why it was blocked.
|
||||||
/// Returns `(purged, channel)` so the UI can confirm what
|
/// Returns `(purged, channel)` so the UI can confirm what
|
||||||
/// just happened.
|
/// just happened. [reviewer] is the operator's typed handle and
|
||||||
|
/// goes out through [ReviewerIdentity.wire] for the same reason
|
||||||
|
/// approvals do: the marker the hub seeds is read by auditors and
|
||||||
|
/// must not claim a verified identity Studio cannot supply.
|
||||||
Future<({int purged, String channel})> clearEventLog({
|
Future<({int purged, String channel})> clearEventLog({
|
||||||
required String reviewer,
|
required String reviewer,
|
||||||
required String reason,
|
required String reason,
|
||||||
}) async {
|
}) async {
|
||||||
final r = await _client.clearEventLog(reviewer: reviewer, reason: reason);
|
final r = await _client.clearEventLog(
|
||||||
|
reviewer: ReviewerIdentity.wire(reviewer),
|
||||||
|
reason: reason,
|
||||||
|
);
|
||||||
return (purged: r.purged.toInt(), channel: r.channel);
|
return (purged: r.purged.toInt(), channel: r.channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -988,6 +1002,7 @@ class HubService {
|
||||||
sizeBytes: f.sizeBytes.toInt(),
|
sizeBytes: f.sizeBytes.toInt(),
|
||||||
requiredCapabilities: List<String>.from(f.requiredCapabilities),
|
requiredCapabilities: List<String>.from(f.requiredCapabilities),
|
||||||
project: f.project,
|
project: f.project,
|
||||||
|
sample: f.sample,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList()
|
.toList()
|
||||||
|
|
@ -1145,7 +1160,13 @@ class HubService {
|
||||||
stepId: e.stepId,
|
stepId: e.stepId,
|
||||||
prompt: e.prompt,
|
prompt: e.prompt,
|
||||||
payloadPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview,
|
payloadPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview,
|
||||||
createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(),
|
// Never fabricate the request time: a missing / unparseable
|
||||||
|
// created_at stays null so the card can omit the line
|
||||||
|
// instead of showing "now" (which would even change on
|
||||||
|
// every refresh) as if it were the truth.
|
||||||
|
createdAt: e.createdAt.isEmpty
|
||||||
|
? null
|
||||||
|
: DateTime.tryParse(e.createdAt),
|
||||||
expiresAt: e.expiresAt.isEmpty
|
expiresAt: e.expiresAt.isEmpty
|
||||||
? null
|
? null
|
||||||
: DateTime.tryParse(e.expiresAt),
|
: DateTime.tryParse(e.expiresAt),
|
||||||
|
|
@ -1156,16 +1177,28 @@ class HubService {
|
||||||
decidedBy: e.decidedBy,
|
decidedBy: e.decidedBy,
|
||||||
reason: e.reason,
|
reason: e.reason,
|
||||||
project: e.project,
|
project: e.project,
|
||||||
|
flowExecution: e.flowExecution.isEmpty ? null : e.flowExecution,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decide an approval. [reviewer] is the operator-facing handle;
|
||||||
|
/// it leaves Studio through [ReviewerIdentity.wire], which labels
|
||||||
|
/// it as the unchecked client claim it is — the hub stores the
|
||||||
|
/// string verbatim, so the record has to carry its own trust
|
||||||
|
/// level (see `lib/data/reviewer_identity.dart`). Every decide
|
||||||
|
/// path (approvals page, batch actions, the flow-editor run
|
||||||
|
/// driver) funnels through here so none of them can bypass that.
|
||||||
Future<void> approve(String id, String reviewer) =>
|
Future<void> approve(String id, String reviewer) =>
|
||||||
_client.approve(approvalId: id, reviewer: reviewer);
|
_client.approve(approvalId: id, reviewer: ReviewerIdentity.wire(reviewer));
|
||||||
|
|
||||||
Future<void> reject(String id, String reviewer, String reason) =>
|
Future<void> reject(String id, String reviewer, String reason) => _client
|
||||||
_client.reject(approvalId: id, reviewer: reviewer, reason: reason);
|
.reject(
|
||||||
|
approvalId: id,
|
||||||
|
reviewer: ReviewerIdentity.wire(reviewer),
|
||||||
|
reason: reason,
|
||||||
|
);
|
||||||
|
|
||||||
/// Detached-runs monitor snapshot: every tracked invocation
|
/// Detached-runs monitor snapshot: every tracked invocation
|
||||||
/// (newest-first, optionally scoped to one [project]) plus whether
|
/// (newest-first, optionally scoped to one [project]) plus whether
|
||||||
|
|
@ -1184,8 +1217,7 @@ class HubService {
|
||||||
|
|
||||||
/// Cancel a running/pending detached invocation. Returns true when
|
/// Cancel a running/pending detached invocation. Returns true when
|
||||||
/// it was signalled, false if already finished or unknown.
|
/// it was signalled, false if already finished or unknown.
|
||||||
Future<bool> cancelDetachedRun(String id) =>
|
Future<bool> cancelDetachedRun(String id) => _client.cancelInvocation(id);
|
||||||
_client.cancelInvocation(id);
|
|
||||||
|
|
||||||
/// Federation satellites currently connected to this hub
|
/// Federation satellites currently connected to this hub
|
||||||
/// (primary side). Empty when none are connected.
|
/// (primary side). Empty when none are connected.
|
||||||
|
|
@ -1241,9 +1273,7 @@ class HubService {
|
||||||
/// error — the hint simply stays away then.
|
/// error — the hint simply stays away then.
|
||||||
Future<UpdateStatus?> checkHubUpdate() async {
|
Future<UpdateStatus?> checkHubUpdate() async {
|
||||||
try {
|
try {
|
||||||
final r = await _client.checkUpdate().timeout(
|
final r = await _client.checkUpdate().timeout(const Duration(seconds: 3));
|
||||||
const Duration(seconds: 3),
|
|
||||||
);
|
|
||||||
if (!r.updateAvailable) return null;
|
if (!r.updateAvailable) return null;
|
||||||
return UpdateStatus(
|
return UpdateStatus(
|
||||||
channel: r.channel,
|
channel: r.channel,
|
||||||
|
|
@ -1326,8 +1356,7 @@ class HubService {
|
||||||
eventChainTamperedAt: chain.tamperedAt.isEmpty ? null : chain.tamperedAt,
|
eventChainTamperedAt: chain.tamperedAt.isEmpty ? null : chain.tamperedAt,
|
||||||
services: services
|
services: services
|
||||||
.map(
|
.map(
|
||||||
(s) =>
|
(s) => ServiceEntry(
|
||||||
ServiceEntry(
|
|
||||||
name: s.name,
|
name: s.name,
|
||||||
endpoint: s.endpoint,
|
endpoint: s.endpoint,
|
||||||
tags: s.tags,
|
tags: s.tags,
|
||||||
|
|
@ -1565,12 +1594,19 @@ class SavedFlow {
|
||||||
/// project, then `general`).
|
/// project, then `general`).
|
||||||
final String project;
|
final String project;
|
||||||
|
|
||||||
|
/// True when the file still carries the bundled-sample header —
|
||||||
|
/// it arrived via the hub's sample import, not from an operator.
|
||||||
|
/// Old hubs without the wire field report false, so no chip is
|
||||||
|
/// ever shown on guesswork.
|
||||||
|
final bool sample;
|
||||||
|
|
||||||
const SavedFlow({
|
const SavedFlow({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.path,
|
required this.path,
|
||||||
required this.sizeBytes,
|
required this.sizeBytes,
|
||||||
required this.requiredCapabilities,
|
required this.requiredCapabilities,
|
||||||
this.project = '',
|
this.project = '',
|
||||||
|
this.sample = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1758,7 +1794,11 @@ class ApprovalRecord {
|
||||||
final String stepId;
|
final String stepId;
|
||||||
final String prompt;
|
final String prompt;
|
||||||
final String? payloadPreview;
|
final String? payloadPreview;
|
||||||
final DateTime createdAt;
|
|
||||||
|
/// When the approval was requested. Null when the hub sent no
|
||||||
|
/// parseable timestamp — the card omits the line rather than
|
||||||
|
/// inventing one.
|
||||||
|
final DateTime? createdAt;
|
||||||
final DateTime? expiresAt;
|
final DateTime? expiresAt;
|
||||||
|
|
||||||
/// One of: pending / approved / rejected / expired.
|
/// One of: pending / approved / rejected / expired.
|
||||||
|
|
@ -1775,6 +1815,11 @@ class ApprovalRecord {
|
||||||
/// Project slug the requesting run was stamped with.
|
/// Project slug the requesting run was stamped with.
|
||||||
final String project;
|
final String project;
|
||||||
|
|
||||||
|
/// The flow-execution (run) id this approval belongs to. Ties the
|
||||||
|
/// pause back to the concrete run and its event timeline. Null on
|
||||||
|
/// pre-0.21 hubs that did not carry the field over the wire.
|
||||||
|
final String? flowExecution;
|
||||||
|
|
||||||
const ApprovalRecord({
|
const ApprovalRecord({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.flowName,
|
required this.flowName,
|
||||||
|
|
@ -1788,6 +1833,7 @@ class ApprovalRecord {
|
||||||
required this.decidedBy,
|
required this.decidedBy,
|
||||||
required this.reason,
|
required this.reason,
|
||||||
this.project = '',
|
this.project = '',
|
||||||
|
this.flowExecution,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
139
lib/data/reviewer_identity.dart
Normal file
139
lib/data/reviewer_identity.dart
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
// Who decided? — the single place Studio answers that question.
|
||||||
|
//
|
||||||
|
// The hub copies the `reviewer` string a client sends straight into
|
||||||
|
// `decided_by` (`DecideApproval` / `ClearEventLog`); nothing on the
|
||||||
|
// wire ties that string to the authenticated caller. The legal
|
||||||
|
// review of the approvals page (usertest 2026-07-26) called it out:
|
||||||
|
// a value that reads like an identity but is an unchecked client
|
||||||
|
// string lends the audit trail a non-repudiation it does not have.
|
||||||
|
//
|
||||||
|
// The real fix is hub-side — derive `decided_by` from the verified
|
||||||
|
// caller (`CALLER_IDENTITY`, the same source `_caller` comes from)
|
||||||
|
// and ignore what the client claims. That contract is written down
|
||||||
|
// in `docs/reviewer-identity.md`; it needs a hub release.
|
||||||
|
//
|
||||||
|
// Until then Studio does the one thing it *can* do honestly: it
|
||||||
|
// marks its own claim as a claim, inside the record. Every reviewer
|
||||||
|
// string Studio sends carries the `unverified:` prefix, so an
|
||||||
|
// export, an SQL reader or another client sees the trust level
|
||||||
|
// without having to know how the deciding Studio was configured.
|
||||||
|
// When the hub starts deriving the value it overwrites the field
|
||||||
|
// and the prefix disappears by itself — no Studio release needed
|
||||||
|
// to stop lying.
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
|
|
||||||
|
/// Marks a reviewer attribution as a client-side claim. Part of the
|
||||||
|
/// recorded value, not just a UI decoration — see the file header.
|
||||||
|
const String kUnverifiedReviewerPrefix = 'unverified:';
|
||||||
|
|
||||||
|
/// How much a reviewer attribution Studio is about to write is
|
||||||
|
/// worth, derived from the hub's authentication policy.
|
||||||
|
enum ReviewerAssurance {
|
||||||
|
/// The hub accepts calls without credentials: a decision cannot
|
||||||
|
/// be tied to anyone at all.
|
||||||
|
anonymousHub,
|
||||||
|
|
||||||
|
/// The hub authenticates the connection (static token or JWT),
|
||||||
|
/// but still records the reviewer name the client supplies.
|
||||||
|
accessControlled,
|
||||||
|
|
||||||
|
/// Studio could not read the hub's auth policy — `AuthStatus` is
|
||||||
|
/// admin-scoped, so a plain reviewer token gets PermissionDenied,
|
||||||
|
/// and hubs older than the RPC answer UNIMPLEMENTED.
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map the hub's `anonymous_allowed` flag onto an assurance level.
|
||||||
|
/// `null` = policy unreadable (denied / unsupported / offline).
|
||||||
|
ReviewerAssurance reviewerAssuranceFor(bool? anonymousAllowed) =>
|
||||||
|
switch (anonymousAllowed) {
|
||||||
|
true => ReviewerAssurance.anonymousHub,
|
||||||
|
false => ReviewerAssurance.accessControlled,
|
||||||
|
null => ReviewerAssurance.unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// What a stored `decided_by` value is worth when read back.
|
||||||
|
enum RecordedReviewerTrust {
|
||||||
|
/// Written by a Studio that labelled its own claim (`unverified:`).
|
||||||
|
selfDeclared,
|
||||||
|
|
||||||
|
/// No trust marker: a legacy row, a CLI decision, or a future
|
||||||
|
/// hub-derived identity. Studio does not know which — and must
|
||||||
|
/// not present it as proven either way.
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `decided_by` value split into what it says and what it is worth.
|
||||||
|
@immutable
|
||||||
|
class RecordedReviewer {
|
||||||
|
/// The name without the trust marker — what a human should read.
|
||||||
|
final String handle;
|
||||||
|
final RecordedReviewerTrust trust;
|
||||||
|
|
||||||
|
const RecordedReviewer({required this.handle, required this.trust});
|
||||||
|
|
||||||
|
bool get isSelfDeclared => trust == RecordedReviewerTrust.selfDeclared;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves the reviewer identity Studio submits and reads recorded
|
||||||
|
/// ones back. Never contacts the hub — the assurance level comes
|
||||||
|
/// from the caller (see [reviewerAssuranceFor]).
|
||||||
|
class ReviewerIdentity {
|
||||||
|
ReviewerIdentity._();
|
||||||
|
|
||||||
|
static String? _debugHandle;
|
||||||
|
|
||||||
|
/// Test seam: pins [localHandle] so suites do not depend on the
|
||||||
|
/// OS account of whoever runs them. `null` restores the default.
|
||||||
|
@visibleForTesting
|
||||||
|
static set debugHandle(String? value) => _debugHandle = value;
|
||||||
|
|
||||||
|
/// Human-readable handle of the operator sitting in front of
|
||||||
|
/// Studio. The OS account is the closest stable label available
|
||||||
|
/// locally — a label, never a proof, which is exactly why it
|
||||||
|
/// leaves the machine through [wire].
|
||||||
|
static String get localHandle {
|
||||||
|
final pinned = _debugHandle;
|
||||||
|
if (pinned != null) return pinned;
|
||||||
|
final user =
|
||||||
|
Platform.environment['USER'] ??
|
||||||
|
Platform.environment['USERNAME'] ??
|
||||||
|
'studio';
|
||||||
|
return '$user@studio';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The value that goes on the wire for [handle] (default:
|
||||||
|
/// [localHandle]). Idempotent, so a value that already carries the
|
||||||
|
/// marker — or one round-tripped through the UI — is not prefixed
|
||||||
|
/// twice. An empty/blank handle falls back to [localHandle]: the
|
||||||
|
/// hub rejects an empty reviewer, and a blank one would be a worse
|
||||||
|
/// record than a labelled guess.
|
||||||
|
static String wire([String? handle]) {
|
||||||
|
final trimmed = (handle ?? localHandle).trim();
|
||||||
|
final name = trimmed.isEmpty ? localHandle : trimmed;
|
||||||
|
if (name.startsWith(kUnverifiedReviewerPrefix)) return name;
|
||||||
|
return '$kUnverifiedReviewerPrefix$name';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a recorded `decided_by` into name + trust.
|
||||||
|
static RecordedReviewer parse(String recorded) {
|
||||||
|
final value = recorded.trim();
|
||||||
|
if (!value.startsWith(kUnverifiedReviewerPrefix)) {
|
||||||
|
return RecordedReviewer(
|
||||||
|
handle: value,
|
||||||
|
trust: RecordedReviewerTrust.unknown,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final handle = value.substring(kUnverifiedReviewerPrefix.length).trim();
|
||||||
|
return RecordedReviewer(
|
||||||
|
// A marker with nothing behind it still says something true
|
||||||
|
// ("someone claimed nothing"); show the marker rather than an
|
||||||
|
// empty cell.
|
||||||
|
handle: handle.isEmpty ? value : handle,
|
||||||
|
trust: RecordedReviewerTrust.selfDeclared,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,8 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
/// One sealed project instance as Studio needs it for the switcher
|
/// One sealed project instance as Studio needs it for the switcher
|
||||||
/// and the connection switch.
|
/// and the connection switch.
|
||||||
class SealedArea {
|
class SealedArea {
|
||||||
|
|
@ -63,7 +65,21 @@ class SealedArea {
|
||||||
|
|
||||||
class SealedAreaService {
|
class SealedAreaService {
|
||||||
SealedAreaService._();
|
SealedAreaService._();
|
||||||
static final SealedAreaService instance = SealedAreaService._();
|
static SealedAreaService instance = SealedAreaService._();
|
||||||
|
|
||||||
|
/// Test hook (HubService.debugSetInstance pattern): swap in a fake
|
||||||
|
/// so suites never scan the operator's real `~/.chain`. Pass null
|
||||||
|
/// to restore the real service.
|
||||||
|
@visibleForTesting
|
||||||
|
static void debugSetInstance(SealedAreaService? replacement) {
|
||||||
|
instance = replacement ?? SealedAreaService._();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extension seam for the test fake — the real constructor is
|
||||||
|
/// library-private, and the fake must not inherit a live
|
||||||
|
/// filesystem scan by accident, so it overrides the members.
|
||||||
|
@visibleForTesting
|
||||||
|
SealedAreaService.forTest();
|
||||||
|
|
||||||
/// `~/.chain` root, or null when the home dir can't be determined.
|
/// `~/.chain` root, or null when the home dir can't be determined.
|
||||||
String? _chainHome() {
|
String? _chainHome() {
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,31 @@ class SystemActions {
|
||||||
return _runFai(['project', 'start', slug]);
|
return _runFai(['project', 'start', slug]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Import the bundled sample flows (`chain flows import-samples`;
|
||||||
|
/// existing files are kept). With [sealedSlug] the import targets
|
||||||
|
/// that sealed instance's own data/modules dirs — sealed areas
|
||||||
|
/// start without samples, this is the deliberate pull.
|
||||||
|
static Future<({bool ok, String stdout, String stderr})>
|
||||||
|
chainFlowsImportSamples({String? sealedSlug}) async {
|
||||||
|
if (sealedSlug == null || sealedSlug.isEmpty) {
|
||||||
|
return _runFai(['flows', 'import-samples']);
|
||||||
|
}
|
||||||
|
final home =
|
||||||
|
Platform.environment['HOME'] ??
|
||||||
|
Platform.environment['USERPROFILE'] ??
|
||||||
|
'';
|
||||||
|
final sep = Platform.pathSeparator;
|
||||||
|
final root = '$home$sep.chain${sep}sealed$sep$sealedSlug';
|
||||||
|
return _runFai([
|
||||||
|
'flows',
|
||||||
|
'import-samples',
|
||||||
|
'--data-dir',
|
||||||
|
'$root${sep}data',
|
||||||
|
'--modules-dir',
|
||||||
|
'$root${sep}modules',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
static Future<({bool ok, String stdout, String stderr})> _runFai(
|
static Future<({bool ok, String stdout, String stderr})> _runFai(
|
||||||
List<String> args,
|
List<String> args,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,28 @@ class Workspace extends ChangeNotifier {
|
||||||
SealedArea? get activeSealed => _activeSealed;
|
SealedArea? get activeSealed => _activeSealed;
|
||||||
bool get inSealedArea => _activeSealed != null;
|
bool get inSealedArea => _activeSealed != null;
|
||||||
|
|
||||||
|
/// True while a sealed-area connection switch (either direction)
|
||||||
|
/// is in flight. Set BEFORE the hub client re-points and cleared
|
||||||
|
/// only after the new context is fully announced, so pages pause
|
||||||
|
/// their pollers and the identity bar can mark the transition —
|
||||||
|
/// otherwise a 2 s poll could render the other hub's data under
|
||||||
|
/// the old context's marking (privacy race).
|
||||||
|
bool _switching = false;
|
||||||
|
bool get switching => _switching;
|
||||||
|
|
||||||
|
/// The area being entered while [switching]; null when the switch
|
||||||
|
/// returns to the shared hub. Lets the identity bar announce the
|
||||||
|
/// target optimistically ("switching to X…").
|
||||||
|
SealedArea? _switchTarget;
|
||||||
|
SealedArea? get switchTarget => _switchTarget;
|
||||||
|
|
||||||
|
/// Monotonic counter bumped each time a connection switch
|
||||||
|
/// completes. Pages capture it before an async fetch and drop the
|
||||||
|
/// reply when it changed — a response from the previous hub must
|
||||||
|
/// never render under the new context's marking.
|
||||||
|
int _contextEpoch = 0;
|
||||||
|
int get contextEpoch => _contextEpoch;
|
||||||
|
|
||||||
/// Active project slug for the shared-hub filter; empty = all
|
/// Active project slug for the shared-hub filter; empty = all
|
||||||
/// projects. Meaningful only when [inSealedArea] is false.
|
/// projects. Meaningful only when [inSealedArea] is false.
|
||||||
String _activeSlug = '';
|
String _activeSlug = '';
|
||||||
|
|
@ -119,6 +141,10 @@ class Workspace extends ChangeNotifier {
|
||||||
_activeSlug = active;
|
_activeSlug = active;
|
||||||
_sealed = sealed;
|
_sealed = sealed;
|
||||||
_activeSealed = activeSealed;
|
_activeSealed = activeSealed;
|
||||||
|
_switching = false;
|
||||||
|
_switchTarget = null;
|
||||||
|
_sharedSlug = null;
|
||||||
|
_sharedEndpoint = null;
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
@ -163,6 +189,9 @@ class Workspace extends ChangeNotifier {
|
||||||
// there — capture it before the first sealed switch.
|
// there — capture it before the first sealed switch.
|
||||||
_sharedEndpoint ??= HubService.instance.currentEndpoint;
|
_sharedEndpoint ??= HubService.instance.currentEndpoint;
|
||||||
|
|
||||||
|
// Open the switch window BEFORE anything touches the connection.
|
||||||
|
_beginSwitch(area);
|
||||||
|
try {
|
||||||
var started = false;
|
var started = false;
|
||||||
// Re-read the current running state (the list may be stale).
|
// Re-read the current running state (the list may be stale).
|
||||||
final live = await _reread(area.slug) ?? area;
|
final live = await _reread(area.slug) ?? area;
|
||||||
|
|
@ -188,8 +217,10 @@ class Workspace extends ChangeNotifier {
|
||||||
await HubService.instance.reconnect(endpoint, persist: false);
|
await HubService.instance.reconnect(endpoint, persist: false);
|
||||||
final healthy = await HubService.instance.healthy();
|
final healthy = await HubService.instance.healthy();
|
||||||
if (!healthy) {
|
if (!healthy) {
|
||||||
// Roll back to the shared hub so Studio isn't stranded.
|
// Roll back to the shared hub so Studio isn't stranded. The
|
||||||
await switchToShared();
|
// sealed context was never announced, so only the endpoint
|
||||||
|
// needs restoring — filter and marking are untouched.
|
||||||
|
await _reconnectShared();
|
||||||
return _switchResult(
|
return _switchResult(
|
||||||
ok: false,
|
ok: false,
|
||||||
started: started,
|
started: started,
|
||||||
|
|
@ -199,17 +230,38 @@ class Workspace extends ChangeNotifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
_activeSealed = area;
|
_activeSealed = area;
|
||||||
|
// Entering from the shared hub: park the filter so the round
|
||||||
|
// trip restores it. (Area→area keeps the first park.)
|
||||||
|
_sharedSlug ??= _activeSlug;
|
||||||
_activeSlug = ''; // the shared-hub filter does not apply here
|
_activeSlug = ''; // the shared-hub filter does not apply here
|
||||||
await refresh();
|
await refresh();
|
||||||
notifyListeners();
|
|
||||||
return _switchResult(started: started);
|
return _switchResult(started: started);
|
||||||
|
} finally {
|
||||||
|
_endSwitch();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return to the shared hub from a sealed area. No-op when already
|
/// Return to the shared hub from a sealed area. No-op when already
|
||||||
/// on the shared hub.
|
/// on the shared hub. Restores the project filter that was active
|
||||||
|
/// before the sealed round trip — it was parked, not dropped.
|
||||||
Future<void> switchToShared() async {
|
Future<void> switchToShared() async {
|
||||||
if (_activeSealed == null) return;
|
if (_activeSealed == null) return;
|
||||||
|
_beginSwitch(null);
|
||||||
|
try {
|
||||||
|
await _reconnectShared();
|
||||||
_activeSealed = null;
|
_activeSealed = null;
|
||||||
|
_activeSlug = _sharedSlug ?? '';
|
||||||
|
_sharedSlug = null;
|
||||||
|
await refresh();
|
||||||
|
} finally {
|
||||||
|
_endSwitch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Point the hub client back at the shared hub (endpoint only —
|
||||||
|
/// no context bookkeeping). Shared by [switchToShared] and the
|
||||||
|
/// failed-switch rollback.
|
||||||
|
Future<void> _reconnectShared() async {
|
||||||
final shared = _sharedEndpoint;
|
final shared = _sharedEndpoint;
|
||||||
if (shared != null) {
|
if (shared != null) {
|
||||||
await HubService.instance.reconnect(shared, persist: false);
|
await HubService.instance.reconnect(shared, persist: false);
|
||||||
|
|
@ -217,12 +269,30 @@ class Workspace extends ChangeNotifier {
|
||||||
// Fall back to re-discovering the installed channel.
|
// Fall back to re-discovering the installed channel.
|
||||||
await HubService.instance.loadPersistedEndpoint();
|
await HubService.instance.loadPersistedEndpoint();
|
||||||
}
|
}
|
||||||
await refresh();
|
}
|
||||||
|
|
||||||
|
void _beginSwitch(SealedArea? target) {
|
||||||
|
_switching = true;
|
||||||
|
_switchTarget = target;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _endSwitch() {
|
||||||
|
_switching = false;
|
||||||
|
_switchTarget = null;
|
||||||
|
_contextEpoch++;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
HubEndpoint? _sharedEndpoint;
|
HubEndpoint? _sharedEndpoint;
|
||||||
|
|
||||||
|
/// The shared-hub project filter parked during a sealed session,
|
||||||
|
/// or null when none is parked. Prefs keep the parked value (they
|
||||||
|
/// always describe the SHARED hub's filter), so live state and
|
||||||
|
/// prefs agree again after the round trip — and a launch after a
|
||||||
|
/// kill inside a sealed area still restores the filter.
|
||||||
|
String? _sharedSlug;
|
||||||
|
|
||||||
Future<SealedArea?> _reread(String slug) async {
|
Future<SealedArea?> _reread(String slug) async {
|
||||||
try {
|
try {
|
||||||
final all = await SealedAreaService.instance.list();
|
final all = await SealedAreaService.instance.list();
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ class WorkspacePrefs {
|
||||||
static Future<void> load() async {
|
static Future<void> load() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
sealedNamesVisible.value = prefs.getBool(_kSealedNamesKey) ?? false;
|
sealedNamesVisible.value = prefs.getBool(_kSealedNamesKey) ?? false;
|
||||||
|
recentContexts.value =
|
||||||
|
prefs.getStringList(_kRecentContextsKey) ?? const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setSealedNamesVisible(bool value) async {
|
static Future<void> setSealedNamesVisible(bool value) async {
|
||||||
|
|
@ -29,4 +31,25 @@ class WorkspacePrefs {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setBool(_kSealedNamesKey, value);
|
await prefs.setBool(_kSealedNamesKey, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const _kRecentContextsKey = 'workspace.recent_contexts';
|
||||||
|
static const _kRecentContextsMax = 5;
|
||||||
|
|
||||||
|
/// Recently chosen switcher values (menu-value scheme: '' /
|
||||||
|
/// `p:<slug>` / `s:<slug>`), most recent first. The command
|
||||||
|
/// palette ranks matching contexts by this list so a poweruser's
|
||||||
|
/// frequent areas surface before the alphabet.
|
||||||
|
static final ValueNotifier<List<String>> recentContexts =
|
||||||
|
ValueNotifier(const []);
|
||||||
|
|
||||||
|
/// Record a switcher selection (deduped, capped, persisted).
|
||||||
|
static Future<void> recordRecentContext(String value) async {
|
||||||
|
final next = [
|
||||||
|
value,
|
||||||
|
...recentContexts.value.where((v) => v != value),
|
||||||
|
].take(_kRecentContextsMax).toList();
|
||||||
|
recentContexts.value = next;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setStringList(_kRecentContextsKey, next);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -674,6 +674,7 @@
|
||||||
"auditClearDialogTitle": "Audit-Log löschen?",
|
"auditClearDialogTitle": "Audit-Log löschen?",
|
||||||
"auditClearDialogBody": "Löscht jedes Audit-Event auf dem aktiven Kanal und seedet einen neuen chain.reset-Marker mit Prüfer und Begründung. Auf beta / production verweigert. Nicht umkehrbar.",
|
"auditClearDialogBody": "Löscht jedes Audit-Event auf dem aktiven Kanal und seedet einen neuen chain.reset-Marker mit Prüfer und Begründung. Auf beta / production verweigert. Nicht umkehrbar.",
|
||||||
"auditClearReviewerLabel": "Prüfer",
|
"auditClearReviewerLabel": "Prüfer",
|
||||||
|
"auditClearReviewerHelper": "Wird als ungeprüfte Angabe festgehalten (Präfix „unverified:“) — der Hub übernimmt den Namen unverändert.",
|
||||||
"auditClearReasonLabel": "Begründung (im chain.reset-Marker festgehalten)",
|
"auditClearReasonLabel": "Begründung (im chain.reset-Marker festgehalten)",
|
||||||
"auditClearReasonHelper": "Erforderlich — Auditoren werden das lesen.",
|
"auditClearReasonHelper": "Erforderlich — Auditoren werden das lesen.",
|
||||||
"auditClearLogButton": "Log löschen",
|
"auditClearLogButton": "Log löschen",
|
||||||
|
|
@ -1095,16 +1096,37 @@
|
||||||
"approvalsPillRejected": "abgelehnt",
|
"approvalsPillRejected": "abgelehnt",
|
||||||
"approvalsPillExpired": "abgelaufen",
|
"approvalsPillExpired": "abgelaufen",
|
||||||
"approvalsPayloadPreview": "ZU PRÜFENDE DATEN",
|
"approvalsPayloadPreview": "ZU PRÜFENDE DATEN",
|
||||||
"approvalsNoPayload": "Keine Daten zum Prüfen angehängt. Der system.approval-Schritt des Flows bestimmt über sein \"show:\"-Feld, was angezeigt wird — setzen Sie es, um die Daten hinter dieser Entscheidung sichtbar zu machen.",
|
"approvalsNoPayload": "Zu dieser Freigabe wurden keine Daten hinterlegt. Sie entscheiden ohne Detailansicht — fragen Sie im Zweifel die Person, die den Vorgang eingerichtet hat. (Technisch: das Feld \"show:\" des Freigabe-Schritts legt fest, was hier erscheint.)",
|
||||||
"approvalsNoDataConfirmTitle": "Ohne Prüfdaten freigeben?",
|
"approvalsNoDataConfirmTitle": "Ohne Prüfdaten freigeben?",
|
||||||
"approvalsNoDataConfirmBody": "Dieser Flow hat bewusst keine Prüfdaten hinterlegt (kein \"show:\" am Freigabe-Schritt). Sie können trotzdem freigeben — entscheiden dann aber, ohne die Daten hinter dieser Entscheidung gesehen zu haben.",
|
"approvalsNoDataConfirmBody": "Dieser Flow hat bewusst keine Prüfdaten hinterlegt (kein \"show:\" am Freigabe-Schritt). Sie können trotzdem freigeben — entscheiden dann aber, ohne die Daten hinter dieser Entscheidung gesehen zu haben.",
|
||||||
"approvalsNoDataConfirmAction": "Trotzdem freigeben",
|
"approvalsNoDataConfirmAction": "Trotzdem freigeben",
|
||||||
"approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich",
|
"approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich",
|
||||||
"approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}",
|
"approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}",
|
||||||
|
"approvalsIntroHelp": "Hier warten pausierte Vorgänge (Flows) auf Ihre Entscheidung. Jede Karte zeigt, welcher Vorgang an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt ihn fort, Ablehnen stoppt ihn mit Ihrer Begründung.",
|
||||||
|
"approvalsReviewerLabel": "Sie entscheiden als",
|
||||||
|
"approvalsReviewerUnverifiedPill": "nicht überprüft",
|
||||||
|
"approvalsReviewerRecordedAs": "Wird gespeichert als: {value}",
|
||||||
|
"approvalsReviewerNoteAnonymous": "Dieser Hub nimmt Aufrufe ohne Zugangsdaten an — eine Entscheidung lässt sich damit niemandem nachweisen. Studio kennzeichnet den Namen deshalb im Eintrag selbst als ungeprüfte Angabe.",
|
||||||
|
"approvalsReviewerNoteAuthenticated": "Der Hub prüft Ihren Zugang, übernimmt den Namen aber unverändert von Studio. Bis der Hub die Identität aus dem geprüften Zugang selbst ableitet, kennzeichnet Studio den Namen im Eintrag als ungeprüfte Angabe.",
|
||||||
|
"approvalsReviewerNoteUnknown": "Studio konnte die Zugangs-Einstellungen dieses Hubs nicht lesen (dafür braucht es Admin-Rechte). Der Name wird unverändert übernommen und deshalb im Eintrag als ungeprüfte Angabe gekennzeichnet.",
|
||||||
|
"approvalsReviewerUnverifiedTooltip": "Ungeprüfte Angabe: Dieser Name stammt vom entscheidenden Studio, nicht vom Hub.",
|
||||||
|
"approvalsRejectDialogHelp": "Die Ablehnung stoppt den Vorgang an diesem Schritt und wird mit Ihrer Begründung im Prüfprotokoll festgehalten.",
|
||||||
|
"approvalsRejectReasonHelper": "Pflichtfeld — wird im Prüfprotokoll (Audit-Log) festgehalten.",
|
||||||
|
"approvalsBatchNoDataTitle": "Ohne Prüfdaten freigeben?",
|
||||||
|
"approvalsBatchNoDataBody": "{count, plural, =1{Von {total} ausgewählten Freigaben hat 1 keine Prüfdaten. Sie geben sie ohne Detailansicht frei.} other{Von {total} ausgewählten Freigaben haben {count} keine Prüfdaten. Sie geben sie ohne Detailansicht frei.}}",
|
||||||
|
"approvalsCopyRun": "Lauf-Kennung kopieren",
|
||||||
|
"approvalsRunCopied": "Lauf-Kennung kopiert",
|
||||||
|
"approvalsUnknownReviewer": "(unbekannt)",
|
||||||
|
"approvalsOriginLabel": "HERKUNFT",
|
||||||
|
"approvalsOriginProject": "Projekt",
|
||||||
|
"approvalsOriginRequested": "Angefordert",
|
||||||
|
"approvalsOriginRun": "Lauf",
|
||||||
|
"approvalsApproveTooltip": "Setzt den Flow an diesem Schritt fort",
|
||||||
|
"approvalsRejectTooltip": "Stoppt den Flow — mit Ihrer Begründung",
|
||||||
"approvalsApproveButton": "Freigeben",
|
"approvalsApproveButton": "Freigeben",
|
||||||
"approvalsRejectButton": "Ablehnen",
|
"approvalsRejectButton": "Ablehnen",
|
||||||
"approvalsRejectDialogTitle": "Freigabe ablehnen",
|
"approvalsRejectDialogTitle": "Freigabe ablehnen",
|
||||||
"approvalsRejectReasonLabel": "Begründung (im Audit-Log festgehalten)",
|
"approvalsRejectReasonLabel": "Begründung",
|
||||||
"approvalsApprovedToast": "freigegeben · {flow} › {step}",
|
"approvalsApprovedToast": "freigegeben · {flow} › {step}",
|
||||||
"@approvalsApprovedToast": {
|
"@approvalsApprovedToast": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|
@ -1148,7 +1170,7 @@
|
||||||
"approvalsDialogDecided": "entschieden",
|
"approvalsDialogDecided": "entschieden",
|
||||||
"approvalsDialogCreated": "erstellt",
|
"approvalsDialogCreated": "erstellt",
|
||||||
"approvalsDialogReason": "Begründung",
|
"approvalsDialogReason": "Begründung",
|
||||||
"approvalsDialogPrompt": "PROMPT",
|
"approvalsDialogPrompt": "FRAGE",
|
||||||
"approvalsExpiresIn": "{minutes}m",
|
"approvalsExpiresIn": "{minutes}m",
|
||||||
"@approvalsExpiresIn": {
|
"@approvalsExpiresIn": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|
@ -1391,6 +1413,9 @@
|
||||||
"searchHintOpen": "Enter öffnen",
|
"searchHintOpen": "Enter öffnen",
|
||||||
"searchHintClose": "Esc schließen",
|
"searchHintClose": "Esc schließen",
|
||||||
"searchGroupPages": "Seiten",
|
"searchGroupPages": "Seiten",
|
||||||
|
"searchGroupWorkspace": "Projekte & Bereiche",
|
||||||
|
"searchWorkspaceHint": "Kontext wechseln · ⌘P",
|
||||||
|
"searchWorkspaceSealedPickerHint": "Auswahl öffnen · ⌘P",
|
||||||
"searchGroupModules": "Module",
|
"searchGroupModules": "Module",
|
||||||
"searchGroupStore": "Store",
|
"searchGroupStore": "Store",
|
||||||
"searchGroupFlows": "Flows",
|
"searchGroupFlows": "Flows",
|
||||||
|
|
@ -1811,7 +1836,14 @@
|
||||||
"federationEnrollmentHint": "Übergeben Sie das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte Zertifikatsstelle authentifiziert die erste Verbindung des Satelliten.",
|
"federationEnrollmentHint": "Übergeben Sie das Token dem Satelliten-Betreiber über einen sicheren Kanal. Die mitgelieferte Zertifikatsstelle authentifiziert die erste Verbindung des Satelliten.",
|
||||||
"workspaceAll": "Alle Projekte",
|
"workspaceAll": "Alle Projekte",
|
||||||
"workspaceDefaultProject": "Allgemein",
|
"workspaceDefaultProject": "Allgemein",
|
||||||
"workspaceSwitcherTooltip": "Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt",
|
"workspaceSwitcherTooltip": "Arbeitskontext wählen — gilt überall: filtert Flows, Läufe, Audit und Freigaben und stempelt neue Läufe mit dem gewählten Projekt",
|
||||||
|
"workspaceAnchorCaption": "Kontext",
|
||||||
|
"workspaceStartConfirmTitle": "Bereich „{name}“ starten?",
|
||||||
|
"@workspaceStartConfirmTitle": {"placeholders": {"name": {"type": "String"}}},
|
||||||
|
"workspaceStartConfirmBody": "Dieser abgeschottete Bereich ist gerade gestoppt. Studio startet seine eigene Hub-Instanz auf diesem Rechner und verbindet sich mit ihr — laufende Bereiche wechseln ohne diese Rückfrage.",
|
||||||
|
"workspaceStartConfirmAction": "Starten und verbinden",
|
||||||
|
"workspaceSealedAggregateWhy": "Namen bleiben zusammengefasst, weil sie Mandanten verraten können.",
|
||||||
|
"workspaceSealedAggregateSettings": "Immer anzeigen: Einstellungen → Sicherheit",
|
||||||
"workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.",
|
"workspaceProtectedHint": "Geschützt: logisch getrennt im gemeinsamen Hub — keine harte Prozessgrenze. Kritische Mandate nutzen einen abgeschotteten Bereich.",
|
||||||
"runsTitle": "Läufe",
|
"runsTitle": "Läufe",
|
||||||
"runsReloadTooltip": "Lauf-Liste neu laden",
|
"runsReloadTooltip": "Lauf-Liste neu laden",
|
||||||
|
|
@ -1819,9 +1851,15 @@
|
||||||
"runsEmptyHint": "Hier erscheinen Läufe, die im Hintergrund weiterlaufen, während Sie anderes tun. Diese Funktion ist optional und standardmäßig ausgeschaltet — die Anleitung zeigt Schritt für Schritt, wie sie eingeschaltet wird.",
|
"runsEmptyHint": "Hier erscheinen Läufe, die im Hintergrund weiterlaufen, während Sie anderes tun. Diese Funktion ist optional und standardmäßig ausgeschaltet — die Anleitung zeigt Schritt für Schritt, wie sie eingeschaltet wird.",
|
||||||
"runsEmptyEnabledHint": "Läufe im Hintergrund sind eingeschaltet — es wurde nur noch keiner gestartet. Starten Sie einen Flow mit der Option „im Hintergrund ausführen“, dann erscheint er hier.",
|
"runsEmptyEnabledHint": "Läufe im Hintergrund sind eingeschaltet — es wurde nur noch keiner gestartet. Starten Sie einen Flow mit der Option „im Hintergrund ausführen“, dann erscheint er hier.",
|
||||||
"runsEmptyGuideButton": "Anleitung öffnen",
|
"runsEmptyGuideButton": "Anleitung öffnen",
|
||||||
|
"runsEmptyFilteredTitle": "Keine Läufe in Projekt „{name}“",
|
||||||
|
"@runsEmptyFilteredTitle": {"placeholders": {"name": {"type": "String"}}},
|
||||||
|
"runsEmptyFilteredHint": "Der Arbeitskontext filtert diese Liste — in anderen Projekten kann es Läufe geben.",
|
||||||
|
"runsEmptyShowAll": "Alle Projekte anzeigen",
|
||||||
"runsHubTooOldTitle": "Diese Ansicht braucht eine neuere Hub-Version",
|
"runsHubTooOldTitle": "Diese Ansicht braucht eine neuere Hub-Version",
|
||||||
"runsHubTooOldHint": "Der Hub ist verbunden, aber seine Version kennt die Laufübersicht noch nicht. Aktualisieren Sie den Hub, dann erscheinen die Läufe hier.",
|
"runsHubTooOldHint": "Der Hub ist verbunden, aber seine Version kennt die Laufübersicht noch nicht. Aktualisieren Sie den Hub, dann erscheinen die Läufe hier.",
|
||||||
"runsHubTooOldButton": "Diagnose öffnen",
|
"runsHubTooOldButton": "Diagnose öffnen",
|
||||||
|
"runsHubUpdateButton": "Hub jetzt aktualisieren",
|
||||||
|
"runsHubUpdateStarted": "Hub wird aktualisiert…",
|
||||||
"runsLoadFailedTitle": "Läufe konnten nicht geladen werden",
|
"runsLoadFailedTitle": "Läufe konnten nicht geladen werden",
|
||||||
"runsCancelButton": "Abbrechen",
|
"runsCancelButton": "Abbrechen",
|
||||||
"runsCancelSignalled": "Abbruch für {flow} ausgelöst.",
|
"runsCancelSignalled": "Abbruch für {flow} ausgelöst.",
|
||||||
|
|
@ -1853,6 +1891,7 @@
|
||||||
"workspaceSealedSwitchFailed": "Wechsel in den abgeschotteten Bereich fehlgeschlagen: {error}",
|
"workspaceSealedSwitchFailed": "Wechsel in den abgeschotteten Bereich fehlgeschlagen: {error}",
|
||||||
"@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}},
|
"@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}},
|
||||||
"sealedIdentityBar": "Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette",
|
"sealedIdentityBar": "Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette",
|
||||||
|
"sealedSwitchingBar": "Verbindung wird umgeschaltet — Daten erscheinen erst nach Abschluss",
|
||||||
"sealedLeave": "Verlassen",
|
"sealedLeave": "Verlassen",
|
||||||
"setupStepOf": "Schritt {n} von {total}",
|
"setupStepOf": "Schritt {n} von {total}",
|
||||||
"@setupStepOf": {"placeholders": {"n": {"type": "int"}, "total": {"type": "int"}}},
|
"@setupStepOf": {"placeholders": {"n": {"type": "int"}, "total": {"type": "int"}}},
|
||||||
|
|
|
||||||
|
|
@ -692,6 +692,7 @@
|
||||||
"auditClearDialogTitle": "Clear audit log?",
|
"auditClearDialogTitle": "Clear audit log?",
|
||||||
"auditClearDialogBody": "Wipes every audit event on the active channel and seeds a fresh chain.reset marker carrying reviewer + reason. Refused on beta / production. Irreversible.",
|
"auditClearDialogBody": "Wipes every audit event on the active channel and seeds a fresh chain.reset marker carrying reviewer + reason. Refused on beta / production. Irreversible.",
|
||||||
"auditClearReviewerLabel": "Reviewer",
|
"auditClearReviewerLabel": "Reviewer",
|
||||||
|
"auditClearReviewerHelper": "Recorded as an unchecked claim (prefix \"unverified:\") — the hub stores this name as sent.",
|
||||||
"auditClearReasonLabel": "Reason (recorded in chain.reset marker)",
|
"auditClearReasonLabel": "Reason (recorded in chain.reset marker)",
|
||||||
"auditClearReasonHelper": "Required — auditors will read this.",
|
"auditClearReasonHelper": "Required — auditors will read this.",
|
||||||
"auditClearLogButton": "Clear log",
|
"auditClearLogButton": "Clear log",
|
||||||
|
|
@ -1113,12 +1114,33 @@
|
||||||
"approvalsPillRejected": "rejected",
|
"approvalsPillRejected": "rejected",
|
||||||
"approvalsPillExpired": "expired",
|
"approvalsPillExpired": "expired",
|
||||||
"approvalsPayloadPreview": "DATA TO REVIEW",
|
"approvalsPayloadPreview": "DATA TO REVIEW",
|
||||||
"approvalsNoPayload": "No data was attached for review. The flow's approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.",
|
"approvalsNoPayload": "No data was attached to this approval. You are deciding without a detail view — when in doubt, ask whoever set up the process. (Technical: the approval step's \"show:\" field controls what appears here.)",
|
||||||
"approvalsNoDataConfirmTitle": "Approve without review data?",
|
"approvalsNoDataConfirmTitle": "Approve without review data?",
|
||||||
"approvalsNoDataConfirmBody": "This flow deliberately attached no review data (no \"show:\" on its approval step). You can still approve — but you would be deciding without seeing the data behind this decision.",
|
"approvalsNoDataConfirmBody": "This flow deliberately attached no review data (no \"show:\" on its approval step). You can still approve — but you would be deciding without seeing the data behind this decision.",
|
||||||
"approvalsNoDataConfirmAction": "Approve anyway",
|
"approvalsNoDataConfirmAction": "Approve anyway",
|
||||||
"approvalsRequestFallback": "Approval required for this step",
|
"approvalsRequestFallback": "Approval required for this step",
|
||||||
"approvalsFlowStepMeta": "Flow: {flow} · Step: {step}",
|
"approvalsFlowStepMeta": "Flow: {flow} · Step: {step}",
|
||||||
|
"approvalsIntroHelp": "Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.",
|
||||||
|
"approvalsReviewerLabel": "You are deciding as",
|
||||||
|
"approvalsReviewerUnverifiedPill": "not verified",
|
||||||
|
"approvalsReviewerRecordedAs": "Recorded as: {value}",
|
||||||
|
"approvalsReviewerNoteAnonymous": "This hub accepts calls without credentials, so a decision cannot be proven to be anyone's. Studio therefore marks the name inside the record itself as an unchecked claim.",
|
||||||
|
"approvalsReviewerNoteAuthenticated": "The hub checks your access but records the name exactly as Studio sends it. Until the hub derives the identity from the checked access itself, Studio marks the name inside the record as an unchecked claim.",
|
||||||
|
"approvalsReviewerNoteUnknown": "Studio could not read this hub's access settings (that needs admin rights). The name is stored exactly as sent and is therefore marked inside the record as an unchecked claim.",
|
||||||
|
"approvalsReviewerUnverifiedTooltip": "Unchecked claim: this name comes from the deciding Studio, not from the hub.",
|
||||||
|
"approvalsRejectDialogHelp": "Rejecting stops the process at this step and is recorded with your reason in the audit trail.",
|
||||||
|
"approvalsRejectReasonHelper": "Required — recorded in the audit log.",
|
||||||
|
"approvalsBatchNoDataTitle": "Approve without review data?",
|
||||||
|
"approvalsBatchNoDataBody": "{count, plural, =1{1 of {total} selected approvals has no review data. You are approving it without a detail view.} other{{count} of {total} selected approvals have no review data. You are approving them without a detail view.}}",
|
||||||
|
"approvalsCopyRun": "Copy run id",
|
||||||
|
"approvalsRunCopied": "Run id copied",
|
||||||
|
"approvalsUnknownReviewer": "(unknown)",
|
||||||
|
"approvalsOriginLabel": "ORIGIN",
|
||||||
|
"approvalsOriginProject": "Project",
|
||||||
|
"approvalsOriginRequested": "Requested",
|
||||||
|
"approvalsOriginRun": "Run",
|
||||||
|
"approvalsApproveTooltip": "Resumes the flow at this step",
|
||||||
|
"approvalsRejectTooltip": "Stops the flow — with your reason",
|
||||||
"@approvalsFlowStepMeta": {
|
"@approvalsFlowStepMeta": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"flow": { "type": "String" },
|
"flow": { "type": "String" },
|
||||||
|
|
@ -1128,7 +1150,7 @@
|
||||||
"approvalsApproveButton": "Approve",
|
"approvalsApproveButton": "Approve",
|
||||||
"approvalsRejectButton": "Reject",
|
"approvalsRejectButton": "Reject",
|
||||||
"approvalsRejectDialogTitle": "Reject approval",
|
"approvalsRejectDialogTitle": "Reject approval",
|
||||||
"approvalsRejectReasonLabel": "Reason (recorded in audit log)",
|
"approvalsRejectReasonLabel": "Reason",
|
||||||
"approvalsApprovedToast": "approved · {flow} › {step}",
|
"approvalsApprovedToast": "approved · {flow} › {step}",
|
||||||
"@approvalsApprovedToast": {
|
"@approvalsApprovedToast": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|
@ -1172,7 +1194,7 @@
|
||||||
"approvalsDialogDecided": "decided",
|
"approvalsDialogDecided": "decided",
|
||||||
"approvalsDialogCreated": "created",
|
"approvalsDialogCreated": "created",
|
||||||
"approvalsDialogReason": "reason",
|
"approvalsDialogReason": "reason",
|
||||||
"approvalsDialogPrompt": "PROMPT",
|
"approvalsDialogPrompt": "QUESTION",
|
||||||
"approvalsExpiresIn": "{minutes}m",
|
"approvalsExpiresIn": "{minutes}m",
|
||||||
"@approvalsExpiresIn": {
|
"@approvalsExpiresIn": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|
@ -1415,6 +1437,9 @@
|
||||||
"searchHintOpen": "enter open",
|
"searchHintOpen": "enter open",
|
||||||
"searchHintClose": "esc close",
|
"searchHintClose": "esc close",
|
||||||
"searchGroupPages": "Pages",
|
"searchGroupPages": "Pages",
|
||||||
|
"searchGroupWorkspace": "Projects & areas",
|
||||||
|
"searchWorkspaceHint": "switch context · ⌘P",
|
||||||
|
"searchWorkspaceSealedPickerHint": "open the picker · ⌘P",
|
||||||
"searchGroupModules": "Modules",
|
"searchGroupModules": "Modules",
|
||||||
"searchGroupStore": "Store",
|
"searchGroupStore": "Store",
|
||||||
"searchGroupFlows": "Flows",
|
"searchGroupFlows": "Flows",
|
||||||
|
|
@ -1850,7 +1875,14 @@
|
||||||
"federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect.",
|
"federationEnrollmentHint": "Hand the token to the satellite operator over a secure channel. The bundled CA authenticates the satellite's first connect.",
|
||||||
"workspaceAll": "All projects",
|
"workspaceAll": "All projects",
|
||||||
"workspaceDefaultProject": "General",
|
"workspaceDefaultProject": "General",
|
||||||
"workspaceSwitcherTooltip": "Workspace — filters this view and stamps new runs with the selected project",
|
"workspaceSwitcherTooltip": "Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project",
|
||||||
|
"workspaceAnchorCaption": "Context",
|
||||||
|
"workspaceStartConfirmTitle": "Start area “{name}”?",
|
||||||
|
"@workspaceStartConfirmTitle": {"placeholders": {"name": {"type": "String"}}},
|
||||||
|
"workspaceStartConfirmBody": "This sealed area is currently stopped. Studio starts its own hub instance on this machine and connects to it — running areas switch without this prompt.",
|
||||||
|
"workspaceStartConfirmAction": "Start and connect",
|
||||||
|
"workspaceSealedAggregateWhy": "Names stay aggregated because they can reveal client identities.",
|
||||||
|
"workspaceSealedAggregateSettings": "Always show: Settings → Security",
|
||||||
"workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.",
|
"workspaceProtectedHint": "Protected: logically separated in the shared hub — no hard process barrier. Critical engagements use a sealed area.",
|
||||||
"runsTitle": "Runs",
|
"runsTitle": "Runs",
|
||||||
"runsReloadTooltip": "Reload the runs list",
|
"runsReloadTooltip": "Reload the runs list",
|
||||||
|
|
@ -1858,9 +1890,15 @@
|
||||||
"runsEmptyHint": "Runs that keep working in the background while you do something else appear here. The feature is optional and off by default — the guide shows step by step how to turn it on.",
|
"runsEmptyHint": "Runs that keep working in the background while you do something else appear here. The feature is optional and off by default — the guide shows step by step how to turn it on.",
|
||||||
"runsEmptyEnabledHint": "Background runs are switched on — none has been started yet. Start a flow with the \"run in background\" option and it will appear here.",
|
"runsEmptyEnabledHint": "Background runs are switched on — none has been started yet. Start a flow with the \"run in background\" option and it will appear here.",
|
||||||
"runsEmptyGuideButton": "Open the guide",
|
"runsEmptyGuideButton": "Open the guide",
|
||||||
|
"runsEmptyFilteredTitle": "No runs in project “{name}”",
|
||||||
|
"@runsEmptyFilteredTitle": {"placeholders": {"name": {"type": "String"}}},
|
||||||
|
"runsEmptyFilteredHint": "The working context filters this list — other projects may have runs.",
|
||||||
|
"runsEmptyShowAll": "Show all projects",
|
||||||
"runsHubTooOldTitle": "This view needs a newer hub version",
|
"runsHubTooOldTitle": "This view needs a newer hub version",
|
||||||
"runsHubTooOldHint": "The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.",
|
"runsHubTooOldHint": "The hub is connected, but its version does not know the runs monitor yet. Update the hub and the runs will appear here.",
|
||||||
"runsHubTooOldButton": "Open Doctor",
|
"runsHubTooOldButton": "Open Doctor",
|
||||||
|
"runsHubUpdateButton": "Update hub now",
|
||||||
|
"runsHubUpdateStarted": "Updating hub…",
|
||||||
"runsLoadFailedTitle": "Runs could not be loaded",
|
"runsLoadFailedTitle": "Runs could not be loaded",
|
||||||
"runsCancelButton": "Cancel",
|
"runsCancelButton": "Cancel",
|
||||||
"runsCancelSignalled": "Cancel signalled for {flow}.",
|
"runsCancelSignalled": "Cancel signalled for {flow}.",
|
||||||
|
|
@ -1892,6 +1930,7 @@
|
||||||
"workspaceSealedSwitchFailed": "Could not switch to the sealed area: {error}",
|
"workspaceSealedSwitchFailed": "Could not switch to the sealed area: {error}",
|
||||||
"@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}},
|
"@workspaceSealedSwitchFailed": {"placeholders": {"error": {"type": "String"}}},
|
||||||
"sealedIdentityBar": "Sealed area — isolated hub, own data and audit chain",
|
"sealedIdentityBar": "Sealed area — isolated hub, own data and audit chain",
|
||||||
|
"sealedSwitchingBar": "Switching connection — data appears once the switch completes",
|
||||||
"sealedLeave": "Leave",
|
"sealedLeave": "Leave",
|
||||||
"setupStepOf": "Step {n} of {total}",
|
"setupStepOf": "Step {n} of {total}",
|
||||||
"@setupStepOf": {"placeholders": {"n": {"type": "int"}, "total": {"type": "int"}}},
|
"@setupStepOf": {"placeholders": {"n": {"type": "int"}, "total": {"type": "int"}}},
|
||||||
|
|
|
||||||
|
|
@ -2384,6 +2384,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Reviewer'**
|
/// **'Reviewer'**
|
||||||
String get auditClearReviewerLabel;
|
String get auditClearReviewerLabel;
|
||||||
|
|
||||||
|
/// No description provided for @auditClearReviewerHelper.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recorded as an unchecked claim (prefix \"unverified:\") — the hub stores this name as sent.'**
|
||||||
|
String get auditClearReviewerHelper;
|
||||||
|
|
||||||
/// No description provided for @auditClearReasonLabel.
|
/// No description provided for @auditClearReasonLabel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -3521,7 +3527,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @approvalsNoPayload.
|
/// No description provided for @approvalsNoPayload.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'No data was attached for review. The flow\'s approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.'**
|
/// **'No data was attached to this approval. You are deciding without a detail view — when in doubt, ask whoever set up the process. (Technical: the approval step\'s \"show:\" field controls what appears here.)'**
|
||||||
String get approvalsNoPayload;
|
String get approvalsNoPayload;
|
||||||
|
|
||||||
/// No description provided for @approvalsNoDataConfirmTitle.
|
/// No description provided for @approvalsNoDataConfirmTitle.
|
||||||
|
|
@ -3554,6 +3560,132 @@ abstract class AppLocalizations {
|
||||||
/// **'Flow: {flow} · Step: {step}'**
|
/// **'Flow: {flow} · Step: {step}'**
|
||||||
String approvalsFlowStepMeta(String flow, String step);
|
String approvalsFlowStepMeta(String flow, String step);
|
||||||
|
|
||||||
|
/// No description provided for @approvalsIntroHelp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.'**
|
||||||
|
String get approvalsIntroHelp;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsReviewerLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'You are deciding as'**
|
||||||
|
String get approvalsReviewerLabel;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsReviewerUnverifiedPill.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'not verified'**
|
||||||
|
String get approvalsReviewerUnverifiedPill;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsReviewerRecordedAs.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recorded as: {value}'**
|
||||||
|
String approvalsReviewerRecordedAs(Object value);
|
||||||
|
|
||||||
|
/// No description provided for @approvalsReviewerNoteAnonymous.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This hub accepts calls without credentials, so a decision cannot be proven to be anyone\'s. Studio therefore marks the name inside the record itself as an unchecked claim.'**
|
||||||
|
String get approvalsReviewerNoteAnonymous;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsReviewerNoteAuthenticated.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The hub checks your access but records the name exactly as Studio sends it. Until the hub derives the identity from the checked access itself, Studio marks the name inside the record as an unchecked claim.'**
|
||||||
|
String get approvalsReviewerNoteAuthenticated;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsReviewerNoteUnknown.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Studio could not read this hub\'s access settings (that needs admin rights). The name is stored exactly as sent and is therefore marked inside the record as an unchecked claim.'**
|
||||||
|
String get approvalsReviewerNoteUnknown;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsReviewerUnverifiedTooltip.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unchecked claim: this name comes from the deciding Studio, not from the hub.'**
|
||||||
|
String get approvalsReviewerUnverifiedTooltip;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsRejectDialogHelp.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Rejecting stops the process at this step and is recorded with your reason in the audit trail.'**
|
||||||
|
String get approvalsRejectDialogHelp;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsRejectReasonHelper.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Required — recorded in the audit log.'**
|
||||||
|
String get approvalsRejectReasonHelper;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsBatchNoDataTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Approve without review data?'**
|
||||||
|
String get approvalsBatchNoDataTitle;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsBatchNoDataBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1{1 of {total} selected approvals has no review data. You are approving it without a detail view.} other{{count} of {total} selected approvals have no review data. You are approving them without a detail view.}}'**
|
||||||
|
String approvalsBatchNoDataBody(num count, Object total);
|
||||||
|
|
||||||
|
/// No description provided for @approvalsCopyRun.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Copy run id'**
|
||||||
|
String get approvalsCopyRun;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsRunCopied.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Run id copied'**
|
||||||
|
String get approvalsRunCopied;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsUnknownReviewer.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'(unknown)'**
|
||||||
|
String get approvalsUnknownReviewer;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsOriginLabel.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'ORIGIN'**
|
||||||
|
String get approvalsOriginLabel;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsOriginProject.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Project'**
|
||||||
|
String get approvalsOriginProject;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsOriginRequested.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Requested'**
|
||||||
|
String get approvalsOriginRequested;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsOriginRun.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Run'**
|
||||||
|
String get approvalsOriginRun;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsApproveTooltip.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Resumes the flow at this step'**
|
||||||
|
String get approvalsApproveTooltip;
|
||||||
|
|
||||||
|
/// No description provided for @approvalsRejectTooltip.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Stops the flow — with your reason'**
|
||||||
|
String get approvalsRejectTooltip;
|
||||||
|
|
||||||
/// No description provided for @approvalsApproveButton.
|
/// No description provided for @approvalsApproveButton.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -3575,7 +3707,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @approvalsRejectReasonLabel.
|
/// No description provided for @approvalsRejectReasonLabel.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Reason (recorded in audit log)'**
|
/// **'Reason'**
|
||||||
String get approvalsRejectReasonLabel;
|
String get approvalsRejectReasonLabel;
|
||||||
|
|
||||||
/// No description provided for @approvalsApprovedToast.
|
/// No description provided for @approvalsApprovedToast.
|
||||||
|
|
@ -3635,7 +3767,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @approvalsDialogPrompt.
|
/// No description provided for @approvalsDialogPrompt.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'PROMPT'**
|
/// **'QUESTION'**
|
||||||
String get approvalsDialogPrompt;
|
String get approvalsDialogPrompt;
|
||||||
|
|
||||||
/// No description provided for @approvalsExpiresIn.
|
/// No description provided for @approvalsExpiresIn.
|
||||||
|
|
@ -4268,6 +4400,24 @@ abstract class AppLocalizations {
|
||||||
/// **'Pages'**
|
/// **'Pages'**
|
||||||
String get searchGroupPages;
|
String get searchGroupPages;
|
||||||
|
|
||||||
|
/// No description provided for @searchGroupWorkspace.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Projects & areas'**
|
||||||
|
String get searchGroupWorkspace;
|
||||||
|
|
||||||
|
/// No description provided for @searchWorkspaceHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'switch context · ⌘P'**
|
||||||
|
String get searchWorkspaceHint;
|
||||||
|
|
||||||
|
/// No description provided for @searchWorkspaceSealedPickerHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'open the picker · ⌘P'**
|
||||||
|
String get searchWorkspaceSealedPickerHint;
|
||||||
|
|
||||||
/// No description provided for @searchGroupModules.
|
/// No description provided for @searchGroupModules.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -5620,9 +5770,45 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @workspaceSwitcherTooltip.
|
/// No description provided for @workspaceSwitcherTooltip.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Workspace — filters this view and stamps new runs with the selected project'**
|
/// **'Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project'**
|
||||||
String get workspaceSwitcherTooltip;
|
String get workspaceSwitcherTooltip;
|
||||||
|
|
||||||
|
/// No description provided for @workspaceAnchorCaption.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Context'**
|
||||||
|
String get workspaceAnchorCaption;
|
||||||
|
|
||||||
|
/// No description provided for @workspaceStartConfirmTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Start area “{name}”?'**
|
||||||
|
String workspaceStartConfirmTitle(String name);
|
||||||
|
|
||||||
|
/// No description provided for @workspaceStartConfirmBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This sealed area is currently stopped. Studio starts its own hub instance on this machine and connects to it — running areas switch without this prompt.'**
|
||||||
|
String get workspaceStartConfirmBody;
|
||||||
|
|
||||||
|
/// No description provided for @workspaceStartConfirmAction.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Start and connect'**
|
||||||
|
String get workspaceStartConfirmAction;
|
||||||
|
|
||||||
|
/// No description provided for @workspaceSealedAggregateWhy.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Names stay aggregated because they can reveal client identities.'**
|
||||||
|
String get workspaceSealedAggregateWhy;
|
||||||
|
|
||||||
|
/// No description provided for @workspaceSealedAggregateSettings.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Always show: Settings → Security'**
|
||||||
|
String get workspaceSealedAggregateSettings;
|
||||||
|
|
||||||
/// No description provided for @workspaceProtectedHint.
|
/// No description provided for @workspaceProtectedHint.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -5665,6 +5851,24 @@ abstract class AppLocalizations {
|
||||||
/// **'Open the guide'**
|
/// **'Open the guide'**
|
||||||
String get runsEmptyGuideButton;
|
String get runsEmptyGuideButton;
|
||||||
|
|
||||||
|
/// No description provided for @runsEmptyFilteredTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No runs in project “{name}”'**
|
||||||
|
String runsEmptyFilteredTitle(String name);
|
||||||
|
|
||||||
|
/// No description provided for @runsEmptyFilteredHint.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'The working context filters this list — other projects may have runs.'**
|
||||||
|
String get runsEmptyFilteredHint;
|
||||||
|
|
||||||
|
/// No description provided for @runsEmptyShowAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Show all projects'**
|
||||||
|
String get runsEmptyShowAll;
|
||||||
|
|
||||||
/// No description provided for @runsHubTooOldTitle.
|
/// No description provided for @runsHubTooOldTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -5683,6 +5887,18 @@ abstract class AppLocalizations {
|
||||||
/// **'Open Doctor'**
|
/// **'Open Doctor'**
|
||||||
String get runsHubTooOldButton;
|
String get runsHubTooOldButton;
|
||||||
|
|
||||||
|
/// No description provided for @runsHubUpdateButton.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Update hub now'**
|
||||||
|
String get runsHubUpdateButton;
|
||||||
|
|
||||||
|
/// No description provided for @runsHubUpdateStarted.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Updating hub…'**
|
||||||
|
String get runsHubUpdateStarted;
|
||||||
|
|
||||||
/// No description provided for @runsLoadFailedTitle.
|
/// No description provided for @runsLoadFailedTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -5833,6 +6049,12 @@ abstract class AppLocalizations {
|
||||||
/// **'Sealed area — isolated hub, own data and audit chain'**
|
/// **'Sealed area — isolated hub, own data and audit chain'**
|
||||||
String get sealedIdentityBar;
|
String get sealedIdentityBar;
|
||||||
|
|
||||||
|
/// No description provided for @sealedSwitchingBar.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Switching connection — data appears once the switch completes'**
|
||||||
|
String get sealedSwitchingBar;
|
||||||
|
|
||||||
/// No description provided for @sealedLeave.
|
/// No description provided for @sealedLeave.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
|
||||||
|
|
@ -1351,6 +1351,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get auditClearReviewerLabel => 'Prüfer';
|
String get auditClearReviewerLabel => 'Prüfer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get auditClearReviewerHelper =>
|
||||||
|
'Wird als ungeprüfte Angabe festgehalten (Präfix „unverified:“) — der Hub übernimmt den Namen unverändert.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get auditClearReasonLabel =>
|
String get auditClearReasonLabel =>
|
||||||
'Begründung (im chain.reset-Marker festgehalten)';
|
'Begründung (im chain.reset-Marker festgehalten)';
|
||||||
|
|
@ -2037,7 +2041,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsNoPayload =>
|
String get approvalsNoPayload =>
|
||||||
'Keine Daten zum Prüfen angehängt. Der system.approval-Schritt des Flows bestimmt über sein \"show:\"-Feld, was angezeigt wird — setzen Sie es, um die Daten hinter dieser Entscheidung sichtbar zu machen.';
|
'Zu dieser Freigabe wurden keine Daten hinterlegt. Sie entscheiden ohne Detailansicht — fragen Sie im Zweifel die Person, die den Vorgang eingerichtet hat. (Technisch: das Feld \"show:\" des Freigabe-Schritts legt fest, was hier erscheint.)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsNoDataConfirmTitle => 'Ohne Prüfdaten freigeben?';
|
String get approvalsNoDataConfirmTitle => 'Ohne Prüfdaten freigeben?';
|
||||||
|
|
@ -2058,6 +2062,88 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return 'Flow: $flow · Schritt: $step';
|
return 'Flow: $flow · Schritt: $step';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsIntroHelp =>
|
||||||
|
'Hier warten pausierte Vorgänge (Flows) auf Ihre Entscheidung. Jede Karte zeigt, welcher Vorgang an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt ihn fort, Ablehnen stoppt ihn mit Ihrer Begründung.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsReviewerLabel => 'Sie entscheiden als';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsReviewerUnverifiedPill => 'nicht überprüft';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String approvalsReviewerRecordedAs(Object value) {
|
||||||
|
return 'Wird gespeichert als: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsReviewerNoteAnonymous =>
|
||||||
|
'Dieser Hub nimmt Aufrufe ohne Zugangsdaten an — eine Entscheidung lässt sich damit niemandem nachweisen. Studio kennzeichnet den Namen deshalb im Eintrag selbst als ungeprüfte Angabe.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsReviewerNoteAuthenticated =>
|
||||||
|
'Der Hub prüft Ihren Zugang, übernimmt den Namen aber unverändert von Studio. Bis der Hub die Identität aus dem geprüften Zugang selbst ableitet, kennzeichnet Studio den Namen im Eintrag als ungeprüfte Angabe.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsReviewerNoteUnknown =>
|
||||||
|
'Studio konnte die Zugangs-Einstellungen dieses Hubs nicht lesen (dafür braucht es Admin-Rechte). Der Name wird unverändert übernommen und deshalb im Eintrag als ungeprüfte Angabe gekennzeichnet.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsReviewerUnverifiedTooltip =>
|
||||||
|
'Ungeprüfte Angabe: Dieser Name stammt vom entscheidenden Studio, nicht vom Hub.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRejectDialogHelp =>
|
||||||
|
'Die Ablehnung stoppt den Vorgang an diesem Schritt und wird mit Ihrer Begründung im Prüfprotokoll festgehalten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRejectReasonHelper =>
|
||||||
|
'Pflichtfeld — wird im Prüfprotokoll (Audit-Log) festgehalten.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsBatchNoDataTitle => 'Ohne Prüfdaten freigeben?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String approvalsBatchNoDataBody(num count, Object total) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other:
|
||||||
|
'Von $total ausgewählten Freigaben haben $count keine Prüfdaten. Sie geben sie ohne Detailansicht frei.',
|
||||||
|
one:
|
||||||
|
'Von $total ausgewählten Freigaben hat 1 keine Prüfdaten. Sie geben sie ohne Detailansicht frei.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsCopyRun => 'Lauf-Kennung kopieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRunCopied => 'Lauf-Kennung kopiert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsUnknownReviewer => '(unbekannt)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsOriginLabel => 'HERKUNFT';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsOriginProject => 'Projekt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsOriginRequested => 'Angefordert';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsOriginRun => 'Lauf';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsApproveTooltip => 'Setzt den Flow an diesem Schritt fort';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRejectTooltip => 'Stoppt den Flow — mit Ihrer Begründung';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsApproveButton => 'Freigeben';
|
String get approvalsApproveButton => 'Freigeben';
|
||||||
|
|
||||||
|
|
@ -2068,8 +2154,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get approvalsRejectDialogTitle => 'Freigabe ablehnen';
|
String get approvalsRejectDialogTitle => 'Freigabe ablehnen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsRejectReasonLabel =>
|
String get approvalsRejectReasonLabel => 'Begründung';
|
||||||
'Begründung (im Audit-Log festgehalten)';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String approvalsApprovedToast(String flow, String step) {
|
String approvalsApprovedToast(String flow, String step) {
|
||||||
|
|
@ -2108,7 +2193,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get approvalsDialogReason => 'Begründung';
|
String get approvalsDialogReason => 'Begründung';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsDialogPrompt => 'PROMPT';
|
String get approvalsDialogPrompt => 'FRAGE';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String approvalsExpiresIn(int minutes) {
|
String approvalsExpiresIn(int minutes) {
|
||||||
|
|
@ -2510,6 +2595,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get searchGroupPages => 'Seiten';
|
String get searchGroupPages => 'Seiten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchGroupWorkspace => 'Projekte & Bereiche';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchWorkspaceHint => 'Kontext wechseln · ⌘P';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchWorkspaceSealedPickerHint => 'Auswahl öffnen · ⌘P';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get searchGroupModules => 'Module';
|
String get searchGroupModules => 'Module';
|
||||||
|
|
||||||
|
|
@ -3329,7 +3423,30 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get workspaceSwitcherTooltip =>
|
String get workspaceSwitcherTooltip =>
|
||||||
'Arbeitsbereich — filtert diese Ansicht und stempelt neue Läufe mit dem gewählten Projekt';
|
'Arbeitskontext wählen — gilt überall: filtert Flows, Läufe, Audit und Freigaben und stempelt neue Läufe mit dem gewählten Projekt';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workspaceAnchorCaption => 'Kontext';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String workspaceStartConfirmTitle(String name) {
|
||||||
|
return 'Bereich „$name“ starten?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workspaceStartConfirmBody =>
|
||||||
|
'Dieser abgeschottete Bereich ist gerade gestoppt. Studio startet seine eigene Hub-Instanz auf diesem Rechner und verbindet sich mit ihr — laufende Bereiche wechseln ohne diese Rückfrage.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workspaceStartConfirmAction => 'Starten und verbinden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workspaceSealedAggregateWhy =>
|
||||||
|
'Namen bleiben zusammengefasst, weil sie Mandanten verraten können.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workspaceSealedAggregateSettings =>
|
||||||
|
'Immer anzeigen: Einstellungen → Sicherheit';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get workspaceProtectedHint =>
|
String get workspaceProtectedHint =>
|
||||||
|
|
@ -3355,6 +3472,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get runsEmptyGuideButton => 'Anleitung öffnen';
|
String get runsEmptyGuideButton => 'Anleitung öffnen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String runsEmptyFilteredTitle(String name) {
|
||||||
|
return 'Keine Läufe in Projekt „$name“';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runsEmptyFilteredHint =>
|
||||||
|
'Der Arbeitskontext filtert diese Liste — in anderen Projekten kann es Läufe geben.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runsEmptyShowAll => 'Alle Projekte anzeigen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get runsHubTooOldTitle =>
|
String get runsHubTooOldTitle =>
|
||||||
'Diese Ansicht braucht eine neuere Hub-Version';
|
'Diese Ansicht braucht eine neuere Hub-Version';
|
||||||
|
|
@ -3366,6 +3495,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get runsHubTooOldButton => 'Diagnose öffnen';
|
String get runsHubTooOldButton => 'Diagnose öffnen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runsHubUpdateButton => 'Hub jetzt aktualisieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runsHubUpdateStarted => 'Hub wird aktualisiert…';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get runsLoadFailedTitle => 'Läufe konnten nicht geladen werden';
|
String get runsLoadFailedTitle => 'Läufe konnten nicht geladen werden';
|
||||||
|
|
||||||
|
|
@ -3465,6 +3600,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get sealedIdentityBar =>
|
String get sealedIdentityBar =>
|
||||||
'Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette';
|
'Abgeschotteter Bereich — isolierter Hub, eigene Daten und Audit-Kette';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sealedSwitchingBar =>
|
||||||
|
'Verbindung wird umgeschaltet — Daten erscheinen erst nach Abschluss';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sealedLeave => 'Verlassen';
|
String get sealedLeave => 'Verlassen';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1364,6 +1364,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get auditClearReviewerLabel => 'Reviewer';
|
String get auditClearReviewerLabel => 'Reviewer';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get auditClearReviewerHelper =>
|
||||||
|
'Recorded as an unchecked claim (prefix \"unverified:\") — the hub stores this name as sent.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get auditClearReasonLabel => 'Reason (recorded in chain.reset marker)';
|
String get auditClearReasonLabel => 'Reason (recorded in chain.reset marker)';
|
||||||
|
|
||||||
|
|
@ -2042,7 +2046,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsNoPayload =>
|
String get approvalsNoPayload =>
|
||||||
'No data was attached for review. The flow\'s approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.';
|
'No data was attached to this approval. You are deciding without a detail view — when in doubt, ask whoever set up the process. (Technical: the approval step\'s \"show:\" field controls what appears here.)';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsNoDataConfirmTitle => 'Approve without review data?';
|
String get approvalsNoDataConfirmTitle => 'Approve without review data?';
|
||||||
|
|
@ -2062,6 +2066,88 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return 'Flow: $flow · Step: $step';
|
return 'Flow: $flow · Step: $step';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsIntroHelp =>
|
||||||
|
'Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsReviewerLabel => 'You are deciding as';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsReviewerUnverifiedPill => 'not verified';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String approvalsReviewerRecordedAs(Object value) {
|
||||||
|
return 'Recorded as: $value';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsReviewerNoteAnonymous =>
|
||||||
|
'This hub accepts calls without credentials, so a decision cannot be proven to be anyone\'s. Studio therefore marks the name inside the record itself as an unchecked claim.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsReviewerNoteAuthenticated =>
|
||||||
|
'The hub checks your access but records the name exactly as Studio sends it. Until the hub derives the identity from the checked access itself, Studio marks the name inside the record as an unchecked claim.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsReviewerNoteUnknown =>
|
||||||
|
'Studio could not read this hub\'s access settings (that needs admin rights). The name is stored exactly as sent and is therefore marked inside the record as an unchecked claim.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsReviewerUnverifiedTooltip =>
|
||||||
|
'Unchecked claim: this name comes from the deciding Studio, not from the hub.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRejectDialogHelp =>
|
||||||
|
'Rejecting stops the process at this step and is recorded with your reason in the audit trail.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRejectReasonHelper =>
|
||||||
|
'Required — recorded in the audit log.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsBatchNoDataTitle => 'Approve without review data?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String approvalsBatchNoDataBody(num count, Object total) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other:
|
||||||
|
'$count of $total selected approvals have no review data. You are approving them without a detail view.',
|
||||||
|
one:
|
||||||
|
'1 of $total selected approvals has no review data. You are approving it without a detail view.',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsCopyRun => 'Copy run id';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRunCopied => 'Run id copied';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsUnknownReviewer => '(unknown)';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsOriginLabel => 'ORIGIN';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsOriginProject => 'Project';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsOriginRequested => 'Requested';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsOriginRun => 'Run';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsApproveTooltip => 'Resumes the flow at this step';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get approvalsRejectTooltip => 'Stops the flow — with your reason';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsApproveButton => 'Approve';
|
String get approvalsApproveButton => 'Approve';
|
||||||
|
|
||||||
|
|
@ -2072,7 +2158,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get approvalsRejectDialogTitle => 'Reject approval';
|
String get approvalsRejectDialogTitle => 'Reject approval';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsRejectReasonLabel => 'Reason (recorded in audit log)';
|
String get approvalsRejectReasonLabel => 'Reason';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String approvalsApprovedToast(String flow, String step) {
|
String approvalsApprovedToast(String flow, String step) {
|
||||||
|
|
@ -2111,7 +2197,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get approvalsDialogReason => 'reason';
|
String get approvalsDialogReason => 'reason';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get approvalsDialogPrompt => 'PROMPT';
|
String get approvalsDialogPrompt => 'QUESTION';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String approvalsExpiresIn(int minutes) {
|
String approvalsExpiresIn(int minutes) {
|
||||||
|
|
@ -2507,6 +2593,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get searchGroupPages => 'Pages';
|
String get searchGroupPages => 'Pages';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchGroupWorkspace => 'Projects & areas';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchWorkspaceHint => 'switch context · ⌘P';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchWorkspaceSealedPickerHint => 'open the picker · ⌘P';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get searchGroupModules => 'Modules';
|
String get searchGroupModules => 'Modules';
|
||||||
|
|
||||||
|
|
@ -3321,7 +3416,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get workspaceSwitcherTooltip =>
|
String get workspaceSwitcherTooltip =>
|
||||||
'Workspace — filters this view and stamps new runs with the selected project';
|
'Choose the working context — applies everywhere: filters flows, runs, audit and approvals, and stamps new runs with the selected project';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workspaceAnchorCaption => 'Context';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String workspaceStartConfirmTitle(String name) {
|
||||||
|
return 'Start area “$name”?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workspaceStartConfirmBody =>
|
||||||
|
'This sealed area is currently stopped. Studio starts its own hub instance on this machine and connects to it — running areas switch without this prompt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workspaceStartConfirmAction => 'Start and connect';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workspaceSealedAggregateWhy =>
|
||||||
|
'Names stay aggregated because they can reveal client identities.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get workspaceSealedAggregateSettings =>
|
||||||
|
'Always show: Settings → Security';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get workspaceProtectedHint =>
|
String get workspaceProtectedHint =>
|
||||||
|
|
@ -3347,6 +3465,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get runsEmptyGuideButton => 'Open the guide';
|
String get runsEmptyGuideButton => 'Open the guide';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String runsEmptyFilteredTitle(String name) {
|
||||||
|
return 'No runs in project “$name”';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runsEmptyFilteredHint =>
|
||||||
|
'The working context filters this list — other projects may have runs.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runsEmptyShowAll => 'Show all projects';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get runsHubTooOldTitle => 'This view needs a newer hub version';
|
String get runsHubTooOldTitle => 'This view needs a newer hub version';
|
||||||
|
|
||||||
|
|
@ -3357,6 +3487,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get runsHubTooOldButton => 'Open Doctor';
|
String get runsHubTooOldButton => 'Open Doctor';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runsHubUpdateButton => 'Update hub now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get runsHubUpdateStarted => 'Updating hub…';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get runsLoadFailedTitle => 'Runs could not be loaded';
|
String get runsLoadFailedTitle => 'Runs could not be loaded';
|
||||||
|
|
||||||
|
|
@ -3456,6 +3592,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get sealedIdentityBar =>
|
String get sealedIdentityBar =>
|
||||||
'Sealed area — isolated hub, own data and audit chain';
|
'Sealed area — isolated hub, own data and audit chain';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get sealedSwitchingBar =>
|
||||||
|
'Switching connection — data appears once the switch completes';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get sealedLeave => 'Leave';
|
String get sealedLeave => 'Leave';
|
||||||
|
|
||||||
|
|
|
||||||
133
lib/main.dart
133
lib/main.dart
|
|
@ -505,9 +505,18 @@ class StudioShellState extends State<StudioShell> {
|
||||||
/// Refreshed alongside the health probe — same 5 s tick.
|
/// Refreshed alongside the health probe — same 5 s tick.
|
||||||
int _pendingApprovals = 0;
|
int _pendingApprovals = 0;
|
||||||
|
|
||||||
|
/// Opens the workspace anchor's menu from the Cmd+P shortcut.
|
||||||
|
final GlobalKey<PopupMenuButtonState<String>> _workspaceMenuKey =
|
||||||
|
GlobalKey<PopupMenuButtonState<String>>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// The shell renders connection-derived labels (sidebar endpoint,
|
||||||
|
// workspace anchor): rebuild when the workspace switches context
|
||||||
|
// so they can never show the previous hub (persona finding: the
|
||||||
|
// sidebar endpoint stayed stale until the next health tick).
|
||||||
|
Workspace.instance.addListener(_onWorkspaceChanged);
|
||||||
_checkHealth();
|
_checkHealth();
|
||||||
_healthPoll = Timer.periodic(
|
_healthPoll = Timer.periodic(
|
||||||
const Duration(seconds: 5),
|
const Duration(seconds: 5),
|
||||||
|
|
@ -515,6 +524,10 @@ class StudioShellState extends State<StudioShell> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onWorkspaceChanged() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
/// One-time hub-update hint (zero-learning-curve: the shell says
|
/// One-time hub-update hint (zero-learning-curve: the shell says
|
||||||
/// actively that a newer hub release exists instead of hiding it
|
/// actively that a newer hub release exists instead of hiding it
|
||||||
/// on the doctor page). Null = nothing to show. Dismissal is
|
/// on the doctor page). Null = nothing to show. Dismissal is
|
||||||
|
|
@ -553,6 +566,10 @@ class StudioShellState extends State<StudioShell> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkHealth({bool retriedAfterTokenReload = false}) async {
|
Future<void> _checkHealth({bool retriedAfterTokenReload = false}) async {
|
||||||
|
// Skip the tick while a sealed-area switch is in flight — the
|
||||||
|
// client points between contexts and a probe result (or the
|
||||||
|
// approvals badge) would be attributed to the wrong hub.
|
||||||
|
if (Workspace.instance.switching) return;
|
||||||
final probe = debugProbeOverride != null
|
final probe = debugProbeOverride != null
|
||||||
? await debugProbeOverride!()
|
? await debugProbeOverride!()
|
||||||
: await HubService.instance.probeHealth();
|
: await HubService.instance.probeHealth();
|
||||||
|
|
@ -623,6 +640,7 @@ class StudioShellState extends State<StudioShell> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
Workspace.instance.removeListener(_onWorkspaceChanged);
|
||||||
_healthPoll?.cancel();
|
_healthPoll?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
@ -652,10 +670,76 @@ class StudioShellState extends State<StudioShell> {
|
||||||
ChainSettingsDialog.show(context);
|
ChainSettingsDialog.show(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
..._workspaceSearchHits(l),
|
||||||
];
|
];
|
||||||
ChainSearchPalette.show(context, staticHits: hits);
|
ChainSearchPalette.show(context, staticHits: hits);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The palette's "Projects & areas" group — every context the
|
||||||
|
/// switcher offers, ranked by the operator's recent choices so a
|
||||||
|
/// poweruser's frequent areas surface before the alphabet. Sealed
|
||||||
|
/// names honour the Settings privacy choice: while the switcher
|
||||||
|
/// aggregates them, the palette must not list them either — it
|
||||||
|
/// offers one entry that opens the switcher's guarded picker.
|
||||||
|
List<ChainSearchHit> _workspaceSearchHits(AppLocalizations l) {
|
||||||
|
final ws = Workspace.instance;
|
||||||
|
final group = l.searchGroupWorkspace;
|
||||||
|
final recents = WorkspacePrefs.recentContexts.value;
|
||||||
|
int rank(String v) {
|
||||||
|
final i = recents.indexOf(v);
|
||||||
|
return i < 0 ? recents.length : i;
|
||||||
|
}
|
||||||
|
|
||||||
|
void select(String value) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
handleWorkspaceMenuSelection(context, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
final entries = <({String value, String label, IconData icon})>[
|
||||||
|
(
|
||||||
|
value: workspaceMenuValueAll(),
|
||||||
|
label: l.workspaceAll,
|
||||||
|
icon: Icons.grid_view_outlined,
|
||||||
|
),
|
||||||
|
for (final p in ws.projects)
|
||||||
|
(
|
||||||
|
value: workspaceMenuValueProject(p.slug),
|
||||||
|
label: p.slug == 'general' ? l.workspaceDefaultProject : p.name,
|
||||||
|
icon: Icons.circle_outlined,
|
||||||
|
),
|
||||||
|
if (WorkspacePrefs.sealedNamesVisible.value)
|
||||||
|
for (final a in ws.sealedAreas)
|
||||||
|
(
|
||||||
|
value: workspaceMenuValueSealed(a.slug),
|
||||||
|
label: a.name,
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
),
|
||||||
|
]..sort((a, b) => rank(a.value).compareTo(rank(b.value)));
|
||||||
|
|
||||||
|
return [
|
||||||
|
for (final e in entries)
|
||||||
|
ChainSearchHit(
|
||||||
|
label: e.label,
|
||||||
|
hint: l.searchWorkspaceHint,
|
||||||
|
icon: e.icon,
|
||||||
|
group: group,
|
||||||
|
onSelect: () => select(e.value),
|
||||||
|
),
|
||||||
|
if (!WorkspacePrefs.sealedNamesVisible.value &&
|
||||||
|
ws.sealedAreas.isNotEmpty)
|
||||||
|
ChainSearchHit(
|
||||||
|
label: l.workspaceSealedAggregate(ws.sealedAreas.length),
|
||||||
|
hint: l.searchWorkspaceSealedPickerHint,
|
||||||
|
icon: Icons.lock_outline,
|
||||||
|
group: group,
|
||||||
|
onSelect: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_workspaceMenuKey.currentState?.showButtonMenu();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -695,6 +779,12 @@ class StudioShellState extends State<StudioShell> {
|
||||||
const _OpenSearchIntent(),
|
const _OpenSearchIntent(),
|
||||||
const SingleActivator(LogicalKeyboardKey.keyK, control: true):
|
const SingleActivator(LogicalKeyboardKey.keyK, control: true):
|
||||||
const _OpenSearchIntent(),
|
const _OpenSearchIntent(),
|
||||||
|
// Workspace switcher (project/area picker) — P as in
|
||||||
|
// project. Opens the sidebar anchor's menu.
|
||||||
|
const SingleActivator(LogicalKeyboardKey.keyP, meta: true):
|
||||||
|
const _OpenWorkspaceIntent(),
|
||||||
|
const SingleActivator(LogicalKeyboardKey.keyP, control: true):
|
||||||
|
const _OpenWorkspaceIntent(),
|
||||||
},
|
},
|
||||||
child: Actions(
|
child: Actions(
|
||||||
actions: <Type, Action<Intent>>{
|
actions: <Type, Action<Intent>>{
|
||||||
|
|
@ -716,6 +806,12 @@ class StudioShellState extends State<StudioShell> {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
_OpenWorkspaceIntent: CallbackAction<_OpenWorkspaceIntent>(
|
||||||
|
onInvoke: (_) {
|
||||||
|
_workspaceMenuKey.currentState?.showButtonMenu();
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
child: Focus(
|
child: Focus(
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
|
@ -734,6 +830,7 @@ class StudioShellState extends State<StudioShell> {
|
||||||
pendingApprovals: _pendingApprovals,
|
pendingApprovals: _pendingApprovals,
|
||||||
forceExpanded: widget.startSidebarExpanded || pinned,
|
forceExpanded: widget.startSidebarExpanded || pinned,
|
||||||
onOpenSearch: _openSearchPalette,
|
onOpenSearch: _openSearchPalette,
|
||||||
|
workspaceMenuKey: _workspaceMenuKey,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
||||||
|
|
@ -862,6 +959,10 @@ class _OpenSearchIntent extends Intent {
|
||||||
const _OpenSearchIntent();
|
const _OpenSearchIntent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _OpenWorkspaceIntent extends Intent {
|
||||||
|
const _OpenWorkspaceIntent();
|
||||||
|
}
|
||||||
|
|
||||||
/// Platform-truthful label for the app's primary-modifier
|
/// Platform-truthful label for the app's primary-modifier
|
||||||
/// shortcuts (the activators bind ⌘ on macOS and Ctrl elsewhere).
|
/// shortcuts (the activators bind ⌘ on macOS and Ctrl elsewhere).
|
||||||
String _metaShortcut(String key) =>
|
String _metaShortcut(String key) =>
|
||||||
|
|
@ -895,6 +996,11 @@ class _Sidebar extends StatefulWidget {
|
||||||
/// knowing the shortcut.
|
/// knowing the shortcut.
|
||||||
final VoidCallback? onOpenSearch;
|
final VoidCallback? onOpenSearch;
|
||||||
|
|
||||||
|
/// Menu key for the workspace anchor so the shell's Cmd+P
|
||||||
|
/// shortcut (and the palette's guarded sealed-picker entry) can
|
||||||
|
/// open the switcher menu.
|
||||||
|
final GlobalKey<PopupMenuButtonState<String>>? workspaceMenuKey;
|
||||||
|
|
||||||
const _Sidebar({
|
const _Sidebar({
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onSelect,
|
required this.onSelect,
|
||||||
|
|
@ -905,6 +1011,7 @@ class _Sidebar extends StatefulWidget {
|
||||||
this.pendingApprovals = 0,
|
this.pendingApprovals = 0,
|
||||||
this.forceExpanded = false,
|
this.forceExpanded = false,
|
||||||
this.onOpenSearch,
|
this.onOpenSearch,
|
||||||
|
this.workspaceMenuKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -928,6 +1035,7 @@ class _SidebarState extends State<_Sidebar>
|
||||||
static const double _brandRowH = 48;
|
static const double _brandRowH = 48;
|
||||||
static const double _connRowH = 44;
|
static const double _connRowH = 44;
|
||||||
static const double _channelRowH = 28;
|
static const double _channelRowH = 28;
|
||||||
|
static const double _anchorRowH = 44;
|
||||||
static const double _rowGap = 8;
|
static const double _rowGap = 8;
|
||||||
late final AnimationController _ctrl;
|
late final AnimationController _ctrl;
|
||||||
// True while the channel-switch menu is open — suppresses the
|
// True while the channel-switch menu is open — suppresses the
|
||||||
|
|
@ -1106,6 +1214,31 @@ class _SidebarState extends State<_Sidebar>
|
||||||
)
|
)
|
||||||
: const SizedBox.shrink(),
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: _rowGap),
|
||||||
|
// The ONE workspace anchor — active project/area,
|
||||||
|
// visible on every page, opens the switcher menu
|
||||||
|
// (per-page switcher embeddings are gone; persona
|
||||||
|
// review 2026-08-27). Fixed height like the other
|
||||||
|
// header rows so expanding the rail never shifts
|
||||||
|
// the destinations below.
|
||||||
|
SizedBox(
|
||||||
|
height: _anchorRowH,
|
||||||
|
child: ChainWorkspaceAnchor(
|
||||||
|
t: t,
|
||||||
|
labelsInteractive: labelsInteractive,
|
||||||
|
iconColumnWidth: _collapsedWidth,
|
||||||
|
menuKey: widget.workspaceMenuKey,
|
||||||
|
onMenuOpen: () {
|
||||||
|
setState(() => _menuOpen = true);
|
||||||
|
_ctrl.forward();
|
||||||
|
},
|
||||||
|
onMenuClose: () {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _menuOpen = false);
|
||||||
|
_ctrl.reverse();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: ChainSpace.md),
|
const SizedBox(height: ChainSpace.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../data/error_presentation.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
|
import '../data/reviewer_identity.dart';
|
||||||
import '../data/workspace.dart';
|
import '../data/workspace.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
|
|
@ -50,18 +52,26 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
/// and falls back to per-row Approve / Reject buttons.
|
/// and falls back to per-row Approve / Reject buttons.
|
||||||
final Set<String> _selectedIds = <String>{};
|
final Set<String> _selectedIds = <String>{};
|
||||||
bool _batchInFlight = false;
|
bool _batchInFlight = false;
|
||||||
// Reviewer identity recorded in the audit log. Defaults to
|
|
||||||
// the OS user (closest stable identity Studio has without an
|
|
||||||
// auth backend); operators can override it per session.
|
|
||||||
late final String _reviewer = _defaultReviewer();
|
|
||||||
|
|
||||||
static String _defaultReviewer() {
|
/// Operator-facing handle of whoever is deciding here. The OS
|
||||||
final user =
|
/// account is a label, not an identity — [HubService.approve]
|
||||||
Platform.environment['USER'] ??
|
/// marks it as an unchecked claim on the wire, and the strip
|
||||||
Platform.environment['USERNAME'] ??
|
/// above the inbox says so in plain words. See
|
||||||
'studio';
|
/// `lib/data/reviewer_identity.dart`.
|
||||||
return '$user@studio';
|
final String _reviewer = ReviewerIdentity.localHandle;
|
||||||
}
|
|
||||||
|
/// What actually goes into `decided_by` — the handle plus the
|
||||||
|
/// marker saying it is an unchecked client claim. Computed here
|
||||||
|
/// (not only inside [HubService], which normalises again as a
|
||||||
|
/// backstop) because the strip above the inbox shows the operator
|
||||||
|
/// this very string before they decide.
|
||||||
|
String get _reviewerWire => ReviewerIdentity.wire(_reviewer);
|
||||||
|
|
||||||
|
/// What that attribution is worth on THIS hub. Starts unknown and
|
||||||
|
/// stays unknown when the policy cannot be read (AuthStatus is
|
||||||
|
/// admin-scoped, and old hubs answer UNIMPLEMENTED) — the strip
|
||||||
|
/// has an honest line for each case.
|
||||||
|
ReviewerAssurance _assurance = ReviewerAssurance.unknown;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -69,6 +79,21 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
Workspace.instance.addListener(_refresh);
|
Workspace.instance.addListener(_refresh);
|
||||||
Workspace.instance.ensureLoaded();
|
Workspace.instance.ensureLoaded();
|
||||||
_refresh();
|
_refresh();
|
||||||
|
_loadAssurance();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAssurance() async {
|
||||||
|
ReviewerAssurance resolved;
|
||||||
|
try {
|
||||||
|
final policy = await HubService.instance.authStatus();
|
||||||
|
resolved = reviewerAssuranceFor(policy.anonymousAllowed);
|
||||||
|
} catch (_) {
|
||||||
|
// Denied / unsupported / hub gone: not knowing is a state of
|
||||||
|
// its own, never an excuse to imply the attribution is sound.
|
||||||
|
resolved = ReviewerAssurance.unknown;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _assurance = resolved);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -79,6 +104,9 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
|
|
||||||
void _refresh() {
|
void _refresh() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
// Mid-switch the client points between contexts — hold off; the
|
||||||
|
// end-of-switch notify lands here again with a settled client.
|
||||||
|
if (Workspace.instance.switching) return;
|
||||||
final project = Workspace.instance.activeSlug;
|
final project = Workspace.instance.activeSlug;
|
||||||
setState(() {
|
setState(() {
|
||||||
_pendingFuture = HubService.instance.listApprovalsRecords(
|
_pendingFuture = HubService.instance.listApprovalsRecords(
|
||||||
|
|
@ -125,25 +153,27 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
try {
|
try {
|
||||||
await HubService.instance.approve(a.id, _reviewer);
|
await HubService.instance.approve(a.id, _reviewerWire);
|
||||||
_toast(l.approvalsApprovedToast(a.flowName, a.stepId));
|
_toast(l.approvalsApprovedToast(a.flowName, a.stepId));
|
||||||
_refresh();
|
_refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_toast(l.approvalsApproveFailed(e.toString()));
|
if (!mounted) return;
|
||||||
|
// Copyable error — never a bare SnackBar(Text(e)).
|
||||||
|
showChainErrorSnack(context, 'approvals.approve', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<void> _reject(ApprovalRecord a) async {
|
Future<void> _reject(ApprovalRecord a) async {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final reason = await _promptReason(context);
|
final reason = await _promptReason(context);
|
||||||
if (reason == null || reason.isEmpty) return;
|
if (reason == null || reason.isEmpty) return;
|
||||||
try {
|
try {
|
||||||
await HubService.instance.reject(a.id, _reviewer, reason);
|
await HubService.instance.reject(a.id, _reviewerWire, reason);
|
||||||
_toast(l.approvalsRejectedToast(a.flowName, a.stepId));
|
_toast(l.approvalsRejectedToast(a.flowName, a.stepId));
|
||||||
_refresh();
|
_refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_toast(l.approvalsRejectFailed(e.toString()));
|
if (!mounted) return;
|
||||||
|
showChainErrorSnack(context, 'approvals.reject', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,25 +200,54 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
/// drains as items succeed.
|
/// drains as items succeed.
|
||||||
Future<void> _batchApprove(List<ApprovalRecord> picked) async {
|
Future<void> _batchApprove(List<ApprovalRecord> picked) async {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
|
// Same conscious-confirmation guard as the single approve, but for
|
||||||
|
// the whole batch: a one-click bulk approve must not silently
|
||||||
|
// sweep through the data-less requests the single path stops on.
|
||||||
|
final noData = picked.where((a) => a.payloadPreview == null).length;
|
||||||
|
if (noData > 0) {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(l.approvalsBatchNoDataTitle),
|
||||||
|
content: Text(l.approvalsBatchNoDataBody(noData, picked.length)),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: Text(l.buttonCancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: Text(l.approvalsNoDataConfirmAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
setState(() => _batchInFlight = true);
|
setState(() => _batchInFlight = true);
|
||||||
var ok = 0;
|
var ok = 0;
|
||||||
var failed = 0;
|
var failed = 0;
|
||||||
|
Object? firstError;
|
||||||
for (final a in picked) {
|
for (final a in picked) {
|
||||||
try {
|
try {
|
||||||
await HubService.instance.approve(a.id, _reviewer);
|
await HubService.instance.approve(a.id, _reviewerWire);
|
||||||
ok += 1;
|
ok += 1;
|
||||||
_selectedIds.remove(a.id);
|
_selectedIds.remove(a.id);
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
|
firstError ??= e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _batchInFlight = false);
|
setState(() => _batchInFlight = false);
|
||||||
_toast(
|
if (failed == 0) {
|
||||||
failed == 0
|
_toast(l.approvalsBatchApproveDoneToast(ok));
|
||||||
? l.approvalsBatchApproveDoneToast(ok)
|
} else {
|
||||||
: l.approvalsBatchPartialFailure(ok, failed),
|
// Partial failure: surface the copyable cause, not just a count.
|
||||||
);
|
// The failed items stay selected so they remain visible.
|
||||||
|
showChainErrorSnack(context, 'approvals.batchApprove', firstError!);
|
||||||
|
}
|
||||||
_refresh();
|
_refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,22 +258,24 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
setState(() => _batchInFlight = true);
|
setState(() => _batchInFlight = true);
|
||||||
var ok = 0;
|
var ok = 0;
|
||||||
var failed = 0;
|
var failed = 0;
|
||||||
|
Object? firstError;
|
||||||
for (final a in picked) {
|
for (final a in picked) {
|
||||||
try {
|
try {
|
||||||
await HubService.instance.reject(a.id, _reviewer, reason);
|
await HubService.instance.reject(a.id, _reviewerWire, reason);
|
||||||
ok += 1;
|
ok += 1;
|
||||||
_selectedIds.remove(a.id);
|
_selectedIds.remove(a.id);
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
|
firstError ??= e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _batchInFlight = false);
|
setState(() => _batchInFlight = false);
|
||||||
_toast(
|
if (failed == 0) {
|
||||||
failed == 0
|
_toast(l.approvalsBatchRejectDoneToast(ok));
|
||||||
? l.approvalsBatchRejectDoneToast(ok)
|
} else {
|
||||||
: l.approvalsBatchPartialFailure(ok, failed),
|
showChainErrorSnack(context, 'approvals.batchReject', firstError!);
|
||||||
);
|
}
|
||||||
_refresh();
|
_refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -230,26 +291,49 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
title: Text(l.approvalsRejectDialogTitle),
|
title: Text(l.approvalsRejectDialogTitle),
|
||||||
content: TextField(
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Explain the consequence at the point of input, and make
|
||||||
|
// the reason a real required field: the button stays
|
||||||
|
// disabled while it is empty instead of the dialog closing
|
||||||
|
// silently and nothing happening (usertest finding).
|
||||||
|
ChainInlineHelp(text: l.approvalsRejectDialogHelp),
|
||||||
|
const SizedBox(height: ChainSpace.md),
|
||||||
|
TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 3,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l.approvalsRejectReasonLabel,
|
labelText: l.approvalsRejectReasonLabel,
|
||||||
|
helperText: l.approvalsRejectReasonHelper,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(ctx, null),
|
onPressed: () => Navigator.pop(ctx, null),
|
||||||
child: Text(l.buttonCancel),
|
child: Text(l.buttonCancel),
|
||||||
),
|
),
|
||||||
FilledButton(
|
ValueListenableBuilder<TextEditingValue>(
|
||||||
onPressed: () => Navigator.pop(ctx, controller.text),
|
valueListenable: controller,
|
||||||
|
builder: (ctx, value, _) {
|
||||||
|
final empty = value.text.trim().isEmpty;
|
||||||
|
return FilledButton(
|
||||||
|
onPressed: empty
|
||||||
|
? null
|
||||||
|
: () => Navigator.pop(ctx, controller.text.trim()),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||||
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
foregroundColor: Theme.of(ctx).colorScheme.onError,
|
||||||
),
|
),
|
||||||
child: Text(l.approvalsRejectButton),
|
child: Text(l.approvalsRejectButton),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -292,8 +376,6 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
const ChainWorkspaceSwitcher(),
|
|
||||||
const SizedBox(width: ChainSpace.md),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.help_outline, size: 18),
|
icon: const Icon(Icons.help_outline, size: 18),
|
||||||
tooltip: AppLocalizations.of(context)!.helpTooltip,
|
tooltip: AppLocalizations.of(context)!.helpTooltip,
|
||||||
|
|
@ -312,6 +394,8 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
children: [
|
children: [
|
||||||
_PendingList(
|
_PendingList(
|
||||||
future: _pendingFuture,
|
future: _pendingFuture,
|
||||||
|
reviewer: _reviewer,
|
||||||
|
assurance: _assurance,
|
||||||
selectedIds: _selectedIds,
|
selectedIds: _selectedIds,
|
||||||
batchInFlight: _batchInFlight,
|
batchInFlight: _batchInFlight,
|
||||||
onApprove: _approve,
|
onApprove: _approve,
|
||||||
|
|
@ -332,6 +416,8 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
|
||||||
|
|
||||||
class _PendingList extends StatelessWidget {
|
class _PendingList extends StatelessWidget {
|
||||||
final Future<List<ApprovalRecord>> future;
|
final Future<List<ApprovalRecord>> future;
|
||||||
|
final String reviewer;
|
||||||
|
final ReviewerAssurance assurance;
|
||||||
final Set<String> selectedIds;
|
final Set<String> selectedIds;
|
||||||
final bool batchInFlight;
|
final bool batchInFlight;
|
||||||
final void Function(ApprovalRecord) onApprove;
|
final void Function(ApprovalRecord) onApprove;
|
||||||
|
|
@ -345,6 +431,8 @@ class _PendingList extends StatelessWidget {
|
||||||
|
|
||||||
const _PendingList({
|
const _PendingList({
|
||||||
required this.future,
|
required this.future,
|
||||||
|
required this.reviewer,
|
||||||
|
required this.assurance,
|
||||||
required this.selectedIds,
|
required this.selectedIds,
|
||||||
required this.batchInFlight,
|
required this.batchInFlight,
|
||||||
required this.onApprove,
|
required this.onApprove,
|
||||||
|
|
@ -380,19 +468,51 @@ class _PendingList extends StatelessWidget {
|
||||||
final selected = pending
|
final selected = pending
|
||||||
.where((a) => selectedIds.contains(a.id))
|
.where((a) => selectedIds.contains(a.id))
|
||||||
.toList();
|
.toList();
|
||||||
return Stack(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// A calm one-line explainer so a first-time reviewer knows
|
||||||
|
// what this inbox is and what Approve / Reject actually do,
|
||||||
|
// without opening the doc sheet.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
ChainSpace.xl,
|
||||||
|
ChainSpace.lg,
|
||||||
|
ChainSpace.xl,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
child: ChainInlineHelp(text: l.approvalsIntroHelp),
|
||||||
|
),
|
||||||
|
// Who the audit trail will name, and what that name is
|
||||||
|
// worth — stated before the decision, not discovered
|
||||||
|
// afterwards by a lawyer reading the export.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
ChainSpace.xl,
|
||||||
|
ChainSpace.md,
|
||||||
|
ChainSpace.xl,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
child: _ReviewerIdentityStrip(
|
||||||
|
handle: reviewer,
|
||||||
|
assurance: assurance,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
ListView.separated(
|
ListView.separated(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
ChainSpace.xl,
|
ChainSpace.xl,
|
||||||
ChainSpace.xl,
|
ChainSpace.md,
|
||||||
ChainSpace.xl,
|
ChainSpace.xl,
|
||||||
// Keep the last card clear of the floating
|
// Keep the last card clear of the floating
|
||||||
// batch action bar.
|
// batch action bar.
|
||||||
selected.isEmpty ? ChainSpace.xl : 96.0,
|
selected.isEmpty ? ChainSpace.xl : 96.0,
|
||||||
),
|
),
|
||||||
itemCount: pending.length,
|
itemCount: pending.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: ChainSpace.md),
|
separatorBuilder: (_, _) =>
|
||||||
|
const SizedBox(height: ChainSpace.md),
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final a = pending[i];
|
final a = pending[i];
|
||||||
final isSelected = selectedIds.contains(a.id);
|
final isSelected = selectedIds.contains(a.id);
|
||||||
|
|
@ -421,12 +541,117 @@ class _PendingList extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "You are deciding as …" — names the attribution the audit trail
|
||||||
|
/// will carry and, in one plain sentence, what it is worth on this
|
||||||
|
/// hub. Studio cannot prove the identity (the hub stores whatever a
|
||||||
|
/// client sends as `decided_by`), so the honest move is to say that
|
||||||
|
/// out loud instead of letting the name pass for proof.
|
||||||
|
class _ReviewerIdentityStrip extends StatelessWidget {
|
||||||
|
final String handle;
|
||||||
|
final ReviewerAssurance assurance;
|
||||||
|
|
||||||
|
const _ReviewerIdentityStrip({
|
||||||
|
required this.handle,
|
||||||
|
required this.assurance,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final note = switch (assurance) {
|
||||||
|
ReviewerAssurance.anonymousHub => l.approvalsReviewerNoteAnonymous,
|
||||||
|
ReviewerAssurance.accessControlled =>
|
||||||
|
l.approvalsReviewerNoteAuthenticated,
|
||||||
|
ReviewerAssurance.unknown => l.approvalsReviewerNoteUnknown,
|
||||||
|
};
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(ChainSpace.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
||||||
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.badge_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: ChainSpace.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Wrap, not Row: a long handle plus the pill must
|
||||||
|
// reflow instead of overflowing on narrow windows.
|
||||||
|
Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: ChainSpace.sm,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l.approvalsReviewerLabel,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SelectableText(
|
||||||
|
handle,
|
||||||
|
maxLines: 1,
|
||||||
|
style: ChainTheme.mono(
|
||||||
|
size: 12,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ChainPill(
|
||||||
|
label: l.approvalsReviewerUnverifiedPill,
|
||||||
|
tone: ChainPillTone.warning,
|
||||||
|
icon: Icons.gpp_maybe_outlined,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
note,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
// The literal value that lands in `decided_by`, so
|
||||||
|
// nobody is surprised by the prefix when they read
|
||||||
|
// the log, the CLI output or a DB export.
|
||||||
|
SelectableText(
|
||||||
|
l.approvalsReviewerRecordedAs(
|
||||||
|
ReviewerIdentity.wire(handle),
|
||||||
|
),
|
||||||
|
style: ChainTheme.mono(
|
||||||
|
size: 11,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Floating action bar that surfaces when the operator
|
/// Floating action bar that surfaces when the operator
|
||||||
/// multi-selects pending approvals. Lets them approve or
|
/// multi-selects pending approvals. Lets them approve or
|
||||||
/// reject the whole picked set in one round-trip per item;
|
/// reject the whole picked set in one round-trip per item;
|
||||||
|
|
@ -582,6 +807,22 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final metaStyle = theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
// One origin fact per line: small leading icon, the value beside
|
||||||
|
// it. Keeps the "where does this come from" block scannable.
|
||||||
|
Widget originLine(IconData icon, Widget child) => Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 13, color: theme.colorScheme.onSurfaceVariant),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(child: child),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
return ChainCard(
|
return ChainCard(
|
||||||
accentTop: true,
|
accentTop: true,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -623,26 +864,89 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: ChainSpace.md),
|
||||||
// Technical context, demoted to a small metadata line.
|
// Origin block — the "which flow, which step, which run, in
|
||||||
|
// which project, requested when" a reviewer needs to place
|
||||||
|
// the request in context. The run id is copyable so it can
|
||||||
|
// be cross-referenced against the event timeline on the
|
||||||
|
// Audit page.
|
||||||
|
Text(
|
||||||
|
l.approvalsOriginLabel,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
originLine(
|
||||||
|
Icons.account_tree_outlined,
|
||||||
|
Text(
|
||||||
|
l.approvalsFlowStepMeta(approval.flowName, approval.stepId),
|
||||||
|
style: metaStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (approval.project.isNotEmpty)
|
||||||
|
originLine(
|
||||||
|
Icons.folder_outlined,
|
||||||
|
Text(
|
||||||
|
'${l.approvalsOriginProject}: ${approval.project}',
|
||||||
|
style: metaStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (approval.createdAt != null)
|
||||||
|
originLine(
|
||||||
|
Icons.schedule,
|
||||||
|
Text(
|
||||||
|
'${l.approvalsOriginRequested}: '
|
||||||
|
'${_formatTimestamp(approval.createdAt!.toLocal())}',
|
||||||
|
style: metaStyle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (approval.flowExecution != null)
|
||||||
|
originLine(
|
||||||
|
Icons.tag_outlined,
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Expanded(
|
||||||
Icons.account_tree_outlined,
|
child: SelectableText(
|
||||||
|
'${l.approvalsOriginRun}: ${approval.flowExecution}',
|
||||||
|
style: ChainTheme.mono(
|
||||||
|
size: 11,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// One-click copy so the run id can be pasted into the
|
||||||
|
// Audit page's search to reach the event timeline
|
||||||
|
// (a filtered deeplink is a larger shell change — see
|
||||||
|
// the backlog).
|
||||||
|
Tooltip(
|
||||||
|
message: l.approvalsCopyRun,
|
||||||
|
child: InkResponse(
|
||||||
|
radius: 16,
|
||||||
|
onTap: () async {
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(text: approval.flowExecution!),
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(l.approvalsRunCopied)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(2),
|
||||||
|
child: Icon(
|
||||||
|
Icons.content_copy,
|
||||||
size: 13,
|
size: 13,
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
l.approvalsFlowStepMeta(approval.flowName, approval.stepId),
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: ChainSpace.md),
|
const SizedBox(height: ChainSpace.md),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
|
|
@ -670,7 +974,7 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: SelectableText(
|
child: SelectableText(
|
||||||
_prettyPreview(approval.payloadPreview!),
|
_prettyJson(approval.payloadPreview!),
|
||||||
style: ChainTheme.mono(
|
style: ChainTheme.mono(
|
||||||
size: 11,
|
size: 11,
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.onSurface,
|
||||||
|
|
@ -720,7 +1024,9 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
OutlinedButton.icon(
|
Tooltip(
|
||||||
|
message: l.approvalsRejectTooltip,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
onPressed: onReject,
|
onPressed: onReject,
|
||||||
icon: const Icon(Icons.close, size: 16),
|
icon: const Icon(Icons.close, size: 16),
|
||||||
label: Text(l.approvalsRejectButton),
|
label: Text(l.approvalsRejectButton),
|
||||||
|
|
@ -731,12 +1037,16 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const SizedBox(width: ChainSpace.sm),
|
const SizedBox(width: ChainSpace.sm),
|
||||||
FilledButton.icon(
|
Tooltip(
|
||||||
|
message: l.approvalsApproveTooltip,
|
||||||
|
child: FilledButton.icon(
|
||||||
onPressed: onApprove,
|
onPressed: onApprove,
|
||||||
icon: const Icon(Icons.check, size: 16),
|
icon: const Icon(Icons.check, size: 16),
|
||||||
label: Text(l.approvalsApproveButton),
|
label: Text(l.approvalsApproveButton),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -744,16 +1054,6 @@ class _ApprovalCard extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _prettyPreview(String raw) {
|
|
||||||
if (raw.isEmpty) return raw;
|
|
||||||
try {
|
|
||||||
final dynamic parsed = const JsonDecoder().convert(raw);
|
|
||||||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
|
||||||
} catch (_) {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _expiresInLabel(BuildContext context, DateTime t) {
|
String _expiresInLabel(BuildContext context, DateTime t) {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final remaining = t.difference(DateTime.now());
|
final remaining = t.difference(DateTime.now());
|
||||||
|
|
@ -814,13 +1114,7 @@ class _HistoryRow extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.md),
|
const SizedBox(width: ChainSpace.md),
|
||||||
if (record.decidedBy.isNotEmpty)
|
if (record.decidedBy.isNotEmpty) _DecidedBy(record.decidedBy),
|
||||||
Text(
|
|
||||||
record.decidedBy,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -849,6 +1143,58 @@ class _HistoryRow extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Renders a stored `decided_by`. A value Studio wrote carries the
|
||||||
|
/// `unverified:` marker — show the readable name and keep the marker
|
||||||
|
/// visible as a warning glyph rather than dropping either. A value
|
||||||
|
/// without the marker (legacy row, a CLI decision) gets no badge at
|
||||||
|
/// all: Studio does not know where it came from and must not imply
|
||||||
|
/// it was checked.
|
||||||
|
class _DecidedBy extends StatelessWidget {
|
||||||
|
final String recorded;
|
||||||
|
const _DecidedBy(this.recorded);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
final parsed = ReviewerIdentity.parse(recorded);
|
||||||
|
final name = Text(
|
||||||
|
parsed.handle,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
);
|
||||||
|
if (!parsed.isSelfDeclared) return name;
|
||||||
|
return Tooltip(
|
||||||
|
message: l.approvalsReviewerUnverifiedTooltip,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.gpp_maybe_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Flexible(child: name),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "who decided" for the detail dialog's one-line summary: the
|
||||||
|
/// readable name, and — when Studio wrote it — the plain-words
|
||||||
|
/// hint that the attribution is an unchecked claim.
|
||||||
|
String _decidedByLabel(BuildContext context, String recorded) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
if (recorded.isEmpty) return l.approvalsUnknownReviewer;
|
||||||
|
final parsed = ReviewerIdentity.parse(recorded);
|
||||||
|
if (!parsed.isSelfDeclared) return parsed.handle;
|
||||||
|
return '${parsed.handle} (${l.approvalsReviewerUnverifiedPill})';
|
||||||
|
}
|
||||||
|
|
||||||
class _HistoryDialog extends StatelessWidget {
|
class _HistoryDialog extends StatelessWidget {
|
||||||
final ApprovalRecord record;
|
final ApprovalRecord record;
|
||||||
const _HistoryDialog({required this.record});
|
const _HistoryDialog({required this.record});
|
||||||
|
|
@ -886,12 +1232,25 @@ class _HistoryDialog extends StatelessWidget {
|
||||||
theme,
|
theme,
|
||||||
AppLocalizations.of(context)!.approvalsDialogDecided,
|
AppLocalizations.of(context)!.approvalsDialogDecided,
|
||||||
'${_formatTimestamp(record.decidedAt!.toLocal())} '
|
'${_formatTimestamp(record.decidedAt!.toLocal())} '
|
||||||
'· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}',
|
'· ${_decidedByLabel(context, record.decidedBy)}',
|
||||||
),
|
),
|
||||||
|
if (record.createdAt != null)
|
||||||
_kv(
|
_kv(
|
||||||
theme,
|
theme,
|
||||||
AppLocalizations.of(context)!.approvalsDialogCreated,
|
AppLocalizations.of(context)!.approvalsDialogCreated,
|
||||||
_formatTimestamp(record.createdAt.toLocal()),
|
_formatTimestamp(record.createdAt!.toLocal()),
|
||||||
|
),
|
||||||
|
if (record.project.isNotEmpty)
|
||||||
|
_kv(
|
||||||
|
theme,
|
||||||
|
AppLocalizations.of(context)!.approvalsOriginProject,
|
||||||
|
record.project,
|
||||||
|
),
|
||||||
|
if (record.flowExecution != null)
|
||||||
|
_kv(
|
||||||
|
theme,
|
||||||
|
AppLocalizations.of(context)!.approvalsOriginRun,
|
||||||
|
record.flowExecution!,
|
||||||
),
|
),
|
||||||
if (record.reason.isNotEmpty)
|
if (record.reason.isNotEmpty)
|
||||||
_kv(
|
_kv(
|
||||||
|
|
@ -934,7 +1293,7 @@ class _HistoryDialog extends StatelessWidget {
|
||||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
child: SelectableText(
|
child: SelectableText(
|
||||||
record.payloadPreview!,
|
_prettyJson(record.payloadPreview!),
|
||||||
style: ChainTheme.mono(size: 11),
|
style: ChainTheme.mono(size: 11),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -983,6 +1342,19 @@ class _HistoryDialog extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pretty-print a JSON payload preview; returns the raw string
|
||||||
|
/// unchanged when it is empty or not valid JSON. Shared by the
|
||||||
|
/// pending card + the history dialog so both render identically.
|
||||||
|
String _prettyJson(String raw) {
|
||||||
|
if (raw.isEmpty) return raw;
|
||||||
|
try {
|
||||||
|
final dynamic parsed = const JsonDecoder().convert(raw);
|
||||||
|
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||||||
|
} catch (_) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Locale-unambiguous YYYY-MM-DD HH:mm:ss formatter shared by
|
/// Locale-unambiguous YYYY-MM-DD HH:mm:ss formatter shared by
|
||||||
/// the history row + dialog. Always renders in the operator's
|
/// the history row + dialog. Always renders in the operator's
|
||||||
/// local time zone — ISO timestamps from the wire still appear
|
/// local time zone — ISO timestamps from the wire still appear
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../data/error_presentation.dart';
|
import '../data/error_presentation.dart';
|
||||||
import '../data/friendly_error.dart';
|
import '../data/friendly_error.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
|
import '../data/reviewer_identity.dart';
|
||||||
import '../data/workspace.dart';
|
import '../data/workspace.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
|
|
@ -108,6 +109,9 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
/// query and the live stream have to be re-established.
|
/// query and the live stream have to be re-established.
|
||||||
void _onWorkspaceChanged() {
|
void _onWorkspaceChanged() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
// Mid-switch the client points between contexts — hold off; the
|
||||||
|
// end-of-switch notify lands here again with a settled client.
|
||||||
|
if (Workspace.instance.switching) return;
|
||||||
_refresh();
|
_refresh();
|
||||||
_subscribeLive();
|
_subscribeLive();
|
||||||
}
|
}
|
||||||
|
|
@ -131,7 +135,12 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
// suite caught this as a pending-timer flake).
|
// suite caught this as a pending-timer flake).
|
||||||
_reconnect?.cancel();
|
_reconnect?.cancel();
|
||||||
_reconnect = Timer(const Duration(seconds: 3), () {
|
_reconnect = Timer(const Duration(seconds: 3), () {
|
||||||
if (mounted && _eventSub == null) _subscribeLive();
|
// Not mid-switch: the end-of-switch notify resubscribes.
|
||||||
|
if (mounted &&
|
||||||
|
_eventSub == null &&
|
||||||
|
!Workspace.instance.switching) {
|
||||||
|
_subscribeLive();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -261,19 +270,25 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
// copy-affordance).
|
// copy-affordance).
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
Future<void> _refresh() async {
|
||||||
|
final ws = Workspace.instance;
|
||||||
|
// Paused during a sealed-area switch: the hub client may already
|
||||||
|
// point at the other hub while this page still renders the old
|
||||||
|
// context — fetching now would show data under the wrong marking.
|
||||||
|
if (ws.switching) return;
|
||||||
|
final epoch = ws.contextEpoch;
|
||||||
try {
|
try {
|
||||||
final events = await HubService.instance.recentEvents(
|
final events = await HubService.instance.recentEvents(
|
||||||
limit: 100,
|
limit: 100,
|
||||||
project: Workspace.instance.activeSlug,
|
project: Workspace.instance.activeSlug,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted || epoch != ws.contextEpoch) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_events = events;
|
_events = events;
|
||||||
_error = null;
|
_error = null;
|
||||||
_initialLoaded = true;
|
_initialLoaded = true;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted || epoch != ws.contextEpoch) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e;
|
_error = e;
|
||||||
_initialLoaded = true;
|
_initialLoaded = true;
|
||||||
|
|
@ -291,11 +306,9 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(AppLocalizations.of(context)!.auditTitle),
|
title: Text(AppLocalizations.of(context)!.auditTitle),
|
||||||
actions: [
|
actions: [
|
||||||
const ChainWorkspaceSwitcher(),
|
// Narrow windows can't fit the inline chip row — collapse
|
||||||
const SizedBox(width: ChainSpace.md),
|
// to a checkmark menu so the app bar never overflows
|
||||||
// Narrow windows can't fit the inline chip row next to the
|
// (responsive_test.dart pins this).
|
||||||
// workspace switcher — collapse to a checkmark menu so the
|
|
||||||
// app bar never overflows (responsive_test.dart pins this).
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(right: ChainSpace.lg),
|
padding: const EdgeInsets.only(right: ChainSpace.lg),
|
||||||
child: MediaQuery.sizeOf(context).width < 900
|
child: MediaQuery.sizeOf(context).width < 900
|
||||||
|
|
@ -326,35 +339,33 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
'clear' => _onClearPressed(),
|
'clear' => _onClearPressed(),
|
||||||
_ => null,
|
_ => null,
|
||||||
},
|
},
|
||||||
|
// Labels wrap instead of running past the menu edge: a
|
||||||
|
// popup menu is at most 280px wide, and these entries
|
||||||
|
// spell out what they do ("Entwicklungs-Reset: Protokoll
|
||||||
|
// löschen … (nur local/dev)"). Unwrapped, the tail was
|
||||||
|
// clipped — and the clipped part is exactly the
|
||||||
|
// "nur local/dev" that keeps the entry from reading as
|
||||||
|
// "delete evidence".
|
||||||
itemBuilder: (ctx) => [
|
itemBuilder: (ctx) => [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'export',
|
value: 'export',
|
||||||
child: Row(
|
child: _menuLabel(
|
||||||
children: [
|
Icons.download_outlined,
|
||||||
const Icon(Icons.download_outlined, size: 16),
|
AppLocalizations.of(ctx)!.auditExportAction,
|
||||||
const SizedBox(width: ChainSpace.sm),
|
|
||||||
Text(AppLocalizations.of(ctx)!.auditExportAction),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'export-all',
|
value: 'export-all',
|
||||||
child: Row(
|
child: _menuLabel(
|
||||||
children: [
|
Icons.archive_outlined,
|
||||||
const Icon(Icons.archive_outlined, size: 16),
|
AppLocalizations.of(ctx)!.auditExportAllAction,
|
||||||
const SizedBox(width: ChainSpace.sm),
|
|
||||||
Text(AppLocalizations.of(ctx)!.auditExportAllAction),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'clear',
|
value: 'clear',
|
||||||
child: Row(
|
child: _menuLabel(
|
||||||
children: [
|
Icons.delete_sweep_outlined,
|
||||||
const Icon(Icons.delete_sweep_outlined, size: 16),
|
AppLocalizations.of(ctx)!.auditDevResetAction,
|
||||||
const SizedBox(width: ChainSpace.sm),
|
|
||||||
Text(AppLocalizations.of(ctx)!.auditDevResetAction),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -456,6 +467,17 @@ class _AuditPageState extends State<AuditPage> {
|
||||||
return parts.join('');
|
return parts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Icon + label for an overflow-menu entry. [Flexible] is the
|
||||||
|
/// point: the menu is width-capped, so a long label has to wrap
|
||||||
|
/// rather than run off the edge.
|
||||||
|
Widget _menuLabel(IconData icon, String label) => Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16),
|
||||||
|
const SizedBox(width: ChainSpace.sm),
|
||||||
|
Flexible(child: Text(label)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
Color _toneFor(String type, ThemeData theme) {
|
Color _toneFor(String type, ThemeData theme) {
|
||||||
if (type.endsWith('.failed')) return theme.colorScheme.error;
|
if (type.endsWith('.failed')) return theme.colorScheme.error;
|
||||||
if (type.endsWith('.completed')) return theme.colorScheme.primary;
|
if (type.endsWith('.completed')) return theme.colorScheme.primary;
|
||||||
|
|
@ -1249,7 +1271,9 @@ class _ExplanationPanel extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Outcome of the clear-audit confirmation dialog.
|
/// Outcome of the clear-audit confirmation dialog. [reviewer] is
|
||||||
|
/// already the wire value — the marked, unchecked claim that lands
|
||||||
|
/// in the `chain.reset` marker (see `data/reviewer_identity.dart`).
|
||||||
class _ClearOutcome {
|
class _ClearOutcome {
|
||||||
final String reviewer;
|
final String reviewer;
|
||||||
final String reason;
|
final String reason;
|
||||||
|
|
@ -1257,10 +1281,11 @@ class _ClearOutcome {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two-field confirmation dialog for "clear the audit log".
|
/// Two-field confirmation dialog for "clear the audit log".
|
||||||
/// Reviewer defaults to the OS user (closest stable identity
|
/// Reviewer prefills with the local handle (the OS account is a
|
||||||
/// without an auth backend); reason has no default so the
|
/// label, not an identity — [HubService.clearEventLog] marks it as
|
||||||
/// operator has to type *something* — the chain.reset marker
|
/// an unchecked claim on the wire, and the field's helper says so);
|
||||||
/// must carry context.
|
/// reason has no default so the operator has to type *something* —
|
||||||
|
/// the chain.reset marker must carry context.
|
||||||
class _ClearAuditDialog extends StatefulWidget {
|
class _ClearAuditDialog extends StatefulWidget {
|
||||||
const _ClearAuditDialog();
|
const _ClearAuditDialog();
|
||||||
|
|
||||||
|
|
@ -1282,11 +1307,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final user =
|
_reviewer = TextEditingController(text: ReviewerIdentity.localHandle);
|
||||||
Platform.environment['USER'] ??
|
|
||||||
Platform.environment['USERNAME'] ??
|
|
||||||
'operator';
|
|
||||||
_reviewer = TextEditingController(text: '$user@studio');
|
|
||||||
_reason = TextEditingController();
|
_reason = TextEditingController();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1320,6 +1341,8 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
||||||
controller: _reviewer,
|
controller: _reviewer,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l.auditClearReviewerLabel,
|
labelText: l.auditClearReviewerLabel,
|
||||||
|
helperText: l.auditClearReviewerHelper,
|
||||||
|
helperMaxLines: 3,
|
||||||
border: const OutlineInputBorder(),
|
border: const OutlineInputBorder(),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
),
|
),
|
||||||
|
|
@ -1356,7 +1379,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
||||||
: () => Navigator.pop(
|
: () => Navigator.pop(
|
||||||
context,
|
context,
|
||||||
_ClearOutcome(
|
_ClearOutcome(
|
||||||
reviewer: _reviewer.text.trim(),
|
reviewer: ReviewerIdentity.wire(_reviewer.text),
|
||||||
reason: _reason.text.trim(),
|
reason: _reason.text.trim(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
// need to know what's inside the editor any more.
|
// need to know what's inside the editor any more.
|
||||||
|
|
||||||
import 'dart:async' show unawaited;
|
import 'dart:async' show unawaited;
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart';
|
import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
|
@ -20,10 +21,10 @@ import '../data/error_presentation.dart';
|
||||||
import '../data/flow_run_driver.dart';
|
import '../data/flow_run_driver.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../data/store_caps.dart';
|
import '../data/store_caps.dart';
|
||||||
|
import '../data/system_actions.dart';
|
||||||
import '../data/workspace.dart';
|
import '../data/workspace.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../widgets/chain_install_confirm.dart';
|
import '../widgets/chain_install_confirm.dart';
|
||||||
import '../widgets/chain_workspace_switcher.dart';
|
|
||||||
|
|
||||||
class FlowsPage extends StatefulWidget {
|
class FlowsPage extends StatefulWidget {
|
||||||
/// Pre-load this flow when the editor first builds. Studio
|
/// Pre-load this flow when the editor first builds. Studio
|
||||||
|
|
@ -54,12 +55,19 @@ class _FlowsPageState extends State<FlowsPage> {
|
||||||
/// editor would keep offering the OTHER hub's capabilities.
|
/// editor would keep offering the OTHER hub's capabilities.
|
||||||
String _connKey = Workspace.instance.activeSealed?.slug ?? '';
|
String _connKey = Workspace.instance.activeSealed?.slug ?? '';
|
||||||
|
|
||||||
|
/// Flow names the hub reports as bundled samples
|
||||||
|
/// (FlowSummary.sample). Null while unknown (fetch pending /
|
||||||
|
/// failed / hub too old) — the editor then shows no sample
|
||||||
|
/// chips rather than guessing.
|
||||||
|
Set<String>? _sampleNames;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_driver = StudioFlowRunDriver();
|
_driver = StudioFlowRunDriver();
|
||||||
_capabilities = _loadCapabilities();
|
_capabilities = _loadCapabilities();
|
||||||
_loadStoreCapabilities();
|
_loadStoreCapabilities();
|
||||||
|
_loadSampleNames();
|
||||||
Workspace.instance.addListener(_onWorkspaceChanged);
|
Workspace.instance.addListener(_onWorkspaceChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -79,9 +87,64 @@ class _FlowsPageState extends State<FlowsPage> {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_storeCaps = null; // other hub — snapshot unknown again
|
_storeCaps = null; // other hub — snapshot unknown again
|
||||||
|
_sampleNames = null;
|
||||||
_capabilities = _loadCapabilities();
|
_capabilities = _loadCapabilities();
|
||||||
});
|
});
|
||||||
_loadStoreCapabilities();
|
_loadStoreCapabilities();
|
||||||
|
_loadSampleNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the hub's sample marking for the current connection.
|
||||||
|
/// Soft-fails to "unknown" — no chips on guesswork.
|
||||||
|
Future<void> _loadSampleNames() async {
|
||||||
|
final key = _connKey;
|
||||||
|
try {
|
||||||
|
final flows = await HubService.instance.listFlows();
|
||||||
|
if (!mounted || key != _connKey) return;
|
||||||
|
setState(() {
|
||||||
|
_sampleNames = {
|
||||||
|
for (final f in flows)
|
||||||
|
if (f.sample) f.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Unknown stays unknown.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flows directory of the CURRENT connection: a sealed area's
|
||||||
|
/// own instance dir, or null for the editor's shared-hub
|
||||||
|
/// default. Without this the editor kept listing the shared
|
||||||
|
/// hub's files while Studio was connected to a sealed area.
|
||||||
|
String? _flowsDirForConnection() {
|
||||||
|
final sealed = Workspace.instance.activeSealed;
|
||||||
|
if (sealed == null) return null;
|
||||||
|
final home =
|
||||||
|
Platform.environment['HOME'] ??
|
||||||
|
Platform.environment['USERPROFILE'] ??
|
||||||
|
'.';
|
||||||
|
return '$home/.chain/sealed/${sealed.slug}/data/flows';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deliberate sample pull for a sealed area's empty hub (the
|
||||||
|
/// shared hub seeds samples itself; a deliberate deletion there
|
||||||
|
/// must not be second-guessed with an import offer).
|
||||||
|
Future<void> _importSamples() async {
|
||||||
|
final sealed = Workspace.instance.activeSealed;
|
||||||
|
if (sealed == null) return;
|
||||||
|
final r = await SystemActions.chainFlowsImportSamples(
|
||||||
|
sealedSlug: sealed.slug,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!r.ok) {
|
||||||
|
showChainErrorSnack(
|
||||||
|
context,
|
||||||
|
'flows.importSamples',
|
||||||
|
(r.stderr.isEmpty ? r.stdout : r.stderr).trim(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _loadSampleNames();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadStoreCapabilities() async {
|
Future<void> _loadStoreCapabilities() async {
|
||||||
|
|
@ -211,7 +274,16 @@ class _FlowsPageState extends State<FlowsPage> {
|
||||||
future: _capabilities,
|
future: _capabilities,
|
||||||
builder: (context, snap) {
|
builder: (context, snap) {
|
||||||
final caps = snap.data ?? const <String>[];
|
final caps = snap.data ?? const <String>[];
|
||||||
|
final sealed = Workspace.instance.activeSealed;
|
||||||
return FlowEditorPage(
|
return FlowEditorPage(
|
||||||
|
// A connection switch replaces the whole editor state:
|
||||||
|
// an open buffer from one context must never survive
|
||||||
|
// into the other (same privacy class as the switch
|
||||||
|
// race), and the file list re-roots at the new dir.
|
||||||
|
key: ValueKey('flow-editor-${sealed?.slug ?? ''}'),
|
||||||
|
flowsDir: _flowsDirForConnection(),
|
||||||
|
sampleFlowNames: _sampleNames,
|
||||||
|
onImportSamples: sealed == null ? null : _importSamples,
|
||||||
initialFlowName: widget.initialFlowName,
|
initialFlowName: widget.initialFlowName,
|
||||||
locale: editorLocale,
|
locale: editorLocale,
|
||||||
runDriver: _driver,
|
runDriver: _driver,
|
||||||
|
|
@ -220,11 +292,9 @@ class _FlowsPageState extends State<FlowsPage> {
|
||||||
onInstallCapability: _onInstallCapability,
|
onInstallCapability: _onInstallCapability,
|
||||||
onAddModuleSource: _onAddModuleSource,
|
onAddModuleSource: _onAddModuleSource,
|
||||||
activeProject: Workspace.instance.activeSlug,
|
activeProject: Workspace.instance.activeSlug,
|
||||||
// Same switcher as Audit/Approvals/Runs, hosted in the
|
// The workspace switcher lives ONCE in the shell sidebar
|
||||||
// editor's toolbar (the page's single header): it
|
// (global anchor) — the editor toolbar no longer hosts
|
||||||
// filters the flow list and is the project a new flow
|
// its own copy.
|
||||||
// gets stamped with.
|
|
||||||
toolbarTrailing: const ChainWorkspaceSwitcher(),
|
|
||||||
// Native file dialog for the Run tab's file inputs —
|
// Native file dialog for the Run tab's file inputs —
|
||||||
// nobody should have to type an absolute path by hand.
|
// nobody should have to type an absolute path by hand.
|
||||||
onPickFile: _pickFlowInputFile,
|
onPickFile: _pickFlowInputFile,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../data/error_presentation.dart';
|
import '../data/error_presentation.dart';
|
||||||
import '../data/friendly_error.dart';
|
import '../data/friendly_error.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
|
import '../data/system_actions.dart';
|
||||||
import '../data/workspace.dart';
|
import '../data/workspace.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../main.dart' show StudioShellState;
|
import '../main.dart' show StudioShellState;
|
||||||
|
|
@ -78,16 +79,28 @@ class RunsLoadErrorView extends StatelessWidget {
|
||||||
/// Opens the runs guide (the feature-off empty state's CTA).
|
/// Opens the runs guide (the feature-off empty state's CTA).
|
||||||
final VoidCallback onOpenGuide;
|
final VoidCallback onOpenGuide;
|
||||||
|
|
||||||
/// Opens the doctor page (the "hub too old" state's CTA, where
|
/// Opens the doctor page (the "hub too old" state's secondary CTA,
|
||||||
/// the update banner lives). Null hides the button.
|
/// and the fallback when the update cannot run from here). Null
|
||||||
|
/// hides the button.
|
||||||
final VoidCallback? onOpenDoctor;
|
final VoidCallback? onOpenDoctor;
|
||||||
|
|
||||||
|
/// Applies the hub update in place (the "hub too old" state's
|
||||||
|
/// primary CTA). Null falls back to the doctor deeplink only —
|
||||||
|
/// keeps the widget test's no-hub path unchanged.
|
||||||
|
final VoidCallback? onUpdateHub;
|
||||||
|
|
||||||
|
/// Whether an update is currently being applied — drives the
|
||||||
|
/// spinner on the primary button.
|
||||||
|
final bool updating;
|
||||||
|
|
||||||
const RunsLoadErrorView({
|
const RunsLoadErrorView({
|
||||||
super.key,
|
super.key,
|
||||||
required this.error,
|
required this.error,
|
||||||
required this.issue,
|
required this.issue,
|
||||||
required this.onOpenGuide,
|
required this.onOpenGuide,
|
||||||
this.onOpenDoctor,
|
this.onOpenDoctor,
|
||||||
|
this.onUpdateHub,
|
||||||
|
this.updating = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -110,16 +123,57 @@ class RunsLoadErrorView extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case RunsLoadIssue.unsupported:
|
case RunsLoadIssue.unsupported:
|
||||||
|
// The whole point of this state is "your hub is too old" —
|
||||||
|
// so lead with the fix, not with a detour. The primary
|
||||||
|
// button updates the hub in place (same `chain update apply`
|
||||||
|
// path as the Diagnose page); "Diagnose öffnen" stays as the
|
||||||
|
// secondary escape hatch for when there is no local CLI or
|
||||||
|
// the release host is unreachable.
|
||||||
return ChainEmptyState(
|
return ChainEmptyState(
|
||||||
icon: Icons.system_update_alt_outlined,
|
icon: Icons.system_update_alt_outlined,
|
||||||
title: l.runsHubTooOldTitle,
|
title: l.runsHubTooOldTitle,
|
||||||
hint: l.runsHubTooOldHint,
|
hint: l.runsHubTooOldHint,
|
||||||
action: onOpenDoctor == null
|
action: onUpdateHub == null
|
||||||
|
? (onOpenDoctor == null
|
||||||
? null
|
? null
|
||||||
: OutlinedButton.icon(
|
: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.health_and_safety_outlined, size: 16),
|
icon: const Icon(
|
||||||
|
Icons.health_and_safety_outlined,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
label: Text(l.runsHubTooOldButton),
|
label: Text(l.runsHubTooOldButton),
|
||||||
onPressed: onOpenDoctor,
|
onPressed: onOpenDoctor,
|
||||||
|
))
|
||||||
|
: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: updating ? null : onUpdateHub,
|
||||||
|
icon: updating
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.system_update_alt, size: 16),
|
||||||
|
label: Text(
|
||||||
|
updating
|
||||||
|
? l.runsHubUpdateStarted
|
||||||
|
: l.runsHubUpdateButton,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onOpenDoctor != null) ...[
|
||||||
|
const SizedBox(height: ChainSpace.sm),
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.health_and_safety_outlined,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
label: Text(l.runsHubTooOldButton),
|
||||||
|
onPressed: updating ? null : onOpenDoctor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case RunsLoadIssue.unreachable:
|
case RunsLoadIssue.unreachable:
|
||||||
|
|
@ -150,6 +204,7 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
RunsLoadIssue _issue = RunsLoadIssue.other;
|
RunsLoadIssue _issue = RunsLoadIssue.other;
|
||||||
bool _loaded = false;
|
bool _loaded = false;
|
||||||
Timer? _poll;
|
Timer? _poll;
|
||||||
|
bool _updatingHub = false;
|
||||||
final Set<String> _cancelling = <String>{};
|
final Set<String> _cancelling = <String>{};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -171,11 +226,18 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refresh() async {
|
Future<void> _refresh() async {
|
||||||
|
final ws = Workspace.instance;
|
||||||
|
// Paused during a sealed-area switch: the hub client may already
|
||||||
|
// point at the other hub while this page still renders the old
|
||||||
|
// context — fetching now would show data under the wrong marking.
|
||||||
|
// The end-of-switch notify re-runs this listener with fresh data.
|
||||||
|
if (ws.switching) return;
|
||||||
|
final epoch = ws.contextEpoch;
|
||||||
try {
|
try {
|
||||||
final snapshot = await HubService.instance.listDetachedRuns(
|
final snapshot = await HubService.instance.listDetachedRuns(
|
||||||
project: Workspace.instance.activeSlug,
|
project: Workspace.instance.activeSlug,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted || epoch != ws.contextEpoch) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_runs = snapshot.runs;
|
_runs = snapshot.runs;
|
||||||
_detachedEnabled = snapshot.enabled;
|
_detachedEnabled = snapshot.enabled;
|
||||||
|
|
@ -183,7 +245,7 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
_loaded = true;
|
_loaded = true;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted || epoch != ws.contextEpoch) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = e;
|
_error = e;
|
||||||
_issue = classifyRunsLoadError(e);
|
_issue = classifyRunsLoadError(e);
|
||||||
|
|
@ -192,6 +254,14 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Display name of the filtered project ("General" localized).
|
||||||
|
String _activeProjectLabel(AppLocalizations l) {
|
||||||
|
final ws = Workspace.instance;
|
||||||
|
final p = ws.active;
|
||||||
|
if (p == null) return ws.activeSlug;
|
||||||
|
return p.slug == 'general' ? l.workspaceDefaultProject : p.name;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _cancel(DetachedRun run) async {
|
Future<void> _cancel(DetachedRun run) async {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
setState(() => _cancelling.add(run.id));
|
setState(() => _cancelling.add(run.id));
|
||||||
|
|
@ -214,6 +284,54 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Apply the hub update in place from the "hub too old" state.
|
||||||
|
/// Uses the same `chain update apply --channel <c>` path as the
|
||||||
|
/// Diagnose page. When there is no local CLI, or the release host
|
||||||
|
/// offers no update, we hand off to the Diagnose page, which
|
||||||
|
/// already renders the offline / locate-binary recovery cleanly
|
||||||
|
/// instead of guessing here.
|
||||||
|
Future<void> _updateHub() async {
|
||||||
|
final shell = StudioShellState.of(context);
|
||||||
|
if (!SystemActions.chainBinaryExists()) {
|
||||||
|
shell?.navigateTo('doctor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _updatingHub = true);
|
||||||
|
try {
|
||||||
|
final status = await HubService.instance.checkHubUpdate();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (status == null) {
|
||||||
|
// No update on offer, or the release host is unreachable —
|
||||||
|
// the Diagnose page states the exact reason.
|
||||||
|
setState(() => _updatingHub = false);
|
||||||
|
shell?.navigateTo('doctor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final r = await SystemActions.chainUpdateApply(status.channel);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _updatingHub = false);
|
||||||
|
if (r.ok) {
|
||||||
|
final l = AppLocalizations.of(context)!;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(l.doctorApplyDone)));
|
||||||
|
// The CLI restarts the daemon; the 2 s poll picks up the
|
||||||
|
// now-supported RPC on its own, but nudge it immediately.
|
||||||
|
await _refresh();
|
||||||
|
} else {
|
||||||
|
showChainErrorSnack(
|
||||||
|
context,
|
||||||
|
'runs.update',
|
||||||
|
(r.stderr.isEmpty ? r.stdout : r.stderr).trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _updatingHub = false);
|
||||||
|
showChainErrorSnack(context, 'runs.update', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -223,8 +341,6 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(l.runsTitle),
|
title: Text(l.runsTitle),
|
||||||
actions: [
|
actions: [
|
||||||
const ChainWorkspaceSwitcher(),
|
|
||||||
const SizedBox(width: ChainSpace.md),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.help_outline, size: 18),
|
icon: const Icon(Icons.help_outline, size: 18),
|
||||||
tooltip: l.helpTooltip,
|
tooltip: l.helpTooltip,
|
||||||
|
|
@ -247,24 +363,45 @@ class _RunsPageState extends State<RunsPage> {
|
||||||
onOpenGuide: () => showFaiDoc(context, 'runs'),
|
onOpenGuide: () => showFaiDoc(context, 'runs'),
|
||||||
onOpenDoctor: () =>
|
onOpenDoctor: () =>
|
||||||
StudioShellState.of(context)?.navigateTo('doctor'),
|
StudioShellState.of(context)?.navigateTo('doctor'),
|
||||||
|
onUpdateHub: _updateHub,
|
||||||
|
updating: _updatingHub,
|
||||||
)
|
)
|
||||||
: _runs.isEmpty
|
: _runs.isEmpty
|
||||||
|
// With an active project filter the honest cause is the
|
||||||
|
// filter, not the feature — name the project and offer the
|
||||||
|
// way out instead of the enable-the-feature guide
|
||||||
|
// (persona finding: the old hint claimed a wrong cause).
|
||||||
|
? (_detachedEnabled && !Workspace.instance.isAll
|
||||||
? ChainEmptyState(
|
? ChainEmptyState(
|
||||||
|
icon: Icons.rocket_launch_outlined,
|
||||||
|
title: l.runsEmptyFilteredTitle(_activeProjectLabel(l)),
|
||||||
|
hint: l.runsEmptyFilteredHint,
|
||||||
|
action: OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.grid_view_outlined, size: 16),
|
||||||
|
label: Text(l.runsEmptyShowAll),
|
||||||
|
onPressed: () => Workspace.instance.setActive(''),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ChainEmptyState(
|
||||||
icon: Icons.rocket_launch_outlined,
|
icon: Icons.rocket_launch_outlined,
|
||||||
title: l.runsEmptyTitle,
|
title: l.runsEmptyTitle,
|
||||||
// The hub reports whether the operator enabled the
|
// The hub reports whether the operator enabled the
|
||||||
// feature — never claim "switched off" while it is on
|
// feature — never claim "switched off" while it is
|
||||||
// and there simply are no runs yet (usertest finding).
|
// on and there simply are no runs yet (usertest
|
||||||
hint: _detachedEnabled ? l.runsEmptyEnabledHint : l.runsEmptyHint,
|
// finding).
|
||||||
// The guide carries the plain-language explanation plus
|
hint: _detachedEnabled
|
||||||
// the exact operator steps (config snippet) — a click
|
? l.runsEmptyEnabledHint
|
||||||
// target instead of a raw config key in the hint.
|
: l.runsEmptyHint,
|
||||||
|
// The guide carries the plain-language explanation
|
||||||
|
// plus the exact operator steps (config snippet) —
|
||||||
|
// a click target instead of a raw config key in
|
||||||
|
// the hint.
|
||||||
action: OutlinedButton.icon(
|
action: OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
icon: const Icon(Icons.menu_book_outlined, size: 16),
|
||||||
label: Text(l.runsEmptyGuideButton),
|
label: Text(l.runsEmptyGuideButton),
|
||||||
onPressed: () => showFaiDoc(context, 'runs'),
|
onPressed: () => showFaiDoc(context, 'runs'),
|
||||||
),
|
),
|
||||||
)
|
))
|
||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
padding: const EdgeInsets.all(ChainSpace.lg),
|
padding: const EdgeInsets.all(ChainSpace.lg),
|
||||||
itemCount: _runs.length,
|
itemCount: _runs.length,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,15 @@ class ChainSealedIdentityBar extends StatelessWidget {
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: Workspace.instance,
|
listenable: Workspace.instance,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final area = Workspace.instance.activeSealed;
|
final ws = Workspace.instance;
|
||||||
|
// While a switch is in flight the bar announces it BEFORE the
|
||||||
|
// client re-points (privacy race guard): entering shows the
|
||||||
|
// target area, leaving keeps the current area's marking —
|
||||||
|
// whatever is still on screen is that area's data.
|
||||||
|
final switching = ws.switching;
|
||||||
|
final area = switching
|
||||||
|
? (ws.switchTarget ?? ws.activeSealed)
|
||||||
|
: ws.activeSealed;
|
||||||
if (area == null) return const SizedBox.shrink();
|
if (area == null) return const SizedBox.shrink();
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
|
|
@ -51,7 +59,7 @@ class ChainSealedIdentityBar extends StatelessWidget {
|
||||||
const SizedBox(width: ChainSpace.md),
|
const SizedBox(width: ChainSpace.md),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
l.sealedIdentityBar,
|
switching ? l.sealedSwitchingBar : l.sealedIdentityBar,
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: onAccent.withValues(alpha: 0.85),
|
color: onAccent.withValues(alpha: 0.85),
|
||||||
),
|
),
|
||||||
|
|
@ -59,6 +67,16 @@ class ChainSealedIdentityBar extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.sm),
|
const SizedBox(width: ChainSpace.sm),
|
||||||
|
if (switching)
|
||||||
|
SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: onAccent,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => Workspace.instance.switchToShared(),
|
onPressed: () => Workspace.instance.switchToShared(),
|
||||||
style: TextButton.styleFrom(foregroundColor: onAccent),
|
style: TextButton.styleFrom(foregroundColor: onAccent),
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,21 @@ import 'hub_auth_policy_panel.dart';
|
||||||
import 'theme_picker_grid.dart';
|
import 'theme_picker_grid.dart';
|
||||||
|
|
||||||
class ChainSettingsDialog extends StatefulWidget {
|
class ChainSettingsDialog extends StatefulWidget {
|
||||||
const ChainSettingsDialog({super.key});
|
/// Category name to open on (e.g. 'security'); null = the first
|
||||||
|
/// category. Lets in-place explainers (the sealed-aggregate
|
||||||
|
/// why-line) jump straight to the toggle they talk about.
|
||||||
|
final String? initialCategory;
|
||||||
|
|
||||||
|
const ChainSettingsDialog({super.key, this.initialCategory});
|
||||||
|
|
||||||
/// Convenience launcher used from the sidebar gear icon.
|
/// Convenience launcher used from the sidebar gear icon.
|
||||||
static Future<bool> show(BuildContext context) async {
|
static Future<bool> show(
|
||||||
|
BuildContext context, {
|
||||||
|
String? initialCategory,
|
||||||
|
}) async {
|
||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (_) => const ChainSettingsDialog(),
|
builder: (_) => ChainSettingsDialog(initialCategory: initialCategory),
|
||||||
);
|
);
|
||||||
return ok ?? false;
|
return ok ?? false;
|
||||||
}
|
}
|
||||||
|
|
@ -88,6 +96,9 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
for (final c in _Category.values) {
|
||||||
|
if (c.name == widget.initialCategory) _category = c;
|
||||||
|
}
|
||||||
final ep = HubService.instance.currentEndpoint;
|
final ep = HubService.instance.currentEndpoint;
|
||||||
_host = TextEditingController(text: ep.host);
|
_host = TextEditingController(text: ep.host);
|
||||||
_port = TextEditingController(text: ep.port.toString());
|
_port = TextEditingController(text: ep.port.toString());
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
// ChainWorkspaceSwitcher — the AppBar workspace (project) control.
|
// Workspace switching — the ONE global control for "where am I
|
||||||
|
// working?".
|
||||||
//
|
//
|
||||||
// One control, two mechanics (docs/architecture/projects.md,
|
// One control, two mechanics (docs/architecture/projects.md,
|
||||||
// § Studio): for open/protected projects the choice sets the page
|
// § Studio): for open/protected projects the choice sets the page
|
||||||
// filter AND the label new runs are stamped with. "All projects"
|
// filter AND the label new runs are stamped with. "All projects"
|
||||||
// stays reachable — a filter, not a jail. Sealed areas are a real
|
// stays reachable — a filter, not a jail. Sealed areas are a real
|
||||||
// CONNECTION SWITCH: selecting one reconnects Studio to the area's
|
// CONNECTION SWITCH: selecting one reconnects Studio to the area's
|
||||||
// own hub (starting it first if stopped, with a notice) and colours
|
// own hub (starting it first if stopped, after an explicit
|
||||||
// the identity bar. Studio's blue stays the app accent — the project
|
// confirmation) and colours the identity bar. Studio's blue stays
|
||||||
// colour is marking, not theming.
|
// the app accent — the project colour is marking, not theming.
|
||||||
|
//
|
||||||
|
// The control lives ONCE in the shell sidebar (ChainWorkspaceAnchor)
|
||||||
|
// so the active context is visible on every page — it used to be
|
||||||
|
// embedded per-page (Flows/Runs/Audit/Approvals), which left it
|
||||||
|
// invisible on the other five pages and moving between positions
|
||||||
|
// (persona review 2026-08-27).
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
|
@ -17,15 +24,50 @@ import '../data/workspace_prefs.dart';
|
||||||
import '../data/workspace.dart';
|
import '../data/workspace.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
import 'chain_settings_dialog.dart';
|
||||||
|
|
||||||
/// Menu-value scheme: '' = all projects, `p:<slug>` = shared project,
|
/// Menu-value scheme: '' = all projects, `p:<slug>` = shared project,
|
||||||
/// `s:<slug>` = sealed area.
|
/// `s:<slug>` = sealed area, `settings:security` = the why-line's
|
||||||
|
/// jump to the Settings toggle.
|
||||||
const _kAll = '';
|
const _kAll = '';
|
||||||
const _pProject = 'p:';
|
const _pProject = 'p:';
|
||||||
const _pSealed = 's:';
|
const _pSealed = 's:';
|
||||||
|
const _kSettingsSecurity = 'settings:security';
|
||||||
|
|
||||||
class ChainWorkspaceSwitcher extends StatelessWidget {
|
/// Menu values for external mount points (the command palette) —
|
||||||
const ChainWorkspaceSwitcher({super.key});
|
/// the scheme itself stays private to this file.
|
||||||
|
String workspaceMenuValueAll() => _kAll;
|
||||||
|
String workspaceMenuValueProject(String slug) => '$_pProject$slug';
|
||||||
|
String workspaceMenuValueSealed(String slug) => '$_pSealed$slug';
|
||||||
|
|
||||||
|
/// The sidebar anchor — the single place the workspace switcher is
|
||||||
|
/// mounted. Geometry mirrors the sidebar's header rows: a fixed
|
||||||
|
/// icon column (context marking) + the label that fades in with the
|
||||||
|
/// rail expansion [t].
|
||||||
|
class ChainWorkspaceAnchor extends StatelessWidget {
|
||||||
|
/// Sidebar expansion 0..1 (collapsed → expanded).
|
||||||
|
final double t;
|
||||||
|
final bool labelsInteractive;
|
||||||
|
final double iconColumnWidth;
|
||||||
|
|
||||||
|
/// Suppress the sidebar's hover-collapse while the menu is open —
|
||||||
|
/// same contract as the channel pill (the menu would otherwise
|
||||||
|
/// float at the pill's old x).
|
||||||
|
final VoidCallback? onMenuOpen;
|
||||||
|
final VoidCallback? onMenuClose;
|
||||||
|
|
||||||
|
/// Lets the shell open the menu from the keyboard shortcut.
|
||||||
|
final GlobalKey<PopupMenuButtonState<String>>? menuKey;
|
||||||
|
|
||||||
|
const ChainWorkspaceAnchor({
|
||||||
|
super.key,
|
||||||
|
required this.t,
|
||||||
|
required this.labelsInteractive,
|
||||||
|
required this.iconColumnWidth,
|
||||||
|
this.onMenuOpen,
|
||||||
|
this.onMenuClose,
|
||||||
|
this.menuKey,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -35,34 +77,157 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final ws = Workspace.instance;
|
final ws = Workspace.instance;
|
||||||
|
final label = workspaceContextLabel(l, ws);
|
||||||
|
|
||||||
final label = ws.inSealedArea
|
final row = Row(
|
||||||
? ws.activeSealed!.name
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
: (ws.isAll
|
children: [
|
||||||
? l.workspaceAll
|
SizedBox(
|
||||||
: (ws.active == null
|
width: iconColumnWidth,
|
||||||
? ws.activeSlug
|
child: Center(child: _ContextMark(ws: ws)),
|
||||||
: _projectLabel(l, ws.active!)));
|
),
|
||||||
|
Expanded(
|
||||||
|
child: t > 0
|
||||||
|
? IgnorePointer(
|
||||||
|
ignoring: !labelsInteractive,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: t,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l.workspaceAnchorCaption,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Scales away mid-expansion — a fixed
|
||||||
|
// icon would overflow the row while the
|
||||||
|
// rail is still narrow.
|
||||||
|
Flexible(
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
right: ChainSpace.md,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.unfold_more,
|
||||||
|
size: 16,
|
||||||
|
color:
|
||||||
|
theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: l.workspaceSwitcherTooltip,
|
message: l.workspaceSwitcherTooltip,
|
||||||
|
waitDuration: const Duration(milliseconds: 400),
|
||||||
child: PopupMenuButton<String>(
|
child: PopupMenuButton<String>(
|
||||||
onOpened: ws.refresh,
|
key: menuKey,
|
||||||
onSelected: (v) => _onSelected(context, v),
|
tooltip: '', // the outer Tooltip carries the message
|
||||||
itemBuilder: (context) => _items(context, ws, l, theme),
|
onOpened: () {
|
||||||
child: _pill(context, ws, l, theme, label),
|
onMenuOpen?.call();
|
||||||
|
Workspace.instance.refresh();
|
||||||
|
},
|
||||||
|
onCanceled: () => onMenuClose?.call(),
|
||||||
|
onSelected: (v) {
|
||||||
|
onMenuClose?.call();
|
||||||
|
handleWorkspaceMenuSelection(context, v);
|
||||||
|
},
|
||||||
|
itemBuilder: (context) =>
|
||||||
|
workspaceMenuItems(context, ws: ws, l: l, theme: theme),
|
||||||
|
child: SizedBox(height: 44, child: row),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<PopupMenuEntry<String>> _items(
|
/// The collapsed-column marking for the active context: grid = all
|
||||||
BuildContext context,
|
/// projects, coloured dot = project, dot + lock = sealed area.
|
||||||
Workspace ws,
|
class _ContextMark extends StatelessWidget {
|
||||||
AppLocalizations l,
|
final Workspace ws;
|
||||||
ThemeData theme,
|
|
||||||
) {
|
const _ContextMark({required this.ws});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final sealed = ws.switching
|
||||||
|
? (ws.switchTarget ?? ws.activeSealed)
|
||||||
|
: ws.activeSealed;
|
||||||
|
if (sealed != null) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_ProjectDot(color: sealed.color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
size: 13,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ws.active != null) {
|
||||||
|
return _ProjectDot(color: ws.active!.color);
|
||||||
|
}
|
||||||
|
return Icon(
|
||||||
|
Icons.grid_view_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display label for the active context (shared by anchor + tests).
|
||||||
|
String workspaceContextLabel(AppLocalizations l, Workspace ws) {
|
||||||
|
final sealed = ws.switching
|
||||||
|
? (ws.switchTarget ?? ws.activeSealed)
|
||||||
|
: ws.activeSealed;
|
||||||
|
if (sealed != null) return sealed.name;
|
||||||
|
if (ws.isAll) return l.workspaceAll;
|
||||||
|
final active = ws.active;
|
||||||
|
return active == null ? ws.activeSlug : _projectLabel(l, active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The switcher menu, one source of truth for every mount point.
|
||||||
|
/// The active entry carries a checkmark so the menu answers "where
|
||||||
|
/// am I?" before anything is clicked.
|
||||||
|
List<PopupMenuEntry<String>> workspaceMenuItems(
|
||||||
|
BuildContext context, {
|
||||||
|
required Workspace ws,
|
||||||
|
required AppLocalizations l,
|
||||||
|
required ThemeData theme,
|
||||||
|
}) {
|
||||||
|
final inSealed = ws.inSealedArea;
|
||||||
final items = <PopupMenuEntry<String>>[
|
final items = <PopupMenuEntry<String>>[
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: _kAll,
|
value: _kAll,
|
||||||
|
|
@ -74,7 +239,8 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.sm),
|
const SizedBox(width: ChainSpace.sm),
|
||||||
Text(l.workspaceAll),
|
Expanded(child: Text(l.workspaceAll)),
|
||||||
|
_ActiveCheck(active: !inSealed && ws.isAll),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -105,6 +271,8 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
const Spacer(),
|
||||||
|
_ActiveCheck(active: !inSealed && ws.activeSlug == p.slug),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -139,23 +307,37 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
child: SealedAreaSection(
|
child: SealedAreaSection(
|
||||||
areas: ws.sealedAreas,
|
areas: ws.sealedAreas,
|
||||||
namesVisible: WorkspacePrefs.sealedNamesVisible.value,
|
namesVisible: WorkspacePrefs.sealedNamesVisible.value,
|
||||||
|
activeSlug: ws.activeSealed?.slug,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onSelected(BuildContext context, String value) async {
|
/// Apply a switcher-menu selection. Shared by every mount point
|
||||||
|
/// (sidebar anchor, command palette). Starting a STOPPED sealed
|
||||||
|
/// area asks first — an area switch must never boot a hub daemon
|
||||||
|
/// as a click side-effect; a running area stays one click.
|
||||||
|
Future<void> handleWorkspaceMenuSelection(
|
||||||
|
BuildContext context,
|
||||||
|
String value,
|
||||||
|
) async {
|
||||||
final ws = Workspace.instance;
|
final ws = Workspace.instance;
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
|
||||||
|
if (value == _kSettingsSecurity) {
|
||||||
|
await ChainSettingsDialog.show(context, initialCategory: 'security');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (value == _kAll) {
|
if (value == _kAll) {
|
||||||
|
WorkspacePrefs.recordRecentContext(value);
|
||||||
await ws.setActive('');
|
await ws.setActive('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (value.startsWith(_pProject)) {
|
if (value.startsWith(_pProject)) {
|
||||||
|
WorkspacePrefs.recordRecentContext(value);
|
||||||
await ws.setActive(value.substring(_pProject.length));
|
await ws.setActive(value.substring(_pProject.length));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -166,13 +348,36 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
if (a.slug == slug) area = a;
|
if (a.slug == slug) area = a;
|
||||||
}
|
}
|
||||||
if (area == null) return;
|
if (area == null) return;
|
||||||
// Announce the intent — starting a stopped area takes a beat.
|
final target = area;
|
||||||
if (!area.running) {
|
if (!target.running) {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(l.workspaceStartConfirmTitle(target.name)),
|
||||||
|
content: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 420),
|
||||||
|
child: Text(l.workspaceStartConfirmBody),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: Text(l.buttonCancel),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: Text(l.workspaceStartConfirmAction),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
// Announce the intent — starting the area's hub takes a beat.
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(content: Text(l.workspaceSealedStarting(area.name))),
|
SnackBar(content: Text(l.workspaceSealedStarting(target.name))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final r = await ws.switchToSealed(area);
|
WorkspacePrefs.recordRecentContext(value);
|
||||||
|
final r = await ws.switchToSealed(target);
|
||||||
messenger.hideCurrentSnackBar();
|
messenger.hideCurrentSnackBar();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
|
|
@ -183,84 +388,27 @@ class ChainWorkspaceSwitcher extends StatelessWidget {
|
||||||
);
|
);
|
||||||
} else if (r.started) {
|
} else if (r.started) {
|
||||||
messenger.showSnackBar(
|
messenger.showSnackBar(
|
||||||
SnackBar(content: Text(l.workspaceSealedStarted(area.name))),
|
SnackBar(content: Text(l.workspaceSealedStarted(target.name))),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _pill(
|
/// Trailing checkmark slot — fixed width so rows with and without
|
||||||
BuildContext context,
|
/// the mark keep their text aligned.
|
||||||
Workspace ws,
|
class _ActiveCheck extends StatelessWidget {
|
||||||
AppLocalizations l,
|
final bool active;
|
||||||
ThemeData theme,
|
|
||||||
String label,
|
|
||||||
) {
|
|
||||||
final Widget leading;
|
|
||||||
if (ws.inSealedArea) {
|
|
||||||
leading = Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
_ProjectDot(color: ws.activeSealed!.color),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Icon(
|
|
||||||
Icons.lock_outline,
|
|
||||||
size: 13,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} else if (ws.active != null) {
|
|
||||||
leading = Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 6),
|
|
||||||
child: _ProjectDot(color: ws.active!.color),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
leading = Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 6),
|
|
||||||
child: Icon(
|
|
||||||
Icons.grid_view_outlined,
|
|
||||||
size: 14,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
const _ActiveCheck({required this.active});
|
||||||
padding: const EdgeInsets.symmetric(horizontal: ChainSpace.md, vertical: 5),
|
|
||||||
decoration: BoxDecoration(
|
@override
|
||||||
color: theme.colorScheme.surfaceContainerHighest,
|
Widget build(BuildContext context) {
|
||||||
borderRadius: BorderRadius.circular(ChainRadius.sm),
|
final theme = Theme.of(context);
|
||||||
),
|
return SizedBox(
|
||||||
child: Row(
|
width: 22,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: active
|
||||||
children: [
|
? Icon(Icons.check, size: 16, color: theme.colorScheme.primary)
|
||||||
leading,
|
: null,
|
||||||
ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 160),
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.labelMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!ws.inSealedArea && (ws.active?.isProtected ?? false)) ...[
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Icon(
|
|
||||||
Icons.shield_outlined,
|
|
||||||
size: 13,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Icon(
|
|
||||||
Icons.arrow_drop_down,
|
|
||||||
size: 18,
|
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -305,19 +453,26 @@ Color? parseAreaColor(String hex) {
|
||||||
/// widget test can pump both privacy modes directly.
|
/// widget test can pump both privacy modes directly.
|
||||||
///
|
///
|
||||||
/// [namesVisible] = the operator's Settings choice. When false the
|
/// [namesVisible] = the operator's Settings choice. When false the
|
||||||
/// block renders one aggregated row (lock + count); a deliberate
|
/// block renders one aggregated row (lock + count) with a why-line
|
||||||
/// tap expands the named rows for THIS menu opening only — nothing
|
/// (the aggregation is a deliberate confidentiality decision and
|
||||||
/// is persisted from the reveal. Rows select via
|
/// must explain itself in place) + a jump to the Settings toggle;
|
||||||
|
/// a deliberate tap expands the named rows for THIS menu opening
|
||||||
|
/// only — nothing is persisted from the reveal. Rows select via
|
||||||
/// `Navigator.pop(context, 's:<slug>')`, which hands the value to
|
/// `Navigator.pop(context, 's:<slug>')`, which hands the value to
|
||||||
/// the enclosing PopupMenuButton exactly like a regular item.
|
/// the enclosing PopupMenuButton exactly like a regular item.
|
||||||
class SealedAreaSection extends StatefulWidget {
|
class SealedAreaSection extends StatefulWidget {
|
||||||
final List<SealedArea> areas;
|
final List<SealedArea> areas;
|
||||||
final bool namesVisible;
|
final bool namesVisible;
|
||||||
|
|
||||||
|
/// Slug of the area Studio is connected to, or null — its row
|
||||||
|
/// carries the active checkmark.
|
||||||
|
final String? activeSlug;
|
||||||
|
|
||||||
const SealedAreaSection({
|
const SealedAreaSection({
|
||||||
super.key,
|
super.key,
|
||||||
required this.areas,
|
required this.areas,
|
||||||
required this.namesVisible,
|
required this.namesVisible,
|
||||||
|
this.activeSlug,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -332,7 +487,11 @@ class _SealedAreaSectionState extends State<SealedAreaSection> {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final l = AppLocalizations.of(context)!;
|
final l = AppLocalizations.of(context)!;
|
||||||
if (!widget.namesVisible && !_revealed) {
|
if (!widget.namesVisible && !_revealed) {
|
||||||
return InkWell(
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
onTap: () => setState(() => _revealed = true),
|
onTap: () => setState(() => _revealed = true),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
|
|
@ -387,6 +546,43 @@ class _SealedAreaSectionState extends State<SealedAreaSection> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
// The why-line: aggregation is not a glitch but a
|
||||||
|
// confidentiality choice — say so where it happens, and
|
||||||
|
// point at the Settings toggle for machines where the
|
||||||
|
// listing is fine.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(37, 0, 16, 6),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 240),
|
||||||
|
child: Text(
|
||||||
|
l.workspaceSealedAggregateWhy,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
InkWell(
|
||||||
|
onTap: () =>
|
||||||
|
Navigator.pop(context, _kSettingsSecurity),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Text(
|
||||||
|
l.workspaceSealedAggregateSettings,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Column(
|
return Column(
|
||||||
|
|
@ -440,6 +636,8 @@ class _SealedAreaSectionState extends State<SealedAreaSection> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
|
_ActiveCheck(active: a.slug == widget.activeSlug),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
#include "../../Flutter/Flutter-Debug.xcconfig"
|
#include "../../Flutter/Flutter-Debug.xcconfig"
|
||||||
#include "Warnings.xcconfig"
|
#include "Warnings.xcconfig"
|
||||||
|
|
||||||
// Disable codesigning for local debug builds. The Repositories.nosync/
|
// Disable codesigning for local debug builds. The repository path on
|
||||||
// path on macOS sprouts extended attributes that codesign rejects with
|
// macOS sprouts extended attributes that codesign rejects with
|
||||||
// "resource fork, Finder information, or similar detritus not allowed".
|
// "resource fork, Finder information, or similar detritus not allowed".
|
||||||
// For development we don't need a signed binary.
|
// For development we don't need a signed binary.
|
||||||
CODE_SIGNING_ALLOWED = NO
|
CODE_SIGNING_ALLOWED = NO
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ packages:
|
||||||
path: "../fai_chain_studio_flow_editor"
|
path: "../fai_chain_studio_flow_editor"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.25.0"
|
version: "0.26.0"
|
||||||
characters:
|
characters:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: chain_studio
|
name: chain_studio
|
||||||
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.80.0
|
version: 0.82.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
125
test/approvals_origin_test.dart
Normal file
125
test/approvals_origin_test.dart
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
// Approvals origin block — a reviewer must be able to see WHERE a
|
||||||
|
// pending approval comes from (which flow, step, run, project, and
|
||||||
|
// when it was requested) and WHAT approve / reject will do, without
|
||||||
|
// leaving the card. The run id in particular used to be dropped at
|
||||||
|
// the Dart mapping layer; this pins that it reaches the card, next
|
||||||
|
// to the rest of the origin facts and the plain-language intro.
|
||||||
|
//
|
||||||
|
// It also pins the honesty invariant from the usertest: a missing
|
||||||
|
// created_at must NOT be rendered as a fabricated "now" — the line
|
||||||
|
// is simply omitted, exactly like the run id on a legacy hub.
|
||||||
|
//
|
||||||
|
// Runs against the scriptable FakeHubService — never a real hub.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:chain_studio/data/hub.dart';
|
||||||
|
import 'package:chain_studio/main.dart';
|
||||||
|
|
||||||
|
import 'support/fake_hub.dart';
|
||||||
|
|
||||||
|
/// Every rendered Text + SelectableText joined — the run id is a
|
||||||
|
/// SelectableText (copyable), so a Text-only sweep would miss it.
|
||||||
|
String _allText(WidgetTester tester) {
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (final w in tester.widgetList<Text>(find.byType(Text))) {
|
||||||
|
buf.writeln(w.data ?? w.textSpan?.toPlainText() ?? '');
|
||||||
|
}
|
||||||
|
for (final w in tester.widgetList<SelectableText>(
|
||||||
|
find.byType(SelectableText),
|
||||||
|
)) {
|
||||||
|
buf.writeln(w.data ?? '');
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
ApprovalRecord _pending({DateTime? createdAt, String? runId = 'run-abc123'}) {
|
||||||
|
return ApprovalRecord(
|
||||||
|
id: 'apr-1',
|
||||||
|
flowName: 'classify-and-file',
|
||||||
|
stepId: 'review',
|
||||||
|
prompt: 'Bitte die Klassifikation prüfen',
|
||||||
|
payloadPreview: '{"label":"Rechnung"}',
|
||||||
|
createdAt: createdAt,
|
||||||
|
expiresAt: null,
|
||||||
|
status: 'pending',
|
||||||
|
decidedAt: null,
|
||||||
|
decidedBy: '',
|
||||||
|
reason: '',
|
||||||
|
project: 'lbs',
|
||||||
|
flowExecution: runId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _renderApprovals(
|
||||||
|
WidgetTester tester,
|
||||||
|
List<ApprovalRecord> approvals,
|
||||||
|
) async {
|
||||||
|
final fake = installFakeHub();
|
||||||
|
fake.approvals = approvals;
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
tester.view.physicalSize = const Size(1280, 900);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const StudioApp(
|
||||||
|
initialThemeMode: ThemeModeValue.dark,
|
||||||
|
initialLocale: Locale('de'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('sidebar-item-approvals')));
|
||||||
|
for (var i = 0; i < 12; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
final text = _allText(tester);
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(minutes: 1));
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('a pending approval card shows its full origin and an '
|
||||||
|
'intro explaining approve / reject', (tester) async {
|
||||||
|
final text = await _renderApprovals(tester, [
|
||||||
|
_pending(createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0)),
|
||||||
|
]);
|
||||||
|
// The human question (headline).
|
||||||
|
expect(text, contains('Bitte die Klassifikation prüfen'));
|
||||||
|
// The origin block — every fact a reviewer needs to place it.
|
||||||
|
expect(text, contains('HERKUNFT'));
|
||||||
|
expect(text, contains('Flow: classify-and-file'));
|
||||||
|
expect(text, contains('Schritt: review'));
|
||||||
|
expect(text, contains('Projekt: lbs'));
|
||||||
|
expect(text, contains('Angefordert:'));
|
||||||
|
// The run id — the field that used to be dropped at the mapping.
|
||||||
|
expect(text, contains('Lauf: run-abc123'));
|
||||||
|
// The intro strip says what this inbox is and what the actions do.
|
||||||
|
expect(text, contains('pausierte'));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a missing requested-at time is omitted, never faked as '
|
||||||
|
'"now"', (tester) async {
|
||||||
|
// created_at unknown: the honest behaviour is to drop the line,
|
||||||
|
// not to invent a timestamp (which would even drift on refresh).
|
||||||
|
final text = await _renderApprovals(tester, [_pending(createdAt: null)]);
|
||||||
|
// The rest of the origin still renders.
|
||||||
|
expect(text, contains('HERKUNFT'));
|
||||||
|
expect(text, contains('Flow: classify-and-file'));
|
||||||
|
expect(text, contains('Lauf: run-abc123'));
|
||||||
|
// But no requested-at line at all.
|
||||||
|
expect(text, isNot(contains('Angefordert:')));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a legacy approval without a run id omits the run line', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final text = await _renderApprovals(tester, [
|
||||||
|
_pending(createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0), runId: null),
|
||||||
|
]);
|
||||||
|
expect(text, contains('HERKUNFT'));
|
||||||
|
expect(text, isNot(contains('Lauf:')));
|
||||||
|
});
|
||||||
|
}
|
||||||
288
test/approvals_reviewer_identity_test.dart
Normal file
288
test/approvals_reviewer_identity_test.dart
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
// Reviewer identity on the surfaces that write it — approvals page
|
||||||
|
// and the audit-log wipe.
|
||||||
|
//
|
||||||
|
// Guard for the legal finding of the 2026-07-26 usertest: the hub
|
||||||
|
// stores whatever reviewer string a client sends as `decided_by`,
|
||||||
|
// so Studio must (a) never send a bare handle that reads like a
|
||||||
|
// proven identity, (b) tell the reviewer BEFORE the decision what
|
||||||
|
// the attribution is worth on this hub, and (c) present a stored
|
||||||
|
// value by what it actually proves.
|
||||||
|
//
|
||||||
|
// Runs against the scriptable FakeHubService — never a real hub.
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:chain_studio/data/hub.dart';
|
||||||
|
import 'package:chain_studio/data/reviewer_identity.dart';
|
||||||
|
import 'package:chain_studio/main.dart';
|
||||||
|
|
||||||
|
import 'support/fake_hub.dart';
|
||||||
|
|
||||||
|
String _allText(WidgetTester tester) {
|
||||||
|
final buf = StringBuffer();
|
||||||
|
for (final w in tester.widgetList<Text>(find.byType(Text))) {
|
||||||
|
buf.writeln(w.data ?? w.textSpan?.toPlainText() ?? '');
|
||||||
|
}
|
||||||
|
for (final w in tester.widgetList<SelectableText>(
|
||||||
|
find.byType(SelectableText),
|
||||||
|
)) {
|
||||||
|
buf.writeln(w.data ?? '');
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
ApprovalRecord _pending() => ApprovalRecord(
|
||||||
|
id: 'apr-1',
|
||||||
|
flowName: 'classify-and-file',
|
||||||
|
stepId: 'review',
|
||||||
|
prompt: 'Bitte die Klassifikation prüfen',
|
||||||
|
payloadPreview: '{"label":"Rechnung"}',
|
||||||
|
createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0),
|
||||||
|
expiresAt: null,
|
||||||
|
status: 'pending',
|
||||||
|
decidedAt: null,
|
||||||
|
decidedBy: '',
|
||||||
|
reason: '',
|
||||||
|
project: 'lbs',
|
||||||
|
flowExecution: 'run-abc123',
|
||||||
|
);
|
||||||
|
|
||||||
|
ApprovalRecord _decided(String decidedBy) => ApprovalRecord(
|
||||||
|
id: 'apr-2',
|
||||||
|
flowName: 'classify-and-file',
|
||||||
|
stepId: 'review',
|
||||||
|
prompt: 'Bitte die Klassifikation prüfen',
|
||||||
|
payloadPreview: null,
|
||||||
|
createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0),
|
||||||
|
expiresAt: null,
|
||||||
|
status: 'approved',
|
||||||
|
decidedAt: DateTime.utc(2026, 7, 26, 12, 5, 0),
|
||||||
|
decidedBy: decidedBy,
|
||||||
|
reason: '',
|
||||||
|
project: 'lbs',
|
||||||
|
flowExecution: 'run-abc123',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Fixed frame budget instead of `pumpAndSettle`: the page keeps a
|
||||||
|
/// progress indicator alive on the hidden tab, so settling never
|
||||||
|
/// completes (same reason the origin suite pumps explicitly).
|
||||||
|
Future<void> _frames(WidgetTester tester, [int count = 12]) async {
|
||||||
|
for (var i = 0; i < count; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boot Studio on the approvals page against [fake].
|
||||||
|
Future<void> _openApprovals(WidgetTester tester, FakeHubService fake) async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
tester.view.physicalSize = const Size(1280, 900);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const StudioApp(
|
||||||
|
initialThemeMode: ThemeModeValue.dark,
|
||||||
|
initialLocale: Locale('de'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('sidebar-item-approvals')));
|
||||||
|
for (var i = 0; i < 12; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _teardownFrames(WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(minutes: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() => ReviewerIdentity.debugHandle = 'stefan@studio');
|
||||||
|
tearDown(() => ReviewerIdentity.debugHandle = null);
|
||||||
|
|
||||||
|
testWidgets('the inbox names who will be recorded, flags the claim as '
|
||||||
|
'unchecked and shows the literal stored value', (tester) async {
|
||||||
|
final fake = installFakeHub();
|
||||||
|
fake.approvals = [_pending()];
|
||||||
|
await _openApprovals(tester, fake);
|
||||||
|
final text = _allText(tester);
|
||||||
|
|
||||||
|
expect(text, contains('Sie entscheiden als'));
|
||||||
|
expect(text, contains('stefan@studio'));
|
||||||
|
expect(text, contains('nicht überprüft'));
|
||||||
|
// No surprise later: the exact string that lands in decided_by.
|
||||||
|
expect(text, contains('unverified:stefan@studio'));
|
||||||
|
await _teardownFrames(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an anonymous hub says a decision cannot be pinned on '
|
||||||
|
'anyone', (tester) async {
|
||||||
|
final fake = installFakeHub();
|
||||||
|
fake.approvals = [_pending()];
|
||||||
|
// Default fake policy: static validator, anonymous allowed.
|
||||||
|
await _openApprovals(tester, fake);
|
||||||
|
expect(_allText(tester), contains('ohne Zugangsdaten'));
|
||||||
|
await _teardownFrames(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an auth-enabled hub says the access is checked but the '
|
||||||
|
'name is not', (tester) async {
|
||||||
|
final fake = installFakeHub();
|
||||||
|
fake.approvals = [_pending()];
|
||||||
|
fake.authPolicy = const HubAuthPolicy(
|
||||||
|
validator: 'static',
|
||||||
|
anonymousAllowed: false,
|
||||||
|
tokens: [],
|
||||||
|
);
|
||||||
|
await _openApprovals(tester, fake);
|
||||||
|
expect(_allText(tester), contains('prüft Ihren Zugang'));
|
||||||
|
await _teardownFrames(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an unreadable auth policy stays honest instead of '
|
||||||
|
'guessing', (tester) async {
|
||||||
|
final fake = installFakeHub();
|
||||||
|
fake.approvals = [_pending()];
|
||||||
|
// AuthStatus is admin-scoped: a plain reviewer token is denied.
|
||||||
|
fake.authPolicy = null;
|
||||||
|
await _openApprovals(tester, fake);
|
||||||
|
expect(_allText(tester), contains('Admin-Rechte'));
|
||||||
|
// Still flagged — not knowing never upgrades the attribution.
|
||||||
|
expect(_allText(tester), contains('nicht überprüft'));
|
||||||
|
await _teardownFrames(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('approving sends the marked attribution, never a bare '
|
||||||
|
'handle', (tester) async {
|
||||||
|
final fake = installFakeHub();
|
||||||
|
fake.approvals = [_pending()];
|
||||||
|
await _openApprovals(tester, fake);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Freigeben').first);
|
||||||
|
for (var i = 0; i < 8; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(fake.decisions, hasLength(1));
|
||||||
|
expect(fake.decisions.single.id, 'apr-1');
|
||||||
|
expect(fake.decisions.single.reviewer, 'unverified:stefan@studio');
|
||||||
|
expect(
|
||||||
|
fake.decisions.single.reviewer,
|
||||||
|
startsWith(kUnverifiedReviewerPrefix),
|
||||||
|
reason: 'the decide path must not claim an identity Studio '
|
||||||
|
'cannot prove',
|
||||||
|
);
|
||||||
|
await _teardownFrames(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('rejecting sends the marked attribution too', (tester) async {
|
||||||
|
final fake = installFakeHub();
|
||||||
|
fake.approvals = [_pending()];
|
||||||
|
await _openApprovals(tester, fake);
|
||||||
|
|
||||||
|
await tester.tap(find.widgetWithText(OutlinedButton, 'Ablehnen').first);
|
||||||
|
await _frames(tester);
|
||||||
|
await tester.enterText(find.byType(TextField).last, 'Beleg fehlt');
|
||||||
|
await _frames(tester);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Ablehnen').last);
|
||||||
|
for (var i = 0; i < 8; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(fake.decisions, hasLength(1));
|
||||||
|
expect(fake.decisions.single.reviewer, 'unverified:stefan@studio');
|
||||||
|
expect(fake.decisions.single.reason, 'Beleg fehlt');
|
||||||
|
await _teardownFrames(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the history shows a marked value by its name and keeps '
|
||||||
|
'the "unchecked" flag', (tester) async {
|
||||||
|
final fake = installFakeHub();
|
||||||
|
fake.approvals = [_decided('unverified:anna@ops')];
|
||||||
|
await _openApprovals(tester, fake);
|
||||||
|
await tester.tap(find.text('Verlauf'));
|
||||||
|
await _frames(tester);
|
||||||
|
|
||||||
|
final text = _allText(tester);
|
||||||
|
expect(text, contains('anna@ops'));
|
||||||
|
// The raw marker is not what a human should read in the list.
|
||||||
|
expect(text, isNot(contains('unverified:anna@ops')));
|
||||||
|
|
||||||
|
// The detail dialog spells the trust level out in words.
|
||||||
|
await tester.tap(find.textContaining('classify-and-file').last);
|
||||||
|
await _frames(tester);
|
||||||
|
expect(_allText(tester), contains('anna@ops (nicht überprüft)'));
|
||||||
|
await _teardownFrames(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the audit-log wipe records the same kind of marked '
|
||||||
|
'attribution in the chain.reset marker', (tester) async {
|
||||||
|
// Same bug class as approvals: the reset marker names a
|
||||||
|
// "Prüfer" the hub never checked, and auditors read exactly
|
||||||
|
// that marker to explain a chain discontinuity.
|
||||||
|
final fake = installFakeHub();
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
tester.view.physicalSize = const Size(1280, 900);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const StudioApp(
|
||||||
|
initialThemeMode: ThemeModeValue.dark,
|
||||||
|
initialLocale: Locale('de'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('sidebar-item-audit')));
|
||||||
|
await _frames(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Weitere Aktionen'));
|
||||||
|
await _frames(tester);
|
||||||
|
// Tap the entry, not its label: the overflow menu's labels are
|
||||||
|
// wider than the 256px menu, so the label's centre lies outside
|
||||||
|
// the hit box (a separate, unrelated layout defect on that
|
||||||
|
// menu). The entry itself is what the operator hits.
|
||||||
|
await tester.tap(
|
||||||
|
find.ancestor(
|
||||||
|
of: find.textContaining('Entwicklungs-Reset'),
|
||||||
|
matching: find.byType(PopupMenuItem<String>),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _frames(tester);
|
||||||
|
|
||||||
|
// The field says what will be stored, before it is stored.
|
||||||
|
expect(_allText(tester), contains('unverified:'));
|
||||||
|
|
||||||
|
await tester.enterText(find.byType(TextField).last, 'Testdaten verworfen');
|
||||||
|
await _frames(tester);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, 'Log löschen'));
|
||||||
|
await _frames(tester);
|
||||||
|
|
||||||
|
expect(fake.clears, hasLength(1));
|
||||||
|
expect(fake.clears.single.reviewer, 'unverified:stefan@studio');
|
||||||
|
expect(fake.clears.single.reason, 'Testdaten verworfen');
|
||||||
|
await _teardownFrames(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a legacy / CLI value without a marker is shown as-is and '
|
||||||
|
'never labelled either way', (tester) async {
|
||||||
|
final fake = installFakeHub();
|
||||||
|
fake.approvals = [_decided('ops-lead')];
|
||||||
|
await _openApprovals(tester, fake);
|
||||||
|
await tester.tap(find.text('Verlauf'));
|
||||||
|
await _frames(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.textContaining('classify-and-file').last);
|
||||||
|
await _frames(tester);
|
||||||
|
final text = _allText(tester);
|
||||||
|
expect(text, contains('ops-lead'));
|
||||||
|
expect(
|
||||||
|
text,
|
||||||
|
isNot(contains('ops-lead (nicht überprüft)')),
|
||||||
|
reason: 'Studio does not know where an unmarked value came '
|
||||||
|
'from and must not classify it',
|
||||||
|
);
|
||||||
|
await _teardownFrames(tester);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -69,4 +69,57 @@ void main() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Popup menus have a hard width cap (~280px) that no window size
|
||||||
|
// relaxes, so the page sweep above cannot see them overflow. The
|
||||||
|
// audit overflow menu spells its entries out in full German
|
||||||
|
// sentences — unwrapped, the tail was clipped, and on the reset
|
||||||
|
// entry the clipped tail was the "(nur local/dev)" that keeps it
|
||||||
|
// from reading as "delete evidence".
|
||||||
|
testWidgets('audit overflow-menu entries wrap instead of overflowing', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
installFakeHub();
|
||||||
|
tester.view.physicalSize = const Size(1280, 800);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const StudioApp(
|
||||||
|
initialThemeMode: ThemeModeValue.dark,
|
||||||
|
initialLocale: Locale('de'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
await tester.tap(find.byKey(const ValueKey('sidebar-item-audit')));
|
||||||
|
await tester.pump(const Duration(milliseconds: 400));
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Weitere Aktionen'));
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
tester.takeException(),
|
||||||
|
isNull,
|
||||||
|
reason: 'the audit overflow menu threw during layout',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every entry stays inside the menu it belongs to — a label
|
||||||
|
// that overruns is not just ugly, it is unreadable and unhittable.
|
||||||
|
final menu = tester.getRect(find.byType(PopupMenuItem<String>).first);
|
||||||
|
for (final label in find.byType(PopupMenuItem<String>).evaluate()) {
|
||||||
|
final text = find.descendant(
|
||||||
|
of: find.byWidget(label.widget),
|
||||||
|
matching: find.byType(Text),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
tester.getRect(text).right,
|
||||||
|
lessThanOrEqualTo(menu.right + 0.5),
|
||||||
|
reason: 'menu label runs past the menu edge',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(minutes: 1));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
105
test/reviewer_identity_test.dart
Normal file
105
test/reviewer_identity_test.dart
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// Reviewer identity — the attribution Studio writes into the audit
|
||||||
|
// trail must never look better than it is.
|
||||||
|
//
|
||||||
|
// Background (usertest 2026-07-26, legal persona): the hub copies
|
||||||
|
// the client's `reviewer` string into `decided_by` unchecked, so a
|
||||||
|
// bare "stefan@studio" in an export reads like a proven identity
|
||||||
|
// while being an arbitrary client claim. Until the hub derives the
|
||||||
|
// value from the authenticated caller (contract:
|
||||||
|
// docs/reviewer-identity.md), every value Studio sends carries the
|
||||||
|
// `unverified:` marker — and everything Studio *reads* is presented
|
||||||
|
// by what it actually proves.
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
import 'package:chain_studio/data/reviewer_identity.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() => ReviewerIdentity.debugHandle = 'stefan@studio');
|
||||||
|
tearDown(() => ReviewerIdentity.debugHandle = null);
|
||||||
|
|
||||||
|
group('what Studio sends', () {
|
||||||
|
test('every reviewer string leaves Studio marked as a claim', () {
|
||||||
|
expect(ReviewerIdentity.wire(), 'unverified:stefan@studio');
|
||||||
|
expect(ReviewerIdentity.wire('anna@ops'), 'unverified:anna@ops');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marking is idempotent — a value that already carries the '
|
||||||
|
'marker is not wrapped twice', () {
|
||||||
|
final once = ReviewerIdentity.wire('anna@ops');
|
||||||
|
expect(ReviewerIdentity.wire(once), once);
|
||||||
|
// The page computes the wire value for display AND HubService
|
||||||
|
// normalises again as a backstop; that must stay harmless.
|
||||||
|
expect(ReviewerIdentity.wire(ReviewerIdentity.wire(once)), once);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a blank handle falls back to the local one instead of '
|
||||||
|
'sending an empty attribution', () {
|
||||||
|
expect(ReviewerIdentity.wire(' '), 'unverified:stefan@studio');
|
||||||
|
expect(ReviewerIdentity.wire(''), 'unverified:stefan@studio');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('surrounding whitespace never reaches the record', () {
|
||||||
|
expect(ReviewerIdentity.wire(' anna@ops '), 'unverified:anna@ops');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the local handle is a label, not an identity — but always '
|
||||||
|
'present', () {
|
||||||
|
ReviewerIdentity.debugHandle = null;
|
||||||
|
expect(ReviewerIdentity.localHandle, isNotEmpty);
|
||||||
|
expect(
|
||||||
|
ReviewerIdentity.wire(),
|
||||||
|
startsWith(kUnverifiedReviewerPrefix),
|
||||||
|
reason: 'no code path may send a bare handle',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('what Studio reads back', () {
|
||||||
|
test('a marked value is shown by its name and flagged as a claim', () {
|
||||||
|
final parsed = ReviewerIdentity.parse('unverified:anna@ops');
|
||||||
|
expect(parsed.handle, 'anna@ops');
|
||||||
|
expect(parsed.trust, RecordedReviewerTrust.selfDeclared);
|
||||||
|
expect(parsed.isSelfDeclared, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unmarked value (legacy row, CLI decision) is never '
|
||||||
|
'presented as checked', () {
|
||||||
|
final parsed = ReviewerIdentity.parse('stefan@studio');
|
||||||
|
expect(parsed.handle, 'stefan@studio');
|
||||||
|
expect(parsed.trust, RecordedReviewerTrust.unknown);
|
||||||
|
expect(
|
||||||
|
parsed.isSelfDeclared,
|
||||||
|
isFalse,
|
||||||
|
reason: 'unknown provenance must not be labelled either way',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a marker with nothing behind it keeps the marker visible '
|
||||||
|
'rather than rendering an empty reviewer', () {
|
||||||
|
final parsed = ReviewerIdentity.parse('unverified:');
|
||||||
|
expect(parsed.handle, 'unverified:');
|
||||||
|
expect(parsed.isSelfDeclared, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('round-trips what Studio wrote', () {
|
||||||
|
final parsed = ReviewerIdentity.parse(ReviewerIdentity.wire());
|
||||||
|
expect(parsed.handle, 'stefan@studio');
|
||||||
|
expect(parsed.isSelfDeclared, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('assurance level per hub policy', () {
|
||||||
|
test('anonymous hub — nobody can be tied to a decision', () {
|
||||||
|
expect(reviewerAssuranceFor(true), ReviewerAssurance.anonymousHub);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auth-enabled hub — the access is checked, the name is not', () {
|
||||||
|
expect(reviewerAssuranceFor(false), ReviewerAssurance.accessControlled);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unreadable policy stays unknown — never optimistic', () {
|
||||||
|
expect(reviewerAssuranceFor(null), ReviewerAssurance.unknown);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -135,6 +135,53 @@ void main() {
|
||||||
expect(doctorOpened, isTrue);
|
expect(doctorOpened, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('too-old hub leads with an in-place update button, '
|
||||||
|
'doctor demoted to secondary', (tester) async {
|
||||||
|
var updateTriggered = false;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_host(
|
||||||
|
RunsLoadErrorView(
|
||||||
|
error: const _FakeGrpcError(12, 'unimplemented'),
|
||||||
|
issue: RunsLoadIssue.unsupported,
|
||||||
|
onOpenGuide: () {},
|
||||||
|
onOpenDoctor: () {},
|
||||||
|
onUpdateHub: () => updateTriggered = true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// Primary CTA is the fix, not the detour.
|
||||||
|
expect(find.text('Hub jetzt aktualisieren'), findsOneWidget);
|
||||||
|
expect(find.text('Diagnose öffnen'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Hub jetzt aktualisieren'));
|
||||||
|
expect(updateTriggered, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('while updating, the button shows progress and is disabled', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
var updateTriggered = false;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
_host(
|
||||||
|
RunsLoadErrorView(
|
||||||
|
error: const _FakeGrpcError(12, 'unimplemented'),
|
||||||
|
issue: RunsLoadIssue.unsupported,
|
||||||
|
onOpenGuide: () {},
|
||||||
|
onOpenDoctor: () {},
|
||||||
|
onUpdateHub: () => updateTriggered = true,
|
||||||
|
updating: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// The spinner animates forever, so pump one frame rather than
|
||||||
|
// pumpAndSettle (which would time out waiting for it to stop).
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('Hub wird aktualisiert…'), findsOneWidget);
|
||||||
|
// Disabled while in flight — a tap must not re-fire.
|
||||||
|
await tester.tap(find.byType(FilledButton), warnIfMissed: false);
|
||||||
|
expect(updateTriggered, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('a genuinely unreachable hub still says unreachable', (
|
testWidgets('a genuinely unreachable hub still says unreachable', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,11 @@ class FakeHubService extends Fake implements HubService {
|
||||||
bool detachedEnabled = false;
|
bool detachedEnabled = false;
|
||||||
bool probeServing = true;
|
bool probeServing = true;
|
||||||
|
|
||||||
|
/// Approvals returned by [listApprovalsRecords], filtered by the
|
||||||
|
/// requested statuses so the pending / history tabs each see the
|
||||||
|
/// right slice. Empty by default (healthy-empty inbox).
|
||||||
|
List<ApprovalRecord> approvals = const [];
|
||||||
|
|
||||||
void failWith(Object error, {Set<String>? only}) {
|
void failWith(Object error, {Set<String>? only}) {
|
||||||
if (only == null) {
|
if (only == null) {
|
||||||
failAll = error;
|
failAll = error;
|
||||||
|
|
@ -178,9 +183,14 @@ class FakeHubService extends Fake implements HubService {
|
||||||
@override
|
@override
|
||||||
Future<List<SavedFlow>> listFlows() => _async('listFlows', () => const []);
|
Future<List<SavedFlow>> listFlows() => _async('listFlows', () => const []);
|
||||||
|
|
||||||
|
/// Registry projects [listProjects] answers with — scriptable so
|
||||||
|
/// workspace suites can keep their seed across the switcher's
|
||||||
|
/// open-menu refresh.
|
||||||
|
List<ProjectRef> projects = const [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ProjectRef>> listProjects() =>
|
Future<List<ProjectRef>> listProjects() =>
|
||||||
_async('listProjects', () => const []);
|
_async('listProjects', () => projects);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<AuditEvent> streamEvents({
|
Stream<AuditEvent> streamEvents({
|
||||||
|
|
@ -209,7 +219,12 @@ class FakeHubService extends Fake implements HubService {
|
||||||
List<String> statuses = const [],
|
List<String> statuses = const [],
|
||||||
int limit = 200,
|
int limit = 200,
|
||||||
String project = '',
|
String project = '',
|
||||||
}) => _async('listApprovalsRecords', () => const []);
|
}) => _async(
|
||||||
|
'listApprovalsRecords',
|
||||||
|
() => statuses.isEmpty
|
||||||
|
? approvals
|
||||||
|
: approvals.where((a) => statuses.contains(a.status)).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<({List<DetachedRun> runs, bool enabled})> listDetachedRuns({
|
Future<({List<DetachedRun> runs, bool enabled})> listDetachedRuns({
|
||||||
|
|
@ -220,6 +235,55 @@ class FakeHubService extends Fake implements HubService {
|
||||||
Future<List<Satellite>> listSatellites() =>
|
Future<List<Satellite>> listSatellites() =>
|
||||||
_async('listSatellites', () => const []);
|
_async('listSatellites', () => const []);
|
||||||
|
|
||||||
|
/// Auth policy the pages read (approvals derives from it what a
|
||||||
|
/// reviewer attribution is worth). Default: the local-dev hub —
|
||||||
|
/// static validator, anonymous calls allowed. Set to `null` to
|
||||||
|
/// script a hub whose policy Studio may not read; `authStatus`
|
||||||
|
/// then fails like a PermissionDenied.
|
||||||
|
HubAuthPolicy? authPolicy = const HubAuthPolicy(
|
||||||
|
validator: 'static',
|
||||||
|
anonymousAllowed: true,
|
||||||
|
tokens: [],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<HubAuthPolicy> authStatus() => _async('authStatus', () {
|
||||||
|
final policy = authPolicy;
|
||||||
|
if (policy == null) {
|
||||||
|
throw const FakeGrpcError(7, 'admin scope required');
|
||||||
|
}
|
||||||
|
return policy;
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Every decision that reached the wire, in order — the guard for
|
||||||
|
/// "Studio never claims an identity it cannot prove" inspects the
|
||||||
|
/// reviewer string exactly as the hub would store it.
|
||||||
|
final List<({String id, String reviewer, String? reason})> decisions = [];
|
||||||
|
|
||||||
|
/// Reviewer + reason handed to the audit-log wipe, same purpose.
|
||||||
|
final List<({String reviewer, String reason})> clears = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<({int purged, String channel})> clearEventLog({
|
||||||
|
required String reviewer,
|
||||||
|
required String reason,
|
||||||
|
}) => _async('clearEventLog', () {
|
||||||
|
clears.add((reviewer: reviewer, reason: reason));
|
||||||
|
return (purged: 0, channel: 'local');
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> approve(String id, String reviewer) => _async(
|
||||||
|
'approve',
|
||||||
|
() => decisions.add((id: id, reviewer: reviewer, reason: null)),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reject(String id, String reviewer, String reason) => _async(
|
||||||
|
'reject',
|
||||||
|
() => decisions.add((id: id, reviewer: reviewer, reason: reason)),
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SystemAiStatus> systemAiStatus() => _async(
|
Future<SystemAiStatus> systemAiStatus() => _async(
|
||||||
'systemAiStatus',
|
'systemAiStatus',
|
||||||
|
|
|
||||||
280
test/workspace_switch_race_test.dart
Normal file
280
test/workspace_switch_race_test.dart
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
// Wächter for the sealed-switch privacy race (persona review
|
||||||
|
// 2026-08-27, HOCH): the hub client must never point at a different
|
||||||
|
// hub than the context the UI announces. The bug class: Studio's
|
||||||
|
// client re-pointed at a sealed hub BEFORE the workspace announced
|
||||||
|
// the sealed context, so a 2 s page poller could fetch and render
|
||||||
|
// confidential data without the sealed marking.
|
||||||
|
//
|
||||||
|
// Invariants pinned here:
|
||||||
|
// 1. reconnect() happens only inside an open switch window
|
||||||
|
// (Workspace.switching == true), and the sealed context is
|
||||||
|
// announced only AFTER the connection settled — never before,
|
||||||
|
// never skipped.
|
||||||
|
// 2. While the window is open, the polling pages issue no reads —
|
||||||
|
// sealed data can never render unmarked (RunsPage as the
|
||||||
|
// poll-pattern representative; Audit uses the same guard).
|
||||||
|
// 3. The shared-hub project filter survives a sealed round trip
|
||||||
|
// (park + restore — the second HOCH bug), and prefs agree with
|
||||||
|
// the live state afterwards.
|
||||||
|
// 4. A failed switch rolls back to the shared hub without ever
|
||||||
|
// announcing the sealed context and closes the window.
|
||||||
|
//
|
||||||
|
// Hermetic: scripted fakes for HubService AND SealedAreaService —
|
||||||
|
// the suite never scans the operator's real ~/.chain.
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint;
|
||||||
|
import 'package:chain_studio/data/hub.dart';
|
||||||
|
import 'package:chain_studio/data/sealed_areas.dart';
|
||||||
|
import 'package:chain_studio/data/workspace.dart';
|
||||||
|
import 'package:chain_studio/l10n/app_localizations.dart';
|
||||||
|
import 'package:chain_studio/pages/runs.dart';
|
||||||
|
|
||||||
|
import 'support/fake_hub.dart';
|
||||||
|
|
||||||
|
const _areaA = SealedArea(
|
||||||
|
slug: 'area-a',
|
||||||
|
name: 'Area A',
|
||||||
|
color: '#e0a458',
|
||||||
|
port: 51001,
|
||||||
|
running: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const _areaB = SealedArea(
|
||||||
|
slug: 'area-b',
|
||||||
|
name: 'Area B',
|
||||||
|
color: '#7cb47c',
|
||||||
|
port: 51002,
|
||||||
|
running: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
class FakeSealedAreas extends SealedAreaService {
|
||||||
|
FakeSealedAreas(this.areas) : super.forTest();
|
||||||
|
|
||||||
|
List<SealedArea> areas;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SealedArea>> list() async => areas;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> boundEndpoint(String slug) async => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordingHub extends FakeHubService {
|
||||||
|
/// Every reconnect with the workspace state AT CALL TIME — the
|
||||||
|
/// race guard reads exactly these two flags.
|
||||||
|
final List<({int port, bool switchingAtCall, bool sealedAnnounced})>
|
||||||
|
reconnects = [];
|
||||||
|
|
||||||
|
/// When set, healthy() blocks until completed — holds the switch
|
||||||
|
/// window open so the poller-pause test can tick timers inside it.
|
||||||
|
Completer<void>? healthyGate;
|
||||||
|
bool healthyAnswer = true;
|
||||||
|
|
||||||
|
int detachedReads = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> reconnect(
|
||||||
|
HubEndpoint endpoint, {
|
||||||
|
Object? authToken = const Object(),
|
||||||
|
bool persist = true,
|
||||||
|
}) async {
|
||||||
|
reconnects.add((
|
||||||
|
port: endpoint.port,
|
||||||
|
switchingAtCall: Workspace.instance.switching,
|
||||||
|
sealedAnnounced: Workspace.instance.inSealedArea,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> healthy() async {
|
||||||
|
final gate = healthyGate;
|
||||||
|
if (gate != null) await gate.future;
|
||||||
|
return healthyAnswer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<({List<DetachedRun> runs, bool enabled})> listDetachedRuns({
|
||||||
|
String project = '',
|
||||||
|
}) {
|
||||||
|
detachedReads++;
|
||||||
|
return super.listDetachedRuns(project: project);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordingHub _install({List<SealedArea> areas = const [_areaA, _areaB]}) {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final hub = RecordingHub();
|
||||||
|
HubService.debugSetInstance(hub);
|
||||||
|
SealedAreaService.debugSetInstance(FakeSealedAreas(areas));
|
||||||
|
addTearDown(() {
|
||||||
|
HubService.debugSetInstance(null);
|
||||||
|
SealedAreaService.debugSetInstance(null);
|
||||||
|
Workspace.instance.debugSeed(projects: const [], active: '');
|
||||||
|
});
|
||||||
|
Workspace.instance.debugSeed(
|
||||||
|
projects: const [],
|
||||||
|
active: '',
|
||||||
|
sealed: areas,
|
||||||
|
);
|
||||||
|
return hub;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
test('the switch window opens before the client re-points and the '
|
||||||
|
'sealed context is announced only after it settled', () async {
|
||||||
|
final hub = _install();
|
||||||
|
final ws = Workspace.instance;
|
||||||
|
|
||||||
|
final r = await ws.switchToSealed(_areaA);
|
||||||
|
|
||||||
|
expect(r.ok, isTrue);
|
||||||
|
expect(hub.reconnects, hasLength(1));
|
||||||
|
final call = hub.reconnects.single;
|
||||||
|
expect(call.port, _areaA.port);
|
||||||
|
expect(
|
||||||
|
call.switchingAtCall,
|
||||||
|
isTrue,
|
||||||
|
reason: 'the client re-pointed OUTSIDE a switch window — pollers '
|
||||||
|
'were not paused and could render sealed data unmarked',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
call.sealedAnnounced,
|
||||||
|
isFalse,
|
||||||
|
reason: 'the sealed context must be announced only after the '
|
||||||
|
'connection settled, never before reconnect',
|
||||||
|
);
|
||||||
|
expect(ws.switching, isFalse);
|
||||||
|
expect(ws.inSealedArea, isTrue);
|
||||||
|
expect(ws.activeSealed, _areaA);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaving a sealed area also runs inside a switch window and '
|
||||||
|
'keeps the sealed marking until the connection settled', () async {
|
||||||
|
final hub = _install();
|
||||||
|
final ws = Workspace.instance;
|
||||||
|
await ws.switchToSealed(_areaA);
|
||||||
|
hub.reconnects.clear();
|
||||||
|
|
||||||
|
await ws.switchToShared();
|
||||||
|
|
||||||
|
expect(hub.reconnects, hasLength(1));
|
||||||
|
final call = hub.reconnects.single;
|
||||||
|
expect(call.switchingAtCall, isTrue);
|
||||||
|
expect(
|
||||||
|
call.sealedAnnounced,
|
||||||
|
isTrue,
|
||||||
|
reason: 'while returning, the data still on screen is the sealed '
|
||||||
|
"area's — its marking must not drop before the switch settled",
|
||||||
|
);
|
||||||
|
expect(ws.switching, isFalse);
|
||||||
|
expect(ws.inSealedArea, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the project filter survives a sealed round trip, live and in '
|
||||||
|
'prefs', () async {
|
||||||
|
_install();
|
||||||
|
final ws = Workspace.instance;
|
||||||
|
await ws.setActive('proj-a');
|
||||||
|
|
||||||
|
await ws.switchToSealed(_areaA);
|
||||||
|
expect(ws.activeSlug, isEmpty,
|
||||||
|
reason: 'the shared-hub filter does not apply inside an area');
|
||||||
|
|
||||||
|
await ws.switchToShared();
|
||||||
|
expect(ws.activeSlug, 'proj-a');
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getString('workspace.active'), 'proj-a',
|
||||||
|
reason: 'live state and prefs must agree after the round trip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('area-to-area switching keeps the originally parked filter',
|
||||||
|
() async {
|
||||||
|
_install();
|
||||||
|
final ws = Workspace.instance;
|
||||||
|
await ws.setActive('proj-a');
|
||||||
|
|
||||||
|
await ws.switchToSealed(_areaA);
|
||||||
|
await ws.switchToSealed(_areaB);
|
||||||
|
await ws.switchToShared();
|
||||||
|
|
||||||
|
expect(ws.activeSlug, 'proj-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed switch rolls back without announcing the sealed '
|
||||||
|
'context and closes the window', () async {
|
||||||
|
final hub = _install();
|
||||||
|
hub.healthyAnswer = false;
|
||||||
|
final ws = Workspace.instance;
|
||||||
|
await ws.setActive('proj-a');
|
||||||
|
hub.reconnects.clear();
|
||||||
|
|
||||||
|
final r = await ws.switchToSealed(_areaA);
|
||||||
|
|
||||||
|
expect(r.ok, isFalse);
|
||||||
|
expect(r.error, contains(_areaA.name));
|
||||||
|
expect(ws.inSealedArea, isFalse);
|
||||||
|
expect(ws.switching, isFalse);
|
||||||
|
expect(ws.activeSlug, 'proj-a', reason: 'filter untouched');
|
||||||
|
// Attempt at the area, then the rollback to the shared hub —
|
||||||
|
// and the sealed context was never announced in between.
|
||||||
|
expect(hub.reconnects, hasLength(2));
|
||||||
|
expect(hub.reconnects.last.port, isNot(_areaA.port));
|
||||||
|
expect(hub.reconnects.map((c) => c.sealedAnnounced), everyElement(isFalse));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the runs poller is paused while the switch window is '
|
||||||
|
'open and resumes on the settled context', (tester) async {
|
||||||
|
final hub = _install();
|
||||||
|
final ws = Workspace.instance;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
locale: Locale('de'),
|
||||||
|
home: RunsPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
final beforeWindow = hub.detachedReads;
|
||||||
|
expect(beforeWindow, greaterThan(0),
|
||||||
|
reason: 'sanity: the page polls at all');
|
||||||
|
|
||||||
|
// Hold the switch in flight and tick the 2 s poller inside it.
|
||||||
|
hub.healthyGate = Completer<void>();
|
||||||
|
final switching = ws.switchToSealed(_areaA);
|
||||||
|
await tester.pump(const Duration(milliseconds: 10));
|
||||||
|
final inWindow = hub.detachedReads;
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
await tester.pump(const Duration(seconds: 2));
|
||||||
|
expect(
|
||||||
|
hub.detachedReads,
|
||||||
|
inWindow,
|
||||||
|
reason: 'a poll fired inside the switch window — it could have '
|
||||||
|
'rendered the sealed hub\'s data without the sealed marking',
|
||||||
|
);
|
||||||
|
|
||||||
|
hub.healthyGate!.complete();
|
||||||
|
await switching;
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
expect(ws.inSealedArea, isTrue);
|
||||||
|
expect(
|
||||||
|
hub.detachedReads,
|
||||||
|
greaterThan(inWindow),
|
||||||
|
reason: 'the end-of-switch notify must refresh the page against '
|
||||||
|
'the settled context',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tear down the page so its periodic timer dies with it.
|
||||||
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
|
await tester.pump(const Duration(minutes: 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
// Workspace switcher — stage-1 contract: the control renders the
|
// Workspace anchor — contract: the sidebar's ONE switcher control
|
||||||
// active selection, lists "All projects" plus every registry
|
// renders the active selection, lists "All projects" plus every
|
||||||
// project (colour dot, shield for protected), and switching
|
// registry project (colour dot, shield for protected, checkmark on
|
||||||
// updates the shared Workspace notifier.
|
// the active entry), keeps sealed-area names aggregated until
|
||||||
|
// deliberately revealed, asks before starting a stopped area, and
|
||||||
|
// switching updates the shared Workspace notifier.
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:chain_studio/data/hub.dart';
|
import 'package:chain_studio/data/hub.dart';
|
||||||
import 'package:chain_studio/data/sealed_areas.dart';
|
import 'package:chain_studio/data/sealed_areas.dart';
|
||||||
|
|
@ -13,16 +16,25 @@ import 'package:chain_studio/data/workspace_prefs.dart';
|
||||||
import 'package:chain_studio/l10n/app_localizations.dart';
|
import 'package:chain_studio/l10n/app_localizations.dart';
|
||||||
import 'package:chain_studio/widgets/chain_workspace_switcher.dart';
|
import 'package:chain_studio/widgets/chain_workspace_switcher.dart';
|
||||||
|
|
||||||
|
import 'support/fake_hub.dart';
|
||||||
|
|
||||||
Widget _host() {
|
Widget _host() {
|
||||||
return const MaterialApp(
|
return const MaterialApp(
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
home: Scaffold(
|
home: Scaffold(
|
||||||
appBar: PreferredSize(
|
body: Align(
|
||||||
preferredSize: Size.fromHeight(kToolbarHeight),
|
alignment: Alignment.topLeft,
|
||||||
child: Material(child: ChainWorkspaceSwitcher()),
|
child: SizedBox(
|
||||||
|
width: 220,
|
||||||
|
height: 44,
|
||||||
|
child: ChainWorkspaceAnchor(
|
||||||
|
t: 1,
|
||||||
|
labelsInteractive: true,
|
||||||
|
iconColumnWidth: 72,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: SizedBox(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -36,26 +48,46 @@ const _clientA = ProjectRef(
|
||||||
);
|
);
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
late FakeHubService fakeHub;
|
||||||
|
|
||||||
|
// Seed the workspace singleton AND script both service fakes to
|
||||||
|
// match: opening the menu triggers Workspace.refresh, which must
|
||||||
|
// answer from the fakes — never from a live hub or the real
|
||||||
|
// ~/.chain (hermeticity, shared/TESTING.md).
|
||||||
|
void seed({
|
||||||
|
List<ProjectRef> projects = const [_general, _clientA],
|
||||||
|
String active = '',
|
||||||
|
List<SealedArea> sealed = const [],
|
||||||
|
SealedArea? activeSealed,
|
||||||
|
}) {
|
||||||
|
fakeHub.projects = projects;
|
||||||
|
SealedAreaService.debugSetInstance(_FakeSealedAreas(sealed));
|
||||||
|
Workspace.instance.debugSeed(
|
||||||
|
projects: projects,
|
||||||
|
active: active,
|
||||||
|
sealed: sealed,
|
||||||
|
activeSealed: activeSealed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
// Seed the singleton without a hub: tests drive the notifier
|
SharedPreferences.setMockInitialValues({});
|
||||||
// directly through its test hook.
|
fakeHub = installFakeHub();
|
||||||
Workspace.instance.debugSeed(projects: [_general, _clientA], active: '');
|
addTearDown(() => SealedAreaService.debugSetInstance(null));
|
||||||
|
seed();
|
||||||
});
|
});
|
||||||
|
|
||||||
tearDown(() {
|
tearDown(() {
|
||||||
WorkspacePrefs.sealedNamesVisible.value = false;
|
WorkspacePrefs.sealedNamesVisible.value = false;
|
||||||
|
WorkspacePrefs.recentContexts.value = const [];
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('sealed areas stay aggregated until deliberately revealed', (
|
testWidgets('sealed areas stay aggregated until deliberately revealed', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
Workspace.instance.debugSeed(
|
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
|
||||||
projects: [_general],
|
|
||||||
active: '',
|
|
||||||
sealed: [_sealedGrid, _sealedLab],
|
|
||||||
);
|
|
||||||
await tester.pumpWidget(_host());
|
await tester.pumpWidget(_host());
|
||||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
// No names on a casual glance — only the aggregate row.
|
// No names on a casual glance — only the aggregate row.
|
||||||
expect(find.text('grid'), findsNothing);
|
expect(find.text('grid'), findsNothing);
|
||||||
|
|
@ -67,17 +99,30 @@ void main() {
|
||||||
expect(find.text('lab'), findsOneWidget);
|
expect(find.text('lab'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('the aggregate row explains itself and links to Settings', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
|
||||||
|
await tester.pumpWidget(_host());
|
||||||
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// The why-line: aggregation is a confidentiality decision and
|
||||||
|
// must say so in place (in-app docs rule), plus the jump to the
|
||||||
|
// Settings toggle.
|
||||||
|
expect(
|
||||||
|
find.textContaining('reveal client identities'),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.textContaining('Settings'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('the Settings toggle restores the direct listing', (
|
testWidgets('the Settings toggle restores the direct listing', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
WorkspacePrefs.sealedNamesVisible.value = true;
|
WorkspacePrefs.sealedNamesVisible.value = true;
|
||||||
Workspace.instance.debugSeed(
|
seed(projects: [_general], sealed: [_sealedGrid, _sealedLab]);
|
||||||
projects: [_general],
|
|
||||||
active: '',
|
|
||||||
sealed: [_sealedGrid, _sealedLab],
|
|
||||||
);
|
|
||||||
await tester.pumpWidget(_host());
|
await tester.pumpWidget(_host());
|
||||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('grid'), findsOneWidget);
|
expect(find.text('grid'), findsOneWidget);
|
||||||
expect(find.text('lab'), findsOneWidget);
|
expect(find.text('lab'), findsOneWidget);
|
||||||
|
|
@ -95,7 +140,7 @@ void main() {
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(_host());
|
await tester.pumpWidget(_host());
|
||||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('All projects'), findsWidgets);
|
expect(find.text('All projects'), findsWidgets);
|
||||||
|
|
@ -105,21 +150,38 @@ void main() {
|
||||||
expect(find.byIcon(Icons.shield_outlined), findsOneWidget);
|
expect(find.byIcon(Icons.shield_outlined), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('the active entry carries the checkmark', (tester) async {
|
||||||
|
seed(active: 'client-a');
|
||||||
|
await tester.pumpWidget(_host());
|
||||||
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
// Exactly one checkmark, and it sits in the active project's row.
|
||||||
|
expect(find.byIcon(Icons.check), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.descendant(
|
||||||
|
of: find.ancestor(
|
||||||
|
of: find.text('Client A'),
|
||||||
|
matching: find.byType(PopupMenuItem<String>),
|
||||||
|
),
|
||||||
|
matching: find.byIcon(Icons.check),
|
||||||
|
),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('selecting a project updates the workspace and the label', (
|
testWidgets('selecting a project updates the workspace and the label', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
await tester.pumpWidget(_host());
|
await tester.pumpWidget(_host());
|
||||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('Client A').last);
|
await tester.tap(find.text('Client A').last);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(Workspace.instance.activeSlug, 'client-a');
|
expect(Workspace.instance.activeSlug, 'client-a');
|
||||||
expect(Workspace.instance.active?.isProtected, isTrue);
|
expect(Workspace.instance.active?.isProtected, isTrue);
|
||||||
// The closed control now shows the active project (label +
|
// The closed control now shows the active project.
|
||||||
// shield marker for protected).
|
|
||||||
expect(find.text('Client A'), findsOneWidget);
|
expect(find.text('Client A'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.shield_outlined), findsOneWidget);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('active falls back to null when the slug left the registry', () {
|
test('active falls back to null when the slug left the registry', () {
|
||||||
|
|
@ -132,9 +194,8 @@ void main() {
|
||||||
testWidgets('lists sealed areas with lock + running/stopped status', (
|
testWidgets('lists sealed areas with lock + running/stopped status', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
Workspace.instance.debugSeed(
|
seed(
|
||||||
projects: [_general],
|
projects: [_general],
|
||||||
active: '',
|
|
||||||
sealed: const [
|
sealed: const [
|
||||||
SealedArea(
|
SealedArea(
|
||||||
slug: 'grid',
|
slug: 'grid',
|
||||||
|
|
@ -153,7 +214,7 @@ void main() {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
await tester.pumpWidget(_host());
|
await tester.pumpWidget(_host());
|
||||||
await tester.tap(find.byType(ChainWorkspaceSwitcher));
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
// Sealed areas appear under the sealed header, aggregated by
|
// Sealed areas appear under the sealed header, aggregated by
|
||||||
|
|
@ -169,13 +230,31 @@ void main() {
|
||||||
expect(find.text('stopped'), findsOneWidget);
|
expect(find.text('stopped'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('the pill shows the active sealed area with a lock', (
|
testWidgets('selecting a STOPPED area asks before starting its hub', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
Workspace.instance.debugSeed(
|
WorkspacePrefs.sealedNamesVisible.value = true;
|
||||||
|
seed(projects: [_general], sealed: [_sealedGrid]); // grid: stopped
|
||||||
|
await tester.pumpWidget(_host());
|
||||||
|
await tester.tap(find.byType(ChainWorkspaceAnchor));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('grid'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The confirmation dialog — an area switch must never boot a
|
||||||
|
// hub daemon as a click side-effect.
|
||||||
|
expect(find.text('Start area “grid”?'), findsOneWidget);
|
||||||
|
expect(find.text('Cancel'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('Cancel'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(Workspace.instance.inSealedArea, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the anchor shows the active sealed area with a lock', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
seed(
|
||||||
projects: [_general],
|
projects: [_general],
|
||||||
active: '',
|
|
||||||
sealed: const [],
|
|
||||||
activeSealed: const SealedArea(
|
activeSealed: const SealedArea(
|
||||||
slug: 'grid',
|
slug: 'grid',
|
||||||
name: 'Grid',
|
name: 'Grid',
|
||||||
|
|
@ -186,12 +265,24 @@ void main() {
|
||||||
);
|
);
|
||||||
await tester.pumpWidget(_host());
|
await tester.pumpWidget(_host());
|
||||||
expect(Workspace.instance.inSealedArea, isTrue);
|
expect(Workspace.instance.inSealedArea, isTrue);
|
||||||
// The closed pill names the sealed area and carries a lock.
|
// The closed anchor names the sealed area and carries a lock.
|
||||||
expect(find.text('Grid'), findsOneWidget);
|
expect(find.text('Grid'), findsOneWidget);
|
||||||
expect(find.byIcon(Icons.lock_outline), findsWidgets);
|
expect(find.byIcon(Icons.lock_outline), findsWidgets);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FakeSealedAreas extends SealedAreaService {
|
||||||
|
_FakeSealedAreas(this.areas) : super.forTest();
|
||||||
|
|
||||||
|
final List<SealedArea> areas;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<SealedArea>> list() async => areas;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> boundEndpoint(String slug) async => null;
|
||||||
|
}
|
||||||
|
|
||||||
// Sealed-area confidentiality (usertest security finding): the
|
// Sealed-area confidentiality (usertest security finding): the
|
||||||
// switcher must not disclose sealed-area names — often client
|
// switcher must not disclose sealed-area names — often client
|
||||||
// identity — on a casual glance. Aggregated row by default,
|
// identity — on a casual glance. Aggregated row by default,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue