refactor: rename internal Fai* design system + fai_ helpers to chain
Some checks failed
Security / Security check (push) Failing after 2s

The Studio design system, widgets and helpers carried a Fai* / fai_
prefix (FaiSpace, FaiColors, FaiTheme, FaiLog, 17 fai_*.dart files, the
faiBinary* l10n keys). Studio is the Ch∆In product, so rename them to
Chain* / chain_ — carefully preserving English fail/failure/failed.
Also fix stale references: the 'fai' binary in l10n strings -> 'chain',
FAI_* env vars (FAI_BIN/DATA_DIR/MODULES_DIR/TODAY/BOOTSTRAP_TOKEN) ->
CHAIN_*, fai_platform -> fai_chain, fai_hub -> chain_hub. Vendor
security-hook tooling (FAI_BANNED_TERMS_FILE) + the .fai bundle ext left.
flutter analyze + test: clean (20 passed).

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-16 17:53:17 +02:00
parent 68d23ab7dd
commit 891acd2ba2
52 changed files with 1225 additions and 1225 deletions

View file

@ -0,0 +1,443 @@
// ChainSearchPalette global Cmd+K command bar.
//
// Indexes: nav destinations, installed modules, store entries,
// saved flows. Filters client-side as the operator types.
// Selected result navigates / opens / acts in one keystroke.
//
// Triggered from the shell-level Shortcuts/Actions binding so
// the palette is reachable from every page.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../data/hub.dart';
import '../l10n/app_localizations.dart';
import 'chain_module_sheet.dart';
/// One row in the palette. Carries everything needed to render
/// + dispatch keep it serializable-shaped so we can persist
/// recent picks later.
class ChainSearchHit {
final String label;
final String hint;
final IconData icon;
/// Free-form group used for the section header in the
/// results list ("Pages", "Modules", "Store", "Flows").
final String group;
/// Lowercase haystack name + hint + group. Filtered against
/// the lowercased query.
final String haystack;
/// Action to run when this hit is selected.
final VoidCallback onSelect;
ChainSearchHit({
required this.label,
required this.hint,
required this.icon,
required this.group,
required this.onSelect,
}) : haystack = '$label $hint $group'.toLowerCase();
}
class ChainSearchPalette extends StatefulWidget {
/// Pages the operator can navigate to. Each entry maps to a
/// callback that selects the right tab back in the shell.
final List<ChainSearchHit> staticHits;
const ChainSearchPalette({super.key, required this.staticHits});
/// Convenience launcher used from the Cmd+K shortcut.
static Future<void> show(
BuildContext context, {
required List<ChainSearchHit> staticHits,
}) {
return showDialog<void>(
context: context,
barrierColor: Colors.black54,
builder: (_) => ChainSearchPalette(staticHits: staticHits),
);
}
@override
State<ChainSearchPalette> createState() => _FaiSearchPaletteState();
}
class _FaiSearchPaletteState extends State<ChainSearchPalette> {
final _query = TextEditingController();
final _focus = FocusNode();
Future<List<ChainSearchHit>>? _dynamicFuture;
int _highlight = 0;
@override
void initState() {
super.initState();
_dynamicFuture = _loadDynamic();
// Autofocus is handled by the TextField, but the focus
// node also catches arrow keys for keyboard navigation.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focus.requestFocus();
});
}
@override
void dispose() {
_query.dispose();
_focus.dispose();
super.dispose();
}
/// Pull modules / store / flows in parallel so the palette
/// is searchable as soon as the operator finishes typing the
/// first character.
Future<List<ChainSearchHit>> _loadDynamic() async {
final svc = HubService.instance;
final l = AppLocalizations.of(context)!;
final results = await Future.wait([
svc.listModules().catchError((_) => <ModuleSummary>[]),
svc.searchStore(limit: 200).catchError((_) => <StoreItem>[]),
svc.listFlows().catchError((_) => <SavedFlow>[]),
]);
final modules = results[0] as List<ModuleSummary>;
final store = results[1] as List<StoreItem>;
final flows = results[2] as List<SavedFlow>;
final hits = <ChainSearchHit>[];
for (final m in modules) {
hits.add(
ChainSearchHit(
label: m.name,
hint: l.searchInstalledModuleHint(m.version),
icon: Icons.extension,
group: l.searchGroupModules,
onSelect: () {
Navigator.pop(context);
ChainModuleSheet.show(context, m.name);
},
),
);
}
final storeGroup = l.searchGroupStore;
final pagesGroup = l.searchGroupPages;
for (final s in store) {
hits.add(
ChainSearchHit(
label: s.name,
hint: s.taglineEn.isEmpty
? l.searchStoreHintWithCategory(
s.category.isEmpty ? l.searchStoreUncategorized : s.category,
)
: l.searchStoreHintWithTagline(s.taglineEn),
icon: Icons.storefront_outlined,
group: storeGroup,
// Selecting a store hit just closes the palette and
// focuses the Store tab; deep-linking to the detail
// sheet would require thread-through plumbing we don't
// have yet.
onSelect: () {
Navigator.pop(context);
for (final h in widget.staticHits) {
if (h.label == storeGroup && h.group == pagesGroup) {
h.onSelect();
return;
}
}
},
),
);
}
final flowsGroup = l.searchGroupFlows;
for (final f in flows) {
hits.add(
ChainSearchHit(
label: f.name,
hint: l.searchSavedFlowHint,
icon: Icons.account_tree_outlined,
group: flowsGroup,
onSelect: () {
Navigator.pop(context);
for (final h in widget.staticHits) {
if (h.label == flowsGroup && h.group == pagesGroup) {
h.onSelect();
return;
}
}
},
),
);
}
return hits;
}
/// Filtered + grouped result list. Empty query returns the
/// pages first then everything else, capped to 50 rows so the
/// dialog stays scrollable.
List<ChainSearchHit> _resultsFor(List<ChainSearchHit> dynamic_, String raw) {
final query = raw.trim().toLowerCase();
final all = [...widget.staticHits, ...dynamic_];
if (query.isEmpty) return all.take(50).toList();
final scored = <(int, ChainSearchHit)>[];
for (final h in all) {
final i = h.haystack.indexOf(query);
if (i < 0) continue;
// Prefer label-prefix matches, then any substring.
final isLabelPrefix = h.label.toLowerCase().startsWith(query);
final score = (isLabelPrefix ? 0 : 1000) + i;
scored.add((score, h));
}
scored.sort((a, b) => a.$1.compareTo(b.$1));
return [for (final s in scored) s.$2].take(50).toList();
}
KeyEventResult _onKeyEvent(FocusNode node, KeyEvent event, int hitCount) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored;
}
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
setState(() {
_highlight = (_highlight + 1).clamp(0, hitCount - 1);
});
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
setState(() {
_highlight = (_highlight - 1).clamp(0, hitCount - 1);
});
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.escape) {
Navigator.pop(context);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
return Dialog(
alignment: Alignment.topCenter,
insetPadding: const EdgeInsets.only(top: 80, left: 24, right: 24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640, maxHeight: 540),
child: FutureBuilder<List<ChainSearchHit>>(
future: _dynamicFuture,
builder: (context, snap) {
final dyn = snap.data ?? const <ChainSearchHit>[];
return ValueListenableBuilder<TextEditingValue>(
valueListenable: _query,
builder: (_, value, _) {
final hits = _resultsFor(dyn, value.text);
if (_highlight >= hits.length) {
_highlight = hits.isEmpty ? 0 : hits.length - 1;
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Focus(
focusNode: _focus,
onKeyEvent: (n, e) => _onKeyEvent(n, e, hits.length),
child: TextField(
controller: _query,
autofocus: true,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search, size: 20),
hintText: l.searchHint,
border: InputBorder.none,
suffixIcon:
snap.connectionState == ConnectionState.waiting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: null,
),
style: theme.textTheme.titleMedium,
onSubmitted: (_) {
if (hits.isNotEmpty) {
hits[_highlight].onSelect();
}
},
),
),
),
const Divider(height: 1),
Flexible(
child: hits.isEmpty
? Padding(
padding: const EdgeInsets.all(24),
child: Text(
snap.connectionState == ConnectionState.waiting
? l.searchIndexing
: l.searchNoMatches,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: hits.length,
itemBuilder: (context, i) {
final h = hits[i];
final highlighted = i == _highlight;
final showHeader =
i == 0 || hits[i - 1].group != h.group;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showHeader)
Padding(
padding: const EdgeInsets.fromLTRB(
16,
8,
16,
4,
),
child: Text(
h.group.toUpperCase(),
style: theme.textTheme.labelSmall
?.copyWith(
color: theme
.colorScheme
.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
),
InkWell(
onTap: h.onSelect,
onHover: (hovered) {
if (hovered) {
setState(() => _highlight = i);
}
},
child: Container(
color: highlighted
? theme
.colorScheme
.surfaceContainerHigh
: null,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
Icon(
h.icon,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
h.label,
style: theme
.textTheme
.bodyMedium
?.copyWith(
fontWeight:
FontWeight.w500,
),
),
if (h.hint.isNotEmpty)
Text(
h.hint,
style: theme
.textTheme
.bodySmall
?.copyWith(
color: theme
.colorScheme
.onSurfaceVariant,
),
maxLines: 1,
overflow:
TextOverflow.ellipsis,
),
],
),
),
if (highlighted)
Icon(
Icons.keyboard_return,
size: 14,
color: theme
.colorScheme
.onSurfaceVariant,
),
],
),
),
),
],
);
},
),
),
const Divider(height: 1),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 6,
),
child: Row(
children: [
_Hint(text: l.searchHintNavigate),
const SizedBox(width: 12),
_Hint(text: l.searchHintOpen),
const SizedBox(width: 12),
_Hint(text: l.searchHintClose),
const Spacer(),
Text(
l.storeNResults(hits.length),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
);
},
);
},
),
),
);
}
}
class _Hint extends StatelessWidget {
final String text;
const _Hint({required this.text});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 10,
),
);
}
}