From 490d71f8c97a24ecdeee355911bd9973fced76f4 Mon Sep 17 00:00:00 2001 From: lollipopkit <10864310+lollipopkit@users.noreply.github.com> Date: Mon, 10 Jun 2024 21:08:33 +0800 Subject: [PATCH] feat: snippet with term ctrl (#380 #382) --- lib/core/route.dart | 2 + lib/data/model/server/snippet.dart | 136 ++++++++++++++++++++++++-- lib/data/provider/server.dart | 29 +++--- lib/view/page/server/tab.dart | 3 +- lib/view/page/ssh/page.dart | 22 +++-- lib/view/page/ssh/tab.dart | 13 ++- lib/view/widget/server_func_btns.dart | 4 +- 7 files changed, 171 insertions(+), 38 deletions(-) diff --git a/lib/core/route.dart b/lib/core/route.dart index a45d1bd8..ece03849 100644 --- a/lib/core/route.dart +++ b/lib/core/route.dart @@ -102,12 +102,14 @@ class AppRoutes { Key? key, required ServerPrivateInfo spi, String? initCmd, + Snippet? initSnippet, }) { return AppRoutes( SSHPage( key: key, spi: spi, initCmd: initCmd, + initSnippet: initSnippet, ), 'ssh_term', ); diff --git a/lib/data/model/server/snippet.dart b/lib/data/model/server/snippet.dart index cd7dbae2..c634be87 100644 --- a/lib/data/model/server/snippet.dart +++ b/lib/data/model/server/snippet.dart @@ -1,5 +1,9 @@ +import 'dart:async'; + +import 'package:fl_lib/fl_lib.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:toolbox/data/model/server/server_private_info.dart'; +import 'package:xterm/core.dart'; import '../app/tag_pickable.dart'; @@ -53,19 +57,114 @@ class Snippet implements TagPickable { @override String get tagName => name; - String fmtWith(ServerPrivateInfo spi) { - final fmted = script.replaceAllMapped( - RegExp(r'\${.+?}'), + static final fmtFinder = RegExp(r'\$\{[^{}]+\}'); + + String fmtWithSpi(ServerPrivateInfo spi) { + return script.replaceAllMapped( + fmtFinder, (match) { final key = match.group(0); final func = fmtArgs[key]; - if (func == null) { - return key!; - } - return func(spi); + if (func != null) return func(spi); + // If not found, return the original content for further processing + return key ?? ''; }, ); - return fmted; + } + + Future runInTerm( + Terminal terminal, + ServerPrivateInfo spi, { + bool autoEnter = false, + }) async { + final argsFmted = fmtWithSpi(spi); + final matches = fmtFinder.allMatches(argsFmted); + + /// There is no [TerminalKey] in the script + if (matches.isEmpty) { + terminal.textInput(argsFmted); + if (autoEnter) terminal.keyInput(TerminalKey.enter); + return; + } + + // Records all start and end indexes of the matches + final (starts, ends) = matches.fold(([], []), (pre, e) { + pre.$1.add(e.start); + pre.$2.add(e.end); + return pre; + }); + + // Check all indexes, `(idx + 1).start` must >= `idx.end` + for (var i = 0; i < starts.length - 1; i++) { + final lastEnd = ends[i]; + final nextStart = starts[i + 1]; + if (nextStart < lastEnd) { + throw 'Invalid format: $nextStart < $lastEnd'; + } + } + + // Start term input + if (starts.first > 0) { + terminal.textInput(argsFmted.substring(0, starts.first)); + } + + // Process matched + for (var idx = 0; idx < starts.length; idx++) { + final start = starts[idx]; + final end = ends[idx]; + final key = argsFmted.substring(start, end).toLowerCase(); + + // Special funcs + final special = _find(specialCtrl, key); + if (special != null) { + final raw = key.substring(special.key.length + 1, key.length - 1); + await special.value(raw); + } + + // Term keys + final termKey = _find(fmtTermKeys, key); + if (termKey != null) await _doTermKeys(terminal, termKey, key); + } + + // End term input + if (ends.last < argsFmted.length) { + terminal.textInput(argsFmted.substring(ends.last)); + } + + if (autoEnter) terminal.keyInput(TerminalKey.enter); + } + + Future _doTermKeys( + Terminal terminal, + MapEntry termKey, + String key, + ) async { + if (termKey.value == TerminalKey.enter) { + terminal.keyInput(TerminalKey.enter); + return; + } + + final ctrlAlt = switch (termKey.value) { + TerminalKey.control => (ctrl: true, alt: false), + TerminalKey.alt => (ctrl: false, alt: true), + _ => (ctrl: false, alt: false), + }; + + // `${ctrl+ad}` -> `ctrla + d` + final chars = key.substring(termKey.key.length + 1, key.length - 1); + terminal.charInput( + chars.codeUnitAt(0), + ctrl: ctrlAlt.ctrl, + alt: ctrlAlt.alt, + ); + + for (final char in chars.codeUnits.skip(1)) { + terminal.charInput(char, ctrl: false, alt: false); + } + } + + MapEntry? _find(Map map, String key) { + return map.entries.firstWhereOrNull((e) => key.startsWith(e.key)); } static final fmtArgs = { @@ -76,6 +175,18 @@ class Snippet implements TagPickable { r'${id}': (ServerPrivateInfo spi) => spi.id, r'${name}': (ServerPrivateInfo spi) => spi.name, }; + + /// r'${ctrl+ad}' -> TerminalKey.control, a, d + static final fmtTermKeys = { + r'${ctrl': TerminalKey.control, + r'${alt': TerminalKey.alt, + r'${enter': TerminalKey.enter, + }; + + static final specialCtrl = { + // `${sleep 3}` -> sleep 3 seconds + r'${sleep': SnippetFuncs.sleep, + }; } class SnippetResult { @@ -89,3 +200,12 @@ class SnippetResult { required this.time, }); } + +abstract final class SnippetFuncs { + static FutureOr sleep(String raw) async { + final seconds = int.tryParse(raw); + if (seconds == null) return; + final duration = Duration(seconds: seconds); + await Future.delayed(duration); + } +} diff --git a/lib/data/provider/server.dart b/lib/data/provider/server.dart index 28c374ba..22aa984f 100644 --- a/lib/data/provider/server.dart +++ b/lib/data/provider/server.dart @@ -18,7 +18,6 @@ import '../../core/utils/server.dart'; import '../model/server/server.dart'; import '../model/server/server_private_info.dart'; import '../model/server/server_status_update_req.dart'; -import '../model/server/snippet.dart'; import '../model/server/try_limiter.dart'; import '../res/status.dart'; @@ -460,20 +459,20 @@ class ServerProvider extends ChangeNotifier { TryLimiter.reset(sid); } - Future runSnippet(String id, Snippet snippet) async { - final server = _servers[id]; - if (server == null) return null; - final watch = Stopwatch()..start(); - final result = await server.client?.run(snippet.fmtWith(server.spi)).string; - final time = watch.elapsed; - watch.stop(); - if (result == null) return null; - return SnippetResult( - dest: _servers[id]?.spi.name, - result: result, - time: time, - ); - } + // Future runSnippet(String id, Snippet snippet) async { + // final server = _servers[id]; + // if (server == null) return null; + // final watch = Stopwatch()..start(); + // final result = await server.client?.run(snippet.fmtWithArgs(server.spi)).string; + // final time = watch.elapsed; + // watch.stop(); + // if (result == null) return null; + // return SnippetResult( + // dest: _servers[id]?.spi.name, + // result: result, + // time: time, + // ); + // } // Future> runSnippetsMulti( // List ids, diff --git a/lib/view/page/server/tab.dart b/lib/view/page/server/tab.dart index 12f21e9f..cf8bafee 100644 --- a/lib/view/page/server/tab.dart +++ b/lib/view/page/server/tab.dart @@ -98,7 +98,8 @@ class _ServerPageState extends State ), floatingActionButton: AutoHide( key: _autoHideKey, - direction: AxisDirection.right, + direction: AxisDirection.down, + offset: 75, controller: _scrollController, child: FloatingActionButton( heroTag: 'addServer', diff --git a/lib/view/page/ssh/page.dart b/lib/view/page/ssh/page.dart index 98e18dfd..a1c1532c 100644 --- a/lib/view/page/ssh/page.dart +++ b/lib/view/page/ssh/page.dart @@ -29,6 +29,7 @@ const _echoPWD = 'echo \$PWD'; class SSHPage extends StatefulWidget { final ServerPrivateInfo spi; final String? initCmd; + final Snippet? initSnippet; final bool notFromTab; final Function()? onSessionEnd; final GlobalKey? terminalKey; @@ -37,6 +38,7 @@ class SSHPage extends StatefulWidget { super.key, required this.spi, this.initCmd, + this.initSnippet, this.notFromTab = true, this.onSessionEnd, this.terminalKey, @@ -309,8 +311,7 @@ class SSHPageState extends State final snippet = snippets.firstOrNull; if (snippet == null) return; - _terminal.textInput(snippet.script); - _terminal.keyInput(TerminalKey.enter); + snippet.runInTerm(_terminal, widget.spi); break; case VirtualKeyFunc.file: // get $PWD from SSH session @@ -415,16 +416,19 @@ class SSHPageState extends State _initService(); + for (final snippet in Pros.snippet.snippets) { + if (snippet.autoRunOn?.contains(widget.spi.id) == true) { + snippet.runInTerm(_terminal, widget.spi); + } + } + if (widget.initCmd != null) { _terminal.textInput(widget.initCmd!); _terminal.keyInput(TerminalKey.enter); - } else { - for (final snippet in Pros.snippet.snippets) { - if (snippet.autoRunOn?.contains(widget.spi.id) == true) { - _terminal.textInput(snippet.script); - _terminal.keyInput(TerminalKey.enter); - } - } + } + + if (widget.initSnippet != null) { + widget.initSnippet!.runInTerm(_terminal, widget.spi); } SSHPage.focusNode.requestFocus(); diff --git a/lib/view/page/ssh/tab.dart b/lib/view/page/ssh/tab.dart index 9ea8b37e..9ea29e04 100644 --- a/lib/view/page/ssh/tab.dart +++ b/lib/view/page/ssh/tab.dart @@ -96,6 +96,7 @@ class _SSHTabPageState extends State Widget _buildAddPage() { return Center( + key: const Key('sshTabAddServer'), child: Consumer(builder: (_, pro, __) { if (pro.serverOrder.isEmpty) { return Center( @@ -105,21 +106,27 @@ class _SSHTabPageState extends State ), ); } - return ListView.builder( + return GridView.builder( padding: const EdgeInsets.all(7), itemBuilder: (_, idx) { final spi = Pros.server.pick(id: pro.serverOrder[idx])?.spi; if (spi == null) return UIs.placeholder; return CardX( child: ListTile( + contentPadding: const EdgeInsets.only(left: 17, right: 7), title: Text(spi.name), - subtitle: Text(spi.id, style: UIs.textGrey), trailing: const Icon(Icons.chevron_right), onTap: () => _onTapInitCard(spi), - ), + ).center(), ); }, itemCount: pro.servers.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 3, + crossAxisSpacing: 3, + mainAxisSpacing: 3, + ), ); }), ); diff --git a/lib/view/widget/server_func_btns.dart b/lib/view/widget/server_func_btns.dart index ae36a948..0d3eb13a 100644 --- a/lib/view/widget/server_func_btns.dart +++ b/lib/view/widget/server_func_btns.dart @@ -126,7 +126,7 @@ void _onTapMoreBtns( if (snippets == null || snippets.isEmpty) return; final snippet = snippets.firstOrNull; if (snippet == null) return; - final fmted = snippet.fmtWith(spi); + final fmted = snippet.fmtWithSpi(spi); final sure = await context.showRoundDialog( title: l10n.attention, child: SingleChildScrollView( @@ -141,7 +141,7 @@ void _onTapMoreBtns( ], ); if (sure != true) return; - AppRoutes.ssh(spi: spi, initCmd: fmted).checkGo( + AppRoutes.ssh(spi: spi, initSnippet: snippet).checkGo( context: context, check: () => _checkClient(context, spi.id), );