mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-11 10:54:08 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
539ed2bba7 | ||
|
|
5bd57608d2 |
No files matched your search
@@ -27,6 +27,10 @@ type ScriptData struct {
|
|||||||
// ScriptPayload contains the task data
|
// ScriptPayload contains the task data
|
||||||
type ScriptPayload struct {
|
type ScriptPayload struct {
|
||||||
Task *Task
|
Task *Task
|
||||||
|
// TaskName is computed before Task is cloned. The clone has no fetcher
|
||||||
|
// manager, so for an unresolved task without Options.Name its Name()
|
||||||
|
// would return "unknown".
|
||||||
|
TaskName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// getScriptPaths extracts script paths from config
|
// getScriptPaths extracts script paths from config
|
||||||
@@ -103,7 +107,7 @@ func (d *Downloader) executeScriptAtPath(scriptPath string, data *ScriptData) er
|
|||||||
cmd.Env = append(os.Environ(),
|
cmd.Env = append(os.Environ(),
|
||||||
fmt.Sprintf("GOPEED_EVENT=%s", data.Event),
|
fmt.Sprintf("GOPEED_EVENT=%s", data.Event),
|
||||||
fmt.Sprintf("GOPEED_TASK_ID=%s", data.Payload.Task.ID),
|
fmt.Sprintf("GOPEED_TASK_ID=%s", data.Payload.Task.ID),
|
||||||
fmt.Sprintf("GOPEED_TASK_NAME=%s", data.Payload.Task.Name()),
|
fmt.Sprintf("GOPEED_TASK_NAME=%s", data.Payload.TaskName),
|
||||||
fmt.Sprintf("GOPEED_TASK_STATUS=%s", data.Payload.Task.Status),
|
fmt.Sprintf("GOPEED_TASK_STATUS=%s", data.Payload.Task.Status),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,7 +141,8 @@ func (d *Downloader) triggerScripts(event ScriptEvent, task *Task, err error) {
|
|||||||
Event: event,
|
Event: event,
|
||||||
Time: time.Now().UnixMilli(),
|
Time: time.Now().UnixMilli(),
|
||||||
Payload: &ScriptPayload{
|
Payload: &ScriptPayload{
|
||||||
Task: task.clone(),
|
Task: task.clone(),
|
||||||
|
TaskName: task.Name(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package download
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -126,6 +128,46 @@ func TestScript_ExecuteScriptAtPath_NonExistentFile(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScript_TaskNameOfUnresolvedTask(t *testing.T) {
|
||||||
|
scriptPath := getTestScriptPath(t, envDumpScriptName())
|
||||||
|
ensureScriptExecutable(t, scriptPath)
|
||||||
|
outputFile := filepath.Join(t.TempDir(), "env_output.txt")
|
||||||
|
|
||||||
|
setupScriptTest(t, func(downloader *Downloader) {
|
||||||
|
cfg, _ := downloader.GetConfig()
|
||||||
|
cfg.Script = &base.ScriptConfig{
|
||||||
|
Enable: true,
|
||||||
|
Paths: []string{scriptPath},
|
||||||
|
}
|
||||||
|
downloader.PutConfig(cfg)
|
||||||
|
|
||||||
|
// Without Options.Name and a resource, the name comes from the fetcher
|
||||||
|
// manager, which the cloned task in the script data does not have.
|
||||||
|
url := "http://127.0.0.1/error.bin"
|
||||||
|
fm, err := downloader.parseFm(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
task := NewTask()
|
||||||
|
task.Meta = &fetcher.FetcherMeta{
|
||||||
|
Req: &base.Request{URL: url},
|
||||||
|
Opts: &base.Options{Path: t.TempDir()},
|
||||||
|
}
|
||||||
|
task.fetcherManager = fm
|
||||||
|
if name := task.Name(); name != "error.bin" {
|
||||||
|
t.Fatalf("Expected task name error.bin, got %q", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
|
||||||
|
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||||
|
|
||||||
|
output := waitForFileContains(t, outputFile, "GOPEED_TASK_PATH=", 3*time.Second)
|
||||||
|
if !strings.Contains(output, "GOPEED_TASK_NAME=error.bin") {
|
||||||
|
t.Errorf("Expected GOPEED_TASK_NAME=error.bin in output, got: %s", output)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func createDownloadDoneTask(t *testing.T, downloadDir, fileName string) (*Task, string) {
|
func createDownloadDoneTask(t *testing.T, downloadDir, fileName string) (*Task, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
content := []byte("downloaded file")
|
content := []byte("downloaded file")
|
||||||
@@ -171,6 +213,31 @@ func waitForFile(t *testing.T, path string, timeout time.Duration) {
|
|||||||
t.Fatalf("Timeout waiting for file: %s", path)
|
t.Fatalf("Timeout waiting for file: %s", path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitForFileContains waits until the file contains want and returns its content.
|
||||||
|
// Test scripts write the file line by line, so waiting for the file to exist is not enough.
|
||||||
|
func waitForFileContains(t *testing.T, path, want string, timeout time.Duration) string {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
if content, err := os.ReadFile(path); err == nil && strings.Contains(string(content), want) {
|
||||||
|
return string(content)
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
content, _ := os.ReadFile(path)
|
||||||
|
t.Fatalf("Timeout waiting for %q in file %s, got: %q", want, path, content)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// envDumpScriptName returns the test script that writes the GOPEED_* variables
|
||||||
|
// into the file named by GOPEED_TEST_OUTPUT_FILE.
|
||||||
|
func envDumpScriptName() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "env_dump.bat"
|
||||||
|
}
|
||||||
|
return "env_dump.sh"
|
||||||
|
}
|
||||||
|
|
||||||
func getTestScriptPath(t *testing.T, name string) string {
|
func getTestScriptPath(t *testing.T, name string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join("testdata", "scripts", name)
|
path := filepath.Join("testdata", "scripts", name)
|
||||||
|
|||||||
@@ -91,13 +91,8 @@ func TestScript_EnvironmentVariables(t *testing.T) {
|
|||||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
|
t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
|
||||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||||
|
|
||||||
waitForFile(t, outputFile, 3*time.Second)
|
// GOPEED_TASK_PATH is the last line the script writes.
|
||||||
content, err := os.ReadFile(outputFile)
|
output := waitForFileContains(t, outputFile, "GOPEED_TASK_PATH=", 3*time.Second)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read output file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
output := string(content)
|
|
||||||
if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") {
|
if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") {
|
||||||
t.Errorf("Expected GOPEED_EVENT in output, got: %s", output)
|
t.Errorf("Expected GOPEED_EVENT in output, got: %s", output)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,13 +87,8 @@ func TestScript_EnvironmentVariables(t *testing.T) {
|
|||||||
t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
|
t.Setenv("GOPEED_TEST_OUTPUT_FILE", outputFile)
|
||||||
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
downloader.triggerScripts(ScriptEventDownloadDone, task, nil)
|
||||||
|
|
||||||
waitForFile(t, outputFile, 5*time.Second)
|
// GOPEED_TASK_PATH is the last line the script writes.
|
||||||
content, err := os.ReadFile(outputFile)
|
output := waitForFileContains(t, outputFile, "GOPEED_TASK_PATH=", 5*time.Second)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read output file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
output := string(content)
|
|
||||||
if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") {
|
if !strings.Contains(output, "GOPEED_EVENT=DOWNLOAD_DONE") {
|
||||||
t.Errorf("Expected GOPEED_EVENT in output, got: %s", output)
|
t.Errorf("Expected GOPEED_EVENT in output, got: %s", output)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,12 @@ import 'package:flutter/gestures.dart' show Drag;
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row;
|
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row;
|
||||||
|
|
||||||
|
import '../../core/utils/breakpoints.dart';
|
||||||
import '../theme/app_palette.dart';
|
import '../theme/app_palette.dart';
|
||||||
import '../../l10n/l10n.dart';
|
import '../../l10n/l10n.dart';
|
||||||
import '../../util/util.dart';
|
import '../../util/util.dart';
|
||||||
|
import 'file_tree_horizontal_viewport.dart';
|
||||||
|
import 'file_tree_name.dart';
|
||||||
import 'virtual_tree_view.dart';
|
import 'virtual_tree_view.dart';
|
||||||
|
|
||||||
class FileTreeItem<T> {
|
class FileTreeItem<T> {
|
||||||
@@ -110,6 +113,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
|||||||
late final Animation<double> _reveal;
|
late final Animation<double> _reveal;
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
final _horizontalScrollController = ScrollController();
|
final _horizontalScrollController = ScrollController();
|
||||||
|
final _wholeTreeScrollController = ScrollController();
|
||||||
Drag? _horizontalDrag;
|
Drag? _horizontalDrag;
|
||||||
final Map<String, double> _labelWidthCache = {};
|
final Map<String, double> _labelWidthCache = {};
|
||||||
TextStyle? _labelMeasurementStyle;
|
TextStyle? _labelMeasurementStyle;
|
||||||
@@ -163,6 +167,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
|||||||
_transitionController.dispose();
|
_transitionController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
_horizontalScrollController.dispose();
|
_horizontalScrollController.dispose();
|
||||||
|
_wholeTreeScrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,6 +201,8 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
|||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final treeWidth = constraints.hasBoundedWidth ? constraints.maxWidth : _treeFallbackWidth;
|
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(
|
final horizontalOverflow = _horizontalOverflow(
|
||||||
_nodes,
|
_nodes,
|
||||||
baseNameViewport:
|
baseNameViewport:
|
||||||
@@ -204,57 +211,70 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
|||||||
measureLabel: measureLabel,
|
measureLabel: measureLabel,
|
||||||
);
|
);
|
||||||
|
|
||||||
return OutlinedContainer(
|
final content = Column(
|
||||||
child: Column(
|
children: [
|
||||||
children: [
|
_FileTreeToolbar(
|
||||||
_FileTreeToolbar(
|
keyPrefix: widget.keyPrefix,
|
||||||
keyPrefix: widget.keyPrefix,
|
headerLeading: widget.headerLeading,
|
||||||
headerLeading: widget.headerLeading,
|
allExpanded: _allFoldersExpanded,
|
||||||
allExpanded: _allFoldersExpanded,
|
nameSortDirection: _sortColumn == _FileTreeSortColumn.name ? _sortDirection : _FileTreeSortDirection.none,
|
||||||
nameSortDirection: _sortColumn == _FileTreeSortColumn.name
|
sizeSortDirection: _sortColumn == _FileTreeSortColumn.size ? _sortDirection : _FileTreeSortDirection.none,
|
||||||
? _sortDirection
|
showSizeColumn: widget.showSizeColumn,
|
||||||
: _FileTreeSortDirection.none,
|
trailingHeader: widget.trailingHeader,
|
||||||
sizeSortDirection: _sortColumn == _FileTreeSortColumn.size
|
trailingColumnWidth: trailingColumnWidth,
|
||||||
? _sortDirection
|
onNameSort: () => _toggleSort(_FileTreeSortColumn.name),
|
||||||
: _FileTreeSortDirection.none,
|
onSizeSort: () => _toggleSort(_FileTreeSortColumn.size),
|
||||||
showSizeColumn: widget.showSizeColumn,
|
onToggleExpanded: _allFoldersExpanded ? _collapseAll : _expandAll,
|
||||||
trailingHeader: widget.trailingHeader,
|
),
|
||||||
trailingColumnWidth: trailingColumnWidth,
|
Expanded(
|
||||||
onNameSort: () => _toggleSort(_FileTreeSortColumn.name),
|
child: _nodes.isEmpty
|
||||||
onSizeSort: () => _toggleSort(_FileTreeSortColumn.size),
|
? _FileTreeEmptyState(label: widget.emptyLabel ?? context.l10n.noFiles)
|
||||||
onToggleExpanded: _allFoldersExpanded ? _collapseAll : _expandAll,
|
: VirtualTreeView<FileTreeNode<T>>(
|
||||||
),
|
nodes: _nodes,
|
||||||
Expanded(
|
controller: _scrollController,
|
||||||
child: _nodes.isEmpty
|
branchLine: BranchLine.path,
|
||||||
? _FileTreeEmptyState(label: widget.emptyLabel ?? context.l10n.noFiles)
|
rowHeight: widget.rowHeight,
|
||||||
: VirtualTreeView<FileTreeNode<T>>(
|
padding: const EdgeInsets.fromLTRB(_treeContentOffset, 4, 8, 4),
|
||||||
nodes: _nodes,
|
itemPadding: EdgeInsets.symmetric(vertical: treeItemInset * 0.5),
|
||||||
controller: _scrollController,
|
itemBuilder: (context, entry) => _buildTreeItem(
|
||||||
branchLine: BranchLine.path,
|
context,
|
||||||
rowHeight: widget.rowHeight,
|
entry,
|
||||||
padding: const EdgeInsets.fromLTRB(_treeContentOffset, 4, 8, 4),
|
nameContentWidth: nameContentWidth,
|
||||||
itemPadding: EdgeInsets.symmetric(vertical: treeItemInset * 0.5),
|
trailingColumnWidth: trailingColumnWidth,
|
||||||
itemBuilder: (context, entry) => _buildTreeItem(
|
hasTrailingColumn: hasTrailingColumn,
|
||||||
context,
|
horizontalScrollEnabled: horizontalOverflow > 0.5,
|
||||||
entry,
|
scrollWholeTree: scrollWholeTree,
|
||||||
nameContentWidth: nameContentWidth,
|
nameContentOffset:
|
||||||
trailingColumnWidth: trailingColumnWidth,
|
_treeContentOffset + (entry.depth.length - 1) * treeItemInset * 3 + fixedNameChrome,
|
||||||
hasTrailingColumn: hasTrailingColumn,
|
contentTextStyle: labelStyle,
|
||||||
horizontalScrollEnabled: horizontalOverflow > 0.5,
|
|
||||||
contentTextStyle: labelStyle,
|
|
||||||
),
|
|
||||||
transitionBuilder: _buildTreeTransition,
|
|
||||||
),
|
),
|
||||||
|
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,
|
keyPrefix: widget.keyPrefix,
|
||||||
controller: _horizontalScrollController,
|
controller: _wholeTreeScrollController,
|
||||||
overflow: horizontalOverflow,
|
contentWidth:
|
||||||
trailingColumnWidth: trailingColumnWidth,
|
_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 double trailingColumnWidth,
|
||||||
required bool hasTrailingColumn,
|
required bool hasTrailingColumn,
|
||||||
required bool horizontalScrollEnabled,
|
required bool horizontalScrollEnabled,
|
||||||
|
required bool scrollWholeTree,
|
||||||
|
required double nameContentOffset,
|
||||||
required TextStyle contentTextStyle,
|
required TextStyle contentTextStyle,
|
||||||
}) {
|
}) {
|
||||||
final palette = AppPalette.of(context);
|
final palette = AppPalette.of(context);
|
||||||
@@ -315,36 +337,22 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
key: ValueKey('${widget.keyPrefix}-name-scroll-${data.key}'),
|
key: ValueKey('${widget.keyPrefix}-name-scroll-${data.key}'),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
onHorizontalDragStart: horizontalScrollEnabled ? _startNameDrag : null,
|
onHorizontalDragStart: !scrollWholeTree && horizontalScrollEnabled ? _startNameDrag : null,
|
||||||
onHorizontalDragUpdate: horizontalScrollEnabled
|
onHorizontalDragUpdate: !scrollWholeTree && horizontalScrollEnabled
|
||||||
? (details) => _horizontalDrag?.update(details)
|
? (details) => _horizontalDrag?.update(details)
|
||||||
: null,
|
: null,
|
||||||
onHorizontalDragEnd: horizontalScrollEnabled ? (details) => _horizontalDrag?.end(details) : null,
|
onHorizontalDragEnd: !scrollWholeTree && horizontalScrollEnabled
|
||||||
onHorizontalDragCancel: horizontalScrollEnabled ? () => _horizontalDrag?.cancel() : null,
|
? (details) => _horizontalDrag?.end(details)
|
||||||
child: ClipRect(
|
: null,
|
||||||
child: AnimatedBuilder(
|
onHorizontalDragCancel: !scrollWholeTree && horizontalScrollEnabled
|
||||||
animation: _horizontalScrollController,
|
? () => _horizontalDrag?.cancel()
|
||||||
builder: (context, child) {
|
: null,
|
||||||
final offset = horizontalScrollEnabled && _horizontalScrollController.hasClients
|
child: FileTreeName(
|
||||||
? _horizontalScrollController.offset
|
name: data.label,
|
||||||
: 0.0;
|
style: contentTextStyle.copyWith(color: palette.textPrimary),
|
||||||
return Transform.translate(offset: Offset(-offset, 0), child: child);
|
controller: scrollWholeTree ? _wholeTreeScrollController : _horizontalScrollController,
|
||||||
},
|
contentWidth: nameContentWidth,
|
||||||
child: OverflowBox(
|
treeContentOffset: scrollWholeTree ? nameContentOffset : null,
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1076,6 +1084,22 @@ double _widestLabelWidth<T>(List<TreeNode<FileTreeNode<T>>> nodes, double Functi
|
|||||||
return widest;
|
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(
|
double _measureLabelWidth(
|
||||||
String label, {
|
String label, {
|
||||||
required TextStyle style,
|
required TextStyle style,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:gopeed/api/model/create_task_batch.dart';
|
import 'package:gopeed/api/model/create_task_batch.dart';
|
||||||
import 'package:gopeed/api/model/options.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/theme/app_theme.dart';
|
||||||
import 'package:gopeed/shared/widgets/app_loading_button.dart';
|
import 'package:gopeed/shared/widgets/app_loading_button.dart';
|
||||||
import 'package:gopeed/shared/widgets/app_tooltip.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;
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||||
|
|
||||||
PendingDownload pending(String id) => PendingDownload(
|
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 {
|
testWidgets('shows all roots immediately and resolves sequentially while preserving choices', (tester) async {
|
||||||
final calls = <String>[];
|
final calls = <String>[];
|
||||||
final responses = [Completer<ResolveResult>(), Completer<ResolveResult>()];
|
final responses = [Completer<ResolveResult>(), Completer<ResolveResult>()];
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/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/file_tree_view.dart';
|
||||||
import 'package:gopeed/shared/widgets/virtual_tree_view.dart';
|
import 'package:gopeed/shared/widgets/virtual_tree_view.dart';
|
||||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||||
@@ -41,7 +44,7 @@ void main() {
|
|||||||
expect(labels(), ['z.txt', 'folder', 'a.txt']);
|
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 {
|
testWidgets('long directory display labels remain readable at $width', (tester) async {
|
||||||
tester.view.physicalSize = Size(width, 800);
|
tester.view.physicalSize = Size(width, 800);
|
||||||
tester.view.devicePixelRatio = 1;
|
tester.view.devicePixelRatio = 1;
|
||||||
@@ -62,20 +65,195 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
for (final label in [name, nestedName]) {
|
for (final label in [name, nestedName]) {
|
||||||
final paragraph = tester.renderObject<RenderParagraph>(find.text(label));
|
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);
|
expect(find.byKey(const ValueKey('file-tree-horizontal-scrollbar')), findsOneWidget);
|
||||||
final root = find.byKey(const ValueKey('file-tree-name-scroll-folder:source-0'));
|
final root = find.byKey(const ValueKey('file-tree-name-scroll-folder:source-0'));
|
||||||
final before = tester.getTopLeft(find.text(name));
|
final before = tester.getTopLeft(find.text(name));
|
||||||
final trailing = tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0')));
|
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();
|
await tester.pumpAndSettle();
|
||||||
expect(tester.getTopLeft(find.text(name)).dx, lessThan(before.dx));
|
final delta = tester.getTopLeft(find.text(name)).dx - before.dx;
|
||||||
expect(tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0'))), trailing);
|
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);
|
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 {
|
testWidgets('empty directory root keeps its identity and expansion when files arrive', (tester) async {
|
||||||
late StateSetter update;
|
late StateSetter update;
|
||||||
var items = [
|
var items = [
|
||||||
@@ -159,3 +337,13 @@ void main() {
|
|||||||
expect(tester.takeException(), isNull);
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user