feat: snippet with term ctrl (#380 #382)

This commit is contained in:
lollipopkit
2024-06-10 21:08:33 +08:00
parent edceb5900e
commit 490d71f8c9
7 changed files with 171 additions and 38 deletions

View File

@@ -102,12 +102,14 @@ class AppRoutes {
Key? key, Key? key,
required ServerPrivateInfo spi, required ServerPrivateInfo spi,
String? initCmd, String? initCmd,
Snippet? initSnippet,
}) { }) {
return AppRoutes( return AppRoutes(
SSHPage( SSHPage(
key: key, key: key,
spi: spi, spi: spi,
initCmd: initCmd, initCmd: initCmd,
initSnippet: initSnippet,
), ),
'ssh_term', 'ssh_term',
); );

View File

@@ -1,5 +1,9 @@
import 'dart:async';
import 'package:fl_lib/fl_lib.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:toolbox/data/model/server/server_private_info.dart'; import 'package:toolbox/data/model/server/server_private_info.dart';
import 'package:xterm/core.dart';
import '../app/tag_pickable.dart'; import '../app/tag_pickable.dart';
@@ -53,19 +57,114 @@ class Snippet implements TagPickable {
@override @override
String get tagName => name; String get tagName => name;
String fmtWith(ServerPrivateInfo spi) { static final fmtFinder = RegExp(r'\$\{[^{}]+\}');
final fmted = script.replaceAllMapped(
RegExp(r'\${.+?}'), String fmtWithSpi(ServerPrivateInfo spi) {
return script.replaceAllMapped(
fmtFinder,
(match) { (match) {
final key = match.group(0); final key = match.group(0);
final func = fmtArgs[key]; final func = fmtArgs[key];
if (func == null) { if (func != null) return func(spi);
return key!; // If not found, return the original content for further processing
} return key ?? '';
return func(spi);
}, },
); );
return fmted; }
Future<void> 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((<int>[], <int>[]), (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<void> _doTermKeys(
Terminal terminal,
MapEntry<String, TerminalKey> 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<String, T>? _find<T>(Map<String, T> map, String key) {
return map.entries.firstWhereOrNull((e) => key.startsWith(e.key));
} }
static final fmtArgs = { static final fmtArgs = {
@@ -76,6 +175,18 @@ class Snippet implements TagPickable {
r'${id}': (ServerPrivateInfo spi) => spi.id, r'${id}': (ServerPrivateInfo spi) => spi.id,
r'${name}': (ServerPrivateInfo spi) => spi.name, 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 { class SnippetResult {
@@ -89,3 +200,12 @@ class SnippetResult {
required this.time, required this.time,
}); });
} }
abstract final class SnippetFuncs {
static FutureOr<void> sleep(String raw) async {
final seconds = int.tryParse(raw);
if (seconds == null) return;
final duration = Duration(seconds: seconds);
await Future.delayed(duration);
}
}

View File

@@ -18,7 +18,6 @@ import '../../core/utils/server.dart';
import '../model/server/server.dart'; import '../model/server/server.dart';
import '../model/server/server_private_info.dart'; import '../model/server/server_private_info.dart';
import '../model/server/server_status_update_req.dart'; import '../model/server/server_status_update_req.dart';
import '../model/server/snippet.dart';
import '../model/server/try_limiter.dart'; import '../model/server/try_limiter.dart';
import '../res/status.dart'; import '../res/status.dart';
@@ -460,20 +459,20 @@ class ServerProvider extends ChangeNotifier {
TryLimiter.reset(sid); TryLimiter.reset(sid);
} }
Future<SnippetResult?> runSnippet(String id, Snippet snippet) async { // Future<SnippetResult?> runSnippet(String id, Snippet snippet) async {
final server = _servers[id]; // final server = _servers[id];
if (server == null) return null; // if (server == null) return null;
final watch = Stopwatch()..start(); // final watch = Stopwatch()..start();
final result = await server.client?.run(snippet.fmtWith(server.spi)).string; // final result = await server.client?.run(snippet.fmtWithArgs(server.spi)).string;
final time = watch.elapsed; // final time = watch.elapsed;
watch.stop(); // watch.stop();
if (result == null) return null; // if (result == null) return null;
return SnippetResult( // return SnippetResult(
dest: _servers[id]?.spi.name, // dest: _servers[id]?.spi.name,
result: result, // result: result,
time: time, // time: time,
); // );
} // }
// Future<List<SnippetResult?>> runSnippetsMulti( // Future<List<SnippetResult?>> runSnippetsMulti(
// List<String> ids, // List<String> ids,

View File

@@ -98,7 +98,8 @@ class _ServerPageState extends State<ServerPage>
), ),
floatingActionButton: AutoHide( floatingActionButton: AutoHide(
key: _autoHideKey, key: _autoHideKey,
direction: AxisDirection.right, direction: AxisDirection.down,
offset: 75,
controller: _scrollController, controller: _scrollController,
child: FloatingActionButton( child: FloatingActionButton(
heroTag: 'addServer', heroTag: 'addServer',

View File

@@ -29,6 +29,7 @@ const _echoPWD = 'echo \$PWD';
class SSHPage extends StatefulWidget { class SSHPage extends StatefulWidget {
final ServerPrivateInfo spi; final ServerPrivateInfo spi;
final String? initCmd; final String? initCmd;
final Snippet? initSnippet;
final bool notFromTab; final bool notFromTab;
final Function()? onSessionEnd; final Function()? onSessionEnd;
final GlobalKey<TerminalViewState>? terminalKey; final GlobalKey<TerminalViewState>? terminalKey;
@@ -37,6 +38,7 @@ class SSHPage extends StatefulWidget {
super.key, super.key,
required this.spi, required this.spi,
this.initCmd, this.initCmd,
this.initSnippet,
this.notFromTab = true, this.notFromTab = true,
this.onSessionEnd, this.onSessionEnd,
this.terminalKey, this.terminalKey,
@@ -309,8 +311,7 @@ class SSHPageState extends State<SSHPage>
final snippet = snippets.firstOrNull; final snippet = snippets.firstOrNull;
if (snippet == null) return; if (snippet == null) return;
_terminal.textInput(snippet.script); snippet.runInTerm(_terminal, widget.spi);
_terminal.keyInput(TerminalKey.enter);
break; break;
case VirtualKeyFunc.file: case VirtualKeyFunc.file:
// get $PWD from SSH session // get $PWD from SSH session
@@ -415,16 +416,19 @@ class SSHPageState extends State<SSHPage>
_initService(); _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) { if (widget.initCmd != null) {
_terminal.textInput(widget.initCmd!); _terminal.textInput(widget.initCmd!);
_terminal.keyInput(TerminalKey.enter); _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(); SSHPage.focusNode.requestFocus();

View File

@@ -96,6 +96,7 @@ class _SSHTabPageState extends State<SSHTabPage>
Widget _buildAddPage() { Widget _buildAddPage() {
return Center( return Center(
key: const Key('sshTabAddServer'),
child: Consumer<ServerProvider>(builder: (_, pro, __) { child: Consumer<ServerProvider>(builder: (_, pro, __) {
if (pro.serverOrder.isEmpty) { if (pro.serverOrder.isEmpty) {
return Center( return Center(
@@ -105,21 +106,27 @@ class _SSHTabPageState extends State<SSHTabPage>
), ),
); );
} }
return ListView.builder( return GridView.builder(
padding: const EdgeInsets.all(7), padding: const EdgeInsets.all(7),
itemBuilder: (_, idx) { itemBuilder: (_, idx) {
final spi = Pros.server.pick(id: pro.serverOrder[idx])?.spi; final spi = Pros.server.pick(id: pro.serverOrder[idx])?.spi;
if (spi == null) return UIs.placeholder; if (spi == null) return UIs.placeholder;
return CardX( return CardX(
child: ListTile( child: ListTile(
contentPadding: const EdgeInsets.only(left: 17, right: 7),
title: Text(spi.name), title: Text(spi.name),
subtitle: Text(spi.id, style: UIs.textGrey),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _onTapInitCard(spi), onTap: () => _onTapInitCard(spi),
), ).center(),
); );
}, },
itemCount: pro.servers.length, itemCount: pro.servers.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 3,
crossAxisSpacing: 3,
mainAxisSpacing: 3,
),
); );
}), }),
); );

View File

@@ -126,7 +126,7 @@ void _onTapMoreBtns(
if (snippets == null || snippets.isEmpty) return; if (snippets == null || snippets.isEmpty) return;
final snippet = snippets.firstOrNull; final snippet = snippets.firstOrNull;
if (snippet == null) return; if (snippet == null) return;
final fmted = snippet.fmtWith(spi); final fmted = snippet.fmtWithSpi(spi);
final sure = await context.showRoundDialog<bool>( final sure = await context.showRoundDialog<bool>(
title: l10n.attention, title: l10n.attention,
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -141,7 +141,7 @@ void _onTapMoreBtns(
], ],
); );
if (sure != true) return; if (sure != true) return;
AppRoutes.ssh(spi: spi, initCmd: fmted).checkGo( AppRoutes.ssh(spi: spi, initSnippet: snippet).checkGo(
context: context, context: context,
check: () => _checkClient(context, spi.id), check: () => _checkClient(context, spi.id),
); );