fix(welcome): structured doc-load error + Forgejo fallback link
Some checks failed
Security / Security check (push) Failing after 2s

The doc-reader bottom-sheet in welcome.dart hits rootBundle
to load assets/docs/<slug>[_de].md. When the asset isn't in
the bundle (e.g. the binary on disk predates the
flutter_markdown_plus migration in 69b54629 or an in-progress
asset reorganisation), the FutureBuilder's error branch dumps
the raw PlatformException text into FaiErrorBox with no
hint at what the operator should do.

Switch the throw to a structured `_DocReaderError` that
carries:
  - the slug, locale, and both attempted asset paths
  - the underlying error message
  - a precomputed `forgejoUrl` pointing at the same doc on
    the platform repo's main branch

The error UI gains a TextButton.icon below FaiErrorBox that
opens the Forgejo copy in the OS browser via the existing
SystemActions.openInOs path — operators always have a
working out, even when the local bundle is broken.

The error toString() also surfaces "rebuild Studio" as the
top suggestion, since the most common cause for asset
mismatches is a stale binary running against a newer source
tree.

New regression test (test/load_docs_test.dart) pumps the
exact code path the sheet uses (rootBundle.loadString +
Markdown with FaiTheme.markdownStyle) against all eight asset
paths (architecture/security/audit/flows × en/de). Catches a
future bundle drift before an operator notices.

No behavioural change on the happy path. Both the rendered
markdown widget and the closed-sheet animation are unchanged.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-30 00:54:46 +02:00
parent 1c306916aa
commit 570c38b238
2 changed files with 107 additions and 7 deletions

View file

@ -910,6 +910,36 @@ class _DocCard extends StatelessWidget {
}
}
/// Structured error type for the doc reader. Allows the
/// error UI to render distinct lines for "what we tried",
/// "what to try next", and "the raw underlying error" and
/// to surface the Forgejo browser fallback as a one-click
/// link instead of a sentence to copy.
class _DocReaderError {
final String slug;
final String locale;
final List<String> attempted;
final String underlying;
const _DocReaderError({
required this.slug,
required this.locale,
required this.attempted,
required this.underlying,
});
String get forgejoUrl =>
'https://git.flemming.ai/fai/platform/src/branch/main/docs/architecture/$slug.md';
@override
String toString() =>
'Could not load the bundled doc "$slug" ($locale).\n'
'Tried: ${attempted.join(", ")}\n'
'Underlying: $underlying\n'
'This typically means the Studio binary on disk is from\n'
'before the doc bundle changed. Rebuild Studio (fai studio\n'
'or the build pipeline) or open the Forgejo copy in browser.';
}
/// Modal bottom-sheet that loads the markdown for a doc entry
/// from `assets/docs/<slug>[_de].md` and renders it inline via
/// flutter_markdown. No browser, no network KRITIS-friendly.
@ -951,18 +981,24 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
final locale = Localizations.localeOf(context).languageCode;
final localised = 'assets/docs/${widget.entry.slug}_$locale.md';
final fallback = 'assets/docs/${widget.entry.slug}.md';
// Cache-bust attempts. If the operator is running a stale
// build from before the flutter_markdown_plus migration,
// the .dart_tool asset manifest can be inconsistent with
// the compiled binary surface "rebuild Studio" as a
// first-class hint rather than just dumping the raw
// PlatformException string into FaiErrorBox.
try {
return await rootBundle.loadString(localised);
} catch (firstErr) {
try {
return await rootBundle.loadString(fallback);
} catch (secondErr) {
// Surface BOTH attempted paths and the underlying
// errors so the operator can copy a useful diagnostic
// out of FaiErrorBox without us having to ship a doc
// about reading Flutter asset errors.
throw 'tried "$localised": $firstErr\n'
'then "$fallback": $secondErr';
throw _DocReaderError(
slug: widget.entry.slug,
locale: locale,
attempted: [localised, fallback],
underlying: '$firstErr / $secondErr',
);
}
}
}
@ -1028,6 +1064,9 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
);
}
if (snap.hasError) {
final err = snap.error;
final structured =
err is _DocReaderError ? err : null;
return Padding(
padding: const EdgeInsets.all(FaiSpace.xxl),
child: Column(
@ -1041,10 +1080,19 @@ class _DocReaderSheetState extends State<_DocReaderSheet> {
),
const SizedBox(height: FaiSpace.sm),
FaiErrorBox(
error: snap.error,
error: err,
isError: true,
maxHeight: 200,
),
if (structured != null) ...[
const SizedBox(height: FaiSpace.md),
TextButton.icon(
icon: const Icon(Icons.open_in_new, size: 16),
label: Text(structured.forgejoUrl),
onPressed: () =>
SystemActions.openInOs(structured.forgejoUrl),
),
],
],
),
);