diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ef4b53..5b266a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,29 @@ +## 1.52.0 + +### Added + +- **The dashboard TUI can now manage git credentials.** From a node's detail screen + (Nodes → Enter), press **`c`** to open the node's git-credential view — the same + remote, caller-scoped operations as `omnyshell drive credential --node`: + - list your own credentials on that node (secrets masked), + - **`a`** add a credential via a form (host + PAT, or username/password), + - **`x`** remove the selected credential (with confirmation). + All three go through the existing `DashboardBackend` port (extended with + `listGitCredentials`/`addGitCredential`/`removeGitCredential`) over `ClientRuntime`, + so the TUI stays `dart:io`-free and testable. Credentials remain scoped to the + connected principal and never leave the node — the dashboard is HTTPS-only, matching + the remote CLI. + +### Fixed + +- **TUIs no longer freeze or crash on an error.** Both the dashboard and the IDE now + catch any exception thrown while handling a keypress and show it in the status bar + instead of hanging or exiting. Dashboard credential loads run in the background so the + UI stays responsive. And **all client Hub/node RPCs are now time-boxed** (list nodes/ + sessions/tunnels, peek/kill/detach, tunnel open/close, git credentials, AI proxy), so + a connected-but-silent peer — e.g. an offline or out-of-date node — surfaces an error + instead of leaving a view stuck (previously “Loading credentials…” could hang forever). + ## 1.51.0 ### Changed diff --git a/README.md b/README.md index 185cd80..4c633ae 100644 --- a/README.md +++ b/README.md @@ -574,6 +574,10 @@ or drive. A missing or wrong credential fails fast rather than hanging on an interactive prompt. (SSH keys with a passphrase require an ssh-agent, and can only be set with `--local` since the key path is node-side.) +The **dashboard TUI** exposes the same remote, caller-scoped management: open a node +(Nodes → Enter) and press `c` to list, `a` to add, or `x` to remove your own git +credentials on that node. + Restrict which sub-paths a directory mount serves with repeatable `--include` (whitelist) / `--exclude` (wins over include) globs — the filter is baked into the mount, so every sync keeps applying it. With **neither** flag given, the directory's diff --git a/bin/omnyshell.dart b/bin/omnyshell.dart index 26b5ccf..3216775 100644 --- a/bin/omnyshell.dart +++ b/bin/omnyshell.dart @@ -716,7 +716,8 @@ class DashboardCommand extends Command { @override String get description => - 'Open the full-screen TUI dashboard (nodes, sessions, tunnels, drive, AI).'; + 'Open the full-screen TUI dashboard (nodes, sessions, git credentials, ' + 'tunnels, drive, AI).'; @override String? get usageFooter => _usageExamples([ @@ -1271,6 +1272,60 @@ class _CliDashboardBackend implements DashboardBackend { } } + @override + Future> listGitCredentials(String nodeId) async { + final res = await _requireClient.driveCredentialList(nodeId: nodeId); + if (!res.ok) throw _CliError(res.message); + return res.entries; + } + + @override + Future addGitCredential( + String nodeId, { + required String host, + String? pat, + String? username, + String? password, + }) async { + final Map credential; + if (pat != null && pat.isNotEmpty) { + credential = GitPat( + token: pat, + username: (username == null || username.isEmpty) + ? 'x-access-token' + : username, + ).toJson(); + } else if (username != null && + username.isNotEmpty && + password != null && + password.isNotEmpty) { + credential = GitUserPass(username: username, password: password).toJson(); + } else { + return const DashboardActionResult( + ok: false, + message: 'Provide a PAT, or a username and password.', + ); + } + final res = await _requireClient.driveCredentialAdd( + nodeId: nodeId, + host: host, + credential: credential, + ); + return DashboardActionResult(ok: res.ok, message: res.message); + } + + @override + Future removeGitCredential( + String nodeId, { + required String host, + }) async { + final res = await _requireClient.driveCredentialRemove( + nodeId: nodeId, + host: host, + ); + return DashboardActionResult(ok: res.ok, message: res.message); + } + /// Maps the port's [DriveSyncDirection] onto OmnyDrive's [SyncDirection] /// (`auto` = null = reconcile both sides). SyncDirection? _syncDirection(DriveSyncDirection d) => switch (d) { diff --git a/lib/src/application/client/client_runtime.dart b/lib/src/application/client/client_runtime.dart index f16145a..ba230d1 100644 --- a/lib/src/application/client/client_runtime.dart +++ b/lib/src/application/client/client_runtime.dart @@ -460,6 +460,25 @@ class ClientRuntime { _connection?.send(ControlFrame(auth)); } + /// Wraps a pending-RPC [future] with a timeout so a peer that never replies + /// surfaces a [TimeoutException] instead of hanging the caller (notably a TUI + /// input loop). [cleanup] drops the orphaned pending entry; [what] names the + /// peer for the message. A dropped connection still fails pending RPCs + /// immediately via the disconnect handler — this guards the "connected but + /// silent" case (e.g. an offline or out-of-date node). + Future _rpcTimeout( + Future future, + void Function() cleanup, { + String what = 'the Hub', + Duration timeout = const Duration(seconds: 20), + }) => future.timeout( + timeout, + onTimeout: () { + cleanup(); + throw TimeoutException('No response from $what'); + }, + ); + /// Lists the nodes visible to this client, with an optional label [filter]. Future> listNodes({ Map filter = const {}, @@ -468,7 +487,10 @@ class ClientRuntime { final completer = Completer>(); _pendingNodeLists.add(completer); _connection!.send(ControlFrame(NodeListRequest(filter: filter))); - return completer.future; + return _rpcTimeout( + completer.future, + () => _pendingNodeLists.remove(completer), + ); } /// Measures round-trip latency to the Hub. @@ -478,7 +500,7 @@ class ClientRuntime { final completer = Completer(); _pendingPings[id] = completer; _connection!.send(ControlFrame(Ping(id: id, ts: config.clock.now()))); - return completer.future; + return _rpcTimeout(completer.future, () => _pendingPings.remove(id)); } /// Opens a session on [nodeId]. @@ -550,7 +572,11 @@ class ClientRuntime { _connection!.send( ControlFrame(DetachedSessionsRequest(requestId: id, nodeId: nodeId)), ); - return completer.future; + return _rpcTimeout( + completer.future, + () => _pendingSessionLists.remove(id), + what: 'node "$nodeId"', + ); } /// Lists only the caller's *detached* sessions on [nodeId]. @@ -569,7 +595,7 @@ class ClientRuntime { final completer = Completer(); _pendingAiConfigs[id] = completer; _connection!.send(ControlFrame(AiConfigRequest(requestId: id))); - return completer.future; + return _rpcTimeout(completer.future, () => _pendingAiConfigs.remove(id)); } /// Asks the Hub to perform an outbound AI HTTPS request on the caller's behalf @@ -605,7 +631,12 @@ class ClientRuntime { ), ), ); - return completer.future; + // Longer bound: this proxies an outbound AI/LLM call which can be slow. + return _rpcTimeout( + completer.future, + () => _pendingHttpProxies.remove(id), + timeout: const Duration(seconds: 120), + ); } /// Fetches the current screen snapshot of one of the caller's sessions on @@ -630,7 +661,11 @@ class ClientRuntime { ), ), ); - return completer.future; + return _rpcTimeout( + completer.future, + () => _pendingSessionScreens.remove(id), + what: 'node "$nodeId"', + ); } /// Adds/overwrites the caller's own git credential for [host] on [nodeId]. @@ -677,7 +712,11 @@ class ClientRuntime { ), ), ); - return completer.future; + return _rpcTimeout( + completer.future, + () => _pendingDriveCredentials.remove(id), + what: 'node "$nodeId" for git-credential $op', + ); } /// Detaches one of the caller's *active* sessions on [nodeId] from this @@ -704,7 +743,11 @@ class ClientRuntime { ), ), ); - return completer.future; + return _rpcTimeout( + completer.future, + () => _pendingActiveDetach.remove(id), + what: 'node "$nodeId"', + ); } /// Terminates one of the caller's sessions on [nodeId] — **running** (attached) @@ -728,7 +771,11 @@ class ClientRuntime { ), ), ); - return completer.future; + return _rpcTimeout( + completer.future, + () => _pendingSessionKills.remove(id), + what: 'node "$nodeId"', + ); } /// Deprecated alias for [killSession] (which now also terminates running @@ -932,7 +979,12 @@ class ClientRuntime { ), ), ); - return completer.future; + return _rpcTimeout( + completer.future, + () => _pendingTunnelOpens.remove(id), + what: 'node "$effectiveNode"', + timeout: const Duration(seconds: 30), + ); } /// Lists the caller's active tunnels held by the Hub. @@ -942,7 +994,7 @@ class ClientRuntime { final completer = Completer>(); _pendingTunnelLists[id] = completer; _connection!.send(ControlFrame(TunnelListRequest(requestId: id))); - return completer.future; + return _rpcTimeout(completer.future, () => _pendingTunnelLists.remove(id)); } /// Closes the caller's tunnel [tunnelRef] (a full id or unambiguous prefix). @@ -954,7 +1006,7 @@ class ClientRuntime { _connection!.send( ControlFrame(TunnelCloseRequest(requestId: id, tunnelRef: tunnelRef)), ); - return completer.future; + return _rpcTimeout(completer.future, () => _pendingTunnelCloses.remove(id)); } /// Serves a Hub-forwarded tunnel connection against this client's own machine diff --git a/lib/src/application/client/dashboard/dashboard_app.dart b/lib/src/application/client/dashboard/dashboard_app.dart index 738228b..efc3634 100644 --- a/lib/src/application/client/dashboard/dashboard_app.dart +++ b/lib/src/application/client/dashboard/dashboard_app.dart @@ -9,6 +9,7 @@ import '../../ai/agent_mode.dart'; import '../../ai/ai_config.dart' show AiProviderKind; import '../../ai/ai_config_io.dart' show AiConfigDescription; import '../../../shared/utils/progress_bar.dart' show formatFileDiff; +import '../../../protocol/control_message.dart' show DriveCredentialEntry; import '../drive/drive_manager.dart' show SyncOutcome, DriveChanges; import '../drive/mount_store.dart' show MountRecord; import '../ide/tui/geometry.dart'; @@ -24,7 +25,16 @@ import 'dashboard_backend.dart'; /// Which screen the dashboard is currently showing. `nodes`, `tunnels`, `drive` /// and `ai` are the four top-level tabs; `nodeDetail`/`driveDetail` are /// sub-screens reached from their tab. -enum _Screen { login, nodes, nodeDetail, tunnels, drive, driveDetail, ai } +enum _Screen { + login, + nodes, + nodeDetail, + nodeCredentials, + tunnels, + drive, + driveDetail, + ai, +} /// A pending yes/no confirmation (the terminate-session prompt). While active it /// captures input: `y`/Enter runs [onYes], `n` dismisses it. @@ -162,6 +172,11 @@ class DashboardApp { NodeDescriptor? _currentNode; List _sessions = const []; int _sessionSel = 0; + + // The caller's own git credentials on [_currentNode] (node-credentials screen). + List _credentials = const []; + int _credSel = 0; + int _credScroll = 0; int _sessionScroll = 0; // Tunnels screen state. @@ -216,7 +231,13 @@ class DashboardApp { final sub = _inputSub; sub?.pause(); for (final key in decoder.decode(bytes)) { - await _handleKey(key); + // Never let an unexpected error from a key handler crash or freeze the + // TUI: surface it in the status bar and keep the loop alive. + try { + await _handleKey(key); + } on Object catch (e) { + _setError('Unexpected error', e); + } if (_done.isCompleted) break; } if (!_done.isCompleted) _render(); @@ -310,6 +331,8 @@ class DashboardApp { await _handleNodesKey(key); case _Screen.nodeDetail: await _handleNodeDetailKey(key); + case _Screen.nodeCredentials: + await _handleNodeCredentialsKey(key); case _Screen.tunnels: await _handleTunnelsKey(key); case _Screen.drive: @@ -340,6 +363,7 @@ class DashboardApp { /// The tab currently active (detail sub-screens map to their parent tab). _Screen get _activeTab => switch (_screenId) { _Screen.nodeDetail => _Screen.nodes, + _Screen.nodeCredentials => _Screen.nodes, _Screen.driveDetail => _Screen.drive, final s => s, }; @@ -768,6 +792,8 @@ class DashboardApp { await _detachSelected(); case 'k': _confirmKillSelected(); + case 'c': + await _openNodeCredentials(); } default: break; @@ -904,6 +930,159 @@ class DashboardApp { ); } + // ---- Node credentials screen --------------------------------------------- + + /// Opens the caller's git-credential view for the current node. + Future _openNodeCredentials() async { + if (_currentNode == null) return; + _screenId = _Screen.nodeCredentials; + _credSel = 0; + _credScroll = 0; + _credentials = const []; + _refreshCredentials(); + } + + /// Loads the credentials in the background so the input loop stays responsive + /// even if the node is slow or unreachable (the RPC is time-boxed); re-renders + /// when it settles. + void _refreshCredentials() { + _setBusy('Loading credentials…'); + unawaited( + _reloadCredentials().whenComplete(() { + if (!_done.isCompleted) _render(); + }), + ); + } + + Future _reloadCredentials() async { + final node = _currentNode; + if (node == null) return; + _loading = true; + try { + _credentials = await _backend.listGitCredentials(node.id.value); + if (_credSel > _maxIndex(_credentials.length)) { + _credSel = _maxIndex(_credentials.length); + } + } on Object catch (e) { + if (_credentials.isEmpty) { + _setError('Failed to list credentials', e); + } else { + _setError('Showing last results — refresh failed', e); + } + } finally { + _loading = false; + } + } + + Future _handleNodeCredentialsKey(KeyEvent key) async { + switch (key.type) { + case KeyType.up: + _credSel = (_credSel - 1).clamp(0, _maxIndex(_credentials.length)); + case KeyType.down: + _credSel = (_credSel + 1).clamp(0, _maxIndex(_credentials.length)); + case KeyType.pageUp: + _credSel = (_credSel - 10).clamp(0, _maxIndex(_credentials.length)); + case KeyType.pageDown: + _credSel = (_credSel + 10).clamp(0, _maxIndex(_credentials.length)); + case KeyType.home: + _credSel = 0; + case KeyType.end: + _credSel = _maxIndex(_credentials.length); + case KeyType.escape: + case KeyType.left: + _screenId = _Screen.nodeDetail; + case KeyType.char: + switch (key.text) { + case 'a': + case 'n': + _openCredentialForm(); + case 'x': + case 'd': + _confirmRemoveCredential(); + case 'r': + _refreshCredentials(); + } + default: + break; + } + } + + DriveCredentialEntry? get _selectedCredential { + if (_credentials.isEmpty) return null; + return _credentials[_credSel.clamp(0, _maxIndex(_credentials.length))]; + } + + void _openCredentialForm() { + final node = _currentNode; + if (node == null) return; + _form = _Form( + title: 'Add git credential on ${node.id.value}', + hint: 'Tab move · Space toggle · Enter submit · Esc cancel', + fields: [ + _Field.text('Host (e.g. github.com)'), + _Field.toggle('Use username + password'), + _Field.secret('PAT / password'), + _Field.text('Username (for user/pass, or PAT user)'), + ], + onSubmit: (f) async { + final host = f[0].value.trim(); + final userpass = f[1].isOn; + final secret = f[2].value; + final user = f[3].value.trim(); + if (host.isEmpty) { + _setMessage('Host is required.', isError: true); + return false; + } + if (secret.isEmpty) { + _setMessage('A PAT or password is required.', isError: true); + return false; + } + if (userpass && user.isEmpty) { + _setMessage('Username is required for user/password.', isError: true); + return false; + } + _setBusy('Storing credential…'); + try { + final r = await _backend.addGitCredential( + node.id.value, + host: host, + pat: userpass ? null : secret, + username: user.isEmpty ? null : user, + password: userpass ? secret : null, + ); + _setMessage(r.message, isError: !r.ok); + } on Object catch (e) { + _setError('Add credential failed', e); + } + await _reloadCredentials(); + return true; + }, + ); + } + + void _confirmRemoveCredential() { + final entry = _selectedCredential; + final node = _currentNode; + if (entry == null || node == null) return; + _confirm = _Confirm( + title: 'Remove credential?', + hint: '${entry.host} on ${node.id.value} · y = remove · n = cancel', + onYes: () async { + _setBusy('Removing credential…'); + try { + final r = await _backend.removeGitCredential( + node.id.value, + host: entry.host, + ); + _setMessage(r.message, isError: !r.ok); + } on Object catch (e) { + _setError('Remove credential failed', e); + } + await _reloadCredentials(); + }, + ); + } + // ---- Tunnels screen ------------------------------------------------------ Future _handleTunnelsKey(KeyEvent key) async { @@ -1532,6 +1711,15 @@ class DashboardApp { ('p', 'peek'), ('d', 'detach'), ('k', 'kill'), + ('c', 'creds'), + ('r', 'refresh'), + ('Esc', 'back'), + ]); + case _Screen.nodeCredentials: + _renderNodeCredentials(s, body); + _renderHints(s, hintRect, const [ + ('a', 'add'), + ('x', 'remove'), ('r', 'refresh'), ('Esc', 'back'), ]); @@ -2092,6 +2280,48 @@ class DashboardApp { // ---- Tunnels / Drive / AI rendering -------------------------------------- + void _renderNodeCredentials(ScreenBuffer s, Rect area) { + if (area.isEmpty) return; + final (header, listRect) = area.splitTop(1); + _bar( + s, + header, + ' ${_pad('HOST', 30)} CREDENTIAL (yours, masked)', + Palette.tabInactive.copyWith(bold: true), + ); + _credScroll = _ensureVisible( + _credSel, + _credScroll, + listRect.height, + _credentials.length, + ); + if (_credentials.isEmpty) { + s.drawText( + listRect.left + 1, + listRect.top, + 'No credentials for you on this node. Press a to add, r to refresh.', + Palette.treeFile.copyWith(fg: const Color.indexed(245)), + ); + return; + } + for (var i = 0; i < listRect.height; i++) { + final idx = _credScroll + i; + if (idx >= _credentials.length) break; + final e = _credentials[idx]; + final y = listRect.top + i; + final selected = idx == _credSel; + final style = selected ? _rowSelected : _rowNormal; + s.fillRect(listRect.left, y, listRect.width, 1, ' ', style); + s.drawText( + listRect.left + 1, + y, + '${_pad(e.host, 30)} ${e.description}', + style, + maxWidth: listRect.width - 2, + ); + } + } + void _renderTunnels(ScreenBuffer s, Rect area) { if (area.isEmpty) return; final (header, listRect) = area.splitTop(1); @@ -2320,6 +2550,9 @@ class DashboardApp { _Screen.nodes => who, _Screen.nodeDetail => '${_currentNode?.id.value ?? '?'} · ${_sessions.length} session(s)', + _Screen.nodeCredentials => + 'Git credentials on ${_currentNode?.id.value ?? '?'} · ' + '${_credentials.length} entr${_credentials.length == 1 ? 'y' : 'ies'}', _Screen.tunnels => '$who · ${_tunnels.length} tunnel(s)', _Screen.drive => '$who · ${_mounts.length} mount(s)', _Screen.driveDetail => 'Mount ${_currentMount?.id ?? '?'}', diff --git a/lib/src/application/client/dashboard/dashboard_backend.dart b/lib/src/application/client/dashboard/dashboard_backend.dart index d2271c2..cc92b16 100644 --- a/lib/src/application/client/dashboard/dashboard_backend.dart +++ b/lib/src/application/client/dashboard/dashboard_backend.dart @@ -2,6 +2,7 @@ import '../../../domain/auth/principal.dart'; import '../../../domain/entities/detached_session_info.dart'; import '../../../domain/entities/node_descriptor.dart'; import '../../../domain/entities/tunnel_info.dart'; +import '../../../protocol/control_message.dart' show DriveCredentialEntry; import '../../ai/agent_mode.dart'; import '../../ai/ai_config.dart'; import '../../ai/ai_config_io.dart'; @@ -263,6 +264,30 @@ abstract interface class DashboardBackend { /// the terminal — the app calls this with the terminal released. Future watchMount(String mountId); + // ---- Drive credentials (mirrors `omnyshell drive credential --node`) ------ + // + // Always scoped to the connected (hub-authenticated) principal: the node + // stores/returns only the caller's own credentials. HTTPS only. + + /// Lists the caller's own git credentials on [nodeId] (secrets masked). + Future> listGitCredentials(String nodeId); + + /// Adds/overwrites the caller's own git credential for [host] on [nodeId]. + /// Provide either [pat] (HTTPS token) or [username]+[password]. + Future addGitCredential( + String nodeId, { + required String host, + String? pat, + String? username, + String? password, + }); + + /// Removes the caller's own git credential for [host] on [nodeId]. + Future removeGitCredential( + String nodeId, { + required String host, + }); + // ---- AI (mirrors `omnyshell ai`) ----------------------------------------- /// Reads the resolved AI configuration (key masked / status only). diff --git a/lib/src/application/client/ide/ide_app.dart b/lib/src/application/client/ide/ide_app.dart index 1fcb637..c216cff 100644 --- a/lib/src/application/client/ide/ide_app.dart +++ b/lib/src/application/client/ide/ide_app.dart @@ -195,7 +195,13 @@ class IdeApp { (bytes) async { inputSub!.pause(); for (final key in decoder.decode(bytes)) { - await _handleKey(key); + // Never let an unexpected error from a key handler crash or freeze + // the TUI: surface it in the status bar and keep the loop alive. + try { + await _handleKey(key); + } on Object catch (e) { + _setMessage('Unexpected error: $e', isError: true); + } if (_done.isCompleted) break; } if (!_done.isCompleted) _render(); diff --git a/lib/src/version.dart b/lib/src/version.dart index d502126..81afd38 100644 --- a/lib/src/version.dart +++ b/lib/src/version.dart @@ -3,4 +3,4 @@ /// This is the single source of truth for "what build is this": it is rendered /// in the CLI banner and is the default a node reports as its /// [NodeConfig.agentVersion] / [PlatformInfo.agentVersion]. -const String omnyShellVersion = '1.51.0'; +const String omnyShellVersion = '1.52.0'; diff --git a/pubspec.yaml b/pubspec.yaml index 1a4db74..fd5f8f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: >- connect to a Hub by node identity (not host:port); the Hub authenticates, authorizes and brokers encrypted sessions to Nodes over WebSocket-on-TLS. Ships Hub, Node, Client and CLI implementations behind first-class Dart APIs. -version: 1.51.0 +version: 1.52.0 repository: https://github.com/OmnyGrid/omnyshell environment: diff --git a/test/unit/dashboard/dashboard_app_test.dart b/test/unit/dashboard/dashboard_app_test.dart index d16ae17..8375575 100644 --- a/test/unit/dashboard/dashboard_app_test.dart +++ b/test/unit/dashboard/dashboard_app_test.dart @@ -16,6 +16,8 @@ import 'package:omnyshell/src/application/client/drive/mount_store.dart' show MountRecord; import 'package:omnyshell/src/application/client/ide/tui/screen_buffer.dart'; import 'package:omnyshell/src/application/client/ide/tui/terminal_driver.dart'; +import 'package:omnyshell/src/protocol/control_message.dart' + show DriveCredentialEntry; import 'package:omnyshell/src/domain/auth/principal.dart'; import 'package:omnyshell/src/domain/entities/detached_session_info.dart'; import 'package:omnyshell/src/domain/entities/node_descriptor.dart'; @@ -311,6 +313,46 @@ class FakeDashboardBackend implements DashboardBackend { calls.add('watchMount:$mountId'); } + // ---- Drive credentials --------------------------------------------------- + + List gitCredentials = const []; + + /// When true, [listGitCredentials] throws (to exercise TUI error handling). + bool failCredentials = false; + + @override + Future> listGitCredentials(String nodeId) async { + calls.add('listGitCredentials:$nodeId'); + if (failCredentials) throw StateError('boom: node unreachable'); + return gitCredentials; + } + + @override + Future addGitCredential( + String nodeId, { + required String host, + String? pat, + String? username, + String? password, + }) async { + calls.add( + 'addGitCredential:$nodeId:$host:${pat != null ? 'pat' : 'userpass'}', + ); + return const DashboardActionResult(ok: true, message: 'Stored credential.'); + } + + @override + Future removeGitCredential( + String nodeId, { + required String host, + }) async { + calls.add('removeGitCredential:$nodeId:$host'); + return const DashboardActionResult( + ok: true, + message: 'Removed credential.', + ); + } + // ---- AI ------------------------------------------------------------------ @override @@ -830,6 +872,118 @@ void main() { await running; }); + test( + 'node credentials: c opens the view and add dispatches addGitCredential', + () async { + final term = FakeTerminal(); + final backend = connectedBackend() + ..gitCredentials = const [ + DriveCredentialEntry( + host: 'github.com', + description: 'GitPat(username: x-access-token, token: ***)', + ), + ]; + final running = _app(term, backend).run(); + await pump(); + term.send(enter); // connect + await pump(); + term.send(enter); // open node web-01 + await pump(); + term.send('c'.codeUnits); // open credentials view + await pump(); + + expect(backend.calls, contains('listGitCredentials:web-01')); + final text = frameText(term.lastFrame); + expect(text, contains('CREDENTIAL (yours, masked)')); + expect(text, contains('github.com')); + expect(text, contains('***')); // masked + + // Add a PAT credential via the modal form. + term.send('a'.codeUnits); + await pump(); + expect(frameText(term.lastFrame), contains('Add git credential')); + term.send('gitlab.com'.codeUnits); // host (field 0) + await pump(); + term.send(enter); // -> user/pass toggle (leave off = PAT) + await pump(); + term.send(enter); // -> PAT / password (secret) + await pump(); + term.send('ghp_secret'.codeUnits); + await pump(); + term.send(enter); // -> username (leave empty) + await pump(); + term.send(enter); // -> submit row + await pump(); + term.send(enter); // submit + await pump(); + + expect(backend.calls, contains('addGitCredential:web-01:gitlab.com:pat')); + + term.send(ctrlQ); + await running; + }, + ); + + test( + 'node credentials: a backend error is shown and the TUI stays interactive', + () async { + final term = FakeTerminal(); + final backend = connectedBackend()..failCredentials = true; + final running = _app(term, backend).run(); + await pump(); + term.send(enter); // connect + await pump(); + term.send(enter); // open node web-01 + await pump(); + term.send('c'.codeUnits); // open credentials -> load throws + await pump(); + + expect(backend.calls, contains('listGitCredentials:web-01')); + // The error is surfaced on screen rather than freezing on "Loading…". + expect(frameText(term.lastFrame).toLowerCase(), contains('failed')); + + // Still interactive: Left/Esc returns to the node-detail screen. + term.send(left); + await pump(); + expect(frameText(term.lastFrame), contains('Node web-01')); + + term.send(ctrlQ); + await running; + }, + ); + + test( + 'node credentials: remove asks to confirm then dispatches removeGitCredential', + () async { + final term = FakeTerminal(); + final backend = connectedBackend() + ..gitCredentials = const [ + DriveCredentialEntry( + host: 'github.com', + description: 'GitPat(username: x-access-token, token: ***)', + ), + ]; + final running = _app(term, backend).run(); + await pump(); + term.send(enter); // connect + await pump(); + term.send(enter); // open node + await pump(); + term.send('c'.codeUnits); // credentials view + await pump(); + + term.send('x'.codeUnits); // remove selected + await pump(); + expect(frameText(term.lastFrame), contains('Remove credential?')); + term.send('y'.codeUnits); // confirm + await pump(); + expect(backend.calls, contains('removeGitCredential:web-01:github.com')); + + term.send(ctrlQ); + await running; + }, + ); + test('tunnel close asks to confirm then dispatches closeTunnel', () async { final term = FakeTerminal(); final backend = connectedBackend()..tunnels = [tunnelInfo('t1')];