feat: Implement accessible SFTP file browser and native editor
This commit is contained in:
@@ -110,6 +110,39 @@ class SshService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SFTP Operations ---
|
||||||
|
|
||||||
|
Future<SftpClient> _getSftpClient() async {
|
||||||
|
if (_client == null) throw Exception('Not connected');
|
||||||
|
return await _client!.sftp();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SftpName>> listDirectory(String path) async {
|
||||||
|
final sftp = await _getSftpClient();
|
||||||
|
return await sftp.listdir(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> 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<void> 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) {
|
void write(String data) {
|
||||||
if (_session != null) {
|
if (_session != null) {
|
||||||
_session!.write(utf8.encode(data));
|
_session!.write(utf8.encode(data));
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ class _ConnectionViewState extends ConsumerState<ConnectionView> {
|
|||||||
// Listen for global connection errors (e.g. lost connection)
|
// Listen for global connection errors (e.g. lost connection)
|
||||||
ref.listen<String?>(connectionErrorProvider, (previous, next) {
|
ref.listen<String?>(connectionErrorProvider, (previous, next) {
|
||||||
if (next != null) {
|
if (next != null) {
|
||||||
_showErrorDialog(next);
|
SemanticsService.sendAnnouncement(
|
||||||
ref.read(connectionErrorProvider.notifier).state = null;
|
View.of(context),
|
||||||
|
next,
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -252,6 +255,7 @@ class _NewConnectionFormState extends ConsumerState<NewConnectionForm> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickKeyFile() async {
|
Future<void> _pickKeyFile() async {
|
||||||
|
final view = View.of(context);
|
||||||
try {
|
try {
|
||||||
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
FilePickerResult? result = await FilePicker.platform.pickFiles(
|
||||||
type: FileType.any, // SSH keys often have no extension or .pem/.key
|
type: FileType.any, // SSH keys often have no extension or .pem/.key
|
||||||
@@ -265,7 +269,11 @@ class _NewConnectionFormState extends ConsumerState<NewConnectionForm> {
|
|||||||
_privateKeyContent = content;
|
_privateKeyContent = content;
|
||||||
_privateKeyName = file.name;
|
_privateKeyName = file.name;
|
||||||
});
|
});
|
||||||
SemanticsService.announce('Key selected: ${file.name}', TextDirection.ltr);
|
SemanticsService.sendAnnouncement(
|
||||||
|
view,
|
||||||
|
'Key selected: ${file.name}',
|
||||||
|
TextDirection.ltr,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
172
lib/ui/views/file_browser_view.dart
Normal file
172
lib/ui/views/file_browser_view.dart
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
147
lib/ui/views/file_editor_view.dart
Normal file
147
lib/ui/views/file_editor_view.dart
Normal 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...',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:wakelock_plus/wakelock_plus.dart';
|
|||||||
import 'package:accessible_terminal/state/terminal_provider.dart';
|
import 'package:accessible_terminal/state/terminal_provider.dart';
|
||||||
import 'package:accessible_terminal/models/terminal_line.dart';
|
import 'package:accessible_terminal/models/terminal_line.dart';
|
||||||
import 'package:accessible_terminal/ui/widgets/ansi_text_parser.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 {
|
class TerminalView extends ConsumerStatefulWidget {
|
||||||
const TerminalView({super.key});
|
const TerminalView({super.key});
|
||||||
@@ -114,7 +115,11 @@ class _TerminalViewState extends ConsumerState<TerminalView> {
|
|||||||
final newLine = next.first;
|
final newLine = next.first;
|
||||||
final cleanText = AnsiTextParser.strip(newLine.text);
|
final cleanText = AnsiTextParser.strip(newLine.text);
|
||||||
if (cleanText.trim().isNotEmpty) {
|
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(
|
Semantics(
|
||||||
label: 'Disconnect',
|
label: 'Disconnect',
|
||||||
button: true,
|
button: true,
|
||||||
|
|||||||
@@ -268,10 +268,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.16.0"
|
version: "1.17.0"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -505,10 +505,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.6"
|
version: "0.7.7"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:accessible_terminal/ui/widgets/ansi_text_parser.dart';
|
import 'package:accessible_terminal/ui/widgets/ansi_text_parser.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('AnsiTextParser', () {
|
group('AnsiTextParser', () {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
// 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.
|
// 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_test/flutter_test.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:accessible_terminal/main.dart';
|
import 'package:accessible_terminal/main.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|||||||
Reference in New Issue
Block a user