From 1bd7df79d884bbe097e29ab257b073c7bad659ab Mon Sep 17 00:00:00 2001 From: Manuel cortez Date: Mon, 22 Dec 2025 14:15:43 -0600 Subject: [PATCH] feat: Implement accessible SFTP file browser and native editor --- lib/services/ssh_service.dart | 33 ++++++ lib/ui/views/connection_view.dart | 14 ++- lib/ui/views/file_browser_view.dart | 172 ++++++++++++++++++++++++++++ lib/ui/views/file_editor_view.dart | 147 ++++++++++++++++++++++++ lib/ui/views/terminal_view.dart | 20 +++- pubspec.lock | 8 +- test/ansi_parser_test.dart | 1 - test/widget_test.dart | 2 +- 8 files changed, 387 insertions(+), 10 deletions(-) create mode 100644 lib/ui/views/file_browser_view.dart create mode 100644 lib/ui/views/file_editor_view.dart diff --git a/lib/services/ssh_service.dart b/lib/services/ssh_service.dart index 32c0fea..540047b 100644 --- a/lib/services/ssh_service.dart +++ b/lib/services/ssh_service.dart @@ -110,6 +110,39 @@ class SshService { } } + // --- SFTP Operations --- + + Future _getSftpClient() async { + if (_client == null) throw Exception('Not connected'); + return await _client!.sftp(); + } + + Future> listDirectory(String path) async { + final sftp = await _getSftpClient(); + return await sftp.listdir(path); + } + + Future readFile(String path) async { + final sftp = await _getSftpClient(); + + // Check file size first + final stat = await sftp.stat(path); + if ((stat.size ?? 0) > 1024 * 1024) { // 1MB limit + throw Exception('File is too large to edit (Max 1MB).'); + } + + final file = await sftp.open(path); + final content = await file.readBytes(); + return utf8.decode(content); + } + + Future writeFile(String path, String content) async { + final sftp = await _getSftpClient(); + final file = await sftp.open(path, mode: SftpFileOpenMode.create | SftpFileOpenMode.write | SftpFileOpenMode.truncate); + await file.writeBytes(utf8.encode(content)); + await file.close(); + } + void write(String data) { if (_session != null) { _session!.write(utf8.encode(data)); diff --git a/lib/ui/views/connection_view.dart b/lib/ui/views/connection_view.dart index 1a2bad9..bc28b6d 100644 --- a/lib/ui/views/connection_view.dart +++ b/lib/ui/views/connection_view.dart @@ -23,8 +23,11 @@ class _ConnectionViewState extends ConsumerState { // Listen for global connection errors (e.g. lost connection) ref.listen(connectionErrorProvider, (previous, next) { if (next != null) { - _showErrorDialog(next); - ref.read(connectionErrorProvider.notifier).state = null; + SemanticsService.sendAnnouncement( + View.of(context), + next, + TextDirection.ltr, + ); } }); @@ -252,6 +255,7 @@ class _NewConnectionFormState extends ConsumerState { } Future _pickKeyFile() async { + final view = View.of(context); try { FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.any, // SSH keys often have no extension or .pem/.key @@ -265,7 +269,11 @@ class _NewConnectionFormState extends ConsumerState { _privateKeyContent = content; _privateKeyName = file.name; }); - SemanticsService.announce('Key selected: ${file.name}', TextDirection.ltr); + SemanticsService.sendAnnouncement( + view, + 'Key selected: ${file.name}', + TextDirection.ltr, + ); } } } catch (e) { diff --git a/lib/ui/views/file_browser_view.dart b/lib/ui/views/file_browser_view.dart new file mode 100644 index 0000000..ac93d70 --- /dev/null +++ b/lib/ui/views/file_browser_view.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:accessible_terminal/state/terminal_provider.dart'; +import 'package:dartssh2/dartssh2.dart'; +import 'file_editor_view.dart'; + +class FileBrowserView extends ConsumerStatefulWidget { + final String initialPath; + const FileBrowserView({super.key, this.initialPath = '.'}); + + @override + ConsumerState createState() => _FileBrowserViewState(); +} + +class _FileBrowserViewState extends ConsumerState { + late String _currentPath; + List? _files; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _currentPath = widget.initialPath; + _loadDirectory(); + } + + Future _loadDirectory() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final sshService = ref.read(sshServiceProvider); + final files = await sshService.listDirectory(_currentPath); + + // Sort: Directories first, then alphabetically + files.sort((a, b) { + if (a.attr.isDirectory && !b.attr.isDirectory) return -1; + if (!a.attr.isDirectory && b.attr.isDirectory) return 1; + return a.filename.toLowerCase().compareTo(b.filename.toLowerCase()); + }); + + if (mounted) { + setState(() { + _files = files.where((f) => f.filename != '.' && f.filename != '..').toList(); + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + } + + void _navigate(String filename) { + setState(() { + if (_currentPath == '/') { + _currentPath = '/$filename'; + } else { + _currentPath = '$_currentPath/$filename'.replaceAll('//', '/'); + } + }); + _loadDirectory(); + } + + void _goUp() { + if (_currentPath == '/' || _currentPath == '.') return; + final parts = _currentPath.split('/'); + parts.removeLast(); + setState(() { + _currentPath = parts.isEmpty ? '/' : parts.join('/'); + if (_currentPath.isEmpty) _currentPath = '/'; + }); + _loadDirectory(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_currentPath), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadDirectory, + tooltip: 'Refresh', + ), + ], + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, color: Colors.red, size: 48), + const SizedBox(height: 16), + Text('Error: $_error', textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton(onPressed: _loadDirectory, child: const Text('Retry')), + ], + ), + ), + ); + } + + return Column( + children: [ + if (_currentPath != '/' && _currentPath != '.') + ListTile( + leading: const Icon(Icons.arrow_upward), + title: const Text('.. (Go Up)'), + onTap: _goUp, + ), + Expanded( + child: ListView.builder( + itemCount: _files?.length ?? 0, + itemBuilder: (context, index) { + final file = _files![index]; + final isDir = file.attr.isDirectory; + + return ListTile( + leading: Icon(isDir ? Icons.folder : Icons.insert_drive_file), + title: Text(file.filename), + subtitle: isDir ? null : Text(_formatSize(file.attr.size ?? 0)), + onTap: () { + if (isDir) { + _navigate(file.filename); + } else { + _openFile(file.filename); + } + }, + ); + }, + ), + ), + ], + ); + } + + String _formatSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + void _openFile(String filename) { + final fullPath = _currentPath == '/' ? '/$filename' : '$_currentPath/$filename'; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => FileEditorView(path: fullPath), + ), + ).then((_) => _loadDirectory()); // Refresh on return in case of changes + } +} diff --git a/lib/ui/views/file_editor_view.dart b/lib/ui/views/file_editor_view.dart new file mode 100644 index 0000000..cf13f02 --- /dev/null +++ b/lib/ui/views/file_editor_view.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:accessible_terminal/state/terminal_provider.dart'; + +class FileEditorView extends ConsumerStatefulWidget { + final String path; + const FileEditorView({super.key, required this.path}); + + @override + ConsumerState createState() => _FileEditorViewState(); +} + +class _FileEditorViewState extends ConsumerState { + final TextEditingController _controller = TextEditingController(); + bool _isLoading = true; + bool _isSaving = false; + String? _error; + + @override + void initState() { + super.initState(); + _loadFile(); + } + + Future _loadFile() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final sshService = ref.read(sshServiceProvider); + final content = await sshService.readFile(widget.path); + if (mounted) { + setState(() { + _controller.text = content; + _isLoading = false; + }); + } + } catch (e) { + String errorMessage = e.toString(); + if (e is FormatException) { + errorMessage = 'This file appears to be binary or uses an unsupported encoding. Cannot edit as text.'; + } + + if (mounted) { + setState(() { + _error = errorMessage; + _isLoading = false; + }); + } + } + } + + Future _saveFile() async { + setState(() { + _isSaving = true; + }); + + try { + final sshService = ref.read(sshServiceProvider); + await sshService.writeFile(widget.path, _controller.text); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('File saved successfully')), + ); + Navigator.pop(context); + } + } catch (e) { + if (mounted) { + setState(() { + _isSaving = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error saving file: $e'), backgroundColor: Colors.red), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final fileName = widget.path.split('/').last; + + return Scaffold( + appBar: AppBar( + title: Text('Editing: $fileName'), + actions: [ + if (!_isLoading && _error == null) + IconButton( + icon: _isSaving + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.save), + onPressed: _isSaving ? null : _saveFile, + tooltip: 'Save File', + ), + ], + ), + body: _buildBody(), + ); + } + + Widget _buildBody() { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, color: Colors.red, size: 48), + const SizedBox(height: 16), + Text('Error: $_error', textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton(onPressed: _loadFile, child: const Text('Retry')), + ], + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Semantics( + label: 'File content editor', + multiline: true, + child: TextField( + controller: _controller, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + autocorrect: false, + enableSuggestions: false, + style: const TextStyle(fontFamily: 'monospace', fontSize: 16), + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'Enter file content...', + ), + ), + ), + ); + } +} diff --git a/lib/ui/views/terminal_view.dart b/lib/ui/views/terminal_view.dart index 6c3ca3a..0100fc8 100644 --- a/lib/ui/views/terminal_view.dart +++ b/lib/ui/views/terminal_view.dart @@ -6,6 +6,7 @@ import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:accessible_terminal/state/terminal_provider.dart'; import 'package:accessible_terminal/models/terminal_line.dart'; import 'package:accessible_terminal/ui/widgets/ansi_text_parser.dart'; +import 'package:accessible_terminal/ui/views/file_browser_view.dart'; class TerminalView extends ConsumerStatefulWidget { const TerminalView({super.key}); @@ -114,7 +115,11 @@ class _TerminalViewState extends ConsumerState { final newLine = next.first; final cleanText = AnsiTextParser.strip(newLine.text); if (cleanText.trim().isNotEmpty) { - SemanticsService.announce(cleanText, TextDirection.ltr); + SemanticsService.sendAnnouncement( + View.of(context), + cleanText, + TextDirection.ltr, + ); } } }); @@ -135,6 +140,19 @@ class _TerminalViewState extends ConsumerState { ), ], ), + Semantics( + label: 'Open File Browser', + button: true, + child: IconButton( + icon: const Icon(Icons.folder_open), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const FileBrowserView()), + ); + }, + ), + ), Semantics( label: 'Disconnect', button: true, diff --git a/pubspec.lock b/pubspec.lock index 73428f7..74a62bb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -268,10 +268,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" package_info_plus: dependency: transitive description: @@ -505,10 +505,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" typed_data: dependency: transitive description: diff --git a/test/ansi_parser_test.dart b/test/ansi_parser_test.dart index 45f81d3..72efec1 100644 --- a/test/ansi_parser_test.dart +++ b/test/ansi_parser_test.dart @@ -1,6 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:accessible_terminal/ui/widgets/ansi_text_parser.dart'; -import 'package:flutter/material.dart'; void main() { group('AnsiTextParser', () { diff --git a/test/widget_test.dart b/test/widget_test.dart index 59e7b13..f619350 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -5,9 +5,9 @@ // 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:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import 'package:accessible_terminal/main.dart'; void main() {