SFTP init.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -47,3 +47,4 @@ app.*.map.json
|
|||||||
|
|
||||||
/android/app/fjy.androidstudio.key
|
/android/app/fjy.androidstudio.key
|
||||||
/release
|
/release
|
||||||
|
test.dart
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class MenuItems {
|
|||||||
static const List<MenuItem> secondItems = [edit];
|
static const List<MenuItem> secondItems = [edit];
|
||||||
|
|
||||||
static const ssh = MenuItem(text: 'SSH', icon: Icons.link);
|
static const ssh = MenuItem(text: 'SSH', icon: Icons.link);
|
||||||
static const sftp = MenuItem(text: 'SFTP', icon: Icons.file_present);
|
static const sftp = MenuItem(text: 'SFTP', icon: Icons.insert_drive_file);
|
||||||
static const snippet = MenuItem(text: 'Snippet', icon: Icons.label);
|
static const snippet = MenuItem(text: 'Snippet', icon: Icons.label);
|
||||||
static const apt = MenuItem(text: 'Apt', icon: Icons.system_security_update);
|
static const apt = MenuItem(text: 'Apt', icon: Icons.system_security_update);
|
||||||
static const edit = MenuItem(text: 'Edit', icon: Icons.edit);
|
static const edit = MenuItem(text: 'Edit', icon: Icons.edit);
|
||||||
|
|||||||
29
lib/data/model/sftp/absolute_path.dart
Normal file
29
lib/data/model/sftp/absolute_path.dart
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
class AbsolutePath {
|
||||||
|
String path;
|
||||||
|
String? _prePath;
|
||||||
|
AbsolutePath(this.path);
|
||||||
|
|
||||||
|
void update(String newPath) {
|
||||||
|
_prePath = path;
|
||||||
|
if (newPath == '..') {
|
||||||
|
path = path.substring(0, path.lastIndexOf('/'));
|
||||||
|
if (path == '') {
|
||||||
|
path = '/';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPath == '/') {
|
||||||
|
path = '/';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
path = path + (path.endsWith('/') ? '' : '/') + newPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool undo() {
|
||||||
|
if (_prePath == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
path = _prePath!;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
lib/data/model/sftp/sftp_side_status.dart
Normal file
39
lib/data/model/sftp/sftp_side_status.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import 'package:dartssh2/dartssh2.dart';
|
||||||
|
import 'package:toolbox/data/model/server/server_private_info.dart';
|
||||||
|
import 'package:toolbox/data/model/sftp/absolute_path.dart';
|
||||||
|
|
||||||
|
class SFTPSideViewStatus {
|
||||||
|
bool leftSelected = false;
|
||||||
|
bool rightSelected = false;
|
||||||
|
ServerPrivateInfo? leftSpi;
|
||||||
|
ServerPrivateInfo? rightSpi;
|
||||||
|
List<SftpName>? leftFiles;
|
||||||
|
List<SftpName>? rightFiles;
|
||||||
|
AbsolutePath? leftPath;
|
||||||
|
AbsolutePath? rightPath;
|
||||||
|
SftpClient? leftClient;
|
||||||
|
SftpClient? rightClient;
|
||||||
|
|
||||||
|
SFTPSideViewStatus();
|
||||||
|
|
||||||
|
ServerPrivateInfo? spi(bool left) => left ? leftSpi : rightSpi;
|
||||||
|
void setSpi(bool left, ServerPrivateInfo nSpi) =>
|
||||||
|
left ? leftSpi = nSpi : rightSpi = nSpi;
|
||||||
|
|
||||||
|
/// Whether the Left/Right Destination is selected.
|
||||||
|
bool selected(bool left) => left ? leftSelected : rightSelected;
|
||||||
|
void setSelect(bool left, bool nSelect) =>
|
||||||
|
left ? leftSelected = nSelect : rightSelected = nSelect;
|
||||||
|
|
||||||
|
List<SftpName>? files(bool left) => left ? leftFiles : rightFiles;
|
||||||
|
void setFiles(bool left, List<SftpName>? nFiles) =>
|
||||||
|
left ? leftFiles = nFiles : rightFiles = nFiles;
|
||||||
|
|
||||||
|
AbsolutePath? path(bool left) => left ? leftPath : rightPath;
|
||||||
|
void setPath(bool left, AbsolutePath? nPath) =>
|
||||||
|
left ? leftPath = nPath : rightPath = nPath;
|
||||||
|
|
||||||
|
SftpClient? client(bool left) => left ? leftClient : rightClient;
|
||||||
|
void setClient(bool left, SftpClient? nClient) =>
|
||||||
|
left ? leftClient = nClient : rightClient = nClient;
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
class BuildData {
|
class BuildData {
|
||||||
static const String name = "ServerBox";
|
static const String name = "ServerBox";
|
||||||
static const int build = 97;
|
static const int build = 98;
|
||||||
static const String engine =
|
static const String engine =
|
||||||
"Flutter 2.8.1 • channel stable • https://github.com/flutter/flutter.git\nFramework • revision 77d935af4d (8 weeks ago) • 2021-12-16 08:37:33 -0800\nEngine • revision 890a5fca2e\nTools • Dart 2.15.1\n";
|
"╔════════════════════════════════════════════════════════════════════════════╗\n║ A new version of Flutter is available! ║\n║ ║\n║ To update to the latest version, run \"flutter upgrade\". ║\n╚════════════════════════════════════════════════════════════════════════════╝\n\nFlutter 2.8.1 • channel stable • https://github.com/flutter/flutter.git\nFramework • revision 77d935af4d (9 weeks ago) • 2021-12-16 08:37:33 -0800\nEngine • revision 890a5fca2e\nTools • Dart 2.15.1\n";
|
||||||
static const String buildAt = "2022-02-10 19:30:23.388434";
|
static const String buildAt = "2022-02-18 13:28:18.254386";
|
||||||
static const int modifications = 9;
|
static const int modifications = 5;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class _MyHomePageState extends State<MyHomePage>
|
|||||||
Widget _buildMain(BuildContext context) {
|
Widget _buildMain(BuildContext context) {
|
||||||
return AdvancedDrawer(
|
return AdvancedDrawer(
|
||||||
controller: _advancedDrawerController,
|
controller: _advancedDrawerController,
|
||||||
animationCurve: Curves.easeInOutCirc,
|
animationCurve: Curves.easeInOut,
|
||||||
animationDuration: const Duration(milliseconds: 300),
|
animationDuration: const Duration(milliseconds: 300),
|
||||||
animateChildDecoration: true,
|
animateChildDecoration: true,
|
||||||
rtlOpening: false,
|
rtlOpening: false,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:toolbox/core/utils.dart';
|
import 'package:toolbox/core/utils.dart';
|
||||||
import 'package:toolbox/data/model/server/server_connection_state.dart';
|
import 'package:toolbox/data/model/server/server_connection_state.dart';
|
||||||
import 'package:toolbox/data/model/server/server_private_info.dart';
|
import 'package:toolbox/data/model/server/server_private_info.dart';
|
||||||
|
import 'package:toolbox/data/model/sftp/absolute_path.dart';
|
||||||
|
import 'package:toolbox/data/model/sftp/sftp_side_status.dart';
|
||||||
import 'package:toolbox/data/provider/server.dart';
|
import 'package:toolbox/data/provider/server.dart';
|
||||||
import 'package:toolbox/locator.dart';
|
import 'package:toolbox/locator.dart';
|
||||||
import 'package:toolbox/view/widget/fade_in.dart';
|
import 'package:toolbox/view/widget/fade_in.dart';
|
||||||
@@ -16,13 +18,7 @@ class SFTPPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SFTPPageState extends State<SFTPPage> {
|
class _SFTPPageState extends State<SFTPPage> {
|
||||||
/// Whether the Left/Right Destination is selected.
|
final SFTPSideViewStatus _status = SFTPSideViewStatus();
|
||||||
final List<bool> _selectedDest = List<bool>.filled(2, false);
|
|
||||||
final List<ServerPrivateInfo?> _destSpi =
|
|
||||||
List<ServerPrivateInfo?>.filled(2, null);
|
|
||||||
final List<List<SftpName>?> _files = List<List<SftpName>?>.filled(2, null);
|
|
||||||
final List<String> _paths = List<String>.filled(2, '');
|
|
||||||
final List<SftpClient?> _clients = List<SftpClient?>.filled(2, null);
|
|
||||||
|
|
||||||
final ScrollController _leftScrollController = ScrollController();
|
final ScrollController _leftScrollController = ScrollController();
|
||||||
final ScrollController _rightScrollController = ScrollController();
|
final ScrollController _rightScrollController = ScrollController();
|
||||||
@@ -39,8 +35,8 @@ class _SFTPPageState extends State<SFTPPage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.spi != null) {
|
if (widget.spi != null) {
|
||||||
_destSpi[0] = widget.spi;
|
_status.setSpi(true, widget.spi!);
|
||||||
_selectedDest[0] = true;
|
_status.setSelect(true, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +45,7 @@ class _SFTPPageState extends State<SFTPPage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
title: Text(_titleText),
|
title: const Text('SFTP'),
|
||||||
),
|
),
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -63,31 +59,10 @@ class _SFTPPageState extends State<SFTPPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String get _titleText {
|
|
||||||
List<String> titles = [
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
];
|
|
||||||
if (_selectedDest[0]) {
|
|
||||||
titles[0] = _destSpi[0]?.name ?? '';
|
|
||||||
}
|
|
||||||
if (_selectedDest[1]) {
|
|
||||||
titles[1] = _destSpi[1]?.name ?? '';
|
|
||||||
}
|
|
||||||
return titles[0] == '' || titles[1] == '' ? 'SFTP' : titles.join(' - ');
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSingleColumn(bool left) {
|
Widget _buildSingleColumn(bool left) {
|
||||||
Widget child;
|
|
||||||
if (!_selectedDest[left ? 0 : 1]) {
|
|
||||||
child = _buildDestSelector(left);
|
|
||||||
} else {
|
|
||||||
child = _buildFileView(left);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: (_media.size.width - 2) / 2,
|
width: (_media.size.width - 2) / 2,
|
||||||
child: child,
|
child: _buildFileView(left),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +78,14 @@ class _SFTPPageState extends State<SFTPPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildFileView(bool left) {
|
Widget _buildFileView(bool left) {
|
||||||
final spi = _destSpi[left ? 0 : 1];
|
if (!_status.selected(left)) {
|
||||||
|
return ListView(
|
||||||
|
children: [
|
||||||
|
_buildDestSelector(left),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final spi = _status.spi(left);
|
||||||
final si =
|
final si =
|
||||||
locator<ServerProvider>().servers.firstWhere((s) => s.info == spi);
|
locator<ServerProvider>().servers.firstWhere((s) => s.info == spi);
|
||||||
final client = si.client;
|
final client = si.client;
|
||||||
@@ -112,44 +94,45 @@ class _SFTPPageState extends State<SFTPPage> {
|
|||||||
return centerCircleLoading;
|
return centerCircleLoading;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_files[left ? 0 : 1] == null) {
|
if (_status.files(left) == null) {
|
||||||
updatePath('/', left);
|
_status.setPath(left, AbsolutePath('/'));
|
||||||
listDir(client, '/', left);
|
listDir('/', left, client: client);
|
||||||
return centerCircleLoading;
|
return centerCircleLoading;
|
||||||
} else {
|
} else {
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
child: FadeIn(
|
child: FadeIn(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: _files[left ? 0 : 1]!.length,
|
itemCount: _status.files(left)!.length + 1,
|
||||||
controller: left ? _leftScrollController : _rightScrollController,
|
controller: left ? _leftScrollController : _rightScrollController,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final file = _files[left ? 0 : 1]![index];
|
if (index == 0) {
|
||||||
|
return _buildDestSelector(left);
|
||||||
|
}
|
||||||
|
final file = _status.files(left)![index - 1];
|
||||||
final isDir = file.attr.mode?.isDirectory ?? true;
|
final isDir = file.attr.mode?.isDirectory ?? true;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading:
|
leading: Icon(isDir ? Icons.folder : Icons.insert_drive_file),
|
||||||
Icon(isDir ? Icons.folder : Icons.insert_drive_file),
|
title: Text(file.filename),
|
||||||
title: Text(file.filename),
|
subtitle:
|
||||||
subtitle: isDir
|
isDir ? null : Text((convertBytes(file.attr.size ?? 0))),
|
||||||
? null
|
onTap: () {
|
||||||
: Text((convertBytes(file.attr.size ?? 0))),
|
if (isDir) {
|
||||||
onTap: () {
|
_status.path(left)?.update(file.filename);
|
||||||
if (isDir) {
|
listDir(_status.path(left)?.path ?? '/', left);
|
||||||
updatePath(file.filename, left);
|
} else {
|
||||||
listDir(client, _paths[left ? 0 : 1], left);
|
onItemPress(context, left, file);
|
||||||
} else {
|
}
|
||||||
// downloadFile(client, file.name);
|
},
|
||||||
}
|
);
|
||||||
},
|
|
||||||
onLongPress: () => onItemLongPress(context, left, file));
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
key: Key(_paths[left ? 0 : 1]),
|
key: Key(_status.spi(left)!.name + _status.path(left)!.path),
|
||||||
),
|
),
|
||||||
onRefresh: () => listDir(client, _paths[left ? 0 : 1], left));
|
onRefresh: () => listDir(_status.path(left)?.path ?? '/', left));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void onItemLongPress(BuildContext context, bool left, SftpName file) {
|
void onItemPress(BuildContext context, bool left, SftpName file) {
|
||||||
showRoundDialog(
|
showRoundDialog(
|
||||||
context,
|
context,
|
||||||
'Action',
|
'Action',
|
||||||
@@ -226,8 +209,8 @@ class _SFTPPageState extends State<SFTPPage> {
|
|||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_clients[left ? 0 : 1]!
|
_status.client(left)!.mkdir(
|
||||||
.mkdir(_paths[left ? 0 : 1] + '/' + textController.text);
|
_status.path(left)!.path + '/' + textController.text);
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'Create',
|
'Create',
|
||||||
@@ -262,7 +245,8 @@ class _SFTPPageState extends State<SFTPPage> {
|
|||||||
]);
|
]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _clients[left ? 0 : 1]!
|
await _status
|
||||||
|
.client(left)!
|
||||||
.rename(file.filename, textController.text);
|
.rename(file.filename, textController.text);
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: const Text(
|
||||||
@@ -286,40 +270,66 @@ class _SFTPPageState extends State<SFTPPage> {
|
|||||||
return '$finalValue ${suffix[squareTimes]}';
|
return '$finalValue ${suffix[squareTimes]}';
|
||||||
}
|
}
|
||||||
|
|
||||||
void updatePath(String filename, bool left) {
|
Future<void> listDir(String path, bool left, {SSHClient? client}) async {
|
||||||
if (filename == '..') {
|
if (client != null) {
|
||||||
_paths[left ? 0 : 1] = _paths[left ? 0 : 1]
|
final sftpc = await client.sftp();
|
||||||
.substring(0, _paths[left ? 0 : 1].lastIndexOf('/'));
|
_status.setClient(left, sftpc);
|
||||||
if (_paths[left ? 0 : 1] == '') {
|
|
||||||
_paths[left ? 0 : 1] = '/';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
_paths[left ? 0 : 1] = _paths[left ? 0 : 1] +
|
final fs = await _status.client(left)!.listdir(path);
|
||||||
(_paths[left ? 0 : 1].endsWith('/') ? '' : '/') +
|
|
||||||
filename;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> listDir(SSHClient client, String path, bool left) async {
|
|
||||||
final sftpc = await client.sftp();
|
|
||||||
_clients[left ? 0 : 1] = sftpc;
|
|
||||||
final fs = await sftpc.listdir(path);
|
|
||||||
fs.sort((a, b) => a.filename.compareTo(b.filename));
|
fs.sort((a, b) => a.filename.compareTo(b.filename));
|
||||||
fs.removeAt(0);
|
fs.removeAt(0);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_files[left ? 0 : 1] = fs;
|
_status.setFiles(left, fs);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDestSelector(bool left) {
|
Widget _buildDestSelector(bool left) {
|
||||||
return Column(
|
final str = _status.path(left)?.path;
|
||||||
children: locator<ServerProvider>()
|
return ExpansionTile(
|
||||||
.servers
|
title: Text(_status.spi(left)?.name ?? 'Choose target'),
|
||||||
.map((e) => _buildDestSelectorItem(e.info, left))
|
subtitle: _status.selected(left)
|
||||||
.toList(),
|
? LayoutBuilder(builder: (context, size) {
|
||||||
);
|
bool exceeded = false;
|
||||||
|
int len = 0;
|
||||||
|
for (; !exceeded && len < str!.length; len++) {
|
||||||
|
// Build the textspan
|
||||||
|
var span = TextSpan(
|
||||||
|
text: '...' + str.substring(str.length - len),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize:
|
||||||
|
Theme.of(context).textTheme.bodyText1?.fontSize ??
|
||||||
|
14),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use a textpainter to determine if it will exceed max lines
|
||||||
|
var tp = TextPainter(
|
||||||
|
maxLines: 1,
|
||||||
|
textAlign: TextAlign.left,
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
text: span,
|
||||||
|
);
|
||||||
|
|
||||||
|
// trigger it to layout
|
||||||
|
tp.layout(maxWidth: size.maxWidth);
|
||||||
|
|
||||||
|
// whether the text overflowed or not
|
||||||
|
exceeded = tp.didExceedMaxLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Text(
|
||||||
|
(exceeded ? '...' : '') + str!.substring(str.length - len),
|
||||||
|
overflow: TextOverflow.clip,
|
||||||
|
maxLines: 1,
|
||||||
|
style: const TextStyle(color: Colors.grey),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
children: locator<ServerProvider>()
|
||||||
|
.servers
|
||||||
|
.map((e) => _buildDestSelectorItem(e.info, left))
|
||||||
|
.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDestSelectorItem(ServerPrivateInfo spi, bool left) {
|
Widget _buildDestSelectorItem(ServerPrivateInfo spi, bool left) {
|
||||||
@@ -327,10 +337,14 @@ class _SFTPPageState extends State<SFTPPage> {
|
|||||||
title: Text(spi.name),
|
title: Text(spi.name),
|
||||||
subtitle: Text('${spi.user}@${spi.ip}:${spi.port}'),
|
subtitle: Text('${spi.user}@${spi.ip}:${spi.port}'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
setState(() {
|
_status.setSpi(left, spi);
|
||||||
_destSpi[left ? 0 : 1] = spi;
|
_status.setSelect(left, true);
|
||||||
_selectedDest[left ? 0 : 1] = true;
|
_status.setPath(left, AbsolutePath('/'));
|
||||||
});
|
listDir('/', left,
|
||||||
|
client: locator<ServerProvider>()
|
||||||
|
.servers
|
||||||
|
.firstWhere((s) => s.info == spi)
|
||||||
|
.client);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user