diff --git a/ui/flutter/lib/shared/widgets/file_tree_horizontal_viewport.dart b/ui/flutter/lib/shared/widgets/file_tree_horizontal_viewport.dart new file mode 100644 index 00000000..be3c5437 --- /dev/null +++ b/ui/flutter/lib/shared/widgets/file_tree_horizontal_viewport.dart @@ -0,0 +1,66 @@ +import 'dart:math' as math; + +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +import '../theme/app_design_tokens.dart'; + +/// Scrolls the header and virtualized tree together without freezing columns. +class FileTreeHorizontalViewport extends StatelessWidget { + const FileTreeHorizontalViewport({ + super.key, + required this.keyPrefix, + required this.controller, + required this.contentWidth, + required this.child, + }); + + final String keyPrefix; + final ScrollController controller; + final double contentWidth; + final Widget child; + + static double? widthOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_FileTreeHorizontalScope>()?.width; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final width = math.max(constraints.maxWidth, contentWidth); + final canScroll = width > constraints.maxWidth + 0.5; + return _FileTreeHorizontalScope( + width: constraints.maxWidth, + child: Scrollbar( + key: ValueKey('$keyPrefix-horizontal-scrollbar'), + controller: controller, + thumbVisibility: canScroll, + trackVisibility: canScroll, + interactive: true, + thickness: 6, + scrollbarOrientation: ScrollbarOrientation.bottom, + notificationPredicate: (notification) => notification.metrics.axis == Axis.horizontal, + child: Padding( + padding: EdgeInsets.only(bottom: canScroll ? AppDesignTokens.space12 : 0), + child: SingleChildScrollView( + key: ValueKey('$keyPrefix-horizontal-scroll'), + controller: controller, + scrollDirection: Axis.horizontal, + child: SizedBox(width: width, child: child), + ), + ), + ), + ); + }, + ); + } +} + +class _FileTreeHorizontalScope extends InheritedWidget { + const _FileTreeHorizontalScope({required this.width, required super.child}); + + final double width; + + @override + bool updateShouldNotify(_FileTreeHorizontalScope oldWidget) => width != oldWidget.width; +} diff --git a/ui/flutter/lib/shared/widgets/file_tree_name.dart b/ui/flutter/lib/shared/widgets/file_tree_name.dart new file mode 100644 index 00000000..1846eddb --- /dev/null +++ b/ui/flutter/lib/shared/widgets/file_tree_name.dart @@ -0,0 +1,69 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +import 'app_tooltip.dart'; +import 'file_tree_horizontal_viewport.dart'; + +/// Keeps the full name scrollable, with an ellipsis at its visible trailing edge. +class FileTreeName extends StatelessWidget { + const FileTreeName({ + super.key, + required this.name, + required this.style, + required this.controller, + required this.contentWidth, + this.treeContentOffset, + }); + + final String name; + final TextStyle style; + final ScrollController controller; + final double contentWidth; + + /// The name's leading position on the canvas when the whole tree scrolls. + /// Null means that only the name scrolls, with the trailing columns frozen. + final double? treeContentOffset; + + @override + Widget build(BuildContext context) { + final treeViewportWidth = FileTreeHorizontalViewport.widthOf(context); + final direction = Directionality.of(context); + return AppTooltip( + message: name, + child: LayoutBuilder( + builder: (context, constraints) => AnimatedBuilder( + animation: controller, + builder: (context, child) { + final offset = controller.hasClients ? controller.offset : 0.0; + final available = treeContentOffset == null + ? constraints.maxWidth + offset + : (treeViewportWidth ?? constraints.maxWidth) + offset - treeContentOffset!; + final width = math.max( + 0.0, + math.min(treeContentOffset == null ? contentWidth : constraints.maxWidth, available), + ); + final text = SizedBox( + width: width, + child: Text(name, style: style, maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis), + ); + if (treeContentOffset != null) { + return Align(alignment: AlignmentDirectional.centerStart, child: text); + } + return ClipRect( + child: OverflowBox( + alignment: AlignmentDirectional.centerStart, + minWidth: 0, + maxWidth: double.infinity, + child: Transform.translate( + offset: Offset(direction == TextDirection.rtl ? offset : -offset, 0), + child: text, + ), + ), + ); + }, + ), + ), + ); + } +} diff --git a/ui/flutter/lib/shared/widgets/file_tree_view.dart b/ui/flutter/lib/shared/widgets/file_tree_view.dart index 0f4bc882..298f9480 100644 --- a/ui/flutter/lib/shared/widgets/file_tree_view.dart +++ b/ui/flutter/lib/shared/widgets/file_tree_view.dart @@ -4,9 +4,12 @@ import 'package:flutter/gestures.dart' show Drag; import 'package:flutter/widgets.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row; +import '../../core/utils/breakpoints.dart'; import '../theme/app_palette.dart'; import '../../l10n/l10n.dart'; import '../../util/util.dart'; +import 'file_tree_horizontal_viewport.dart'; +import 'file_tree_name.dart'; import 'virtual_tree_view.dart'; class FileTreeItem { @@ -110,6 +113,7 @@ class _FileTreeViewState extends State> with SingleTickerProv late final Animation _reveal; final _scrollController = ScrollController(); final _horizontalScrollController = ScrollController(); + final _wholeTreeScrollController = ScrollController(); Drag? _horizontalDrag; final Map _labelWidthCache = {}; TextStyle? _labelMeasurementStyle; @@ -163,6 +167,7 @@ class _FileTreeViewState extends State> with SingleTickerProv _transitionController.dispose(); _scrollController.dispose(); _horizontalScrollController.dispose(); + _wholeTreeScrollController.dispose(); super.dispose(); } @@ -196,6 +201,8 @@ class _FileTreeViewState extends State> with SingleTickerProv return LayoutBuilder( builder: (context, constraints) { final treeWidth = constraints.hasBoundedWidth ? constraints.maxWidth : _treeFallbackWidth; + // Folder expansion must not switch between frozen and scrolling columns. + final scrollWholeTree = treeWidth < Breakpoints.mobile; final horizontalOverflow = _horizontalOverflow( _nodes, baseNameViewport: @@ -204,57 +211,70 @@ class _FileTreeViewState extends State> with SingleTickerProv measureLabel: measureLabel, ); - return OutlinedContainer( - child: Column( - children: [ - _FileTreeToolbar( - keyPrefix: widget.keyPrefix, - headerLeading: widget.headerLeading, - allExpanded: _allFoldersExpanded, - nameSortDirection: _sortColumn == _FileTreeSortColumn.name - ? _sortDirection - : _FileTreeSortDirection.none, - sizeSortDirection: _sortColumn == _FileTreeSortColumn.size - ? _sortDirection - : _FileTreeSortDirection.none, - showSizeColumn: widget.showSizeColumn, - trailingHeader: widget.trailingHeader, - trailingColumnWidth: trailingColumnWidth, - onNameSort: () => _toggleSort(_FileTreeSortColumn.name), - onSizeSort: () => _toggleSort(_FileTreeSortColumn.size), - onToggleExpanded: _allFoldersExpanded ? _collapseAll : _expandAll, - ), - Expanded( - child: _nodes.isEmpty - ? _FileTreeEmptyState(label: widget.emptyLabel ?? context.l10n.noFiles) - : VirtualTreeView>( - nodes: _nodes, - controller: _scrollController, - branchLine: BranchLine.path, - rowHeight: widget.rowHeight, - padding: const EdgeInsets.fromLTRB(_treeContentOffset, 4, 8, 4), - itemPadding: EdgeInsets.symmetric(vertical: treeItemInset * 0.5), - itemBuilder: (context, entry) => _buildTreeItem( - context, - entry, - nameContentWidth: nameContentWidth, - trailingColumnWidth: trailingColumnWidth, - hasTrailingColumn: hasTrailingColumn, - horizontalScrollEnabled: horizontalOverflow > 0.5, - contentTextStyle: labelStyle, - ), - transitionBuilder: _buildTreeTransition, + final content = Column( + children: [ + _FileTreeToolbar( + keyPrefix: widget.keyPrefix, + headerLeading: widget.headerLeading, + allExpanded: _allFoldersExpanded, + nameSortDirection: _sortColumn == _FileTreeSortColumn.name ? _sortDirection : _FileTreeSortDirection.none, + sizeSortDirection: _sortColumn == _FileTreeSortColumn.size ? _sortDirection : _FileTreeSortDirection.none, + showSizeColumn: widget.showSizeColumn, + trailingHeader: widget.trailingHeader, + trailingColumnWidth: trailingColumnWidth, + onNameSort: () => _toggleSort(_FileTreeSortColumn.name), + onSizeSort: () => _toggleSort(_FileTreeSortColumn.size), + onToggleExpanded: _allFoldersExpanded ? _collapseAll : _expandAll, + ), + Expanded( + child: _nodes.isEmpty + ? _FileTreeEmptyState(label: widget.emptyLabel ?? context.l10n.noFiles) + : VirtualTreeView>( + nodes: _nodes, + controller: _scrollController, + branchLine: BranchLine.path, + rowHeight: widget.rowHeight, + padding: const EdgeInsets.fromLTRB(_treeContentOffset, 4, 8, 4), + itemPadding: EdgeInsets.symmetric(vertical: treeItemInset * 0.5), + itemBuilder: (context, entry) => _buildTreeItem( + context, + entry, + nameContentWidth: nameContentWidth, + trailingColumnWidth: trailingColumnWidth, + hasTrailingColumn: hasTrailingColumn, + horizontalScrollEnabled: horizontalOverflow > 0.5, + scrollWholeTree: scrollWholeTree, + nameContentOffset: + _treeContentOffset + (entry.depth.length - 1) * treeItemInset * 3 + fixedNameChrome, + contentTextStyle: labelStyle, ), + transitionBuilder: _buildTreeTransition, + ), + ), + if (!scrollWholeTree && _nodes.isNotEmpty && horizontalOverflow > 0.5) + _FileNameHorizontalScrollbar( + keyPrefix: widget.keyPrefix, + controller: _horizontalScrollController, + overflow: horizontalOverflow, + trailingColumnWidth: trailingColumnWidth, ), - if (_nodes.isNotEmpty && horizontalOverflow > 0.5) - _FileNameHorizontalScrollbar( + ], + ); + + return OutlinedContainer( + child: scrollWholeTree + ? FileTreeHorizontalViewport( keyPrefix: widget.keyPrefix, - controller: _horizontalScrollController, - overflow: horizontalOverflow, - trailingColumnWidth: trailingColumnWidth, - ), - ], - ), + controller: _wholeTreeScrollController, + contentWidth: + _treeHorizontalPadding + + fixedNameChrome + + _treeTrailingGap + + trailingColumnWidth + + _widestIndentedLabelWidth(_nodes, measureLabel, treeItemInset * 3), + child: content, + ) + : content, ); }, ); @@ -267,6 +287,8 @@ class _FileTreeViewState extends State> with SingleTickerProv required double trailingColumnWidth, required bool hasTrailingColumn, required bool horizontalScrollEnabled, + required bool scrollWholeTree, + required double nameContentOffset, required TextStyle contentTextStyle, }) { final palette = AppPalette.of(context); @@ -315,36 +337,22 @@ class _FileTreeViewState extends State> with SingleTickerProv child: GestureDetector( key: ValueKey('${widget.keyPrefix}-name-scroll-${data.key}'), behavior: HitTestBehavior.opaque, - onHorizontalDragStart: horizontalScrollEnabled ? _startNameDrag : null, - onHorizontalDragUpdate: horizontalScrollEnabled + onHorizontalDragStart: !scrollWholeTree && horizontalScrollEnabled ? _startNameDrag : null, + onHorizontalDragUpdate: !scrollWholeTree && horizontalScrollEnabled ? (details) => _horizontalDrag?.update(details) : null, - onHorizontalDragEnd: horizontalScrollEnabled ? (details) => _horizontalDrag?.end(details) : null, - onHorizontalDragCancel: horizontalScrollEnabled ? () => _horizontalDrag?.cancel() : null, - child: ClipRect( - child: AnimatedBuilder( - animation: _horizontalScrollController, - builder: (context, child) { - final offset = horizontalScrollEnabled && _horizontalScrollController.hasClients - ? _horizontalScrollController.offset - : 0.0; - return Transform.translate(offset: Offset(-offset, 0), child: child); - }, - child: OverflowBox( - alignment: Alignment.centerLeft, - minWidth: 0, - maxWidth: double.infinity, - child: SizedBox( - width: nameContentWidth, - child: Text( - data.label, - maxLines: 1, - softWrap: false, - style: contentTextStyle.copyWith(color: palette.textPrimary), - ), - ), - ), - ), + onHorizontalDragEnd: !scrollWholeTree && horizontalScrollEnabled + ? (details) => _horizontalDrag?.end(details) + : null, + onHorizontalDragCancel: !scrollWholeTree && horizontalScrollEnabled + ? () => _horizontalDrag?.cancel() + : null, + child: FileTreeName( + name: data.label, + style: contentTextStyle.copyWith(color: palette.textPrimary), + controller: scrollWholeTree ? _wholeTreeScrollController : _horizontalScrollController, + contentWidth: nameContentWidth, + treeContentOffset: scrollWholeTree ? nameContentOffset : null, ), ), ), @@ -1076,6 +1084,22 @@ double _widestLabelWidth(List>> nodes, double Functi return widest; } +double _widestIndentedLabelWidth( + List>> nodes, + double Function(String label) measureLabel, + double depthIndent, [ + int depth = 0, +]) { + var widest = _treeMinimumNameContentWidth; + for (final node in nodes) { + final item = node as TreeItemNode>; + widest = math.max(widest, depth * depthIndent + measureLabel(item.data.label)); + // Include collapsed descendants so expanding folders keeps the canvas stable. + widest = math.max(widest, _widestIndentedLabelWidth(item.children, measureLabel, depthIndent, depth + 1)); + } + return widest; +} + double _measureLabelWidth( String label, { required TextStyle style, diff --git a/ui/flutter/test/features/tasks/presentation/resolve_download_dialog_test.dart b/ui/flutter/test/features/tasks/presentation/resolve_download_dialog_test.dart index d484ab83..7964d488 100644 --- a/ui/flutter/test/features/tasks/presentation/resolve_download_dialog_test.dart +++ b/ui/flutter/test/features/tasks/presentation/resolve_download_dialog_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/widgets.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:gopeed/api/model/create_task_batch.dart'; import 'package:gopeed/api/model/options.dart'; @@ -16,6 +17,7 @@ import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dar import 'package:gopeed/shared/theme/app_theme.dart'; import 'package:gopeed/shared/widgets/app_loading_button.dart'; import 'package:gopeed/shared/widgets/app_tooltip.dart'; +import 'package:gopeed/shared/widgets/file_tree_horizontal_viewport.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; PendingDownload pending(String id) => PendingDownload( @@ -76,6 +78,50 @@ void main() { }); } + testWidgets('long pending links scroll with the tree before and after resolving on narrow screens', (tester) async { + final response = Completer(); + final url = 'https://pan.quark.cn/s/${List.filled(8, 'long-resource-link').join('-')}'; + final source = PendingDownload( + id: 'long', + request: Request(url: url), + options: Options(), + ); + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.resolve, (_) => response.future); + await open(tester, [source], registry, width: 320); + expect(find.byType(FileTreeHorizontalViewport), findsOneWidget); + final paragraph = tester.renderObject(find.text(url)); + expect(paragraph.didExceedMaxLines, isTrue); + expect(paragraph.overflow, TextOverflow.ellipsis); + expect(paragraph.text.toPlainText(), url); + expect(find.byWidgetPredicate((widget) => widget is AppTooltip && widget.message == url), findsOneWidget); + final label = find.byKey(const ValueKey('resolve-tree-name-scroll-folder:long')); + final before = tester.getTopLeft(find.text(url)); + final expand = find.byKey(const ValueKey('resolve-tree-node-expand-folder:long')); + final expandBefore = tester.getTopLeft(expand); + await tester.dragFrom(tester.getTopLeft(label) + const Offset(20, 8), const Offset(-100, 0)); + // The pending indicator animates continuously, so do not wait for settling. + await tester.pump(const Duration(seconds: 1)); + final delta = tester.getTopLeft(find.text(url)).dx - before.dx; + expect(delta, lessThan(0)); + expect(tester.getTopLeft(expand).dx - expandBefore.dx, closeTo(delta, 0.01)); + final horizontal = tester.widget( + find.byKey(const ValueKey('resolve-tree-horizontal-scroll')), + ); + horizontal.controller!.jumpTo(horizontal.controller!.position.maxScrollExtent); + await tester.pump(); + expect( + paragraph.getBoxesForSelection(TextSelection(baseOffset: url.length - 4, extentOffset: url.length)), + isNotEmpty, + ); + response.complete(resolved('resolved-file.txt')); + await tester.pumpAndSettle(); + expect(find.text('resolved-file.txt'), findsOneWidget); + expect(find.byType(FileTreeHorizontalViewport), findsOneWidget); + expect(source.selected, {0}); + expect(tester.takeException(), isNull); + }); + testWidgets('shows all roots immediately and resolves sequentially while preserving choices', (tester) async { final calls = []; final responses = [Completer(), Completer()]; diff --git a/ui/flutter/test/shared/widgets/file_tree_view_test.dart b/ui/flutter/test/shared/widgets/file_tree_view_test.dart index 98473848..6f093d3d 100644 --- a/ui/flutter/test/shared/widgets/file_tree_view_test.dart +++ b/ui/flutter/test/shared/widgets/file_tree_view_test.dart @@ -1,7 +1,10 @@ import 'package:flutter/widgets.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/core/utils/breakpoints.dart'; import 'package:gopeed/shared/theme/app_theme.dart'; +import 'package:gopeed/shared/widgets/app_tooltip.dart'; +import 'package:gopeed/shared/widgets/file_tree_horizontal_viewport.dart'; import 'package:gopeed/shared/widgets/file_tree_view.dart'; import 'package:gopeed/shared/widgets/virtual_tree_view.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; @@ -41,7 +44,7 @@ void main() { expect(labels(), ['z.txt', 'folder', 'a.txt']); }); - for (final width in [390.0, 700.0, 1024.0]) { + for (final width in [320.0, 390.0, 700.0, 1024.0]) { testWidgets('long directory display labels remain readable at $width', (tester) async { tester.view.physicalSize = Size(width, 800); tester.view.devicePixelRatio = 1; @@ -62,20 +65,195 @@ void main() { await tester.pumpAndSettle(); for (final label in [name, nestedName]) { final paragraph = tester.renderObject(find.text(label)); - expect(paragraph.size.width, greaterThanOrEqualTo(paragraph.getMaxIntrinsicWidth(double.infinity))); + expect(paragraph.text.toPlainText(), label); + expect(paragraph.overflow, TextOverflow.ellipsis); + expect(find.byWidgetPredicate((widget) => widget is AppTooltip && widget.message == label), findsOneWidget); } expect(find.byKey(const ValueKey('file-tree-horizontal-scrollbar')), findsOneWidget); final root = find.byKey(const ValueKey('file-tree-name-scroll-folder:source-0')); final before = tester.getTopLeft(find.text(name)); final trailing = tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0'))); - await tester.drag(root, const Offset(-120, 0)); + final expand = find.byKey(const ValueKey('file-tree-node-expand-folder:source-0')); + final expandBefore = tester.getTopLeft(expand); + final header = find.byKey(const ValueKey('file-tree-sort-size-asc')); + final headerBefore = tester.getTopLeft(header); + await tester.dragFrom(tester.getTopLeft(root) + const Offset(40, 10), const Offset(-120, 0)); await tester.pumpAndSettle(); - expect(tester.getTopLeft(find.text(name)).dx, lessThan(before.dx)); - expect(tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0'))), trailing); + final delta = tester.getTopLeft(find.text(name)).dx - before.dx; + expect(delta, lessThan(0)); + final trailingAfter = tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0'))); + if (width < Breakpoints.mobile) { + expect(trailingAfter.left - trailing.left, closeTo(delta, 0.01)); + expect(tester.getTopLeft(expand).dx - expandBefore.dx, closeTo(delta, 0.01)); + expect(tester.getTopLeft(header).dx - headerBefore.dx, closeTo(delta, 0.01)); + } else { + expect(trailingAfter, trailing); + expect(tester.getTopLeft(expand), expandBefore); + expect(tester.getTopLeft(header), headerBefore); + } expect(tester.takeException(), isNull); }); } + testWidgets('deep trees scroll all controls and keep their canvas stable when folders toggle', (tester) async { + tester.view.physicalSize = const Size(1024, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + const name = '课程视频与配套练习_最终修订版.mp4'; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: Center( + child: SizedBox( + width: 320, + height: 500, + child: FileTreeView( + items: const [FileTreeItem(key: 'deep', path: 'a/b/c/d/e/f', name: name, size: 1024, data: 0)], + leadingBuilder: (_, _) => const SizedBox(width: 16, height: 16), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + // The list's width, rather than the desktop window's width, chooses the mode. + expect(find.byType(FileTreeHorizontalViewport), findsOneWidget); + final horizontal = tester.widget(find.byKey(const ValueKey('file-tree-horizontal-scroll'))); + final controller = horizontal.controller!; + final extent = controller.position.maxScrollExtent; + expect(extent, greaterThan(0)); + final paragraph = tester.renderObject(find.text(name)); + expect(paragraph.overflow, TextOverflow.ellipsis); + expect(paragraph.text.toPlainText(), name); + final root = find.byKey(const ValueKey('file-tree-node-expand-folder:a')); + final trailing = find.byKey(const ValueKey('file-tree-trailing-file:deep')); + final before = tester.getTopLeft(trailing); + final label = find.byKey(const ValueKey('file-tree-name-scroll-folder:a')); + await tester.dragFrom(tester.getTopLeft(label) + const Offset(20, 8), const Offset(-100, 0)); + await tester.pumpAndSettle(); + expect(tester.getTopLeft(trailing).dx, lessThan(before.dx)); + controller.jumpTo(controller.position.maxScrollExtent); + await tester.pump(); + final viewport = tester.getRect(find.byType(FileTreeHorizontalViewport)); + expect(tester.getRect(trailing).right, lessThanOrEqualTo(viewport.right)); + expectNameTailVisible(tester, name, viewport); + controller.jumpTo(0); + await tester.pump(); + await tester.tap(root); + await tester.pumpAndSettle(); + expect(find.text(name), findsNothing); + expect(find.byType(FileTreeHorizontalViewport), findsOneWidget); + expect(controller.position.maxScrollExtent, extent); + await tester.tap(root); + await tester.pumpAndSettle(); + expect(find.text(name), findsOneWidget); + expect(controller.position.maxScrollExtent, extent); + expect(tester.takeException(), isNull); + }); + + for (final width in [320.0, 1024.0]) { + testWidgets('long names show ellipsis, reveal their tail on scroll and expose full text at $width', (tester) async { + tester.view.physicalSize = Size(width, 600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final name = '${List.filled(8, '课程视频与完整配套资料').join('_')}.mp4'; + var presses = 0; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: FileTreeView( + items: [FileTreeItem(key: 'long', path: '', name: name, size: 1024, data: 0)], + onNodePressed: (_) => presses++, + ), + ), + ); + await tester.pumpAndSettle(); + final label = find.text(name); + final paragraph = tester.renderObject(label); + expect(paragraph.didExceedMaxLines, isTrue); + expect(paragraph.overflow, TextOverflow.ellipsis); + expect(paragraph.text.toPlainText(), name); + expect( + paragraph.getBoxesForSelection(TextSelection(baseOffset: name.length - 4, extentOffset: name.length)), + isEmpty, + ); + + final horizontal = tester.widget( + find.byKey(const ValueKey('file-tree-horizontal-scroll')), + ); + horizontal.controller!.jumpTo(horizontal.controller!.position.maxScrollExtent); + await tester.pumpAndSettle(); + expectNameTailVisible(tester, name, Rect.fromLTWH(0, 0, width, 600)); + horizontal.controller!.jumpTo(0); + await tester.pump(); + await tester.longPressAt(tester.getTopLeft(label) + const Offset(20, 8)); + await tester.pumpAndSettle(); + expect(find.text(name), findsNWidgets(2)); + expect(presses, 0); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('resizing changes scrolling mode without losing folder state or tap handling', (tester) async { + tester.view.physicalSize = const Size(1024, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + late StateSetter update; + var width = 390.0; + final pressed = []; + await tester.pumpWidget( + shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: StatefulBuilder( + builder: (context, setState) { + update = setState; + return Center( + child: SizedBox( + width: width, + height: 500, + child: FileTreeView( + items: const [FileTreeItem(key: 'file', path: 'bundle', name: 'file.txt', size: 10, data: 0)], + directoryLabels: const { + 'bundle': 'Long resource title that needs horizontal scrolling in both desktop and mobile views', + }, + onNodePressed: (node) => pressed.add(node.key), + ), + ), + ); + }, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(FileTreeHorizontalViewport), findsOneWidget); + await tester.tapAt(tester.getTopLeft(find.text('file.txt')) + const Offset(20, 8)); + await tester.pump(); + expect(pressed, ['file:file']); + final expand = find.byKey(const ValueKey('file-tree-node-expand-folder:bundle')); + await tester.tap(expand); + await tester.pumpAndSettle(); + update(() => width = 900); + await tester.pumpAndSettle(); + expect(find.byType(FileTreeHorizontalViewport), findsNothing); + expect(find.text('file.txt'), findsNothing); + update(() => width = 390); + await tester.pumpAndSettle(); + expect(find.byType(FileTreeHorizontalViewport), findsOneWidget); + expect(find.text('file.txt'), findsNothing); + await tester.tap(expand); + await tester.pumpAndSettle(); + await tester.tapAt(tester.getTopLeft(find.text('file.txt')) + const Offset(20, 8)); + await tester.pump(); + expect(pressed, ['file:file', 'file:file']); + expect(tester.takeException(), isNull); + }); + testWidgets('empty directory root keeps its identity and expansion when files arrive', (tester) async { late StateSetter update; var items = [ @@ -159,3 +337,13 @@ void main() { expect(tester.takeException(), isNull); }); } + +void expectNameTailVisible(WidgetTester tester, String name, Rect viewport) { + final paragraph = tester.renderObject(find.text(name)); + final boxes = paragraph.getBoxesForSelection(TextSelection(baseOffset: name.length - 4, extentOffset: name.length)); + expect(boxes, isNotEmpty); + for (final box in boxes) { + expect(paragraph.localToGlobal(Offset(box.left, 0)).dx, greaterThanOrEqualTo(viewport.left)); + expect(paragraph.localToGlobal(Offset(box.right, 0)).dx, lessThanOrEqualTo(viewport.right)); + } +}