feat(studio): registry credentials panel in Settings (v0.44.0)
The hub install path now reads `~/.fai/registry-token` as a fallback when `FAI_REGISTRY_TOKEN` is unset (platform v0.10.92). Studio now writes that file directly: a fresh operator pastes the PAT into Settings → Registry credentials, hits Save, and the next install attempt resolves the auth wall without any shell or env-var setup. The token never round-trips back into Studio after save — status is shown only as "Configured (40 chars)" / "Not set" with no display of the secret itself. The Clear action deletes the file. On Unix the file is chmod-ed to 0600 (owner-only read/write); Windows leaves the default user ACL in place. Storage is strictly local: `~/.fai/registry-token`, never sent to a remote service. The hint text under the field says so to make the data flow explicit. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
01f3f773cf
commit
878f0a4e28
8 changed files with 464 additions and 6 deletions
66
lib/data/registry_token.dart
Normal file
66
lib/data/registry_token.dart
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import 'dart:io';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
/// Operator-managed registry auth token, kept at
|
||||
/// `~/.fai/registry-token` (mode 0600 on Unix). The hub reads
|
||||
/// this file at install time when `FAI_REGISTRY_TOKEN` is unset
|
||||
/// — see `download_to_temp` in `crates/fai_hub/src/lib.rs`.
|
||||
///
|
||||
/// Studio writes the file directly so a fresh install never
|
||||
/// requires the operator to fiddle with shell env vars.
|
||||
class RegistryToken {
|
||||
static String _faiHome() {
|
||||
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||
if (home == null || home.isEmpty) {
|
||||
throw StateError('Cannot resolve home directory (no HOME / USERPROFILE)');
|
||||
}
|
||||
return p.join(home, '.fai');
|
||||
}
|
||||
|
||||
/// Absolute path to the token file.
|
||||
static String get path => p.join(_faiHome(), 'registry-token');
|
||||
|
||||
/// True iff the file exists with a non-empty trimmed body.
|
||||
static Future<bool> isConfigured() async {
|
||||
final f = File(path);
|
||||
if (!await f.exists()) return false;
|
||||
final content = await f.readAsString();
|
||||
return content.trim().isNotEmpty;
|
||||
}
|
||||
|
||||
/// Length of the trimmed token, or null when the file is
|
||||
/// missing / empty. Used for status display ("Configured
|
||||
/// (40 chars)") without ever surfacing the secret.
|
||||
static Future<int?> charCount() async {
|
||||
final f = File(path);
|
||||
if (!await f.exists()) return null;
|
||||
final content = await f.readAsString();
|
||||
final trimmed = content.trim();
|
||||
return trimmed.isEmpty ? null : trimmed.length;
|
||||
}
|
||||
|
||||
/// Persist [token] to disk, creating `~/.fai/` if needed.
|
||||
/// On Unix the file is chmod-ed to 0600 (owner read/write
|
||||
/// only). On Windows the default user-ACL is left alone —
|
||||
/// best-effort, no PowerShell handshake.
|
||||
static Future<void> write(String token) async {
|
||||
final f = File(path);
|
||||
await f.parent.create(recursive: true);
|
||||
await f.writeAsString(token.trim(), flush: true);
|
||||
if (Platform.isLinux || Platform.isMacOS) {
|
||||
try {
|
||||
await Process.run('chmod', ['600', f.path]);
|
||||
} catch (_) {
|
||||
// best-effort; if chmod is unavailable the file still
|
||||
// exists with default perms (usually 0644, world-readable
|
||||
// but only on the operator's own machine).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the token file. No-op when the file doesn't exist.
|
||||
static Future<void> delete() async {
|
||||
final f = File(path);
|
||||
if (await f.exists()) await f.delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -433,6 +433,22 @@
|
|||
"@flowsOpenInEditorFailed": {
|
||||
"placeholders": { "error": { "type": "String" } }
|
||||
},
|
||||
"registryCredentialsHeader": "REGISTRY-ZUGANGSDATEN",
|
||||
"registryCredentialsBlurb": "Token zum Herunterladen von .fai-Modulen aus einer Registry mit Anmeldepflicht (Forgejo, private GitHub-Repos). Gespeichert in ~/.fai/registry-token, Modus 0600. Die Umgebungsvariable FAI_REGISTRY_TOKEN hat weiterhin Vorrang, wenn gesetzt.",
|
||||
"registryTokenStatusConfigured": "Konfiguriert ({chars} Zeichen)",
|
||||
"@registryTokenStatusConfigured": { "placeholders": { "chars": { "type": "int" } } },
|
||||
"registryTokenStatusNotSet": "Nicht gesetzt",
|
||||
"registryTokenFieldLabel": "Token",
|
||||
"registryTokenFieldHint": "Forgejo- oder GitHub-PAT einfügen",
|
||||
"registryTokenSaveButton": "Speichern",
|
||||
"registryTokenClearButton": "Entfernen",
|
||||
"registryTokenStorageHint": "Lokal gespeichert in ~/.fai/registry-token. Wird nie an einen Remote-Dienst übertragen.",
|
||||
"registryTokenSavedToast": "Registry-Token gespeichert.",
|
||||
"registryTokenClearedToast": "Registry-Token entfernt.",
|
||||
"registryTokenSaveFailedToast": "Speichern fehlgeschlagen: {error}",
|
||||
"@registryTokenSaveFailedToast": {
|
||||
"placeholders": { "error": { "type": "String" } }
|
||||
},
|
||||
"welcomeChecklistAllSetTitle": "Du bist eingerichtet.",
|
||||
"welcomeChecklistAllSetBody": "Drei Stränge, an denen du als nächstes ziehen kannst:",
|
||||
"welcomeChecklistNextAuditTitle": "Audit-Log lesen",
|
||||
|
|
|
|||
|
|
@ -434,6 +434,22 @@
|
|||
"@flowsOpenInEditorFailed": {
|
||||
"placeholders": { "error": { "type": "String" } }
|
||||
},
|
||||
"registryCredentialsHeader": "REGISTRY CREDENTIALS",
|
||||
"registryCredentialsBlurb": "Token used to download .fai modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.fai/registry-token, mode 0600. The FAI_REGISTRY_TOKEN env var still wins when set.",
|
||||
"registryTokenStatusConfigured": "Configured ({chars} chars)",
|
||||
"@registryTokenStatusConfigured": { "placeholders": { "chars": { "type": "int" } } },
|
||||
"registryTokenStatusNotSet": "Not set",
|
||||
"registryTokenFieldLabel": "Token",
|
||||
"registryTokenFieldHint": "Paste your Forgejo or GitHub personal access token",
|
||||
"registryTokenSaveButton": "Save",
|
||||
"registryTokenClearButton": "Clear",
|
||||
"registryTokenStorageHint": "Saved locally to ~/.fai/registry-token. Never sent to a remote service.",
|
||||
"registryTokenSavedToast": "Registry token saved.",
|
||||
"registryTokenClearedToast": "Registry token cleared.",
|
||||
"registryTokenSaveFailedToast": "Could not save: {error}",
|
||||
"@registryTokenSaveFailedToast": {
|
||||
"placeholders": { "error": { "type": "String" } }
|
||||
},
|
||||
"welcomeChecklistAllSetTitle": "You're set up.",
|
||||
"welcomeChecklistAllSetBody": "Three threads to pull on next:",
|
||||
"welcomeChecklistNextAuditTitle": "Read the audit log",
|
||||
|
|
|
|||
|
|
@ -1970,6 +1970,78 @@ abstract class AppLocalizations {
|
|||
/// **'Could not open file: {error}'**
|
||||
String flowsOpenInEditorFailed(String error);
|
||||
|
||||
/// No description provided for @registryCredentialsHeader.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'REGISTRY CREDENTIALS'**
|
||||
String get registryCredentialsHeader;
|
||||
|
||||
/// No description provided for @registryCredentialsBlurb.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Token used to download .fai modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.fai/registry-token, mode 0600. The FAI_REGISTRY_TOKEN env var still wins when set.'**
|
||||
String get registryCredentialsBlurb;
|
||||
|
||||
/// No description provided for @registryTokenStatusConfigured.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Configured ({chars} chars)'**
|
||||
String registryTokenStatusConfigured(int chars);
|
||||
|
||||
/// No description provided for @registryTokenStatusNotSet.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Not set'**
|
||||
String get registryTokenStatusNotSet;
|
||||
|
||||
/// No description provided for @registryTokenFieldLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Token'**
|
||||
String get registryTokenFieldLabel;
|
||||
|
||||
/// No description provided for @registryTokenFieldHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Paste your Forgejo or GitHub personal access token'**
|
||||
String get registryTokenFieldHint;
|
||||
|
||||
/// No description provided for @registryTokenSaveButton.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Save'**
|
||||
String get registryTokenSaveButton;
|
||||
|
||||
/// No description provided for @registryTokenClearButton.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Clear'**
|
||||
String get registryTokenClearButton;
|
||||
|
||||
/// No description provided for @registryTokenStorageHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Saved locally to ~/.fai/registry-token. Never sent to a remote service.'**
|
||||
String get registryTokenStorageHint;
|
||||
|
||||
/// No description provided for @registryTokenSavedToast.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Registry token saved.'**
|
||||
String get registryTokenSavedToast;
|
||||
|
||||
/// No description provided for @registryTokenClearedToast.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Registry token cleared.'**
|
||||
String get registryTokenClearedToast;
|
||||
|
||||
/// No description provided for @registryTokenSaveFailedToast.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Could not save: {error}'**
|
||||
String registryTokenSaveFailedToast(String error);
|
||||
|
||||
/// No description provided for @welcomeChecklistAllSetTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -1117,6 +1117,48 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return 'Konnte Datei nicht öffnen: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get registryCredentialsHeader => 'REGISTRY-ZUGANGSDATEN';
|
||||
|
||||
@override
|
||||
String get registryCredentialsBlurb =>
|
||||
'Token zum Herunterladen von .fai-Modulen aus einer Registry mit Anmeldepflicht (Forgejo, private GitHub-Repos). Gespeichert in ~/.fai/registry-token, Modus 0600. Die Umgebungsvariable FAI_REGISTRY_TOKEN hat weiterhin Vorrang, wenn gesetzt.';
|
||||
|
||||
@override
|
||||
String registryTokenStatusConfigured(int chars) {
|
||||
return 'Konfiguriert ($chars Zeichen)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get registryTokenStatusNotSet => 'Nicht gesetzt';
|
||||
|
||||
@override
|
||||
String get registryTokenFieldLabel => 'Token';
|
||||
|
||||
@override
|
||||
String get registryTokenFieldHint => 'Forgejo- oder GitHub-PAT einfügen';
|
||||
|
||||
@override
|
||||
String get registryTokenSaveButton => 'Speichern';
|
||||
|
||||
@override
|
||||
String get registryTokenClearButton => 'Entfernen';
|
||||
|
||||
@override
|
||||
String get registryTokenStorageHint =>
|
||||
'Lokal gespeichert in ~/.fai/registry-token. Wird nie an einen Remote-Dienst übertragen.';
|
||||
|
||||
@override
|
||||
String get registryTokenSavedToast => 'Registry-Token gespeichert.';
|
||||
|
||||
@override
|
||||
String get registryTokenClearedToast => 'Registry-Token entfernt.';
|
||||
|
||||
@override
|
||||
String registryTokenSaveFailedToast(String error) {
|
||||
return 'Speichern fehlgeschlagen: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get welcomeChecklistAllSetTitle => 'Du bist eingerichtet.';
|
||||
|
||||
|
|
|
|||
|
|
@ -1131,6 +1131,49 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
return 'Could not open file: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get registryCredentialsHeader => 'REGISTRY CREDENTIALS';
|
||||
|
||||
@override
|
||||
String get registryCredentialsBlurb =>
|
||||
'Token used to download .fai modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.fai/registry-token, mode 0600. The FAI_REGISTRY_TOKEN env var still wins when set.';
|
||||
|
||||
@override
|
||||
String registryTokenStatusConfigured(int chars) {
|
||||
return 'Configured ($chars chars)';
|
||||
}
|
||||
|
||||
@override
|
||||
String get registryTokenStatusNotSet => 'Not set';
|
||||
|
||||
@override
|
||||
String get registryTokenFieldLabel => 'Token';
|
||||
|
||||
@override
|
||||
String get registryTokenFieldHint =>
|
||||
'Paste your Forgejo or GitHub personal access token';
|
||||
|
||||
@override
|
||||
String get registryTokenSaveButton => 'Save';
|
||||
|
||||
@override
|
||||
String get registryTokenClearButton => 'Clear';
|
||||
|
||||
@override
|
||||
String get registryTokenStorageHint =>
|
||||
'Saved locally to ~/.fai/registry-token. Never sent to a remote service.';
|
||||
|
||||
@override
|
||||
String get registryTokenSavedToast => 'Registry token saved.';
|
||||
|
||||
@override
|
||||
String get registryTokenClearedToast => 'Registry token cleared.';
|
||||
|
||||
@override
|
||||
String registryTokenSaveFailedToast(String error) {
|
||||
return 'Could not save: $error';
|
||||
}
|
||||
|
||||
@override
|
||||
String get welcomeChecklistAllSetTitle => 'You\'re set up.';
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/hub.dart';
|
||||
import '../data/registry_token.dart';
|
||||
import '../data/system_actions.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
|
|
@ -41,6 +42,10 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
SystemAiStatus? _aiStatus;
|
||||
List<McpClientInfo>? _mcpClients;
|
||||
List<N8nEndpointInfo>? _n8nEndpoints;
|
||||
/// Length of the configured registry token, or null when the
|
||||
/// `~/.fai/registry-token` file is missing / empty. Reloaded
|
||||
/// after save / clear.
|
||||
int? _registryTokenChars;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -53,6 +58,51 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
_loadAiStatus();
|
||||
_loadMcpClients();
|
||||
_loadN8nEndpoints();
|
||||
_loadRegistryToken();
|
||||
}
|
||||
|
||||
Future<void> _loadRegistryToken() async {
|
||||
try {
|
||||
final n = await RegistryToken.charCount();
|
||||
if (!mounted) return;
|
||||
setState(() => _registryTokenChars = n);
|
||||
} catch (_) {/* fail-quiet */}
|
||||
}
|
||||
|
||||
Future<void> _saveRegistryToken(String token) async {
|
||||
try {
|
||||
await RegistryToken.write(token);
|
||||
await _loadRegistryToken();
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.registryTokenSavedToast)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearRegistryToken() async {
|
||||
try {
|
||||
await RegistryToken.delete();
|
||||
await _loadRegistryToken();
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.registryTokenClearedToast)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadN8nEndpoints() async {
|
||||
|
|
@ -453,6 +503,12 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
onRefresh: _refreshN8nEndpoints,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_RegistryCredentialsPanel(
|
||||
configuredChars: _registryTokenChars,
|
||||
onSave: _saveRegistryToken,
|
||||
onClear: _clearRegistryToken,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -1381,12 +1437,159 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
|||
/// metadata; nothing is auto-spawned. Trust model:
|
||||
/// referencing public projects with their canonical install
|
||||
/// command is the same legal / security shape as a package
|
||||
/// manager listing a third-party repo.
|
||||
/// Credentials panel for the operator's registry auth token.
|
||||
/// The hub reads the same token via `~/.fai/registry-token`
|
||||
/// (or the `FAI_REGISTRY_TOKEN` env var, which still wins)
|
||||
/// when downloading `.fai` bundles from a registry behind a
|
||||
/// signin wall — Forgejo with REQUIRE_SIGNIN_VIEW=true,
|
||||
/// GitHub-private releases, etc.
|
||||
///
|
||||
/// All entries point at official Anthropic MCP servers from
|
||||
/// `github.com/modelcontextprotocol/servers` plus a few
|
||||
/// community-curated standards. Refresh the list when the
|
||||
/// upstream README changes.
|
||||
/// The token itself is never round-tripped back into Studio
|
||||
/// after save: `configuredChars` is the trimmed length, used
|
||||
/// only as a "Configured (40 chars)" status display so the
|
||||
/// operator knows something landed without seeing the secret.
|
||||
class _RegistryCredentialsPanel extends StatefulWidget {
|
||||
/// Trimmed length of the persisted token, or null when none
|
||||
/// is configured.
|
||||
final int? configuredChars;
|
||||
final ValueChanged<String> onSave;
|
||||
final Future<void> Function() onClear;
|
||||
|
||||
const _RegistryCredentialsPanel({
|
||||
required this.configuredChars,
|
||||
required this.onSave,
|
||||
required this.onClear,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_RegistryCredentialsPanel> createState() =>
|
||||
_RegistryCredentialsPanelState();
|
||||
}
|
||||
|
||||
class _RegistryCredentialsPanelState
|
||||
extends State<_RegistryCredentialsPanel> {
|
||||
final _controller = TextEditingController();
|
||||
bool _obscured = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller.addListener(() {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final isConfigured = widget.configuredChars != null;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l.registryCredentialsHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l.registryCredentialsBlurb,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isConfigured ? Icons.check_circle : Icons.radio_button_unchecked,
|
||||
size: 14,
|
||||
color: isConfigured
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.xs),
|
||||
Text(
|
||||
isConfigured
|
||||
? l.registryTokenStatusConfigured(widget.configuredChars!)
|
||||
: l.registryTokenStatusNotSet,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: isConfigured
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: isConfigured ? FontWeight.w600 : null,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (isConfigured)
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.delete_outline, size: 14),
|
||||
onPressed: () async {
|
||||
await widget.onClear();
|
||||
_controller.clear();
|
||||
},
|
||||
label: Text(l.registryTokenClearButton),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
obscureText: _obscured,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.registryTokenFieldLabel,
|
||||
hintText: l.registryTokenFieldHint,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscured ? Icons.visibility : Icons.visibility_off,
|
||||
size: 16,
|
||||
),
|
||||
onPressed: () => setState(() => _obscured = !_obscured),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
FilledButton.icon(
|
||||
icon: const Icon(Icons.save_outlined, size: 14),
|
||||
label: Text(l.registryTokenSaveButton),
|
||||
onPressed: _controller.text.trim().isEmpty
|
||||
? null
|
||||
: () {
|
||||
widget.onSave(_controller.text);
|
||||
_controller.clear();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l.registryTokenStorageHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _McpSuggestion {
|
||||
final String name;
|
||||
final IconData icon;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue