Some checks failed
Security / Security check (push) Failing after 1s
New top-level destination "Editor" (Cmd+5) ships as Studio's
fifth surface. The editor reads + writes flow YAML directly
under ~/.fai/data/flows/ via dart:io — the hub picks up the
changes on the next listFlows / runSavedFlow call.
Layout: two-pane shell. Left (240 px) is the file list; right
flexes to the code pane and an optional results column when
Run produces output. Top toolbar exposes:
* filename + dirty-mark
* New flow (scaffolds from a debug.echo template)
* Save (writes the active file to disk)
* Run (saves first if dirty, calls
HubService.runSavedFlow, surfaces typed FlowOutputs in
a side panel)
* Refresh
YAML highlighting via flutter_code_editor + the highlight
package's yaml language. Lightweight style map mapping the
five token classes that actually appear in flow YAML
(attr / string / number / comment / subst for the
${{ ... }} template syntax) to FaiTheme colors — keeps the
editor visually consistent with the rest of Studio.
New-flow naming uses a FilteringTextInputFormatter that
restricts the name to [a-z0-9_-]. A "name already exists"
SnackBar surfaces the conflict instead of silently
overwriting.
Bilingual strings shipped (en.arb + de.arb) for every
operator-facing string: toolbar buttons, dialogs, empty
states, file-exists error, run-output header.
New deps:
* flutter_code_editor ^0.3.5
* highlight (transitive — pinned as direct so the
yaml-language import has its declared dependency).
Smoke-test (test/flow_editor_test.dart) pumps the page and
asserts the empty-state + toolbar render without throwing
on hosts that don't have ~/.fai/data/flows yet. The full
file-list + open-on-tap flow needs a writable HOME override
which dart:io's read-only Platform.environment doesn't allow
inside a test isolate — that path lives in the integration
suite as a follow-up.
Version bumps:
* pubspec.yaml: 0.49.1 → 0.50.0
* main.dart kStudioVersion: 0.42.0 → 0.50.0 (had drifted
behind pubspec; brought back into sync as part of this
bump)
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
739 lines
22 KiB
Dart
739 lines
22 KiB
Dart
// Flow Editor — inline IDE for hub-managed flow YAML.
|
|
//
|
|
// Two-pane layout:
|
|
// left pane — list of saved flows under ~/.fai/data/flows/
|
|
// right pane — code editor with YAML syntax highlighting
|
|
//
|
|
// Top bar exposes Save, Run, New Flow, Refresh. Bottom bar
|
|
// shows cursor position + a dirty mark.
|
|
//
|
|
// File I/O note: the v1 implementation reads + writes flow
|
|
// YAML directly via `dart:io` because Studio and the hub share
|
|
// the local filesystem in the supported deployment. A future
|
|
// GetFlowText / SetFlowText pair of RPCs will let Studio talk
|
|
// to a remote hub the same way (planned, not blocking).
|
|
|
|
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
|
import 'package:highlight/languages/yaml.dart';
|
|
|
|
import '../data/friendly_error.dart';
|
|
import '../data/hub.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../theme/theme.dart';
|
|
import '../theme/tokens.dart';
|
|
import '../widgets/widgets.dart';
|
|
|
|
/// Compute the default flow directory under the operator's
|
|
/// home. Studio runs on the same machine as the hub in every
|
|
/// supported deployment, so a direct FS path is the simplest
|
|
/// and matches what the hub itself writes to.
|
|
String _defaultFlowsDir() {
|
|
final home = Platform.environment['HOME'] ??
|
|
Platform.environment['USERPROFILE'] ??
|
|
'.';
|
|
return '$home/.fai/data/flows';
|
|
}
|
|
|
|
/// Top-level flow editor page. Keeps the file-tree loader and
|
|
/// the editor state alongside each other because Save needs
|
|
/// both to know what name to write under.
|
|
class FlowEditorPage extends StatefulWidget {
|
|
const FlowEditorPage({super.key});
|
|
|
|
@override
|
|
State<FlowEditorPage> createState() => _FlowEditorPageState();
|
|
}
|
|
|
|
class _FlowEditorPageState extends State<FlowEditorPage> {
|
|
late final CodeController _code;
|
|
String? _activeName;
|
|
String _loadedText = '';
|
|
late Future<List<_FlowFile>> _files;
|
|
Object? _runError;
|
|
Map<String, FlowOutput>? _runOutputs;
|
|
bool _running = false;
|
|
bool _saving = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_code = CodeController(text: '', language: yaml);
|
|
_code.addListener(_onCodeChange);
|
|
_files = _listFiles();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_code.removeListener(_onCodeChange);
|
|
_code.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onCodeChange() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
bool get _dirty => _activeName != null && _code.text != _loadedText;
|
|
|
|
Future<List<_FlowFile>> _listFiles() async {
|
|
final dir = Directory(_defaultFlowsDir());
|
|
if (!dir.existsSync()) return <_FlowFile>[];
|
|
final entries = await dir
|
|
.list()
|
|
.where((e) => e is File && e.path.endsWith('.yaml'))
|
|
.cast<File>()
|
|
.toList();
|
|
final files = entries
|
|
.map(
|
|
(f) => _FlowFile(
|
|
name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''),
|
|
path: f.path,
|
|
sizeBytes: f.statSync().size,
|
|
),
|
|
)
|
|
.toList()
|
|
..sort((a, b) => a.name.compareTo(b.name));
|
|
return files;
|
|
}
|
|
|
|
Future<void> _openFile(_FlowFile f) async {
|
|
if (_dirty) {
|
|
final keep = await _confirmDiscard();
|
|
if (keep == false || !mounted) return;
|
|
}
|
|
final text = await File(f.path).readAsString();
|
|
setState(() {
|
|
_activeName = f.name;
|
|
_loadedText = text;
|
|
_code.text = text;
|
|
_runOutputs = null;
|
|
_runError = null;
|
|
});
|
|
}
|
|
|
|
Future<bool?> _confirmDiscard() async {
|
|
final l = AppLocalizations.of(context)!;
|
|
return showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l.flowEditorDiscardTitle),
|
|
content: Text(l.flowEditorDiscardBody),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(l.flowEditorDiscardKeep),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(l.flowEditorDiscardThrow),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
final name = _activeName;
|
|
if (name == null) return;
|
|
setState(() => _saving = true);
|
|
try {
|
|
// Write directly via dart:io. The hub reads the saved-
|
|
// flows directory on every list / run, so the next
|
|
// runSavedFlow picks up our changes without an RPC. A
|
|
// follow-up patch can route through the hub's SaveFlow
|
|
// RPC once the SDK wraps it as a typed call; the file
|
|
// shape is identical either way.
|
|
final f = File('${_defaultFlowsDir()}/$name.yaml');
|
|
await f.writeAsString(_code.text, flush: true);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_loadedText = _code.text;
|
|
_files = _listFiles();
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
final fe = friendlyError(e, l);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(fe.headline)),
|
|
);
|
|
} finally {
|
|
if (mounted) setState(() => _saving = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _newFlow() async {
|
|
final l = AppLocalizations.of(context)!;
|
|
final name = await showDialog<String>(
|
|
context: context,
|
|
builder: (ctx) => _NewFlowDialog(),
|
|
);
|
|
if (name == null || name.isEmpty || !mounted) return;
|
|
final template = '''# ${l.flowEditorNewTemplateComment(name)}
|
|
|
|
inputs:
|
|
name:
|
|
type: text
|
|
|
|
steps:
|
|
- id: greet
|
|
use: debug.echo@^0
|
|
with:
|
|
text: "Hello, \${{ inputs.name }}!"
|
|
|
|
outputs:
|
|
greeting: \${{ steps.greet.result }}
|
|
''';
|
|
try {
|
|
// Create file on disk + ensure the directory exists.
|
|
final dir = Directory(_defaultFlowsDir());
|
|
if (!dir.existsSync()) await dir.create(recursive: true);
|
|
final f = File('${dir.path}/$name.yaml');
|
|
if (f.existsSync()) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.flowEditorAlreadyExists(name))),
|
|
);
|
|
return;
|
|
}
|
|
await f.writeAsString(template, flush: true);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_activeName = name;
|
|
_loadedText = template;
|
|
_code.text = template;
|
|
_files = _listFiles();
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final fe = friendlyError(e, l);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(fe.headline)),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _run() async {
|
|
final name = _activeName;
|
|
if (name == null) return;
|
|
// Save first so the hub runs the latest version.
|
|
if (_dirty) await _save();
|
|
setState(() {
|
|
_running = true;
|
|
_runOutputs = null;
|
|
_runError = null;
|
|
});
|
|
try {
|
|
// For v1 we run with empty inputs and let the operator
|
|
// discover what's needed via the resulting error if any
|
|
// input is required. A later iteration could read the
|
|
// flow's input definitions and prompt for each.
|
|
final out = await HubService.instance.runSavedFlow(name: name);
|
|
if (!mounted) return;
|
|
setState(() => _runOutputs = out);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _runError = e);
|
|
} finally {
|
|
if (mounted) setState(() => _running = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _refresh() async {
|
|
setState(() => _files = _listFiles());
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
final theme = Theme.of(context);
|
|
|
|
return Scaffold(
|
|
body: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_Toolbar(
|
|
activeName: _activeName,
|
|
dirty: _dirty,
|
|
saving: _saving,
|
|
running: _running,
|
|
onSave: _activeName != null ? _save : null,
|
|
onRun: _activeName != null && !_running ? _run : null,
|
|
onNew: _newFlow,
|
|
onRefresh: _refresh,
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
SizedBox(
|
|
width: 240,
|
|
child: _FileList(
|
|
filesFuture: _files,
|
|
activeName: _activeName,
|
|
onOpen: _openFile,
|
|
onRefresh: _refresh,
|
|
),
|
|
),
|
|
const VerticalDivider(width: 1),
|
|
Expanded(
|
|
child: _activeName == null
|
|
? _EmptyState()
|
|
: _EditorPane(
|
|
controller: _code,
|
|
theme: theme,
|
|
outputs: _runOutputs,
|
|
runError: _runError,
|
|
running: _running,
|
|
l: l,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FlowFile {
|
|
final String name;
|
|
final String path;
|
|
final int sizeBytes;
|
|
const _FlowFile({
|
|
required this.name,
|
|
required this.path,
|
|
required this.sizeBytes,
|
|
});
|
|
}
|
|
|
|
class _Toolbar extends StatelessWidget {
|
|
final String? activeName;
|
|
final bool dirty;
|
|
final bool saving;
|
|
final bool running;
|
|
final VoidCallback? onSave;
|
|
final VoidCallback? onRun;
|
|
final VoidCallback onNew;
|
|
final VoidCallback onRefresh;
|
|
const _Toolbar({
|
|
required this.activeName,
|
|
required this.dirty,
|
|
required this.saving,
|
|
required this.running,
|
|
required this.onSave,
|
|
required this.onRun,
|
|
required this.onNew,
|
|
required this.onRefresh,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
final theme = Theme.of(context);
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: FaiSpace.md,
|
|
vertical: FaiSpace.sm,
|
|
),
|
|
color: theme.colorScheme.surfaceContainer,
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
activeName == null ? l.flowEditorEmptyTitle : '${activeName!}.yaml',
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontFamily: 'monospace',
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
if (dirty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: FaiSpace.xs),
|
|
child: Icon(
|
|
Icons.circle,
|
|
size: 8,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
FilledButton.tonalIcon(
|
|
onPressed: onNew,
|
|
icon: const Icon(Icons.add, size: 16),
|
|
label: Text(l.flowEditorNew),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
IconButton.outlined(
|
|
onPressed: onRefresh,
|
|
tooltip: l.flowEditorRefresh,
|
|
icon: const Icon(Icons.refresh, size: 18),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
FilledButton.tonalIcon(
|
|
onPressed: saving ? null : onSave,
|
|
icon: saving
|
|
? const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.save_outlined, size: 16),
|
|
label: Text(l.flowEditorSave),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
FilledButton.icon(
|
|
onPressed: onRun,
|
|
icon: running
|
|
? const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.play_arrow, size: 18),
|
|
label: Text(l.flowEditorRun),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FileList extends StatelessWidget {
|
|
final Future<List<_FlowFile>> filesFuture;
|
|
final String? activeName;
|
|
final void Function(_FlowFile) onOpen;
|
|
final VoidCallback onRefresh;
|
|
|
|
const _FileList({
|
|
required this.filesFuture,
|
|
required this.activeName,
|
|
required this.onOpen,
|
|
required this.onRefresh,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Container(
|
|
color: theme.colorScheme.surface,
|
|
child: FutureBuilder<List<_FlowFile>>(
|
|
future: filesFuture,
|
|
builder: (context, snap) {
|
|
if (snap.connectionState == ConnectionState.waiting) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
if (snap.hasError) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(FaiSpace.md),
|
|
child: FaiErrorBox(error: snap.error, isError: true),
|
|
);
|
|
}
|
|
final files = snap.data ?? <_FlowFile>[];
|
|
if (files.isEmpty) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(FaiSpace.md),
|
|
child: FaiEmptyState(
|
|
icon: Icons.folder_outlined,
|
|
title: l.flowEditorListEmptyTitle,
|
|
hint: l.flowEditorListEmptyBody,
|
|
),
|
|
);
|
|
}
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
|
itemCount: files.length,
|
|
itemBuilder: (_, i) {
|
|
final f = files[i];
|
|
final isActive = f.name == activeName;
|
|
return InkWell(
|
|
onTap: () => onOpen(f),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: FaiSpace.md,
|
|
vertical: FaiSpace.sm,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isActive
|
|
? theme.colorScheme.secondaryContainer
|
|
: null,
|
|
border: Border(
|
|
left: BorderSide(
|
|
width: 3,
|
|
color: isActive
|
|
? theme.colorScheme.primary
|
|
: Colors.transparent,
|
|
),
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
f.name,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
Text(
|
|
'${f.sizeBytes} B',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EditorPane extends StatelessWidget {
|
|
final CodeController controller;
|
|
final ThemeData theme;
|
|
final Map<String, FlowOutput>? outputs;
|
|
final Object? runError;
|
|
final bool running;
|
|
final AppLocalizations l;
|
|
const _EditorPane({
|
|
required this.controller,
|
|
required this.theme,
|
|
required this.outputs,
|
|
required this.runError,
|
|
required this.running,
|
|
required this.l,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final mono = TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
fontSize: 13,
|
|
height: 1.45,
|
|
color: theme.colorScheme.onSurface,
|
|
);
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 3,
|
|
child: CodeTheme(
|
|
data: CodeThemeData(styles: _yamlStyle(theme)),
|
|
child: SingleChildScrollView(
|
|
child: CodeField(
|
|
controller: controller,
|
|
textStyle: mono,
|
|
expands: false,
|
|
gutterStyle: GutterStyle(
|
|
textStyle: mono.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
background: theme.colorScheme.surfaceContainer,
|
|
showLineNumbers: true,
|
|
),
|
|
background: theme.colorScheme.surface,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (outputs != null || runError != null) ...[
|
|
const VerticalDivider(width: 1),
|
|
SizedBox(
|
|
width: 320,
|
|
child: _RunResult(
|
|
outputs: outputs,
|
|
error: runError,
|
|
running: running,
|
|
l: l,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RunResult extends StatelessWidget {
|
|
final Map<String, FlowOutput>? outputs;
|
|
final Object? error;
|
|
final bool running;
|
|
final AppLocalizations l;
|
|
const _RunResult({
|
|
required this.outputs,
|
|
required this.error,
|
|
required this.running,
|
|
required this.l,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Container(
|
|
color: theme.colorScheme.surface,
|
|
padding: const EdgeInsets.all(FaiSpace.md),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(
|
|
l.flowEditorRunOutput,
|
|
style: theme.textTheme.labelMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
if (running) const LinearProgressIndicator(),
|
|
if (error != null)
|
|
FaiErrorBox(error: error, isError: true)
|
|
else if (outputs != null)
|
|
Expanded(
|
|
child: ListView(
|
|
children: [
|
|
for (final entry in outputs!.entries)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: FaiSpace.md),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
entry.key,
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontFamily: 'monospace',
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.xs),
|
|
SelectableText(
|
|
entry.value.toString(),
|
|
style: FaiTheme.mono(
|
|
size: 12,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyState extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(FaiSpace.xxl),
|
|
child: FaiEmptyState(
|
|
icon: Icons.edit_note,
|
|
title: l.flowEditorEmptyTitle,
|
|
hint: l.flowEditorEmptyBody,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NewFlowDialog extends StatefulWidget {
|
|
@override
|
|
State<_NewFlowDialog> createState() => _NewFlowDialogState();
|
|
}
|
|
|
|
class _NewFlowDialogState extends State<_NewFlowDialog> {
|
|
final _ctrl = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_ctrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
return AlertDialog(
|
|
title: Text(l.flowEditorNewDialogTitle),
|
|
content: TextField(
|
|
controller: _ctrl,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
labelText: l.flowEditorNewDialogLabel,
|
|
hintText: 'my-flow',
|
|
helperText: l.flowEditorNewDialogHelper,
|
|
),
|
|
inputFormatters: [
|
|
// Filenames are restricted to lowercase, digits,
|
|
// hyphen, underscore. The hub gets cranky about
|
|
// path-injection characters and Windows file system
|
|
// doesn't like colons / pipes either.
|
|
FilteringTextInputFormatter.allow(RegExp(r'[a-z0-9_\-]')),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text(l.flowEditorNewDialogCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
if (_ctrl.text.isNotEmpty) {
|
|
Navigator.pop(context, _ctrl.text);
|
|
}
|
|
},
|
|
child: Text(l.flowEditorNewDialogCreate),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// Lightweight YAML colour map. The full highlight package
|
|
// ships dozens of token classes; we map only the ones that
|
|
// actually appear in flow YAML to avoid styling-system noise
|
|
// against the warm-minimalism palette.
|
|
Map<String, TextStyle> _yamlStyle(ThemeData theme) {
|
|
final base = TextStyle(
|
|
fontFamily: 'JetBrains Mono',
|
|
color: theme.colorScheme.onSurface,
|
|
);
|
|
return {
|
|
'root': base,
|
|
// Keys before the colon — primary accent for structure.
|
|
'attr': base.copyWith(
|
|
color: theme.colorScheme.primary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
// Strings (with or without quotes).
|
|
'string': base.copyWith(color: theme.colorScheme.tertiary),
|
|
// Numbers and booleans.
|
|
'number': base.copyWith(color: theme.colorScheme.secondary),
|
|
'literal': base.copyWith(color: theme.colorScheme.secondary),
|
|
// Comments — dim, italic.
|
|
'comment': base.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
// Pipe/dash list/key indicators.
|
|
'meta': base.copyWith(color: theme.colorScheme.primary),
|
|
// Template syntax ${{ ... }} — high-contrast, mono.
|
|
'subst': base.copyWith(
|
|
color: theme.colorScheme.error,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
};
|
|
}
|