diff --git a/flutter/lib/common.dart b/flutter/lib/common.dart index a987f54df..e1315d233 100644 --- a/flutter/lib/common.dart +++ b/flutter/lib/common.dart @@ -202,7 +202,7 @@ const G = M * K; String readableFileSize(double size) { if (size < K) { - return size.toString() + " B"; + return size.toStringAsFixed(2) + " B"; } else if (size < M) { return (size / K).toStringAsFixed(2) + " KB"; } else if (size < G) { diff --git a/flutter/lib/desktop/pages/file_manager_page.dart b/flutter/lib/desktop/pages/file_manager_page.dart index fc9f0994d..6e8dd57c8 100644 --- a/flutter/lib/desktop/pages/file_manager_page.dart +++ b/flutter/lib/desktop/pages/file_manager_page.dart @@ -1,5 +1,5 @@ -import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_breadcrumb/flutter_breadcrumb.dart'; @@ -23,15 +23,18 @@ class FileManagerPage extends StatefulWidget { class _FileManagerPageState extends State with AutomaticKeepAliveClientMixin { - final _selectedItems = SelectedItems(); - final _breadCrumbLocalScroller = ScrollController(); - final _breadCrumbRemoteScroller = ScrollController(); + final _localSelectedItems = SelectedItems(); + final _remoteSelectedItems = SelectedItems(); /// FFI with name file_transfer_id FFI get _ffi => ffi('ft_${widget.id}'); FileModel get model => _ffi.fileModel; + SelectedItems getSelectedItem(bool isLocal) { + return isLocal ? _localSelectedItems : _remoteSelectedItems; + } + @override void initState() { super.initState(); @@ -83,53 +86,16 @@ class _FileManagerPageState extends State })); } - bool needShowCheckBox() { - if (!model.selectMode) { - return false; - } - return !_selectedItems.isOtherPage(model.isLocal); - } - Widget menu({bool isLocal = false}) { return PopupMenuButton( icon: Icon(Icons.more_vert), itemBuilder: (context) { return [ - PopupMenuItem( - child: Row( - children: [ - Icon(Icons.refresh, color: Colors.black), - SizedBox(width: 5), - Text(translate("Refresh File")) - ], - ), - value: "refresh", - ), - PopupMenuItem( - child: Row( - children: [ - Icon(Icons.check, color: Colors.black), - SizedBox(width: 5), - Text(translate("Multi Select")) - ], - ), - value: "select", - ), - PopupMenuItem( - child: Row( - children: [ - Icon(Icons.folder_outlined, color: Colors.black), - SizedBox(width: 5), - Text(translate("Create Folder")) - ], - ), - value: "folder", - ), PopupMenuItem( child: Row( children: [ Icon( - model.currentShowHidden + model.getCurrentShowHidden(isLocal) ? Icons.check_box_outlined : Icons.check_box_outline_blank, color: Colors.black), @@ -142,46 +108,7 @@ class _FileManagerPageState extends State ]; }, onSelected: (v) { - if (v == "refresh") { - model.refresh(); - } else if (v == "select") { - _selectedItems.clear(); - model.toggleSelectMode(); - } else if (v == "folder") { - final name = TextEditingController(); - DialogManager.show((setState, close) => CustomAlertDialog( - title: Text(translate("Create Folder")), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - decoration: InputDecoration( - labelText: - translate("Please enter the folder name"), - ), - controller: name, - ), - ], - ), - actions: [ - TextButton( - style: flatButtonStyle, - onPressed: () => close(false), - child: Text(translate("Cancel"))), - ElevatedButton( - style: flatButtonStyle, - onPressed: () { - if (name.value.text.isNotEmpty) { - model.createDir(PathUtil.join( - model.currentDir.path, - name.value.text, - model.currentIsWindows)); - close(); - } - }, - child: Text(translate("OK"))) - ])); - } else if (v == "hidden") { + if (v == "hidden") { model.toggleShowHidden(local: isLocal); } }); @@ -190,9 +117,23 @@ class _FileManagerPageState extends State Widget body({bool isLocal = false}) { final fd = isLocal ? model.currentLocalDir : model.currentRemoteDir; final entries = fd.entries; + final sortIndex = (SortBy style) { + switch (style) { + case SortBy.Name: + return 1; + case SortBy.Type: + return 0; + case SortBy.Modified: + return 2; + case SortBy.Size: + return 3; + } + }(model.getSortStyle(isLocal)); + final sortAscending = + isLocal ? model.localSortAscending : model.remoteSortAscending; return Container( decoration: BoxDecoration( - color: Colors.white70, border: Border.all(color: Colors.grey)), + color: Colors.white54, border: Border.all(color: Colors.black26)), margin: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(8.0), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -205,16 +146,36 @@ class _FileManagerPageState extends State child: SingleChildScrollView( child: DataTable( showCheckboxColumn: true, - dataRowHeight: 30, + dataRowHeight: 25, + headingRowHeight: 30, columnSpacing: 8, + showBottomBorder: true, + sortColumnIndex: sortIndex, + sortAscending: sortAscending, columns: [ DataColumn(label: Text(translate(" "))), // icon DataColumn( label: Text( - translate("Name"), - )), - DataColumn(label: Text(translate("Modified"))), - DataColumn(label: Text(translate("Size"))), + translate("Name"), + ), + onSort: (columnIndex, ascending) { + model.changeSortStyle(SortBy.Name, + isLocal: isLocal, ascending: ascending); + }), + DataColumn( + label: Text( + translate("Modified"), + ), + onSort: (columnIndex, ascending) { + model.changeSortStyle(SortBy.Modified, + isLocal: isLocal, ascending: ascending); + }), + DataColumn( + label: Text(translate("Size")), + onSort: (columnIndex, ascending) { + model.changeSortStyle(SortBy.Size, + isLocal: isLocal, ascending: ascending); + }), ], rows: entries.map((entry) { final sizeStr = entry.isFile @@ -223,23 +184,44 @@ class _FileManagerPageState extends State return DataRow( key: ValueKey(entry.name), onSelectChanged: (s) { - // TODO + if (s != null) { + if (s) { + getSelectedItem(isLocal).add(isLocal, entry); + } else { + getSelectedItem(isLocal).remove(entry); + } + setState(() {}); + } }, + selected: getSelectedItem(isLocal).contains(entry), cells: [ - // TODO: icon DataCell(Icon( entry.isFile ? Icons.feed_outlined : Icons.folder, size: 25)), DataCell( ConstrainedBox( constraints: BoxConstraints(maxWidth: 100), - child: Text(entry.name, - overflow: TextOverflow.ellipsis)), - onTap: () { + child: Tooltip( + message: entry.name, + child: Text(entry.name, + overflow: TextOverflow.ellipsis), + )), onTap: () { if (entry.isDirectory) { model.openDirectory(entry.path, isLocal: isLocal); + if (isLocal) { + _localSelectedItems.clear(); + } else { + _remoteSelectedItems.clear(); + } } else { // Perform file-related tasks. + final _selectedItems = getSelectedItem(isLocal); + if (_selectedItems.contains(entry)) { + _selectedItems.remove(entry); + } else { + _selectedItems.add(isLocal, entry); + } + setState(() {}); } }), DataCell(Text( @@ -263,7 +245,7 @@ class _FileManagerPageState extends State ) ], )), - Center(child: listTail(isLocal: isLocal)), + // Center(child: listTail(isLocal: isLocal)), // Expanded( // child: ListView.builder( // itemCount: entries.length + 1, @@ -374,9 +356,94 @@ class _FileManagerPageState extends State Widget statusList() { return PreferredSize( child: Container( - margin: const EdgeInsets.only(top: 16.0,bottom: 16.0, right: 16.0), + margin: const EdgeInsets.only(top: 16.0, bottom: 16.0, right: 16.0), padding: const EdgeInsets.all(8.0), - decoration: BoxDecoration(color: Colors.white70,border: Border.all(color: Colors.grey)), + decoration: BoxDecoration( + color: Colors.white70, border: Border.all(color: Colors.grey)), + child: Obx( + () => ListView.builder( + itemBuilder: (BuildContext context, int index) { + final item = model.jobTable[index]; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Transform.rotate( + angle: item.isRemote ? pi : 0, + child: Icon(Icons.send)), + SizedBox( + width: 16.0, + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Tooltip( + message: item.jobName, + child: Text( + '${item.jobName}', + maxLines: 1, + style: TextStyle(color: Colors.black45), + overflow: TextOverflow.ellipsis, + )), + Wrap( + children: [ + Text( + '${item.state.display()} ${max(0, item.fileNum)}/${item.fileCount} '), + Text( + '${translate("files")} ${readableFileSize(item.totalSize.toDouble())} '), + Offstage( + offstage: + item.state != JobState.inProgress, + child: Text( + '${readableFileSize(item.speed) + "/s"} ')), + Offstage( + offstage: item.totalSize <= 0, + child: Text( + '${(item.finishedSize.toDouble() * 100 / item.totalSize.toDouble()).toStringAsFixed(2)}%'), + ), + ], + ), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Offstage( + offstage: item.state != JobState.paused, + child: IconButton( + onPressed: () { + model.resumeJob(item.id); + }, + icon: Icon(Icons.restart_alt_rounded)), + ), + IconButton( + icon: Icon(Icons.delete), + onPressed: () { + model.jobTable.removeAt(index); + model.cancelJob(item.id); + }, + ), + ], + ) + ], + ), + SizedBox( + height: 8.0, + ), + Divider( + height: 2.0, + ) + ], + ); + }, + itemCount: model.jobTable.length, + ), + ), ), preferredSize: Size(200, double.infinity)); } @@ -385,67 +452,176 @@ class _FileManagerPageState extends State model.goToParentDirectory(isLocal: isLocal); } - breadCrumbScrollToEnd(bool isLocal) { - final controller = - isLocal ? _breadCrumbLocalScroller : _breadCrumbRemoteScroller; - Future.delayed(Duration(milliseconds: 200), () { - controller.animateTo(controller.position.maxScrollExtent, - duration: Duration(milliseconds: 200), - curve: Curves.fastLinearToSlowEaseIn); - }); - } - Widget headTools(bool isLocal) => Container( - child: Row( + child: Column( children: [ - Expanded( - child: BreadCrumb( - items: getPathBreadCrumbItems(() => model.goHome(), (list) { - var path = ""; - if (model.currentHome.startsWith(list[0])) { - // absolute path - for (var item in list) { - path = PathUtil.join(path, item, model.currentIsWindows); - } - } else { - path += model.currentHome; - for (var item in list) { - path = PathUtil.join(path, item, model.currentIsWindows); - } - } - model.openDirectory(path, isLocal: isLocal); - }, isLocal), - divider: Icon(Icons.chevron_right), - overflow: ScrollableOverflow( - controller: isLocal - ? _breadCrumbLocalScroller - : _breadCrumbRemoteScroller), - )), + // symbols + PreferredSize( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration(color: Colors.blue), + padding: EdgeInsets.all(8.0), + child: FutureBuilder( + future: _ffi.bind.sessionGetPlatform( + id: _ffi.id, isRemote: !isLocal), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data!.isNotEmpty) { + return getPlatformImage('${snapshot.data}'); + } else { + return CircularProgressIndicator(color: Colors.white,); + } + })), + Text(isLocal + ? translate("Local Computer") + : translate("Remote Computer")) + .marginOnly(left: 8.0) + ], + ), + preferredSize: Size(double.infinity, 70)), + // buttons Row( children: [ - IconButton( - icon: Icon(Icons.arrow_upward), - onPressed: () { - goBack(isLocal: isLocal); - }, + Row( + children: [ + IconButton( + onPressed: () { + model.goHome(isLocal: isLocal); + }, + icon: Icon(Icons.home_outlined)), + IconButton( + icon: Icon(Icons.arrow_upward), + onPressed: () { + goBack(isLocal: isLocal); + }, + ), + menu(isLocal: isLocal), + ], ), - PopupMenuButton( - icon: Icon(Icons.sort), - itemBuilder: (context) { - return SortBy.values - .map((e) => PopupMenuItem( - child: - Text(translate(e.toString().split(".").last)), - value: e, - )) - .toList(); + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.black12)), + child: TextField( + decoration: InputDecoration( + border: InputBorder.none, + isDense: true, + prefix: Padding(padding: EdgeInsets.only(left: 4.0)), + suffix: DropdownButton( + isDense: true, + underline: Offstage(), + items: [ + // TODO: favourite + DropdownMenuItem(child: Text('/'), value: '/',) + ], onChanged: (path) { + if (path is String && path.isNotEmpty){ + model.openDirectory(path, isLocal: isLocal); + } + }) + ), + controller: TextEditingController( + text: isLocal + ? model.currentLocalDir.path + : model.currentRemoteDir.path), + onSubmitted: (path) { + model.openDirectory(path, isLocal: isLocal); + }, + ))), + IconButton( + onPressed: () { + model.refresh(isLocal: isLocal); }, - onSelected: (sort) { - model.changeSortStyle(sort, isLocal: isLocal); - }), - menu(isLocal: isLocal) + icon: Icon(Icons.refresh)) ], - ) + ), + Row( + textDirection: isLocal ? TextDirection.ltr : TextDirection.rtl, + children: [ + Expanded( + child: Row( + mainAxisAlignment: + isLocal ? MainAxisAlignment.start : MainAxisAlignment.end, + children: [ + IconButton( + onPressed: () { + final name = TextEditingController(); + DialogManager.show((setState, close) => + CustomAlertDialog( + title: Text(translate("Create Folder")), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + decoration: InputDecoration( + labelText: translate( + "Please enter the folder name"), + ), + controller: name, + ), + ], + ), + actions: [ + TextButton( + style: flatButtonStyle, + onPressed: () => close(false), + child: Text(translate("Cancel"))), + ElevatedButton( + style: flatButtonStyle, + onPressed: () { + if (name.value.text.isNotEmpty) { + model.createDir( + PathUtil.join( + model + .getCurrentDir(isLocal) + .path, + name.value.text, + model.getCurrentIsWindows( + isLocal)), + isLocal: isLocal); + close(); + } + }, + child: Text(translate("OK"))) + ])); + }, + icon: Icon(Icons.create_new_folder_outlined)), + IconButton( + onPressed: () async { + final items = isLocal + ? _localSelectedItems + : _remoteSelectedItems; + debugPrint("remove items: ${items.items}"); + await (model.removeAction(items)); + items.clear(); + }, + icon: Icon(Icons.delete_forever_outlined)), + ], + ), + ), + TextButton.icon( + onPressed: () { + final items = getSelectedItem(isLocal); + model.sendFiles(items, isRemote: !isLocal); + items.clear(); + }, + icon: Transform.rotate( + angle: isLocal ? 0 : pi, + child: Icon( + Icons.send, + color: Colors.black54, + ), + ), + label: Text( + isLocal ? translate('Send') : translate('Receive'), + style: TextStyle( + color: Colors.black54, + ), + )), + ], + ).marginOnly(top: 8.0) ], )); @@ -476,14 +652,15 @@ class _FileManagerPageState extends State Widget? bottomSheet() { final state = model.jobState; - final isOtherPage = _selectedItems.isOtherPage(model.isLocal); - final selectedItemsLen = "${_selectedItems.length} ${translate("items")}"; - final local = _selectedItems.isLocal == null + final isOtherPage = _localSelectedItems.isOtherPage(model.isLocal); + final selectedItemsLen = + "${_localSelectedItems.length} ${translate("items")}"; + final local = _localSelectedItems.isLocal == null ? "" - : " [${_selectedItems.isLocal! ? translate("Local") : translate("Remote")}]"; + : " [${_localSelectedItems.isLocal! ? translate("Local") : translate("Remote")}]"; if (model.selectMode) { - if (_selectedItems.length == 0 || !isOtherPage) { + if (_localSelectedItems.length == 0 || !isOtherPage) { return BottomSheetBody( leading: Icon(Icons.check), title: translate("Selected"), @@ -497,8 +674,8 @@ class _FileManagerPageState extends State IconButton( icon: Icon(Icons.delete_forever), onPressed: () { - if (_selectedItems.length > 0) { - model.removeAction(_selectedItems); + if (_localSelectedItems.length > 0) { + model.removeAction(_localSelectedItems); } }, ) @@ -518,7 +695,7 @@ class _FileManagerPageState extends State icon: Icon(Icons.paste), onPressed: () { model.toggleSelectMode(); - model.sendFiles(_selectedItems); + model.sendFiles(_localSelectedItems); }, ) ]); @@ -576,6 +753,15 @@ class _FileManagerPageState extends State @override bool get wantKeepAlive => true; + + /// Get the image for the current [platform]. + Widget getPlatformImage(String platform) { + platform = platform.toLowerCase(); + if (platform == 'mac os') + platform = 'mac'; + else if (platform != 'linux' && platform != 'android') platform = 'win'; + return Image.asset('assets/$platform.png', width: 25, height: 25); + } } class BottomSheetBody extends StatelessWidget { diff --git a/flutter/lib/models/file_model.dart b/flutter/lib/models/file_model.dart index c3d44f4a9..ba76d52ae 100644 --- a/flutter/lib/models/file_model.dart +++ b/flutter/lib/models/file_model.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/mobile/pages/file_manager_page.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; import 'package:path/path.dart' as Path; import 'model.dart'; @@ -22,6 +23,11 @@ class FileModel extends ChangeNotifier { var _jobProgress = JobProgress(); // from rust update + /// JobTable + final _jobTable = List.empty(growable: true).obs; + + RxList get jobTable => _jobTable; + bool get isLocal => _isLocal; bool get selectMode => _selectMode; @@ -34,6 +40,20 @@ class FileModel extends ChangeNotifier { SortBy get sortStyle => _sortStyle; + SortBy _localSortStyle = SortBy.Name; + + bool _localSortAscending = true; + + bool _remoteSortAscending = true; + + SortBy _remoteSortStyle = SortBy.Name; + + bool get localSortAscending => _localSortAscending; + + SortBy getSortStyle(bool isLocal){ + return isLocal ? _localSortStyle : _remoteSortStyle; + } + FileDirectory _currentLocalDir = FileDirectory(); FileDirectory get currentLocalDir => _currentLocalDir; @@ -44,8 +64,20 @@ class FileModel extends ChangeNotifier { FileDirectory get currentDir => _isLocal ? currentLocalDir : currentRemoteDir; + FileDirectory getCurrentDir(bool isLocal) { + return isLocal ? currentLocalDir : currentRemoteDir; + } + String get currentHome => _isLocal ? _localOption.home : _remoteOption.home; + String getCurrentHome(bool isLocal) { + return isLocal ? _localOption.home : _remoteOption.home; + } + + int getJob(int id) { + return jobTable.indexWhere((element) => element.id == id); + } + String get currentShortPath { if (currentDir.path.startsWith(currentHome)) { var path = currentDir.path.replaceFirst(currentHome, ""); @@ -78,9 +110,17 @@ class FileModel extends ChangeNotifier { bool get currentShowHidden => _isLocal ? _localOption.showHidden : _remoteOption.showHidden; + bool getCurrentShowHidden(bool isLocal) { + return isLocal ? _localOption.showHidden : _remoteOption.showHidden; + } + bool get currentIsWindows => _isLocal ? _localOption.isWindows : _remoteOption.isWindows; + bool getCurrentIsWindows(bool isLocal) { + return isLocal ? _localOption.isWindows : _remoteOption.isWindows; + } + final _fileFetcher = FileFetcher(); final _jobResultListener = JobResultListener>(); @@ -115,10 +155,23 @@ class FileModel extends ChangeNotifier { tryUpdateJobProgress(Map evt) { try { int id = int.parse(evt['id']); - _jobProgress.id = id; - _jobProgress.fileNum = int.parse(evt['file_num']); - _jobProgress.speed = double.parse(evt['speed']); - _jobProgress.finishedSize = int.parse(evt['finished_size']); + if (!isDesktop) { + _jobProgress.id = id; + _jobProgress.fileNum = int.parse(evt['file_num']); + _jobProgress.speed = double.parse(evt['speed']); + _jobProgress.finishedSize = int.parse(evt['finished_size']); + } else { + // Desktop uses jobTable + // id = index + 1 + final jobIndex = getJob(id); + if (jobIndex >= 0 && _jobTable.length > jobIndex){ + final job = _jobTable[jobIndex]; + job.fileNum = int.parse(evt['file_num']); + job.speed = double.parse(evt['speed']); + job.finishedSize = int.parse(evt['finished_size']); + debugPrint("update job ${id} with ${evt}"); + } + } notifyListeners(); } catch (e) { debugPrint("Failed to tryUpdateJobProgress,evt:${evt.toString()}"); @@ -126,49 +179,89 @@ class FileModel extends ChangeNotifier { } receiveFileDir(Map evt) { - if (_remoteOption.home.isEmpty && evt['is_local'] == "false") { + debugPrint("recv file dir:${evt}"); + if (evt['is_local'] == "false") { // init remote home, the connection will automatic read remote home when established, try { final fd = FileDirectory.fromJson(jsonDecode(evt['value'])); fd.format(_remoteOption.isWindows, sort: _sortStyle); - _remoteOption.home = fd.path; - debugPrint("init remote home:${fd.path}"); - _currentRemoteDir = fd; - notifyListeners(); - return; - } finally {} + if (fd.id > 0) { + final jobIndex = getJob(fd.id); + if (jobIndex != -1) { + final job = jobTable[jobIndex]; + var totalSize = 0; + var fileCount = fd.entries.length; + fd.entries.forEach((element) { + totalSize += element.size; + }); + job.totalSize = totalSize; + job.fileCount = fileCount; + debugPrint("update receive details:${fd.path}"); + } + } else if (_remoteOption.home.isEmpty) { + _remoteOption.home = fd.path; + debugPrint("init remote home:${fd.path}"); + _currentRemoteDir = fd; + } + } + finally {} } _fileFetcher.tryCompleteTask(evt['value'], evt['is_local']); + notifyListeners(); } jobDone(Map evt) { - if (_jobResultListener.isListening) { - _jobResultListener.complete(evt); - return; + if (!isDesktop) { + if (_jobResultListener.isListening) { + _jobResultListener.complete(evt); + return; + } + _selectMode = false; + _jobProgress.state = JobState.done; + } else { + int id = int.parse(evt['id']); + final jobIndex = getJob(id); + if (jobIndex != -1) { + final job = jobTable[jobIndex]; + job.finishedSize = job.totalSize; + job.state = JobState.done; + job.fileNum = int.parse(evt['file_num']); + } } - _selectMode = false; - _jobProgress.state = JobState.done; refresh(); } jobError(Map evt) { - if (_jobResultListener.isListening) { - _jobResultListener.complete(evt); - return; + if (!isDesktop) { + if (_jobResultListener.isListening) { + _jobResultListener.complete(evt); + return; + } + _selectMode = false; + _jobProgress.clear(); + _jobProgress.state = JobState.error; + } else { + int jobIndex = getJob(int.parse(evt['id'])); + if (jobIndex != -1) { + final job = jobTable[jobIndex]; + job.state = JobState.error; + } } - debugPrint("jobError $evt"); - _selectMode = false; - _jobProgress.clear(); - _jobProgress.state = JobState.error; notifyListeners(); } overrideFileConfirm(Map evt) async { final resp = await showFileConfirmDialog( translate("Overwrite"), "${evt['read_path']}", true); + final id = int.tryParse(evt['id']) ?? 0; if (false == resp) { - cancelJob(int.tryParse(evt['id']) ?? 0); + final jobIndex = getJob(id); + if (jobIndex != -1){ + cancelJob(id); + final job = jobTable[jobIndex]; + job.state = JobState.done; + } } else { var need_override = false; if (resp == null) { @@ -179,9 +272,9 @@ class FileModel extends ChangeNotifier { need_override = true; } _ffi.target?.bind.sessionSetConfirmOverrideFile(id: _ffi.target?.id ?? "", - actId: evt['id'], fileNum: evt['file_num'], + actId: id, fileNum: int.parse(evt['file_num']), needOverride: need_override, remember: fileConfirmCheckboxRemember, - isUpload: evt['is_upload']); + isUpload: evt['is_upload'] == "true"); } } @@ -216,6 +309,8 @@ class FileModel extends ChangeNotifier { if (_currentRemoteDir.path.isEmpty) { openDirectory(_remoteOption.home, isLocal: false); } + // load last transfer jobs + await _ffi.target?.bind.sessionLoadLastTransferJobs(id: '${_ffi.target?.id}'); } onClose() { @@ -238,10 +333,10 @@ class FileModel extends ChangeNotifier { _remoteOption.clear(); } - refresh() { + refresh({bool? isLocal}) { if (isDesktop) { - openDirectory(currentRemoteDir.path); - openDirectory(currentLocalDir.path); + isLocal = isLocal ?? _isLocal; + isLocal ? openDirectory(currentLocalDir.path) : openDirectory(currentRemoteDir.path); } else { openDirectory(currentDir.path); } @@ -254,7 +349,7 @@ class FileModel extends ChangeNotifier { final isWindows = isLocal ? _localOption.isWindows : _remoteOption.isWindows; // process /C:\ -> C:\ on Windows - if (currentIsWindows && path.length > 1 && path[0] == '/') { + if (isLocal ? _localOption.isWindows : _remoteOption.isWindows && path.length > 1 && path[0] == '/') { path = path.substring(1); if (path[path.length - 1] != '\\') { path = path + "\\"; @@ -270,19 +365,22 @@ class FileModel extends ChangeNotifier { } notifyListeners(); } catch (e) { - debugPrint("Failed to openDirectory :$e"); + debugPrint("Failed to openDirectory ${path} :$e"); } } - goHome() { - openDirectory(currentHome); + goHome({bool? isLocal}) { + isLocal = isLocal ?? _isLocal; + openDirectory(getCurrentHome(isLocal), isLocal: isLocal); } goToParentDirectory({bool? isLocal}) { - final currDir = isLocal != null ? isLocal ? currentLocalDir : currentRemoteDir : currentDir; - var parent = PathUtil.dirname(currDir.path, currentIsWindows); + isLocal = isLocal ?? _isLocal; + final isWindows = isLocal ? _localOption.isWindows : _remoteOption.isWindows; + final currDir = isLocal ? currentLocalDir : currentRemoteDir; + var parent = PathUtil.dirname(currDir.path, isWindows); // specially for C:\, D:\, goto '/' - if (parent == currDir.path && currentIsWindows) { + if (parent == currDir.path && isWindows) { openDirectory('/', isLocal: isLocal); return; } @@ -293,17 +391,24 @@ class FileModel extends ChangeNotifier { sendFiles(SelectedItems items, {bool isRemote = false}) { if (isDesktop) { // desktop sendFiles - _jobProgress.state = JobState.inProgress; final toPath = - isRemote ? currentRemoteDir.path : currentLocalDir.path; + isRemote ? currentLocalDir.path : currentRemoteDir.path; final isWindows = - isRemote ? _localOption.isWindows : _remoteOption.isWindows; + isRemote ? _remoteOption.isWindows : _localOption.isWindows; final showHidden = - isRemote ? _localOption.showHidden : _remoteOption.showHidden ; + isRemote ? _remoteOption.showHidden : _localOption.showHidden; items.items.forEach((from) async { - _jobId++; - await _ffi.target?.bind.sessionSendFiles(id: '${_ffi.target?.id}', actId: _jobId, path: from.path, to: PathUtil.join(toPath, from.name, isWindows) + final jobId = ++_jobId; + _jobTable.add(JobProgress() + ..jobName = from.path + ..totalSize = from.size + ..state = JobState.inProgress + ..id = jobId + ..isRemote = isRemote + ); + _ffi.target?.bind.sessionSendFiles(id: '${_ffi.target?.id}', actId: _jobId, path: from.path, to: PathUtil.join(toPath, from.name, isWindows) ,fileNum: 0, includeHidden: showHidden, isRemote: isRemote); + print("path:${from.path}, toPath:${toPath}, to:${PathUtil.join(toPath, from.name, isWindows)}"); }); } else { if (items.isLocal == null) { @@ -514,33 +619,38 @@ class FileModel extends ChangeNotifier { } sendRemoveFile(String path, int fileNum, bool isLocal) { - _ffi.target?.bind.sessionRemoveFile(id: '${_ffi.target?.getId()}', actId: _jobId, path: path, isRemote: !isLocal, fileNum: fileNum); + _ffi.target?.bind.sessionRemoveFile(id: '${_ffi.target?.id}', actId: _jobId, path: path, isRemote: !isLocal, fileNum: fileNum); } sendRemoveEmptyDir(String path, int fileNum, bool isLocal) { - _ffi.target?.bind.sessionRemoveAllEmptyDirs(id: '${_ffi.target?.getId()}', actId: _jobId, path: path, isRemote: !isLocal); + _ffi.target?.bind.sessionRemoveAllEmptyDirs(id: '${_ffi.target?.id}', actId: _jobId, path: path, isRemote: !isLocal); } - createDir(String path) async { + createDir(String path, {bool? isLocal}) async { + isLocal = isLocal ?? this.isLocal; _jobId++; - _ffi.target?.bind.sessionCreateDir(id: '${_ffi.target?.getId()}', actId: _jobId, path: path, isRemote: !isLocal); + _ffi.target?.bind.sessionCreateDir(id: '${_ffi.target?.id}', actId: _jobId, path: path, isRemote: !isLocal); } cancelJob(int id) async { - _ffi.target?.bind.sessionCancelJob(id: '${_ffi.target?.getId()}', actId: id); + _ffi.target?.bind.sessionCancelJob(id: '${_ffi.target?.id}', actId: id); jobReset(); } - changeSortStyle(SortBy sort, {bool? isLocal}) { + changeSortStyle(SortBy sort, {bool? isLocal, bool ascending = true}) { _sortStyle = sort; if (isLocal == null) { // compatible for mobile logic - _currentLocalDir.changeSortStyle(sort); - _currentRemoteDir.changeSortStyle(sort); + _currentLocalDir.changeSortStyle(sort, ascending: ascending); + _currentRemoteDir.changeSortStyle(sort, ascending: ascending); + _localSortStyle = sort; _localSortAscending = ascending; + _remoteSortStyle = sort; _remoteSortAscending = ascending; } else if (isLocal) { - _currentLocalDir.changeSortStyle(sort); + _currentLocalDir.changeSortStyle(sort, ascending: ascending); + _localSortStyle = sort; _localSortAscending = ascending; } else { - _currentRemoteDir.changeSortStyle(sort); + _currentRemoteDir.changeSortStyle(sort, ascending: ascending); + _remoteSortStyle = sort; _remoteSortAscending = ascending; } notifyListeners(); } @@ -548,6 +658,67 @@ class FileModel extends ChangeNotifier { initFileFetcher() { _fileFetcher.id = _ffi.target?.id; } + + void updateFolderFiles(Map evt) { + // ret: "{\"id\":1,\"num_entries\":12,\"total_size\":1264822.0}" + Map info = json.decode(evt['info']); + int id = info['id']; + int num_entries = info['num_entries']; + double total_size = info['total_size']; + final jobIndex = getJob(id); + if (jobIndex != -1) { + final job = jobTable[jobIndex]; + job.fileCount = num_entries; + job.totalSize = total_size.toInt(); + } + debugPrint("update folder files: ${info}"); + } + + bool get remoteSortAscending => _remoteSortAscending; + + void loadLastJob(Map evt) { + debugPrint("load last job: ${evt}"); + Map jobDetail = json.decode(evt['value']); + // int id = int.parse(jobDetail['id']); + String remote = jobDetail['remote']; + String to = jobDetail['to']; + bool showHidden = jobDetail['show_hidden']; + int fileNum = jobDetail['file_num']; + bool isRemote = jobDetail['is_remote']; + final currJobId = _jobId++; + var jobProgress = JobProgress() + ..jobName = isRemote ? remote : to + ..id = currJobId + ..isRemote = isRemote + ..fileNum = fileNum + ..remote = remote + ..to = to + ..showHidden = showHidden + ..state = JobState.paused; + jobTable.add(jobProgress); + _ffi.target?.bind.sessionAddJob(id: '${_ffi.target?.id}', + isRemote: isRemote, + includeHidden: showHidden, + actId: currJobId, + path: isRemote ? remote : to, + to: isRemote ? to: remote, + fileNum: fileNum, + ); + } + + resumeJob(int jobId) { + final jobIndex = getJob(jobId); + if (jobIndex != -1) { + final job = jobTable[jobIndex]; + _ffi.target?.bind.sessionResumeJob(id: '${_ffi.target?.id}', + actId: job.id, + isRemote: job.isRemote); + job.state = JobState.inProgress; + } else { + debugPrint("jobId ${jobId} is not exists"); + } + notifyListeners(); + } } class JobResultListener { @@ -717,8 +888,8 @@ class FileDirectory { } } - changeSortStyle(SortBy sort) { - entries = _sortList(entries, sort); + changeSortStyle(SortBy sort, {bool ascending = true}) { + entries = _sortList(entries, sort, ascending); } clear() { @@ -753,7 +924,24 @@ class Entry { } } -enum JobState { none, inProgress, done, error } +enum JobState { none, inProgress, done, error, paused } + +extension JobStateDisplay on JobState { + String display() { + switch (this) { + case JobState.none: + return translate("Waiting"); + case JobState.inProgress: + return translate("Transfer File"); + case JobState.done: + return translate("Finished"); + case JobState.error: + return translate("Error"); + default: + return ""; + } + } +} class JobProgress { JobState state = JobState.none; @@ -761,6 +949,13 @@ class JobProgress { var fileNum = 0; var speed = 0.0; var finishedSize = 0; + var totalSize = 0; + var fileCount = 0; + var isRemote = false; + var jobName = ""; + var remote = ""; + var to = ""; + var showHidden = false; clear() { state = JobState.none; @@ -768,6 +963,10 @@ class JobProgress { fileNum = 0; speed = 0; finishedSize = 0; + jobName = ""; + fileCount = 0; + remote = ""; + to = ""; } } @@ -814,7 +1013,7 @@ class DirectoryOption { } // code from file_manager pkg after edit -List _sortList(List list, SortBy sortType) { +List _sortList(List list, SortBy sortType, bool ascending) { if (sortType == SortBy.Name) { // making list of only folders. final dirs = list.where((element) => element.isDirectory).toList(); @@ -827,7 +1026,7 @@ List _sortList(List list, SortBy sortType) { files.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); // first folders will go to list (if available) then files will go to list. - return [...dirs, ...files]; + return ascending ? [...dirs, ...files] : [...dirs.reversed.toList(), ...files.reversed.toList()]; } else if (sortType == SortBy.Modified) { // making the list of Path & DateTime List<_PathStat> _pathStat = []; @@ -842,7 +1041,7 @@ List _sortList(List list, SortBy sortType) { list.sort((a, b) => _pathStat .indexWhere((element) => element.path == a.name) .compareTo(_pathStat.indexWhere((element) => element.path == b.name))); - return list; + return ascending ? list : list.reversed.toList(); } else if (sortType == SortBy.Type) { // making list of only folders. final dirs = list.where((element) => element.isDirectory).toList(); @@ -859,7 +1058,7 @@ List _sortList(List list, SortBy sortType) { .split('.') .last .compareTo(b.name.toLowerCase().split('.').last)); - return [...dirs, ...files]; + return ascending ? [...dirs, ...files]: [...dirs.reversed.toList(), ...files.reversed.toList()]; } else if (sortType == SortBy.Size) { // create list of path and size Map _sizeMap = {}; @@ -884,7 +1083,7 @@ List _sortList(List list, SortBy sortType) { .indexWhere((element) => element.key == a.name) .compareTo( _sizeMapList.indexWhere((element) => element.key == b.name))); - return [...dirs, ...files]; + return ascending ? [...dirs, ...files]: [...dirs.reversed.toList(), ...files.reversed.toList()]; } return []; } diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index e5e521035..45a5bc696 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -168,6 +168,10 @@ class FfiModel with ChangeNotifier { parent.target?.fileModel.jobError(evt); } else if (name == 'override_file_confirm') { parent.target?.fileModel.overrideFileConfirm(evt); + } else if (name == 'load_last_job') { + parent.target?.fileModel.loadLastJob(evt); + } else if (name == 'update_folder_files') { + parent.target?.fileModel.updateFolderFiles(evt); } else if (name == 'try_start_without_auth') { parent.target?.serverModel.loginRequest(evt); } else if (name == 'on_client_authorized') { @@ -217,6 +221,10 @@ class FfiModel with ChangeNotifier { parent.target?.fileModel.jobError(evt); } else if (name == 'override_file_confirm') { parent.target?.fileModel.overrideFileConfirm(evt); + } else if (name == 'load_last_job') { + parent.target?.fileModel.loadLastJob(evt); + } else if (name == 'update_folder_files') { + parent.target?.fileModel.updateFolderFiles(evt); } else if (name == 'try_start_without_auth') { parent.target?.serverModel.loginRequest(evt); } else if (name == 'on_client_authorized') { diff --git a/flutter/macos/Runner/bridge_generated.h b/flutter/macos/Runner/bridge_generated.h index 7f072e770..163ad91cd 100644 --- a/flutter/macos/Runner/bridge_generated.h +++ b/flutter/macos/Runner/bridge_generated.h @@ -102,6 +102,10 @@ void wire_session_peer_option(int64_t port_, struct wire_uint_8_list *name, struct wire_uint_8_list *value); +void wire_session_get_peer_option(int64_t port_, + struct wire_uint_8_list *id, + struct wire_uint_8_list *name); + void wire_session_input_os_password(int64_t port_, struct wire_uint_8_list *id, struct wire_uint_8_list *value); @@ -139,7 +143,8 @@ void wire_session_read_dir_recursive(int64_t port_, struct wire_uint_8_list *id, int32_t act_id, struct wire_uint_8_list *path, - bool is_remote); + bool is_remote, + bool show_hidden); void wire_session_remove_all_empty_dirs(int64_t port_, struct wire_uint_8_list *id, @@ -197,6 +202,7 @@ static int64_t dummy_method_to_enforce_bundling(void) { dummy_var ^= ((int64_t) (void*) wire_session_send_chat); dummy_var ^= ((int64_t) (void*) wire_session_send_mouse); dummy_var ^= ((int64_t) (void*) wire_session_peer_option); + dummy_var ^= ((int64_t) (void*) wire_session_get_peer_option); dummy_var ^= ((int64_t) (void*) wire_session_input_os_password); dummy_var ^= ((int64_t) (void*) wire_session_read_remote_dir); dummy_var ^= ((int64_t) (void*) wire_session_send_files); diff --git a/src/client/file_trait.rs b/src/client/file_trait.rs index 1d5be47da..cc149c53f 100644 --- a/src/client/file_trait.rs +++ b/src/client/file_trait.rs @@ -93,7 +93,7 @@ pub trait FileManager: Interface { } fn add_job( - &mut self, + &self, id: i32, path: String, to: String, @@ -111,7 +111,7 @@ pub trait FileManager: Interface { ))); } - fn resume_job(&mut self, id: i32, is_remote: bool) { + fn resume_job(&self, id: i32, is_remote: bool) { self.send(Data::ResumeJob((id, is_remote))); } } diff --git a/src/common.rs b/src/common.rs index 92ccb901e..c344b93a1 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1,5 +1,9 @@ +use std::sync::{Arc, Mutex}; + #[cfg(not(any(target_os = "android", target_os = "ios")))] pub use arboard::Clipboard as ClipboardContext; +use serde_json::json; + use hbb_common::{ allow_err, anyhow::bail, @@ -14,7 +18,6 @@ use hbb_common::{ }; // #[cfg(any(target_os = "android", target_os = "ios", feature = "cli"))] use hbb_common::{config::RENDEZVOUS_PORT, futures::future::join_all}; -use std::sync::{Arc, Mutex}; pub const CLIPBOARD_NAME: &'static str = "clipboard"; pub const CLIPBOARD_INTERVAL: u64 = 333; @@ -633,3 +636,30 @@ pub fn make_fd_to_json(fd: FileDirectory) -> String { fd_json.insert("entries".into(), json!(entries)); serde_json::to_string(&fd_json).unwrap_or("".into()) } + +pub fn make_fd_flutter(id: i32, entries: &Vec, only_count: bool) -> String { + let mut m = serde_json::Map::new(); + m.insert("id".into(), json!(id)); + let mut a = vec![]; + let mut n: u64 = 0; + for entry in entries { + n += entry.size; + if only_count { + continue; + } + let mut e = serde_json::Map::new(); + e.insert("name".into(), json!(entry.name.to_owned())); + let tmp = entry.entry_type.value(); + e.insert("type".into(), json!(if tmp == 0 { 1 } else { tmp })); + e.insert("time".into(), json!(entry.modified_time as f64)); + e.insert("size".into(), json!(entry.size as f64)); + a.push(e); + } + if only_count { + m.insert("num_entries".into(), json!(entries.len() as i32)); + } else { + m.insert("entries".into(), json!(a)); + } + m.insert("total_size".into(), json!(n as f64)); + serde_json::to_string(&m).unwrap_or("".into()) +} diff --git a/src/flutter.rs b/src/flutter.rs index 4877cce58..2807d1711 100644 --- a/src/flutter.rs +++ b/src/flutter.rs @@ -5,6 +5,8 @@ use std::{ use flutter_rust_bridge::{StreamSink, ZeroCopyBuffer}; +use hbb_common::config::{PeerConfig, TransferSerde}; +use hbb_common::fs::{get_job, TransferJobMeta}; use hbb_common::{ allow_err, compress::decompress, @@ -27,7 +29,7 @@ use hbb_common::{ }; use crate::common::make_fd_to_json; -use crate::{client::*, flutter_ffi::EventToUI}; +use crate::{client::*, flutter_ffi::EventToUI, make_fd_flutter}; lazy_static::lazy_static! { // static ref SESSION: Arc>> = Default::default(); @@ -464,6 +466,45 @@ impl Session { log::debug!("{:?}", msg_out); self.send_msg(msg_out); } + + pub fn load_config(&self) -> PeerConfig { + load_config(&self.id) + } + + pub fn save_config(&self, config: &PeerConfig) { + config.store(&self.id); + } + + pub fn get_platform(&self, is_remote: bool) -> String { + if is_remote { + self.lc.read().unwrap().info.platform.clone() + } else { + whoami::platform().to_string() + } + } + + pub fn load_last_jobs(&self) { + let pc = self.load_config(); + if pc.transfer.write_jobs.is_empty() && pc.transfer.read_jobs.is_empty() { + // no last jobs + return; + } + let mut cnt = 1; + for job_str in pc.transfer.read_jobs.iter() { + if !job_str.is_empty() { + self.push_event("load_last_job", vec![("value", job_str)]); + cnt += 1; + println!("restore read_job: {:?}", job_str); + } + } + for job_str in pc.transfer.write_jobs.iter() { + if !job_str.is_empty() { + self.push_event("load_last_job", vec![("value", job_str)]); + cnt += 1; + println!("restore write_job: {:?}", job_str); + } + } + } } impl FileManager for Session {} @@ -941,6 +982,7 @@ impl Connection { async fn handle_msg_from_ui(&mut self, data: Data, peer: &mut Stream) -> bool { match data { Data::Close => { + self.sync_jobs_status_to_local().await; return false; } Data::Login((password, remember)) => { @@ -952,8 +994,7 @@ impl Connection { allow_err!(peer.send(&msg).await); } Data::SendFiles((id, path, to, file_num, include_hidden, is_remote)) => { - // in mobile, can_enable_override_detection is always true - let od = true; + let od = can_enable_overwrite_detection(self.session.lc.read().unwrap().version); if is_remote { log::debug!("New job {}, write to {} from remote {}", id, to, path); self.write_jobs.push(fs::TransferJob::new_write( @@ -964,7 +1005,7 @@ impl Connection { include_hidden, is_remote, Vec::new(), - true, + od, )); allow_err!( peer.send(&fs::new_send(id, path, file_num, include_hidden)) @@ -978,7 +1019,7 @@ impl Connection { file_num, include_hidden, is_remote, - true, + od, ) { Err(err) => { self.handle_job_status(id, -1, Some(err.to_string())); @@ -991,6 +1032,9 @@ impl Connection { to, job.files().len() ); + let m = make_fd_flutter(id, job.files(), true); + self.session + .push_event("update_folder_files", vec![("info", &m)]); let files = job.files().clone(); self.read_jobs.push(job); self.timer = time::interval(MILLI1); @@ -1140,6 +1184,87 @@ impl Connection { } } } + Data::AddJob((id, path, to, file_num, include_hidden, is_remote)) => { + let od = can_enable_overwrite_detection(self.session.lc.read().unwrap().version); + if is_remote { + log::debug!( + "new write waiting job {}, write to {} from remote {}", + id, + to, + path + ); + let mut job = fs::TransferJob::new_write( + id, + path.clone(), + to, + file_num, + include_hidden, + is_remote, + Vec::new(), + od, + ); + job.is_last_job = true; + self.write_jobs.push(job); + } else { + match fs::TransferJob::new_read( + id, + to.clone(), + path.clone(), + file_num, + include_hidden, + is_remote, + od, + ) { + Err(err) => { + self.handle_job_status(id, -1, Some(err.to_string())); + } + Ok(mut job) => { + log::debug!( + "new read waiting job {}, read {} to remote {}, {} files", + id, + path, + to, + job.files().len() + ); + let m = make_fd_flutter(job.id(), job.files(), true); + self.session + .push_event("update_folder_files", vec![("info", &m)]); + job.is_last_job = true; + self.read_jobs.push(job); + self.timer = time::interval(MILLI1); + } + } + } + } + Data::ResumeJob((id, is_remote)) => { + if is_remote { + if let Some(job) = get_job(id, &mut self.write_jobs) { + job.is_last_job = false; + allow_err!( + peer.send(&fs::new_send( + id, + job.remote.clone(), + job.file_num, + job.show_hidden + )) + .await + ); + } + } else { + if let Some(job) = get_job(id, &mut self.read_jobs) { + job.is_last_job = false; + allow_err!( + peer.send(&fs::new_receive( + id, + job.path.to_string_lossy().to_string(), + job.file_num, + job.files.clone() + )) + .await + ); + } + } + } _ => {} } true @@ -1229,6 +1354,24 @@ impl Connection { ], ); } + + async fn sync_jobs_status_to_local(&mut self) -> bool { + log::info!("sync transfer job status"); + let mut config: PeerConfig = self.session.load_config(); + let mut transfer_metas = TransferSerde::default(); + for job in self.read_jobs.iter() { + let json_str = serde_json::to_string(&job.gen_meta()).unwrap(); + transfer_metas.read_jobs.push(json_str); + } + for job in self.write_jobs.iter() { + let json_str = serde_json::to_string(&job.gen_meta()).unwrap(); + transfer_metas.write_jobs.push(json_str); + } + log::info!("meta: {:?}", transfer_metas); + config.transfer = transfer_metas; + self.session.save_config(&config); + true + } } // Server Side @@ -1470,7 +1613,6 @@ pub mod connection_manager { mut files, } => { // in mobile, can_enable_override_detection is always true - let od = true; WRITE_JOBS.lock().unwrap().push(fs::TransferJob::new_write( id, "".to_string(), diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 9bc533336..f2bef5716 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -339,6 +339,45 @@ pub fn session_read_local_dir_sync(id: String, path: String, show_hidden: bool) "".to_string() } +pub fn session_get_platform(id: String, is_remote: bool) -> String { + if let Some(session) = SESSIONS.read().unwrap().get(&id) { + return session.get_platform(is_remote); + } + "".to_string() +} + +pub fn session_load_last_transfer_jobs(id: String) { + if let Some(session) = SESSIONS.read().unwrap().get(&id) { + return session.load_last_jobs(); + } else { + // a tip for flutter dev + eprintln!( + "cannot load last transfer job from non-existed session. Please ensure session \ + is connected before calling load last transfer jobs." + ); + } +} + +pub fn session_add_job( + id: String, + act_id: i32, + path: String, + to: String, + file_num: i32, + include_hidden: bool, + is_remote: bool, +) { + if let Some(session) = SESSIONS.read().unwrap().get(&id) { + session.add_job(act_id, path, to, file_num, include_hidden, is_remote); + } +} + +pub fn session_resume_job(id: String, act_id: i32, is_remote: bool) { + if let Some(session) = SESSIONS.read().unwrap().get(&id) { + session.resume_job(act_id, is_remote); + } +} + /// FFI for **get** commands which are idempotent. /// Return result in c string. ///