mirror of
https://github.com/rustdesk/rustdesk.git
synced 2026-06-22 10:02:20 +00:00
feat(mobile): inline persistent terminal — multiple tabs beside the live screen
Add an embeddable, multi-tab persistent terminal to the mobile remote session: open one or more terminal tabs in a resizable sheet over the live screen stream, all on a single authenticated connection. This lets you watch the remote screen and run shell commands at the same time — like an SSH+tmux split, built into RustDesk with no external wrapper. Client-only: no changes under src/ or libs/ — the server and protocol stay stock. The feature reuses RustDesk's native terminal stack (TerminalModel + the `terminal-persistent` option), so sessions survive disconnect/idle and reattach on reconnect. Highlights: - Multiple tabs over ONE connection (TerminalConnectionManager keeps one FFI per peer, refcounted); each tab is a distinct terminal_id, one live TerminalView per tab in an IndexedStack. - Persistent sessions: on (re)connect the server's surviving session ids are offered as tabs to reattach; `exit` closes the tab and reaps the session. - Keyboard routed to the terminal while it's open (not the remote desktop), with a Termius-style accessory bar: sticky Ctrl/Alt that combine with the next key. - One Dark theme; resizable/maximizable sheet that stays clear of the system bars and the soft keyboard. - Hardening of the shared terminal stack it builds on: use-after-dispose guards, identity-checked event routing, an error-safe per-peer connection pool, and guarded padding math. The desktop split-view reuses the same InlineTerminalPanel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3c574a4182
commit
3ce8e2cf1b
10 changed files with 1205 additions and 61 deletions
733
flutter/lib/desktop/pages/inline_terminal_panel.dart
Normal file
733
flutter/lib/desktop/pages/inline_terminal_panel.dart
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/consts.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
import 'package:flutter_hbb/models/terminal_model.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
import 'terminal_connection_manager.dart';
|
||||
|
||||
/// An embeddable terminal panel that shows one or more persistent terminal
|
||||
/// tabs over a single shared terminal connection to [peerId].
|
||||
///
|
||||
/// It reuses RustDesk's native terminal stack (TerminalModel + the
|
||||
/// `terminal-persistent` option), so sessions survive disconnect/idle and
|
||||
/// reattach on reconnect — no tmux/SSH wrapper, no server/proto changes.
|
||||
class InlineTerminalPanel extends StatefulWidget {
|
||||
final String peerId;
|
||||
final String? password;
|
||||
final bool? isSharedPassword;
|
||||
final bool? forceRelay;
|
||||
// Called when the user closes the last tab — the host closes the terminal UI
|
||||
// (we don't silently spawn a replacement session).
|
||||
final VoidCallback? onClose;
|
||||
|
||||
const InlineTerminalPanel({
|
||||
Key? key,
|
||||
required this.peerId,
|
||||
this.password,
|
||||
this.isSharedPassword,
|
||||
this.forceRelay,
|
||||
this.onClose,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<InlineTerminalPanel> createState() => _InlineTerminalPanelState();
|
||||
}
|
||||
|
||||
class _TerminalTab {
|
||||
final int id;
|
||||
final TerminalModel model;
|
||||
final FocusNode focusNode;
|
||||
bool ready = false;
|
||||
// True once the remote shell has exited (e.g. the user typed `exit`).
|
||||
bool closed = false;
|
||||
VoidCallback? listener;
|
||||
|
||||
_TerminalTab({
|
||||
required this.id,
|
||||
required this.model,
|
||||
required this.focusNode,
|
||||
});
|
||||
}
|
||||
|
||||
class _InlineTerminalPanelState extends State<InlineTerminalPanel> {
|
||||
// Offset terminal ids to avoid colliding with a standalone terminal window
|
||||
// that may share the same per-peer connection.
|
||||
static const int _baseTerminalId = 900;
|
||||
|
||||
late final FFI _ffi;
|
||||
final List<_TerminalTab> _tabs = [];
|
||||
int _selectedTabIndex = 0;
|
||||
int _nextTabId = 0;
|
||||
// True once the shared connection has come up (first terminal opened).
|
||||
bool _connReady = false;
|
||||
// Real terminal cell height (px), reported by the model on resize; used for
|
||||
// vertical padding instead of a hardcoded guess.
|
||||
double _cellHeight = 18.0;
|
||||
|
||||
// A calm, readable One Dark-inspired palette for a tidy, "Termius-like" look
|
||||
// (intentionally no settings UI). Background matches the panel chrome so the
|
||||
// split view stays cohesive.
|
||||
static const TerminalTheme _theme = TerminalTheme(
|
||||
cursor: Color(0xFF61AFEF),
|
||||
selection: Color(0x553B4252),
|
||||
foreground: Color(0xFFD7DAE0),
|
||||
background: Color(0xFF1E1E1E),
|
||||
black: Color(0xFF21252B),
|
||||
red: Color(0xFFE06C75),
|
||||
green: Color(0xFF98C379),
|
||||
yellow: Color(0xFFE5C07B),
|
||||
blue: Color(0xFF61AFEF),
|
||||
magenta: Color(0xFFC678DD),
|
||||
cyan: Color(0xFF56B6C2),
|
||||
white: Color(0xFFABB2BF),
|
||||
brightBlack: Color(0xFF5C6370),
|
||||
brightRed: Color(0xFFE06C75),
|
||||
brightGreen: Color(0xFF98C379),
|
||||
brightYellow: Color(0xFFE5C07B),
|
||||
brightBlue: Color(0xFF61AFEF),
|
||||
brightMagenta: Color(0xFFC678DD),
|
||||
brightCyan: Color(0xFF56B6C2),
|
||||
brightWhite: Color(0xFFFFFFFF),
|
||||
searchHitBackground: Color(0xFFFFFF2B),
|
||||
searchHitBackgroundCurrent: Color(0xFF31FF26),
|
||||
searchHitForeground: Color(0xFF000000),
|
||||
);
|
||||
|
||||
// Slightly larger than xterm's default (13) with comfortable line height for
|
||||
// legibility on a phone-sized split view.
|
||||
static const TerminalStyle _textStyle =
|
||||
TerminalStyle(fontSize: 14, height: 1.3);
|
||||
|
||||
// Show the on-screen special-keys bar (Esc/Ctrl/Alt/arrows/…). Honours the
|
||||
// same option as the stock mobile terminal (default on).
|
||||
late final bool _showExtraKeys;
|
||||
|
||||
// Sticky modifiers (Termius-style): tap Ctrl/Alt to arm it, the next key —
|
||||
// from this bar OR the system keyboard — combines with it, then it releases.
|
||||
bool _ctrlActive = false;
|
||||
bool _altActive = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_showExtraKeys = !isWebDesktop &&
|
||||
mainGetLocalBoolOptionSync(kOptionEnableShowTerminalExtraKeys);
|
||||
// Establish the terminal connection exactly like the stock mobile terminal
|
||||
// (peer_card -> connect(isTerminal:true) -> TerminalPage): a plain
|
||||
// getConnection + registered TerminalModel. No connToken / persistence
|
||||
// toggle / event-callback routing here — those broke the connection.
|
||||
_ffi = TerminalConnectionManager.getConnection(
|
||||
peerId: widget.peerId,
|
||||
password: widget.password,
|
||||
isSharedPassword: widget.isSharedPassword,
|
||||
forceRelay: widget.forceRelay,
|
||||
);
|
||||
// If we reused an already-connected FFI (e.g. a second panel on the same
|
||||
// peer), peer_info won't fire again, so the FFI "ready" event that normally
|
||||
// drives the first OpenTerminal never comes. Seed _connReady from the live
|
||||
// connection so the first tab opens directly instead of hanging on
|
||||
// "Connecting…".
|
||||
_connReady = _ffi.ffiModel.pi.isSet.value;
|
||||
_ensurePersistent();
|
||||
_addTab();
|
||||
}
|
||||
|
||||
/// Enable RustDesk's native persistent-terminal option so sessions survive
|
||||
/// disconnect/idle and reattach on reconnect. This only flips a local config
|
||||
/// bool + queues an option message; it does NOT restart the connection.
|
||||
void _ensurePersistent() {
|
||||
try {
|
||||
final on = bind.sessionGetToggleOptionSync(
|
||||
sessionId: _ffi.sessionId,
|
||||
arg: kOptionTerminalPersistent,
|
||||
);
|
||||
if (!on) {
|
||||
bind.sessionToggleOption(
|
||||
sessionId: _ffi.sessionId,
|
||||
value: kOptionTerminalPersistent,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[InlineTerminalPanel] Failed to enable persistence: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final tab in _tabs) {
|
||||
_disposeTab(tab);
|
||||
}
|
||||
_tabs.clear();
|
||||
// Release this panel's single reference to the shared connection.
|
||||
TerminalConnectionManager.releaseConnection(widget.peerId);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _disposeTab(_TerminalTab tab) {
|
||||
if (tab.listener != null) {
|
||||
tab.model.removeListener(tab.listener!);
|
||||
}
|
||||
_ffi.unregisterTerminalModel(tab.id, tab.model);
|
||||
tab.model.dispose();
|
||||
tab.focusNode.dispose();
|
||||
}
|
||||
|
||||
void _addTab() {
|
||||
_addTabWithId(_baseTerminalId + _nextTabId);
|
||||
_nextTabId++;
|
||||
}
|
||||
|
||||
/// Create a tab bound to a specific server-side terminal_id. Used for new
|
||||
/// tabs and for restoring surviving persistent sessions after a reconnect.
|
||||
/// All tabs share ONE authenticated connection (no per-tab re-login).
|
||||
void _addTabWithId(int terminalId, {bool selectNew = true}) {
|
||||
if (_tabs.any((t) => t.id == terminalId)) return; // already shown
|
||||
final model = TerminalModel(_ffi, terminalId);
|
||||
// Focusable from birth so the tab can always receive keyboard input.
|
||||
final focusNode = FocusNode();
|
||||
|
||||
// Track the real cell height; only rebuild on an actual change (not every
|
||||
// resize frame) so the padding stays correct without churn.
|
||||
model.onResizeExternal = (w, h, pw, ph) {
|
||||
if (ph > 0 && _cellHeight != ph) {
|
||||
_cellHeight = ph.toDouble();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
};
|
||||
// Surface other surviving sessions so we can restore them as tabs.
|
||||
model.onPersistentSessions = _restorePersistentSessions;
|
||||
// Apply sticky Ctrl/Alt to keystrokes coming from the system keyboard.
|
||||
model.inputTransform = _applyModifiers;
|
||||
|
||||
final tab = _TerminalTab(
|
||||
id: terminalId,
|
||||
model: model,
|
||||
focusNode: focusNode,
|
||||
);
|
||||
|
||||
tab.listener = () {
|
||||
if (!mounted) return;
|
||||
if (model.terminalOpened) {
|
||||
_connReady = true;
|
||||
if (!tab.ready || tab.closed) {
|
||||
setState(() {
|
||||
tab.ready = true;
|
||||
tab.closed = false;
|
||||
});
|
||||
// Grab the keyboard once the selected tab is actually up.
|
||||
if (_tabs.isNotEmpty &&
|
||||
_selectedTabIndex < _tabs.length &&
|
||||
_tabs[_selectedTabIndex] == tab) {
|
||||
_focusSelected();
|
||||
}
|
||||
}
|
||||
} else if (tab.ready && !tab.closed) {
|
||||
// The terminal reported closed. Show the "Session closed / Restart"
|
||||
// banner but KEEP the tab — a `closed` may be spurious (saturated
|
||||
// output channel, transient reconnect), and we must never make a
|
||||
// session vanish on its own. The tab goes away only via an explicit ×
|
||||
// (with confirm); Restart reattaches/relaunches in place.
|
||||
setState(() => tab.closed = true);
|
||||
}
|
||||
};
|
||||
model.addListener(tab.listener!);
|
||||
|
||||
// Registering lets the FFI drive open()/reattach() on connect AND on
|
||||
// reconnect (re-sends OpenTerminal(force) → reattaches to the persistent
|
||||
// session and replays output).
|
||||
_ffi.registerTerminalModel(terminalId, model);
|
||||
_tabs.add(tab);
|
||||
if (selectNew) _selectedTabIndex = _tabs.length - 1;
|
||||
|
||||
// The FFI "ready" event only opens models registered before it fired. A tab
|
||||
// added after the connection is already up must be opened directly (same
|
||||
// connection, no re-login). Guard against the tab being removed meanwhile.
|
||||
if (_connReady) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _tabs.contains(tab)) model.onReady();
|
||||
});
|
||||
}
|
||||
|
||||
if (mounted) setState(() {});
|
||||
if (selectNew) _focusSelected();
|
||||
}
|
||||
|
||||
/// On (re)connect the server reports surviving persistent session ids; show
|
||||
/// each as a tab so you can switch to whichever you want. Cascades until all
|
||||
/// survivors are restored; ids already shown are skipped.
|
||||
void _restorePersistentSessions(List<int> ids) {
|
||||
// We are inside a successful 'opened' callback → the shared connection is
|
||||
// up. Mark it ready so restored tabs are opened directly (they must send
|
||||
// OpenTerminal to actually reattach; otherwise input is silently buffered).
|
||||
_connReady = true;
|
||||
for (final id in ids) {
|
||||
if (_tabs.any((t) => t.id == id)) continue;
|
||||
final offset = id - _baseTerminalId;
|
||||
if (offset >= _nextTabId) _nextTabId = offset + 1; // avoid id collisions
|
||||
_addTabWithId(id, selectNew: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _closeTab(int index) async {
|
||||
if (index < 0 || index >= _tabs.length) return;
|
||||
final tab = _tabs[index];
|
||||
// Confirm only when killing a session that is actually alive.
|
||||
if (tab.ready && !tab.closed) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(translate('Close')),
|
||||
content: Text('${translate('Close')} "Tab ${index + 1}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(translate('Cancel'))),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(translate('OK'))),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !mounted) return;
|
||||
}
|
||||
// An explicit × means "end this session for good", so always reap it
|
||||
// server-side (force covers a closed/hung tab whose model isn't "opened").
|
||||
await _removeTab(tab, reap: true);
|
||||
}
|
||||
|
||||
/// Dispose a tab and drop it from the bar, optionally reaping its server-side
|
||||
/// session first. Removing the last tab calls onClose (the host closes the
|
||||
/// terminal UI) rather than spawning a replacement. closeTerminal is awaited
|
||||
/// before dispose so its post-RPC notifyListeners() can't hit a disposed
|
||||
/// ChangeNotifier.
|
||||
Future<void> _removeTab(_TerminalTab tab, {bool reap = false}) async {
|
||||
if (reap) {
|
||||
await tab.model.closeTerminal(force: true);
|
||||
if (!mounted) return;
|
||||
}
|
||||
// Re-find by identity — _tabs may have changed during the await.
|
||||
final i = _tabs.indexOf(tab);
|
||||
if (i < 0) return;
|
||||
_disposeTab(tab);
|
||||
_tabs.removeAt(i);
|
||||
if (_selectedTabIndex >= _tabs.length) {
|
||||
_selectedTabIndex = _tabs.isEmpty ? 0 : _tabs.length - 1;
|
||||
}
|
||||
if (mounted) setState(() {});
|
||||
if (_tabs.isEmpty) {
|
||||
// No silent replacement session — let the host close the terminal UI.
|
||||
widget.onClose?.call();
|
||||
} else {
|
||||
_focusSelected();
|
||||
}
|
||||
}
|
||||
|
||||
/// Move keyboard focus to the selected tab's terminal (after the next frame,
|
||||
/// once its view is laid out). Keeps typing going to the visible terminal.
|
||||
void _focusSelected() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _tabs.isEmpty) return;
|
||||
final i = _selectedTabIndex.clamp(0, _tabs.length - 1);
|
||||
_tabs[i].focusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
EdgeInsets _calculatePadding(double heightPx) {
|
||||
const defaultPadding = EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
final cell = _cellHeight > 0 ? _cellHeight : 18.0;
|
||||
final rows = (heightPx / cell).floor();
|
||||
if (rows <= 0) return defaultPadding;
|
||||
final extraSpace = heightPx - rows * cell;
|
||||
if (!extraSpace.isFinite || extraSpace < 0) return defaultPadding;
|
||||
return EdgeInsets.symmetric(
|
||||
horizontal: defaultPadding.horizontal / 2,
|
||||
vertical: extraSpace / 2.0,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasTabs = _tabs.isNotEmpty;
|
||||
final selIndex =
|
||||
hasTabs ? _selectedTabIndex.clamp(0, _tabs.length - 1) : 0;
|
||||
|
||||
return Container(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTabBar(),
|
||||
if (hasTabs)
|
||||
Expanded(
|
||||
// One TerminalView per tab, all kept alive. Switching tabs only
|
||||
// changes which is shown — we never swap a terminal underneath a
|
||||
// single view (that broke repaint/focus and dropped keystrokes).
|
||||
child: IndexedStack(
|
||||
index: selIndex,
|
||||
sizing: StackFit.expand,
|
||||
children: [
|
||||
for (final tab in _tabs)
|
||||
KeyedSubtree(
|
||||
key: ValueKey(tab.id),
|
||||
child: _buildTerminalView(tab),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_showExtraKeys && hasTabs) _buildExtraKeys(_tabs[selIndex]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// A single tab's terminal view (kept alive inside the IndexedStack). Focus is
|
||||
// managed explicitly via _focusSelected, so autofocus stays off here (else the
|
||||
// offstage tabs would fight over focus).
|
||||
Widget _buildTerminalView(_TerminalTab tab) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final view = TerminalView(
|
||||
tab.model.terminal,
|
||||
controller: tab.model.terminalController,
|
||||
focusNode: tab.focusNode,
|
||||
autofocus: false,
|
||||
theme: _theme,
|
||||
textStyle: _textStyle,
|
||||
backgroundOpacity: 0.7,
|
||||
padding: _calculatePadding(constraints.maxHeight),
|
||||
onSecondaryTapDown: (details, offset) async {
|
||||
final selection = tab.model.terminalController.selection;
|
||||
if (selection != null) {
|
||||
final text = tab.model.terminal.buffer.getText(selection);
|
||||
tab.model.terminalController.clearSelection();
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
} else {
|
||||
final data = await Clipboard.getData('text/plain');
|
||||
final text = data?.text;
|
||||
if (text != null) {
|
||||
tab.model.terminal.paste(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
return RepaintBoundary(
|
||||
child: Stack(
|
||||
children: [
|
||||
view,
|
||||
if (!tab.ready && !tab.closed)
|
||||
Positioned.fill(child: _connectingView()),
|
||||
if (tab.closed)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _closedBanner(tab),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Escape sequences for the bar's non-printable keys.
|
||||
static const Map<String, String> _keySequences = {
|
||||
'Esc': '\x1B',
|
||||
'Tab': '\t',
|
||||
'↑': '\x1B[A',
|
||||
'↓': '\x1B[B',
|
||||
'→': '\x1B[C',
|
||||
'←': '\x1B[D',
|
||||
'Home': '\x1B[H',
|
||||
'End': '\x1B[F',
|
||||
'PgUp': '\x1B[5~',
|
||||
'PgDn': '\x1B[6~',
|
||||
};
|
||||
|
||||
// A Termius-style accessory bar: one scrollable row docked above the system
|
||||
// keyboard, with sticky Ctrl/Alt that highlight while armed and combine with
|
||||
// the next key (this bar's or the system keyboard's).
|
||||
Widget _buildExtraKeys(_TerminalTab tab) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF161618),
|
||||
border: Border(top: BorderSide(color: Color(0xFF333336), width: 1)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 6),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Row(
|
||||
children: [
|
||||
_keyCap(tab, label: 'esc', onTap: () => _sendKey(tab, 'Esc')),
|
||||
_keyCap(tab, label: 'ctrl', active: _ctrlActive, onTap: () => _toggleMod(ctrl: true)),
|
||||
_keyCap(tab, label: 'alt', active: _altActive, onTap: () => _toggleMod(ctrl: false)),
|
||||
_keyCap(tab, label: 'tab', onTap: () => _sendKey(tab, 'Tab')),
|
||||
_barSeparator(),
|
||||
_keyCap(tab, icon: Icons.west, onTap: () => _sendKey(tab, '←')),
|
||||
_keyCap(tab, icon: Icons.north, onTap: () => _sendKey(tab, '↑')),
|
||||
_keyCap(tab, icon: Icons.south, onTap: () => _sendKey(tab, '↓')),
|
||||
_keyCap(tab, icon: Icons.east, onTap: () => _sendKey(tab, '→')),
|
||||
_barSeparator(),
|
||||
for (final s in const ['-', '/', '|', '~', '`'])
|
||||
_keyCap(tab, label: s, onTap: () => _sendKey(tab, s)),
|
||||
_barSeparator(),
|
||||
for (final k in const ['Home', 'End', 'PgUp', 'PgDn'])
|
||||
_keyCap(tab, label: k, onTap: () => _sendKey(tab, k)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _barSeparator() => Container(
|
||||
width: 1,
|
||||
height: 18,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 7),
|
||||
color: const Color(0xFF38383B),
|
||||
);
|
||||
|
||||
Widget _keyCap(
|
||||
_TerminalTab tab, {
|
||||
String? label,
|
||||
IconData? icon,
|
||||
bool active = false,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final fg = active ? Colors.white : const Color(0xFFCED0D4);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
child: Material(
|
||||
color: active ? const Color(0xFF3B6FE0) : const Color(0xFF2B2B2E),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
// Never take focus from the terminal — otherwise the soft keyboard
|
||||
// closes and the armed modifier can't combine with the next key.
|
||||
canRequestFocus: false,
|
||||
onTap: () {
|
||||
onTap();
|
||||
// Keep the terminal focused so the keyboard stays up and the next
|
||||
// system-keyboard key reaches it (with the modifier applied).
|
||||
if (!tab.focusNode.hasFocus) tab.focusNode.requestFocus();
|
||||
},
|
||||
child: Container(
|
||||
height: 34,
|
||||
constraints: const BoxConstraints(minWidth: 42),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11),
|
||||
child: icon != null
|
||||
? Icon(icon, size: 17, color: fg)
|
||||
: Text(
|
||||
label!,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: 13,
|
||||
height: 1.0,
|
||||
fontWeight: active ? FontWeight.w700 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleMod({required bool ctrl}) {
|
||||
setState(() {
|
||||
if (ctrl) {
|
||||
_ctrlActive = !_ctrlActive;
|
||||
} else {
|
||||
_altActive = !_altActive;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _sendKey(_TerminalTab tab, String label) {
|
||||
final raw = _keySequences[label] ?? label;
|
||||
tab.model.sendVirtualKey(_applyModifiers(raw));
|
||||
}
|
||||
|
||||
/// Apply any armed sticky modifier to [data], then release it (one-shot).
|
||||
/// Set as each model's inputTransform, so it also catches the system keyboard.
|
||||
String _applyModifiers(String data) {
|
||||
if (!_ctrlActive && !_altActive) return data;
|
||||
var out = data;
|
||||
if (_ctrlActive) out = _ctrlTransform(out);
|
||||
if (_altActive) out = '\x1B$out'; // Alt = ESC prefix
|
||||
_ctrlActive = false;
|
||||
_altActive = false;
|
||||
// We may be inside an input event; update the highlight next frame.
|
||||
if (mounted) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Map a single character to its Ctrl- control code (Ctrl+C -> 0x03, etc.).
|
||||
// Multi-char input (e.g. arrow sequences) is left unchanged.
|
||||
static String _ctrlTransform(String data) {
|
||||
if (data.length != 1) return data;
|
||||
final c = data.codeUnitAt(0);
|
||||
if (c >= 0x61 && c <= 0x7A) return String.fromCharCode(c - 0x60); // a-z
|
||||
if (c >= 0x41 && c <= 0x5A) return String.fromCharCode(c - 0x40); // A-Z
|
||||
if (c >= 0x5B && c <= 0x5F) return String.fromCharCode(c - 0x40); // [ \ ] ^ _
|
||||
if (c == 0x20) return '\x00'; // Ctrl+Space -> NUL
|
||||
return data;
|
||||
}
|
||||
|
||||
Widget _closedBanner(_TerminalTab tab) {
|
||||
return Container(
|
||||
color: Colors.black.withOpacity(0.65),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.cancel, size: 14, color: Colors.grey.shade400),
|
||||
const SizedBox(width: 8),
|
||||
Text(translate('Session closed'),
|
||||
style: TextStyle(color: Colors.grey.shade300, fontSize: 12)),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: () => tab.model.openTerminal(force: true),
|
||||
child: Text(translate('Restart')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _connectingView() {
|
||||
return Container(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${translate('Connecting')}...',
|
||||
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTabBar() {
|
||||
return Container(
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2D2D2D),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey.shade800, width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
Icon(Icons.terminal, size: 16, color: Colors.grey.shade400),
|
||||
// Persistent-session indicator: sessions survive reconnect/idle.
|
||||
const SizedBox(width: 6),
|
||||
Tooltip(
|
||||
message: translate('Keep terminal sessions on disconnect'),
|
||||
child: Icon(Icons.push_pin, size: 12, color: Colors.green.shade400),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _tabs.length,
|
||||
itemBuilder: (context, index) => _buildTab(index),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.add, size: 16, color: Colors.grey.shade400),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: _addTab,
|
||||
tooltip: translate('New tab'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTab(int index) {
|
||||
final tab = _tabs[index];
|
||||
final isSelected = index == _selectedTabIndex;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedTabIndex = index;
|
||||
// Don't carry an armed modifier across tabs.
|
||||
_ctrlActive = false;
|
||||
_altActive = false;
|
||||
});
|
||||
// Move the keyboard to the newly selected tab.
|
||||
_focusSelected();
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF3D3D3D) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: isSelected
|
||||
? Border.all(color: Colors.blue.shade400, width: 1)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (tab.closed)
|
||||
Icon(Icons.cancel, size: 10, color: Colors.grey.shade500)
|
||||
else if (!tab.ready)
|
||||
SizedBox(
|
||||
width: 8,
|
||||
height: 8,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 1,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Icons.check_circle, size: 10, color: Colors.green.shade400),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
// Positional label so it stays consistent after close/restore.
|
||||
'Tab ${index + 1}',
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : Colors.grey.shade400,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Always offer a close affordance — even the last/only tab (a hung
|
||||
// session must be closable); closing the last tab closes the
|
||||
// terminal UI via onClose.
|
||||
GestureDetector(
|
||||
onTap: () => _closeTab(index),
|
||||
child: Icon(Icons.close, size: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import '../../utils/image.dart';
|
|||
import '../widgets/remote_toolbar.dart';
|
||||
import '../widgets/kb_layout_type_chooser.dart';
|
||||
import '../widgets/tabbar_widget.dart';
|
||||
import 'inline_terminal_panel.dart';
|
||||
|
||||
import 'package:flutter_hbb/native/custom_cursor.dart'
|
||||
if (dart.library.html) 'package:flutter_hbb/web/custom_cursor.dart';
|
||||
|
|
@ -105,6 +106,9 @@ class _RemotePageState extends State<RemotePage>
|
|||
bool _waylandKeyboardModeNormalized = false;
|
||||
bool _waylandKeyboardModeNormalizing = false;
|
||||
|
||||
// Inline terminal panel state
|
||||
bool _showInlineTerminal = false;
|
||||
|
||||
SessionID get sessionId => _ffi.sessionId;
|
||||
|
||||
_RemotePageState(String id) {
|
||||
|
|
@ -392,6 +396,43 @@ class _RemotePageState extends State<RemotePage>
|
|||
removeSharedStates(widget.id);
|
||||
}
|
||||
|
||||
/// Toggle the inline terminal panel visibility
|
||||
void toggleInlineTerminal() {
|
||||
setState(() {
|
||||
_showInlineTerminal = !_showInlineTerminal;
|
||||
});
|
||||
}
|
||||
|
||||
/// Build split view with remote desktop on top and terminal on bottom
|
||||
Widget _buildSplitView(BuildContext context, Widget remoteBody) {
|
||||
return Column(
|
||||
children: [
|
||||
// Remote desktop view (takes remaining space)
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: remoteBody,
|
||||
),
|
||||
// Terminal panel (bottom 40% of screen)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: InlineTerminalPanel(
|
||||
peerId: widget.id,
|
||||
password: widget.password,
|
||||
isSharedPassword: widget.isSharedPassword,
|
||||
forceRelay: widget.forceRelay,
|
||||
// Closing the last tab collapses the split view instead of leaving
|
||||
// an empty panel.
|
||||
onClose: () {
|
||||
if (_showInlineTerminal) {
|
||||
setState(() => _showInlineTerminal = false);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget emptyOverlay() => BlockableOverlay(
|
||||
/// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
|
||||
/// see override build() in [BlockableOverlay]
|
||||
|
|
@ -419,6 +460,7 @@ class _RemotePageState extends State<RemotePage>
|
|||
}
|
||||
},
|
||||
setRemoteState: setState,
|
||||
onToggleInlineTerminal: toggleInlineTerminal,
|
||||
);
|
||||
|
||||
bodyWidget() {
|
||||
|
|
@ -506,7 +548,9 @@ class _RemotePageState extends State<RemotePage>
|
|||
}
|
||||
// Block the whole `bodyWidget()` when dialog shows.
|
||||
return BlockableOverlay(
|
||||
underlying: bodyWidget(),
|
||||
underlying: _showInlineTerminal
|
||||
? _buildSplitView(context, bodyWidget())
|
||||
: bodyWidget(),
|
||||
state: _blockableOverlayState,
|
||||
);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../../models/model.dart';
|
||||
|
||||
/// Manages terminal connections to ensure one FFI instance per peer
|
||||
|
|
@ -16,7 +17,7 @@ class TerminalConnectionManager {
|
|||
required String? password,
|
||||
required bool? isSharedPassword,
|
||||
required bool? forceRelay,
|
||||
required String? connToken,
|
||||
String? connToken,
|
||||
}) {
|
||||
final existingFfi = _connections[peerId];
|
||||
if (existingFfi != null && !existingFfi.closed) {
|
||||
|
|
@ -26,24 +27,38 @@ class TerminalConnectionManager {
|
|||
return existingFfi;
|
||||
}
|
||||
|
||||
// Create new FFI instance for first terminal
|
||||
// Create new FFI instance for first terminal.
|
||||
// IMPORTANT: pass a fresh SessionID. On mobile FFI(null) reuses a shared
|
||||
// constant SessionID, which would collide with the active video session's
|
||||
// FFI — the native side then injects a "close" into the video stream and
|
||||
// never spawns the terminal's io_loop, so the terminal hangs on
|
||||
// "Connecting…". A unique id makes the terminal a distinct session
|
||||
// (desktop already does this).
|
||||
debugPrint('[TerminalConnectionManager] Creating new terminal connection for peer $peerId');
|
||||
final ffi = FFI(null);
|
||||
ffi.start(
|
||||
peerId,
|
||||
password: password,
|
||||
isSharedPassword: isSharedPassword,
|
||||
forceRelay: forceRelay,
|
||||
connToken: connToken,
|
||||
isTerminal: true,
|
||||
);
|
||||
|
||||
final ffi = FFI(const Uuid().v4obj());
|
||||
// Track the connection BEFORE start() so a throw can't leave an orphaned,
|
||||
// half-started native session that nothing ever closes.
|
||||
_connections[peerId] = ffi;
|
||||
_connectionRefCount[peerId] = 1;
|
||||
|
||||
// Register the FFI instance with Get for dependency injection
|
||||
Get.put<FFI>(ffi, tag: 'terminal_$peerId');
|
||||
|
||||
try {
|
||||
ffi.start(
|
||||
peerId,
|
||||
password: password,
|
||||
isSharedPassword: isSharedPassword,
|
||||
forceRelay: forceRelay,
|
||||
connToken: connToken,
|
||||
isTerminal: true,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[TerminalConnectionManager] start failed for $peerId: $e');
|
||||
_connections.remove(peerId);
|
||||
_connectionRefCount.remove(peerId);
|
||||
Get.delete<FFI>(tag: 'terminal_$peerId', force: true);
|
||||
ffi.close();
|
||||
rethrow;
|
||||
}
|
||||
|
||||
debugPrint('[TerminalConnectionManager] New connection created. Total connections: ${_connections.length}');
|
||||
return ffi;
|
||||
}
|
||||
|
|
@ -54,15 +69,14 @@ class TerminalConnectionManager {
|
|||
debugPrint('[TerminalConnectionManager] Releasing connection for peer $peerId. Current ref count: $refCount');
|
||||
|
||||
if (refCount <= 1) {
|
||||
// Last reference, close the connection
|
||||
final ffi = _connections[peerId];
|
||||
if (ffi != null) {
|
||||
debugPrint('[TerminalConnectionManager] Closing connection for peer $peerId (last reference)');
|
||||
ffi.close();
|
||||
_connections.remove(peerId);
|
||||
_connectionRefCount.remove(peerId);
|
||||
Get.delete<FFI>(tag: 'terminal_$peerId');
|
||||
}
|
||||
// Last reference: tear everything down. Clear all bookkeeping even if the
|
||||
// FFI is already gone, so a desync can't leave a stale refcount/service id
|
||||
// behind that poisons later getTerminalCount()/hasConnection() checks.
|
||||
debugPrint('[TerminalConnectionManager] Closing connection for peer $peerId (last reference)');
|
||||
_connections.remove(peerId)?.close();
|
||||
_connectionRefCount.remove(peerId);
|
||||
_serviceIds.remove(peerId);
|
||||
Get.delete<FFI>(tag: 'terminal_$peerId', force: true);
|
||||
} else {
|
||||
// Decrement reference count
|
||||
_connectionRefCount[peerId] = refCount - 1;
|
||||
|
|
|
|||
|
|
@ -75,6 +75,9 @@ class _TerminalPageState extends State<TerminalPage>
|
|||
'[TerminalPage] Terminal model created for terminal ${widget.terminalId}');
|
||||
|
||||
_terminalModel.onResizeExternal = (w, h, pw, ph) {
|
||||
// A resize can still fire while the page is tearing down; bail before
|
||||
// touching the (possibly disposed) focus node.
|
||||
if (!mounted) return;
|
||||
_cellHeight = ph * 1.0;
|
||||
|
||||
// Enable focus once terminal has valid dimensions (first valid resize)
|
||||
|
|
@ -121,8 +124,10 @@ class _TerminalPageState extends State<TerminalPage>
|
|||
void dispose() {
|
||||
// Cancel tab state subscription to prevent memory leak
|
||||
_tabStateSubscription?.cancel();
|
||||
// Drop the resize callback before disposing the focus node it touches.
|
||||
_terminalModel.onResizeExternal = null;
|
||||
// Unregister terminal model from FFI
|
||||
_ffi.unregisterTerminalModel(widget.terminalId);
|
||||
_ffi.unregisterTerminalModel(widget.terminalId, _terminalModel);
|
||||
_terminalModel.dispose();
|
||||
_terminalFocusNode.dispose();
|
||||
// Release connection reference instead of closing directly
|
||||
|
|
|
|||
|
|
@ -437,18 +437,16 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||
HardwareKeyboard.instance.isMetaPressed &&
|
||||
!HardwareKeyboard.instance.isShiftPressed) {
|
||||
// macOS: Cmd+W (standard for close tab)
|
||||
final currentTab = tabController.state.value.selectedTabInfo;
|
||||
if (tabController.state.value.tabs.length > 1) {
|
||||
_closeTab(currentTab.key);
|
||||
_closeTab(tabController.state.value.selectedTabInfo.key);
|
||||
return true;
|
||||
}
|
||||
} else if (!isMacOS &&
|
||||
HardwareKeyboard.instance.isControlPressed &&
|
||||
HardwareKeyboard.instance.isShiftPressed) {
|
||||
// Other platforms: Ctrl+Shift+W (to avoid conflict with Ctrl+W word delete)
|
||||
final currentTab = tabController.state.value.selectedTabInfo;
|
||||
if (tabController.state.value.tabs.length > 1) {
|
||||
_closeTab(currentTab.key);
|
||||
_closeTab(tabController.state.value.selectedTabInfo.key);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -501,13 +499,18 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||
}
|
||||
|
||||
void _addNewTerminal(String peerId, {int? terminalId}) {
|
||||
// Find first tab for this peer to get connection parameters
|
||||
final firstTab = tabController.state.value.tabs.firstWhere(
|
||||
(tab) {
|
||||
final last = tab.key.lastIndexOf('_');
|
||||
return last > 0 && tab.key.substring(0, last) == peerId;
|
||||
},
|
||||
);
|
||||
// Find first tab for this peer to get connection parameters. Use a nullable
|
||||
// lookup (not firstWhere, which throws StateError) since the seed tab may
|
||||
// have been closed while a restore loop was awaiting between iterations.
|
||||
TabInfo? firstTab;
|
||||
for (final tab in tabController.state.value.tabs) {
|
||||
final last = tab.key.lastIndexOf('_');
|
||||
if (last > 0 && tab.key.substring(0, last) == peerId) {
|
||||
firstTab = tab;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (firstTab == null) return;
|
||||
if (firstTab.page is TerminalPage) {
|
||||
final page = firstTab.page as TerminalPage;
|
||||
final newTerminalId = terminalId ?? _nextTerminalId++;
|
||||
|
|
@ -526,6 +529,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
|
|||
}
|
||||
|
||||
void _addNewTerminalForCurrentPeer({int? terminalId}) {
|
||||
if (tabController.state.value.tabs.isEmpty) return;
|
||||
final currentTab = tabController.state.value.selectedTabInfo;
|
||||
final parsed = _parseTabKey(currentTab.key);
|
||||
if (parsed == null) return;
|
||||
|
|
|
|||
|
|
@ -446,6 +446,7 @@ class RemoteToolbar extends StatefulWidget {
|
|||
final Function(int, Function(bool)) onEnterOrLeaveImageSetter;
|
||||
final Function(int) onEnterOrLeaveImageCleaner;
|
||||
final Function(VoidCallback) setRemoteState;
|
||||
final VoidCallback? onToggleInlineTerminal;
|
||||
|
||||
RemoteToolbar({
|
||||
Key? key,
|
||||
|
|
@ -455,6 +456,7 @@ class RemoteToolbar extends StatefulWidget {
|
|||
required this.onEnterOrLeaveImageSetter,
|
||||
required this.onEnterOrLeaveImageCleaner,
|
||||
required this.setRemoteState,
|
||||
this.onToggleInlineTerminal,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
|
@ -838,6 +840,11 @@ class _RemoteToolbarState extends State<RemoteToolbar> {
|
|||
|
||||
toolbarItems
|
||||
.add(_ControlMenu(id: widget.id, ffi: widget.ffi, state: widget.state));
|
||||
toolbarItems.add(_InlineTerminalButton(
|
||||
id: widget.id,
|
||||
ffi: widget.ffi,
|
||||
onToggle: widget.onToggleInlineTerminal,
|
||||
));
|
||||
toolbarItems.add(_DisplayMenu(
|
||||
id: widget.id,
|
||||
ffi: widget.ffi,
|
||||
|
|
@ -1331,6 +1338,38 @@ class _ControlMenu extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class _InlineTerminalButton extends StatelessWidget {
|
||||
final String id;
|
||||
final FFI ffi;
|
||||
final VoidCallback? onToggle;
|
||||
|
||||
const _InlineTerminalButton({
|
||||
Key? key,
|
||||
required this.id,
|
||||
required this.ffi,
|
||||
this.onToggle,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _IconSubmenuButton(
|
||||
tooltip: '${translate('Terminal')} (inline)',
|
||||
svg: "assets/terminal.svg",
|
||||
color: _ToolbarTheme.blueColor,
|
||||
hoverColor: _ToolbarTheme.hoverBlueColor,
|
||||
ffi: ffi,
|
||||
menuChildrenGetter: (_) => [
|
||||
MenuButton(
|
||||
child: Text('${translate('Terminal')} (inline)'),
|
||||
onPressed: () {
|
||||
onToggle?.call();
|
||||
},
|
||||
ffi: ffi,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class ScreenAdjustor {
|
||||
final String id;
|
||||
final FFI ffi;
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import '../../models/platform_model.dart';
|
|||
import '../../utils/image.dart';
|
||||
import '../widgets/dialog.dart';
|
||||
import '../widgets/custom_scale_widget.dart';
|
||||
import '../../desktop/pages/inline_terminal_panel.dart';
|
||||
|
||||
final initText = '1' * 1024;
|
||||
|
||||
|
|
@ -74,6 +75,13 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||
final FocusNode _mobileFocusNode = FocusNode();
|
||||
final FocusNode _physicalFocusNode = FocusNode();
|
||||
var _showEdit = false; // use soft keyboard
|
||||
bool _showInlineTerminal = false;
|
||||
// Lazily mount the terminal panel on first open, then keep it alive so
|
||||
// terminal sessions/tabs survive while the sheet is toggled.
|
||||
bool _terminalMounted = false;
|
||||
// Terminal sheet height as a fraction of the screen (drag handle / maximize).
|
||||
double _terminalSheetFraction = 0.62;
|
||||
bool _draggingSheet = false;
|
||||
|
||||
Worker? _waylandKeyboardGateWorker;
|
||||
bool _waylandKeyboardGateInitialized = false;
|
||||
|
|
@ -242,7 +250,11 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||
if (!visible) {
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
|
||||
// [pi.version.isNotEmpty] -> check ready or not, avoid login without soft-keyboard
|
||||
if (gFFI.chatModel.chatWindowOverlayEntry == null &&
|
||||
// Don't re-suppress the keyboard while the inline terminal is open — it
|
||||
// needs the soft keyboard, and re-arming FLAG_ALT_FOCUSABLE_IM here would
|
||||
// stop the terminal from receiving input after the keyboard is dismissed.
|
||||
if (!_showInlineTerminal &&
|
||||
gFFI.chatModel.chatWindowOverlayEntry == null &&
|
||||
gFFI.ffiModel.pi.version.isNotEmpty) {
|
||||
gFFI.invokeMethod("enable_soft_keyboard", false);
|
||||
}
|
||||
|
|
@ -433,6 +445,189 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||
});
|
||||
}
|
||||
|
||||
void _toggleTerminal(bool show) {
|
||||
setState(() {
|
||||
_showInlineTerminal = show;
|
||||
if (show) _terminalMounted = true;
|
||||
});
|
||||
// Hand the keyboard to whichever surface is in front. While the terminal is
|
||||
// open, ALLOW the soft keyboard (the desktop default blocks it via
|
||||
// FLAG_ALT_FOCUSABLE_IM) and release the remote-desktop input focus (hidden
|
||||
// text field + physical-key scope) so keystrokes go to the terminal. On
|
||||
// close, restore the desktop default (keyboard suppressed) and its focus.
|
||||
if (show) {
|
||||
_showEdit = false;
|
||||
gFFI.invokeMethod("enable_soft_keyboard", true);
|
||||
_mobileFocusNode.unfocus();
|
||||
_physicalFocusNode.unfocus();
|
||||
} else {
|
||||
_showEdit = false;
|
||||
gFFI.invokeMethod("enable_soft_keyboard", false);
|
||||
_physicalFocusNode.requestFocus();
|
||||
}
|
||||
_refitStream();
|
||||
}
|
||||
|
||||
/// Refit the live stream to the area ABOVE the terminal sheet: tell the
|
||||
/// canvas how much the sheet covers, then recompute the view style so the
|
||||
/// stream fits and top-aligns to the visible region (not centered behind it).
|
||||
void _refitStream() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
try {
|
||||
final h = MediaQuery.of(context).size.height;
|
||||
final obstruction =
|
||||
_showInlineTerminal ? h * _terminalSheetFraction : 0.0;
|
||||
// Skip the FFI roundtrip when nothing changed (e.g. a no-op drag-end).
|
||||
if (gFFI.canvasModel.bottomObstructionPx == obstruction) return;
|
||||
gFFI.canvasModel.bottomObstructionPx = obstruction;
|
||||
gFFI.canvasModel.updateViewStyle();
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
/// A clean slide-up terminal sheet over the live stream. Drag the handle to
|
||||
/// resize, tap maximize to expand to nearly full screen, and the scrim / ✕ /
|
||||
/// drag-down-to-dismiss to close. Kept alive once mounted so sessions survive.
|
||||
// Sheet height capped to the space actually on screen: when the keyboard is
|
||||
// up the Scaffold body shrinks, so a full-height sheet would push its tab bar
|
||||
// and top rows off the top. Clamping keeps everything visible above the
|
||||
// keyboard (matters most when maximized).
|
||||
double _effectiveSheetHeight(BuildContext context) {
|
||||
final mq = MediaQuery.of(context);
|
||||
final available = mq.size.height - mq.viewInsets.bottom;
|
||||
return (mq.size.height * _terminalSheetFraction)
|
||||
.clamp(0.0, available)
|
||||
.toDouble();
|
||||
}
|
||||
|
||||
Widget _buildTerminalSheet(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final sheetHeight = _effectiveSheetHeight(context);
|
||||
final shown = _showInlineTerminal;
|
||||
return Stack(
|
||||
children: [
|
||||
if (shown)
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () => _toggleTerminal(false),
|
||||
child: Container(color: Colors.black38),
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
duration: _draggingSheet
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: sheetHeight,
|
||||
bottom: shown ? 0 : -(sheetHeight + 48),
|
||||
child: Material(
|
||||
color: const Color(0xFF1E1E1E),
|
||||
elevation: 12,
|
||||
// Keep the header and terminal clear of the status bar / gesture
|
||||
// nav bar so every edge stays tappable. The top inset only matters
|
||||
// when maximized (sheet reaches the status bar); a partial sheet
|
||||
// sits mid-screen and needs no top padding.
|
||||
child: SafeArea(
|
||||
top: _terminalSheetFraction >= 0.88,
|
||||
bottom: true,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildTerminalSheetHeader(size.height),
|
||||
Expanded(
|
||||
// Pause the panel's tickers (cursor blink) while the sheet
|
||||
// is hidden — sessions stay alive, just no wasted repaints.
|
||||
child: TickerMode(
|
||||
enabled: _showInlineTerminal || _draggingSheet,
|
||||
child: InlineTerminalPanel(
|
||||
peerId: widget.id,
|
||||
password: widget.password,
|
||||
isSharedPassword: widget.isSharedPassword,
|
||||
forceRelay: widget.forceRelay,
|
||||
// Closing the last tab closes the terminal UI and
|
||||
// unmounts the panel, so reopening starts a fresh
|
||||
// session instead of a silent replacement tab.
|
||||
onClose: () {
|
||||
_toggleTerminal(false);
|
||||
setState(() => _terminalMounted = false);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTerminalSheetHeader(double screenH) {
|
||||
final isMax = _terminalSheetFraction >= 0.88;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onVerticalDragStart: (_) => setState(() => _draggingSheet = true),
|
||||
onVerticalDragUpdate: (d) {
|
||||
setState(() {
|
||||
_terminalSheetFraction =
|
||||
(_terminalSheetFraction - d.delta.dy / screenH).clamp(0.12, 0.95);
|
||||
});
|
||||
},
|
||||
onVerticalDragEnd: (DragEndDetails d) {
|
||||
setState(() => _draggingSheet = false);
|
||||
// Close ONLY on a clear downward fling; a slow drag just resizes the
|
||||
// sheet (use the ✕ to close explicitly).
|
||||
if ((d.primaryVelocity ?? 0) > 700 && _terminalSheetFraction <= 0.3) {
|
||||
_terminalSheetFraction = 0.62;
|
||||
_toggleTerminal(false);
|
||||
}
|
||||
_refitStream();
|
||||
},
|
||||
child: Container(
|
||||
height: 36,
|
||||
color: const Color(0xFF2D2D2D),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(isMax ? Icons.fullscreen_exit : Icons.fullscreen,
|
||||
size: 18, color: Colors.grey.shade300),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
tooltip: isMax ? translate('Restore') : translate('Maximize'),
|
||||
onPressed: () {
|
||||
setState(() => _terminalSheetFraction = isMax ? 0.62 : 0.95);
|
||||
_refitStream();
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade600,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, size: 18, color: Colors.grey.shade300),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
tooltip: translate('Close'),
|
||||
onPressed: () => _toggleTerminal(false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bottomWidget() => _showGestureHelp
|
||||
? getGestureHelp()
|
||||
: (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty
|
||||
|
|
@ -500,7 +695,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||
() => getRawPointerAndKeyBody(Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (context) {
|
||||
return Container(
|
||||
final remoteView = Container(
|
||||
color: kColorCanvas,
|
||||
child: isWebDesktop
|
||||
? getBodyForDesktopWithListener()
|
||||
|
|
@ -527,6 +722,28 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||
}),
|
||||
),
|
||||
);
|
||||
// Keep the original full-screen render path when the
|
||||
// terminal isn't mounted (no regression to the live stream).
|
||||
if (!_terminalMounted) return remoteView;
|
||||
// When the terminal is up, shrink the stream into the visible
|
||||
// area ABOVE the sheet so it fits there instead of being
|
||||
// covered (the canvas refits via updateViewStyle on toggle).
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
// Keep the stream's bottom aligned with the (keyboard-
|
||||
// capped) sheet top so they don't diverge.
|
||||
bottom: _showInlineTerminal
|
||||
? _effectiveSheetHeight(context)
|
||||
: 0,
|
||||
child: remoteView,
|
||||
),
|
||||
_buildTerminalSheet(context),
|
||||
],
|
||||
);
|
||||
})
|
||||
],
|
||||
)),
|
||||
|
|
@ -541,7 +758,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||
inputModel: inputModel,
|
||||
// Disable RawKeyFocusScope before the connecting is established.
|
||||
// The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog.
|
||||
child: gFFI.ffiModel.pi.isSet.isTrue
|
||||
// Also disable it while the inline terminal is open so physical keys reach
|
||||
// the terminal instead of being routed to the remote desktop.
|
||||
child: gFFI.ffiModel.pi.isSet.isTrue && !_showInlineTerminal
|
||||
? RawKeyFocusScope(
|
||||
focusNode: _physicalFocusNode,
|
||||
inputModel: inputModel,
|
||||
|
|
@ -554,7 +773,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||
final ffiModel = Provider.of<FfiModel>(context);
|
||||
return BottomAppBar(
|
||||
elevation: 10,
|
||||
color: MyTheme.accent,
|
||||
color: _showInlineTerminal ? const Color(0xFF2D2D2D) : MyTheme.accent,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
|
|
@ -575,7 +794,28 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
|
|||
setState(() => _showEdit = false);
|
||||
showOptions(context, widget.id, gFFI.dialogManager);
|
||||
},
|
||||
)
|
||||
),
|
||||
// Terminal toggle - visually distinct when active
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _showInlineTerminal
|
||||
? MyTheme.accent.withOpacity(0.3)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: IconButton(
|
||||
color: _showInlineTerminal
|
||||
? MyTheme.accent
|
||||
: Colors.white,
|
||||
icon: const Icon(Icons.terminal),
|
||||
tooltip: _showInlineTerminal
|
||||
? translate('Close')
|
||||
: translate('Terminal'),
|
||||
onPressed: () =>
|
||||
_toggleTerminal(!_showInlineTerminal),
|
||||
),
|
||||
),
|
||||
] +
|
||||
(isWebDesktop || ffiModel.viewOnly || !ffiModel.keyboard
|
||||
? []
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ class _TerminalPageState extends State<TerminalPage>
|
|||
@override
|
||||
void dispose() {
|
||||
// Unregister terminal model from FFI
|
||||
_ffi.unregisterTerminalModel(widget.terminalId);
|
||||
_ffi.unregisterTerminalModel(widget.terminalId, _terminalModel);
|
||||
_terminalModel.dispose();
|
||||
_keyboardDebounce?.cancel();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
|
@ -116,6 +116,7 @@ class _TerminalPageState extends State<TerminalPage>
|
|||
|
||||
_keyboardDebounce?.cancel();
|
||||
_keyboardDebounce = Timer(const Duration(milliseconds: 20), () {
|
||||
if (!mounted) return;
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
setState(() {
|
||||
_sysKeyboardHeight = bottomInset;
|
||||
|
|
@ -131,14 +132,28 @@ class _TerminalPageState extends State<TerminalPage>
|
|||
}
|
||||
|
||||
EdgeInsets _calculatePadding(double heightPx) {
|
||||
if (_cellHeight == null) {
|
||||
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
const defaultPadding = EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
|
||||
final cellHeight = _cellHeight;
|
||||
// Guard against NaN/zero/negative inputs (e.g. a tall keyboard in landscape
|
||||
// makes realHeight <= 0); mirrors the hardened desktop version.
|
||||
if (!heightPx.isFinite ||
|
||||
heightPx <= 0 ||
|
||||
cellHeight == null ||
|
||||
!cellHeight.isFinite ||
|
||||
cellHeight <= 0) {
|
||||
return defaultPadding;
|
||||
}
|
||||
final realHeight = heightPx - _sysKeyboardHeight - _keyboardHeight;
|
||||
final rows = (realHeight / _cellHeight!).floor();
|
||||
final extraSpace = realHeight - rows * _cellHeight!;
|
||||
final topBottom = max(0.0, extraSpace / 2.0);
|
||||
return EdgeInsets.only(left: 5.0, right: 5.0, top: topBottom, bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
|
||||
if (!realHeight.isFinite) return defaultPadding;
|
||||
final rows = (realHeight / cellHeight).floor();
|
||||
if (rows <= 0) return defaultPadding;
|
||||
final extraSpace = realHeight - rows * cellHeight;
|
||||
final topBottom = extraSpace.isFinite ? max(0.0, extraSpace / 2.0) : 0.0;
|
||||
return EdgeInsets.only(
|
||||
left: 5.0,
|
||||
right: 5.0,
|
||||
top: topBottom,
|
||||
bottom: topBottom + _sysKeyboardHeight + _keyboardHeight);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -2254,6 +2254,11 @@ class CanvasModel with ChangeNotifier {
|
|||
static double get bottomToEdge =>
|
||||
isDesktop ? windowBorderWidth + kDragToResizeAreaPadding.bottom : 0;
|
||||
|
||||
// Height (px) of a bottom overlay that covers part of the canvas on mobile
|
||||
// (e.g. the inline terminal sheet). Subtracted from the usable height so the
|
||||
// stream fits and top-aligns to the visible area above it.
|
||||
double bottomObstructionPx = 0;
|
||||
|
||||
Size getSize() {
|
||||
final mediaData = MediaQueryData.fromView(ui.window);
|
||||
final size = mediaData.size;
|
||||
|
|
@ -2267,6 +2272,7 @@ class CanvasModel with ChangeNotifier {
|
|||
// bottom overlay (e.g. key-help tools) so the canvas is not covered.
|
||||
h = h -
|
||||
mediaData.viewInsets.bottom -
|
||||
bottomObstructionPx -
|
||||
(parent.target?.cursorModel.keyHelpToolsRectToAdjustCanvas?.bottom ??
|
||||
0);
|
||||
// Orientation-specific handling:
|
||||
|
|
@ -2366,7 +2372,11 @@ class CanvasModel with ChangeNotifier {
|
|||
|
||||
_resetCanvasOffset(int displayWidth, int displayHeight) {
|
||||
_x = (size.width - displayWidth * _scale) / 2;
|
||||
_y = (size.height - displayHeight * _scale) / 2;
|
||||
// Top-align when a bottom overlay (the inline terminal sheet) shrinks the
|
||||
// canvas, so the stream sits at the top of the visible area, not centered.
|
||||
_y = bottomObstructionPx > 0
|
||||
? 0
|
||||
: (size.height - displayHeight * _scale) / 2;
|
||||
if (isMobile) {
|
||||
_moveToCenterCursor();
|
||||
}
|
||||
|
|
@ -2683,6 +2693,8 @@ class CanvasModel with ChangeNotifier {
|
|||
_x = 0;
|
||||
_y = 0;
|
||||
_scale = 1.0;
|
||||
// Reset the terminal-sheet obstruction so it can't shrink the next session.
|
||||
bottomObstructionPx = 0;
|
||||
_lastViewStyle = ViewStyle.defaultViewStyle();
|
||||
_timerMobileFocusCanvasCursor?.cancel();
|
||||
_timerMobileRestoreCanvasOffset?.cancel();
|
||||
|
|
@ -3981,15 +3993,26 @@ class FFI {
|
|||
return await platformFFI.invokeMethod(method, arguments);
|
||||
}
|
||||
|
||||
// Terminal model management
|
||||
// Terminal model management. Models are keyed by terminal id over a single
|
||||
// per-peer connection, so two embedders (e.g. the tabbed terminal and the
|
||||
// inline panel) must not reuse an id; warn if a live model would be clobbered.
|
||||
void registerTerminalModel(int terminalId, TerminalModel model) {
|
||||
debugPrint('[FFI] Registering terminal model for terminal $terminalId');
|
||||
final existing = _terminalModels[terminalId];
|
||||
if (existing != null && !identical(existing, model)) {
|
||||
debugPrint(
|
||||
'[FFI] WARNING: terminal id $terminalId already registered to a different model; overwriting');
|
||||
}
|
||||
_terminalModels[terminalId] = model;
|
||||
}
|
||||
|
||||
void unregisterTerminalModel(int terminalId) {
|
||||
// Identity-checked so a late-disposing page can't remove a replacement model
|
||||
// that re-registered under the same id (which would silently drop its events).
|
||||
void unregisterTerminalModel(int terminalId, TerminalModel model) {
|
||||
debugPrint('[FFI] Unregistering terminal model for terminal $terminalId');
|
||||
_terminalModels.remove(terminalId);
|
||||
if (identical(_terminalModels[terminalId], model)) {
|
||||
_terminalModels.remove(terminalId);
|
||||
}
|
||||
}
|
||||
|
||||
void routeTerminalResponse(Map<String, dynamic> evt) {
|
||||
|
|
@ -3999,6 +4022,8 @@ class FFI {
|
|||
final model = _terminalModels[terminalId];
|
||||
if (model != null) {
|
||||
model.handleTerminalResponse(evt);
|
||||
} else {
|
||||
debugPrint('[FFI] No terminal model registered for terminal $terminalId; dropping event');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ class TerminalModel with ChangeNotifier {
|
|||
bool _suppressNextTerminalDataOutput = false;
|
||||
|
||||
void Function(int w, int h, int pw, int ph)? onResizeExternal;
|
||||
// Fired with the server's surviving persistent terminal session ids (those
|
||||
// not already open locally), so an embedder can offer to reattach to them.
|
||||
void Function(List<int> persistentSessions)? onPersistentSessions;
|
||||
// Optional transform applied to keyboard input from the terminal view before
|
||||
// it is sent — lets an embedder implement sticky modifiers (e.g. a Ctrl/Alt
|
||||
// accessory bar) that combine with the next system-keyboard key.
|
||||
String Function(String data)? inputTransform;
|
||||
|
||||
Future<void> _handleInput(String data) async {
|
||||
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
|
||||
|
|
@ -77,7 +84,8 @@ class TerminalModel with ChangeNotifier {
|
|||
// Setup terminal callbacks
|
||||
terminal.onOutput = (data) {
|
||||
if (_suppressTerminalOutput) return;
|
||||
_handleInput(data);
|
||||
final t = inputTransform;
|
||||
_handleInput(t != null ? t(data) : data);
|
||||
};
|
||||
|
||||
terminal.onResize = (w, h, pw, ph) async {
|
||||
|
|
@ -169,12 +177,17 @@ class TerminalModel with ChangeNotifier {
|
|||
}
|
||||
}
|
||||
|
||||
// NOTE: this bypasses terminal.onOutput, so [inputTransform] is NOT applied
|
||||
// here — callers (e.g. an accessory key bar) must apply their own modifiers
|
||||
// before calling, otherwise a sticky modifier would be applied twice.
|
||||
Future<void> sendVirtualKey(String data) async {
|
||||
return _handleInput(data);
|
||||
}
|
||||
|
||||
Future<void> closeTerminal() async {
|
||||
if (_terminalOpened) {
|
||||
Future<void> closeTerminal({bool force = false}) async {
|
||||
// force lets us reap a session that never finished opening (e.g. a hung
|
||||
// corpse restored from a previous run), which would otherwise be unclosable.
|
||||
if (_terminalOpened || force) {
|
||||
try {
|
||||
await bind
|
||||
.sessionCloseTerminal(
|
||||
|
|
@ -194,7 +207,8 @@ class TerminalModel with ChangeNotifier {
|
|||
// Continue with cleanup even if close fails
|
||||
}
|
||||
_terminalOpened = false;
|
||||
notifyListeners();
|
||||
// The widget may have been disposed during the await above.
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -248,6 +262,10 @@ class TerminalModel with ChangeNotifier {
|
|||
}
|
||||
|
||||
void handleTerminalResponse(Map<String, dynamic> evt) {
|
||||
// A terminal_response can still arrive (and be routed here) between this
|
||||
// model being disposed and its widget tearing down; ignore it so we never
|
||||
// notifyListeners() on a disposed ChangeNotifier.
|
||||
if (_disposed) return;
|
||||
final String? type = evt['type'];
|
||||
final int evtTerminalId = getTerminalIdFromEvt(evt);
|
||||
|
||||
|
|
@ -301,12 +319,13 @@ class TerminalModel with ChangeNotifier {
|
|||
_scheduleMarkViewReady();
|
||||
}
|
||||
|
||||
// Process any buffered input
|
||||
// Process any buffered input. These callbacks resolve on a later
|
||||
// microtask, by which point the model may have been disposed.
|
||||
_processBufferedInputAsync().then((_) {
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
}).catchError((e) {
|
||||
debugPrint('[TerminalModel] Error processing buffered input: $e');
|
||||
notifyListeners();
|
||||
if (!_disposed) notifyListeners();
|
||||
});
|
||||
|
||||
final persistentSessions =
|
||||
|
|
@ -314,6 +333,7 @@ class TerminalModel with ChangeNotifier {
|
|||
.whereType<int>()
|
||||
.where((id) => !parent.terminalModels.containsKey(id))
|
||||
.toList();
|
||||
onPersistentSessions?.call(persistentSessions);
|
||||
if (kWindowId != null && persistentSessions.isNotEmpty) {
|
||||
DesktopMultiWindow.invokeMethod(
|
||||
kWindowId!,
|
||||
|
|
@ -472,7 +492,12 @@ class TerminalModel with ChangeNotifier {
|
|||
final int exitCode = evt['exit_code'] ?? 0;
|
||||
_writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n');
|
||||
_terminalOpened = false;
|
||||
notifyListeners();
|
||||
// Surface the close to the UI (it shows a "Session closed / Restart"
|
||||
// banner), but do NOT reap the persistent session here: a `closed` can be
|
||||
// spurious/stale (e.g. a saturated output channel or a transient reconnect),
|
||||
// and reaping it would permanently destroy a session the user was still
|
||||
// using. The session is reaped only by an explicit tab close.
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
void _handleTerminalError(Map<String, dynamic> evt) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue