diff --git a/lib/src/flow_editor_page.dart b/lib/src/flow_editor_page.dart index d89645a..5c37757 100644 --- a/lib/src/flow_editor_page.dart +++ b/lib/src/flow_editor_page.dart @@ -14,7 +14,6 @@ library; import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_code_editor/flutter_code_editor.dart'; @@ -26,7 +25,8 @@ import 'widgets.dart'; /// Compute the flows directory under the operator's home. String _defaultFlowsDir() { - final home = Platform.environment['HOME'] ?? + final home = + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'; return '$home/.fai/data/flows'; @@ -102,16 +102,20 @@ class _FlowEditorPageState extends State { .where((e) => e is File && e.path.endsWith('.yaml')) .cast() .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)); + 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; } @@ -188,9 +192,9 @@ class _FlowEditorPageState extends State { }); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString())), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.toString()))); } finally { if (mounted) setState(() => _saving = false); } @@ -223,9 +227,9 @@ outputs: final f = File('${dir.path}/$name.yaml'); if (f.existsSync()) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(_l.alreadyExists(name))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(_l.alreadyExists(name)))); return; } await f.writeAsString(template, flush: true); @@ -238,9 +242,9 @@ outputs: }); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(e.toString())), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(e.toString()))); } } @@ -328,7 +332,6 @@ outputs: ? _run : null, onNew: _newFlow, - onRefresh: _refresh, ), const Divider(height: 1), Expanded( @@ -396,7 +399,6 @@ class _Toolbar extends StatelessWidget { final VoidCallback? onSave; final VoidCallback? onRun; final VoidCallback onNew; - final VoidCallback onRefresh; const _Toolbar({ required this.strings, required this.activeName, @@ -407,7 +409,6 @@ class _Toolbar extends StatelessWidget { required this.onSave, required this.onRun, required this.onNew, - required this.onRefresh, }); @override @@ -452,12 +453,6 @@ class _Toolbar extends StatelessWidget { label: Text(strings.newFlow), ), const SizedBox(width: FaiSpace.sm), - IconButton.outlined( - onPressed: onRefresh, - tooltip: strings.refresh, - icon: const Icon(Icons.refresh, size: 18), - ), - const SizedBox(width: FaiSpace.sm), FilledButton.tonalIcon( onPressed: saving ? null : onSave, icon: saving @@ -507,81 +502,128 @@ class _FileList extends StatelessWidget { final theme = Theme.of(context); return Container( color: theme.colorScheme.surface, - child: FutureBuilder>( - 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: strings.listEmptyTitle, - hint: strings.listEmptyBody, - ), - ); - } - 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.stretch, + children: [ + // List header — refresh icon lives here (not in the + // main toolbar) because Refresh re-lists the files, + // it doesn't touch the open editor's content. Keeping + // it in the file-list panel keeps the editor toolbar + // focused on the four primary actions (back / new / + // save / run) without a stylistically-outlier outline + // icon sandwiched between filled buttons. + Container( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.md, + vertical: FaiSpace.xs, + ), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: theme.dividerColor)), + ), + child: Row( + children: [ + Expanded( + child: Text( + strings.listHeader, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, ), ), - 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, - ), - ), - ], + ), + IconButton( + onPressed: onRefresh, + tooltip: strings.refresh, + icon: const Icon(Icons.refresh, size: 16), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 28, + minHeight: 28, ), ), - ); - }, - ); - }, + ], + ), + ), + Expanded(child: _buildBody(context, theme)), + ], ), ); } + + Widget _buildBody(BuildContext context, ThemeData theme) { + return FutureBuilder>( + 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: strings.listEmptyTitle, + hint: strings.listEmptyBody, + ), + ); + } + 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 { @@ -609,33 +651,43 @@ class _EditorPane extends StatelessWidget { color: theme.colorScheme.onSurface, ); return Row( - // Stretch so the editor's scroll viewport fills the row's - // full vertical extent. Without this the Row defaults to - // CrossAxisAlignment.center, which centers a short file - // vertically — uncomfortable in an IDE where readers - // expect content to always start at the top. Default - // SingleChildScrollView origin is top, so stretching the - // viewport is enough to anchor content at the top edge. + // Stretch + CodeField(expands: true) is what actually + // pins the editor to the top edge. The previous attempt + // (just CrossAxisAlignment.stretch with a + // SingleChildScrollView) only stretched the SCROLL + // viewport — the CodeField inside still had its natural, + // content-sized height and sat vertically centered within + // the viewport because CodeField defers to its parent's + // alignment when smaller than the viewport. Removing the + // SCV wrapper and setting expands:true on the CodeField + // (with explicit minLines:null + maxLines:null, which is + // what the underlying TextField requires) makes the + // CodeField fill its parent's bounded height. The line- + // number gutter then runs the full height of the editor + // pane, line 1 sits at the top edge, and vertical + // scrolling is handled by the CodeField itself for long + // files. Standard behaviour for any IDE-grade editor + // widget. crossAxisAlignment: CrossAxisAlignment.stretch, 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, + child: CodeField( + controller: controller, + textStyle: mono, + expands: true, + minLines: null, + maxLines: null, + gutterStyle: GutterStyle( + textStyle: mono.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), - background: theme.colorScheme.surface, + background: theme.colorScheme.surfaceContainer, + showLineNumbers: true, ), + background: theme.colorScheme.surface, ), ), ), diff --git a/lib/src/l10n.dart b/lib/src/l10n.dart index 45d1387..a8ee8f9 100644 --- a/lib/src/l10n.dart +++ b/lib/src/l10n.dart @@ -17,6 +17,7 @@ class FlowEditorStrings { String get save => _t('Save', 'Speichern'); String get run => _t('Run', 'Ausführen'); String get refresh => _t('Refresh file list', 'Datei-Liste neu laden'); + String get listHeader => _t('FLOWS', 'FLOWS'); String get copy => _t('Copy', 'Kopieren'); String get runOutput => _t('RUN OUTPUT', 'LAUF-ERGEBNIS'); String get emptyTitle => _t('No flow open', 'Kein Flow geöffnet'); diff --git a/pubspec.yaml b/pubspec.yaml index 3532f53..fc42da8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fai_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.1.3 +version: 0.1.4 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor