import 'dart:async'; import 'dart:io'; import 'dart:math'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_breadcrumb/flutter_breadcrumb.dart'; import 'package:flutter_hbb/desktop/widgets/list_search_action_listener.dart'; import 'package:flutter_hbb/desktop/widgets/tabbar_widget.dart'; import 'package:flutter_hbb/models/file_model.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; import 'package:wakelock/wakelock.dart'; import '../../consts.dart'; import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu; import '../../common.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; import '../widgets/popup_menu.dart'; /// status of location bar enum LocationStatus { /// normal bread crumb bar bread, /// show path text field pathLocation, /// show file search bar text field fileSearchBar } /// The status of currently focused scope of the mouse enum MouseFocusScope { /// Mouse is in local field. local, /// Mouse is in remote field. remote, /// Mouse is not in local field, remote neither. none } class FileManagerPage extends StatefulWidget { const FileManagerPage({Key? key, required this.id}) : super(key: key); final String id; @override State createState() => _FileManagerPageState(); } class _FileManagerPageState extends State with AutomaticKeepAliveClientMixin { final _localSelectedItems = SelectedItems(); final _remoteSelectedItems = SelectedItems(); final _locationStatusLocal = LocationStatus.bread.obs; final _locationStatusRemote = LocationStatus.bread.obs; final _locationNodeLocal = FocusNode(debugLabel: "locationNodeLocal"); final _locationNodeRemote = FocusNode(debugLabel: "locationNodeRemote"); final _locationBarKeyLocal = GlobalKey(debugLabel: "locationBarKeyLocal"); final _locationBarKeyRemote = GlobalKey(debugLabel: "locationBarKeyRemote"); final _searchTextLocal = "".obs; final _searchTextRemote = "".obs; final _breadCrumbScrollerLocal = ScrollController(); final _breadCrumbScrollerRemote = ScrollController(); final _mouseFocusScope = Rx(MouseFocusScope.none); final _keyboardNodeLocal = FocusNode(debugLabel: "keyboardNodeLocal"); final _keyboardNodeRemote = FocusNode(debugLabel: "keyboardNodeRemote"); final _listSearchBufferLocal = TimeoutStringBuffer(); final _listSearchBufferRemote = TimeoutStringBuffer(); /// [_lastClickTime], [_lastClickEntry] help to handle double click int _lastClickTime = DateTime.now().millisecondsSinceEpoch - bind.getDoubleClickTime() - 1000; Entry? _lastClickEntry; final _dropMaskVisible = false.obs; // TODO impl drop mask ScrollController getBreadCrumbScrollController(bool isLocal) { return isLocal ? _breadCrumbScrollerLocal : _breadCrumbScrollerRemote; } GlobalKey getLocationBarKey(bool isLocal) { return isLocal ? _locationBarKeyLocal : _locationBarKeyRemote; } late FFI _ffi; FileModel get model => _ffi.fileModel; SelectedItems getSelectedItems(bool isLocal) { return isLocal ? _localSelectedItems : _remoteSelectedItems; } @override void initState() { super.initState(); _ffi = FFI(); _ffi.start(widget.id, isFileTransfer: true); WidgetsBinding.instance.addPostFrameCallback((_) { _ffi.dialogManager .showLoading(translate('Connecting...'), onCancel: closeConnection); }); Get.put(_ffi, tag: 'ft_${widget.id}'); if (!Platform.isLinux) { Wakelock.enable(); } debugPrint("File manager page init success with id ${widget.id}"); model.onDirChanged = breadCrumbScrollToEnd; // register location listener _locationNodeLocal.addListener(onLocalLocationFocusChanged); _locationNodeRemote.addListener(onRemoteLocationFocusChanged); } @override void dispose() { model.onClose().whenComplete(() { _ffi.close(); _ffi.dialogManager.dismissAll(); if (!Platform.isLinux) { Wakelock.disable(); } Get.delete(tag: 'ft_${widget.id}'); _locationNodeLocal.removeListener(onLocalLocationFocusChanged); _locationNodeRemote.removeListener(onRemoteLocationFocusChanged); _locationNodeLocal.dispose(); _locationNodeRemote.dispose(); }); super.dispose(); } @override Widget build(BuildContext context) { super.build(context); return Overlay(initialEntries: [ OverlayEntry(builder: (context) { _ffi.dialogManager.setOverlayState(Overlay.of(context)); return ChangeNotifierProvider.value( value: _ffi.fileModel, child: Consumer(builder: (context, model, child) { return Scaffold( backgroundColor: Theme.of(context).backgroundColor, body: Row( children: [ Flexible(flex: 3, child: body(isLocal: true)), Flexible(flex: 3, child: body(isLocal: false)), Flexible(flex: 2, child: statusList()) ], ), ); })); }) ]); } Widget menu({bool isLocal = false}) { var menuPos = RelativeRect.fill; final List> items = [ MenuEntrySwitch( switchType: SwitchType.scheckbox, text: translate("Show Hidden Files"), getter: () async { return model.getCurrentShowHidden(isLocal); }, setter: (bool v) async { model.toggleShowHidden(local: isLocal); }, padding: kDesktopMenuPadding, dismissOnClicked: true, ), MenuEntryButton( childBuilder: (style) => Text(translate("Select All"), style: style), proc: () => setState(() => getSelectedItems(isLocal) .selectAll(model.getCurrentDir(isLocal).entries)), padding: kDesktopMenuPadding, dismissOnClicked: true), MenuEntryButton( childBuilder: (style) => Text(translate("Unselect All"), style: style), proc: () => setState(() => getSelectedItems(isLocal).clear()), padding: kDesktopMenuPadding, dismissOnClicked: true) ]; return Listener( onPointerDown: (e) { final x = e.position.dx; final y = e.position.dy; menuPos = RelativeRect.fromLTRB(x, y, x, y); }, child: IconButton( icon: const Icon(Icons.more_vert), splashRadius: kDesktopIconButtonSplashRadius, onPressed: () => mod_menu.showMenu( context: context, position: menuPos, items: items .map((e) => e.build( context, MenuConfig( commonColor: CustomPopupMenuTheme.commonColor, height: CustomPopupMenuTheme.height, dividerHeight: CustomPopupMenuTheme.dividerHeight))) .expand((i) => i) .toList(), elevation: 8, ), )); } Widget body({bool isLocal = false}) { final scrollController = ScrollController(); return Container( decoration: BoxDecoration(border: Border.all(color: Colors.black26)), margin: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(8.0), child: DropTarget( onDragDone: (detail) => handleDragDone(detail, isLocal), onDragEntered: (enter) { _dropMaskVisible.value = true; }, onDragExited: (exit) { _dropMaskVisible.value = false; }, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ headTools(isLocal), Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: SingleChildScrollView( controller: scrollController, child: _buildDataTable(context, isLocal, scrollController), ), ) ], )), ]), ), ); } Widget _buildDataTable( BuildContext context, bool isLocal, ScrollController scrollController) { const rowHeight = 25.0; final fd = model.getCurrentDir(isLocal); final entries = fd.entries; final sortIndex = (SortBy style) { switch (style) { case SortBy.name: return 0; case SortBy.type: return 0; case SortBy.modified: return 1; case SortBy.size: return 2; } }(model.getSortStyle(isLocal)); final sortAscending = isLocal ? model.localSortAscending : model.remoteSortAscending; return MouseRegion( onEnter: (evt) { _mouseFocusScope.value = isLocal ? MouseFocusScope.local : MouseFocusScope.remote; if (isLocal) { _keyboardNodeLocal.requestFocus(); } else { _keyboardNodeRemote.requestFocus(); } }, onExit: (evt) { _mouseFocusScope.value = MouseFocusScope.none; }, child: ListSearchActionListener( node: isLocal ? _keyboardNodeLocal : _keyboardNodeRemote, buffer: isLocal ? _listSearchBufferLocal : _listSearchBufferRemote, onNext: (buffer) { debugPrint("searching next for $buffer"); assert(buffer.length == 1); final selectedEntries = getSelectedItems(isLocal); assert(selectedEntries.length <= 1); var skipCount = 0; if (selectedEntries.items.isNotEmpty) { final index = entries.indexOf(selectedEntries.items.first); if (index < 0) { return; } skipCount = index + 1; } var searchResult = entries .skip(skipCount) .where((element) => element.name.startsWith(buffer)); if (searchResult.isEmpty) { // cannot find next, lets restart search from head searchResult = entries.where((element) => element.name.startsWith(buffer)); } if (searchResult.isEmpty) { setState(() { getSelectedItems(isLocal).clear(); }); return; } _jumpToEntry( isLocal, searchResult.first, scrollController, rowHeight, buffer); }, onSearch: (buffer) { debugPrint("searching for $buffer"); final selectedEntries = getSelectedItems(isLocal); final searchResult = entries.where((element) => element.name.startsWith(buffer)); selectedEntries.clear(); if (searchResult.isEmpty) { setState(() { getSelectedItems(isLocal).clear(); }); return; } _jumpToEntry( isLocal, searchResult.first, scrollController, rowHeight, buffer); }, child: ObxValue( (searchText) { final filteredEntries = searchText.isNotEmpty ? entries.where((element) { return element.name.contains(searchText.value); }).toList(growable: false) : entries; return DataTable( key: ValueKey(isLocal ? 0 : 1), showCheckboxColumn: false, dataRowHeight: rowHeight, headingRowHeight: 30, horizontalMargin: 8, columnSpacing: 8, showBottomBorder: true, sortColumnIndex: sortIndex, sortAscending: sortAscending, columns: [ DataColumn( label: Text( translate("Name"), ).marginSymmetric(horizontal: 4), 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: filteredEntries.map((entry) { final sizeStr = entry.isFile ? readableFileSize(entry.size.toDouble()) : ""; final lastModifiedStr = entry.isDrive ? " " : "${entry.lastModified().toString().replaceAll(".000", "")} "; return DataRow( key: ValueKey(entry.name), onSelectChanged: (s) { _onSelectedChanged(getSelectedItems(isLocal), filteredEntries, entry, isLocal); }, selected: getSelectedItems(isLocal).contains(entry), cells: [ DataCell( Container( width: 200, child: Tooltip( waitDuration: Duration(milliseconds: 500), message: entry.name, child: Row(children: [ entry.isDrive ? Image( image: iconHardDrive, fit: BoxFit.scaleDown, color: Theme.of(context) .iconTheme .color ?.withOpacity(0.7)) .paddingAll(4) : Icon( entry.isFile ? Icons.feed_outlined : Icons.folder, size: 20, color: Theme.of(context) .iconTheme .color ?.withOpacity(0.7), ).marginSymmetric(horizontal: 2), Expanded( child: Text(entry.name, overflow: TextOverflow.ellipsis)) ]), )), onTap: () { final items = getSelectedItems(isLocal); // handle double click if (_checkDoubleClick(entry)) { openDirectory(entry.path, isLocal: isLocal); items.clear(); return; } _onSelectedChanged( items, filteredEntries, entry, isLocal); }, ), DataCell(FittedBox( child: Tooltip( waitDuration: Duration(milliseconds: 500), message: lastModifiedStr, child: Text( lastModifiedStr, style: TextStyle( fontSize: 12, color: MyTheme.darkGray), )))), DataCell(Tooltip( waitDuration: Duration(milliseconds: 500), message: sizeStr, child: Text( sizeStr, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 10, color: MyTheme.darkGray), ))), ]); }).toList(growable: false), ); }, isLocal ? _searchTextLocal : _searchTextRemote, ), ), ); } void _jumpToEntry(bool isLocal, Entry entry, ScrollController scrollController, double rowHeight, String buffer) { final entries = model.getCurrentDir(isLocal).entries; final index = entries.indexOf(entry); if (index == -1) { debugPrint("entry is not valid: ${entry.path}"); } final selectedEntries = getSelectedItems(isLocal); final searchResult = entries.where((element) => element.name.startsWith(buffer)); selectedEntries.clear(); if (searchResult.isEmpty) { return; } final offset = min( max(scrollController.position.minScrollExtent, entries.indexOf(searchResult.first) * rowHeight), scrollController.position.maxScrollExtent); scrollController.jumpTo(offset); setState(() { selectedEntries.add(isLocal, searchResult.first); debugPrint("focused on ${searchResult.first.name}"); }); } void _onSelectedChanged(SelectedItems selectedItems, List entries, Entry entry, bool isLocal) { final isCtrlDown = RawKeyboard.instance.keysPressed .contains(LogicalKeyboardKey.controlLeft); final isShiftDown = RawKeyboard.instance.keysPressed.contains(LogicalKeyboardKey.shiftLeft); if (isCtrlDown) { if (selectedItems.contains(entry)) { selectedItems.remove(entry); } else { selectedItems.add(isLocal, entry); } } else if (isShiftDown) { final List indexGroup = []; for (var selected in selectedItems.items) { indexGroup.add(entries.indexOf(selected)); } indexGroup.add(entries.indexOf(entry)); indexGroup.removeWhere((e) => e == -1); final maxIndex = indexGroup.reduce(max); final minIndex = indexGroup.reduce(min); selectedItems.clear(); entries .getRange(minIndex, maxIndex + 1) .forEach((e) => selectedItems.add(isLocal, e)); } else { selectedItems.clear(); selectedItems.add(isLocal, entry); } setState(() {}); } bool _checkDoubleClick(Entry entry) { final current = DateTime.now().millisecondsSinceEpoch; final elapsed = current - _lastClickTime; _lastClickTime = current; if (_lastClickEntry == entry) { if (elapsed < bind.getDoubleClickTime()) { return true; } } else { _lastClickEntry = entry; } return false; } /// transfer status list /// watch transfer status Widget statusList() { return PreferredSize( preferredSize: const Size(200, double.infinity), child: Container( margin: const EdgeInsets.only(top: 16.0, bottom: 16.0, right: 16.0), padding: const EdgeInsets.all(8.0), decoration: BoxDecoration(border: Border.all(color: Colors.grey)), child: Obx( () => ListView.builder( controller: ScrollController(), 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: const Icon(Icons.send)), const SizedBox( width: 16.0, ), Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Tooltip( waitDuration: Duration(milliseconds: 500), message: item.jobName, child: Text( item.jobName, maxLines: 1, overflow: TextOverflow.ellipsis, )), Wrap( children: [ Text( '${item.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); }, splashRadius: kDesktopIconButtonSplashRadius, icon: const Icon(Icons.restart_alt_rounded)), ), IconButton( icon: const Icon(Icons.close), splashRadius: 1, onPressed: () { model.jobTable.removeAt(index); model.cancelJob(item.id); }, ), ], ) ], ), SizedBox( height: 8.0, ), Divider( height: 2.0, ) ], ); }, itemCount: model.jobTable.length, ), ), )); } Widget headTools(bool isLocal) { final locationStatus = isLocal ? _locationStatusLocal : _locationStatusRemote; final locationFocus = isLocal ? _locationNodeLocal : _locationNodeRemote; final selectedItems = getSelectedItems(isLocal); return Container( child: Column( children: [ // 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: 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: [ Row( children: [ IconButton( icon: const Icon(Icons.arrow_back), splashRadius: kDesktopIconButtonSplashRadius, onPressed: () { selectedItems.clear(); model.goBack(isLocal: isLocal); }, ), IconButton( icon: const Icon(Icons.arrow_upward), splashRadius: kDesktopIconButtonSplashRadius, onPressed: () { selectedItems.clear(); model.goToParentDirectory(isLocal: isLocal); }, ), ], ), Expanded( child: GestureDetector( onTap: () { locationStatus.value = locationStatus.value == LocationStatus.bread ? LocationStatus.pathLocation : LocationStatus.bread; Future.delayed(Duration.zero, () { if (locationStatus.value == LocationStatus.pathLocation) { locationFocus.requestFocus(); } }); }, child: Obx(() => Container( decoration: BoxDecoration( border: Border.all( color: locationStatus.value == LocationStatus.bread ? Colors.black12 : Theme.of(context) .colorScheme .primary .withOpacity(0.5))), child: Row( children: [ Expanded( child: locationStatus.value == LocationStatus.bread ? buildBread(isLocal) : buildPathLocation(isLocal)), ], ))), )), Obx(() { switch (locationStatus.value) { case LocationStatus.bread: return IconButton( onPressed: () { locationStatus.value = LocationStatus.fileSearchBar; final focusNode = isLocal ? _locationNodeLocal : _locationNodeRemote; Future.delayed( Duration.zero, () => focusNode.requestFocus()); }, splashRadius: kDesktopIconButtonSplashRadius, icon: Icon(Icons.search)); case LocationStatus.pathLocation: return IconButton( color: Theme.of(context).disabledColor, onPressed: null, splashRadius: kDesktopIconButtonSplashRadius, icon: Icon(Icons.close)); case LocationStatus.fileSearchBar: return IconButton( color: Theme.of(context).disabledColor, onPressed: () { onSearchText("", isLocal); locationStatus.value = LocationStatus.bread; }, splashRadius: 1, icon: Icon(Icons.close)); } }), IconButton( onPressed: () { model.refresh(isLocal: isLocal); }, splashRadius: kDesktopIconButtonSplashRadius, icon: const Icon(Icons.refresh)), ], ), Row( textDirection: isLocal ? TextDirection.ltr : TextDirection.rtl, children: [ Expanded( child: Row( mainAxisAlignment: isLocal ? MainAxisAlignment.start : MainAxisAlignment.end, children: [ IconButton( onPressed: () { model.goHome(isLocal: isLocal); }, icon: const Icon(Icons.home_outlined), splashRadius: kDesktopIconButtonSplashRadius, ), IconButton( onPressed: () { final name = TextEditingController(); _ffi.dialogManager.show((setState, close) { submit() { if (name.value.text.isNotEmpty) { model.createDir( PathUtil.join( model.getCurrentDir(isLocal).path, name.value.text, model.getCurrentIsWindows(isLocal)), isLocal: isLocal); close(); } } cancel() => close(false); return CustomAlertDialog( title: Text(translate("Create Folder")), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextFormField( decoration: InputDecoration( labelText: translate( "Please enter the folder name"), ), controller: name, focusNode: FocusNode()..requestFocus(), ), ], ), actions: [ TextButton( style: flatButtonStyle, onPressed: cancel, child: Text(translate("Cancel"))), ElevatedButton( style: flatButtonStyle, onPressed: submit, child: Text(translate("OK"))) ], onSubmit: submit, onCancel: cancel, ); }); }, splashRadius: kDesktopIconButtonSplashRadius, icon: const Icon(Icons.create_new_folder_outlined)), IconButton( onPressed: validItems(selectedItems) ? () async { await (model.removeAction(selectedItems, isLocal: isLocal)); selectedItems.clear(); } : null, splashRadius: kDesktopIconButtonSplashRadius, icon: const Icon(Icons.delete_forever_outlined)), menu(isLocal: isLocal), ], ), ), TextButton.icon( onPressed: validItems(selectedItems) ? () { model.sendFiles(selectedItems, isRemote: !isLocal); selectedItems.clear(); } : null, icon: Transform.rotate( angle: isLocal ? 0 : pi, child: const Icon( Icons.send, ), ), label: Text( isLocal ? translate('Send') : translate('Receive'), )), ], ).marginOnly(top: 8.0) ], )); } bool validItems(SelectedItems items) { if (items.length > 0) { // exclude DirDrive type return items.items.any((item) => !item.isDrive); } return false; } @override bool get wantKeepAlive => true; void onLocalLocationFocusChanged() { debugPrint("focus changed on local"); if (_locationNodeLocal.hasFocus) { // ignore } else { // lost focus, change to bread if (_locationStatusLocal.value != LocationStatus.fileSearchBar) { _locationStatusLocal.value = LocationStatus.bread; } } } void onRemoteLocationFocusChanged() { debugPrint("focus changed on remote"); if (_locationNodeRemote.hasFocus) { // ignore } else { // lost focus, change to bread if (_locationStatusRemote.value != LocationStatus.fileSearchBar) { _locationStatusRemote.value = LocationStatus.bread; } } } Widget buildBread(bool isLocal) { final items = getPathBreadCrumbItems(isLocal, (list) { var path = ""; for (var item in list) { path = PathUtil.join(path, item, model.getCurrentIsWindows(isLocal)); } openDirectory(path, isLocal: isLocal); }); final locationBarKey = getLocationBarKey(isLocal); return items.isEmpty ? Offstage() : Row( key: locationBarKey, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: Listener( // handle mouse wheel onPointerSignal: (e) { if (e is PointerScrollEvent) { final sc = getBreadCrumbScrollController(isLocal); final scale = Platform.isWindows ? 2 : 4; sc.jumpTo(sc.offset + e.scrollDelta.dy / scale); } }, child: BreadCrumb( items: items, divider: Icon(Icons.chevron_right), overflow: ScrollableOverflow( controller: getBreadCrumbScrollController(isLocal)), ))), ActionIcon( message: "", icon: Icons.arrow_drop_down, onTap: () async { final renderBox = locationBarKey.currentContext ?.findRenderObject() as RenderBox; locationBarKey.currentContext?.size; final size = renderBox.size; final offset = renderBox.localToGlobal(Offset.zero); final x = offset.dx; final y = offset.dy + size.height + 1; final isPeerWindows = model.getCurrentIsWindows(isLocal); final List menuItems = [ MenuEntryButton( childBuilder: (TextStyle? style) => isPeerWindows ? buildWindowsThisPC(style) : Text( '/', style: style, ), proc: () { openDirectory('/', isLocal: isLocal); }, dismissOnClicked: true), MenuEntryDivider() ]; if (isPeerWindows) { var loadingTag = ""; if (!isLocal) { loadingTag = _ffi.dialogManager.showLoading("Waiting"); } try { final fd = await model.fetchDirectory("/", isLocal, isLocal); for (var entry in fd.entries) { menuItems.add(MenuEntryButton( childBuilder: (TextStyle? style) => Row(children: [ Image( image: iconHardDrive, fit: BoxFit.scaleDown, color: Theme.of(context) .iconTheme .color ?.withOpacity(0.7)), SizedBox(width: 10), Text( entry.name, style: style, ) ]), proc: () { openDirectory('${entry.name}\\', isLocal: isLocal); }, dismissOnClicked: true)); } } catch (e) { debugPrint("buildBread fetchDirectory err=$e"); } finally { if (!isLocal) { _ffi.dialogManager.dismissByTag(loadingTag); } } } menuItems.add(MenuEntryDivider()); mod_menu.showMenu( context: context, position: RelativeRect.fromLTRB(x, y, x, y), elevation: 4, items: menuItems .map((e) => e.build( context, MenuConfig( commonColor: CustomPopupMenuTheme.commonColor, height: CustomPopupMenuTheme.height, dividerHeight: CustomPopupMenuTheme.dividerHeight, boxWidth: size.width))) .expand((i) => i) .toList()); }, iconSize: 20, ) ]); } Widget buildWindowsThisPC([TextStyle? textStyle]) { final color = Theme.of(context).iconTheme.color?.withOpacity(0.7); return Row(children: [ Icon(Icons.computer, size: 20, color: color), SizedBox(width: 10), Text(translate('This PC'), style: textStyle) ]); } List getPathBreadCrumbItems( bool isLocal, void Function(List) onPressed) { final path = model.getCurrentDir(isLocal).path; final breadCrumbList = List.empty(growable: true); final isWindows = model.getCurrentIsWindows(isLocal); if (isWindows && path == '/') { breadCrumbList.add(BreadCrumbItem( content: TextButton( child: buildWindowsThisPC(), style: ButtonStyle( minimumSize: MaterialStateProperty.all(Size(0, 0))), onPressed: () => onPressed(['/'])) .marginSymmetric(horizontal: 4))); } else { final list = PathUtil.split(path, isWindows); breadCrumbList.addAll(list.asMap().entries.map((e) => BreadCrumbItem( content: TextButton( child: Text(e.value), style: ButtonStyle( minimumSize: MaterialStateProperty.all(Size(0, 0))), onPressed: () => onPressed(list.sublist(0, e.key + 1))) .marginSymmetric(horizontal: 4)))); } return breadCrumbList; } breadCrumbScrollToEnd(bool isLocal) { Future.delayed(Duration(milliseconds: 200), () { final breadCrumbScroller = getBreadCrumbScrollController(isLocal); if (breadCrumbScroller.hasClients) { breadCrumbScroller.animateTo( breadCrumbScroller.position.maxScrollExtent, duration: Duration(milliseconds: 200), curve: Curves.fastLinearToSlowEaseIn); } }); } Widget buildPathLocation(bool isLocal) { final searchTextObs = isLocal ? _searchTextLocal : _searchTextRemote; final locationStatus = isLocal ? _locationStatusLocal : _locationStatusRemote; final focusNode = isLocal ? _locationNodeLocal : _locationNodeRemote; final text = locationStatus.value == LocationStatus.pathLocation ? model.getCurrentDir(isLocal).path : searchTextObs.value; final textController = TextEditingController(text: text) ..selection = TextSelection.collapsed(offset: text.length); return Row(children: [ Icon( locationStatus.value == LocationStatus.pathLocation ? Icons.folder : Icons.search, color: Theme.of(context).hintColor, ).paddingSymmetric(horizontal: 2), Expanded( child: TextField( focusNode: focusNode, decoration: InputDecoration( border: InputBorder.none, isDense: true, prefix: Padding(padding: EdgeInsets.only(left: 4.0))), controller: textController, onSubmitted: (path) { openDirectory(path, isLocal: isLocal); }, onChanged: locationStatus.value == LocationStatus.fileSearchBar ? (searchText) => onSearchText(searchText, isLocal) : null, )) ]); } onSearchText(String searchText, bool isLocal) { if (isLocal) { _localSelectedItems.clear(); _searchTextLocal.value = searchText; } else { _remoteSelectedItems.clear(); _searchTextRemote.value = searchText; } } openDirectory(String path, {bool isLocal = false}) { model.openDirectory(path, isLocal: isLocal); } void handleDragDone(DropDoneDetails details, bool isLocal) { if (isLocal) { // ignore local return; } var items = SelectedItems(); for (var file in details.files) { final f = File(file.path); items.add( true, Entry() ..path = file.path ..name = file.name ..size = FileSystemEntity.isDirectorySync(f.path) ? 0 : f.lengthSync()); } model.sendFiles(items, isRemote: false); } void refocusKeyboardListener(bool isLocal) { Future.delayed(Duration.zero, () { if (isLocal) { _keyboardNodeLocal.requestFocus(); } else { _keyboardNodeRemote.requestFocus(); } }); } }