fix(ui): keep narrow file trees readable and scrollable

This commit is contained in:
Levi committed 2026-11-03 22:18:32 +08:00
1 parent 5bd57608d2
commit 539ed2bba7
5 files changed
+474 -81

No files matched your search

@@ -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;
}
@@ -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,
),
),
);
},
),
),
);
}
}
+100 -76
View File
@@ -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<T> {
@@ -110,6 +113,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
late final Animation<double> _reveal;
final _scrollController = ScrollController();
final _horizontalScrollController = ScrollController();
final _wholeTreeScrollController = ScrollController();
Drag? _horizontalDrag;
final Map<String, double> _labelWidthCache = {};
TextStyle? _labelMeasurementStyle;
@@ -163,6 +167,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
_transitionController.dispose();
_scrollController.dispose();
_horizontalScrollController.dispose();
_wholeTreeScrollController.dispose();
super.dispose();
}
@@ -196,6 +201,8 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> 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<T> extends State<FileTreeView<T>> 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<FileTreeNode<T>>(
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<FileTreeNode<T>>(
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<T> extends State<FileTreeView<T>> 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<T> extends State<FileTreeView<T>> 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<T>(List<TreeNode<FileTreeNode<T>>> nodes, double Functi
return widest;
}
double _widestIndentedLabelWidth<T>(
List<TreeNode<FileTreeNode<T>>> nodes,
double Function(String label) measureLabel,
double depthIndent, [
int depth = 0,
]) {
var widest = _treeMinimumNameContentWidth;
for (final node in nodes) {
final item = node as TreeItemNode<FileTreeNode<T>>;
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,
@@ -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<ResolveResult>();
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<RenderParagraph>(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<SingleChildScrollView>(
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 = <String>[];
final responses = [Completer<ResolveResult>(), Completer<ResolveResult>()];
@@ -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<RenderParagraph>(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<int>(
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<SingleChildScrollView>(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<RenderParagraph>(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<int>(
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<RenderParagraph>(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<SingleChildScrollView>(
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 = <String>[];
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<int>(
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<RenderParagraph>(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));
}
}