diff --git a/AGENTS.md b/AGENTS.md index 6c2b914..87020ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,8 +138,10 @@ lib/ ## Learned Workspace Facts - Home navigation uses `AppTab` (Client, Agents, Logs) with an `IndexedStack`. -- OpenCode integrates through `opencode_api` and `OpenCodeConnectionService`; sessions are scoped by a `directory` query param; `getConfig`/`updateConfig` exposed for server-side config. -- `SSHProfile` includes `agentPort` (default 5000) and `useHttps`; `agentBaseUrl` builds the OpenCode URL. +- OpenCode V2 is talked to by `OpenCodeConnectionService` over Dio (`/api/info`, `/api/session`, `/api/event`, etc.) with HTTP Basic auth; responses are mapped into `opencode_api` V1 UI models. Do not call legacy V1 routes like `/global/health` (they can return SPA HTML 200). +- Sessions are scoped by a `directory` query/`location.directory`; `getConfig` reads `/api/config` documents; arbitrary config PATCH is not available on V2 (experimental shell patch only). Insecure TLS is opt-in via `allowSelfSignedCerts` (never implied by HTTPS alone) and is limited to the profile host. +- `SSHProfile` includes `agentPort` (default 5000), `useHttps`, `allowSelfSignedCerts`, `agentUsername` (default `opencode`), and `agentPassword` (secure storage); `agentBaseUrl` builds the OpenCode URL. Agent HTTP Basic auth uses `effectiveAgentUsername` / `effectiveAgentPassword` (falls back to SSH password when agent password is unset). +- Client terminal AI command generation lives in the accessory bar wand + top `AiCommandOverlay` (not the shortcut bar). - `AgentProvider` is a `ChangeNotifierProxyProvider` that routes `onLog` to `SSHProvider.addLog`. - Agent directory path persists in ConfigService as `agent_last_directory`. - Android widgets (`SshQuickConnectWidget`, `AgentQuickConnectWidget`) sync profiles via `WidgetProfileService` and deep-link via `sshapp://widget/ssh|agent?profileId=` through `WidgetLaunchHandler`. @@ -148,7 +150,7 @@ lib/ - Agents tab uses master-detail navigation on all screen sizes with `PopScope` and Android predictive back. - `AppLifecycleService` and `ConnectionForegroundService` keep SSH/agent sessions alive when Android is backgrounded. - Agent chat groups consecutive same-role messages via `groupAgentMessages` in `lib/utils/agent_message_grouping.dart`; rendering uses `AgentMessageBubble` and `AgentMessagePartTile`. -- `OpenCodeRemoteConfigService` imports Windows host config over SSH from `%USERPROFILE%\.config\opencode\`; Android build uses AGP 9.0.1 with vendored `packages/home_widget`. +- `OpenCodeRemoteConfigService` imports Windows host config over SSH from `%USERPROFILE%\.config\opencode\`; Android build uses AGP 9.3.1 / Kotlin 2.4.20 with vendored `packages/home_widget`. ## Cursor Cloud specific instructions diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index b6458bf..156ddfa 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -19,8 +19,8 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "9.0.1" apply false - id("org.jetbrains.kotlin.android") version "2.3.20" apply false + id("com.android.application") version "9.3.1" apply false + id("org.jetbrains.kotlin.android") version "2.4.20" apply false id("org.gradle.toolchains.foojay-resolver-convention") version "0.10.0" } diff --git a/lib/main.dart b/lib/main.dart index c173aad..0c95945 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import 'constants/app_metadata.dart'; @@ -106,6 +106,14 @@ class MyApp extends StatelessWidget { darkTheme: settings.appTheme == AppTheme.hacker ? hackerTheme : darkTheme, + builder: (context, child) { + // Bridge ThemeData/MaterialLocalizations for packages that still + // import package:flutter/material.dart (xterm, share_plus, etc.). + // ignore: deprecated_member_use + return MaterialUiCompatibilityBridge( + child: child ?? const SizedBox.shrink(), + ); + }, home: const SplashScreen(), ), ); diff --git a/lib/models/home_toolbar_action.dart b/lib/models/home_toolbar_action.dart index accba75..94e7a16 100644 --- a/lib/models/home_toolbar_action.dart +++ b/lib/models/home_toolbar_action.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// Actions that can appear in the home AppBar or its overflow menu. enum HomeToolbarAction { diff --git a/lib/models/ssh_profile.dart b/lib/models/ssh_profile.dart index 4764d16..ed3676d 100644 --- a/lib/models/ssh_profile.dart +++ b/lib/models/ssh_profile.dart @@ -14,6 +14,12 @@ class SSHProfile { final String? startupCommand; final int agentPort; final bool useHttps; + + /// When true with [useHttps], agent TLS accepts self-signed certificates. + /// Off by default — must be opted in per profile (LAN OpenCode). + final bool allowSelfSignedCerts; + final String agentUsername; + final String? agentPassword; final SessionManager sessionManager; SSHProfile({ @@ -28,12 +34,28 @@ class SSHProfile { this.startupCommand, this.agentPort = 5000, this.useHttps = false, + this.allowSelfSignedCerts = false, + this.agentUsername = 'opencode', + this.agentPassword, this.sessionManager = SessionManager.none, }) : id = id ?? const Uuid().v4(); String get agentBaseUrl => '${useHttps ? 'https' : 'http'}://$host:$agentPort'; + /// HTTP Basic username for OpenCode serve (never empty). + String get effectiveAgentUsername => + agentUsername.isNotEmpty ? agentUsername : 'opencode'; + + /// OpenCode serve password, falling back to SSH password when unset. + String get effectiveAgentPassword { + final agent = agentPassword; + if (agent != null && agent.isNotEmpty) { + return agent; + } + return password ?? ''; + } + SSHProfile copyWith({ String? id, String? name, @@ -46,6 +68,9 @@ class SSHProfile { String? startupCommand, int? agentPort, bool? useHttps, + bool? allowSelfSignedCerts, + String? agentUsername, + String? agentPassword, SessionManager? sessionManager, }) { return SSHProfile( @@ -60,6 +85,9 @@ class SSHProfile { startupCommand: startupCommand ?? this.startupCommand, agentPort: agentPort ?? this.agentPort, useHttps: useHttps ?? this.useHttps, + allowSelfSignedCerts: allowSelfSignedCerts ?? this.allowSelfSignedCerts, + agentUsername: agentUsername ?? this.agentUsername, + agentPassword: agentPassword ?? this.agentPassword, sessionManager: sessionManager ?? this.sessionManager, ); } @@ -78,6 +106,9 @@ class SSHProfile { 'startupCommand': startupCommand, 'agentPort': agentPort, 'useHttps': useHttps, + 'allowSelfSignedCerts': allowSelfSignedCerts, + 'agentUsername': agentUsername, + if (includePassword) 'agentPassword': agentPassword, 'sessionManager': sessionManager.name, }; } @@ -95,6 +126,9 @@ class SSHProfile { startupCommand: json['startupCommand'] as String?, agentPort: json['agentPort'] as int? ?? 5000, useHttps: json['useHttps'] as bool? ?? false, + allowSelfSignedCerts: json['allowSelfSignedCerts'] as bool? ?? false, + agentUsername: json['agentUsername'] as String? ?? 'opencode', + agentPassword: json['agentPassword'] as String?, sessionManager: SessionManagerX.fromStorage( json['sessionManager'] as String?, ), diff --git a/lib/providers/agent_provider.dart b/lib/providers/agent_provider.dart index 58716a7..9dfc989 100644 --- a/lib/providers/agent_provider.dart +++ b/lib/providers/agent_provider.dart @@ -47,23 +47,29 @@ class AgentProvider extends ChangeNotifier { } Future connectFromProfile(SSHProfile profile) async { - final username = - profile.username.isNotEmpty ? profile.username : 'opencode'; final service = OpenCodeConnectionService( baseUrl: profile.agentBaseUrl, - username: username, - password: profile.password ?? '', + username: profile.effectiveAgentUsername, + password: profile.effectiveAgentPassword, + allowInsecureTls: profile.useHttps && profile.allowSelfSignedCerts, ); try { await service.connect(); - final sessions = sortSessionsByUpdatedDesc(await service.getSessions()); + final savedDirectory = await _config.getAgentLastDirectory(); + final directory = (savedDirectory != null && savedDirectory.isNotEmpty) + ? savedDirectory + : null; + final sessions = sortSessionsByUpdatedDesc( + await service.getSessions(directory: directory), + ); final connection = AgentConnection( id: const Uuid().v4(), profile: profile, service: service, sessions: sessions, isConnected: true, + selectedDirectory: directory, ); _wireConnection(connection); @@ -92,8 +98,9 @@ class AgentProvider extends ChangeNotifier { final profile = SSHProfile( name: name, host: Uri.parse(url).host, - username: username, - password: password, + username: 'unused', + agentUsername: username, + agentPassword: password, agentPort: Uri.parse(url).port == 0 ? 5000 : Uri.parse(url).port, useHttps: url.startsWith('https'), ); @@ -102,21 +109,23 @@ class AgentProvider extends ChangeNotifier { Future connectToLocalDesktop({ required int port, + String username = 'opencode', String password = '', }) async { final profile = SSHProfile( name: 'Local Desktop', host: '127.0.0.1', - username: 'opencode', - password: password, + username: 'unused', + agentUsername: username, + agentPassword: password, agentPort: port, ); - const username = 'opencode'; final service = OpenCodeConnectionService( baseUrl: profile.agentBaseUrl, - username: username, - password: password, + username: profile.effectiveAgentUsername, + password: profile.effectiveAgentPassword, + allowInsecureTls: profile.useHttps && profile.allowSelfSignedCerts, ); try { @@ -504,15 +513,25 @@ class AgentProvider extends ChangeNotifier { return; } - if (type == 'permission.asked') { + if (type == 'permission.asked' || + (type.contains('permission') && + !type.contains('reply') && + !type.contains('list') && + !type.contains('saved'))) { final connection = _findConnection(connectionId); if (connection == null) return; + final permissionId = + props['permissionID'] as String? ?? props['id'] as String? ?? ''; + if (permissionId.isEmpty) return; _pendingPermission = AgentPermissionRequest( connectionId: connectionId, - sessionId: props['sessionID'] as String? ?? '', - permissionId: props['permissionID'] as String? ?? '', + sessionId: props['sessionID'] as String? ?? + props['sessionId'] as String? ?? + '', + permissionId: permissionId, message: props['message'] as String? ?? props['description'] as String? ?? + props['action'] as String? ?? 'Permission requested', service: connection.service, ); @@ -525,7 +544,12 @@ class AgentProvider extends ChangeNotifier { type.contains('message')) { // ignore: unawaited_futures _reloadMessages(connectionId); - if (type.startsWith('session.')) { + if (type.startsWith('session.') && + (type.contains('create') || + type.contains('update') || + type.contains('remove') || + type.contains('idle') || + type.endsWith('.updated'))) { // ignore: unawaited_futures refreshSessions(connectionId); } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 62cfb83..9c6a6c1 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../models/ai_provider.dart'; import '../models/home_toolbar_action.dart'; @@ -45,6 +45,7 @@ class SettingsProvider extends ChangeNotifier { List _shortcuts = []; bool _isLoaded = false; double _terminalFontSize = 12.0; + bool _terminalPinchToZoom = false; bool _showMobileShortcutBar = false; int _defaultAgentPort = 5000; TerminalFontFamily _terminalFontFamily = TerminalFontFamily.monospace; @@ -70,7 +71,11 @@ class SettingsProvider extends ChangeNotifier { List get shortcuts => _shortcuts; bool get isLoaded => _isLoaded; double get terminalFontSize => _terminalFontSize; + bool get terminalPinchToZoom => _terminalPinchToZoom; bool get showMobileShortcutBar => _showMobileShortcutBar; + + static const double kTerminalFontSizeMin = 6; + static const double kTerminalFontSizeMax = 24; int get defaultAgentPort => _defaultAgentPort; TerminalFontFamily get terminalFontFamily => _terminalFontFamily; TerminalFontWeight get terminalFontWeight => _terminalFontWeight; @@ -136,19 +141,33 @@ class SettingsProvider extends ChangeNotifier { final shortcutsData = settings['shortcuts'] as List?; if (shortcutsData != null && shortcutsData.isNotEmpty) { - _shortcuts = shortcutsData - .map((e) => - KeyboardShortcut.fromJson(Map.from(e as Map))) + final loaded = shortcutsData + .map((e) => KeyboardShortcut.fromJson( + Map.from(e as Map), + )) .toList(); + _shortcuts = _dedupeShortcuts(loaded); + if (_shortcuts.length != loaded.length) { + // Persist cleanup so duplicate chips do not return next launch. + await _saveSetting( + 'shortcuts', + _shortcuts.map((s) => s.toJson()).toList(), + ); + } } else { _shortcuts = KeyboardShortcut.defaults; } final fontSizeVal = settings['terminalFontSize']; if (fontSizeVal != null) { - _terminalFontSize = (fontSizeVal as num).toDouble(); + _terminalFontSize = (fontSizeVal as num).toDouble().clamp( + kTerminalFontSizeMin, + kTerminalFontSizeMax, + ); } + _terminalPinchToZoom = settings['terminalPinchToZoom'] as bool? ?? false; + _showMobileShortcutBar = settings['showMobileShortcutBar'] as bool? ?? false; @@ -276,9 +295,27 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } - Future setTerminalFontSize(double size) async { - _terminalFontSize = size; - await _saveSetting('terminalFontSize', size); + Future setTerminalFontSize( + double size, { + bool persist = true, + }) async { + final clamped = size.clamp(kTerminalFontSizeMin, kTerminalFontSizeMax); + if (_terminalFontSize == clamped) { + if (persist) { + await _saveSetting('terminalFontSize', clamped); + } + return; + } + _terminalFontSize = clamped; + notifyListeners(); + if (persist) { + await _saveSetting('terminalFontSize', clamped); + } + } + + Future setTerminalPinchToZoom(bool value) async { + _terminalPinchToZoom = value; + await _saveSetting('terminalPinchToZoom', value); notifyListeners(); } @@ -420,8 +457,8 @@ class SettingsProvider extends ChangeNotifier { } Future updateShortcuts(List shortcuts) async { - _shortcuts = shortcuts; - await _saveSetting('shortcuts', shortcuts.map((s) => s.toJson()).toList()); + _shortcuts = _dedupeShortcuts(shortcuts); + await _saveSetting('shortcuts', _shortcuts.map((s) => s.toJson()).toList()); notifyListeners(); } @@ -431,6 +468,20 @@ class SettingsProvider extends ChangeNotifier { notifyListeners(); } + /// Keeps the first chip for each action+row pair. + static List _dedupeShortcuts( + List shortcuts, + ) { + final seen = {}; + final result = []; + for (final shortcut in shortcuts) { + final key = '${shortcut.action.name}:${shortcut.row}'; + if (!seen.add(key)) continue; + result.add(shortcut); + } + return result; + } + Future _saveSetting(String key, dynamic value) async { final settings = await _config.getSettings(); settings[key] = value; diff --git a/lib/providers/snippet_provider.dart b/lib/providers/snippet_provider.dart index 398530a..272d5de 100644 --- a/lib/providers/snippet_provider.dart +++ b/lib/providers/snippet_provider.dart @@ -61,6 +61,25 @@ class SnippetProvider extends ChangeNotifier { notifyListeners(); } + /// Moves a snippet within the saved list. + /// + /// [newIndex] uses Flutter 3.41+ [ReorderableListView.onReorderItem] + /// semantics (already adjusted for the removed item). + Future reorderSnippets(int oldIndex, int newIndex) async { + if (oldIndex < 0 || + oldIndex >= _snippets.length || + newIndex < 0 || + newIndex >= _snippets.length || + oldIndex == newIndex) { + return; + } + + final snippet = _snippets.removeAt(oldIndex); + _snippets.insert(newIndex, snippet); + await _saveSnippets(); + notifyListeners(); + } + Future resetSnippets() async { _snippets = Snippet.defaults; await _saveSnippets(); diff --git a/lib/providers/ssh_provider.dart b/lib/providers/ssh_provider.dart index e6ade22..5aac007 100644 --- a/lib/providers/ssh_provider.dart +++ b/lib/providers/ssh_provider.dart @@ -139,9 +139,11 @@ class SSHProvider extends ChangeNotifier { var profileData = await _config.getProfiles(); final migratedProfiles = await SecureStorageService.migrateProfilePasswords(profileData); - if (!identical(migratedProfiles, profileData)) { - await _config.saveProfiles(migratedProfiles); - profileData = migratedProfiles; + final migratedAgentProfiles = + await SecureStorageService.migrateAgentPasswords(migratedProfiles); + if (!identical(migratedAgentProfiles, profileData)) { + await _config.saveProfiles(migratedAgentProfiles); + profileData = migratedAgentProfiles; } profiles = []; @@ -170,10 +172,16 @@ class SSHProvider extends ChangeNotifier { Future _hydrateProfile(SSHProfile profile) async { final securePassword = await SecureStorageService.readProfilePassword(profile.id); - if (securePassword == null || securePassword.isEmpty) { - return profile; + final secureAgentPassword = + await SecureStorageService.readAgentPassword(profile.id); + var hydrated = profile; + if (securePassword != null && securePassword.isNotEmpty) { + hydrated = hydrated.copyWith(password: securePassword); } - return profile.copyWith(password: securePassword); + if (secureAgentPassword != null && secureAgentPassword.isNotEmpty) { + hydrated = hydrated.copyWith(agentPassword: secureAgentPassword); + } + return hydrated; } Future saveProfile(SSHProfile profile) async { @@ -188,6 +196,10 @@ class SSHProvider extends ChangeNotifier { profile.id, profile.password, ); + await SecureStorageService.writeAgentPassword( + profile.id, + profile.agentPassword, + ); await _config.saveProfiles(profiles.map((e) => e.toJson()).toList()); await WidgetProfileService.syncProfiles(profiles); notifyListeners(); @@ -196,6 +208,7 @@ class SSHProvider extends ChangeNotifier { Future deleteProfile(String id) async { profiles.removeWhere((p) => p.id == id); await SecureStorageService.deleteProfilePassword(id); + await SecureStorageService.deleteAgentPassword(id); await _config.saveProfiles(profiles.map((e) => e.toJson()).toList()); await WidgetProfileService.syncProfiles(profiles); notifyListeners(); @@ -207,6 +220,10 @@ class SSHProvider extends ChangeNotifier { profile.id, profile.password, ); + await SecureStorageService.writeAgentPassword( + profile.id, + profile.agentPassword, + ); await _config.saveLastSession(profile.toJson()); notifyListeners(); } diff --git a/lib/screens/agents_tab.dart b/lib/screens/agents_tab.dart index e1faafd..9fd7871 100644 --- a/lib/screens/agents_tab.dart +++ b/lib/screens/agents_tab.dart @@ -1,7 +1,7 @@ import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../models/agent_connection.dart'; @@ -26,8 +26,12 @@ class AgentsTab extends StatefulWidget { class AgentsTabState extends State { final TextEditingController _manualUrlController = TextEditingController(); + final TextEditingController _manualUsernameController = + TextEditingController(text: 'opencode'); final TextEditingController _manualPasswordController = TextEditingController(); + final TextEditingController _localUsernameController = + TextEditingController(text: 'opencode'); final TextEditingController _localPasswordController = TextEditingController(); bool _isConnecting = false; @@ -47,7 +51,9 @@ class AgentsTabState extends State { @override void dispose() { _manualUrlController.dispose(); + _manualUsernameController.dispose(); _manualPasswordController.dispose(); + _localUsernameController.dispose(); _localPasswordController.dispose(); super.dispose(); } @@ -78,9 +84,11 @@ class AgentsTabState extends State { Future _connectLocalDesktop(int port) async { setState(() => _isConnecting = true); try { + final localUser = _localUsernameController.text.trim(); await Provider.of(context, listen: false) .connectToLocalDesktop( port: port, + username: localUser.isEmpty ? 'opencode' : localUser, password: _localPasswordController.text, ); } catch (e) { @@ -103,8 +111,10 @@ class AgentsTabState extends State { setState(() => _isConnecting = true); try { + final manualUser = _manualUsernameController.text.trim(); await Provider.of(context, listen: false).connectFromUrl( url: url.startsWith('http') ? url : 'http://$url', + username: manualUser.isEmpty ? 'opencode' : manualUser, password: _manualPasswordController.text, ); } catch (e) { @@ -204,14 +214,28 @@ class AgentsTabState extends State { ), Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), - child: TextField( - controller: _localPasswordController, - decoration: const InputDecoration( - labelText: 'Local password (optional)', - border: OutlineInputBorder(), - isDense: true, - ), - obscureText: true, + child: Column( + children: [ + TextField( + controller: _localUsernameController, + decoration: const InputDecoration( + labelText: 'Username', + hintText: 'opencode', + border: OutlineInputBorder(), + isDense: true, + ), + ), + const SizedBox(height: 8), + TextField( + controller: _localPasswordController, + decoration: const InputDecoration( + labelText: 'OpenCode serve password', + border: OutlineInputBorder(), + isDense: true, + ), + obscureText: true, + ), + ], ), ), const Divider(height: 32), @@ -253,10 +277,19 @@ class AgentsTabState extends State { ), ), const SizedBox(height: 8), + TextField( + controller: _manualUsernameController, + decoration: const InputDecoration( + labelText: 'Username', + hintText: 'opencode', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), TextField( controller: _manualPasswordController, decoration: const InputDecoration( - labelText: 'Password (optional)', + labelText: 'OpenCode serve password', border: OutlineInputBorder(), ), obscureText: true, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 14ef568..f7ed90c 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; import 'package:home_widget/home_widget.dart'; import 'package:provider/provider.dart'; @@ -17,6 +17,7 @@ import '../widgets/key_manager.dart'; import '../widgets/keyboard_shortcut_bar.dart'; import '../widgets/connection_modal.dart'; import '../widgets/network_discovery.dart'; +import '../widgets/ai_command_overlay.dart'; import '../widgets/ctrl_button_panel.dart'; import '../widgets/snippet_button_panel.dart'; import '../widgets/sftp_browser.dart'; @@ -27,10 +28,12 @@ import '../screens/agents_tab.dart'; import '../services/share_clone_handler.dart'; import '../services/share_intent_service.dart'; import '../services/widget_launch_handler.dart'; +import '../utils/terminal_clipboard.dart'; import '../utils/terminal_style_builder.dart'; import '../utils/terminal_themes.dart'; import '../utils/enter_mapping_input_handler.dart'; import '../utils/terminal_enter_mapping.dart'; +import '../widgets/terminal_pinch_zoom.dart'; import '../widgets/terminal_selection_handles.dart'; import '../widgets/terminal_selection_input_guard.dart'; import '../widgets/terminal_selection_toolbar.dart'; @@ -443,6 +446,22 @@ class _HomeScreenState extends State { AppTab.logs, ]; + bool _isMobilePlatform() { + if (kIsWeb) return false; + return Platform.isAndroid || Platform.isIOS; + } + + /// Top shortcut chips duplicate the bottom accessory keyboard while a + /// Client session is connected on mobile. + bool _shouldShowShortcutBar(SSHProvider ssh) { + if (_isFullScreen) return false; + if (!_isMobilePlatform()) return true; + final clientAccessoryVisible = _selectedTab == AppTab.client && + _accessoryVisible && + ssh.sessions.any((s) => s.isConnected); + return !clientAccessoryVisible; + } + @override Widget build(BuildContext context) { return Consumer2( @@ -501,7 +520,9 @@ class _HomeScreenState extends State { ), body: Column( children: [ - if (!_isFullScreen) const KeyboardShortcutBar(), + // On mobile Client, the bottom CtrlButtonPanel already has + // Tab/arrows/Ctrl keys — hide the top bar to avoid duplicates. + if (_shouldShowShortcutBar(ssh)) const KeyboardShortcutBar(), Expanded( child: IndexedStack( index: _selectedTab.index, @@ -553,6 +574,7 @@ class _ClientTabState extends State { final Map _scrollControllers = {}; bool _searchVisible = false; + bool _aiOverlayVisible = false; final Map _enterHandlers = {}; @@ -617,20 +639,42 @@ class _ClientTabState extends State { Widget _buildSessionTabBar(BuildContext context) { return Consumer(builder: (context, ssh, child) { + final ColorScheme colors = Theme.of(context).colorScheme; final sessions = ssh.sessions; final chips = sessions.map((s) { final isActive = ssh.activeSessionId == s.id; return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2.0), + padding: const EdgeInsets.only(right: 6), child: InputChip( selected: isActive, + showCheckmark: false, + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 4), + labelPadding: const EdgeInsets.only(left: 2, right: 4), + selectedColor: colors.primary.withValues(alpha: 0.22), + side: BorderSide( + color: isActive ? colors.primary : colors.outlineVariant, + ), avatar: CircleAvatar( radius: 4, - backgroundColor: s.isConnected ? Colors.green : Colors.red, + backgroundColor: + s.isConnected ? Colors.greenAccent : colors.error, + ), + label: Text( + s.name, + style: TextStyle( + fontSize: 13, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, + color: isActive ? colors.primary : null, + ), ), - label: Text('${s.name} (${s.profile.host}:${s.profile.port})'), onPressed: () => ssh.switchActiveSession(s.id), - deleteIcon: const Icon(Icons.close, size: 16), + deleteIcon: Icon( + Icons.close, + size: 16, + color: isActive ? colors.primary : colors.onSurfaceVariant, + ), onDeleted: () => ssh.removeSession(s.id), ), ); @@ -638,45 +682,47 @@ class _ClientTabState extends State { chips.add( Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: GestureDetector( - onTap: () => showDialog( - context: context, builder: (c) => const ConnectionModal()), - child: const Chip(label: Icon(Icons.add)), + padding: const EdgeInsets.only(right: 4), + child: ActionChip( + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.symmetric(horizontal: 6), + label: Icon(Icons.add, size: 18, color: colors.primary), + onPressed: () => showDialog( + context: context, + builder: (c) => const ConnectionModal(), + ), ), ), ); final hasConnectedSession = ssh.sessions.any((s) => s.isConnected); - return Row( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row(children: chips), - ), - ), - if (hasConnectedSession) - IconButton( - icon: const Icon(Icons.add_circle_outline), - tooltip: 'New connection', - onPressed: () => showDialog( - context: context, - builder: (c) => const ConnectionModal(), + return Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 4, 2), + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row(children: chips), ), ), - if (hasConnectedSession) - IconButton( - icon: Icon( - Icons.search, - color: _searchVisible ? Colors.tealAccent : null, + if (hasConnectedSession) + IconButton( + visualDensity: VisualDensity.compact, + icon: Icon( + Icons.search, + color: _searchVisible ? colors.primary : null, + ), + tooltip: _searchVisible + ? 'Close terminal search' + : 'Find in terminal', + onPressed: () => + setState(() => _searchVisible = !_searchVisible), ), - tooltip: - _searchVisible ? 'Close terminal search' : 'Find in terminal', - onPressed: () => setState(() => _searchVisible = !_searchVisible), - ), - ], + ], + ), ); }); } @@ -728,6 +774,12 @@ class _ClientTabState extends State { ), onClose: () => setState(() => _searchVisible = false), ), + if (_aiOverlayVisible && + ssh.activeSession != null && + ssh.activeSession!.isConnected) + AiCommandOverlay( + onClose: () => setState(() => _aiOverlayVisible = false), + ), Expanded( child: Consumer(builder: (context, ssh, child) { final active = ssh.activeSession; @@ -818,10 +870,8 @@ class _ClientTabState extends State { terminal: active.terminal, controller: controller, onPaste: () async { - final data = - await Clipboard.getData(Clipboard.kTextPlain); - final text = data?.text; - if (text == null || text.isEmpty) { + final text = await TerminalClipboard.readText(); + if (text == null) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -835,36 +885,60 @@ class _ClientTabState extends State { }, ), Expanded( - child: Stack( - children: [ - TerminalView( - key: _viewKeyFor(active.id), - active.terminal, - controller: controller, - scrollController: scrollController, - padding: const EdgeInsets.all(8), - theme: terminalTheme, - textStyle: - TerminalStyleBuilder.buildTerminalStyle( - settings, - ), - autoResize: true, - onSecondaryTapDown: (_, __) { - active.terminal.keyInput( - TerminalKey.f10, - shift: true, - ); - }, - ), - Positioned.fill( - child: TerminalSelectionHandles( - viewKey: _viewKeyFor(active.id), - terminal: active.terminal, - controller: controller, - scrollController: scrollController, - ), - ), - ], + child: Builder( + builder: (context) { + Widget terminalStack = Stack( + children: [ + TerminalView( + key: _viewKeyFor(active.id), + active.terminal, + controller: controller, + scrollController: scrollController, + padding: const EdgeInsets.all(8), + theme: terminalTheme, + textStyle: TerminalStyleBuilder + .buildTerminalStyle( + settings, + ), + autoResize: true, + onSecondaryTapDown: (_, __) { + active.terminal.keyInput( + TerminalKey.f10, + shift: true, + ); + }, + ), + Positioned.fill( + child: TerminalSelectionHandles( + viewKey: _viewKeyFor(active.id), + terminal: active.terminal, + controller: controller, + scrollController: scrollController, + ), + ), + ], + ); + + if (settings.terminalPinchToZoom) { + terminalStack = TerminalPinchZoom( + fontSize: settings.terminalFontSize, + minFontSize: + SettingsProvider.kTerminalFontSizeMin, + maxFontSize: + SettingsProvider.kTerminalFontSizeMax, + onChanged: (size) { + settings.setTerminalFontSize( + size, + persist: false, + ); + }, + onCommit: settings.setTerminalFontSize, + child: terminalStack, + ); + } + + return terminalStack; + }, ), ), ], @@ -882,6 +956,8 @@ class _ClientTabState extends State { child: _AccessoryArea( theme: Theme.of(context), ssh: ssh, + onAiGenerate: () => + setState(() => _aiOverlayVisible = true), ), ), ], @@ -895,25 +971,29 @@ class _ClientTabState extends State { /// Renders the snippet + ctrl button panels. The snippet panel collapses to /// a single chip when toggled off, letting AnimatedSize shrink the area. class _AccessoryArea extends StatelessWidget { - const _AccessoryArea({required this.theme, required this.ssh}); + const _AccessoryArea({ + required this.theme, + required this.ssh, + required this.onAiGenerate, + }); final ThemeData theme; final SSHProvider ssh; + final VoidCallback onAiGenerate; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 2.0), - child: const Wrap( + child: Wrap( alignment: WrapAlignment.center, spacing: 8, runSpacing: 8, children: [ - SnippetButtonPanel(), - CtrlButtonPanel(), + const SnippetButtonPanel(), + CtrlButtonPanel(onAiGenerate: onAiGenerate), ], ), ); } } - diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 68bccfb..85c3e69 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,7 +1,7 @@ import 'dart:io' show Platform; import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -53,38 +53,42 @@ class _SettingsScreenState extends State { String confirmLabel = 'Continue', }) async { final controller = TextEditingController(); - try { - return await showDialog( - context: context, - barrierDismissible: false, - builder: (dialogContext) { - return AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - obscureText: true, - autofocus: true, - decoration: const InputDecoration( - labelText: 'Password', - hintText: 'Minimum 8 characters recommended', - ), + final String? result = await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + obscureText: true, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Password', + hintText: 'Minimum 8 characters recommended', ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () => Navigator.pop(dialogContext, controller.text), - child: Text(confirmLabel), - ), - ], - ); - }, - ); - } finally { + onSubmitted: (value) => Navigator.pop(dialogContext, value), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(dialogContext, controller.text), + child: Text(confirmLabel), + ), + ], + ); + }, + ); + // Dispose after the dialog route finishes deactivating its TextField; + // disposing in a finally block races InheritedElement teardown and can + // trip `_dependents.isEmpty`. + WidgetsBinding.instance.addPostFrameCallback((_) { controller.dispose(); - } + }); + return result; } Future _confirmPlainSecretsExport() async { @@ -175,6 +179,9 @@ class _SettingsScreenState extends State { }, ); if (choice == null || !mounted) return; + + final Rect? shareOrigin = _shareOriginRect(); + setState(() => _isExporting = true); try { switch (choice) { @@ -184,24 +191,36 @@ class _SettingsScreenState extends State { confirmLabel: 'Export', ); if (password == null || password.isEmpty) return; - if (password.length < 8 && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Use at least 8 characters for backup password'), - backgroundColor: Colors.orange, - ), - ); + if (password.length < 8) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: + Text('Use at least 8 characters for backup password'), + backgroundColor: Colors.orange, + ), + ); + } return; } - await BackupService.exportEncrypted(password); + if (!mounted) return; + await BackupService.exportEncrypted( + password, + sharePositionOrigin: shareOrigin, + ); break; case 'plain-secrets': final ok = await _confirmPlainSecretsExport(); if (!ok) return; - await BackupService.exportPlain(includeSecrets: true); + if (!mounted) return; + await BackupService.exportPlain( + includeSecrets: true, + sharePositionOrigin: shareOrigin, + ); break; default: - await BackupService.exportPlain(); + if (!mounted) return; + await BackupService.exportPlain(sharePositionOrigin: shareOrigin); } if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -222,6 +241,14 @@ class _SettingsScreenState extends State { } } + Rect? _shareOriginRect() { + final RenderObject? renderObject = context.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) { + return null; + } + return renderObject.localToGlobal(Offset.zero) & renderObject.size; + } + Future _handleImport() async { setState(() => _isImporting = true); try { @@ -284,7 +311,9 @@ class _SettingsScreenState extends State { SwitchListTile( title: const Text('Mobile shortcut bar'), subtitle: const Text( - 'Show the configured keyboard shortcut bar on Android/iOS', + 'Show top shortcut chips when idle. Hidden while a ' + 'Client session is connected (bottom accessory keyboard ' + 'covers those keys).', ), value: settings.showMobileShortcutBar, onChanged: settings.setShowMobileShortcutBar, diff --git a/lib/screens/snippet_config_screen.dart b/lib/screens/snippet_config_screen.dart index bfab77d..4d021c7 100644 --- a/lib/screens/snippet_config_screen.dart +++ b/lib/screens/snippet_config_screen.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../models/snippet.dart'; import '../providers/snippet_provider.dart'; @@ -94,71 +94,81 @@ class _SnippetConfigScreenState extends State { ); } - return ListView.builder( - padding: const EdgeInsets.all(8), - itemCount: filteredSnippets.length, - itemBuilder: (context, index) { - final snippet = filteredSnippets[index]; - return Card( - margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - child: ListTile( - leading: CircleAvatar( - backgroundColor: - Theme.of(context).colorScheme.secondaryContainer, - child: const Icon(Icons.terminal, size: 20), - ), - title: Text(snippet.name, - style: const TextStyle(fontWeight: FontWeight.bold)), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - snippet.content, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'monospace', fontSize: 12), - ), - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - snippet.category, - style: TextStyle( - fontSize: 10, - color: - Theme.of(context).colorScheme.onSurfaceVariant, + final canReorder = _searchQuery.isEmpty; + + return Column( + children: [ + if (!canReorder) + Material( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.6), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon(Icons.info_outline, size: 16), + SizedBox(width: 8), + Expanded( + child: Text( + 'Clear search to drag and reorder snippets', + style: TextStyle(fontSize: 12), ), ), - ), - ], - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: () => - _showSnippetDialog(context, snippet: snippet), - ), - IconButton( - icon: - const Icon(Icons.delete_outline, color: Colors.red), - onPressed: () => - _confirmDelete(context, provider, snippet), - ), - ], + ], + ), ), ), - ); - }, + Expanded( + child: canReorder + ? ReorderableListView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), + buildDefaultDragHandles: false, + itemCount: filteredSnippets.length, + onReorderItem: provider.reorderSnippets, + proxyDecorator: _snippetProxyDecorator, + itemBuilder: (context, index) { + return _SnippetTile( + key: ValueKey(filteredSnippets[index].id), + snippet: filteredSnippets[index], + index: index, + showDragHandle: true, + onEdit: () => _showSnippetDialog( + context, + snippet: filteredSnippets[index], + ), + onDelete: () => _confirmDelete( + context, + provider, + filteredSnippets[index], + ), + ); + }, + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), + itemCount: filteredSnippets.length, + itemBuilder: (context, index) { + return _SnippetTile( + key: ValueKey(filteredSnippets[index].id), + snippet: filteredSnippets[index], + index: index, + showDragHandle: false, + onEdit: () => _showSnippetDialog( + context, + snippet: filteredSnippets[index], + ), + onDelete: () => _confirmDelete( + context, + provider, + filteredSnippets[index], + ), + ); + }, + ), + ), + ], ); }, ), @@ -170,6 +180,27 @@ class _SnippetConfigScreenState extends State { ); } + Widget _snippetProxyDecorator( + Widget child, + int index, + Animation animation, + ) { + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + final elevation = Tween(begin: 0, end: 6).evaluate(animation); + return Material( + elevation: elevation, + color: Colors.transparent, + shadowColor: Theme.of(context).colorScheme.shadow, + borderRadius: BorderRadius.circular(12), + child: child, + ); + }, + child: child, + ); + } + void _confirmDelete( BuildContext context, SnippetProvider provider, Snippet snippet) { showDialog( @@ -293,3 +324,88 @@ class _SnippetConfigScreenState extends State { ); } } + +class _SnippetTile extends StatelessWidget { + const _SnippetTile({ + required this.snippet, + required this.index, + required this.showDragHandle, + required this.onEdit, + required this.onDelete, + super.key, + }); + + final Snippet snippet; + final int index; + final bool showDragHandle; + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: theme.colorScheme.secondaryContainer, + child: const Icon(Icons.terminal, size: 20), + ), + title: Text( + snippet.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + snippet.content, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontFamily: 'monospace', fontSize: 12), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + snippet.category, + style: TextStyle( + fontSize: 10, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: onEdit, + ), + IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.red), + onPressed: onDelete, + ), + if (showDragHandle) + ReorderableDragStartListener( + index: index, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Icon( + Icons.drag_handle, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index a275d28..4b79f87 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:home_widget/home_widget.dart'; import '../constants/app_metadata.dart'; diff --git a/lib/services/app_lifecycle_service.dart b/lib/services/app_lifecycle_service.dart index 3877e71..3e43d5f 100644 --- a/lib/services/app_lifecycle_service.dart +++ b/lib/services/app_lifecycle_service.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../providers/agent_provider.dart'; diff --git a/lib/services/backup_service.dart b/lib/services/backup_service.dart index ec62554..ee76ec8 100644 --- a/lib/services/backup_service.dart +++ b/lib/services/backup_service.dart @@ -1,8 +1,11 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:ui' show Rect; + import 'package:file_picker/file_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; + import 'backup_crypto.dart'; import 'config_service.dart'; import '../constants/app_metadata.dart'; @@ -47,13 +50,25 @@ class BackupService { } final rawProfiles = await ConfigService.getProfiles(); - final profiles = includeSecrets - ? rawProfiles - : rawProfiles.map((p) { - final map = Map.from(p); - map.remove('password'); - return map; - }).toList(); + final profiles = >[]; + for (final raw in rawProfiles) { + final map = Map.from(raw); + final id = map['id'] as String?; + if (includeSecrets && id != null) { + final password = await SecureStorageService.readProfilePassword(id); + if (password != null && password.isNotEmpty) { + map['password'] = password; + } + final agentPassword = await SecureStorageService.readAgentPassword(id); + if (agentPassword != null && agentPassword.isNotEmpty) { + map['agentPassword'] = agentPassword; + } + } else { + map.remove('password'); + map.remove('agentPassword'); + } + profiles.add(map); + } return { 'version': _backupVersion, @@ -69,37 +84,62 @@ class BackupService { static Future _shareJson( Map envelope, - String fileName, - ) async { + String fileName, { + Rect? sharePositionOrigin, + }) async { final jsonString = const JsonEncoder.withIndent(' ').convert(envelope); final dir = await getTemporaryDirectory(); final file = File('${dir.path}/$fileName'); await file.writeAsString(jsonString); - await SharePlus.instance.share( - ShareParams( - files: [XFile(file.path)], - subject: '$kAppDisplayName Backup', - ), - ); - - // Cleanup temp file after sharing - await file.delete(); + try { + await SharePlus.instance.share( + ShareParams( + files: [ + XFile(file.path, mimeType: 'application/json'), + ], + subject: '$kAppDisplayName Backup', + sharePositionOrigin: sharePositionOrigin, + ), + ); + } finally { + // Delay delete so the share target can finish reading the file. + // ignore: unawaited_futures + Future.delayed(const Duration(seconds: 60), () async { + if (await file.exists()) { + await file.delete(); + } + }); + } } /// Encrypted export (recommended): full payload protected by [password]. - static Future exportEncrypted(String password) async { + static Future exportEncrypted( + String password, { + Rect? sharePositionOrigin, + }) async { final payload = await _buildPayload(includeSecrets: true); final inner = json.encode(payload); final envelope = await BackupCrypto.encrypt(inner, password); - await _shareJson(envelope, 'ssh_app_backup.encrypted.json'); + await _shareJson( + envelope, + 'ssh_app_backup.encrypted.json', + sharePositionOrigin: sharePositionOrigin, + ); } /// Plain export. Secrets are stripped unless [includeSecrets] is explicitly /// true (caller must show a risk acknowledgement first). - static Future exportPlain({bool includeSecrets = false}) async { + static Future exportPlain({ + bool includeSecrets = false, + Rect? sharePositionOrigin, + }) async { final payload = await _buildPayload(includeSecrets: includeSecrets); - await _shareJson(payload, 'ssh_app_backup.json'); + await _shareJson( + payload, + 'ssh_app_backup.json', + sharePositionOrigin: sharePositionOrigin, + ); } /// Legacy entry point: plain export without secrets (safe default). diff --git a/lib/services/opencode_connection_service.dart b/lib/services/opencode_connection_service.dart index f7fd46c..1198cbf 100644 --- a/lib/services/opencode_connection_service.dart +++ b/lib/services/opencode_connection_service.dart @@ -1,9 +1,13 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; import 'package:opencode_api/opencode_api.dart'; +import 'opencode_v2_mappers.dart'; + enum ConnectionEventType { disconnected } class ConnectionEvent { @@ -12,19 +16,26 @@ class ConnectionEvent { final ConnectionEventType type; } +/// OpenCode HTTP client for server V2 (`/api/*` + Basic auth). +/// +/// Adapts responses into the V1 `opencode_api` model types the UI already uses. class OpenCodeConnectionService { OpenCodeConnectionService({ required this.baseUrl, required this.username, required this.password, + this.allowInsecureTls = false, }); final String baseUrl; final String username; final String password; + /// When true, accepts untrusted TLS certificates for this connection only. + /// Must be explicitly opted in by the caller (never implied by HTTPS alone). + final bool allowInsecureTls; + late final Dio _dio; - late final Opencode _client; final StreamController> _eventController = StreamController>.broadcast(); final StreamController _connectionEventController = @@ -44,107 +55,303 @@ class OpenCodeConnectionService { bool get isEventStreamActive => _listening; - Opencode get client => _client; + String get _normalizedBaseUrl { + var url = baseUrl.trim(); + while (url.endsWith('/')) { + url = url.substring(0, url.length - 1); + } + return url; + } Future connect() async { - _dio = Opencode.createDio( - baseUrl: baseUrl, - username: username, - password: password, + if (password.isEmpty) { + throw StateError( + 'OpenCode password is required. Set the agent password on the profile ' + '(default username is "opencode").', + ); + } + + _dio = Dio( + BaseOptions( + baseUrl: _normalizedBaseUrl, + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 30), + headers: { + 'authorization': + 'Basic ${base64Encode(utf8.encode('$username:$password'))}', + 'accept': 'application/json', + }, + validateStatus: (int? status) => + status != null && status >= 200 && status < 500, + ), ); - _client = Opencode(dio: _dio); + + if (allowInsecureTls) { + final expectedHost = Uri.tryParse(_normalizedBaseUrl)?.host; + _dio.httpClientAdapter = IOHttpClientAdapter( + createHttpClient: () { + final client = HttpClient(); + // Opt-in only: accept certs for the configured agent host. + client.badCertificateCallback = + (X509Certificate cert, String host, int port) { + if (expectedHost == null || expectedHost.isEmpty) { + return false; + } + return host.toLowerCase() == expectedHost.toLowerCase(); + }; + return client; + }, + ); + } + + _dio.interceptors.add( + InterceptorsWrapper( + onRequest: (RequestOptions options, RequestInterceptorHandler handler) { + // Keep parity with prior debug logging without depending on package logger. + assert(() { + // ignore: avoid_print + print('[DEBUG] API Request: ${options.method} ${options.uri}'); + return true; + }()); + return handler.next(options); + }, + onResponse: + (Response response, ResponseInterceptorHandler handler) { + assert(() { + // ignore: avoid_print + print( + '[INFO] API Response: ${response.statusCode} for ' + '${response.requestOptions.path}', + ); + return true; + }()); + return handler.next(response); + }, + ), + ); + await checkHealth(); // ignore: unawaited_futures _listenToEvents(); } Future checkHealth() async { - return _client.global.getHealth(); + final response = await _dio.get('/api/info'); + _throwIfUnauthorized(response); + _throwIfNotJsonApi(response, path: '/api/info'); + + final body = response.data; + if (body is! Map) { + throw StateError('OpenCode /api/info returned an unexpected payload.'); + } + final map = Map.from(body); + final version = map['version'] as String?; + if (version == null || version.isEmpty) { + throw StateError( + 'OpenCode /api/info missing version — is this an OpenCode V2 server?', + ); + } + return HealthResponse(healthy: true, version: version); } - Future getConfig() => _client.config.getConfig(); + Future getConfig() async { + final response = await _dio.get('/api/config'); + _ensureOk(response); + final entries = unwrapV2ObjectList(response.data); + Map? info; + for (final entry in entries) { + if (entry['type'] == 'document') { + final candidate = entry['info']; + if (candidate is Map) { + info = Map.from(candidate); + break; + } + } + } + + final defaults = defaultsFromV2ConfigInfo(info); + final providers = {}; + for (final entry in defaults.entries) { + providers[entry.key] = {'default': entry.value}; + } - Future updateConfig(Map body) => - _client.config.updateConfig(body); + return ConfigResponse( + providers: providers.isEmpty ? info : providers, + defaultModels: + defaults.isEmpty ? null : Map.from(defaults), + ); + } - Future getServerPath() async { - final pathResponse = await _client.path.getPath(); - return pathResponse.path; + Future updateConfig(Map body) async { + // V2 only exposes an experimental shell patch; keep a clear failure path. + if (body.containsKey('shell')) { + final response = await _dio.patch( + '/api/experimental/config', + data: {'shell': body['shell']}, + ); + _ensureOk(response); + return getConfig(); + } + throw UnsupportedError( + 'OpenCode V2 does not support arbitrary config PATCH via HTTP. ' + 'Edit opencode.json on the host or use SSH import.', + ); } - Future> getSessions({String? directory}) async { - if (directory == null || directory.isEmpty) { - return _client.session.getSessions(); + Future getServerPath() async { + final response = await _dio.get('/api/info'); + _ensureOk(response); + final body = response.data; + if (body is! Map) return null; + final paths = body['paths']; + if (paths is Map) { + return paths['tmp'] as String?; } + return null; + } - final response = await _dio.get>( - '/session', - queryParameters: {'directory': directory}, + Future> getSessions({String? directory}) async { + final query = { + 'order': 'desc', + if (directory != null && directory.isNotEmpty) 'directory': directory, + }; + final response = await _dio.get( + '/api/session', + queryParameters: query, ); - final data = response.data ?? []; - return data - .map((dynamic item) => Session.fromJson(item as Map)) - .toList(); + _ensureOk(response); + return unwrapV2ObjectList(response.data).map(mapV2Session).toList(); } Future createSession({String? title, String? directory}) async { - if (directory == null || directory.isEmpty) { - return _client.session.createSession( - title != null ? {'title': title} : {}, - ); - } - - final body = {'directory': directory}; - if (title != null) { + final body = {}; + if (title != null && title.isNotEmpty) { body['title'] = title; } + if (directory != null && directory.isNotEmpty) { + body['location'] = {'directory': directory}; + } - final response = await _dio.post>( - '/session', - data: body, - queryParameters: {'directory': directory}, - ); - return Session.fromJson(response.data!); + final response = await _dio.post('/api/session', data: body); + _ensureOk(response); + final data = unwrapV2Data(response.data); + if (data is! Map) { + throw StateError('OpenCode create session returned no session object.'); + } + return mapV2Session(Map.from(data)); } - Future deleteSession(String id) => _client.session.deleteSession(id); + Future deleteSession(String id) async { + final response = await _dio.delete('/api/session/$id'); + _ensureOk(response); + } - Future> getMessages(String sessionId) { - return _client.session.getMessages(sessionId); + Future> getMessages(String sessionId) async { + final response = await _dio.get('/api/session/$sessionId/message'); + _ensureOk(response); + final messages = + unwrapV2ObjectList(response.data).map(mapV2Message).toList(); + // V2 returns newest-first; chat UI expects oldest-first. + return messages.reversed.toList(); } - Future sendMessageAsync(String sessionId, String text) { - return _client.session.sendMessageAsyncRaw( - sessionId, - { - 'parts': >[ - {'type': 'text', 'text': text}, - ], - }, + Future sendMessageAsync(String sessionId, String text) async { + final response = await _dio.post( + '/api/session/$sessionId/prompt', + data: {'text': text}, ); + _ensureOk(response); } - Future> getCommands() => _client.commands.getCommands(); + Future> getCommands() async { + final response = await _dio.get('/api/command'); + _ensureOk(response); + return unwrapV2ObjectList(response.data).map(mapV2Command).toList(); + } - Future> getAgents() => _client.agents.getAgents(); + Future> getAgents() async { + final response = await _dio.get('/api/agent'); + _ensureOk(response); + return unwrapV2ObjectList(response.data).map(mapV2Agent).toList(); + } + + Future getProviders() async { + final providersResponse = await _dio.get('/api/provider'); + _ensureOk(providersResponse); + final connectedIds = await _connectedIntegrationIds(); + final providers = unwrapV2ObjectList(providersResponse.data) + .map( + (Map json) => mapV2Provider( + json, + connectedIds: connectedIds, + ), + ) + .toList(); + + final config = await getConfig(); + final defaults = {}; + final rawDefaults = config.defaultModels; + if (rawDefaults != null) { + for (final entry in rawDefaults.entries) { + final value = entry.value; + if (value is String && value.isNotEmpty) { + defaults[entry.key] = value; + } + } + } - Future getProviders() => - _client.provider.getProviders(); + return mapV2ProviderList( + providers: providers, + defaults: defaults, + connected: connectedIds.toList()..sort(), + ); + } - Future getConfigProviders() => - _client.config.getConfigProviders(); + Future getConfigProviders() async { + final providerList = await getProviders(); + return ConfigProvidersResponse( + providers: providerList.all, + default_: providerList.default_, + ); + } Future setProviderAuth( String providerId, Map body, - ) => - _client.auth.setAuth(providerId, body); + ) async { + final key = body['key'] as String? ?? body['apiKey'] as String?; + if (key == null || key.isEmpty) { + throw ArgumentError('Provider API key is required.'); + } + + final response = await _dio.post( + '/api/credential', + data: { + 'integrationID': providerId, + 'activate': true, + 'value': { + 'type': 'key', + 'key': key, + }, + }, + ); + _ensureOk(response); + return true; + } Future executeCommand(String sessionId, String command) async { - await _client.session.executeCommand( - sessionId, - {'command': command}, + final parsed = parseSlashCommand(command); + if (parsed.name.isEmpty) { + throw ArgumentError('Command name is empty.'); + } + final response = await _dio.post( + '/api/session/$sessionId/command', + data: { + 'name': parsed.name, + 'text': parsed.text, + }, ); + _ensureOk(response); } Future respondToPermission( @@ -152,15 +359,14 @@ class OpenCodeConnectionService { String permissionId, { required String response, bool remember = false, - }) { - return _client.session.respondToPermissionRequest( - sessionId, - permissionId, - { - 'response': response, - 'remember': remember, - }, + }) async { + final decision = mapPermissionDecision(response, remember: remember); + final apiResponse = await _dio.post( + '/api/session/$sessionId/permission/$permissionId/reply', + data: {'decision': decision}, ); + _ensureOk(apiResponse); + return true; } Future reconnectEvents() async { @@ -175,52 +381,82 @@ class OpenCodeConnectionService { await reconnectEvents(); } + Future> _connectedIntegrationIds() async { + try { + final response = await _dio.get('/api/credential'); + if (response.statusCode != 200) { + return const {}; + } + final ids = {}; + for (final item in unwrapV2ObjectList(response.data)) { + final active = item['active'] as bool? ?? true; + if (!active) continue; + final id = item['integrationID'] as String?; + if (id != null && id.isNotEmpty) { + ids.add(id); + } + } + return ids; + } catch (_) { + return const {}; + } + } + Future _listenToEvents() async { if (_disposed || _listening) return; _listening = true; try { final response = await _dio.get( - '/event', + '/api/event', options: Options( responseType: ResponseType.stream, receiveTimeout: Duration.zero, + headers: {'accept': 'text/event-stream'}, ), ); + if (response.statusCode == 401) { + _listening = false; + throw StateError( + 'OpenCode rejected event stream credentials (401). ' + 'Check agent username/password.', + ); + } + final stream = response.data?.stream; if (stream == null || _disposed) { _listening = false; return; } - // Use utf8 decoder streaming to handle multi-byte boundaries correctly final pending = StringBuffer(); _sseSubscription = stream.listen( (List chunk) { if (_disposed) return; - // Use allowMalformed to handle split multi-byte sequences pending.write(utf8.decode(chunk, allowMalformed: true)); final content = pending.toString(); final lines = content.split('\n'); pending.clear(); - // Keep incomplete last line in buffer if (!content.endsWith('\n') && lines.isNotEmpty) { pending.write(lines.removeLast()); } for (final line in lines) { - if (line.startsWith('data: ')) { - final data = line.substring(6).trim(); - if (data.isEmpty) continue; - try { - final event = json.decode(data) as Map; - if (!_eventController.isClosed) { - _eventController.add(event); - } - } catch (_) { - // Skip malformed SSE payloads + if (!line.startsWith('data: ')) continue; + final data = line.substring(6).trim(); + if (data.isEmpty) continue; + try { + final decoded = json.decode(data); + if (decoded is! Map) continue; + final event = normalizeV2Event( + Map.from(decoded), + ); + if (!_eventController.isClosed) { + _eventController.add(event); } + } catch (_) { + // Skip malformed SSE payloads } } }, @@ -249,7 +485,6 @@ class OpenCodeConnectionService { void _scheduleReconnect() { if (_disposed || _listening) return; if (_reconnectAttempts >= _maxReconnectAttempts) { - // Max attempts reached, stop trying return; } _reconnectAttempts++; @@ -268,6 +503,49 @@ class OpenCodeConnectionService { _sseSubscription = null; } + void _throwIfUnauthorized(Response response) { + if (response.statusCode == 401) { + throw StateError( + 'OpenCode authentication failed (401). ' + 'Check agent username/password for $baseUrl.', + ); + } + } + + void _throwIfNotJsonApi(Response response, {required String path}) { + final contentType = response.headers.value('content-type') ?? ''; + if (contentType.contains('text/html')) { + throw StateError( + 'OpenCode returned HTML for $path instead of JSON. ' + 'Use the OpenCode serve API port and Basic auth ' + '(V2 routes are under /api/*).', + ); + } + if (response.statusCode != null && + response.statusCode! >= 400 && + response.statusCode != 401) { + throw StateError( + 'OpenCode $path failed with HTTP ${response.statusCode}.', + ); + } + } + + void _ensureOk(Response response) { + _throwIfUnauthorized(response); + final status = response.statusCode ?? 0; + if (status >= 200 && status < 300) { + return; + } + final body = response.data; + var detail = ''; + if (body is Map && body['message'] is String) { + detail = ': ${body['message']}'; + } + throw StateError( + 'OpenCode request failed (HTTP $status)$detail', + ); + } + void dispose() { _disposed = true; // ignore: unawaited_futures diff --git a/lib/services/opencode_v2_mappers.dart b/lib/services/opencode_v2_mappers.dart new file mode 100644 index 0000000..b520bad --- /dev/null +++ b/lib/services/opencode_v2_mappers.dart @@ -0,0 +1,328 @@ +import 'package:opencode_api/opencode_api.dart'; + +/// Normalizes an OpenCode V2 `{data: ...}` envelope into the inner payload. +dynamic unwrapV2Data(dynamic body) { + if (body is Map) { + final map = Map.from(body); + if (map.containsKey('data')) { + return map['data']; + } + } + return body; +} + +/// Extracts a list from a V2 response body (`{data: [...]}` or bare list). +List> unwrapV2ObjectList(dynamic body) { + final unwrapped = unwrapV2Data(body); + if (unwrapped is! List) { + return const >[]; + } + return unwrapped + .whereType>() + .map( + (Map item) => Map.from(item), + ) + .toList(); +} + +int? _asInt(dynamic value) { + if (value is int) return value; + if (value is num) return value.toInt(); + return null; +} + +DateTime? _dateFromEpoch(dynamic value) { + final millis = _asInt(value); + if (millis == null) return null; + final normalized = millis > 9999999999 ? millis : millis * 1000; + return DateTime.fromMillisecondsSinceEpoch(normalized); +} + +/// Maps a V2 Session.Info object into the V1 [Session] UI model. +Session mapV2Session(Map json) { + final location = json['location']; + String? directory; + if (location is Map) { + directory = location['directory'] as String?; + } + + final time = json['time']; + SessionTime? sessionTime; + if (time is Map) { + sessionTime = SessionTime( + created: _asInt(time['created']), + updated: _asInt(time['updated']), + ); + } + + return Session( + id: json['id'] as String?, + projectID: json['projectID'] as String?, + directory: directory, + title: json['title'] as String?, + parentID: json['parentID'] as String?, + time: sessionTime, + ); +} + +String _roleFromV2MessageType(String? type) { + return switch (type) { + 'user' => 'user', + 'assistant' => 'assistant', + 'system' => 'system', + 'synthetic' => 'system', + 'shell' => 'assistant', + _ => type ?? 'unknown', + }; +} + +String _toolPartText(Map tool) { + final name = tool['name'] as String? ?? 'tool'; + final state = tool['state']; + final buffer = StringBuffer(name); + if (state is Map) { + final status = state['status'] as String?; + if (status != null && status.isNotEmpty) { + buffer.write(' ($status)'); + } + final input = state['input']; + if (input != null) { + buffer.write('\n$input'); + } + final output = state['output'] ?? state['error']; + if (output != null) { + buffer.write('\n$output'); + } + } + return buffer.toString(); +} + +List _partsFromV2Message(Map json) { + final type = json['type'] as String? ?? ''; + final id = json['id'] as String? ?? 'msg'; + + if (type == 'user' || type == 'system' || type == 'synthetic') { + final text = json['text'] as String? ?? ''; + final description = json['description'] as String?; + final body = description == null || description.isEmpty + ? text + : '$description\n$text'; + return [ + MessagePart(id: '$id-text', type: 'text', text: body), + ]; + } + + if (type == 'shell') { + final text = json['text'] as String? ?? json['command'] as String? ?? ''; + return [ + MessagePart(id: '$id-shell', type: 'text', text: text), + ]; + } + + final content = json['content']; + if (content is! List) { + final fallback = json['text'] as String?; + if (fallback == null || fallback.isEmpty) { + return const []; + } + return [ + MessagePart(id: '$id-text', type: 'text', text: fallback), + ]; + } + + final parts = []; + for (var i = 0; i < content.length; i++) { + final item = content[i]; + if (item is! Map) continue; + final part = Map.from(item); + final partType = part['type'] as String? ?? 'text'; + final partId = part['id'] as String? ?? '$id-$i'; + + if (partType == 'tool') { + parts.add( + MessagePart( + id: partId, + type: 'tool', + text: _toolPartText(part), + content: _toolPartText(part), + ), + ); + continue; + } + + final text = part['text'] as String? ?? ''; + parts.add( + MessagePart( + id: partId, + type: partType, + text: text, + content: text, + ), + ); + } + return parts; +} + +/// Maps a V2 Session.Message.Info object into [MessageWithParts]. +MessageWithParts mapV2Message(Map json) { + final type = json['type'] as String?; + final time = json['time']; + DateTime? createdAt; + if (time is Map) { + createdAt = _dateFromEpoch(time['created']); + } + + return MessageWithParts( + info: Message( + id: json['id'] as String?, + role: _roleFromV2MessageType(type), + createdAt: createdAt, + content: json['text'] as String?, + ), + parts: _partsFromV2Message(json), + ); +} + +/// Maps V2 Command.Info into V1 [Command]. +Command mapV2Command(Map json) { + final name = json['name'] as String?; + return Command( + id: name, + name: name, + description: json['description'] as String?, + ); +} + +/// Maps V2 Agent.Info into V1 [Agent]. +Agent mapV2Agent(Map json) { + return Agent( + id: json['id'] as String?, + name: json['name'] as String?, + description: json['description'] as String?, + category: json['mode'] as String?, + ); +} + +/// Maps V2 Provider.Info into V1 [Provider]. +Provider mapV2Provider( + Map json, { + Set connectedIds = const {}, +}) { + final id = json['id'] as String?; + final integrationId = json['integrationID'] as String? ?? id; + final connected = + integrationId != null && connectedIds.contains(integrationId); + return Provider( + id: id, + name: json['name'] as String?, + description: json['package'] as String?, + connected: connected, + ); +} + +/// Builds provider list response used by model/slash helpers. +ProviderListResponse mapV2ProviderList({ + required List providers, + required Map defaults, + required List connected, +}) { + return ProviderListResponse( + all: providers, + default_: defaults.isEmpty ? null : defaults, + connected: connected, + ); +} + +/// Extracts default provider→model pairs from a V2 config document `info` map. +Map defaultsFromV2ConfigInfo(Map? info) { + final defaults = {}; + if (info == null) return defaults; + + void addModel(dynamic model) { + if (model is! Map) return; + final providerId = model['providerID'] as String?; + final modelId = model['model'] as String? ?? model['id'] as String?; + if (providerId == null || + providerId.isEmpty || + modelId == null || + modelId.isEmpty) { + return; + } + defaults[providerId] = modelId; + } + + addModel(info['model']); + + final agents = info['agents']; + if (agents is Map) { + for (final value in agents.values) { + if (value is Map) { + addModel(value['model']); + } + } + } + + return defaults; +} + +/// Parses `/name args` into V2 command `{name, text}`. +({String name, String text}) parseSlashCommand(String command) { + var trimmed = command.trim(); + if (trimmed.startsWith('/')) { + trimmed = trimmed.substring(1); + } + final space = trimmed.indexOf(' '); + if (space <= 0) { + return (name: trimmed, text: ''); + } + return ( + name: trimmed.substring(0, space).trim(), + text: trimmed.substring(space + 1).trim(), + ); +} + +/// Maps UI allow/deny (+ remember) to V2 Permission.Reply. +String mapPermissionDecision(String response, {required bool remember}) { + final normalized = response.trim().toLowerCase(); + if (normalized == 'deny' || + normalized == 'reject' || + normalized == 'once-reject') { + return 'reject'; + } + if (remember || normalized == 'always') { + return 'always'; + } + return 'once'; +} + +/// Adapts a V2 SSE event into the shape AgentProvider already understands. +Map normalizeV2Event(Map event) { + final normalized = Map.from(event); + final data = event['data']; + if (!normalized.containsKey('properties') && data is Map) { + normalized['properties'] = Map.from(data); + } + + final type = event['type'] as String?; + if (type != null && + type.contains('permission') && + !type.contains('reply') && + !type.contains('list') && + !type.contains('saved')) { + // Prefer a stable alias for the permission dialog handler. + if (type != 'permission.asked') { + normalized['type'] = 'permission.asked'; + } + final rawProps = normalized['properties']; + final props = rawProps is Map + ? Map.from(rawProps) + : {}; + props['permissionID'] ??= props['id']; + props['sessionID'] ??= props['sessionId']; + props['message'] ??= + props['description'] ?? props['action'] ?? props['message']; + normalized['properties'] = props; + } + + return normalized; +} diff --git a/lib/services/secure_storage.dart b/lib/services/secure_storage.dart index 8c13568..5da4c72 100644 --- a/lib/services/secure_storage.dart +++ b/lib/services/secure_storage.dart @@ -34,6 +34,12 @@ abstract interface class SecureStorage { Future readProfilePassword(String profileId); Future writeProfilePassword(String profileId, String? password); Future deleteProfilePassword(String profileId); + Future readAgentPassword(String profileId); + Future writeAgentPassword(String profileId, String? password); + Future deleteAgentPassword(String profileId); + Future>> migrateAgentPasswords( + List> profiles, + ); Future readKeyPassphrase(String keyId); Future writeKeyPassphrase(String keyId, String? passphrase); Future deleteKeyPassphrase(String keyId); @@ -63,12 +69,16 @@ class SecureStorageImpl implements SecureStorage { static const String _opencodeZenApiKeyKey = 'opencode_zen_api_key'; static const String _kiloApiKeyKey = 'kilo_api_key'; static const String _profilePasswordPrefix = 'ssh_profile_password_'; + static const String _agentPasswordPrefix = 'ssh_agent_password_'; static const String _keyPassphrasePrefix = 'ssh_key_passphrase_'; static const String _privateKeyPrefix = 'ssh_private_key_'; static String _profilePasswordKey(String profileId) => '$_profilePasswordPrefix$profileId'; + static String _agentPasswordKey(String profileId) => + '$_agentPasswordPrefix$profileId'; + static String _keyPassphraseKey(String keyId) => '$_keyPassphrasePrefix$keyId'; @@ -154,6 +164,29 @@ class SecureStorageImpl implements SecureStorage { () => _storage.delete(key: _profilePasswordKey(profileId))); } + @override + Future readAgentPassword(String profileId) async { + return _safeRead(() => _storage.read(key: _agentPasswordKey(profileId))); + } + + @override + Future writeAgentPassword( + String profileId, + String? password, + ) async { + final key = _agentPasswordKey(profileId); + if (password == null || password.isEmpty) { + await _safeDelete(() => _storage.delete(key: key)); + return; + } + await _safeWrite(() => _storage.write(key: key, value: password)); + } + + @override + Future deleteAgentPassword(String profileId) async { + await _safeDelete(() => _storage.delete(key: _agentPasswordKey(profileId))); + } + @override Future readKeyPassphrase(String keyId) async { return _safeRead(() => _storage.read(key: _keyPassphraseKey(keyId))); @@ -227,6 +260,32 @@ class SecureStorageImpl implements SecureStorage { return List>.from(profiles); } + @override + Future>> migrateAgentPasswords( + List> profiles, + ) async { + var changed = false; + final out = >[]; + for (final raw in profiles) { + final map = Map.from(raw); + final id = map['id'] as String?; + final password = map['agentPassword'] as String?; + if (id != null && password != null && password.isNotEmpty) { + final success = await _safeWrite(() => + _storage.write(key: _agentPasswordKey(id), value: password)); + if (success) { + map['agentPassword'] = null; + changed = true; + } + } + out.add(map); + } + if (changed) { + return out; + } + return List>.from(profiles); + } + @override Future>> migrateKeyPassphrases( List> keys, diff --git a/lib/services/secure_storage_service.dart b/lib/services/secure_storage_service.dart index ebc2238..03506b4 100644 --- a/lib/services/secure_storage_service.dart +++ b/lib/services/secure_storage_service.dart @@ -36,6 +36,21 @@ class SecureStorageService { return _impl.deleteProfilePassword(profileId); } + static Future readAgentPassword(String profileId) async { + return _impl.readAgentPassword(profileId); + } + + static Future writeAgentPassword( + String profileId, + String? password, + ) async { + return _impl.writeAgentPassword(profileId, password); + } + + static Future deleteAgentPassword(String profileId) async { + return _impl.deleteAgentPassword(profileId); + } + static Future readKeyPassphrase(String keyId) async { return _impl.readKeyPassphrase(keyId); } @@ -78,6 +93,13 @@ class SecureStorageService { return _impl.migrateProfilePasswords(profiles); } + /// Migrates plaintext agent passwords into secure storage. + static Future>> migrateAgentPasswords( + List> profiles, + ) async { + return _impl.migrateAgentPasswords(profiles); + } + /// Migrates plaintext key passphrases into secure storage. static Future>> migrateKeyPassphrases( List> keys, diff --git a/lib/services/share_clone_handler.dart b/lib/services/share_clone_handler.dart index 2be9ede..39250c3 100644 --- a/lib/services/share_clone_handler.dart +++ b/lib/services/share_clone_handler.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../models/session_entry.dart'; diff --git a/lib/services/widget_launch_handler.dart b/lib/services/widget_launch_handler.dart index 71414a2..022f070 100644 --- a/lib/services/widget_launch_handler.dart +++ b/lib/services/widget_launch_handler.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../models/ssh_profile.dart'; diff --git a/lib/utils/terminal_clipboard.dart b/lib/utils/terminal_clipboard.dart new file mode 100644 index 0000000..bb8c392 --- /dev/null +++ b/lib/utils/terminal_clipboard.dart @@ -0,0 +1,25 @@ +import 'package:flutter/services.dart'; + +/// Shared clipboard helpers for terminal copy/paste. +/// +/// Always uses [Clipboard.kTextPlain] and never trims paste content so +/// intentional leading/trailing whitespace in commands is preserved. +class TerminalClipboard { + const TerminalClipboard._(); + + /// Copies [text] to the system clipboard. + static Future copyText(String text) { + return Clipboard.setData(ClipboardData(text: text)); + } + + /// Reads plain text from the clipboard, or `null` when empty/unavailable. + static Future readText() async { + final ClipboardData? data = + await Clipboard.getData(Clipboard.kTextPlain); + final String? text = data?.text; + if (text == null || text.isEmpty) { + return null; + } + return text; + } +} diff --git a/lib/utils/terminal_style_builder.dart b/lib/utils/terminal_style_builder.dart index 6e80eff..d523231 100644 --- a/lib/utils/terminal_style_builder.dart +++ b/lib/utils/terminal_style_builder.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:xterm/xterm.dart'; diff --git a/lib/utils/terminal_themes.dart b/lib/utils/terminal_themes.dart index 790d420..5323254 100644 --- a/lib/utils/terminal_themes.dart +++ b/lib/utils/terminal_themes.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:xterm/xterm.dart'; /// Color palette applied to [TerminalView], independent of app ThemeData. diff --git a/lib/widgets/agent/agent_chat_pane.dart b/lib/widgets/agent/agent_chat_pane.dart index df5bdd7..748fb5c 100644 --- a/lib/widgets/agent/agent_chat_pane.dart +++ b/lib/widgets/agent/agent_chat_pane.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../models/agent_connection.dart'; import '../../providers/agent_provider.dart'; diff --git a/lib/widgets/agent/agent_connection_bar.dart b/lib/widgets/agent/agent_connection_bar.dart index 1368534..05961ce 100644 --- a/lib/widgets/agent/agent_connection_bar.dart +++ b/lib/widgets/agent/agent_connection_bar.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../../providers/agent_provider.dart'; diff --git a/lib/widgets/agent/agent_directory_bar.dart b/lib/widgets/agent/agent_directory_bar.dart index 8056c81..627aa34 100644 --- a/lib/widgets/agent/agent_directory_bar.dart +++ b/lib/widgets/agent/agent_directory_bar.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../../models/agent_connection.dart'; diff --git a/lib/widgets/agent/agent_scroll_fab.dart b/lib/widgets/agent/agent_scroll_fab.dart index 9a374c4..d4d71b8 100644 --- a/lib/widgets/agent/agent_scroll_fab.dart +++ b/lib/widgets/agent/agent_scroll_fab.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// Floating action button for scrolling chat messages. class AgentScrollFab extends StatelessWidget { diff --git a/lib/widgets/agent/agent_session_list.dart b/lib/widgets/agent/agent_session_list.dart index e85d726..8148ba7 100644 --- a/lib/widgets/agent/agent_session_list.dart +++ b/lib/widgets/agent/agent_session_list.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:opencode_api/opencode_api.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/agent_message_bubble.dart b/lib/widgets/agent_message_bubble.dart index 164f652..0c846a7 100644 --- a/lib/widgets/agent_message_bubble.dart +++ b/lib/widgets/agent_message_bubble.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:opencode_api/opencode_api.dart'; import '../utils/agent_message_grouping.dart'; diff --git a/lib/widgets/agent_message_part_tile.dart b/lib/widgets/agent_message_part_tile.dart index d6d8d8e..b93fe4c 100644 --- a/lib/widgets/agent_message_part_tile.dart +++ b/lib/widgets/agent_message_part_tile.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:opencode_api/opencode_api.dart'; String _normalizePartType(MessagePart part) { diff --git a/lib/widgets/agent_model_provider_sheet.dart b/lib/widgets/agent_model_provider_sheet.dart index aea20be..9bb539d 100644 --- a/lib/widgets/agent_model_provider_sheet.dart +++ b/lib/widgets/agent_model_provider_sheet.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:opencode_api/opencode_api.dart' as opencode_api; import 'package:provider/provider.dart'; diff --git a/lib/widgets/agent_permission_dialog.dart b/lib/widgets/agent_permission_dialog.dart index f556217..33073d1 100644 --- a/lib/widgets/agent_permission_dialog.dart +++ b/lib/widgets/agent_permission_dialog.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../models/agent_permission_request.dart'; diff --git a/lib/widgets/agent_prompt_input.dart b/lib/widgets/agent_prompt_input.dart index 1ab3719..988f0ec 100644 --- a/lib/widgets/agent_prompt_input.dart +++ b/lib/widgets/agent_prompt_input.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; import 'package:opencode_api/opencode_api.dart'; diff --git a/lib/widgets/ai_command_button.dart b/lib/widgets/ai_command_button.dart deleted file mode 100644 index af50cf0..0000000 --- a/lib/widgets/ai_command_button.dart +++ /dev/null @@ -1,295 +0,0 @@ -import 'dart:async'; - -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; - -import '../models/ai_provider.dart'; -import '../providers/settings_provider.dart'; -import '../providers/ssh_provider.dart'; -import '../services/ai_gateway_service.dart'; -import '../utils/terminal_context.dart'; -import '../utils/terminal_enter_mapping.dart'; - -class AiCommandButton extends StatelessWidget { - const AiCommandButton({super.key}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(right: 4), - child: InkWell( - onTap: () => _showGenerateDialog(context), - borderRadius: BorderRadius.circular(0.5), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: Colors.purple.shade800, - borderRadius: BorderRadius.circular(0.25), - border: Border.all(color: Colors.purple.shade300), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - 'AI', - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: TextStyle( - fontSize: 7, - fontFamily: 'monospace', - fontWeight: FontWeight.bold, - color: Colors.white, - ), - ), - ), - SizedBox(width: 2), - Flexible( - child: Text( - 'Generate command', - overflow: TextOverflow.ellipsis, - maxLines: 1, - style: TextStyle( - fontSize: 7, - color: Colors.white70, - ), - ), - ), - ], - ), - ), - ), - ); - } - - Future _showGenerateDialog(BuildContext context) async { - await showDialog( - context: context, - builder: (dialogContext) => const _AiCommandDialog(), - ); - } -} - -class _AiCommandDialog extends StatefulWidget { - const _AiCommandDialog(); - - @override - State<_AiCommandDialog> createState() => _AiCommandDialogState(); -} - -class _AiCommandDialogState extends State<_AiCommandDialog> { - final TextEditingController _promptController = TextEditingController(); - final TextEditingController _resultController = TextEditingController(); - final CancelToken _cancelToken = CancelToken(); - StreamSubscription? _streamSubscription; - bool _isGenerating = false; - bool _includeTerminalContext = true; - bool _hasResult = false; - - @override - void initState() { - super.initState(); - _resultController.addListener(_onResultChanged); - } - - void _onResultChanged() { - final hasResult = _resultController.text.trim().isNotEmpty; - if (hasResult != _hasResult) { - setState(() => _hasResult = hasResult); - } - } - - @override - void dispose() { - _streamSubscription?.cancel(); - _cancelToken.cancel(); - _resultController - ..removeListener(_onResultChanged) - ..dispose(); - _promptController.dispose(); - super.dispose(); - } - - Future _generate() async { - final settings = context.read(); - final ssh = context.read(); - final prompt = _promptController.text.trim(); - if (prompt.isEmpty) return; - - await _streamSubscription?.cancel(); - _resultController.clear(); - - final terminalContext = _includeTerminalContext - ? ssh.getActiveTerminalContext( - lineCount: kDefaultTerminalContextLines, - ) - : null; - - setState(() => _isGenerating = true); - try { - final stream = AiGatewayService.streamCommand( - provider: settings.aiProvider, - apiKey: settings.activeAiApiKey, - model: settings.activeAiModel, - userPrompt: prompt, - terminalContext: terminalContext, - cancelToken: _cancelToken, - ); - - _streamSubscription = stream.listen( - (chunk) { - _resultController.text += chunk; - _resultController.selection = TextSelection.collapsed( - offset: _resultController.text.length, - ); - }, - onError: (Object error) { - if (!mounted) return; - setState(() => _isGenerating = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(error.toString()), - backgroundColor: Colors.red, - ), - ); - }, - onDone: () { - if (!mounted) return; - setState(() => _isGenerating = false); - if (_resultController.text.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('AI returned an empty command'), - backgroundColor: Colors.red, - ), - ); - } - }, - cancelOnError: true, - ); - } catch (e) { - if (!mounted) return; - setState(() => _isGenerating = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e.toString()), - backgroundColor: Colors.red, - ), - ); - } - } - - Future _sendToTerminal() async { - final command = _resultController.text.trim(); - if (command.isEmpty) return; - - final ssh = context.read(); - ssh.sendString(withEnterSuffix(command, ssh.terminalEnterSends)); - if (mounted) Navigator.of(context).pop(); - } - - Future _copyCommand() async { - final command = _resultController.text.trim(); - if (command.isEmpty) return; - await Clipboard.setData(ClipboardData(text: command)); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Command copied to clipboard')), - ); - } - } - - @override - Widget build(BuildContext context) { - final settings = context.watch(); - - return AlertDialog( - title: const Text('AI Command Generator'), - content: SizedBox( - width: 420, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Provider: ${settings.aiProvider.displayName} · ' - 'Model: ${settings.activeAiModel}', - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 12), - TextField( - controller: _promptController, - decoration: const InputDecoration( - labelText: 'Describe the command', - hintText: 'e.g. list running docker containers', - border: OutlineInputBorder(), - ), - minLines: 2, - maxLines: 4, - enabled: !_isGenerating, - ), - const SizedBox(height: 8), - CheckboxListTile( - contentPadding: EdgeInsets.zero, - dense: true, - title: Text( - 'Include last $kDefaultTerminalContextLines lines of terminal ' - 'output', - style: Theme.of(context).textTheme.bodySmall, - ), - value: _includeTerminalContext, - onChanged: _isGenerating - ? null - : (value) { - setState(() { - _includeTerminalContext = value ?? true; - }); - }, - ), - const SizedBox(height: 4), - TextField( - controller: _resultController, - decoration: InputDecoration( - labelText: 'Generated command', - border: const OutlineInputBorder(), - suffixIcon: _isGenerating - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : null, - ), - minLines: 2, - maxLines: 6, - readOnly: _isGenerating, - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: _isGenerating ? null : () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - TextButton( - onPressed: !_hasResult || _isGenerating ? null : _copyCommand, - child: const Text('Copy'), - ), - FilledButton( - onPressed: _isGenerating ? null : _generate, - child: const Text('Generate'), - ), - FilledButton( - onPressed: !_hasResult || _isGenerating ? null : _sendToTerminal, - child: const Text('Send to terminal'), - ), - ], - ); - } -} diff --git a/lib/widgets/ai_command_overlay.dart b/lib/widgets/ai_command_overlay.dart new file mode 100644 index 0000000..633d118 --- /dev/null +++ b/lib/widgets/ai_command_overlay.dart @@ -0,0 +1,314 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:provider/provider.dart'; + +import 'package:ssh_app/models/ai_provider.dart'; +import 'package:ssh_app/providers/settings_provider.dart'; +import 'package:ssh_app/providers/ssh_provider.dart'; +import 'package:ssh_app/services/ai_gateway_service.dart'; +import 'package:ssh_app/utils/terminal_clipboard.dart'; +import 'package:ssh_app/utils/terminal_context.dart'; +import 'package:ssh_app/utils/terminal_enter_mapping.dart'; + +/// Termius-style top overlay for converting natural language to a shell command. +class AiCommandOverlay extends StatefulWidget { + const AiCommandOverlay({ + required this.onClose, + super.key, + }); + + final VoidCallback onClose; + + @override + State createState() => _AiCommandOverlayState(); +} + +class _AiCommandOverlayState extends State { + final TextEditingController _promptController = TextEditingController(); + final TextEditingController _resultController = TextEditingController(); + final CancelToken _cancelToken = CancelToken(); + StreamSubscription? _streamSubscription; + bool _isGenerating = false; + bool _includeTerminalContext = true; + bool _hasResult = false; + + @override + void initState() { + super.initState(); + _resultController.addListener(_onResultChanged); + } + + void _onResultChanged() { + final hasResult = _resultController.text.trim().isNotEmpty; + if (hasResult != _hasResult) { + setState(() => _hasResult = hasResult); + } + } + + @override + void dispose() { + _streamSubscription?.cancel(); + _cancelToken.cancel(); + _resultController + ..removeListener(_onResultChanged) + ..dispose(); + _promptController.dispose(); + super.dispose(); + } + + Future _generate() async { + final settings = context.read(); + final ssh = context.read(); + final prompt = _promptController.text.trim(); + if (prompt.isEmpty) { + return; + } + + if (settings.activeAiApiKey.isEmpty) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Configure an AI API key in Settings first'), + backgroundColor: Colors.orange, + ), + ); + return; + } + + await _streamSubscription?.cancel(); + _resultController.clear(); + + final terminalContext = _includeTerminalContext + ? ssh.getActiveTerminalContext( + lineCount: kDefaultTerminalContextLines, + ) + : null; + + setState(() => _isGenerating = true); + try { + final stream = AiGatewayService.streamCommand( + provider: settings.aiProvider, + apiKey: settings.activeAiApiKey, + model: settings.activeAiModel, + userPrompt: prompt, + terminalContext: terminalContext, + cancelToken: _cancelToken, + ); + + _streamSubscription = stream.listen( + (chunk) { + _resultController.text += chunk; + _resultController.selection = TextSelection.collapsed( + offset: _resultController.text.length, + ); + }, + onError: (Object error) { + if (!mounted) { + return; + } + setState(() => _isGenerating = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(error.toString()), + backgroundColor: Colors.red, + ), + ); + }, + onDone: () { + if (!mounted) { + return; + } + setState(() => _isGenerating = false); + if (_resultController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('AI returned an empty command'), + backgroundColor: Colors.red, + ), + ); + } + }, + cancelOnError: true, + ); + } catch (e) { + if (!mounted) { + return; + } + setState(() => _isGenerating = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toString()), + backgroundColor: Colors.red, + ), + ); + } + } + + Future _sendToTerminal() async { + final command = _resultController.text.trim(); + if (command.isEmpty) { + return; + } + + final ssh = context.read(); + ssh.sendString(withEnterSuffix(command, ssh.terminalEnterSends)); + widget.onClose(); + } + + Future _copyCommand() async { + final command = _resultController.text.trim(); + if (command.isEmpty) { + return; + } + await TerminalClipboard.copyText(command); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Command copied to clipboard')), + ); + } + } + + @override + Widget build(BuildContext context) { + final ColorScheme colors = Theme.of(context).colorScheme; + final settings = context.watch(); + + return Material( + elevation: 4, + color: colors.surfaceContainerHigh, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Convert text to command with AI', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colors.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + IconButton( + visualDensity: VisualDensity.compact, + tooltip: 'Close', + onPressed: _isGenerating ? null : widget.onClose, + icon: const Icon(Icons.close, size: 20), + ), + ], + ), + Text( + '${settings.aiProvider.displayName} · ${settings.activeAiModel}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextField( + controller: _promptController, + enabled: !_isGenerating, + decoration: InputDecoration( + hintText: 'example: monitor disk usage', + filled: true, + fillColor: colors.primary.withValues(alpha: 0.12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + isDense: true, + ), + minLines: 1, + maxLines: 3, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _generate(), + ), + ), + const SizedBox(width: 8), + Material( + color: colors.primary, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: _isGenerating ? null : _generate, + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 48, + height: 48, + child: _isGenerating + ? const Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Icon( + Icons.auto_awesome, + color: colors.onPrimary, + ), + ), + ), + ), + ], + ), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text( + 'Include last $kDefaultTerminalContextLines lines of output', + style: Theme.of(context).textTheme.bodySmall, + ), + value: _includeTerminalContext, + onChanged: _isGenerating + ? null + : (value) { + setState(() { + _includeTerminalContext = value ?? true; + }); + }, + ), + if (_hasResult || _isGenerating) ...[ + TextField( + controller: _resultController, + decoration: const InputDecoration( + labelText: 'Generated command', + border: OutlineInputBorder(), + isDense: true, + ), + minLines: 1, + maxLines: 4, + readOnly: _isGenerating, + ), + const SizedBox(height: 8), + Row( + children: [ + TextButton( + onPressed: + !_hasResult || _isGenerating ? null : _copyCommand, + child: const Text('Copy'), + ), + const Spacer(), + FilledButton( + onPressed: + !_hasResult || _isGenerating ? null : _sendToTerminal, + child: const Text('Send to terminal'), + ), + ], + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/ai_provider_settings.dart b/lib/widgets/ai_provider_settings.dart index f2ee400..cc70261 100644 --- a/lib/widgets/ai_provider_settings.dart +++ b/lib/widgets/ai_provider_settings.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../models/ai_provider.dart'; diff --git a/lib/widgets/app_about_tile.dart b/lib/widgets/app_about_tile.dart index 9b873e1..fafc5f1 100644 --- a/lib/widgets/app_about_tile.dart +++ b/lib/widgets/app_about_tile.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:ssh_app/constants/app_metadata.dart'; diff --git a/lib/widgets/connection_modal.dart b/lib/widgets/connection_modal.dart index 9a266a8..3ebfa2d 100644 --- a/lib/widgets/connection_modal.dart +++ b/lib/widgets/connection_modal.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import 'package:ssh_app/models/ssh_key.dart'; diff --git a/lib/widgets/ctrl_button_panel.dart b/lib/widgets/ctrl_button_panel.dart index 4d3beb8..d36db3e 100644 --- a/lib/widgets/ctrl_button_panel.dart +++ b/lib/widgets/ctrl_button_panel.dart @@ -1,9 +1,9 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import 'package:xterm/xterm.dart'; import 'package:ssh_app/providers/ssh_provider.dart'; +import 'package:ssh_app/utils/terminal_clipboard.dart'; /// Compact multi-row accessory keyboard for mobile SSH sessions. /// @@ -12,7 +12,10 @@ import 'package:ssh_app/providers/ssh_provider.dart'; /// to that keypress before auto-releasing. Gboard interop works because /// [SSHProvider] applies armed modifiers to the next terminal output. class CtrlButtonPanel extends StatelessWidget { - const CtrlButtonPanel({super.key}); + const CtrlButtonPanel({this.onAiGenerate, super.key}); + + /// Opens the AI command overlay when the wand control is tapped. + final VoidCallback? onAiGenerate; @override Widget build(BuildContext context) { @@ -27,12 +30,14 @@ class CtrlButtonPanel extends StatelessWidget { final bool ctrlLocked = ssh.lockedCtrl; final bool altLocked = ssh.lockedAlt; + final ColorScheme colors = Theme.of(context).colorScheme; return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + margin: const EdgeInsets.symmetric(vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), decoration: BoxDecoration( - color: const Color(0xFF16213E), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade700), + color: colors.surfaceContainerHighest.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colors.outlineVariant), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -61,6 +66,13 @@ class CtrlButtonPanel extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ + if (onAiGenerate != null) + _IconNavButton( + icon: Icons.auto_awesome, + tooltip: 'Generate command with AI', + color: colors.primary, + onTap: onAiGenerate!, + ), _NavButton( label: 'Esc', onTap: () => _sendKey( @@ -257,9 +269,8 @@ class CtrlButtonPanel extends StatelessWidget { BuildContext context, SSHProvider ssh, ) async { - final data = await Clipboard.getData('text/plain'); - final text = data?.text; - if (text == null || text.isEmpty) { + final text = await TerminalClipboard.readText(); + if (text == null) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Clipboard is empty')), @@ -267,11 +278,48 @@ class CtrlButtonPanel extends StatelessWidget { } return; } - // Do not trim — trailing newlines/spaces matter for scripts. ssh.sendString(text); } } +class _IconNavButton extends StatelessWidget { + const _IconNavButton({ + required this.icon, + required this.tooltip, + required this.onTap, + this.color, + }); + + final IconData icon; + final String tooltip; + final VoidCallback onTap; + final Color? color; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Tooltip( + message: tooltip, + child: Material( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(6), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Container( + constraints: const BoxConstraints(minHeight: 36, minWidth: 36), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + alignment: Alignment.center, + child: Icon(icon, size: 18, color: color ?? Colors.white), + ), + ), + ), + ), + ); + } +} + class _NavButton extends StatelessWidget { const _NavButton({ required this.label, @@ -287,16 +335,18 @@ class _NavButton extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 2), child: Material( color: Colors.grey[800], - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(6), child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(6), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + constraints: const BoxConstraints(minHeight: 36), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + alignment: Alignment.center, child: Text( label, style: const TextStyle( - fontSize: 11, + fontSize: 12, fontFamily: 'monospace', fontWeight: FontWeight.bold, color: Colors.white, @@ -332,16 +382,18 @@ class _ToggleButton extends StatelessWidget { : active ? Colors.teal.shade700 : Colors.grey[800], - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(6), child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(6), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + constraints: const BoxConstraints(minHeight: 36), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + alignment: Alignment.center, child: Text( label, style: TextStyle( - fontSize: 11, + fontSize: 12, fontFamily: 'monospace', fontWeight: FontWeight.bold, color: active ? Colors.white : Colors.white70, diff --git a/lib/widgets/host_key_dialog.dart b/lib/widgets/host_key_dialog.dart index f821ff0..721fdfe 100644 --- a/lib/widgets/host_key_dialog.dart +++ b/lib/widgets/host_key_dialog.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; import '../models/known_host.dart'; diff --git a/lib/widgets/key_manager.dart b/lib/widgets/key_manager.dart index b0032a6..1b1a17d 100644 --- a/lib/widgets/key_manager.dart +++ b/lib/widgets/key_manager.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../models/ssh_key.dart'; import '../services/ssh_key_generator.dart'; import '../services/config_service.dart'; diff --git a/lib/widgets/keyboard_shortcut_bar.dart b/lib/widgets/keyboard_shortcut_bar.dart index cb2b778..1679563 100644 --- a/lib/widgets/keyboard_shortcut_bar.dart +++ b/lib/widgets/keyboard_shortcut_bar.dart @@ -1,13 +1,12 @@ import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import 'package:xterm/xterm.dart'; import '../models/keyboard_shortcut.dart'; import '../providers/settings_provider.dart'; import '../providers/ssh_provider.dart'; -import 'ai_command_button.dart'; +import '../utils/terminal_clipboard.dart'; import 'connection_modal.dart'; import 'profile_manager.dart'; import 'network_discovery.dart'; @@ -163,7 +162,6 @@ class _ShortcutRow extends StatelessWidget { shortcut: s, isConnected: isConnected, )), - if (isFirstRow && isConnected) const AiCommandButton(), const SizedBox(width: 8), ], ), @@ -391,9 +389,8 @@ class _ShortcutChip extends StatelessWidget { BuildContext context, SSHProvider ssh, ) async { - final data = await Clipboard.getData('text/plain'); - final text = data?.text?.trim(); - if (text == null || text.isEmpty) { + final text = await TerminalClipboard.readText(); + if (text == null) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Clipboard is empty')), diff --git a/lib/widgets/log_viewer.dart b/lib/widgets/log_viewer.dart index e33ad9e..6b28d77 100644 --- a/lib/widgets/log_viewer.dart +++ b/lib/widgets/log_viewer.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../providers/settings_provider.dart'; diff --git a/lib/widgets/network_discovery.dart b/lib/widgets/network_discovery.dart index 79573e5..9d736ba 100644 --- a/lib/widgets/network_discovery.dart +++ b/lib/widgets/network_discovery.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../providers/ssh_provider.dart'; import '../services/network_discovery_service.dart'; diff --git a/lib/widgets/opencode_config_sheet.dart b/lib/widgets/opencode_config_sheet.dart index 1a84d98..1f64e54 100644 --- a/lib/widgets/opencode_config_sheet.dart +++ b/lib/widgets/opencode_config_sheet.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:opencode_api/opencode_api.dart' hide ConfigService, Provider; import 'package:provider/provider.dart'; @@ -163,7 +163,7 @@ class _OpenCodeConfigSheetState extends State<_OpenCodeConfigSheet> { final profile = widget.connection.profile; final updated = profile.copyWith( agentPort: remote.agentPort ?? profile.agentPort, - password: remote.password ?? profile.password, + agentPassword: remote.password ?? profile.agentPassword, ); await Provider.of(context, listen: false).saveProfile(updated); @@ -378,7 +378,7 @@ Future importOpenCodeConfigViaSsh(BuildContext context) async { final updated = profile.copyWith( agentPort: remote.agentPort ?? profile.agentPort, - password: remote.password ?? profile.password, + agentPassword: remote.password ?? profile.agentPassword, ); await ssh.saveProfile(updated); diff --git a/lib/widgets/profile_manager.dart b/lib/widgets/profile_manager.dart index c221785..180aae2 100644 --- a/lib/widgets/profile_manager.dart +++ b/lib/widgets/profile_manager.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../providers/ssh_provider.dart'; import '../models/ssh_key.dart'; @@ -118,9 +118,16 @@ class _ProfileManagerState extends State { TextEditingController(text: profile?.username ?? ''); final passwordController = TextEditingController(text: profile?.password ?? ''); + final agentUsernameController = TextEditingController( + text: profile?.agentUsername ?? 'opencode', + ); + final agentPasswordController = TextEditingController( + text: profile?.agentPassword ?? '', + ); final startupCommandController = TextEditingController(text: profile?.startupCommand ?? ''); var useHttps = profile?.useHttps ?? false; + var allowSelfSignedCerts = profile?.allowSelfSignedCerts ?? false; var sessionManager = profile?.sessionManager ?? SessionManager.none; String? selectedKeyId = profile?.privateKey != null && !looksLikePemPrivateKey(profile!.privateKey!) @@ -172,16 +179,33 @@ class _ProfileManagerState extends State { keyboardType: TextInputType.number, ), const SizedBox(height: 8), + TextField( + controller: agentUsernameController, + decoration: const InputDecoration( + labelText: 'Agent username', + hintText: 'opencode', + ), + ), + const SizedBox(height: 8), + TextField( + controller: agentPasswordController, + decoration: const InputDecoration( + labelText: 'Agent password (OpenCode serve)', + ), + obscureText: true, + ), + const SizedBox(height: 8), TextField( controller: usernameController, - decoration: - const InputDecoration(labelText: 'Username'), + decoration: const InputDecoration( + labelText: 'SSH Username', + ), ), const SizedBox(height: 8), TextField( controller: passwordController, decoration: const InputDecoration( - labelText: 'Password (optional with key)', + labelText: 'SSH Password (optional with key)', ), obscureText: true, ), @@ -241,9 +265,26 @@ class _ProfileManagerState extends State { SwitchListTile( title: const Text('Use HTTPS for Agent'), value: useHttps, - onChanged: (value) => - setDialogState(() => useHttps = value), + onChanged: (value) => setDialogState(() { + useHttps = value; + if (!value) { + allowSelfSignedCerts = false; + } + }), ), + if (useHttps) + SwitchListTile( + title: const Text('Allow self-signed cert'), + subtitle: const Text( + 'Only for trusted LAN OpenCode hosts. ' + 'Disables certificate verification for this ' + 'profile host.', + ), + value: allowSelfSignedCerts, + onChanged: (value) => setDialogState( + () => allowSelfSignedCerts = value, + ), + ), ], ), ), @@ -258,6 +299,7 @@ class _ProfileManagerState extends State { hostController.text.isEmpty) { return; } + final agentUser = agentUsernameController.text.trim(); final newProfile = SSHProfile( id: profile?.id, name: nameController.text, @@ -275,6 +317,13 @@ class _ProfileManagerState extends State { agentPort: int.tryParse(agentPortController.text) ?? 5000, useHttps: useHttps, + allowSelfSignedCerts: + useHttps && allowSelfSignedCerts, + agentUsername: + agentUser.isEmpty ? 'opencode' : agentUser, + agentPassword: agentPasswordController.text.isEmpty + ? null + : agentPasswordController.text, sessionManager: sessionManager, ); Provider.of(context, listen: false) diff --git a/lib/widgets/sftp/sftp_browser_header.dart b/lib/widgets/sftp/sftp_browser_header.dart index c358870..999c8c7 100644 --- a/lib/widgets/sftp/sftp_browser_header.dart +++ b/lib/widgets/sftp/sftp_browser_header.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; import 'package:ssh_app/utils/remote_fs_sort.dart'; diff --git a/lib/widgets/sftp/sftp_entry_list.dart b/lib/widgets/sftp/sftp_entry_list.dart index 91d8a73..0b21f0d 100644 --- a/lib/widgets/sftp/sftp_entry_list.dart +++ b/lib/widgets/sftp/sftp_entry_list.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; diff --git a/lib/widgets/sftp/sftp_image_preview.dart b/lib/widgets/sftp/sftp_image_preview.dart index db1d4c7..193b971 100644 --- a/lib/widgets/sftp/sftp_image_preview.dart +++ b/lib/widgets/sftp/sftp_image_preview.dart @@ -1,6 +1,6 @@ import 'dart:typed_data'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; const Set kSftpImagePreviewExtensions = { '.png', diff --git a/lib/widgets/sftp/sftp_text_preview.dart b/lib/widgets/sftp/sftp_text_preview.dart index 8327b71..45f2cce 100644 --- a/lib/widgets/sftp/sftp_text_preview.dart +++ b/lib/widgets/sftp/sftp_text_preview.dart @@ -1,7 +1,7 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; const Set kSftpTextPreviewExtensions = { '.txt', diff --git a/lib/widgets/sftp/sftp_transfer_banner.dart b/lib/widgets/sftp/sftp_transfer_banner.dart index 989b0ab..eaaa53b 100644 --- a/lib/widgets/sftp/sftp_transfer_banner.dart +++ b/lib/widgets/sftp/sftp_transfer_banner.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// Shared SFTP transfer status banner with cancel support. class SftpTransferBanner extends StatelessWidget { diff --git a/lib/widgets/sftp_browser.dart b/lib/widgets/sftp_browser.dart index 444a845..5ac0e58 100644 --- a/lib/widgets/sftp_browser.dart +++ b/lib/widgets/sftp_browser.dart @@ -4,7 +4,7 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import 'package:ssh_app/models/remote_fs_entry.dart'; diff --git a/lib/widgets/sftp_directory_picker.dart b/lib/widgets/sftp_directory_picker.dart index afd1a53..53df23b 100644 --- a/lib/widgets/sftp_directory_picker.dart +++ b/lib/widgets/sftp_directory_picker.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:dartssh2/dartssh2.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:ssh_app/providers/sftp_controller.dart'; import 'package:ssh_app/utils/remote_fs_sort.dart'; diff --git a/lib/widgets/shortcut_editor.dart b/lib/widgets/shortcut_editor.dart index 095e3f8..b45a034 100644 --- a/lib/widgets/shortcut_editor.dart +++ b/lib/widgets/shortcut_editor.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import 'package:ssh_app/models/keyboard_shortcut.dart'; import 'package:ssh_app/providers/settings_provider.dart'; diff --git a/lib/widgets/snippet_button_panel.dart b/lib/widgets/snippet_button_panel.dart index e47a229..faa470d 100644 --- a/lib/widgets/snippet_button_panel.dart +++ b/lib/widgets/snippet_button_panel.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../providers/ssh_provider.dart'; import '../providers/snippet_provider.dart'; diff --git a/lib/widgets/snippet_manager.dart b/lib/widgets/snippet_manager.dart index c0b8907..dfa7b12 100644 --- a/lib/widgets/snippet_manager.dart +++ b/lib/widgets/snippet_manager.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../models/snippet.dart'; import '../providers/snippet_provider.dart'; diff --git a/lib/widgets/ssh_client_form.dart b/lib/widgets/ssh_client_form.dart index 478042c..e1da00c 100644 --- a/lib/widgets/ssh_client_form.dart +++ b/lib/widgets/ssh_client_form.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../providers/ssh_provider.dart'; diff --git a/lib/widgets/terminal_font_settings.dart b/lib/widgets/terminal_font_settings.dart index ae131c3..7edde41 100644 --- a/lib/widgets/terminal_font_settings.dart +++ b/lib/widgets/terminal_font_settings.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../providers/settings_provider.dart'; @@ -28,11 +28,21 @@ class TerminalFontSettings extends StatelessWidget { ), Slider( value: settings.terminalFontSize, - min: 6, - max: 24, + min: SettingsProvider.kTerminalFontSizeMin, + max: SettingsProvider.kTerminalFontSizeMax, divisions: 18, onChanged: settings.setTerminalFontSize, ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + secondary: const Icon(Icons.pinch_outlined), + title: const Text('Pinch to zoom'), + subtitle: const Text( + 'Two-finger pinch on the terminal changes font size', + ), + value: settings.terminalPinchToZoom, + onChanged: settings.setTerminalPinchToZoom, + ), const SizedBox(height: 8), const Text('Font Family'), const SizedBox(height: 8), diff --git a/lib/widgets/terminal_input_settings.dart b/lib/widgets/terminal_input_settings.dart index f4fe7e0..c4cf850 100644 --- a/lib/widgets/terminal_input_settings.dart +++ b/lib/widgets/terminal_input_settings.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../providers/settings_provider.dart'; diff --git a/lib/widgets/terminal_pinch_zoom.dart b/lib/widgets/terminal_pinch_zoom.dart new file mode 100644 index 0000000..2b45b74 --- /dev/null +++ b/lib/widgets/terminal_pinch_zoom.dart @@ -0,0 +1,96 @@ +import 'package:material_ui/material_ui.dart'; + +/// Two-finger pinch that adjusts terminal font size. +/// +/// Uses a translucent [Listener] so the terminal keeps one-finger scroll and +/// selection gestures; only two-pointer distance changes affect the size. +class TerminalPinchZoom extends StatefulWidget { + const TerminalPinchZoom({ + required this.fontSize, + required this.minFontSize, + required this.maxFontSize, + required this.onChanged, + required this.onCommit, + required this.child, + super.key, + }); + + final double fontSize; + final double minFontSize; + final double maxFontSize; + final ValueChanged onChanged; + final ValueChanged onCommit; + final Widget child; + + @override + State createState() => _TerminalPinchZoomState(); +} + +class _TerminalPinchZoomState extends State { + final Map _pointers = {}; + double? _startDistance; + double? _startFontSize; + double? _lastEmitted; + + @override + Widget build(BuildContext context) { + return Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: _onPointerDown, + onPointerMove: _onPointerMove, + onPointerUp: _onPointerUp, + onPointerCancel: _onPointerUp, + child: widget.child, + ); + } + + void _onPointerDown(PointerDownEvent event) { + _pointers[event.pointer] = event.localPosition; + if (_pointers.length == 2) { + _startDistance = _pointerDistance(); + _startFontSize = widget.fontSize; + _lastEmitted = widget.fontSize; + } + } + + void _onPointerMove(PointerMoveEvent event) { + if (!_pointers.containsKey(event.pointer)) return; + _pointers[event.pointer] = event.localPosition; + + final startDistance = _startDistance; + final startFontSize = _startFontSize; + if (_pointers.length != 2 || + startDistance == null || + startFontSize == null || + startDistance < 8) { + return; + } + + final scale = _pointerDistance() / startDistance; + final next = (startFontSize * scale).clamp( + widget.minFontSize, + widget.maxFontSize, + ); + final rounded = next.roundToDouble(); + if (_lastEmitted == rounded) return; + _lastEmitted = rounded; + widget.onChanged(rounded); + } + + void _onPointerUp(PointerEvent event) { + final wasPinching = _pointers.length >= 2; + _pointers.remove(event.pointer); + if (!wasPinching || _pointers.length >= 2) return; + + final committed = _lastEmitted ?? widget.fontSize; + _startDistance = null; + _startFontSize = null; + _lastEmitted = null; + widget.onCommit(committed); + } + + double _pointerDistance() { + final points = _pointers.values.toList(growable: false); + return (points[0] - points[1]).distance; + } +} diff --git a/lib/widgets/terminal_search_bar.dart b/lib/widgets/terminal_search_bar.dart index 428b580..c1e655e 100644 --- a/lib/widgets/terminal_search_bar.dart +++ b/lib/widgets/terminal_search_bar.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:xterm/xterm.dart'; import 'package:ssh_app/utils/terminal_search.dart'; diff --git a/lib/widgets/terminal_selection_handles.dart b/lib/widgets/terminal_selection_handles.dart index 3c8736a..00a52f7 100644 --- a/lib/widgets/terminal_selection_handles.dart +++ b/lib/widgets/terminal_selection_handles.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; import 'package:xterm/xterm.dart'; @@ -49,9 +49,10 @@ class _TerminalListenable extends ChangeNotifier { } class _TerminalSelectionHandlesState extends State { - late final _TerminalListenable _terminalListenable; + late _TerminalListenable _terminalListenable; ScrollController? _scrollController; VoidCallback? _scrollListener; + bool _layoutRetryScheduled = false; @override void initState() { @@ -60,6 +61,19 @@ class _TerminalSelectionHandlesState extends State { _attachScrollController(widget.scrollController); } + void _scheduleLayoutRetry() { + if (_layoutRetryScheduled || !mounted) { + return; + } + _layoutRetryScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _layoutRetryScheduled = false; + if (mounted && widget.controller.selection != null) { + setState(() {}); + } + }); + } + @override void didUpdateWidget(TerminalSelectionHandles oldWidget) { super.didUpdateWidget(oldWidget); @@ -178,13 +192,15 @@ class _TerminalSelectionHandlesState extends State { try { render = state.renderTerminal; } catch (_) { - // View not laid out yet — skip this frame. + // View not laid out yet — retry next frame. + _scheduleLayoutRetry(); return const SizedBox.shrink(); } final BufferRange normalized = selection.normalized; final Offset? base = _toLocal(render, normalized.begin); final Offset? extent = _toLocal(render, normalized.end); if (base == null || extent == null) { + _scheduleLayoutRetry(); return const SizedBox.shrink(); } final Color accent = Theme.of(context).colorScheme.primary; diff --git a/lib/widgets/terminal_selection_input_guard.dart b/lib/widgets/terminal_selection_input_guard.dart index 937b6b6..3a04489 100644 --- a/lib/widgets/terminal_selection_input_guard.dart +++ b/lib/widgets/terminal_selection_input_guard.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:xterm/xterm.dart'; /// Suspends mouse/tap forwarding to the remote terminal while text is selected. diff --git a/lib/widgets/terminal_selection_toolbar.dart b/lib/widgets/terminal_selection_toolbar.dart index c3ac8d8..c72a13f 100644 --- a/lib/widgets/terminal_selection_toolbar.dart +++ b/lib/widgets/terminal_selection_toolbar.dart @@ -1,7 +1,7 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:xterm/xterm.dart'; +import 'package:ssh_app/utils/terminal_clipboard.dart'; import 'package:ssh_app/utils/terminal_selection.dart'; /// Compact action bar shown while the terminal has an active selection. @@ -19,10 +19,15 @@ class TerminalSelectionToolbar extends StatelessWidget { Future _copy(BuildContext context, {required bool clearAfter}) async { final String? text = selectedTerminalText(terminal, controller); - if (text == null) { + if (text == null || text.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Nothing to copy')), + ); + } return; } - await Clipboard.setData(ClipboardData(text: text)); + await TerminalClipboard.copyText(text); if (clearAfter) { controller.clearSelection(); } @@ -60,32 +65,42 @@ class TerminalSelectionToolbar extends StatelessWidget { return const SizedBox.shrink(); } final ColorScheme colors = Theme.of(context).colorScheme; + final ButtonStyle actionStyle = TextButton.styleFrom( + foregroundColor: colors.primary, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); return Material( - elevation: 2, - color: colors.surfaceContainerHigh, + elevation: 1, + color: colors.surfaceContainerHighest, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( mainAxisSize: MainAxisSize.min, children: [ TextButton.icon( + style: actionStyle, onPressed: () => _copy(context, clearAfter: true), icon: const Icon(Icons.copy, size: 16), label: const Text('Copy'), ), TextButton.icon( + style: actionStyle, onPressed: _selectAll, icon: const Icon(Icons.select_all, size: 16), label: const Text('Select all'), ), TextButton.icon( + style: actionStyle, onPressed: controller.clearSelection, icon: const Icon(Icons.clear, size: 16), label: const Text('Clear'), ), TextButton.icon( + style: actionStyle, onPressed: () async { await onPaste(); controller.clearSelection(); diff --git a/lib/widgets/theme_picker.dart b/lib/widgets/theme_picker.dart index 83cb3ba..3969462 100644 --- a/lib/widgets/theme_picker.dart +++ b/lib/widgets/theme_picker.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../providers/settings_provider.dart'; diff --git a/lib/widgets/toolbar_action_settings.dart b/lib/widgets/toolbar_action_settings.dart index c38f2ba..c27bc31 100644 --- a/lib/widgets/toolbar_action_settings.dart +++ b/lib/widgets/toolbar_action_settings.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import 'package:ssh_app/models/home_toolbar_action.dart'; diff --git a/pubspec.lock b/pubspec.lock index 30952ee..392c4b3 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "48e48f73a431eb269eec66d243a63a07ed90ef9417a74047a3b98d52338626ad" + sha256: "9bfb8122e2505aca17219018593c384a87166cfe860d11c7ae927e7f978eb05f" url: "https://pub.dev" source: hosted - version: "109.0.0" + version: "110.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "6d377901d198536b648172974d9d4f277910502cad92193466869cac0ff34990" + sha256: "74c36179c4de542efea24c9dff78324375e4b118a2c1e14e8a7659f457947f8d" url: "https://pub.dev" source: hosted - version: "14.5.0" + version: "14.6.0" android_file_picker: dependency: transitive description: name: android_file_picker - sha256: "0aac28063a1b19893f02a55dc8e2318799814881b7f920df2d12d0d043c8fbbb" + sha256: e94229cc41c4bb5fb6a2254146340a8b650664c99b5c93b9001f66c3375d0060 url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.1" ansicolor: dependency: transitive description: @@ -93,10 +93,10 @@ packages: dependency: transitive description: name: build_daemon - sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33 + sha256: "39ab65f7c60c19af64b9ef96e6a5d3ab48355f7dddfd98a9079d55a8cb6628fc" url: "https://pub.dev" source: hosted - version: "4.1.6" + version: "4.1.7" build_runner: dependency: "direct dev" description: @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: code_builder - sha256: aa5932e94c6c39c2f9ec4e5e06dfdd11a9430a61f6c41b6ba75b28ce0c481baf + sha256: "811709c1badd02fc054039a13f66e0db1961865f9a314175f68c3eaeebec786f" url: "https://pub.dev" source: hosted - version: "4.12.0" + version: "4.13.0" collection: dependency: transitive description: @@ -217,14 +217,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: a47c4e7c66fd7444a063a602597fceb238f705c3f6b1322bc0053d0a2638ee6e + url: "https://pub.dev" + source: hosted + version: "1.1.4" dart_style: dependency: transitive description: name: dart_style - sha256: "82ade9fc4273f29ed673e33166944465225b4f7fc5d4aaef48605cc751c18fc1" + sha256: a0e4f65339b31eb6a9e6a12e14b4682a48d46fbc54a82f4a702396fbdbac63a7 url: "https://pub.dev" source: hosted - version: "3.1.13" + version: "3.1.14" dartssh2: dependency: "direct main" description: @@ -301,18 +309,18 @@ packages: dependency: "direct main" description: name: file_picker - sha256: "98c0b156b6380ba55bc767a14e2e6b3feb246e713ad40092424596fa79005bea" + sha256: f201f3cbbb38ea5cd4e6896549662690f445a66261484f8ad697351c1ca25581 url: "https://pub.dev" source: hosted - version: "13.1.0" + version: "13.2.0" file_picker_darwin: dependency: transitive description: name: file_picker_darwin - sha256: c40e125317842be577a05ffa111655094d7ff52734e3bbb328829c0d5c23fe14 + sha256: bd172f8ddec522f9bd9a26365545320f130a6ab7839eb2a634051fab4ced7eb7 url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.4" file_picker_linux: dependency: transitive description: @@ -333,10 +341,10 @@ packages: dependency: transitive description: name: file_picker_web - sha256: "32af8262aca907b02b8970b32328b0312db0b42ba6f930be6d27fde179d59b7d" + sha256: a2288008280aabe371aff029466b3405e60838e0a1d714767488c59b926c8b8c url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "4.2.0" fixnum: dependency: transitive description: @@ -358,6 +366,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_native_splash: dependency: "direct main" description: @@ -491,10 +504,10 @@ packages: dependency: transitive description: name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + sha256: "361668bdd71f0ae2dc6d17b75a593520f3c414af6534f9eed1aa72049dd71d18" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" http_parser: dependency: transitive description: @@ -515,10 +528,10 @@ packages: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.3" io: dependency: transitive description: @@ -631,6 +644,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + material_ui: + dependency: "direct main" + description: + name: material_ui + sha256: "98ab9eda579ecae623e4b376c603035c5def78ce706f735f30bd632af5814695" + url: "https://pub.dev" + source: hosted + version: "1.6.0" meta: dependency: transitive description: @@ -859,10 +880,10 @@ packages: dependency: transitive description: name: pubspec_parse - sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796 + sha256: "9ac81c01ffd6a9cf9b2713288834fbaaf6aebde05ffdf02d380e68b8e06f27d8" url: "https://pub.dev" source: hosted - version: "1.6.0" + version: "1.7.0" quiver: dependency: transitive description: @@ -915,18 +936,18 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + sha256: "593034a16e82953ca31145db6c85174286262715f9c5e6a9d8c65ffd9fcdce09" url: "https://pub.dev" source: hosted - version: "2.4.28" + version: "2.5.3" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + sha256: c2c02dd59bd7b3bb6bfb77aa7ad4a7886b24ee40e95dcaea754b83274b567ff5 url: "https://pub.dev" source: hosted - version: "2.5.7" + version: "2.6.0" shared_preferences_linux: dependency: transitive description: @@ -963,10 +984,10 @@ packages: dependency: transitive description: name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + sha256: e75e9cf311d7fd712048038cc17c25199c38536c20330d53e7db058b543d5a48 url: "https://pub.dev" source: hosted - version: "1.4.2" + version: "1.4.3" shelf_web_socket: dependency: transitive description: @@ -984,10 +1005,10 @@ packages: dependency: transitive description: name: source_gen - sha256: "11b6047da8e4eb6c643ccac3d0fda3f9c9e11651695f7da24a85ade8aff15a44" + sha256: "833ea7d15b5be79949d3d6c077da0267a0bd93c16897b36a771395a91e63086e" url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.3.1" source_span: dependency: transitive description: @@ -1152,10 +1173,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" url: "https://pub.dev" source: hosted - version: "15.3.0" + version: "15.4.0" watcher: dependency: transitive description: @@ -1168,10 +1189,10 @@ packages: dependency: transitive description: name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" web_socket: dependency: transitive description: @@ -1246,4 +1267,4 @@ packages: version: "0.0.6" sdks: dart: ">=3.13.0 <4.0.0" - flutter: ">=3.44.0" + flutter: ">=3.47.0" diff --git a/pubspec.yaml b/pubspec.yaml index f81bfe8..bfe8c62 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,7 +32,8 @@ dependencies: path: packages/home_widget package_info_plus: ^10.2.2 url_launcher: ^6.3.2 - intl: ^0.19.0 + intl: 0.20.3 + material_ui: 1.6.0 dev_dependencies: flutter_test: diff --git a/test/models/ssh_profile_agent_auth_test.dart b/test/models/ssh_profile_agent_auth_test.dart new file mode 100644 index 0000000..da2e7d3 --- /dev/null +++ b/test/models/ssh_profile_agent_auth_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ssh_app/models/ssh_profile.dart'; + +void main() { + group('SSHProfile agent auth', () { + test('defaults agentUsername to opencode', () { + final profile = SSHProfile( + name: 'win', + host: '192.168.1.1', + username: 'Lukas', + ); + expect(profile.agentUsername, 'opencode'); + expect(profile.effectiveAgentUsername, 'opencode'); + expect(profile.allowSelfSignedCerts, isFalse); + }); + + test('effectiveAgentUsername falls back when empty', () { + final profile = SSHProfile( + name: 'win', + host: 'h', + username: 'Lukas', + agentUsername: '', + ); + expect(profile.effectiveAgentUsername, 'opencode'); + }); + + test('effectiveAgentPassword prefers agentPassword', () { + final profile = SSHProfile( + name: 'win', + host: 'h', + username: 'Lukas', + password: '<>', + agentPassword: '<>', + ); + expect(profile.effectiveAgentPassword, '<>'); + }); + + test('effectiveAgentPassword falls back to SSH password', () { + final profile = SSHProfile( + name: 'win', + host: 'h', + username: 'Lukas', + password: '<>', + ); + expect(profile.effectiveAgentPassword, '<>'); + }); + + test('toJson omits agentPassword unless includePassword', () { + final profile = SSHProfile( + name: 'win', + host: 'h', + username: 'Lukas', + agentUsername: 'opencode', + agentPassword: '<>', + ); + expect(profile.toJson().containsKey('agentPassword'), isFalse); + expect(profile.toJson().containsKey('agentUsername'), isTrue); + expect( + profile.toJson(includePassword: true)['agentPassword'], + '<>', + ); + }); + + test('fromJson round-trips agentUsername and self-signed flag', () { + final profile = SSHProfile.fromJson({ + 'id': 'id-1', + 'name': 'n', + 'host': 'h', + 'username': 'u', + 'agentUsername': 'custom', + 'agentPort': 4000, + 'useHttps': true, + 'allowSelfSignedCerts': true, + }); + expect(profile.agentUsername, 'custom'); + expect(profile.agentPort, 4000); + expect(profile.useHttps, isTrue); + expect(profile.allowSelfSignedCerts, isTrue); + }); + }); +} diff --git a/test/providers/snippet_provider_reorder_test.dart b/test/providers/snippet_provider_reorder_test.dart new file mode 100644 index 0000000..4188681 --- /dev/null +++ b/test/providers/snippet_provider_reorder_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ssh_app/models/snippet.dart'; +import 'package:ssh_app/providers/snippet_provider.dart'; +import 'package:ssh_app/services/config_repository.dart'; + +class _MemoryConfig implements ConfigRepository { + List> snippets = >[]; + + @override + Future>> getSnippets() async => snippets; + + @override + Future saveSnippets(List> value) async { + snippets = value; + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + test('reorderSnippets persists new order', () async { + final config = _MemoryConfig() + ..snippets = >[ + Snippet(id: 'a', name: 'A', content: 'a').toJson(), + Snippet(id: 'b', name: 'B', content: 'b').toJson(), + Snippet(id: 'c', name: 'C', content: 'c').toJson(), + ]; + final provider = SnippetProvider(config: config); + await provider.loadSnippets(); + + // onReorderItem: move index 0 into index 2 (already adjusted). + await provider.reorderSnippets(0, 2); + + expect( + provider.snippets.map((s) => s.id).toList(), + ['b', 'c', 'a'], + ); + expect( + config.snippets.map((s) => s['id']).toList(), + ['b', 'c', 'a'], + ); + }); +} diff --git a/test/services/opencode_v2_mappers_test.dart b/test/services/opencode_v2_mappers_test.dart new file mode 100644 index 0000000..677b712 --- /dev/null +++ b/test/services/opencode_v2_mappers_test.dart @@ -0,0 +1,151 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ssh_app/services/opencode_v2_mappers.dart'; + +void main() { + group('unwrapV2Data', () { + test('returns inner data payload', () { + expect( + unwrapV2Data({ + 'data': {'id': 'ses_1'}, + }), + {'id': 'ses_1'}, + ); + }); + + test('returns body when no envelope', () { + expect(unwrapV2Data({'id': 'x'}), { + 'id': 'x', + }); + }); + }); + + group('mapV2Session', () { + test('maps location.directory and time', () { + final session = mapV2Session({ + 'id': 'ses_1', + 'projectID': 'proj', + 'title': 'Demo', + 'location': {'directory': r'I:\dev\app'}, + 'time': { + 'created': 1000, + 'updated': 2000, + }, + }); + + expect(session.id, 'ses_1'); + expect(session.directory, r'I:\dev\app'); + expect(session.title, 'Demo'); + expect(session.time?.updated, 2000); + }); + }); + + group('mapV2Message', () { + test('maps user text message', () { + final message = mapV2Message({ + 'id': 'msg_1', + 'type': 'user', + 'text': 'hello', + 'time': {'created': 1793063165868}, + }); + + expect(message.info?.role, 'user'); + expect(message.parts?.single.text, 'hello'); + }); + + test('maps assistant content parts', () { + final message = mapV2Message({ + 'id': 'msg_2', + 'type': 'assistant', + 'time': {'created': 1793063165868}, + 'content': >[ + {'type': 'reasoning', 'text': 'think'}, + {'type': 'text', 'text': 'answer'}, + { + 'type': 'tool', + 'id': 'call_1', + 'name': 'shell', + 'state': { + 'status': 'completed', + 'output': 'ok', + }, + }, + ], + }); + + expect(message.info?.role, 'assistant'); + expect(message.parts?.length, 3); + expect(message.parts?[1].type, 'text'); + expect(message.parts?[1].text, 'answer'); + expect(message.parts?[2].type, 'tool'); + expect(message.parts?[2].text, contains('shell')); + }); + }); + + group('parseSlashCommand', () { + test('splits name and arguments', () { + final parsed = parseSlashCommand('/model kilo/free'); + expect(parsed.name, 'model'); + expect(parsed.text, 'kilo/free'); + }); + + test('handles bare command', () { + final parsed = parseSlashCommand('/status'); + expect(parsed.name, 'status'); + expect(parsed.text, ''); + }); + }); + + group('mapPermissionDecision', () { + test('maps allow/deny/remember', () { + expect(mapPermissionDecision('allow', remember: false), 'once'); + expect(mapPermissionDecision('allow', remember: true), 'always'); + expect(mapPermissionDecision('deny', remember: false), 'reject'); + }); + }); + + group('normalizeV2Event', () { + test('copies data into properties', () { + final event = normalizeV2Event({ + 'type': 'session.step.started', + 'data': {'sessionID': 'ses_1'}, + }); + expect(event['properties'], {'sessionID': 'ses_1'}); + }); + + test('aliases permission events', () { + final event = normalizeV2Event({ + 'type': 'session.permission.asked', + 'data': { + 'id': 'perm_1', + 'sessionID': 'ses_1', + 'action': 'edit', + }, + }); + expect(event['type'], 'permission.asked'); + final props = event['properties'] as Map; + expect(props['permissionID'], 'perm_1'); + }); + }); + + group('defaultsFromV2ConfigInfo', () { + test('reads top-level and agent models', () { + final defaults = defaultsFromV2ConfigInfo({ + 'model': { + 'providerID': 'kilo', + 'model': 'kilo-auto/free', + }, + 'agents': { + 'plan': { + 'model': { + 'providerID': 'nvidia', + 'model': 'deepseek', + }, + }, + }, + }); + + expect(defaults['kilo'], 'kilo-auto/free'); + expect(defaults['nvidia'], 'deepseek'); + }); + }); +} diff --git a/test/services/secure_storage_test.dart b/test/services/secure_storage_test.dart index cb48598..653372f 100644 --- a/test/services/secure_storage_test.dart +++ b/test/services/secure_storage_test.dart @@ -148,6 +148,44 @@ void main() { }); }); + group('agent password operations', () { + test('readAgentPassword returns null when not set', () async { + final result = await storage.readAgentPassword('profile-1'); + expect(result, null); + }); + + test('writeAgentPassword writes value', () async { + await storage.writeAgentPassword('profile-1', 'serve-pass'); + final stored = + await fakeStorage.read(key: 'ssh_agent_password_profile-1'); + expect(stored, 'serve-pass'); + }); + + test('writeAgentPassword deletes when empty', () async { + await fakeStorage.write( + key: 'ssh_agent_password_profile-1', value: 'existing'); + await storage.writeAgentPassword('profile-1', ''); + final stored = + await fakeStorage.read(key: 'ssh_agent_password_profile-1'); + expect(stored, null); + }); + + test('migrateAgentPasswords moves plaintext into secure storage', + () async { + final migrated = await storage.migrateAgentPasswords([ + { + 'id': 'p1', + 'agentPassword': 'from-json', + }, + ]); + expect(migrated.single['agentPassword'], null); + expect( + await fakeStorage.read(key: 'ssh_agent_password_p1'), + 'from-json', + ); + }); + }); + group('key passphrase operations', () { test('readKeyPassphrase returns null when not set', () async { final result = await storage.readKeyPassphrase('key-1'); diff --git a/test/services/share_intent_test.dart b/test/services/share_intent_test.dart index cd51aa6..7797b2c 100644 --- a/test/services/share_intent_test.dart +++ b/test/services/share_intent_test.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/ui_test.dart b/test/ui_test.dart index 4a72544..94b387c 100644 --- a/test/ui_test.dart +++ b/test/ui_test.dart @@ -1,5 +1,5 @@ import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; diff --git a/test/utils/terminal_clipboard_test.dart b/test/utils/terminal_clipboard_test.dart new file mode 100644 index 0000000..10fe484 --- /dev/null +++ b/test/utils/terminal_clipboard_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ssh_app/utils/terminal_clipboard.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('TerminalClipboard', () { + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'Clipboard.setData') { + return null; + } + if (call.method == 'Clipboard.getData') { + return {'text': ' keep spaces '}; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + }); + + test('readText preserves whitespace (no trim)', () async { + final text = await TerminalClipboard.readText(); + expect(text, ' keep spaces '); + }); + + test('copyText completes', () async { + await TerminalClipboard.copyText('hello'); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index a341478..552a6e9 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,7 +5,7 @@ // gestures. You can also use WidgetTester to find child widgets in the widget // tree, read text, and verify that the values of widget properties are correct. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ssh_app/constants/app_metadata.dart'; diff --git a/test/widgets/sftp_browser_header_layout_test.dart b/test/widgets/sftp_browser_header_layout_test.dart index e163295..4b06912 100644 --- a/test/widgets/sftp_browser_header_layout_test.dart +++ b/test/widgets/sftp_browser_header_layout_test.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ssh_app/utils/remote_fs_sort.dart'; import 'package:ssh_app/widgets/sftp/sftp_browser_header.dart'; diff --git a/test/widgets/sftp_directory_picker_layout_test.dart b/test/widgets/sftp_directory_picker_layout_test.dart index 07df131..8b93e84 100644 --- a/test/widgets/sftp_directory_picker_layout_test.dart +++ b/test/widgets/sftp_directory_picker_layout_test.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:ssh_app/models/remote_fs_entry.dart'; import 'package:ssh_app/widgets/sftp/sftp_entry_list.dart'; diff --git a/test/widgets/shortcut_editor_test.dart b/test/widgets/shortcut_editor_test.dart index d62d538..abf56bd 100644 --- a/test/widgets/shortcut_editor_test.dart +++ b/test/widgets/shortcut_editor_test.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; diff --git a/test/widgets/terminal_selection_handles_test.dart b/test/widgets/terminal_selection_handles_test.dart index f8c9ae9..7aa44b7 100644 --- a/test/widgets/terminal_selection_handles_test.dart +++ b/test/widgets/terminal_selection_handles_test.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xterm/xterm.dart'; diff --git a/test/widgets/terminal_selection_toolbar_test.dart b/test/widgets/terminal_selection_toolbar_test.dart index e857243..2aae322 100644 --- a/test/widgets/terminal_selection_toolbar_test.dart +++ b/test/widgets/terminal_selection_toolbar_test.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:xterm/xterm.dart'; diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index be05421..9fce4ea 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,14 +7,14 @@ #include "generated_plugin_registrant.h" #include -#include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); - SharePlusWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index b6885b8..31be2b0 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,8 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows - share_plus url_launcher_windows + share_plus ) list(APPEND FLUTTER_FFI_PLUGIN_LIST