feat: Implement accessible SFTP file browser and native editor

This commit is contained in:
2025-12-22 14:15:43 -06:00
parent 7a967ef759
commit 1bd7df79d8
8 changed files with 387 additions and 10 deletions

View File

@@ -23,8 +23,11 @@ class _ConnectionViewState extends ConsumerState<ConnectionView> {
// Listen for global connection errors (e.g. lost connection)
ref.listen<String?>(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<NewConnectionForm> {
}
Future<void> _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<NewConnectionForm> {
_privateKeyContent = content;
_privateKeyName = file.name;
});
SemanticsService.announce('Key selected: ${file.name}', TextDirection.ltr);
SemanticsService.sendAnnouncement(
view,
'Key selected: ${file.name}',
TextDirection.ltr,
);
}
}
} catch (e) {

View File

@@ -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<FileBrowserView> createState() => _FileBrowserViewState();
}
class _FileBrowserViewState extends ConsumerState<FileBrowserView> {
late String _currentPath;
List<SftpName>? _files;
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_currentPath = widget.initialPath;
_loadDirectory();
}
Future<void> _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
}
}

View File

@@ -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<FileEditorView> createState() => _FileEditorViewState();
}
class _FileEditorViewState extends ConsumerState<FileEditorView> {
final TextEditingController _controller = TextEditingController();
bool _isLoading = true;
bool _isSaving = false;
String? _error;
@override
void initState() {
super.initState();
_loadFile();
}
Future<void> _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<void> _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...',
),
),
),
);
}
}

View File

@@ -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<TerminalView> {
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<TerminalView> {
),
],
),
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,