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..97a58d27 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,116 @@ 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(SnippetFuncs.specialCtrl, key); + if (special != null) { + final raw = key.substring(special.key.length + 1, key.length - 1); + await special.value((term: terminal, raw: 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); + if (chars.isEmpty) return; + final ok = terminal.charInput( + chars.codeUnitAt(0), + ctrl: ctrlAlt.ctrl, + alt: ctrlAlt.alt, + ); + if (!ok) { + Loggers.app.warning('Failed to input: $key'); + } + + terminal.textInput(chars.substring(1)); + } + + MapEntry? _find(Map map, String key) { + return map.entries.firstWhereOrNull((e) => key.startsWith(e.key)); } static final fmtArgs = { @@ -76,6 +177,12 @@ 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, + }; } class SnippetResult { @@ -89,3 +196,32 @@ class SnippetResult { required this.time, }); } + +typedef SnippetFuncCtx = ({Terminal term, String raw}); + +abstract final class SnippetFuncs { + static final specialCtrl = { + // `${sleep 3}` -> sleep 3 seconds + r'${sleep': SnippetFuncs.sleep, + r'${enter': SnippetFuncs.enter, + }; + + static const help = { + 'sleep': 'Sleep for a few seconds', + 'enter': 'Enter a few times', + }; + + static FutureOr sleep(SnippetFuncCtx ctx) async { + final seconds = int.tryParse(ctx.raw); + if (seconds == null) return; + final duration = Duration(seconds: seconds); + await Future.delayed(duration); + } + + static FutureOr enter(SnippetFuncCtx ctx) async { + final times = int.tryParse(ctx.raw) ?? 1; + for (var i = 0; i < times; i++) { + ctx.term.keyInput(TerminalKey.enter); + } + } +} 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/l10n/app_de.arb b/lib/l10n/app_de.arb index 56d2f78c..23651a96 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -112,6 +112,7 @@ "followSystem": "System verfolgen", "font": "Schriftarten", "fontSize": "Schriftgröße", + "forExample": "Zum Beispiel", "force": "freiwillig", "foundNUpdate": "Update {count} gefunden", "fullScreen": "Vollbildmodus", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 58e20538..f0c3de10 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -112,6 +112,7 @@ "followSystem": "Follow system", "font": "Font", "fontSize": "Font size", + "forExample": "For example", "force": "Force", "foundNUpdate": "Found {count} update", "fullScreen": "Full screen mode", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 2c281b08..9de9687e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -112,6 +112,7 @@ "followSystem": "Seguir al sistema", "font": "Fuente", "fontSize": "Tamaño de fuente", + "forExample": "Por ejemplo", "force": "Forzar", "foundNUpdate": "Encontradas {count} actualizaciones", "fullScreen": "Modo pantalla completa", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 9d9a658a..6c6cfb2b 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -112,6 +112,7 @@ "followSystem": "Suivre le système", "font": "Police", "fontSize": "Taille de la police", + "forExample": "Par exemple", "force": "Forcer", "foundNUpdate": "{count} mise à jour trouvée", "fullScreen": "Mode plein écran", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 9ef3ffa9..e156fe62 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -112,6 +112,7 @@ "followSystem": "Ikuti sistem", "font": "Font", "fontSize": "Ukuran huruf", + "forExample": "Sebagai contoh", "force": "sukarela", "foundNUpdate": "Menemukan {count} pembaruan", "fullScreen": "Mode Layar Penuh", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index af713a6f..567c3553 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -112,6 +112,7 @@ "followSystem": "システムに従う", "font": "フォント", "fontSize": "フォントサイズ", + "forExample": "例えば", "force": "強制", "foundNUpdate": "{count}個の更新が見つかりました", "fullScreen": "フルスクリーンモード", diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index b6657b14..c934c245 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -112,6 +112,7 @@ "followSystem": "Volg systeem", "font": "Lettertype", "fontSize": "Lettergrootte", + "forExample": "Bijvoorbeeld", "force": "Forceer", "foundNUpdate": "{count} update gevonden", "fullScreen": "Volledig schermmodus", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 965951fc..b6b90324 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -112,6 +112,7 @@ "followSystem": "Seguir sistema", "font": "Fonte", "fontSize": "Tamanho da fonte", + "forExample": "Por exemplo", "force": "Forçar", "foundNUpdate": "Encontradas {count} atualizações", "fullScreen": "Modo tela cheia", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 7691f4d1..5d2c8e20 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -112,6 +112,7 @@ "followSystem": "следовать за системой", "font": "шрифт", "fontSize": "размер шрифта", + "forExample": "Например", "force": "принудительно", "foundNUpdate": "Найдено {count} обновлений", "fullScreen": "полноэкранный режим", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index faa68b09..89bcaab9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -112,6 +112,7 @@ "followSystem": "跟随系统", "font": "字体", "fontSize": "字体大小", + "forExample": "例如", "force": "强制", "foundNUpdate": "找到 {count} 个更新", "fullScreen": "全屏模式", diff --git a/lib/l10n/app_zh_tw.arb b/lib/l10n/app_zh_tw.arb index 455fc4e1..4024e9b5 100644 --- a/lib/l10n/app_zh_tw.arb +++ b/lib/l10n/app_zh_tw.arb @@ -112,6 +112,7 @@ "followSystem": "跟隨系統", "font": "字體", "fontSize": "字體大小", + "forExample": "例如", "force": "強制", "foundNUpdate": "找到 {count} 個更新", "fullScreen": "全屏模式", diff --git a/lib/view/page/container.dart b/lib/view/page/container.dart index 3bda2a3d..369e6202 100644 --- a/lib/view/page/container.dart +++ b/lib/view/page/container.dart @@ -562,7 +562,10 @@ class _ContainerPageState extends State { case ContainerMenu.terminal: AppRoutes.ssh( spi: widget.spi, - initCmd: 'docker exec -it ${dItem.id} sh', + initCmd: '${switch (_container.type) { + ContainerType.podman => 'podman', + ContainerType.docker => 'docker', + }} exec -it ${dItem.id} sh', ).go(context); break; // case DockerMenuType.stats: 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/snippet/edit.dart b/lib/view/page/snippet/edit.dart index 170f4146..81157e9e 100644 --- a/lib/view/page/snippet/edit.dart +++ b/lib/view/page/snippet/edit.dart @@ -200,9 +200,13 @@ class _SnippetEditPageState extends State padding: const EdgeInsets.all(13), child: SimpleMarkdown( data: ''' -📌 ${l10n.supportFmtArgs} +📌 ${l10n.supportFmtArgs}\n +${Snippet.fmtArgs.keys.map((e) => '`$e`').join(', ')}\n -${Snippet.fmtArgs.keys.map((e) => '`$e`').join(', ')} +${Snippet.fmtTermKeys.keys.map((e) => '`$e+?}`').join(', ')}\n +${l10n.forExample}: +- `\${ctrl+c}` (Control + C) +- `\${ctrl+b}d` (Tmux Detach) ''', styleSheet: MarkdownStyleSheet( codeblockDecoration: const BoxDecoration( 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), );