mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-07 09:00:47 +00:00
feat(tasks): group extension folder downloads (#1594)
This commit is contained in:
1 parent
fc0a1e0ef9
commit
1e36599d52
84 files changed
+6690
-881
No files matched your search
@@ -0,0 +1,52 @@
|
||||
# Batch task creation
|
||||
|
||||
`POST /api/v1/tasks/batch` accepts ordinary and grouped batches:
|
||||
|
||||
```json
|
||||
{
|
||||
"group": true,
|
||||
"groupName": "album",
|
||||
"opts": {"path": "/downloads"},
|
||||
"reqs": [
|
||||
{"rid": "resolved-resource-id", "opts": {"selectFiles": [1]}},
|
||||
{"req": {"url": "https://example.com/cover.jpg"}}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Each member accepts `rid` or `req`. A nonempty `rid` takes priority and creates a
|
||||
task from the cached resolve result. A member's `opts`, when present, replaces
|
||||
batch `opts` as a whole; omitted fields do not inherit individual batch values.
|
||||
Members without `opts` use batch `opts`. BT file selections use the indexes from
|
||||
the resolve result. Requests without `rid` continue to work.
|
||||
|
||||
For groups, batch `opts.path` selects the parent directory. Members inheriting
|
||||
batch options, or with an empty path, use the reserved group directory. Explicit
|
||||
relative member paths are resolved under that directory; do not prefix them with
|
||||
the batch parent path. For example, parent `downloads` and member path `nested`
|
||||
place the member in `downloads/<group name>/nested`. Absolute paths under the
|
||||
parent directory preserve their relative structure within the group. In ordinary
|
||||
batches, an empty member path uses the configured default download directory.
|
||||
|
||||
All members are attempted. If at least one task is persisted, the response is
|
||||
successful and contains the created task IDs. A task that fails to start after
|
||||
being persisted is included; use the existing task controls to retry it.
|
||||
Successful tasks are kept when other members fail. If every member fails, the
|
||||
API returns an error and removes any newly reserved empty group and directory.
|
||||
|
||||
Both ordinary and grouped batches now return an object in `data`:
|
||||
|
||||
```json
|
||||
{"code": 0, "msg": "", "data": {"taskIds": ["task-1", "task-2"], "parentId": "group-id"}}
|
||||
```
|
||||
|
||||
Ordinary batches omit `parentId`. **This replaces the former array response for
|
||||
ordinary batches.** The outer `code`/`msg`/`data` envelope is unchanged. Clients
|
||||
must read `data.taskIds`; there are no per-member results. A successful response
|
||||
completes the batch, including partial success, so clients must not automatically
|
||||
resubmit missing members.
|
||||
|
||||
With `group: true`, the server generates the group ID and reserves its directory.
|
||||
Creation does not accept a client group ID or append to an existing group. Group
|
||||
metadata remains available through the existing task list and detail responses;
|
||||
`parentId` identifies membership for group operations.
|
||||
+2
-2
@@ -180,11 +180,11 @@ func (s *Service) createTaskBatch(ctx *Context) *Response {
|
||||
if len(request.Reqs) == 0 {
|
||||
return newErrorResponse("param invalid: reqs", restmodel.CodeInvalidParam)
|
||||
}
|
||||
taskIDs, err := s.Downloader.CreateDirectBatch(&request)
|
||||
result, err := s.Downloader.CreateDirectBatchWithResult(&request)
|
||||
if err != nil {
|
||||
return newErrorResponse(err.Error())
|
||||
}
|
||||
return newOKResponse(taskIDs)
|
||||
return newOKResponse(result)
|
||||
}
|
||||
|
||||
func (s *Service) patchTask(ctx *Context) *Response {
|
||||
|
||||
@@ -267,3 +267,65 @@ func TestFullConfigAndJobTriggerShareProtectedProtocolState(t *testing.T) {
|
||||
t.Fatalf("job state overwritten: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchResolvedAndDirectCreation(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Length", "4")
|
||||
_, _ = w.Write([]byte("test"))
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
|
||||
if err := d.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
cfg, err := d.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.MaxRunning = 0
|
||||
if err := d.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service, err := NewService(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, grouped := range []bool{false, true} {
|
||||
resolved := serviceTestRequest[*download.ResolveResult](t, service, http.MethodPost, "/api/v1/resolve", &restmodel.ResolveTask{Req: &base.Request{URL: upstream.URL + "/resolved"}})
|
||||
result := serviceTestRequest[map[string]json.RawMessage](t, service, http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{
|
||||
Group: grouped, GroupName: "folder", Opts: &base.Options{Path: t.TempDir()},
|
||||
Reqs: []*base.CreateTaskBatchItem{
|
||||
{Rid: "invalid"},
|
||||
{Rid: resolved.ID, Req: &base.Request{URL: "unsupported://ignored"}, Opts: &base.Options{SelectFiles: []int{0}}},
|
||||
{Req: &base.Request{URL: upstream.URL + "/direct"}},
|
||||
},
|
||||
})
|
||||
var ids []string
|
||||
if err := json.Unmarshal(result["taskIds"], &ids); err != nil || len(ids) != 2 {
|
||||
t.Fatalf("unexpected task IDs: %+v, %v", result, err)
|
||||
}
|
||||
wantFields := 1
|
||||
if grouped {
|
||||
wantFields = 2
|
||||
}
|
||||
if len(result) != wantFields {
|
||||
t.Fatalf("unexpected response fields: %+v", result)
|
||||
}
|
||||
var parentID string
|
||||
if grouped {
|
||||
if err := json.Unmarshal(result["parentId"], &parentID); err != nil || parentID == "" {
|
||||
t.Fatalf("missing parent ID: %+v", result)
|
||||
}
|
||||
}
|
||||
task := serviceTestRequest[*download.Task](t, service, http.MethodGet, "/api/v1/tasks/"+ids[0], nil)
|
||||
if task.ParentID != parentID || !reflect.DeepEqual(task.Meta.Opts.SelectFiles, []int{0}) {
|
||||
t.Fatalf("resolved task lost membership or selection: %+v", task)
|
||||
}
|
||||
status, response := service.JSON(http.MethodPost, "/api/v1/tasks/batch", nil, []byte(`{"reqs":[{"rid":"missing"},{"req":{"url":"unsupported://bad"}}]}`), nil)
|
||||
var failed restmodel.Result[any]
|
||||
if err := json.Unmarshal(response, &failed); err != nil || status != http.StatusOK || failed.Code == restmodel.CodeOk {
|
||||
t.Fatalf("all-failed batch succeeded: %d %s", status, response)
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -200,11 +200,14 @@ func ParseOptExtra[E any](opts *Options) error {
|
||||
}
|
||||
|
||||
type CreateTaskBatch struct {
|
||||
Reqs []*CreateTaskBatchItem `json:"reqs"`
|
||||
Opts *Options `json:"opts"`
|
||||
Reqs []*CreateTaskBatchItem `json:"reqs"`
|
||||
Opts *Options `json:"opts"`
|
||||
Group bool `json:"group"`
|
||||
GroupName string `json:"groupName"`
|
||||
}
|
||||
|
||||
type CreateTaskBatchItem struct {
|
||||
Rid string `json:"rid,omitempty"`
|
||||
Req *Request `json:"req"`
|
||||
Opts *Options `json:"opts"`
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ func categoryForResource(cfg *base.DownloaderStoreConfig, res *base.Resource) *b
|
||||
// runs on the result, so a category directory the white list rejects falls
|
||||
// back to the base directory of this task instead of failing it.
|
||||
func (d *Downloader) decideDownloadDir(opts *base.Options, res *base.Resource) (string, error) {
|
||||
return d.decideDownloadDirForTask(opts, res, nil)
|
||||
}
|
||||
|
||||
func (d *Downloader) decideDownloadDirForTask(opts *base.Options, res *base.Resource, group *TaskGroup) (string, error) {
|
||||
if opts == nil {
|
||||
opts = &base.Options{}
|
||||
}
|
||||
@@ -48,6 +52,9 @@ func (d *Downloader) decideDownloadDir(opts *base.Options, res *base.Resource) (
|
||||
if base == "" {
|
||||
return "", errors.New("download directory is empty")
|
||||
}
|
||||
if group != nil && opts.SkipAutoCategorize {
|
||||
return d.initDownloadPathForTask(base, group)
|
||||
}
|
||||
target := base
|
||||
if !opts.SkipAutoCategorize {
|
||||
if category := categoryForResource(cfg, res); category != nil {
|
||||
|
||||
@@ -234,6 +234,38 @@ func TestDecideDownloadDirWhiteList(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDecideGroupDownloadDirWhiteList(t *testing.T) {
|
||||
parent := t.TempDir()
|
||||
root := filepath.Join(parent, "folder")
|
||||
nested := filepath.Join(root, "nested")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
path string
|
||||
group *TaskGroup
|
||||
allowed string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "group root", path: root, group: &TaskGroup{Path: root}, allowed: filepath.Join(parent, "*")},
|
||||
{name: "nested member", path: nested, group: &TaskGroup{Path: root}, allowed: filepath.Join(parent, "*")},
|
||||
{name: "ordinary nested task", path: nested, allowed: filepath.Join(parent, "*"), wantErr: true},
|
||||
{name: "member outside group", path: filepath.Join(parent, "other"), group: &TaskGroup{Path: root}, allowed: filepath.Join(parent, "*"), wantErr: true},
|
||||
{name: "group permission revoked", path: nested, group: &TaskGroup{Path: root}, allowed: nested, wantErr: true},
|
||||
{name: "literal group name", path: filepath.Join(parent, "%year%", "nested"), group: &TaskGroup{Path: filepath.Join(parent, "%year%")}, allowed: filepath.Join(parent, "*")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
d := newUnitCategoryDownloader(newTestCategoryConfig())
|
||||
d.cfg.WhiteDownloadDirs = []string{test.allowed}
|
||||
got, err := d.decideDownloadDirForTask(&base.Options{Path: test.path, SkipAutoCategorize: true}, singleFileResource("file.txt"), test.group)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("decideDownloadDirForTask() = %q, %v; want error = %v", got, err, test.wantErr)
|
||||
}
|
||||
if err == nil && got != test.path {
|
||||
t.Fatalf("path = %q, want %q", got, test.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestedDownloadDir(t *testing.T) {
|
||||
programDir := filepath.Join("./downloads", "Program")
|
||||
|
||||
|
||||
+490
-122
@@ -37,7 +37,8 @@ const (
|
||||
uploadCheckpointInterval = 30 * time.Second
|
||||
|
||||
// task info bucket
|
||||
bucketTask = "task"
|
||||
bucketTask = "task"
|
||||
bucketTaskGroup = "task_group"
|
||||
// task download data bucket
|
||||
bucketSave = "save"
|
||||
// task statistics snapshot bucket
|
||||
@@ -113,13 +114,16 @@ type Downloader struct {
|
||||
Logger *logger.Logger
|
||||
ExtensionLogger *logger.Logger
|
||||
|
||||
cfg *DownloaderConfig
|
||||
fetcherCache map[string]fetcher.Fetcher
|
||||
storage Storage
|
||||
tasks []*Task
|
||||
waitTasks []*Task
|
||||
watchedTasks sync.Map
|
||||
listener Listener
|
||||
cfg *DownloaderConfig
|
||||
fetcherCache map[string]fetcher.Fetcher
|
||||
storage Storage
|
||||
tasks []*Task
|
||||
waitTasks []*Task
|
||||
groups map[string]*TaskGroup
|
||||
creatingGroups map[string]struct{}
|
||||
deletingTasks map[string]*Task
|
||||
watchedTasks sync.Map
|
||||
listener Listener
|
||||
|
||||
lock *sync.Mutex
|
||||
configLock *sync.RWMutex
|
||||
@@ -150,6 +154,9 @@ func NewDownloader(cfg *DownloaderConfig) *Downloader {
|
||||
cfg: cfg,
|
||||
fetcherCache: make(map[string]fetcher.Fetcher),
|
||||
waitTasks: make([]*Task, 0),
|
||||
groups: make(map[string]*TaskGroup),
|
||||
creatingGroups: make(map[string]struct{}),
|
||||
deletingTasks: make(map[string]*Task),
|
||||
storage: cfg.Storage,
|
||||
|
||||
lock: &sync.Mutex{},
|
||||
@@ -176,7 +183,7 @@ func (d *Downloader) Setup() error {
|
||||
d.blob = internalblob.NewRegistry("")
|
||||
|
||||
// setup storage
|
||||
if err := d.storage.Setup([]string{bucketTask, bucketSave, bucketTaskStats, bucketProtocolState, bucketProtocolJobs, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
|
||||
if err := d.storage.Setup([]string{bucketTask, bucketTaskGroup, bucketSave, bucketTaskStats, bucketProtocolState, bucketProtocolJobs, bucketConfig, bucketExtension, bucketExtensionStorage}); err != nil {
|
||||
return err
|
||||
}
|
||||
// load config from storage
|
||||
@@ -220,6 +227,15 @@ func (d *Downloader) Setup() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Group metadata is independent of task/fetcher recovery records.
|
||||
var groups []*TaskGroup
|
||||
if err := d.storage.List(bucketTaskGroup, &groups); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, group := range groups {
|
||||
d.groups[group.ID] = group
|
||||
}
|
||||
|
||||
// load tasks from storage
|
||||
var tasks []*Task
|
||||
if err = d.storage.List(bucketTask, &tasks); err != nil {
|
||||
@@ -235,6 +251,7 @@ func (d *Downloader) Setup() error {
|
||||
tasks = append(tasks[:i], tasks[i+1:]...)
|
||||
continue
|
||||
}
|
||||
task.group = d.groups[task.ParentID]
|
||||
d.assignFetcherManager(task)
|
||||
initTask(task)
|
||||
if task.Status != base.DownloadStatusDone && task.Status != base.DownloadStatusError {
|
||||
@@ -243,6 +260,11 @@ func (d *Downloader) Setup() error {
|
||||
}
|
||||
}
|
||||
d.tasks = tasks
|
||||
groupIDs := make([]string, 0, len(d.groups))
|
||||
for id := range d.groups {
|
||||
groupIDs = append(groupIDs, id)
|
||||
}
|
||||
d.cleanupEmptyGroups(groupIDs)
|
||||
// sort by create time
|
||||
sort.Slice(d.tasks, func(i, j int) bool {
|
||||
return d.tasks[i].CreatedAt.Before(d.tasks[j].CreatedAt)
|
||||
@@ -607,14 +629,14 @@ func (d *Downloader) CreateDirect(req *base.Request, opts *base.Options) (taskId
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
taskId, err = d.createDirect(req, opts)
|
||||
taskId, err = d.createDirect(req, opts, "")
|
||||
if err == nil {
|
||||
d.persistDefaultPathBestEffort(defaultPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (d *Downloader) createDirect(req *base.Request, opts *base.Options) (taskId string, err error) {
|
||||
func (d *Downloader) createDirect(req *base.Request, opts *base.Options, groupID string) (taskId string, err error) {
|
||||
ensureRequestRawURL(req)
|
||||
var fetcher fetcher.Fetcher
|
||||
fetcher, err = d.buildFetcher(req.URL)
|
||||
@@ -626,31 +648,203 @@ func (d *Downloader) createDirect(req *base.Request, opts *base.Options) (taskId
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return d.doCreate(fetcher, initOpt)
|
||||
return d.doCreate(fetcher, initOpt, groupID)
|
||||
}
|
||||
|
||||
func (d *Downloader) CreateDirectBatch(req *base.CreateTaskBatch) (taskId []string, err error) {
|
||||
// CreateDirectBatch retains the original Go API for callers that only need
|
||||
// the task IDs. APIs that expose group metadata use CreateDirectBatchWithResult.
|
||||
func (d *Downloader) CreateDirectBatch(req *base.CreateTaskBatch) ([]string, error) {
|
||||
result, err := d.CreateDirectBatchWithResult(req)
|
||||
if err != nil || result == nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.TaskIDs, nil
|
||||
}
|
||||
|
||||
func (d *Downloader) CreateDirectBatchWithResult(req *base.CreateTaskBatch) (*CreateTaskBatchResult, error) {
|
||||
if req == nil || len(req.Reqs) == 0 {
|
||||
return nil, errors.New("batch requires at least one task")
|
||||
}
|
||||
defaultPath, err := d.prepareDefaultPath(req.Opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskIds := make([]string, 0)
|
||||
for _, ir := range req.Reqs {
|
||||
opts := ir.Opts
|
||||
if opts == nil {
|
||||
opts = req.Opts
|
||||
result := &CreateTaskBatchResult{TaskIDs: make([]string, 0)}
|
||||
var group *TaskGroup
|
||||
if req.Group {
|
||||
parentPath := ""
|
||||
if req.Opts != nil {
|
||||
parentPath = req.Opts.Path
|
||||
}
|
||||
if opts != nil {
|
||||
opts = opts.Clone()
|
||||
}
|
||||
taskId, err := d.createDirect(ir.Req, opts)
|
||||
group, err = d.createTaskGroup(req.GroupName, parentPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
taskIds = append(taskIds, taskId)
|
||||
result.ParentID = group.ID
|
||||
defer func() {
|
||||
d.lock.Lock()
|
||||
delete(d.creatingGroups, group.ID)
|
||||
d.lock.Unlock()
|
||||
d.cleanupEmptyGroups([]string{group.ID})
|
||||
}()
|
||||
}
|
||||
var failures []error
|
||||
for _, item := range req.Reqs {
|
||||
var taskID string
|
||||
var opts *base.Options
|
||||
if item != nil {
|
||||
opts = item.Opts
|
||||
}
|
||||
if opts == nil {
|
||||
opts = req.Opts
|
||||
}
|
||||
if opts == nil {
|
||||
opts = &base.Options{}
|
||||
} else {
|
||||
opts = opts.Clone()
|
||||
}
|
||||
var itemErr error
|
||||
groupID := ""
|
||||
if group != nil {
|
||||
groupID = group.ID
|
||||
parentPath := filepath.Dir(group.Path)
|
||||
relative := "."
|
||||
if item != nil && item.Opts != nil && opts.Path != "" {
|
||||
requestedPath := util.ReplacePathPlaceholders(opts.Path)
|
||||
if !filepath.IsAbs(requestedPath) {
|
||||
requestedPath = filepath.Join(parentPath, requestedPath)
|
||||
}
|
||||
requested, pathErr := filepath.Abs(requestedPath)
|
||||
if pathErr != nil {
|
||||
itemErr = pathErr
|
||||
} else {
|
||||
relative, itemErr = filepath.Rel(parentPath, requested)
|
||||
if itemErr == nil && !filepath.IsLocal(relative) {
|
||||
itemErr = errors.New("task path is outside the group directory")
|
||||
}
|
||||
}
|
||||
}
|
||||
opts.Path = filepath.Join(group.Path, relative)
|
||||
opts.SkipAutoCategorize = true
|
||||
}
|
||||
if itemErr == nil {
|
||||
switch {
|
||||
case item == nil:
|
||||
itemErr = errors.New("task request or rid is required")
|
||||
case item.Rid != "":
|
||||
taskID, itemErr = d.createResolved(item.Rid, opts, groupID)
|
||||
case item.Req != nil:
|
||||
taskID, itemErr = d.createDirect(item.Req, opts, groupID)
|
||||
default:
|
||||
itemErr = errors.New("task request or rid is required")
|
||||
}
|
||||
}
|
||||
if taskID != "" {
|
||||
result.TaskIDs = append(result.TaskIDs, taskID)
|
||||
} else if itemErr != nil {
|
||||
failures = append(failures, itemErr)
|
||||
}
|
||||
}
|
||||
if len(result.TaskIDs) == 0 {
|
||||
return nil, fmt.Errorf("all batch tasks failed: %w", errors.Join(failures...))
|
||||
}
|
||||
d.persistDefaultPathBestEffort(defaultPath)
|
||||
return taskIds, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
const maxTaskGroupDirSuffix = 1000
|
||||
|
||||
func (d *Downloader) createTaskGroup(name, parentPath string) (*TaskGroup, error) {
|
||||
parentOpts, err := d.initOptions(&base.Options{Path: parentPath}, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parentPath = util.ReplacePathPlaceholders(strings.TrimSpace(parentOpts.Path))
|
||||
if parentPath == "" {
|
||||
return nil, errors.New("download directory is empty")
|
||||
}
|
||||
parentPath, err = filepath.Abs(parentPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name = strings.TrimSpace(name)
|
||||
if strings.ContainsAny(name, "/\\\x00") || filepath.IsAbs(name) {
|
||||
return nil, errors.New("invalid task group name")
|
||||
}
|
||||
if strings.ContainsAny(name, `:*?"<>|`) || strings.HasSuffix(name, ".") {
|
||||
return nil, errors.New("invalid task group name")
|
||||
}
|
||||
name = util.SafeFilename(name)
|
||||
if name == "" || name == "." || name == ".." {
|
||||
return nil, errors.New("invalid task group name")
|
||||
}
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
requested := filepath.Join(parentPath, name)
|
||||
var dir string
|
||||
for suffix := 0; suffix <= maxTaskGroupDirSuffix; suffix++ {
|
||||
candidate := requested
|
||||
if suffix > 0 {
|
||||
candidate = fmt.Sprintf("%s (%d)", requested, suffix)
|
||||
}
|
||||
if !d.isDownloadPathAllowed(candidate) {
|
||||
return nil, errors.New("task group directory is outside the white list")
|
||||
}
|
||||
reserved := false
|
||||
for _, existing := range d.groups {
|
||||
if existing.Path == candidate {
|
||||
reserved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if reserved {
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(parentPath, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Mkdir(candidate, 0755); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
dir = candidate
|
||||
break
|
||||
}
|
||||
if dir == "" {
|
||||
return nil, errors.New("too many duplicate task group directories")
|
||||
}
|
||||
group := &TaskGroup{ID: NewTask().ID, Name: filepath.Base(dir), Path: dir, CreatedAt: time.Now()}
|
||||
if err := d.storage.Put(bucketTaskGroup, group.ID, group); err != nil {
|
||||
_ = os.Remove(dir)
|
||||
return nil, err
|
||||
}
|
||||
d.groups[group.ID] = group
|
||||
d.creatingGroups[group.ID] = struct{}{}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
// Group IDs expand through parentId, even when metadata is unavailable.
|
||||
// The caller holds d.lock.
|
||||
func (d *Downloader) expandGroupTaskIDsLocked(ids []string) []string {
|
||||
selected := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
selected[id] = true
|
||||
}
|
||||
expanded := make([]string, 0, len(ids))
|
||||
for _, task := range d.tasks {
|
||||
if selected[task.ID] || (task.ParentID != "" && selected[task.ParentID]) {
|
||||
expanded = append(expanded, task.ID)
|
||||
}
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
func (d *Downloader) expandGroupTaskIDs(ids []string) []string {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
return d.expandGroupTaskIDsLocked(ids)
|
||||
}
|
||||
|
||||
func (d *Downloader) Create(rrId string) (taskId string, err error) {
|
||||
@@ -659,22 +853,38 @@ func (d *Downloader) Create(rrId string) (taskId string, err error) {
|
||||
|
||||
// CreateWithOptions creates a resolved task using the submitted options, or the
|
||||
// default download options when opts is nil.
|
||||
func (d *Downloader) CreateWithOptions(rrId string, opts *base.Options) (taskId string, err error) {
|
||||
d.fetcherMapLock.RLock()
|
||||
fetcher, ok := d.fetcherCache[rrId]
|
||||
d.fetcherMapLock.RUnlock()
|
||||
if !ok {
|
||||
return "", errors.New("invalid resource id")
|
||||
}
|
||||
if opts == nil {
|
||||
opts = &base.Options{}
|
||||
}
|
||||
// The options of the caller decide the global default download directory,
|
||||
// the task options are prepared later, when the task starts.
|
||||
func (d *Downloader) CreateWithOptions(rrId string, opts *base.Options) (string, error) {
|
||||
defaultPath, err := d.prepareDefaultPath(opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
taskID, err := d.createResolved(rrId, opts, "")
|
||||
if taskID != "" {
|
||||
d.persistDefaultPathBestEffort(defaultPath)
|
||||
}
|
||||
return taskID, err
|
||||
}
|
||||
|
||||
func (d *Downloader) createResolved(rrId string, opts *base.Options, groupID string) (taskId string, err error) {
|
||||
d.fetcherMapLock.Lock()
|
||||
fetcher, ok := d.fetcherCache[rrId]
|
||||
delete(d.fetcherCache, rrId)
|
||||
d.fetcherMapLock.Unlock()
|
||||
if !ok {
|
||||
return "", errors.New("invalid resource id")
|
||||
}
|
||||
// Only one creator may use a resolved fetcher. Failed creation makes it
|
||||
// available again; a persisted task consumes it even if starting fails.
|
||||
defer func() {
|
||||
if taskId == "" {
|
||||
d.fetcherMapLock.Lock()
|
||||
d.fetcherCache[rrId] = fetcher
|
||||
d.fetcherMapLock.Unlock()
|
||||
}
|
||||
}()
|
||||
if opts == nil {
|
||||
opts = &base.Options{}
|
||||
}
|
||||
taskOpts, err := d.initOptions(opts.Clone(), fetcher.Meta().Req.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -698,15 +908,7 @@ func (d *Downloader) CreateWithOptions(rrId string, opts *base.Options) (taskId
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
d.fetcherMapLock.Lock()
|
||||
delete(d.fetcherCache, rrId)
|
||||
d.fetcherMapLock.Unlock()
|
||||
}()
|
||||
taskId, err = d.doCreate(fetcher, nil)
|
||||
if err == nil {
|
||||
d.persistDefaultPathBestEffort(defaultPath)
|
||||
}
|
||||
taskId, err = d.doCreate(fetcher, nil, groupID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -762,11 +964,30 @@ func (d *Downloader) Pause(filter *TaskFilter) (err error) {
|
||||
return d.pauseAll()
|
||||
}
|
||||
|
||||
if len(filter.IDs) > 0 {
|
||||
filter.IDs = d.expandGroupTaskIDs(filter.IDs)
|
||||
if len(filter.IDs) == 0 {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
}
|
||||
filter.NotStatuses = []base.Status{base.DownloadStatusPause, base.DownloadStatusError, base.DownloadStatusDone}
|
||||
pauseTasks := d.GetTasksByFilter(filter)
|
||||
if len(pauseTasks) == 0 {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
d.lock.Lock()
|
||||
pausedIDs := make(map[string]struct{}, len(pauseTasks))
|
||||
for _, task := range pauseTasks {
|
||||
pausedIDs[task.ID] = struct{}{}
|
||||
}
|
||||
queued := d.waitTasks[:0]
|
||||
for _, task := range d.waitTasks {
|
||||
if _, paused := pausedIDs[task.ID]; !paused {
|
||||
queued = append(queued, task)
|
||||
}
|
||||
}
|
||||
d.waitTasks = queued
|
||||
d.lock.Unlock()
|
||||
|
||||
for _, task := range pauseTasks {
|
||||
if err = d.doPause(task); err != nil {
|
||||
@@ -802,6 +1023,12 @@ func (d *Downloader) Continue(filter *TaskFilter) (err error) {
|
||||
return d.continueAll()
|
||||
}
|
||||
|
||||
if len(filter.IDs) > 0 {
|
||||
filter.IDs = d.expandGroupTaskIDs(filter.IDs)
|
||||
if len(filter.IDs) == 0 {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
}
|
||||
filter.NotStatuses = []base.Status{base.DownloadStatusRunning, base.DownloadStatusDone}
|
||||
continueTasks := d.GetTasksByFilter(filter)
|
||||
if len(continueTasks) == 0 {
|
||||
@@ -901,6 +1128,12 @@ func (d *Downloader) ContinueBatch(filter *TaskFilter) (err error) {
|
||||
return d.continueAll()
|
||||
}
|
||||
|
||||
if len(filter.IDs) > 0 {
|
||||
filter.IDs = d.expandGroupTaskIDs(filter.IDs)
|
||||
if len(filter.IDs) == 0 {
|
||||
return ErrTaskNotFound
|
||||
}
|
||||
}
|
||||
continueTasks := d.GetTasksByFilter(filter)
|
||||
for _, task := range continueTasks {
|
||||
if err = d.doStart(task); err != nil {
|
||||
@@ -920,72 +1153,175 @@ func (d *Downloader) ContinueOnStartup() error {
|
||||
return d.Continue(nil)
|
||||
}
|
||||
|
||||
func (d *Downloader) Delete(filter *TaskFilter, force bool) (err error) {
|
||||
func (d *Downloader) Delete(filter *TaskFilter, force bool) error {
|
||||
d.lock.Lock()
|
||||
groups := make(map[string]*TaskGroup)
|
||||
if filter == nil || filter.IsEmpty() {
|
||||
return d.deleteAll(force)
|
||||
}
|
||||
|
||||
deleteTasks := d.GetTasksByFilter(filter)
|
||||
if len(deleteTasks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
deleteIds := make([]string, 0)
|
||||
deleteTasksPtr := make([]*Task, 0)
|
||||
for _, task := range deleteTasks {
|
||||
deleteIds = append(deleteIds, task.ID)
|
||||
deleteTasksPtr = append(deleteTasksPtr, task)
|
||||
}
|
||||
func() {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
for _, id := range deleteIds {
|
||||
for i, t := range d.tasks {
|
||||
if t.ID == id {
|
||||
d.tasks = append(d.tasks[:i], d.tasks[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
for i, t := range d.waitTasks {
|
||||
if t.ID == id {
|
||||
d.waitTasks = append(d.waitTasks[:i], d.waitTasks[i+1:]...)
|
||||
break
|
||||
}
|
||||
for id, group := range d.groups {
|
||||
groups[id] = group
|
||||
}
|
||||
} else if len(filter.IDs) > 0 {
|
||||
copyFilter := *filter
|
||||
copyFilter.IDs = d.expandGroupTaskIDsLocked(filter.IDs)
|
||||
for _, id := range filter.IDs {
|
||||
if group := d.groups[id]; group != nil {
|
||||
groups[id] = group
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for _, task := range deleteTasksPtr {
|
||||
err = d.doDelete(task, force)
|
||||
if err != nil {
|
||||
return
|
||||
if len(copyFilter.IDs) == 0 {
|
||||
if len(groups) == 0 {
|
||||
d.lock.Unlock()
|
||||
return nil
|
||||
}
|
||||
// An empty ID filter means all tasks; retain a nonmatching sentinel.
|
||||
copyFilter.IDs = []string{""}
|
||||
}
|
||||
filter = ©Filter
|
||||
}
|
||||
for id := range groups {
|
||||
if _, creating := d.creatingGroups[id]; creating {
|
||||
d.lock.Unlock()
|
||||
return errors.New("task group is being created")
|
||||
}
|
||||
}
|
||||
|
||||
tasks := append([]*Task(nil), d.getTasksByFilter(filter)...)
|
||||
deleted := make(map[string]bool, len(tasks))
|
||||
groupIDs := make([]string, 0, len(groups))
|
||||
for id := range groups {
|
||||
groupIDs = append(groupIDs, id)
|
||||
}
|
||||
for _, task := range tasks {
|
||||
d.deletingTasks[task.ID] = task
|
||||
deleted[task.ID] = true
|
||||
if task.ParentID != "" {
|
||||
groupIDs = append(groupIDs, task.ParentID)
|
||||
}
|
||||
}
|
||||
kept := d.tasks[:0]
|
||||
for _, task := range d.tasks {
|
||||
if !deleted[task.ID] {
|
||||
kept = append(kept, task)
|
||||
}
|
||||
}
|
||||
d.tasks = kept
|
||||
wasQueued := make(map[string]bool)
|
||||
queued := d.waitTasks[:0]
|
||||
for _, task := range d.waitTasks {
|
||||
if deleted[task.ID] {
|
||||
wasQueued[task.ID] = true
|
||||
}
|
||||
if !deleted[task.ID] {
|
||||
queued = append(queued, task)
|
||||
}
|
||||
}
|
||||
d.waitTasks = queued
|
||||
d.lock.Unlock()
|
||||
var firstErr error
|
||||
for _, task := range tasks {
|
||||
if err := d.doDelete(task, force); err != nil {
|
||||
// Deletion can fail after removing bucketTask. Keep recovery durable.
|
||||
if saveErr := d.storage.Put(bucketTask, task.ID, task.clone()); saveErr != nil {
|
||||
d.Logger.Error().Err(saveErr).Msg("restore failed task deletion failed")
|
||||
if firstErr == nil {
|
||||
firstErr = saveErr
|
||||
}
|
||||
}
|
||||
d.lock.Lock()
|
||||
d.tasks = append(d.tasks, task)
|
||||
if wasQueued[task.ID] {
|
||||
d.waitTasks = append(d.waitTasks, task)
|
||||
}
|
||||
d.lock.Unlock()
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
d.lock.Lock()
|
||||
for _, task := range tasks {
|
||||
delete(d.deletingTasks, task.ID)
|
||||
}
|
||||
d.lock.Unlock()
|
||||
if !force {
|
||||
groups = nil
|
||||
}
|
||||
if err := d.cleanupTaskGroups(groupIDs, groups); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
d.notifyRunning()
|
||||
return
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (d *Downloader) deleteAll(force bool) (err error) {
|
||||
var deleteTasksTemp []*Task
|
||||
func() {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
// Active batches keep their metadata until the final creation attempt finishes.
|
||||
func (d *Downloader) cleanupEmptyGroups(groupIDs []string) {
|
||||
if err := d.cleanupTaskGroups(groupIDs, nil); err != nil {
|
||||
d.Logger.Error().Err(err).Msg("cleanup empty task group failed")
|
||||
}
|
||||
}
|
||||
|
||||
for _, task := range d.tasks {
|
||||
deleteTasksTemp = append(deleteTasksTemp, task)
|
||||
func (d *Downloader) cleanupTaskGroups(groupIDs []string, forceGroups map[string]*TaskGroup) error {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
candidates := make(map[string]bool, len(groupIDs))
|
||||
for _, id := range groupIDs {
|
||||
candidates[id] = true
|
||||
}
|
||||
for _, task := range d.tasks {
|
||||
delete(candidates, task.ParentID)
|
||||
}
|
||||
for _, task := range d.deletingTasks {
|
||||
delete(candidates, task.ParentID)
|
||||
}
|
||||
var firstErr error
|
||||
for id := range candidates {
|
||||
group := d.groups[id]
|
||||
if group == nil {
|
||||
continue
|
||||
}
|
||||
d.tasks = make([]*Task, 0)
|
||||
d.waitTasks = make([]*Task, 0)
|
||||
}()
|
||||
if _, creating := d.creatingGroups[id]; creating {
|
||||
continue
|
||||
}
|
||||
// Reserve the path until its final filesystem operation finishes.
|
||||
if forceGroups[id] != nil {
|
||||
if err := os.RemoveAll(group.Path); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
removeEmptyGroupDirectories(group.Path)
|
||||
}
|
||||
if err := d.storage.Delete(bucketTaskGroup, id); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
delete(d.groups, id)
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
for _, task := range deleteTasksTemp {
|
||||
if err = d.doDelete(task, force); err != nil {
|
||||
return
|
||||
// Remove empty directory trees without touching retained files or symlinks.
|
||||
func removeEmptyGroupDirectories(root string) {
|
||||
if root == "" {
|
||||
return
|
||||
}
|
||||
info, err := os.Lstat(root)
|
||||
if err != nil || !info.IsDir() {
|
||||
return
|
||||
}
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
removeEmptyGroupDirectories(filepath.Join(root, entry.Name()))
|
||||
}
|
||||
}
|
||||
return
|
||||
_ = os.Remove(root)
|
||||
}
|
||||
|
||||
func (d *Downloader) Stats(id string) (sr any, err error) {
|
||||
@@ -1334,6 +1670,11 @@ func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
return d.getTasksByFilter(filter)
|
||||
}
|
||||
|
||||
// The caller holds d.lock.
|
||||
func (d *Downloader) getTasksByFilter(filter *TaskFilter) []*Task {
|
||||
if filter == nil || filter.IsEmpty() {
|
||||
return d.tasks
|
||||
}
|
||||
@@ -1819,7 +2160,7 @@ func (d *Downloader) restoreFetcher(task *Task) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId string, err error) {
|
||||
func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options, groupID string) (taskId string, err error) {
|
||||
if f.Meta().Opts == nil {
|
||||
f.Meta().Opts = opts
|
||||
}
|
||||
@@ -1830,6 +2171,7 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
|
||||
return
|
||||
}
|
||||
task := NewTask()
|
||||
task.ParentID = groupID
|
||||
task.fetcherManager = fm
|
||||
task.fetcher = f
|
||||
task.Protocol = fm.Name()
|
||||
@@ -1848,15 +2190,21 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str
|
||||
d.releaseBlobTask(task)
|
||||
}
|
||||
}()
|
||||
if err = d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
|
||||
return
|
||||
}
|
||||
taskId = task.ID
|
||||
|
||||
func() {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
if groupID != "" {
|
||||
task.group = d.groups[groupID]
|
||||
if task.group == nil {
|
||||
err = ErrTaskNotFound
|
||||
return
|
||||
}
|
||||
}
|
||||
if err = d.storage.Put(bucketTask, task.ID, task.clone()); err != nil {
|
||||
return
|
||||
}
|
||||
taskId = task.ID
|
||||
d.tasks = append(d.tasks, task)
|
||||
|
||||
remainRunningCount := d.remainRunningCount()
|
||||
@@ -1913,23 +2261,42 @@ func (d *Downloader) initOptions(opts *base.Options, requestURL string) (*base.O
|
||||
func (d *Downloader) initDownloadPath(path string) (string, error) {
|
||||
// Replace placeholders in download path (e.g., %year%, %month%, %day%, %date%)
|
||||
path = util.ReplacePathPlaceholders(path)
|
||||
|
||||
// if enable white download directory, check if the download directory is in the white list
|
||||
if len(d.cfg.WhiteDownloadDirs) > 0 {
|
||||
inWhiteList := false
|
||||
for _, dir := range d.cfg.WhiteDownloadDirs {
|
||||
if match, err := filepath.Match(dir, path); match && err == nil {
|
||||
inWhiteList = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inWhiteList {
|
||||
return "", errors.New("download directory is not in white list")
|
||||
}
|
||||
if !d.isDownloadPathAllowed(path) {
|
||||
return "", errors.New("download directory is not in white list")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (d *Downloader) initDownloadPathForTask(path string, group *TaskGroup) (string, error) {
|
||||
if group == nil {
|
||||
return d.initDownloadPath(path)
|
||||
}
|
||||
// Group paths are finalized at creation; validate the reserved root and
|
||||
// containment without expanding placeholders again.
|
||||
relative, err := filepath.Rel(filepath.Clean(group.Path), filepath.Clean(path))
|
||||
if err != nil || !filepath.IsLocal(relative) {
|
||||
return "", errors.New("task path is outside the group directory")
|
||||
}
|
||||
if !d.isDownloadPathAllowed(group.Path) {
|
||||
return "", errors.New("task group directory is outside the white list")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// isDownloadPathAllowed reports whether the white list accepts this exact
|
||||
// path. With no white list configured every path is allowed.
|
||||
func (d *Downloader) isDownloadPathAllowed(path string) bool {
|
||||
if len(d.cfg.WhiteDownloadDirs) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, dir := range d.cfg.WhiteDownloadDirs {
|
||||
if match, err := filepath.Match(dir, path); match && err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error) {
|
||||
task.statusLock.Lock()
|
||||
defer task.statusLock.Unlock()
|
||||
@@ -1950,7 +2317,7 @@ func (d *Downloader) prepareTask(task *Task) error {
|
||||
preparedOpts := *task.Meta.Opts
|
||||
preparedMeta.Opts = &preparedOpts
|
||||
preparedMeta.Res = util.DeepClone(task.Meta.Res)
|
||||
dir, err := d.decideDownloadDir(preparedMeta.Opts, preparedMeta.Res)
|
||||
dir, err := d.decideDownloadDirForTask(preparedMeta.Opts, preparedMeta.Res, task.group)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2074,19 +2441,20 @@ func (d *Downloader) doStart(task *Task) (err error) {
|
||||
if err := d.prepareTask(task); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err := d.initDownloadPath(task.Meta.Opts.Path); err != nil {
|
||||
} else if _, err := d.initDownloadPathForTask(task.Meta.Opts.Path, task.group); err != nil {
|
||||
// A prepared task keeps its directory, but the directory still has
|
||||
// to pass the white list on every start.
|
||||
return err
|
||||
}
|
||||
|
||||
task.statusLock.Lock()
|
||||
task.Progress.Speed = 0
|
||||
task.statusLock.Unlock()
|
||||
if !d.taskIsRunningGeneration(task, generation) {
|
||||
if task.Status != base.DownloadStatusRunning || task.runGeneration != generation {
|
||||
task.statusLock.Unlock()
|
||||
return nil
|
||||
}
|
||||
task.Progress.Speed = 0
|
||||
task.timer.Start()
|
||||
task.statusLock.Unlock()
|
||||
if err := task.fetcher.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+1099
-1
File diff suppressed because it is too large.
Load diff
@@ -70,10 +70,11 @@ func TestExtensionResolveSizeThroughTaskCreation(t *testing.T) {
|
||||
if err := json.Unmarshal(wire, &submitted); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids, err := d.CreateDirectBatch(&submitted)
|
||||
batchResult, err := d.CreateDirectBatch(&submitted)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := batchResult
|
||||
for i, id := range ids {
|
||||
task := d.GetTask(id)
|
||||
if task.Meta.Res != nil {
|
||||
|
||||
@@ -2044,8 +2044,13 @@ func waitForTaskStatus(t *testing.T, downloader *Downloader, id string, status b
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
task := downloader.GetTask(id)
|
||||
if task != nil && task.Status == status {
|
||||
return
|
||||
if task != nil && downloader.taskStatus(task) == status {
|
||||
if status != base.DownloadStatusDone {
|
||||
return
|
||||
}
|
||||
if _, watching := downloader.watchedTasks.Load(id); !watching {
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
@@ -2053,9 +2058,6 @@ func waitForTaskStatus(t *testing.T, downloader *Downloader, id string, status b
|
||||
if task == nil {
|
||||
t.Fatalf("timeout waiting for task %s status %s: task not found", id, status)
|
||||
}
|
||||
total := int64(0)
|
||||
if task.Meta != nil && task.Meta.Res != nil {
|
||||
total = task.Meta.Res.Size
|
||||
}
|
||||
t.Fatalf("timeout waiting for task %s status %s: got %s downloaded=%d total=%d", id, status, task.Status, task.Progress.Downloaded, total)
|
||||
snapshot, err := downloader.RuntimeStatus(id)
|
||||
t.Fatalf("timeout waiting for task %s status %s: got %+v, error=%v", id, status, snapshot, err)
|
||||
}
|
||||
+29
-9
@@ -29,18 +29,34 @@ type ResolveResult struct {
|
||||
SuggestedDownloadDir string `json:"suggestedDownloadDir,omitempty"`
|
||||
}
|
||||
|
||||
// TaskGroup stores the directory shared by independently scheduled tasks.
|
||||
type TaskGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// CreateTaskBatchResult is shared by ordinary and grouped batches.
|
||||
type CreateTaskBatchResult struct {
|
||||
TaskIDs []string `json:"taskIds"`
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
}
|
||||
|
||||
// Task represents a download unit.
|
||||
// Options flow path:
|
||||
// REST Request (model.CreateTask/ResolveTask) -> pkg/download Downloader -> Task.Meta.Opts (base.Options) -> Fetcher.Resolve/Start (e.g. internal/protocol/http/fetcher.go)
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Protocol string `json:"protocol"`
|
||||
Meta *fetcher.FetcherMeta `json:"meta"`
|
||||
Status base.Status `json:"status"`
|
||||
Uploading bool `json:"uploading"`
|
||||
Progress *Progress `json:"progress"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Protocol string `json:"protocol"`
|
||||
Meta *fetcher.FetcherMeta `json:"meta"`
|
||||
// ParentID identifies the independently stored group.
|
||||
ParentID string `json:"parentId,omitempty"`
|
||||
Status base.Status `json:"status"`
|
||||
Uploading bool `json:"uploading"`
|
||||
Progress *Progress `json:"progress"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// DoneAt is the time when the task completed; nil if it has never
|
||||
// finished. A done task never leaves the done state, so the value is
|
||||
// written once and kept.
|
||||
@@ -54,6 +70,8 @@ type Task struct {
|
||||
// does not have it and keeps its directory and name.
|
||||
NeedsPreparation bool `json:"needsPreparation,omitempty"`
|
||||
|
||||
group *TaskGroup
|
||||
|
||||
producer production.Source
|
||||
producerReceived int64
|
||||
|
||||
@@ -144,10 +162,12 @@ func (t *Task) MarshalJSON() ([]byte, error) {
|
||||
type rawTaskType Task
|
||||
jsonTask := struct {
|
||||
*rawTaskType
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Group *TaskGroup `json:"group,omitempty"`
|
||||
}{
|
||||
(*rawTaskType)(t),
|
||||
t.Name(),
|
||||
t.group,
|
||||
}
|
||||
return json.Marshal(jsonTask)
|
||||
}
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ func CreateTaskBatch(w http.ResponseWriter, r *http.Request) {
|
||||
WriteJson(w, model.NewErrorResult("param invalid: reqs", model.CodeInvalidParam))
|
||||
return
|
||||
}
|
||||
taskIds, err := Downloader.CreateDirectBatch(&req)
|
||||
taskIds, err := Downloader.CreateDirectBatchWithResult(&req)
|
||||
if err != nil {
|
||||
WriteJson(w, model.NewErrorResult(err.Error()))
|
||||
return
|
||||
|
||||
+38
-2
@@ -197,9 +197,10 @@ func TestCreateDirectTaskBatch(t *testing.T) {
|
||||
Req: createReq.Req,
|
||||
})
|
||||
}
|
||||
taskIds := httpRequestCheckOk[[]string](http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{
|
||||
result := httpRequestCheckOk[download.CreateTaskBatchResult](http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{
|
||||
Reqs: reqs,
|
||||
})
|
||||
taskIds := result.TaskIDs
|
||||
if len(taskIds) != len(reqs) {
|
||||
t.Errorf("CreateDirectTaskBatch() got = %v, want %v", len(taskIds), len(reqs))
|
||||
}
|
||||
@@ -220,12 +221,13 @@ func TestCreateDirectTaskBatchWithOpt(t *testing.T) {
|
||||
}
|
||||
reqs = append(reqs, item)
|
||||
}
|
||||
taskIds := httpRequestCheckOk[[]string](http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{
|
||||
result := httpRequestCheckOk[download.CreateTaskBatchResult](http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{
|
||||
Reqs: reqs,
|
||||
Opts: &base.Options{
|
||||
Name: "default_opt.data",
|
||||
},
|
||||
})
|
||||
taskIds := result.TaskIDs
|
||||
if len(taskIds) != len(reqs) {
|
||||
t.Errorf("CreateDirectTaskBatch() got = %v, want %v", len(taskIds), len(reqs))
|
||||
}
|
||||
@@ -245,6 +247,40 @@ func TestCreateDirectTaskBatchWithOpt(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateDirectTaskBatchWithGroup(t *testing.T) {
|
||||
doTest(func() {
|
||||
cfg, err := Downloader.GetConfig()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.MaxRunning = 0
|
||||
if err := Downloader.PutConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parent := t.TempDir()
|
||||
result := httpRequestCheckOk[download.CreateTaskBatchResult](http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{
|
||||
Reqs: []*base.CreateTaskBatchItem{{Req: createReq.Req}, {Req: createReq.Req}},
|
||||
Opts: &base.Options{Path: parent}, Group: true, GroupName: "folder",
|
||||
})
|
||||
if result.ParentID == "" || len(result.TaskIDs) != 2 {
|
||||
t.Fatalf("unexpected group result: %+v", result)
|
||||
}
|
||||
all := httpRequestCheckOk[[]map[string]any](http.MethodGet, "/api/v1/tasks", nil)
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("expected real tasks only, got %d", len(all))
|
||||
}
|
||||
for _, task := range all {
|
||||
if task["parentId"] != result.ParentID || task["type"] != nil {
|
||||
t.Fatalf("invalid member: %+v", task)
|
||||
}
|
||||
metadata, ok := task["group"].(map[string]any)
|
||||
if !ok || metadata["path"] != filepath.Join(parent, "folder") || metadata["name"] != "folder" {
|
||||
t.Fatalf("missing group metadata: %+v", task)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPauseAndContinueTask(t *testing.T) {
|
||||
doTest(func() {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -94,6 +94,8 @@ State broadcasting must observe the owning Riverpod state centrally. Do not broa
|
||||
- Storage RPC methods expose business capabilities such as create-history operations, not raw `box.get` or `box.put` access.
|
||||
- Prefer batch mutations across the window boundary. For example, save all parsed URLs in one `saveCreateHistory(List<String>)` call.
|
||||
- New child-window storage requirements must be added to `StorageMethods` and bound in the main registry.
|
||||
- Config reads, full config saves, and create-history mutations share one main-window queue. Each history mutation reads, changes, and writes the config within that queue. History reads wait for earlier saves.
|
||||
- `StorageMethods` owns `extra.createHistory`; full config saves preserve its latest value inside the queue so stale settings or child-window snapshots cannot overwrite history.
|
||||
|
||||
## 7. Gopeed Backend Migration
|
||||
|
||||
@@ -128,3 +130,16 @@ Child-window code must not:
|
||||
- Parse RPC JSON directly inside a page or widget.
|
||||
|
||||
These constraints keep all backend and persistence ownership in the main window and allow multiple child windows to operate concurrently without database locks or duplicated Gopeed runtimes.
|
||||
|
||||
## 10. Batch Task Creation
|
||||
|
||||
`GopeedMethods.createTaskBatch` carries `CreateTaskBatch` through the existing
|
||||
codec registry. Each member accepts either `rid` from resolve or `req`, plus
|
||||
creation `opts`. A nonempty `rid` takes priority. BT selections retain their
|
||||
original file indexes in `opts.selectFiles`.
|
||||
|
||||
Both ordinary and grouped creation return `CreateTaskBatchResult`, an object
|
||||
with `taskIds` and an optional `parentId`. A successful response completes the
|
||||
batch, including partial success. The preview tracks completed batches so a
|
||||
failed batch can be retried without repeating successful batches. Creation does
|
||||
not accept a group ID to append members to an existing group.
|
||||
@@ -85,10 +85,10 @@ Future<String> createTask(CreateTask createTask) {
|
||||
return _parse<String>(() => _request('api/v1/tasks', method: 'POST', data: createTask), (data) => data as String);
|
||||
}
|
||||
|
||||
Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) {
|
||||
return _parse<List<String>>(
|
||||
Future<CreateTaskBatchResult> createTaskBatch(CreateTaskBatch createTaskBatch) {
|
||||
return _parse<CreateTaskBatchResult>(
|
||||
() => _request('api/v1/tasks/batch', method: 'POST', data: createTaskBatch),
|
||||
(data) => (data as List<dynamic>).map((e) => e as String).toList(),
|
||||
(json) => CreateTaskBatchResult.fromJson(json as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ class CreateTaskBatch {
|
||||
List<CreateTaskBatchItem>? reqs;
|
||||
Options? opts;
|
||||
|
||||
CreateTaskBatch({this.reqs, this.opts});
|
||||
bool group;
|
||||
String groupName;
|
||||
|
||||
CreateTaskBatch({this.reqs, this.opts, this.group = false, this.groupName = ''});
|
||||
|
||||
factory CreateTaskBatch.fromJson(Map<String, dynamic> json) => _$CreateTaskBatchFromJson(json);
|
||||
|
||||
@@ -19,11 +22,23 @@ class CreateTaskBatch {
|
||||
|
||||
@JsonSerializable()
|
||||
class CreateTaskBatchItem {
|
||||
String? rid;
|
||||
Request? req;
|
||||
Options? opts;
|
||||
|
||||
CreateTaskBatchItem({this.req, this.opts});
|
||||
CreateTaskBatchItem({this.rid, this.req, this.opts});
|
||||
|
||||
factory CreateTaskBatchItem.fromJson(Map<String, dynamic> json) => _$CreateTaskBatchItemFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$CreateTaskBatchItemToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class CreateTaskBatchResult {
|
||||
final List<String> taskIds;
|
||||
final String? parentId;
|
||||
|
||||
CreateTaskBatchResult({required this.taskIds, this.parentId});
|
||||
|
||||
factory CreateTaskBatchResult.fromJson(Map<String, dynamic> json) => _$CreateTaskBatchResultFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$CreateTaskBatchResultToJson(this);
|
||||
}
|
||||
@@ -6,22 +6,55 @@ part of 'create_task_batch.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
CreateTaskBatch _$CreateTaskBatchFromJson(Map<String, dynamic> json) => CreateTaskBatch(
|
||||
reqs: (json['reqs'] as List<dynamic>?)?.map((e) => CreateTaskBatchItem.fromJson(e as Map<String, dynamic>)).toList(),
|
||||
opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map<String, dynamic>),
|
||||
);
|
||||
CreateTaskBatch _$CreateTaskBatchFromJson(Map<String, dynamic> json) =>
|
||||
CreateTaskBatch(
|
||||
reqs: (json['reqs'] as List<dynamic>?)
|
||||
?.map((e) => CreateTaskBatchItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
opts: json['opts'] == null
|
||||
? null
|
||||
: Options.fromJson(json['opts'] as Map<String, dynamic>),
|
||||
group: json['group'] as bool? ?? false,
|
||||
groupName: json['groupName'] as String? ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CreateTaskBatchToJson(CreateTaskBatch instance) => <String, dynamic>{
|
||||
'reqs': ?instance.reqs?.map((e) => e.toJson()).toList(),
|
||||
'opts': ?instance.opts?.toJson(),
|
||||
};
|
||||
Map<String, dynamic> _$CreateTaskBatchToJson(CreateTaskBatch instance) =>
|
||||
<String, dynamic>{
|
||||
'reqs': ?instance.reqs?.map((e) => e.toJson()).toList(),
|
||||
'opts': ?instance.opts?.toJson(),
|
||||
'group': instance.group,
|
||||
'groupName': instance.groupName,
|
||||
};
|
||||
|
||||
CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map<String, dynamic> json) => CreateTaskBatchItem(
|
||||
req: json['req'] == null ? null : Request.fromJson(json['req'] as Map<String, dynamic>),
|
||||
opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map<String, dynamic>),
|
||||
);
|
||||
CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map<String, dynamic> json) =>
|
||||
CreateTaskBatchItem(
|
||||
rid: json['rid'] as String?,
|
||||
req: json['req'] == null
|
||||
? null
|
||||
: Request.fromJson(json['req'] as Map<String, dynamic>),
|
||||
opts: json['opts'] == null
|
||||
? null
|
||||
: Options.fromJson(json['opts'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CreateTaskBatchItemToJson(CreateTaskBatchItem instance) => <String, dynamic>{
|
||||
Map<String, dynamic> _$CreateTaskBatchItemToJson(
|
||||
CreateTaskBatchItem instance,
|
||||
) => <String, dynamic>{
|
||||
'rid': ?instance.rid,
|
||||
'req': ?instance.req,
|
||||
'opts': ?instance.opts,
|
||||
};
|
||||
|
||||
CreateTaskBatchResult _$CreateTaskBatchResultFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => CreateTaskBatchResult(
|
||||
taskIds: (json['taskIds'] as List<dynamic>).map((e) => e as String).toList(),
|
||||
parentId: json['parentId'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CreateTaskBatchResultToJson(
|
||||
CreateTaskBatchResult instance,
|
||||
) => <String, dynamic>{
|
||||
'taskIds': instance.taskIds,
|
||||
'parentId': ?instance.parentId,
|
||||
};
|
||||
@@ -28,7 +28,10 @@ enum ExtractStatus {
|
||||
class Task {
|
||||
String id;
|
||||
String name;
|
||||
@JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue)
|
||||
Protocol? protocol;
|
||||
TaskGroup? group;
|
||||
String parentId;
|
||||
Meta meta;
|
||||
Status status;
|
||||
bool uploading;
|
||||
@@ -47,6 +50,8 @@ class Task {
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.doneAt,
|
||||
this.group,
|
||||
this.parentId = '',
|
||||
});
|
||||
|
||||
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
|
||||
@@ -54,6 +59,18 @@ class Task {
|
||||
Map<String, dynamic> toJson() => _$TaskToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class TaskGroup {
|
||||
final String id;
|
||||
final String name;
|
||||
final String path;
|
||||
final DateTime createdAt;
|
||||
|
||||
TaskGroup({required this.id, required this.name, required this.path, required this.createdAt});
|
||||
factory TaskGroup.fromJson(Map<String, dynamic> json) => _$TaskGroupFromJson(json);
|
||||
Map<String, dynamic> toJson() => _$TaskGroupToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class Progress {
|
||||
int used;
|
||||
|
||||
@@ -16,12 +16,16 @@ Task _$TaskFromJson(Map<String, dynamic> json) => Task(
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
doneAt: json['doneAt'] == null ? null : DateTime.parse(json['doneAt'] as String),
|
||||
)..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol']);
|
||||
group: json['group'] == null ? null : TaskGroup.fromJson(json['group'] as Map<String, dynamic>),
|
||||
parentId: json['parentId'] as String? ?? '',
|
||||
)..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol'], unknownValue: JsonKey.nullForUndefinedEnumValue);
|
||||
|
||||
Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'protocol': ?_$ProtocolEnumMap[instance.protocol],
|
||||
'group': ?instance.group?.toJson(),
|
||||
'parentId': instance.parentId,
|
||||
'meta': instance.meta.toJson(),
|
||||
'status': _$StatusEnumMap[instance.status]!,
|
||||
'uploading': instance.uploading,
|
||||
@@ -48,6 +52,20 @@ const _$ProtocolEnumMap = {
|
||||
Protocol.ftp: 'ftp',
|
||||
};
|
||||
|
||||
TaskGroup _$TaskGroupFromJson(Map<String, dynamic> json) => TaskGroup(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
path: json['path'] as String,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TaskGroupToJson(TaskGroup instance) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'name': instance.name,
|
||||
'path': instance.path,
|
||||
'createdAt': instance.createdAt.toIso8601String(),
|
||||
};
|
||||
|
||||
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
|
||||
used: (json['used'] as num).toInt(),
|
||||
speed: (json['speed'] as num).toInt(),
|
||||
|
||||
@@ -34,8 +34,9 @@ class AppCapabilities {
|
||||
class LocalAppCapabilities {
|
||||
LocalAppCapabilities._() : codecs = createAppCapabilityCodecs() {
|
||||
registry = CapabilityRegistry(codecs);
|
||||
_bindGopeed(registry);
|
||||
_bindStorage(registry);
|
||||
final config = _ConfigStore();
|
||||
_bindGopeed(registry, config);
|
||||
_bindStorage(registry, config);
|
||||
registry.bind(NavigationMethods.showDownloadingTasks, (_) {
|
||||
AppRouter.showDownloadingTasks();
|
||||
return const RpcUnit();
|
||||
@@ -62,6 +63,7 @@ RpcCodecRegistry createAppCapabilityCodecs() {
|
||||
..register<ResolveResult>((json) => ResolveResult.fromJson(json! as Map<String, dynamic>))
|
||||
..register<CreateTask>((json) => CreateTask.fromJson(json! as Map<String, dynamic>))
|
||||
..register<CreateTaskBatch>((json) => CreateTaskBatch.fromJson(json! as Map<String, dynamic>))
|
||||
..register<CreateTaskBatchResult>((json) => CreateTaskBatchResult.fromJson(json! as Map<String, dynamic>))
|
||||
..register<DownloaderConfig>((json) => DownloaderConfig.fromJson(json! as Map<String, dynamic>))
|
||||
..register<TaskRuntimeStatus>((json) => TaskRuntimeStatus.fromJson(json! as Map<String, dynamic>))
|
||||
..register<List<Task>>(
|
||||
@@ -80,7 +82,7 @@ RpcCodecRegistry createAppCapabilityCodecs() {
|
||||
return codecs;
|
||||
}
|
||||
|
||||
void _bindGopeed(CapabilityRegistry registry) {
|
||||
void _bindGopeed(CapabilityRegistry registry, _ConfigStore configStore) {
|
||||
registry
|
||||
..bind(GopeedMethods.triggerProtocolJob, (p) async {
|
||||
await api.triggerProtocolJob(p['protocol'] as String, p['name'] as String);
|
||||
@@ -127,9 +129,9 @@ void _bindGopeed(CapabilityRegistry registry) {
|
||||
await api.deleteTasks(_optionalStringList(params['ids']), params['force'] as bool? ?? false);
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.getConfig, (_) => api.getConfig())
|
||||
..bind(GopeedMethods.getConfig, (_) => configStore.get())
|
||||
..bind(GopeedMethods.putConfig, (config) async {
|
||||
await api.putConfig(config);
|
||||
await configStore.put(config);
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.installExtension, api.installExtension)
|
||||
@@ -163,35 +165,59 @@ void _bindGopeed(CapabilityRegistry registry) {
|
||||
});
|
||||
}
|
||||
|
||||
void _bindStorage(CapabilityRegistry registry) {
|
||||
// Shared by local and child-window handlers. History owns its config field;
|
||||
// full config snapshots must preserve the latest history inside the same queue.
|
||||
class _ConfigStore {
|
||||
Future<void> _tail = Future<void>.value();
|
||||
|
||||
Future<T> _run<T>(Future<T> Function() operation) {
|
||||
final result = _tail.then((_) => operation());
|
||||
_tail = result.then<void>((_) {}, onError: (Object _, StackTrace _) {});
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<DownloaderConfig> get() => _run(api.getConfig);
|
||||
|
||||
Future<void> put(DownloaderConfig config) {
|
||||
final snapshot = DownloaderConfig.fromJson(config.toJson());
|
||||
return _run(() async {
|
||||
final latest = await api.getConfig();
|
||||
snapshot.extra.createHistory = latest.extra.createHistory;
|
||||
await api.putConfig(snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateHistory(void Function(List<String>) mutation) => _run(() async {
|
||||
final config = await api.getConfig();
|
||||
final history = List<String>.of(config.extra.createHistory);
|
||||
mutation(history);
|
||||
config.extra.createHistory = history;
|
||||
await api.putConfig(config);
|
||||
});
|
||||
}
|
||||
|
||||
void _bindStorage(CapabilityRegistry registry, _ConfigStore configStore) {
|
||||
registry
|
||||
..bind(StorageMethods.getCreateHistory, (_) async {
|
||||
final config = await api.getConfig();
|
||||
final config = await configStore.get();
|
||||
return config.extra.createHistory;
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (urls) async {
|
||||
final config = await api.getConfig();
|
||||
final history = List<String>.of(config.extra.createHistory);
|
||||
for (final url in urls) {
|
||||
history.remove(url);
|
||||
history.insert(0, url);
|
||||
if (history.length > 64) history.removeLast();
|
||||
}
|
||||
config.extra.createHistory = history;
|
||||
await api.putConfig(config);
|
||||
await configStore.updateHistory((history) {
|
||||
for (final url in urls) {
|
||||
history.remove(url);
|
||||
history.insert(0, url);
|
||||
if (history.length > 64) history.removeLast();
|
||||
}
|
||||
});
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(StorageMethods.removeCreateHistory, (url) async {
|
||||
final config = await api.getConfig();
|
||||
final history = List<String>.of(config.extra.createHistory)..remove(url);
|
||||
config.extra.createHistory = history;
|
||||
await api.putConfig(config);
|
||||
await configStore.updateHistory((history) => history.remove(url));
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(StorageMethods.clearCreateHistory, (_) async {
|
||||
final config = await api.getConfig();
|
||||
config.extra.createHistory = const [];
|
||||
await api.putConfig(config);
|
||||
await configStore.updateHistory((history) => history.clear());
|
||||
return const RpcUnit();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ abstract final class GopeedMethods {
|
||||
static const getMirrorCandidates = RpcMethod<Map<String, dynamic>, List<String>>('gopeed.mirrors.candidates');
|
||||
static const resolve = RpcMethod<ResolveTask, ResolveResult>('gopeed.resolve');
|
||||
static const createTask = RpcMethod<CreateTask, String>('gopeed.task.create');
|
||||
static const createTaskBatch = RpcMethod<CreateTaskBatch, List<String>>('gopeed.task.createBatch');
|
||||
static const createTaskBatch = RpcMethod<CreateTaskBatch, CreateTaskBatchResult>('gopeed.task.createBatch');
|
||||
static const patchTask = RpcMethod<Map<String, dynamic>, RpcUnit>('gopeed.task.patch');
|
||||
static const getTasks = RpcMethod<List<String>, List<Task>>('gopeed.task.list');
|
||||
static const getTaskStatus = RpcMethod<String, TaskRuntimeStatus>('gopeed.task.status');
|
||||
@@ -53,7 +53,7 @@ class GopeedService {
|
||||
|
||||
Future<String> createTask(CreateTask request) => _invoker.invoke(GopeedMethods.createTask, request);
|
||||
|
||||
Future<List<String>> createTaskBatch(CreateTaskBatch request) =>
|
||||
Future<CreateTaskBatchResult> createTaskBatch(CreateTaskBatch request) =>
|
||||
_invoker.invoke(GopeedMethods.createTaskBatch, request);
|
||||
|
||||
Future<void> patchTask(String id, PatchTask request) async {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform;
|
||||
import 'package:flutter/material.dart' show Colors, Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -48,10 +49,10 @@ class _BatchActionAvailability {
|
||||
_BatchActionAvailability _batchActionAvailability(Iterable<TaskRecord> tasks, TaskBatchSelectionController selection) {
|
||||
var canPause = false;
|
||||
var canResume = false;
|
||||
for (final task in tasks) {
|
||||
for (final task in tasks.expand((task) => [task, ...task.members])) {
|
||||
if (!selection.contains(task.id)) continue;
|
||||
canPause = canPause || task.status == TaskStatus.downloading;
|
||||
canResume = canResume || task.status == TaskStatus.paused || task.status == TaskStatus.failed;
|
||||
canPause = canPause || task.canPause;
|
||||
canResume = canResume || task.canResume;
|
||||
if (canPause && canResume) break;
|
||||
}
|
||||
return _BatchActionAvailability(canPause: canPause, canResume: canResume);
|
||||
@@ -69,6 +70,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
_TaskFilter _activeFilter = _TaskFilter.downloading;
|
||||
TaskRecord? _selectedTask;
|
||||
bool _batchMode = false;
|
||||
final Set<String> _expandedGroupIds = {};
|
||||
final TaskBatchSelectionController _batchSelection = TaskBatchSelectionController();
|
||||
|
||||
@override
|
||||
@@ -96,11 +98,16 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final tasksState = tasksAsync.value;
|
||||
final tasks = tasksState?.tasks ?? const <TaskRecord>[];
|
||||
final filteredTasks = _filteredTasks(tasks);
|
||||
final visibleTasks = _visibleTasks(filteredTasks);
|
||||
final memberIds = {
|
||||
for (final task in tasks)
|
||||
for (final member in task.members) member.id,
|
||||
};
|
||||
final emptyStateMessage = _searchController.text.trim().isEmpty
|
||||
? context.l10n.emptyTaskList
|
||||
: context.l10n.noMatchingTasks;
|
||||
final selectedTask = _latestSelectedTask(tasks);
|
||||
final visibleTaskIds = filteredTasks.map((task) => task.id).toList(growable: false);
|
||||
final selectedTask = _latestSelectedTask();
|
||||
final visibleTaskIds = visibleTasks.map((task) => task.id).toList(growable: false);
|
||||
final pendingUpdateTask = ref.watch(pendingUpdateTaskProvider);
|
||||
final navigationItems = _taskNavigationItems(tasks);
|
||||
final desktopBody = shad.Scaffold(
|
||||
@@ -158,7 +165,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
listenable: _batchSelection,
|
||||
builder: (context, _) => _TaskBatchRow(
|
||||
selectedCount: _batchSelection.count,
|
||||
totalCount: filteredTasks.length,
|
||||
totalCount: visibleTasks.length,
|
||||
allSelected: _batchSelection.areAllSelected(visibleTaskIds),
|
||||
someSelected:
|
||||
_batchSelection.isNotEmpty &&
|
||||
@@ -179,16 +186,19 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
itemCount: filteredTasks.length,
|
||||
itemCount: visibleTasks.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final task = filteredTasks[index];
|
||||
final task = visibleTasks[index];
|
||||
return TaskBatchSelectionBuilder(
|
||||
key: ValueKey(task.id),
|
||||
controller: _batchSelection,
|
||||
taskId: task.id,
|
||||
visibleTaskIds: visibleTaskIds,
|
||||
builder: (context, selectedInBatch, allSelected) => TaskCard(
|
||||
task: task,
|
||||
nested: memberIds.contains(task.id),
|
||||
expanded: _expandedGroupIds.contains(task.id),
|
||||
selected: _selectedTask?.id == task.id,
|
||||
batchMode: _batchMode,
|
||||
selectedInBatch: selectedInBatch,
|
||||
@@ -242,6 +252,8 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final mobileBody = tasksAsync.when(
|
||||
loading: () => _MobileTasksView(
|
||||
tasks: const [],
|
||||
memberIds: const {},
|
||||
expandedGroupIds: _expandedGroupIds,
|
||||
batchMode: _batchMode,
|
||||
batchSelection: _batchSelection,
|
||||
loading: true,
|
||||
@@ -262,7 +274,9 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
),
|
||||
error: (error, _) => ColoredBox(color: palette.bg, child: const TaskEmptyState()),
|
||||
data: (_) => _MobileTasksView(
|
||||
tasks: filteredTasks,
|
||||
tasks: visibleTasks,
|
||||
memberIds: memberIds,
|
||||
expandedGroupIds: _expandedGroupIds,
|
||||
emptyStateMessage: emptyStateMessage,
|
||||
onCreateTask: _searchController.text.trim().isEmpty ? _handleAddTask : null,
|
||||
batchMode: _batchMode,
|
||||
@@ -331,20 +345,20 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
_TaskFilter.completed => task.status == TaskStatus.completed,
|
||||
_TaskFilter.failed => task.status == TaskStatus.failed,
|
||||
};
|
||||
final passesSearch =
|
||||
query.isEmpty || task.name.toLowerCase().contains(query) || task.url.toLowerCase().contains(query);
|
||||
final passesSearch = task.matchesSearch(query);
|
||||
return passesFilter && passesSearch;
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
TaskRecord? _latestSelectedTask(List<TaskRecord> tasks) {
|
||||
List<TaskRecord> _visibleTasks(List<TaskRecord> tasks) => [
|
||||
for (final task in tasks) ...[task, if (_expandedGroupIds.contains(task.id)) ...task.members],
|
||||
];
|
||||
|
||||
TaskRecord? _latestSelectedTask() {
|
||||
final selectedTask = _selectedTask;
|
||||
if (selectedTask == null) return null;
|
||||
for (final task in tasks) {
|
||||
if (task.id == selectedTask.id) return task;
|
||||
}
|
||||
return selectedTask;
|
||||
return ref.read(tasksControllerProvider).value?.findTask(selectedTask.id);
|
||||
}
|
||||
|
||||
List<ResponsiveNavigationItem<_TaskFilter>> _taskNavigationItems(List<TaskRecord> tasks) {
|
||||
@@ -376,7 +390,13 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
if (_batchMode) {
|
||||
_toggleBatchTask(task.id);
|
||||
} else {
|
||||
_showTaskDetails(task);
|
||||
if (task.isGroup) {
|
||||
setState(() {
|
||||
if (!_expandedGroupIds.remove(task.id)) _expandedGroupIds.add(task.id);
|
||||
});
|
||||
} else {
|
||||
_showTaskDetails(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,8 +426,8 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
void _toggleBatchTask(String taskId) => _batchSelection.toggle(taskId);
|
||||
|
||||
void _toggleSelectAll() {
|
||||
final visibleIds = _filteredTasks(
|
||||
ref.read(tasksControllerProvider).value?.tasks ?? const [],
|
||||
final visibleIds = _visibleTasks(
|
||||
_filteredTasks(ref.read(tasksControllerProvider).value?.tasks ?? const []),
|
||||
).map((task) => task.id).toList(growable: false);
|
||||
if (_batchSelection.areAllSelected(visibleIds)) {
|
||||
_batchSelection.clear();
|
||||
@@ -430,22 +450,20 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
|
||||
void _pauseSelectedTasks() {
|
||||
if (_batchMode && _batchSelection.isEmpty) return;
|
||||
final ids = _batchMode ? _selectedTaskIdsFor((task) => task.status == TaskStatus.downloading) : null;
|
||||
final ids = _batchMode ? _selectedTaskIdsFor((task) => task.canPause) : null;
|
||||
if (_batchMode && ids!.isEmpty) return;
|
||||
unawaited(_runTaskAction(() => ref.read(tasksControllerProvider.notifier).pauseAll(ids)));
|
||||
}
|
||||
|
||||
void _resumeSelectedTasks() {
|
||||
if (_batchMode && _batchSelection.isEmpty) return;
|
||||
final ids = _batchMode
|
||||
? _selectedTaskIdsFor((task) => task.status == TaskStatus.paused || task.status == TaskStatus.failed)
|
||||
: null;
|
||||
final ids = _batchMode ? _selectedTaskIdsFor((task) => task.canResume) : null;
|
||||
if (_batchMode && ids!.isEmpty) return;
|
||||
unawaited(_runTaskAction(() => ref.read(tasksControllerProvider.notifier).resumeAll(ids)));
|
||||
}
|
||||
|
||||
List<String> _selectedTaskIdsFor(bool Function(TaskRecord task) test) {
|
||||
final tasks = ref.read(tasksControllerProvider).value?.tasks ?? const <TaskRecord>[];
|
||||
final tasks = ref.read(tasksControllerProvider).value?.allTasks ?? const <TaskRecord>[];
|
||||
return tasks.where((task) => _batchSelection.contains(task.id) && test(task)).map((task) => task.id).toList();
|
||||
}
|
||||
|
||||
@@ -479,8 +497,8 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
}
|
||||
|
||||
List<String> _contextTaskIds(TaskRecord task) {
|
||||
final visibleIds = _filteredTasks(
|
||||
ref.read(tasksControllerProvider).value?.tasks ?? const [],
|
||||
final visibleIds = _visibleTasks(
|
||||
_filteredTasks(ref.read(tasksControllerProvider).value?.tasks ?? const []),
|
||||
).map((item) => item.id).toSet();
|
||||
return <String>{..._batchSelection.where(visibleIds.contains), task.id}.toList(growable: false);
|
||||
}
|
||||
@@ -502,9 +520,27 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
if (ids.isEmpty) return;
|
||||
final runtime = ref.read(appRuntimeControllerProvider).value;
|
||||
final config = runtime?.downloaderConfig;
|
||||
final selected =
|
||||
ref.read(tasksControllerProvider).value?.allTasks.where((task) => ids.contains(task.id)).toList() ??
|
||||
<TaskRecord>[];
|
||||
final groupedMemberIds = {
|
||||
for (final task in selected)
|
||||
for (final member in task.members) member.id,
|
||||
};
|
||||
ids = ids.where((id) => !groupedMemberIds.contains(id)).toList();
|
||||
selected.removeWhere((task) => groupedMemberIds.contains(task.id));
|
||||
final hasGroups = selected.any((task) => task.isGroup);
|
||||
final keepFiles = await showTaskDeleteDialog(
|
||||
context,
|
||||
taskCount: ids.length,
|
||||
memberCount: !hasGroups
|
||||
? null
|
||||
: selected.fold<int>(0, (total, task) => total + (task.isGroup ? task.members.length : 1)),
|
||||
// A task always downloads at least one file; records whose resource
|
||||
// has no file list (plain single-file downloads) still count as one.
|
||||
fileCount: !hasGroups
|
||||
? null
|
||||
: selected.fold<int>(0, (total, task) => total + (task.files.isEmpty ? 1 : task.files.length)),
|
||||
keepFiles: config?.extra.lastDeleteTaskKeep ?? false,
|
||||
);
|
||||
if (keepFiles == null || !mounted) return;
|
||||
@@ -518,10 +554,11 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
await ref.read(appRuntimeControllerProvider.notifier).reloadConfig();
|
||||
});
|
||||
if (!mounted) return;
|
||||
_batchSelection.removeAll(ids);
|
||||
_batchSelection.removeAll({...ids, ...groupedMemberIds});
|
||||
setState(() {
|
||||
_batchMode = false;
|
||||
if (_selectedTask != null && ids.contains(_selectedTask!.id)) {
|
||||
_expandedGroupIds.removeAll(ids);
|
||||
if (_selectedTask != null && (ids.contains(_selectedTask!.id) || groupedMemberIds.contains(_selectedTask!.id))) {
|
||||
_selectedTask = null;
|
||||
}
|
||||
});
|
||||
@@ -569,14 +606,8 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
Future<void> _updateTaskUrl(String id, String url, {Map<String, String> headers = const {}}) async {
|
||||
try {
|
||||
await ref.read(tasksControllerProvider.notifier).updateUrl(id, url, headers: headers);
|
||||
final updatedTasks = ref.read(tasksControllerProvider).value?.tasks ?? const <TaskRecord>[];
|
||||
for (final task in updatedTasks) {
|
||||
if (task.id == id) {
|
||||
if (mounted) {
|
||||
setState(() => _selectedTask = task);
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _selectedTask = ref.read(tasksControllerProvider).value?.findTask(id));
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mounted) rethrow;
|
||||
@@ -586,6 +617,16 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
}
|
||||
|
||||
Future<void> _openTask(TaskRecord task) async {
|
||||
final nativeDirectory =
|
||||
!Util.isWeb() &&
|
||||
switch (defaultTargetPlatform) {
|
||||
TargetPlatform.windows || TargetPlatform.macOS || TargetPlatform.linux => true,
|
||||
_ => false,
|
||||
};
|
||||
if (task.isGroup && !nativeDirectory) {
|
||||
await browseTaskFiles(context, task, mobile: MediaQuery.sizeOf(context).width < Breakpoints.mobile);
|
||||
return;
|
||||
}
|
||||
final opened = task.isFolder
|
||||
? await FileExplorer.reveal(task.storagePath)
|
||||
: await FileExplorer.open(task.storagePath);
|
||||
@@ -674,6 +715,8 @@ class _TaskBatchRow extends StatelessWidget {
|
||||
class _MobileTasksView extends StatelessWidget {
|
||||
const _MobileTasksView({
|
||||
required this.tasks,
|
||||
required this.memberIds,
|
||||
required this.expandedGroupIds,
|
||||
required this.batchMode,
|
||||
required this.batchSelection,
|
||||
required this.onToggleSelectAll,
|
||||
@@ -695,6 +738,8 @@ class _MobileTasksView extends StatelessWidget {
|
||||
});
|
||||
|
||||
final List<TaskRecord> tasks;
|
||||
final Set<String> memberIds;
|
||||
final Set<String> expandedGroupIds;
|
||||
final bool batchMode;
|
||||
final TaskBatchSelectionController batchSelection;
|
||||
final VoidCallback onToggleSelectAll;
|
||||
@@ -760,11 +805,14 @@ class _MobileTasksView extends StatelessWidget {
|
||||
itemBuilder: (context, index) {
|
||||
final task = tasks[index];
|
||||
return TaskBatchSelectionBuilder(
|
||||
key: ValueKey(task.id),
|
||||
controller: batchSelection,
|
||||
taskId: task.id,
|
||||
visibleTaskIds: visibleTaskIds,
|
||||
builder: (context, selectedInBatch, allSelected) => TaskCard(
|
||||
task: task,
|
||||
nested: memberIds.contains(task.id),
|
||||
expanded: expandedGroupIds.contains(task.id),
|
||||
selected: false,
|
||||
batchMode: batchMode,
|
||||
selectedInBatch: selectedInBatch,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
import '../../../api/model/create_task_batch.dart';
|
||||
import '../../../api/model/options.dart';
|
||||
import '../../../api/model/request.dart';
|
||||
import '../../../api/model/resource.dart';
|
||||
import '../../../api/model/resolve_result.dart';
|
||||
import '../../../api/model/resolve_task.dart';
|
||||
import '../../../core/capabilities/gopeed_capability.dart';
|
||||
|
||||
/// IDs identify sources within one preview and are never sent as group IDs.
|
||||
class PendingDownload {
|
||||
PendingDownload({required this.id, required this.request, required this.options});
|
||||
|
||||
final String id;
|
||||
final Request request;
|
||||
final Options options;
|
||||
ResolveResult? result;
|
||||
String? resolveError;
|
||||
String? createError;
|
||||
final Set<int> selected = {};
|
||||
bool created = false;
|
||||
bool resolving = false;
|
||||
|
||||
bool get loading => result == null && resolveError == null;
|
||||
bool get isGroup => result != null && result!.id.isEmpty && result!.res.name.trim().isNotEmpty && files.length > 1;
|
||||
bool get isFtp => RegExp(r'^(ftps?|ftpes):', caseSensitive: false).hasMatch(request.url.trim());
|
||||
bool get isBt {
|
||||
if (result?.id.isEmpty == true) return false;
|
||||
final url = request.url.trim().toLowerCase();
|
||||
if (url.startsWith('http:') || url.startsWith('https:') || isFtp) return false;
|
||||
return url.startsWith('magnet:') ||
|
||||
url.startsWith('data:application/x-bittorrent;base64,') ||
|
||||
url.split(RegExp(r'[?#]')).first.endsWith('.torrent');
|
||||
}
|
||||
|
||||
String get name {
|
||||
if (!isBt &&
|
||||
(!isFtp || result?.res.name.trim().isEmpty == true) &&
|
||||
result?.id.isNotEmpty == true &&
|
||||
files.length == 1 &&
|
||||
files.single.name.isNotEmpty) {
|
||||
return files.single.name;
|
||||
}
|
||||
if (result?.res.name.trim().isNotEmpty == true) return result!.res.name;
|
||||
return request.url;
|
||||
}
|
||||
|
||||
List<FileInfo> get files => result?.res.files ?? const [];
|
||||
bool get hasSelection => !created && result != null && selected.isNotEmpty;
|
||||
|
||||
Future<void> resolve(GopeedService service) async {
|
||||
if (result != null || resolving) return;
|
||||
resolving = true;
|
||||
resolveError = null;
|
||||
try {
|
||||
result = await service.resolve(ResolveTask(req: request));
|
||||
selected.addAll(List.generate(files.isEmpty ? 1 : files.length, (index) => index));
|
||||
} catch (error) {
|
||||
resolveError = error.toString();
|
||||
} finally {
|
||||
resolving = false;
|
||||
}
|
||||
}
|
||||
|
||||
List<CreateTaskBatchItem> creationItems() {
|
||||
final indexes = selected.toList()..sort();
|
||||
if (result!.id.isNotEmpty || files.isEmpty) {
|
||||
final opts = Options.fromJson(options.toJson());
|
||||
if (files.isNotEmpty) opts.selectFiles = indexes;
|
||||
if (indexes.length > 1) _omitChecksum(opts);
|
||||
return [
|
||||
CreateTaskBatchItem(
|
||||
rid: result!.id.isEmpty ? null : result!.id,
|
||||
req: result!.id.isEmpty ? request : null,
|
||||
opts: opts,
|
||||
),
|
||||
];
|
||||
}
|
||||
final group = isGroup;
|
||||
final resourceName = result!.res.name;
|
||||
final parentSegments = [
|
||||
if (!group && options.path.isNotEmpty) options.path,
|
||||
if (!group && resourceName.trim().isNotEmpty) resourceName,
|
||||
];
|
||||
final skipAutoCategorize = options.skipAutoCategorize || resourceName.isNotEmpty || files.length > 1;
|
||||
final httpUrl = RegExp(r'^https?:', caseSensitive: false);
|
||||
return indexes.map((index) {
|
||||
final file = files[index];
|
||||
final member = Options.fromJson(options.toJson());
|
||||
member.name = files.length == 1 && options.name.isNotEmpty ? options.name : file.name;
|
||||
final segments = [...parentSegments, if (file.path.isNotEmpty) file.path];
|
||||
member.path = segments.isEmpty ? '' : path.joinAll(segments);
|
||||
// The selected parent directory is persisted separately from members.
|
||||
member.asDefaultPath = false;
|
||||
member.skipAutoCategorize = skipAutoCategorize;
|
||||
final req = file.req ?? request;
|
||||
member.size = httpUrl.hasMatch(req.url) ? file.size : 0;
|
||||
if (RegExp(r'^(ftps?|ftpes):', caseSensitive: false).hasMatch(req.url)) {
|
||||
final extra = OptsExtraFtp.fromJson(Map<String, dynamic>.from(member.extra as Map? ?? const {}));
|
||||
extra.connections = extra.connections.clamp(0, 32);
|
||||
member.extra = extra.toJson();
|
||||
}
|
||||
if (indexes.length > 1) _omitChecksum(member);
|
||||
return CreateTaskBatchItem(req: req, opts: member);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void _omitChecksum(Options opts) {
|
||||
if (opts.extra is Map) opts.extra = Map<String, dynamic>.from(opts.extra! as Map)..remove('checksum');
|
||||
}
|
||||
}
|
||||
|
||||
/// A successful response completes the entire batch, including partial success.
|
||||
class DownloadSubmission {
|
||||
DownloadSubmission({required this.sources, required this.request});
|
||||
|
||||
final List<PendingDownload> sources;
|
||||
final CreateTaskBatch request;
|
||||
|
||||
Future<void> submit(GopeedService service) async {
|
||||
for (final source in sources) {
|
||||
source.createError = null;
|
||||
}
|
||||
try {
|
||||
await service.createTaskBatch(request);
|
||||
for (final source in sources) {
|
||||
source.created = true;
|
||||
}
|
||||
} catch (error) {
|
||||
for (final source in sources) {
|
||||
source.createError = error.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<DownloadSubmission> planDownloads(List<PendingDownload> sources) {
|
||||
final groups = <DownloadSubmission>[];
|
||||
final independent = <PendingDownload>[];
|
||||
for (final source in sources.where((source) => source.hasSelection)) {
|
||||
if (source.isGroup) {
|
||||
groups.add(
|
||||
DownloadSubmission(
|
||||
sources: [source],
|
||||
request: CreateTaskBatch(
|
||||
group: true,
|
||||
groupName: source.options.name.isNotEmpty ? source.options.name : source.result!.res.name.trim(),
|
||||
opts: source.options,
|
||||
reqs: source.creationItems(),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
independent.add(source);
|
||||
}
|
||||
}
|
||||
return [
|
||||
...groups,
|
||||
if (independent.isNotEmpty)
|
||||
DownloadSubmission(
|
||||
sources: independent,
|
||||
request: CreateTaskBatch(reqs: [for (final source in independent) ...source.creationItems()]),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -23,6 +23,10 @@ class TasksState {
|
||||
final int uploadSpeedBytesPerSecond;
|
||||
final Object? lastError;
|
||||
|
||||
Iterable<TaskRecord> get allTasks => tasks.expand((task) => [task, ...task.members]);
|
||||
|
||||
TaskRecord? findTask(String id) => allTasks.where((task) => task.id == id).firstOrNull;
|
||||
|
||||
TasksState copyWith({
|
||||
List<TaskRecord>? tasks,
|
||||
int? downloadSpeedBytesPerSecond,
|
||||
@@ -122,13 +126,14 @@ class TasksController extends AsyncNotifier<TasksState> {
|
||||
Future<TasksState> _fetch() async {
|
||||
final tasks = await ref.read(gopeedServiceProvider).getTasks(api_model.Status.values);
|
||||
final transferSpeeds = aggregateTaskTransferSpeeds(tasks);
|
||||
tasks.sort((a, b) {
|
||||
if (a.status == api_model.Status.running && b.status != api_model.Status.running) return -1;
|
||||
if (a.status != api_model.Status.running && b.status == api_model.Status.running) return 1;
|
||||
return b.updatedAt.compareTo(a.updatedAt);
|
||||
});
|
||||
return TasksState(
|
||||
tasks: tasks.map(TaskRecord.fromApi).toList(growable: false),
|
||||
tasks: taskRecordsFromApi(tasks)
|
||||
..sort((a, b) {
|
||||
final aRunning = a.status == TaskStatus.downloading && !a.waiting;
|
||||
final bRunning = b.status == TaskStatus.downloading && !b.waiting;
|
||||
if (aRunning != bRunning) return aRunning ? -1 : 1;
|
||||
return (b.updatedAt ?? b.createdAt!).compareTo(a.updatedAt ?? a.createdAt!);
|
||||
}),
|
||||
downloadSpeedBytesPerSecond: transferSpeeds.downloadBytesPerSecond,
|
||||
uploadSpeedBytesPerSecond: transferSpeeds.uploadBytesPerSecond,
|
||||
);
|
||||
|
||||
@@ -42,6 +42,10 @@ class TaskFileNode {
|
||||
required this.sizeBytes,
|
||||
this.downloadedBytes,
|
||||
this.resourceIndex,
|
||||
this.status,
|
||||
this.taskId,
|
||||
this.accessPath,
|
||||
this.localPath,
|
||||
});
|
||||
|
||||
final int? resourceIndex;
|
||||
@@ -49,6 +53,10 @@ class TaskFileNode {
|
||||
final String name;
|
||||
final int sizeBytes;
|
||||
final int? downloadedBytes;
|
||||
final TaskStatus? status;
|
||||
final String? taskId;
|
||||
final String? accessPath;
|
||||
final String? localPath;
|
||||
|
||||
double? get progress {
|
||||
final downloaded = downloadedBytes;
|
||||
@@ -68,6 +76,8 @@ class TaskRecord {
|
||||
required this.files,
|
||||
required this.uploading,
|
||||
this.isFolder = false,
|
||||
this.isGroup = false,
|
||||
this.members = const [],
|
||||
this.protocol,
|
||||
this.requestHeaders = const {},
|
||||
this.total,
|
||||
@@ -84,6 +94,8 @@ class TaskRecord {
|
||||
this.totalBytes,
|
||||
this.speedBytes,
|
||||
this.uploadedBytes,
|
||||
this.uploadSpeedBytes,
|
||||
this.updatedAt,
|
||||
this.remainingSeconds,
|
||||
this.waiting = false,
|
||||
}) : assert(!uploading || uploadSpeed != null);
|
||||
@@ -108,16 +120,31 @@ class TaskRecord {
|
||||
final int? totalBytes;
|
||||
final int? speedBytes;
|
||||
final int? uploadedBytes;
|
||||
final int? uploadSpeedBytes;
|
||||
final DateTime? updatedAt;
|
||||
final int? remainingSeconds;
|
||||
final bool waiting;
|
||||
final List<TaskFileNode> files;
|
||||
final bool uploading;
|
||||
final bool isFolder;
|
||||
final bool isGroup;
|
||||
final List<TaskRecord> members;
|
||||
final api_task.Protocol? protocol;
|
||||
final Map<String, String> requestHeaders;
|
||||
|
||||
bool get canUpdateUrl =>
|
||||
protocol == api_task.Protocol.http && (status == TaskStatus.paused || status == TaskStatus.failed);
|
||||
!isGroup && protocol == api_task.Protocol.http && (status == TaskStatus.paused || status == TaskStatus.failed);
|
||||
|
||||
bool get canPause => isGroup ? members.any((task) => task.canPause) : status == TaskStatus.downloading;
|
||||
bool get canResume =>
|
||||
isGroup ? members.any((task) => task.canResume) : status == TaskStatus.paused || status == TaskStatus.failed;
|
||||
|
||||
bool matchesSearch(String query) =>
|
||||
query.isEmpty ||
|
||||
name.toLowerCase().contains(query) ||
|
||||
url.toLowerCase().contains(query) ||
|
||||
files.any((file) => '${file.path}/${file.name}'.toLowerCase().contains(query)) ||
|
||||
members.any((member) => member.matchesSearch(query));
|
||||
|
||||
bool get isIndeterminate => status == TaskStatus.downloading && !waiting && total == null;
|
||||
|
||||
@@ -147,6 +174,7 @@ class TaskRecord {
|
||||
remainingSeconds: _remainingSeconds(task, totalBytes, downloadedBytes),
|
||||
downloadDuration: task.progress.used > 0 ? Duration(microseconds: (task.progress.used + 999) ~/ 1000) : null,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
doneAt: task.doneAt,
|
||||
waiting: task.status == api_task.Status.wait,
|
||||
progress: progress,
|
||||
@@ -156,6 +184,7 @@ class TaskRecord {
|
||||
totalBytes: totalBytes > 0 ? totalBytes : null,
|
||||
speedBytes: task.progress.speed,
|
||||
uploadedBytes: task.progress.uploaded,
|
||||
uploadSpeedBytes: task.progress.uploadSpeed,
|
||||
files: _fileNodes(task.meta.res?.files, task.meta.opts.selectFiles),
|
||||
uploading: task.uploading,
|
||||
isFolder: task.meta.res?.name.isNotEmpty ?? false,
|
||||
@@ -165,6 +194,186 @@ class TaskRecord {
|
||||
}
|
||||
}
|
||||
|
||||
List<TaskRecord> taskRecordsFromApi(List<api_task.Task> tasks) {
|
||||
final members = <String, List<TaskRecord>>{};
|
||||
final metadata = <String, api_task.TaskGroup>{};
|
||||
final records = <String, TaskRecord>{};
|
||||
for (final task in tasks) {
|
||||
final record = TaskRecord.fromApi(task);
|
||||
records[task.id] = record;
|
||||
if (task.parentId.isNotEmpty) {
|
||||
members.putIfAbsent(task.parentId, () => []).add(record);
|
||||
if (task.group != null) metadata[task.parentId] = task.group!;
|
||||
}
|
||||
}
|
||||
final emitted = <String>{};
|
||||
return [
|
||||
for (final task in tasks)
|
||||
if (task.parentId.isEmpty || !metadata.containsKey(task.parentId))
|
||||
records[task.id]!
|
||||
else if (emitted.add(task.parentId))
|
||||
_groupRecord(metadata[task.parentId]!, members[task.parentId]!),
|
||||
];
|
||||
}
|
||||
|
||||
TaskRecord _groupRecord(api_task.TaskGroup group, List<TaskRecord> members) {
|
||||
final running = members.any((task) => task.status == TaskStatus.downloading && !task.waiting);
|
||||
final failed = members.any((task) => task.status == TaskStatus.failed);
|
||||
final paused = members.isEmpty || members.any((task) => task.status == TaskStatus.paused);
|
||||
final completed = members.isNotEmpty && members.every((task) => task.status == TaskStatus.completed);
|
||||
final status = running
|
||||
? TaskStatus.downloading
|
||||
: failed
|
||||
? TaskStatus.failed
|
||||
: paused
|
||||
? TaskStatus.paused
|
||||
: completed
|
||||
? TaskStatus.completed
|
||||
: TaskStatus.downloading;
|
||||
final downloaded = members.fold(0, (total, task) => total + (task.downloadedBytes ?? 0));
|
||||
final total = members.every((task) => task.totalBytes != null || task.status == TaskStatus.completed)
|
||||
? members.fold(0, (total, task) => total + (task.totalBytes ?? 0))
|
||||
: null;
|
||||
final speed = members.fold(
|
||||
0,
|
||||
(total, task) => total + (task.status == TaskStatus.downloading && !task.waiting ? task.speedBytes ?? 0 : 0),
|
||||
);
|
||||
// The backend freezes a finished single task's speed at bytes over active
|
||||
// time (markTaskDone); a finished group reports the same ratio across its
|
||||
// members instead of collapsing to "0 B/s" when the last member completes.
|
||||
final reportedSpeed = speed > 0 ? speed : (completed ? _averageSpeed(members) ?? 0 : 0);
|
||||
final uploadSpeed = members.fold(0, (total, task) => total + (task.uploading ? task.uploadSpeedBytes ?? 0 : 0));
|
||||
final uploading = members.any((task) => task.uploading);
|
||||
final uploaded = members.fold(0, (total, task) => total + (task.uploadedBytes ?? 0));
|
||||
final pathContext = path.Context(
|
||||
style: group.path.contains('\\') || members.any((task) => task.storagePath.contains('\\'))
|
||||
? path.Style.windows
|
||||
: path.Style.posix,
|
||||
);
|
||||
final root = group.path;
|
||||
final files = <TaskFileNode>[];
|
||||
for (final member in members) {
|
||||
final sourceFiles = member.isFolder
|
||||
? member.files
|
||||
: [TaskFileNode(path: '', name: member.name, sizeBytes: member.totalBytes ?? 0)];
|
||||
// Folder members do not carry per-file progress from the backend; spread
|
||||
// the member's overall ratio over its files so the tree keeps moving
|
||||
// instead of showing flat zeros.
|
||||
final folderTotal = member.isFolder ? member.files.fold<int>(0, (total, file) => total + file.sizeBytes) : 0;
|
||||
final folderRatio = folderTotal > 0 && member.downloadedBytes != null
|
||||
? (member.downloadedBytes! / folderTotal).clamp(0.0, 1.0)
|
||||
: null;
|
||||
for (final file in sourceFiles) {
|
||||
final fullPath = member.isFolder
|
||||
? pathContext.join(member.storagePath, file.path, file.name)
|
||||
: member.storagePath;
|
||||
final relative = pathContext.relative(fullPath, from: root).replaceAll('\\', '/');
|
||||
final String nodePath;
|
||||
final String nodeName;
|
||||
if (pathContext.isAbsolute(relative) || relative == '..' || relative.startsWith('../')) {
|
||||
// Keep unexpected stored paths within the displayed tree.
|
||||
if (member.isFolder) {
|
||||
nodePath = path.posix.join(pathContext.basename(member.storagePath), file.path.replaceAll('\\', '/'));
|
||||
nodeName = file.name;
|
||||
} else {
|
||||
nodePath = '';
|
||||
nodeName = member.name;
|
||||
}
|
||||
} else {
|
||||
nodePath = path.posix.dirname(relative) == '.' ? '' : path.posix.dirname(relative);
|
||||
nodeName = path.posix.basename(relative);
|
||||
}
|
||||
files.add(
|
||||
TaskFileNode(
|
||||
resourceIndex: files.length,
|
||||
path: nodePath,
|
||||
name: nodeName,
|
||||
sizeBytes: file.sizeBytes,
|
||||
downloadedBytes: member.isFolder
|
||||
? folderRatio != null && file.sizeBytes > 0
|
||||
? (file.sizeBytes * folderRatio).round()
|
||||
: null
|
||||
: member.downloadedBytes,
|
||||
status: member.status,
|
||||
taskId: member.id,
|
||||
localPath: fullPath,
|
||||
accessPath: member.isFolder
|
||||
? path.posix.join(file.path.replaceAll('\\', '/'), file.name)
|
||||
: pathContext.relative(fullPath, from: pathContext.dirname(member.storagePath)).replaceAll('\\', '/'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
var updatedAt = group.createdAt;
|
||||
DateTime? doneAt;
|
||||
for (final member in members) {
|
||||
final update = member.updatedAt;
|
||||
if (update != null && update.isAfter(updatedAt)) updatedAt = update;
|
||||
if (completed) {
|
||||
final done = member.doneAt;
|
||||
if (done != null && (doneAt == null || done.isAfter(doneAt))) doneAt = done;
|
||||
}
|
||||
}
|
||||
// The folder card answers "how long did this download take end to end", so
|
||||
// a completed group reports its wall-clock span; single tasks keep their
|
||||
// active-time semantics.
|
||||
final createdAt = group.createdAt;
|
||||
final downloadDuration = doneAt != null && doneAt.isAfter(createdAt) ? doneAt.difference(createdAt) : null;
|
||||
return TaskRecord(
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
status: status,
|
||||
downloaded: _formatBytes(downloaded),
|
||||
total: total == null ? null : _formatBytes(total),
|
||||
downloadedBytes: downloaded,
|
||||
totalBytes: total,
|
||||
speed: TransferRateFormatter.format(reportedSpeed).text,
|
||||
speedBytes: reportedSpeed,
|
||||
uploadSpeed: uploading ? TransferRateFormatter.format(uploadSpeed).text : null,
|
||||
uploadedBytes: uploaded,
|
||||
uploadSpeedBytes: uploadSpeed,
|
||||
uploading: uploading,
|
||||
progress: completed
|
||||
? 1
|
||||
: total != null && total > 0
|
||||
? (downloaded / total).clamp(0.0, 1.0)
|
||||
: null,
|
||||
remainingSeconds: total != null && speed > 0 && total > downloaded ? ((total - downloaded) / speed).ceil() : null,
|
||||
waiting: status == TaskStatus.downloading && !running,
|
||||
downloadDuration: downloadDuration,
|
||||
url: '',
|
||||
storagePath: root,
|
||||
files: files,
|
||||
isFolder: true,
|
||||
isGroup: true,
|
||||
members: members,
|
||||
createdAt: group.createdAt,
|
||||
updatedAt: updatedAt,
|
||||
doneAt: doneAt,
|
||||
);
|
||||
}
|
||||
|
||||
// The combined average rate of a set of members: downloaded bytes over active
|
||||
// download time, the same ratio the backend freezes into a finished single
|
||||
// task's progress. Members without a measurable duration are skipped because
|
||||
// they would inflate the rate.
|
||||
int? _averageSpeed(List<TaskRecord> members) {
|
||||
var downloaded = 0;
|
||||
var microseconds = 0;
|
||||
for (final member in members) {
|
||||
final duration = member.downloadDuration;
|
||||
if (duration == null || duration.inMicroseconds <= 0) {
|
||||
continue;
|
||||
}
|
||||
downloaded += member.downloadedBytes ?? 0;
|
||||
microseconds += duration.inMicroseconds;
|
||||
}
|
||||
if (microseconds <= 0) {
|
||||
return null;
|
||||
}
|
||||
return (downloaded * Duration.microsecondsPerSecond / microseconds).round();
|
||||
}
|
||||
|
||||
Map<String, String> _requestHeaders(Object? extra) {
|
||||
if (extra is! Map) return const {};
|
||||
final header = extra['header'];
|
||||
|
||||
@@ -19,8 +19,6 @@ import '../../../../api/model/create_task_batch.dart';
|
||||
import '../../../../api/model/downloader_config.dart';
|
||||
import '../../../../api/model/options.dart' as api_options;
|
||||
import '../../../../api/model/request.dart';
|
||||
import '../../../../api/model/resolve_result.dart';
|
||||
import '../../../../api/model/resolve_task.dart';
|
||||
import '../../../../core/capabilities/app_capabilities.dart';
|
||||
import '../../../../core/utils/breakpoints.dart';
|
||||
import '../../../../core/utils/text_wrap.dart';
|
||||
@@ -39,10 +37,11 @@ import '../../../../shared/widgets/app_toast.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../util/util.dart';
|
||||
import '../../application/pending_create_task.dart';
|
||||
import '../../application/pending_download.dart';
|
||||
import '../../application/task_list_navigation.dart';
|
||||
import '../widgets/history_search_field.dart';
|
||||
import '../widgets/create_task_action_buttons.dart';
|
||||
import '../widgets/resolve_file_tree.dart';
|
||||
import '../widgets/resolve_download_dialog.dart';
|
||||
|
||||
class CreateTaskWindowPage extends ConsumerStatefulWidget {
|
||||
const CreateTaskWindowPage({super.key, this.windowController, this.initialTask});
|
||||
@@ -92,6 +91,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
bool _directDownload = false;
|
||||
int _protocolTab = 0;
|
||||
bool _creating = false;
|
||||
final Map<String, int> _completedSubmissionCounts = {};
|
||||
int _createSession = 0;
|
||||
bool _draggingTorrent = false;
|
||||
bool _asDefaultPath = false;
|
||||
String _configuredDownloadDirectory = '';
|
||||
@@ -248,6 +249,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
if (task == null || !mounted) return;
|
||||
final req = task.req;
|
||||
final opts = task.opts ?? api_options.Options();
|
||||
_createSession++;
|
||||
_completedSubmissionCounts.clear();
|
||||
setState(() {
|
||||
_initialSize = 0;
|
||||
_checksumAlgorithm = 'md5';
|
||||
@@ -871,7 +874,20 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
await windowManager.close();
|
||||
}
|
||||
|
||||
Future<void> _finishCreation() async {
|
||||
Future<void> _finishCreation(int session) async {
|
||||
if (!mounted || session != _createSession) return;
|
||||
if (_asDefaultPath && _directoryManuallySet && _directoryController.text.trim().isNotEmpty) {
|
||||
try {
|
||||
final service = ref.read(gopeedServiceProvider);
|
||||
final config = await service.getConfig();
|
||||
if (!mounted || session != _createSession) return;
|
||||
config.downloadDir = _directoryController.text.trim();
|
||||
await service.putConfig(config);
|
||||
} catch (error) {
|
||||
if (mounted && session == _createSession) _showToast(error.toString());
|
||||
}
|
||||
}
|
||||
if (!mounted || session != _createSession) return;
|
||||
if (widget.windowController != null) {
|
||||
// Navigation is best-effort after a successful submission. A navigation
|
||||
// failure must not leave a completed form available for resubmission.
|
||||
@@ -883,6 +899,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
} else {
|
||||
ref.read(taskListNavigationProvider.notifier).showDownloading();
|
||||
}
|
||||
if (!mounted || session != _createSession) return;
|
||||
await _closeWindow();
|
||||
}
|
||||
|
||||
@@ -989,53 +1006,74 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
_showToast(context.l10n.downloadLinkValid);
|
||||
return;
|
||||
}
|
||||
if (!_validateAdvancedOptions()) {
|
||||
final occurrences = <String, int>{};
|
||||
final pendingUrls = <String>[];
|
||||
for (final url in urls) {
|
||||
final occurrence = (occurrences[url] ?? 0) + 1;
|
||||
occurrences[url] = occurrence;
|
||||
if (occurrence > (_completedSubmissionCounts[url] ?? 0)) {
|
||||
pendingUrls.add(url);
|
||||
}
|
||||
}
|
||||
if (pendingUrls.isNotEmpty && !_validateAdvancedOptions()) {
|
||||
return;
|
||||
}
|
||||
final session = _createSession;
|
||||
|
||||
setState(() {
|
||||
_creating = true;
|
||||
_draggingTorrent = false;
|
||||
});
|
||||
try {
|
||||
await _saveCreateHistory(urls);
|
||||
final isDirect = _directDownload || urls.length > 1;
|
||||
if (isDirect) {
|
||||
await Future.wait(
|
||||
urls.map((url) {
|
||||
return ref
|
||||
.read(gopeedServiceProvider)
|
||||
.createTask(
|
||||
CreateTask(
|
||||
req: _buildRequest(url, protocolUrl: _protocolUrlFor(url)),
|
||||
opts: _buildOptions(
|
||||
url: _protocolUrlFor(url),
|
||||
name: urls.length > 1 ? '' : _renameController.text.trim(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
unawaited(
|
||||
_saveCreateHistory(urls).catchError((Object error) {
|
||||
if (mounted && session == _createSession) _showToast(error.toString());
|
||||
}),
|
||||
);
|
||||
if (pendingUrls.isEmpty) {
|
||||
await _finishCreation(session);
|
||||
return;
|
||||
}
|
||||
final sources = [
|
||||
for (final entry in pendingUrls.indexed)
|
||||
PendingDownload(
|
||||
id: 'source-${entry.$1}',
|
||||
request: _buildRequest(entry.$2, protocolUrl: _protocolUrlFor(entry.$2)),
|
||||
options: _buildOptions(
|
||||
url: _protocolUrlFor(entry.$2),
|
||||
name: urls.length == 1 ? _renameController.text.trim() : '',
|
||||
omitChecksum: urls.length > 1,
|
||||
),
|
||||
),
|
||||
];
|
||||
final service = ref.read(gopeedServiceProvider);
|
||||
if (_directDownload) {
|
||||
await service.createTaskBatch(
|
||||
CreateTaskBatch(
|
||||
reqs: [for (final source in sources) CreateTaskBatchItem(req: source.request, opts: source.options)],
|
||||
),
|
||||
);
|
||||
await _finishCreation();
|
||||
await _finishCreation(session);
|
||||
return;
|
||||
}
|
||||
|
||||
final request = _buildRequest(urls.first, protocolUrl: _protocolUrlFor(urls.first));
|
||||
final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request));
|
||||
if (!mounted) return;
|
||||
final created = await _showResolveDialog(request, result);
|
||||
if (!created) {
|
||||
if (mounted) {
|
||||
setState(() => _creating = false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await _finishCreation();
|
||||
final created = await showResolveDownloadsDialog(
|
||||
context,
|
||||
sources: sources,
|
||||
service: service,
|
||||
onBatchCreated: (sources) {
|
||||
if (!mounted || session != _createSession) return;
|
||||
for (final source in sources) {
|
||||
_completedSubmissionCounts.update(source.request.url, (count) => count + 1, ifAbsent: () => 1);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!mounted || session != _createSession || !created) return;
|
||||
await _finishCreation(session);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
_showToast(error.toString());
|
||||
setState(() => _creating = false);
|
||||
}
|
||||
if (mounted && session == _createSession) _showToast(error.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _creating = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1298,190 +1336,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
|
||||
_applyCategoryHighlight();
|
||||
}
|
||||
|
||||
Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async {
|
||||
final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList())
|
||||
..sort();
|
||||
final isMultiFile = selected.length > 1;
|
||||
if (result.id.isNotEmpty) {
|
||||
await ref
|
||||
.read(gopeedServiceProvider)
|
||||
.createTask(
|
||||
CreateTask(
|
||||
rid: result.id,
|
||||
opts: _buildOptions(url: request.url, selectFiles: selected, omitChecksum: isMultiFile),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.res.files.isNotEmpty) {
|
||||
// The extension returned a file list, so the resource keeps a directory
|
||||
// structure. The split tasks must not be classified into a category, the
|
||||
// structure of the resolved resource is kept. The decision is based on
|
||||
// the resolved resource, not on the number of selected files.
|
||||
final keepStructure = _directoryManuallySet || result.res.name.isNotEmpty || result.res.files.length > 1;
|
||||
final reqs = selected.map((index) {
|
||||
final file = result.res.files[index];
|
||||
final fileRequest = file.req ?? request;
|
||||
return CreateTaskBatchItem(
|
||||
req: fileRequest,
|
||||
opts: api_options.Options(
|
||||
size: _parseProtocol(fileRequest.url) == _TaskProtocol.http ? file.size : 0,
|
||||
name: file.name,
|
||||
path: path.join(_directoryController.text.trim(), result.res.name, file.path),
|
||||
skipAutoCategorize: keepStructure,
|
||||
extra: _buildOptions(url: fileRequest.url, omitChecksum: isMultiFile).extra,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
await ref
|
||||
.read(gopeedServiceProvider)
|
||||
.createTaskBatch(
|
||||
CreateTaskBatch(
|
||||
reqs: reqs,
|
||||
opts: _buildOptions(url: request.url, omitChecksum: isMultiFile, skipAutoCategorize: keepStructure),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(gopeedServiceProvider)
|
||||
.createTask(
|
||||
CreateTask(
|
||||
req: request,
|
||||
opts: _buildOptions(url: request.url),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _showResolveDialog(Request request, ResolveResult result) async {
|
||||
final initialSelection = List<int>.generate(result.res.files.length, (index) => index);
|
||||
var selectedIndexes = List<int>.of(initialSelection);
|
||||
var submitting = false;
|
||||
String? errorText;
|
||||
// The directory the task will be saved to. A directory the user named
|
||||
// wins over the category suggestion of the resolve response, the backend
|
||||
// keeps it and does not classify the task.
|
||||
final effectiveDownloadDir = _directoryManuallySet
|
||||
? _directoryController.text.trim()
|
||||
: (result.suggestedDownloadDir.isNotEmpty ? result.suggestedDownloadDir : _directoryController.text.trim());
|
||||
|
||||
final dialog = const DialogOverlayHandler().show<bool>(
|
||||
context: context,
|
||||
alignment: Alignment.center,
|
||||
barrierDismissable: !submitting,
|
||||
builder: (dialogContext) {
|
||||
final palette = AppPalette.of(dialogContext);
|
||||
final screenSize = MediaQuery.sizeOf(dialogContext);
|
||||
final dialogWidth = screenSize.width < 760 ? screenSize.width - 32 : 720.0;
|
||||
final treeHeight = screenSize.height < 700 ? screenSize.height * 0.55 : 440.0;
|
||||
|
||||
return StatefulBuilder(
|
||||
builder: (dialogContext, setDialogState) {
|
||||
Future<void> submit() async {
|
||||
if (result.res.files.isNotEmpty && selectedIndexes.isEmpty) {
|
||||
setDialogState(() => errorText = dialogContext.l10n.selectAtLeastOneFile);
|
||||
return;
|
||||
}
|
||||
setDialogState(() {
|
||||
submitting = true;
|
||||
errorText = null;
|
||||
});
|
||||
try {
|
||||
await _submitResolved(request, result, selectedIndexes);
|
||||
if (dialogContext.mounted) {
|
||||
await closeOverlay(dialogContext, true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!dialogContext.mounted) return;
|
||||
setDialogState(() {
|
||||
submitting = false;
|
||||
errorText = error.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
padding: const EdgeInsets.all(18),
|
||||
title: Text(
|
||||
result.res.name.trim().isEmpty ? dialogContext.l10n.resolvedFiles : result.res.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
content: SizedBox(
|
||||
width: dialogWidth,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
result.res.files.isEmpty
|
||||
? dialogContext.l10n.resourceHasNoFiles
|
||||
: dialogContext.l10n.resolvedFileCount(result.res.files.length),
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 12),
|
||||
),
|
||||
if (effectiveDownloadDir.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
dialogContext.l10n.resolvedCategoryDirectory(effectiveDownloadDir),
|
||||
key: const ValueKey('resolve-category-directory'),
|
||||
style: TextStyle(color: palette.textSecondary, fontSize: 12),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: treeHeight.clamp(260.0, 460.0),
|
||||
child: result.res.files.isEmpty
|
||||
? Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: palette.cardBg,
|
||||
border: Border.all(color: palette.border),
|
||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
||||
),
|
||||
child: Text(
|
||||
dialogContext.l10n.noFiles,
|
||||
style: TextStyle(color: palette.textMuted, fontSize: 13),
|
||||
),
|
||||
)
|
||||
: ResolveFileTree(
|
||||
files: result.res.files,
|
||||
initialSelection: initialSelection,
|
||||
onSelectionChanged: (values) => selectedIndexes = values,
|
||||
),
|
||||
),
|
||||
if (errorText != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(errorText!, style: TextStyle(color: palette.error, fontSize: 12)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
flutter.Flexible(
|
||||
child: CreateTaskActionButtons(
|
||||
submitting: submitting,
|
||||
onCancel: () => closeOverlay(dialogContext, false),
|
||||
onSubmit: submit,
|
||||
cancelLabel: dialogContext.l10n.cancel,
|
||||
submitLabel: dialogContext.l10n.createAction,
|
||||
cancelButtonKey: const ValueKey('resolve-cancel-button'),
|
||||
submitButtonKey: const ValueKey('resolve-create-button'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
final created = await dialog.future;
|
||||
return created == true;
|
||||
}
|
||||
|
||||
Future<void> _showCreateHistory() async {
|
||||
List<String> histories;
|
||||
try {
|
||||
|
||||
@@ -21,13 +21,8 @@ class TaskDetailsPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tasksAsync = ref.watch(tasksControllerProvider);
|
||||
TaskRecord? task = initialTask;
|
||||
for (final item in tasksAsync.value?.tasks ?? const <TaskRecord>[]) {
|
||||
if (item.id == taskId) {
|
||||
task = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final state = tasksAsync.value;
|
||||
final task = state == null ? initialTask : state.findTask(taskId);
|
||||
final palette = AppPalette.of(context);
|
||||
|
||||
return AppDetailPage(
|
||||
@@ -43,12 +38,12 @@ class TaskDetailsPage extends ConsumerWidget {
|
||||
key: ValueKey(task.id),
|
||||
task: task,
|
||||
mobile: true,
|
||||
onOpenStorage: () => FileExplorer.reveal(task!.storagePath),
|
||||
onOpenStorage: () => FileExplorer.reveal(task.storagePath),
|
||||
onUpdateUrl: (url) async {
|
||||
try {
|
||||
await ref
|
||||
.read(tasksControllerProvider.notifier)
|
||||
.updateUrl(task!.id, url, headers: task.requestHeaders);
|
||||
.updateUrl(task.id, url, headers: task.requestHeaders);
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
showAppToast(context, error.toString(), type: AppToastType.error);
|
||||
|
||||
@@ -20,13 +20,8 @@ class TaskFilesPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tasksAsync = ref.watch(tasksControllerProvider);
|
||||
TaskRecord? task = initialTask;
|
||||
for (final item in tasksAsync.value?.tasks ?? const <TaskRecord>[]) {
|
||||
if (item.id == taskId) {
|
||||
task = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final state = tasksAsync.value;
|
||||
final task = state == null ? initialTask : state.findTask(taskId);
|
||||
final palette = AppPalette.of(context);
|
||||
|
||||
return shad.Scaffold(
|
||||
|
||||
@@ -18,7 +18,7 @@ class CreateTaskActionButtons extends StatelessWidget {
|
||||
|
||||
final bool submitting;
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback onSubmit;
|
||||
final VoidCallback? onSubmit;
|
||||
final String cancelLabel;
|
||||
final String submitLabel;
|
||||
final Key? cancelButtonKey;
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:flutter/widgets.dart' as flutter show Flexible;
|
||||
|
||||
import '../../../../core/capabilities/gopeed_capability.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/app_tooltip.dart';
|
||||
import '../../application/pending_download.dart';
|
||||
import 'create_task_action_buttons.dart';
|
||||
import 'resolve_file_tree.dart';
|
||||
|
||||
Future<bool> showResolveDownloadsDialog(
|
||||
BuildContext context, {
|
||||
required List<PendingDownload> sources,
|
||||
required GopeedService service,
|
||||
ValueChanged<List<PendingDownload>>? onBatchCreated,
|
||||
}) async {
|
||||
final dialog = const DialogOverlayHandler().show<bool>(
|
||||
context: context,
|
||||
alignment: Alignment.center,
|
||||
barrierDismissable: false,
|
||||
builder: (_) => _ResolveDownloadsDialog(sources: sources, service: service, onBatchCreated: onBatchCreated),
|
||||
);
|
||||
return await dialog.future == true;
|
||||
}
|
||||
|
||||
class _ResolveDownloadsDialog extends StatefulWidget {
|
||||
const _ResolveDownloadsDialog({required this.sources, required this.service, required this.onBatchCreated});
|
||||
|
||||
final List<PendingDownload> sources;
|
||||
final GopeedService service;
|
||||
final ValueChanged<List<PendingDownload>>? onBatchCreated;
|
||||
|
||||
@override
|
||||
State<_ResolveDownloadsDialog> createState() => _ResolveDownloadsDialogState();
|
||||
}
|
||||
|
||||
class _ResolveDownloadsDialogState extends State<_ResolveDownloadsDialog> {
|
||||
bool _cancelled = false;
|
||||
bool _submitting = false;
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _resolveAll());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cancelled = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _resolveAll() async {
|
||||
for (final source in widget.sources) {
|
||||
if (_cancelled || !mounted) return;
|
||||
if (source.result != null || source.resolveError != null) continue;
|
||||
final resolving = source.resolve(widget.service);
|
||||
setState(() {});
|
||||
await resolving;
|
||||
if (_cancelled || !mounted) return;
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _cancel() {
|
||||
_cancelled = true;
|
||||
closeOverlay(context, false);
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_submitting || widget.sources.any((source) => source.loading)) return;
|
||||
final batches = planDownloads(widget.sources);
|
||||
if (batches.isEmpty) return;
|
||||
setState(() {
|
||||
_submitting = true;
|
||||
_errorText = null;
|
||||
for (final source in widget.sources) {
|
||||
source.createError = null;
|
||||
}
|
||||
});
|
||||
for (final batch in batches) {
|
||||
await batch.submit(widget.service);
|
||||
if (!mounted) return;
|
||||
if (batch.sources.every((source) => source.created)) {
|
||||
widget.onBatchCreated?.call(batch.sources);
|
||||
}
|
||||
}
|
||||
final errors = widget.sources.map((source) => source.createError).whereType<String>().toSet();
|
||||
if (errors.isEmpty) {
|
||||
await closeOverlay(context, true);
|
||||
} else {
|
||||
setState(() {
|
||||
_submitting = false;
|
||||
_errorText = errors.join('\n');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screen = MediaQuery.sizeOf(context);
|
||||
final canCreate =
|
||||
!_submitting &&
|
||||
!widget.sources.any((source) => source.loading) &&
|
||||
widget.sources.any((source) => source.hasSelection);
|
||||
return AlertDialog(
|
||||
padding: const EdgeInsets.all(18),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(context.l10n.resolvedFiles)),
|
||||
AppTooltip(
|
||||
message: context.l10n.close,
|
||||
child: GhostButton(
|
||||
key: const ValueKey('resolve-close-button'),
|
||||
onPressed: _submitting ? null : _cancel,
|
||||
child: const Icon(Icons.close, size: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: screen.width < 760 ? screen.width - 68 : 720,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: (screen.height * 0.48).clamp(120.0, 440.0),
|
||||
child: IgnorePointer(
|
||||
ignoring: _submitting,
|
||||
child: ResolveFileTree(
|
||||
sources: List.of(widget.sources),
|
||||
onSelectionChanged: (selection) {
|
||||
final selectedBySource = <String, List<int>>{};
|
||||
for (final (sourceId, index) in selection) {
|
||||
selectedBySource.putIfAbsent(sourceId, () => []).add(index);
|
||||
}
|
||||
setState(() {
|
||||
for (final source in widget.sources) {
|
||||
if (source.result == null || source.created) continue;
|
||||
source.selected
|
||||
..clear()
|
||||
..addAll(selectedBySource[source.id] ?? const []);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_errorText != null)
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: screen.height * 0.15),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(_errorText!, style: TextStyle(color: AppPalette.of(context).error, fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
flutter.Flexible(
|
||||
child: CreateTaskActionButtons(
|
||||
submitting: _submitting,
|
||||
onCancel: _cancel,
|
||||
onSubmit: canCreate ? _submit : null,
|
||||
cancelLabel: context.l10n.cancel,
|
||||
submitLabel: context.l10n.createAction,
|
||||
cancelButtonKey: const ValueKey('resolve-cancel-button'),
|
||||
submitButtonKey: const ValueKey('resolve-create-button'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,99 +2,150 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row;
|
||||
|
||||
import '../../../../api/model/resource.dart';
|
||||
import '../../../../core/icons/gopeed_icons.dart';
|
||||
import '../../../../shared/widgets/app_tooltip.dart';
|
||||
import '../../application/pending_download.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/widgets/file_tree_view.dart';
|
||||
import '../../../../util/util.dart';
|
||||
|
||||
class ResolveFileTree extends StatefulWidget {
|
||||
const ResolveFileTree({
|
||||
super.key,
|
||||
required this.files,
|
||||
required this.initialSelection,
|
||||
required this.onSelectionChanged,
|
||||
});
|
||||
typedef PendingFileKey = (String, int);
|
||||
|
||||
final List<FileInfo> files;
|
||||
final List<int> initialSelection;
|
||||
final ValueChanged<List<int>> onSelectionChanged;
|
||||
class ResolveFileTree extends StatefulWidget {
|
||||
const ResolveFileTree({super.key, required this.sources, required this.onSelectionChanged});
|
||||
|
||||
final List<PendingDownload> sources;
|
||||
final ValueChanged<Set<PendingFileKey>> onSelectionChanged;
|
||||
|
||||
@override
|
||||
State<ResolveFileTree> createState() => _ResolveFileTreeState();
|
||||
}
|
||||
|
||||
class _ResolveFileTreeState extends State<ResolveFileTree> {
|
||||
late List<FileTreeItem<int>> _treeItems;
|
||||
late List<int> _allFileIndexes;
|
||||
late final Set<int> _selectedIndexes;
|
||||
late List<FileTreeItem<PendingFileKey>> _treeItems;
|
||||
late Map<PendingFileKey, FileInfo> _files;
|
||||
late Map<String, String> _labels;
|
||||
late Map<String, PendingDownload> _sourcesById;
|
||||
late Map<String, PendingDownload> _sourcesByRootKey;
|
||||
final Set<PendingFileKey> _selected = {};
|
||||
final Set<_FileTypeFilter> _activeTypeFilters = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedIndexes = widget.initialSelection.where(_isValidIndex).toSet();
|
||||
_rebuildTreeItems();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySelection());
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ResolveFileTree oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (identical(widget.files, oldWidget.files)) return;
|
||||
_selectedIndexes.removeWhere((index) => !_isValidIndex(index));
|
||||
_activeTypeFilters.clear();
|
||||
final previousFiles = _files;
|
||||
_rebuildTreeItems();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySelection());
|
||||
if (previousFiles.length != _files.length || !previousFiles.keys.every(_files.containsKey)) {
|
||||
_activeTypeFilters.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void _rebuildTreeItems() {
|
||||
_files = {};
|
||||
_labels = {};
|
||||
_treeItems = [];
|
||||
_sourcesById = {for (final source in widget.sources) source.id: source};
|
||||
_sourcesByRootKey = {for (final source in widget.sources) 'folder:${source.id}': source};
|
||||
_selected.clear();
|
||||
for (final source in widget.sources) {
|
||||
if (source.result == null) {
|
||||
_labels[source.id] = source.name;
|
||||
_treeItems.add(
|
||||
FileTreeItem(
|
||||
key: source.id,
|
||||
path: source.id,
|
||||
name: source.name,
|
||||
size: 0,
|
||||
data: (source.id, -1),
|
||||
directoryOnly: true,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
final files = source.files.isEmpty ? [FileInfo(name: source.name)] : source.files;
|
||||
// A non-empty resource name is a real directory, even for one file.
|
||||
final hasRoot = source.result!.res.name.trim().isNotEmpty;
|
||||
if (hasRoot) _labels[source.id] = source.name;
|
||||
for (final entry in files.indexed) {
|
||||
final key = (source.id, entry.$1);
|
||||
final file = entry.$2;
|
||||
if (!source.created) {
|
||||
_files[key] = file;
|
||||
if (source.selected.contains(entry.$1)) _selected.add(key);
|
||||
}
|
||||
_treeItems.add(
|
||||
FileTreeItem<PendingFileKey>(
|
||||
key: '${source.id}:${entry.$1}',
|
||||
path: hasRoot ? '${source.id}/${file.path}' : file.path,
|
||||
namespace: hasRoot ? '' : source.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
data: key,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selectedCount = _selectedIndexes.length;
|
||||
final allSelected = _allFileIndexes.isNotEmpty && selectedCount == _allFileIndexes.length;
|
||||
final partiallySelected = selectedCount > 0 && !allSelected;
|
||||
final selectedSize = _selectedIndexes.fold<int>(0, (sum, index) => sum + widget.files[index].size);
|
||||
|
||||
final allSelected = _files.isNotEmpty && _selected.length == _files.length;
|
||||
final partial = _selected.isNotEmpty && !allSelected;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FileTreeView<int>(
|
||||
child: FileTreeView<PendingFileKey>(
|
||||
items: _treeItems,
|
||||
initialExpandedDepth: 1,
|
||||
directoryLabels: _labels,
|
||||
initialExpandedDepth: 2,
|
||||
keyPrefix: 'resolve-tree',
|
||||
iconWidgetBuilder: _sourceIcon,
|
||||
rowHeight: _resolveTreeRowHeight,
|
||||
contentTextStyle: _resolveFileNameStyle,
|
||||
headerLeading: ExcludeFocus(
|
||||
child: Checkbox(
|
||||
state: allSelected
|
||||
? CheckboxState.checked
|
||||
: (partiallySelected ? CheckboxState.indeterminate : CheckboxState.unchecked),
|
||||
onChanged: _allFileIndexes.isEmpty ? null : (_) => _setFileSelection(_allFileIndexes, !allSelected),
|
||||
: partial
|
||||
? CheckboxState.indeterminate
|
||||
: CheckboxState.unchecked,
|
||||
onChanged: _files.isEmpty ? null : (_) => _setSelection(_files.keys, !allSelected),
|
||||
),
|
||||
),
|
||||
leadingBuilder: (context, node) {
|
||||
final indexes = node.leafItems.map((item) => item.data).toList(growable: false);
|
||||
final nodeSelectedCount = indexes.where(_selectedIndexes.contains).length;
|
||||
final selected = indexes.isNotEmpty && nodeSelectedCount == indexes.length;
|
||||
final partial = nodeSelectedCount > 0 && !selected;
|
||||
final keys = node.leafItems.map((item) => item.data).where(_files.containsKey).toList();
|
||||
final count = keys.where(_selected.contains).length;
|
||||
return ExcludeFocus(
|
||||
child: Checkbox(
|
||||
state: selected
|
||||
state: keys.isNotEmpty && count == keys.length
|
||||
? CheckboxState.checked
|
||||
: (partial ? CheckboxState.indeterminate : CheckboxState.unchecked),
|
||||
onChanged: (_) => _setFileSelection(indexes, !selected),
|
||||
: count > 0
|
||||
? CheckboxState.indeterminate
|
||||
: CheckboxState.unchecked,
|
||||
onChanged: keys.isEmpty ? null : (_) => _setSelection(keys, count != keys.length),
|
||||
),
|
||||
);
|
||||
},
|
||||
onNodePressed: _toggleNodeSelection,
|
||||
onNodePressed: (node) {
|
||||
final keys = node.leafItems.map((item) => item.data).where(_files.containsKey).toList();
|
||||
if (keys.isNotEmpty) _setSelection(keys, !keys.every(_selected.contains));
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_TreeFooter(
|
||||
selectedCount: selectedCount,
|
||||
totalCount: _allFileIndexes.length,
|
||||
selectedSize: selectedSize,
|
||||
unknownSize: selectedCount > 0 && selectedSize == 0,
|
||||
selectedCount: _selected.length,
|
||||
totalCount: _files.length,
|
||||
selectedSize: _selected.fold(0, (sum, key) => sum + _files[key]!.size),
|
||||
unknownSize: _selected.any((key) => _files[key]!.size <= 0),
|
||||
activeTypeFilters: _activeTypeFilters,
|
||||
onSelectVideo: () => _toggleTypeFilter(_FileTypeFilter.video),
|
||||
onSelectAudio: () => _toggleTypeFilter(_FileTypeFilter.audio),
|
||||
@@ -104,65 +155,63 @@ class _ResolveFileTreeState extends State<ResolveFileTree> {
|
||||
);
|
||||
}
|
||||
|
||||
bool _isValidIndex(int index) => index >= 0 && index < widget.files.length;
|
||||
|
||||
void _rebuildTreeItems() {
|
||||
_allFileIndexes = List<int>.generate(widget.files.length, (index) => index, growable: false);
|
||||
_treeItems = widget.files
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
(entry) => FileTreeItem<int>(
|
||||
key: entry.key.toString(),
|
||||
path: entry.value.path,
|
||||
name: entry.value.name,
|
||||
size: entry.value.size,
|
||||
data: entry.key,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
Widget _sourceIcon(BuildContext context, FileTreeNode<PendingFileKey> node) {
|
||||
final source = node.item == null ? _sourcesByRootKey[node.key] : _sourcesById[node.item!.data.$1];
|
||||
final palette = AppPalette.of(context);
|
||||
if (source == null) {
|
||||
return Icon(node.isFolder ? BootstrapIcons.folder2 : GopeedIcons.file, size: 18, color: palette.textSecondary);
|
||||
}
|
||||
final pending = source;
|
||||
final message = pending.loading
|
||||
? (pending.resolving ? context.l10n.resolvingResource : context.l10n.waitingToResolve)
|
||||
: pending.resolveError ?? pending.request.url;
|
||||
return AppTooltip(
|
||||
focusable: true,
|
||||
message: message,
|
||||
child: Semantics(
|
||||
label: message,
|
||||
child: pending.loading
|
||||
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator())
|
||||
: Icon(
|
||||
pending.resolveError != null
|
||||
? Icons.error_outline
|
||||
: node.isFolder && pending.isBt
|
||||
? GopeedIcons.folderBt
|
||||
: node.isFolder
|
||||
? BootstrapIcons.folder2
|
||||
: GopeedIcons.file,
|
||||
size: 18,
|
||||
color: pending.resolveError != null ? palette.error : palette.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _setFileSelection(Iterable<int> indexes, bool selected) {
|
||||
void _setSelection(Iterable<PendingFileKey> keys, bool selected) {
|
||||
setState(() {
|
||||
_activeTypeFilters.clear();
|
||||
if (selected) {
|
||||
_selectedIndexes.addAll(indexes);
|
||||
_selected.addAll(keys);
|
||||
} else {
|
||||
_selectedIndexes.removeAll(indexes);
|
||||
_selected.removeAll(keys);
|
||||
}
|
||||
});
|
||||
_notifySelection();
|
||||
}
|
||||
|
||||
void _toggleNodeSelection(FileTreeNode<int> node) {
|
||||
final indexes = node.leafItems.map((item) => item.data).toList(growable: false);
|
||||
final selected = indexes.isNotEmpty && indexes.every(_selectedIndexes.contains);
|
||||
_setFileSelection(indexes, !selected);
|
||||
widget.onSelectionChanged(Set.of(_selected));
|
||||
}
|
||||
|
||||
void _toggleTypeFilter(_FileTypeFilter filter) {
|
||||
setState(() {
|
||||
if (!_activeTypeFilters.remove(filter)) {
|
||||
_activeTypeFilters.add(filter);
|
||||
}
|
||||
final extensions = <String>{for (final activeFilter in _activeTypeFilters) ...activeFilter.extensions};
|
||||
_selectedIndexes
|
||||
if (!_activeTypeFilters.remove(filter)) _activeTypeFilters.add(filter);
|
||||
final extensions = {for (final active in _activeTypeFilters) ...active.extensions};
|
||||
_selected
|
||||
..clear()
|
||||
..addAll(
|
||||
widget.files
|
||||
.asMap()
|
||||
.entries
|
||||
_files.entries
|
||||
.where((entry) => extensions.contains(_extensionOf(entry.value.name)))
|
||||
.map((entry) => entry.key),
|
||||
);
|
||||
});
|
||||
_notifySelection();
|
||||
}
|
||||
|
||||
void _notifySelection() {
|
||||
final values = _selectedIndexes.toList()..sort();
|
||||
widget.onSelectionChanged(values);
|
||||
widget.onSelectionChanged(Set.of(_selected));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../../../core/utils/breakpoints.dart';
|
||||
import '../../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../domain/task_record.dart';
|
||||
import '../task_context_menu.dart';
|
||||
import 'task_card_desktop.dart';
|
||||
@@ -10,6 +11,8 @@ class TaskCard extends StatelessWidget {
|
||||
const TaskCard({
|
||||
super.key,
|
||||
required this.task,
|
||||
this.expanded = false,
|
||||
this.nested = false,
|
||||
required this.selected,
|
||||
required this.batchMode,
|
||||
required this.selectedInBatch,
|
||||
@@ -25,6 +28,8 @@ class TaskCard extends StatelessWidget {
|
||||
});
|
||||
|
||||
final TaskRecord task;
|
||||
final bool expanded;
|
||||
final bool nested;
|
||||
final bool selected;
|
||||
final bool batchMode;
|
||||
final bool selectedInBatch;
|
||||
@@ -42,40 +47,44 @@ class TaskCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = MediaQuery.sizeOf(context).width < Breakpoints.mobile;
|
||||
|
||||
if (isMobile) {
|
||||
final card = TaskCardMobile(
|
||||
task: task,
|
||||
selected: selected,
|
||||
batchMode: batchMode,
|
||||
selectedInBatch: selectedInBatch,
|
||||
onPressed: onPressed,
|
||||
onToggleBatch: onToggleBatch,
|
||||
onPause: onPause,
|
||||
onResume: onResume,
|
||||
onDelete: onDelete,
|
||||
onOpen: onOpen,
|
||||
onReveal: onReveal,
|
||||
listeningForUpdate: listeningForUpdate,
|
||||
);
|
||||
final actions = contextActions;
|
||||
return actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card);
|
||||
}
|
||||
|
||||
final card = TaskCardDesktop(
|
||||
task: task,
|
||||
selected: selected,
|
||||
batchMode: batchMode,
|
||||
selectedInBatch: selectedInBatch,
|
||||
onPressed: onPressed,
|
||||
onToggleBatch: onToggleBatch,
|
||||
onPause: onPause,
|
||||
onResume: onResume,
|
||||
onDelete: onDelete,
|
||||
onOpen: onOpen,
|
||||
onReveal: onReveal,
|
||||
listeningForUpdate: listeningForUpdate,
|
||||
);
|
||||
final card = isMobile
|
||||
? TaskCardMobile(
|
||||
task: task,
|
||||
expanded: expanded,
|
||||
selected: selected,
|
||||
batchMode: batchMode,
|
||||
selectedInBatch: selectedInBatch,
|
||||
onPressed: onPressed,
|
||||
onToggleBatch: onToggleBatch,
|
||||
onPause: task.canPause ? onPause : null,
|
||||
onResume: task.canResume ? onResume : null,
|
||||
onDelete: onDelete,
|
||||
onOpen: onOpen,
|
||||
onReveal: onReveal,
|
||||
listeningForUpdate: listeningForUpdate,
|
||||
)
|
||||
: TaskCardDesktop(
|
||||
task: task,
|
||||
expanded: expanded,
|
||||
selected: selected,
|
||||
batchMode: batchMode,
|
||||
selectedInBatch: selectedInBatch,
|
||||
onPressed: onPressed,
|
||||
onToggleBatch: onToggleBatch,
|
||||
onPause: task.canPause ? onPause : null,
|
||||
onResume: task.canResume ? onResume : null,
|
||||
onDelete: onDelete,
|
||||
onOpen: onOpen,
|
||||
onReveal: onReveal,
|
||||
listeningForUpdate: listeningForUpdate,
|
||||
);
|
||||
final actions = contextActions;
|
||||
return actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card);
|
||||
return Padding(
|
||||
padding: EdgeInsetsDirectional.only(start: nested ? AppDesignTokens.space24 : 0),
|
||||
child: Semantics(
|
||||
expanded: task.isGroup ? expanded : null,
|
||||
child: actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+19
-11
@@ -14,6 +14,7 @@ class TaskCardDesktop extends StatefulWidget {
|
||||
const TaskCardDesktop({
|
||||
super.key,
|
||||
required this.task,
|
||||
this.expanded = false,
|
||||
required this.selected,
|
||||
required this.batchMode,
|
||||
required this.selectedInBatch,
|
||||
@@ -28,6 +29,7 @@ class TaskCardDesktop extends StatefulWidget {
|
||||
});
|
||||
|
||||
final TaskRecord task;
|
||||
final bool expanded;
|
||||
final bool selected;
|
||||
final bool batchMode;
|
||||
final bool selectedInBatch;
|
||||
@@ -64,7 +66,7 @@ class _TaskCardDesktopState extends State<TaskCardDesktop> {
|
||||
_lastTapTime = isDoubleTap ? null : now;
|
||||
_lastTapPosition = isDoubleTap ? null : details.globalPosition;
|
||||
widget.onPressed();
|
||||
if (isDoubleTap) {
|
||||
if (isDoubleTap && !widget.task.isGroup) {
|
||||
widget.onOpen?.call();
|
||||
}
|
||||
}
|
||||
@@ -134,7 +136,7 @@ class _TaskCardDesktopState extends State<TaskCardDesktop> {
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
TaskCardIcon(task: task),
|
||||
TaskCardIcon(task: task, expanded: widget.expanded),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
@@ -234,15 +236,21 @@ class _ProgressContent extends StatelessWidget {
|
||||
}
|
||||
|
||||
List<Widget> _buildActionButtons(TaskRecord task, AppPalette palette, bool isDark) {
|
||||
final actions = switch (task.status) {
|
||||
TaskStatus.downloading => [(Icons.pause, onPause), (Icons.close, onDelete)],
|
||||
TaskStatus.paused => [(Icons.play_arrow, onResume), (Icons.close, onDelete)],
|
||||
TaskStatus.failed => [(Icons.refresh, onResume), (Icons.delete_outline, onDelete)],
|
||||
TaskStatus.completed => [
|
||||
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, null),
|
||||
(Icons.delete_outline, onDelete),
|
||||
],
|
||||
};
|
||||
final actions = task.isGroup && task.status != TaskStatus.completed
|
||||
? [
|
||||
if (task.canPause) (Icons.pause, onPause),
|
||||
if (task.canResume) (Icons.play_arrow, onResume),
|
||||
(Icons.close, onDelete),
|
||||
]
|
||||
: switch (task.status) {
|
||||
TaskStatus.downloading => [(Icons.pause, onPause), (Icons.close, onDelete)],
|
||||
TaskStatus.paused => [(Icons.play_arrow, onResume), (Icons.close, onDelete)],
|
||||
TaskStatus.failed => [(Icons.refresh, onResume), (Icons.delete_outline, onDelete)],
|
||||
TaskStatus.completed => [
|
||||
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, null),
|
||||
(Icons.delete_outline, onDelete),
|
||||
],
|
||||
};
|
||||
|
||||
return actions
|
||||
.map(
|
||||
|
||||
+19
-11
@@ -13,6 +13,7 @@ class TaskCardMobile extends StatefulWidget {
|
||||
const TaskCardMobile({
|
||||
super.key,
|
||||
required this.task,
|
||||
this.expanded = false,
|
||||
required this.selected,
|
||||
required this.batchMode,
|
||||
required this.selectedInBatch,
|
||||
@@ -27,6 +28,7 @@ class TaskCardMobile extends StatefulWidget {
|
||||
});
|
||||
|
||||
final TaskRecord task;
|
||||
final bool expanded;
|
||||
final bool selected;
|
||||
final bool batchMode;
|
||||
final bool selectedInBatch;
|
||||
@@ -62,7 +64,7 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onPressed,
|
||||
onDoubleTap: !widget.batchMode && task.status == TaskStatus.completed ? widget.onOpen : null,
|
||||
onDoubleTap: !widget.batchMode && !task.isGroup && task.status == TaskStatus.completed ? widget.onOpen : null,
|
||||
child: Container(
|
||||
key: const ValueKey('task-card-mobile-surface'),
|
||||
decoration: BoxDecoration(
|
||||
@@ -117,7 +119,7 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
TaskCardIcon(task: task),
|
||||
TaskCardIcon(task: task, expanded: widget.expanded),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -168,15 +170,21 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
|
||||
}
|
||||
|
||||
List<Widget> _buildActionButtons(TaskRecord task, AppPalette palette, bool isDark) {
|
||||
final actions = switch (task.status) {
|
||||
TaskStatus.downloading => [(Icons.pause, widget.onPause), (Icons.close, widget.onDelete)],
|
||||
TaskStatus.paused => [(Icons.play_arrow, widget.onResume), (Icons.close, widget.onDelete)],
|
||||
TaskStatus.failed => [(Icons.refresh, widget.onResume), (Icons.delete_outline, widget.onDelete)],
|
||||
TaskStatus.completed => [
|
||||
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, widget.onReveal),
|
||||
(Icons.delete_outline, widget.onDelete),
|
||||
],
|
||||
};
|
||||
final actions = task.isGroup && task.status != TaskStatus.completed
|
||||
? [
|
||||
if (task.canPause) (Icons.pause, widget.onPause),
|
||||
if (task.canResume) (Icons.play_arrow, widget.onResume),
|
||||
(Icons.close, widget.onDelete),
|
||||
]
|
||||
: switch (task.status) {
|
||||
TaskStatus.downloading => [(Icons.pause, widget.onPause), (Icons.close, widget.onDelete)],
|
||||
TaskStatus.paused => [(Icons.play_arrow, widget.onResume), (Icons.close, widget.onDelete)],
|
||||
TaskStatus.failed => [(Icons.refresh, widget.onResume), (Icons.delete_outline, widget.onDelete)],
|
||||
TaskStatus.completed => [
|
||||
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, widget.onReveal),
|
||||
(Icons.delete_outline, widget.onDelete),
|
||||
],
|
||||
};
|
||||
|
||||
return actions
|
||||
.map(
|
||||
|
||||
@@ -46,25 +46,35 @@ class TaskUpdateListeningIndicator extends StatelessWidget {
|
||||
}
|
||||
|
||||
class TaskCardIcon extends StatelessWidget {
|
||||
const TaskCardIcon({super.key, required this.task});
|
||||
const TaskCardIcon({super.key, required this.task, this.expanded = false});
|
||||
|
||||
final TaskRecord task;
|
||||
final bool expanded;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
|
||||
return SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: ColoredBox(
|
||||
color: palette.taskCardIconBg,
|
||||
child: Center(child: Icon(task.icon, size: 20, color: palette.textSecondary)),
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (task.isGroup) ...[
|
||||
Icon(expanded ? Icons.expand_more : Icons.chevron_right, size: 18, color: palette.textSecondary),
|
||||
const SizedBox(width: AppDesignTokens.space4),
|
||||
],
|
||||
SizedBox(
|
||||
width: 32,
|
||||
height: 32,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: ColoredBox(
|
||||
color: palette.taskCardIconBg,
|
||||
child: Center(child: Icon(task.icon, size: 20, color: palette.textSecondary)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,14 +69,14 @@ class TaskContextMenu extends StatelessWidget {
|
||||
shad.MenuButton(
|
||||
key: const ValueKey('task-context-resume'),
|
||||
leading: const Icon(Icons.play_arrow, size: 16),
|
||||
enabled: task.status == TaskStatus.paused || task.status == TaskStatus.failed,
|
||||
enabled: task.canResume,
|
||||
onPressed: (_) => actions.onResume(),
|
||||
child: Text(context.l10n.continueAction),
|
||||
),
|
||||
shad.MenuButton(
|
||||
key: const ValueKey('task-context-pause'),
|
||||
leading: const Icon(Icons.pause, size: 16),
|
||||
enabled: task.status == TaskStatus.downloading,
|
||||
enabled: task.canPause,
|
||||
onPressed: (_) => actions.onPause(),
|
||||
child: Text(context.l10n.pause),
|
||||
),
|
||||
|
||||
@@ -5,7 +5,13 @@ import '../../../../shared/theme/app_palette.dart';
|
||||
import '../../../../shared/theme/app_design_tokens.dart';
|
||||
import '../../../../l10n/l10n.dart';
|
||||
|
||||
Future<bool?> showTaskDeleteDialog(BuildContext context, {required int taskCount, required bool keepFiles}) async {
|
||||
Future<bool?> showTaskDeleteDialog(
|
||||
BuildContext context, {
|
||||
required int taskCount,
|
||||
required bool keepFiles,
|
||||
int? memberCount,
|
||||
int? fileCount,
|
||||
}) async {
|
||||
var currentKeepFiles = keepFiles;
|
||||
final dialog = const DialogOverlayHandler().show<bool>(
|
||||
context: context,
|
||||
@@ -32,34 +38,46 @@ Future<bool?> showTaskDeleteDialog(BuildContext context, {required int taskCount
|
||||
title: Text(dialogContext.l10n.deleteTasksTitle(taskCount)),
|
||||
content: SizedBox(
|
||||
width: contentWidth,
|
||||
child: AnimatedContainer(
|
||||
key: const ValueKey('task-delete-keep-files-option'),
|
||||
duration: const Duration(milliseconds: 160),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDesignTokens.space16,
|
||||
vertical: AppDesignTokens.space12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: currentKeepFiles ? palette.brandSoft : palette.surfaceSoft,
|
||||
border: Border.all(color: currentKeepFiles ? palette.brand.withValues(alpha: 0.45) : palette.border),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
dialogContext.l10n.deleteTaskTip,
|
||||
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDesignTokens.space16),
|
||||
Switch(
|
||||
key: const ValueKey('task-delete-keep-files-switch'),
|
||||
value: currentKeepFiles,
|
||||
onChanged: (value) => setDialogState(() => currentKeepFiles = value),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (memberCount != null) ...[
|
||||
Text(dialogContext.l10n.taskGroupDeleteImpact(memberCount, fileCount ?? 0)),
|
||||
const SizedBox(height: AppDesignTokens.space16),
|
||||
],
|
||||
),
|
||||
AnimatedContainer(
|
||||
key: const ValueKey('task-delete-keep-files-option'),
|
||||
duration: const Duration(milliseconds: 160),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppDesignTokens.space16,
|
||||
vertical: AppDesignTokens.space12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: currentKeepFiles ? palette.brandSoft : palette.surfaceSoft,
|
||||
border: Border.all(
|
||||
color: currentKeepFiles ? palette.brand.withValues(alpha: 0.45) : palette.border,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
dialogContext.l10n.deleteTaskTip,
|
||||
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppDesignTokens.space16),
|
||||
Switch(
|
||||
key: const ValueKey('task-delete-keep-files-switch'),
|
||||
value: currentKeepFiles,
|
||||
onChanged: (value) => setDialogState(() => currentKeepFiles = value),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
|
||||
@@ -121,7 +121,9 @@ class _TaskDetailsViewState extends ConsumerState<TaskDetailsView> {
|
||||
final contentPadding = widget.mobile
|
||||
? AppDesignTokens.taskDetailsMobilePadding
|
||||
: AppDesignTokens.taskDetailsDesktopPadding;
|
||||
final runtimeStatus = _tabIndex == 1 ? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value : null;
|
||||
final runtimeStatus = _tabIndex == 1 && !widget.task.isGroup
|
||||
? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value
|
||||
: null;
|
||||
final content = Column(
|
||||
children: [
|
||||
Container(
|
||||
@@ -148,12 +150,14 @@ class _TaskDetailsViewState extends ConsumerState<TaskDetailsView> {
|
||||
active: _tabIndex == 1,
|
||||
onTap: () => setState(() => _tabIndex = 1),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_DrawerTabButton(
|
||||
label: context.l10n.taskDetailsStatsTab,
|
||||
active: _tabIndex == 2,
|
||||
onTap: () => setState(() => _tabIndex = 2),
|
||||
),
|
||||
if (!widget.task.isGroup) ...[
|
||||
const SizedBox(width: 24),
|
||||
_DrawerTabButton(
|
||||
label: context.l10n.taskDetailsStatsTab,
|
||||
active: _tabIndex == 2,
|
||||
onTap: () => setState(() => _tabIndex = 2),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -340,7 +344,7 @@ class _TaskInfoTab extends StatelessWidget {
|
||||
onCancel: onCancelEditUrl,
|
||||
onSave: onSaveUrl,
|
||||
)
|
||||
else
|
||||
else if (!task.isGroup)
|
||||
_CopyableInfoBlock(
|
||||
label: context.l10n.downloadLink,
|
||||
value: task.url,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
@@ -7,6 +8,7 @@ import '../../../../l10n/l10n.dart';
|
||||
import '../../../../shared/widgets/app_tooltip.dart';
|
||||
import '../../../../util/util.dart';
|
||||
import '../../domain/task_record.dart';
|
||||
import '../../application/tasks_controller.dart';
|
||||
import 'task_file_manager.dart';
|
||||
|
||||
Future<void> browseTaskFiles(BuildContext context, TaskRecord task, {required bool mobile}) async {
|
||||
@@ -49,7 +51,16 @@ Future<void> showTaskFileBrowserDialog(BuildContext context, TaskRecord task) as
|
||||
content: SizedBox(
|
||||
width: dialogWidth,
|
||||
height: dialogHeight,
|
||||
child: TaskFileManagerView(task: task),
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final state = ref.watch(tasksControllerProvider).value;
|
||||
final current = state?.findTask(task.id);
|
||||
if (state != null && current == null) {
|
||||
return Center(child: Text(context.l10n.taskNotFound));
|
||||
}
|
||||
return TaskFileManagerView(task: current ?? task);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -120,14 +120,19 @@ class _TaskFileManagerViewState extends State<TaskFileManagerView> {
|
||||
}
|
||||
|
||||
String _localPath(_TaskFileEntry entry) {
|
||||
if (entry.localPath != null) return entry.localPath!;
|
||||
if (!widget.task.isFolder && _index.fileCount == 1) return widget.task.storagePath;
|
||||
final segments = entry.fullPath.split('/').where((part) => part.isNotEmpty);
|
||||
return path.joinAll([widget.task.storagePath, ...segments]);
|
||||
}
|
||||
|
||||
String _accessUrl(_TaskFileEntry entry) {
|
||||
final encodedPath = entry.fullPath.split('/').where((part) => part.isNotEmpty).map(Uri.encodeComponent).join('/');
|
||||
return api.join('/fs/tasks/${Uri.encodeComponent(widget.task.id)}/$encodedPath');
|
||||
final encodedPath = (entry.source?.accessPath ?? entry.fullPath)
|
||||
.split('/')
|
||||
.where((part) => part.isNotEmpty)
|
||||
.map(Uri.encodeComponent)
|
||||
.join('/');
|
||||
return api.join('/fs/tasks/${Uri.encodeComponent(entry.source?.taskId ?? widget.task.id)}/$encodedPath');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,7 +309,7 @@ class _FileActionButton extends StatelessWidget {
|
||||
|
||||
class _TaskFileDirectoryIndex {
|
||||
_TaskFileDirectoryIndex(TaskRecord task) {
|
||||
final sourceFiles = task.files.isEmpty
|
||||
final sourceFiles = task.files.isEmpty && !task.isGroup
|
||||
? [TaskFileNode(path: '/', name: task.name, sizeBytes: task.totalBytes ?? 0)]
|
||||
: task.files;
|
||||
fileCount = sourceFiles.length;
|
||||
@@ -313,8 +318,18 @@ class _TaskFileDirectoryIndex {
|
||||
final directory = _normalizeDirectory(file.path);
|
||||
final name = singleFile ? path.basename(task.storagePath) : file.name;
|
||||
final fullPath = directory == '/' ? '/$name' : '$directory/$name';
|
||||
_add(directory, _TaskFileEntry(directory: false, fullPath: fullPath, name: name, size: file.sizeBytes));
|
||||
_addParentDirectories(directory);
|
||||
_add(
|
||||
directory,
|
||||
_TaskFileEntry(
|
||||
directory: false,
|
||||
fullPath: fullPath,
|
||||
name: name,
|
||||
size: file.sizeBytes,
|
||||
source: file,
|
||||
localPath: file.localPath,
|
||||
),
|
||||
);
|
||||
_addParentDirectories(directory, file.localPath == null ? null : path.dirname(file.localPath!));
|
||||
}
|
||||
for (final entries in _entries.values) {
|
||||
entries.sort((left, right) {
|
||||
@@ -331,15 +346,19 @@ class _TaskFileDirectoryIndex {
|
||||
|
||||
List<_TaskFileEntry> entries(String directory) => _entries[directory] ?? const [];
|
||||
|
||||
void _addParentDirectories(String directory) {
|
||||
void _addParentDirectories(String directory, String? localPath) {
|
||||
var current = directory;
|
||||
while (current != '/') {
|
||||
final parent = path.posix.dirname(current);
|
||||
final normalizedParent = parent == '.' ? '/' : parent;
|
||||
final name = path.posix.basename(current);
|
||||
_add(normalizedParent, _TaskFileEntry(directory: true, fullPath: current, name: name, size: 0));
|
||||
_add(
|
||||
normalizedParent,
|
||||
_TaskFileEntry(directory: true, fullPath: current, name: name, size: 0, localPath: localPath),
|
||||
);
|
||||
_entries.putIfAbsent(current, () => []);
|
||||
current = normalizedParent;
|
||||
if (localPath != null) localPath = path.dirname(localPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,12 +369,21 @@ class _TaskFileDirectoryIndex {
|
||||
}
|
||||
|
||||
class _TaskFileEntry {
|
||||
const _TaskFileEntry({required this.directory, required this.fullPath, required this.name, required this.size});
|
||||
const _TaskFileEntry({
|
||||
required this.directory,
|
||||
required this.fullPath,
|
||||
required this.name,
|
||||
required this.size,
|
||||
this.source,
|
||||
this.localPath,
|
||||
});
|
||||
|
||||
final bool directory;
|
||||
final String fullPath;
|
||||
final String name;
|
||||
final int size;
|
||||
final TaskFileNode? source;
|
||||
final String? localPath;
|
||||
}
|
||||
|
||||
String _normalizeDirectory(String value) {
|
||||
|
||||
@@ -42,6 +42,13 @@ class _TaskFileTreeState extends State<TaskFileTree> {
|
||||
Widget build(BuildContext context) {
|
||||
final progressByIndex = {
|
||||
for (final file in widget.runtimeStatus?.files ?? const <FileRuntimeStatus>[]) file.index: file,
|
||||
if (widget.task.isGroup)
|
||||
for (final (index, file) in widget.task.files.indexed)
|
||||
file.resourceIndex ?? index: FileRuntimeStatus(
|
||||
index: file.resourceIndex ?? index,
|
||||
size: file.sizeBytes,
|
||||
downloaded: file.downloadedBytes ?? 0,
|
||||
),
|
||||
};
|
||||
|
||||
return Column(
|
||||
@@ -179,7 +186,17 @@ class _FileDownloadProgress extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = AppPalette.of(context);
|
||||
final taskStateIcon = switch (taskStatus) {
|
||||
final statuses = node.leafItems.map((item) => item.data.status).whereType<TaskStatus>().toSet();
|
||||
final status = statuses.isEmpty
|
||||
? taskStatus
|
||||
: statuses.contains(TaskStatus.downloading)
|
||||
? TaskStatus.downloading
|
||||
: statuses.contains(TaskStatus.failed)
|
||||
? TaskStatus.failed
|
||||
: statuses.contains(TaskStatus.paused)
|
||||
? TaskStatus.paused
|
||||
: TaskStatus.completed;
|
||||
final taskStateIcon = switch (status) {
|
||||
TaskStatus.paused => Semantics(
|
||||
label: context.l10n.pause,
|
||||
child: Icon(Icons.pause_circle_filled, size: 16, color: palette.textMuted),
|
||||
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "بيانات الملف المُتعذّب فارغة",
|
||||
"torrentPathUnavailable": "غير قادر على الوصول إلى طريق الملف المُتعذّب",
|
||||
"selectAtLeastOneFile": "اختار ملف واحد على الأقل",
|
||||
"resolvedFiles": "الملفات المبتغاة",
|
||||
"resolvedFiles": "قائمة الملفات",
|
||||
"waitingToResolve": "بانتظار التحليل",
|
||||
"resolvingResource": "جارٍ تحليل المورد",
|
||||
"resolvedFileCount": "{count}",
|
||||
"resolvedCategoryDirectory": "سيتم الحفظ في {path}",
|
||||
"resourceHasNoFiles": "ولا توجد في هذا المورد قائمة ملف قابلة للاختيار",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "حالة التحديث",
|
||||
"trackerSubscriptionTrackerCount": "عدد المتتبعات",
|
||||
"trackerSubscriptionNotUpdated": "لم يُحدّث بعد",
|
||||
"actions": "الإجراءات"
|
||||
"actions": "الإجراءات",
|
||||
"taskGroupDeleteImpact": "سيؤثر على {taskCount} من مهام التنزيل و{fileCount} من الملفات."
|
||||
}
|
||||
@@ -386,7 +386,9 @@
|
||||
"torrentDataEmpty": "Torrent fayl məlumatı boşdur",
|
||||
"torrentPathUnavailable": "Torrent fayl yoluna giriş mümkün olmadı",
|
||||
"selectAtLeastOneFile": "Ən azı bir fayl seçin",
|
||||
"resolvedFiles": "Tapılmış Fayllar",
|
||||
"resolvedFiles": "Fayl siyahısı",
|
||||
"waitingToResolve": "Təhlil gözlənilir",
|
||||
"resolvingResource": "Resurs təhlil edilir",
|
||||
"resolvedFileCount": "{count} fayl tapıldı",
|
||||
"resolvedCategoryDirectory": "{path} qovluğuna saxlanılacaq",
|
||||
"resourceHasNoFiles": "Bu resursda seçilə bilən fayl siyahısı yoxdur",
|
||||
@@ -661,5 +663,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Yeniləmə statusu",
|
||||
"trackerSubscriptionTrackerCount": "Tracker sayı",
|
||||
"trackerSubscriptionNotUpdated": "Hələ yenilənməyib",
|
||||
"actions": "Əməliyyatlar"
|
||||
"actions": "Əməliyyatlar",
|
||||
"taskGroupDeleteImpact": "{taskCount} endirməyə və {fileCount} fayla təsir edir."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "El fitxer torrent de dades és buit",
|
||||
"torrentPathUnavailable": "No s' ha pogut accedir al camí del fitxer torrent",
|
||||
"selectAtLeastOneFile": "Seleccioneu com a mínim un fitxer",
|
||||
"resolvedFiles": "Fitxers resolts",
|
||||
"resolvedFiles": "Llista de fitxers",
|
||||
"waitingToResolve": "Esperant l’anàlisi",
|
||||
"resolvingResource": "S’està analitzant el recurs",
|
||||
"resolvedFileCount": "{count}",
|
||||
"resolvedCategoryDirectory": "Es desarà a {path}",
|
||||
"resourceHasNoFiles": "El recurs no té cap llista de fitxers per seleccionar",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Estat de l’actualització",
|
||||
"trackerSubscriptionTrackerCount": "Nombre de trackers",
|
||||
"trackerSubscriptionNotUpdated": "Sense actualitzar",
|
||||
"actions": "Accions"
|
||||
"actions": "Accions",
|
||||
"taskGroupDeleteImpact": "Afecta {taskCount} baixades i {fileCount} fitxers."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "Torrent-Dateidaten sind leer",
|
||||
"torrentPathUnavailable": "Auf den Torrent-Dateipfad kann nicht zugegriffen werden",
|
||||
"selectAtLeastOneFile": "Wählen Sie mindestens eine Datei aus",
|
||||
"resolvedFiles": "Aufgelöste Dateien",
|
||||
"resolvedFiles": "Dateiliste",
|
||||
"waitingToResolve": "Warten auf Auflösung",
|
||||
"resolvingResource": "Ressource wird aufgelöst",
|
||||
"resolvedFileCount": "{count}-Dateien behoben",
|
||||
"resolvedCategoryDirectory": "Wird in {path} gespeichert",
|
||||
"resourceHasNoFiles": "Die Ressource verfügt über keine auswählbare Dateiliste",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Aktualisierungsstatus",
|
||||
"trackerSubscriptionTrackerCount": "Tracker-Anzahl",
|
||||
"trackerSubscriptionNotUpdated": "Noch nicht aktualisiert",
|
||||
"actions": "Aktionen"
|
||||
"actions": "Aktionen",
|
||||
"taskGroupDeleteImpact": "Betrifft {taskCount} Downloads und {fileCount} Dateien."
|
||||
}
|
||||
@@ -406,7 +406,9 @@
|
||||
"torrentDataEmpty": "Torrent file data is empty",
|
||||
"torrentPathUnavailable": "Unable to access the torrent file path",
|
||||
"selectAtLeastOneFile": "Select at least one file",
|
||||
"resolvedFiles": "Resolved Files",
|
||||
"resolvedFiles": "File List",
|
||||
"waitingToResolve": "Waiting to resolve",
|
||||
"resolvingResource": "Resolving resource",
|
||||
"resolvedFileCount": "{count, plural, =1{1 file resolved} other{{count} files resolved}}",
|
||||
"resolvedCategoryDirectory": "Will be saved to {path}",
|
||||
"@resolvedCategoryDirectory": {
|
||||
@@ -704,5 +706,16 @@
|
||||
"trackerSubscriptionUpdateStatus": "Update status",
|
||||
"trackerSubscriptionTrackerCount": "Tracker count",
|
||||
"trackerSubscriptionNotUpdated": "Not updated",
|
||||
"actions": "Actions"
|
||||
"actions": "Actions",
|
||||
"taskGroupDeleteImpact": "Affects {taskCount} downloads and {fileCount} files.",
|
||||
"@taskGroupDeleteImpact": {
|
||||
"placeholders": {
|
||||
"taskCount": {
|
||||
"type": "int"
|
||||
},
|
||||
"fileCount": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "Los datos de archivos Torrent están vacíos",
|
||||
"torrentPathUnavailable": "Incapaz de acceder a la ruta del archivo torrent",
|
||||
"selectAtLeastOneFile": "Seleccione al menos un archivo",
|
||||
"resolvedFiles": "Archivos resueltos",
|
||||
"resolvedFiles": "Lista de archivos",
|
||||
"waitingToResolve": "Esperando resolución",
|
||||
"resolvingResource": "Resolviendo recurso",
|
||||
"resolvedFileCount": "{count} resueltos",
|
||||
"resolvedCategoryDirectory": "Se guardará en {path}",
|
||||
"resourceHasNoFiles": "El recurso no tiene lista de archivos seleccionable",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Estado de actualización",
|
||||
"trackerSubscriptionTrackerCount": "Número de trackers",
|
||||
"trackerSubscriptionNotUpdated": "Sin actualizar",
|
||||
"actions": "Acciones"
|
||||
"actions": "Acciones",
|
||||
"taskGroupDeleteImpact": "Afecta a {taskCount} descargas y {fileCount} archivos."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "داده های فایل تورنت خالی است",
|
||||
"torrentPathUnavailable": "امکان دسترسی به مسیر فایل تورنت",
|
||||
"selectAtLeastOneFile": "حداقل یک فایل را انتخاب کنید",
|
||||
"resolvedFiles": "فایل های حل شده",
|
||||
"resolvedFiles": "فهرست فایلها",
|
||||
"waitingToResolve": "در انتظار پردازش",
|
||||
"resolvingResource": "در حال پردازش منبع",
|
||||
"resolvedFileCount": "{count} فایل شناسایی شد",
|
||||
"resolvedCategoryDirectory": "در {path} ذخیره میشود",
|
||||
"resourceHasNoFiles": "منبع هیچ لیست فایل قابل انتخاب ندارد",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "وضعیت بهروزرسانی",
|
||||
"trackerSubscriptionTrackerCount": "تعداد ردیابها",
|
||||
"trackerSubscriptionNotUpdated": "هنوز بهروزرسانی نشده",
|
||||
"actions": "عملیات"
|
||||
"actions": "عملیات",
|
||||
"taskGroupDeleteImpact": "بر {taskCount} بارگیری و {fileCount} پرونده تأثیر میگذارد."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "Les données du fichier Torrent sont vides",
|
||||
"torrentPathUnavailable": "Impossible d'accéder au chemin de fichier torrent",
|
||||
"selectAtLeastOneFile": "Sélectionnez au moins un fichier",
|
||||
"resolvedFiles": "Fichiers résolus",
|
||||
"resolvedFiles": "Liste des fichiers",
|
||||
"waitingToResolve": "En attente d’analyse",
|
||||
"resolvingResource": "Analyse de la ressource",
|
||||
"resolvedFileCount": "{count} résolus",
|
||||
"resolvedCategoryDirectory": "Sera enregistré dans {path}",
|
||||
"resourceHasNoFiles": "La ressource n'a pas de liste de fichiers sélectionnable",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "État de mise à jour",
|
||||
"trackerSubscriptionTrackerCount": "Nombre de trackers",
|
||||
"trackerSubscriptionNotUpdated": "Pas encore mis à jour",
|
||||
"actions": "Actions"
|
||||
"actions": "Actions",
|
||||
"taskGroupDeleteImpact": "Concerne {taskCount} téléchargements et {fileCount} fichiers."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "A torrent fájl adatai üresek",
|
||||
"torrentPathUnavailable": "Nem érhető el a torrent fájl elérési útja",
|
||||
"selectAtLeastOneFile": "Válasszon ki legalább egy fájlt",
|
||||
"resolvedFiles": "Feloldott fájlok",
|
||||
"resolvedFiles": "Fájllista",
|
||||
"waitingToResolve": "Feldolgozásra vár",
|
||||
"resolvingResource": "Erőforrás feldolgozása",
|
||||
"resolvedFileCount": "{count} fájlok megoldva",
|
||||
"resolvedCategoryDirectory": "Ide lesz mentve: {path}",
|
||||
"resourceHasNoFiles": "Az erőforrásnak nincs kiválasztható fájllistája",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Frissítés állapota",
|
||||
"trackerSubscriptionTrackerCount": "Trackerek száma",
|
||||
"trackerSubscriptionNotUpdated": "Még nem frissült",
|
||||
"actions": "Műveletek"
|
||||
"actions": "Műveletek",
|
||||
"taskGroupDeleteImpact": "{taskCount} letöltést és {fileCount} fájlt érint."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "Data berkas Torrent kosong",
|
||||
"torrentPathUnavailable": "Tak bisa mengakses path berkas torrent",
|
||||
"selectAtLeastOneFile": "Pilih paling tidak satu berkas",
|
||||
"resolvedFiles": "Berkas Terpecahkan",
|
||||
"resolvedFiles": "Daftar berkas",
|
||||
"waitingToResolve": "Menunggu pemrosesan",
|
||||
"resolvingResource": "Memproses sumber daya",
|
||||
"resolvedFileCount": "{count} terselesaikan",
|
||||
"resolvedCategoryDirectory": "Akan disimpan ke {path}",
|
||||
"resourceHasNoFiles": "Sumber daya tidak memiliki daftar berkas yang dapat dipilih",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Status pembaruan",
|
||||
"trackerSubscriptionTrackerCount": "Jumlah tracker",
|
||||
"trackerSubscriptionNotUpdated": "Belum diperbarui",
|
||||
"actions": "Tindakan"
|
||||
"actions": "Tindakan",
|
||||
"taskGroupDeleteImpact": "Memengaruhi {taskCount} unduhan dan {fileCount} berkas."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "I dati dei file Torrent sono vuoti",
|
||||
"torrentPathUnavailable": "Non è possibile accedere al percorso file torrent",
|
||||
"selectAtLeastOneFile": "Selezionare almeno un file",
|
||||
"resolvedFiles": "File recuperati",
|
||||
"resolvedFiles": "Elenco dei file",
|
||||
"waitingToResolve": "In attesa di analisi",
|
||||
"resolvingResource": "Analisi della risorsa",
|
||||
"resolvedFileCount": "{count} risolti",
|
||||
"resolvedCategoryDirectory": "Verrà salvato in {path}",
|
||||
"resourceHasNoFiles": "La risorsa non ha un elenco di file selezionabile",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Stato aggiornamento",
|
||||
"trackerSubscriptionTrackerCount": "Numero di tracker",
|
||||
"trackerSubscriptionNotUpdated": "Non ancora aggiornato",
|
||||
"actions": "Azioni"
|
||||
"actions": "Azioni",
|
||||
"taskGroupDeleteImpact": "Riguarda {taskCount} download e {fileCount} file."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "トレントファイルデータが空です",
|
||||
"torrentPathUnavailable": "トレントファイルパスにアクセスできない",
|
||||
"selectAtLeastOneFile": "少なくとも1つのファイルを選択します",
|
||||
"resolvedFiles": "解決されたファイル",
|
||||
"resolvedFiles": "ファイル一覧",
|
||||
"waitingToResolve": "解析待ち",
|
||||
"resolvingResource": "リソースを解析中",
|
||||
"resolvedFileCount": "{count} ファイルが解決しました",
|
||||
"resolvedCategoryDirectory": "{path} に保存されます",
|
||||
"resourceHasNoFiles": "リソースには、選択可能なファイルリストはありません",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "更新状況",
|
||||
"trackerSubscriptionTrackerCount": "Tracker 数",
|
||||
"trackerSubscriptionNotUpdated": "未更新",
|
||||
"actions": "操作"
|
||||
"actions": "操作",
|
||||
"taskGroupDeleteImpact": "{taskCount} 件のダウンロードと {fileCount} 個のファイルが対象です。"
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "Torrent 파일 데이터가 비어 있습니다",
|
||||
"torrentPathUnavailable": "토렌트 파일 경로에 액세스 할 수 없습니다",
|
||||
"selectAtLeastOneFile": "최소 1개의 파일을 선택하십시오",
|
||||
"resolvedFiles": "해결된 파일",
|
||||
"resolvedFiles": "파일 목록",
|
||||
"waitingToResolve": "분석 대기 중",
|
||||
"resolvingResource": "리소스 분석 중",
|
||||
"resolvedFileCount": "{count} 파일 해결",
|
||||
"resolvedCategoryDirectory": "{path}에 저장됩니다",
|
||||
"resourceHasNoFiles": "리소스에는 선택 가능한 파일 목록이 없습니다",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "업데이트 상태",
|
||||
"trackerSubscriptionTrackerCount": "트래커 수",
|
||||
"trackerSubscriptionNotUpdated": "아직 업데이트되지 않음",
|
||||
"actions": "작업"
|
||||
"actions": "작업",
|
||||
"taskGroupDeleteImpact": "다운로드 {taskCount}개와 파일 {fileCount}개에 영향을 줍니다."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "Dane pliku torrenta są puste",
|
||||
"torrentPathUnavailable": "Nie można uzyskać dostępu do ścieżki pliku torrenta",
|
||||
"selectAtLeastOneFile": "Wybierz co najmniej jeden plik",
|
||||
"resolvedFiles": "Rozwiązywane pliki",
|
||||
"resolvedFiles": "Lista plików",
|
||||
"waitingToResolve": "Oczekiwanie na analizę",
|
||||
"resolvingResource": "Analizowanie zasobu",
|
||||
"resolvedFileCount": "{count} plików rozwiązanych",
|
||||
"resolvedCategoryDirectory": "Zostanie zapisane w {path}",
|
||||
"resourceHasNoFiles": "Zasób nie ma listy plików do wyboru",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Stan aktualizacji",
|
||||
"trackerSubscriptionTrackerCount": "Liczba trackerów",
|
||||
"trackerSubscriptionNotUpdated": "Jeszcze nie zaktualizowano",
|
||||
"actions": "Działania"
|
||||
"actions": "Działania",
|
||||
"taskGroupDeleteImpact": "Dotyczy {taskCount} pobrań i {fileCount} plików."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "Os dados do ficheiro Torrent estão em branco",
|
||||
"torrentPathUnavailable": "Não foi possível aceder ao local do ficheiro torrent",
|
||||
"selectAtLeastOneFile": "Selecione pelo menos um arquivo",
|
||||
"resolvedFiles": "Ficheiros Resolvidos",
|
||||
"resolvedFiles": "Lista de arquivos",
|
||||
"waitingToResolve": "Aguardando resolução",
|
||||
"resolvingResource": "Resolvendo recurso",
|
||||
"resolvedFileCount": "{count} ficheiros resolvidos",
|
||||
"resolvedCategoryDirectory": "Será salvo em {path}",
|
||||
"resourceHasNoFiles": "O recurso não tem lista de arquivos selecionáveis",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Estado da atualização",
|
||||
"trackerSubscriptionTrackerCount": "Número de trackers",
|
||||
"trackerSubscriptionNotUpdated": "Ainda não atualizado",
|
||||
"actions": "Ações"
|
||||
"actions": "Ações",
|
||||
"taskGroupDeleteImpact": "Afeta {taskCount} downloads e {fileCount} arquivos."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "Данные торрент-файлов пусты",
|
||||
"torrentPathUnavailable": "Невозможность доступа к торрент-файлу",
|
||||
"selectAtLeastOneFile": "Выберите хотя бы один файл",
|
||||
"resolvedFiles": "Решенные файлы",
|
||||
"resolvedFiles": "Список файлов",
|
||||
"waitingToResolve": "Ожидание анализа",
|
||||
"resolvingResource": "Анализ ресурса",
|
||||
"resolvedFileCount": "{count} файлов, разрешенных",
|
||||
"resolvedCategoryDirectory": "Будет сохранено в {path}",
|
||||
"resourceHasNoFiles": "Ресурс не имеет выбираемого списка файлов",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Статус обновления",
|
||||
"trackerSubscriptionTrackerCount": "Число трекеров",
|
||||
"trackerSubscriptionNotUpdated": "Ещё не обновлено",
|
||||
"actions": "Действия"
|
||||
"actions": "Действия",
|
||||
"taskGroupDeleteImpact": "Затронет {taskCount} загрузок и {fileCount} файлов."
|
||||
}
|
||||
@@ -395,7 +395,9 @@
|
||||
"torrentDataEmpty": "Údaje torrent súboru sú prázdne",
|
||||
"torrentPathUnavailable": "Nie je možné pristúpiť k ceste torrent súboru",
|
||||
"selectAtLeastOneFile": "Vyberte aspoň jeden súbor",
|
||||
"resolvedFiles": "Vyriešené súbory",
|
||||
"resolvedFiles": "Zoznam súborov",
|
||||
"waitingToResolve": "Čaká na spracovanie",
|
||||
"resolvingResource": "Spracovanie zdroja",
|
||||
"resolvedFileCount": "{count, plural, =1{vyriešený 1 súbor} other{vyriešených {count} súborov}}",
|
||||
"resolvedCategoryDirectory": "Uloží sa do {path}",
|
||||
"resourceHasNoFiles": "Zdroj nemá žiadny vyberateľný zoznam súborov",
|
||||
@@ -678,5 +680,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Stav aktualizácie",
|
||||
"trackerSubscriptionTrackerCount": "Počet trackerov",
|
||||
"trackerSubscriptionNotUpdated": "Zatiaľ neaktualizované",
|
||||
"actions": "Akcie"
|
||||
"actions": "Akcie",
|
||||
"taskGroupDeleteImpact": "Ovplyvní {taskCount} sťahovaní a {fileCount} súborov."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "டொரண்ட் கோப்பு தரவு காலியாக உள்ளது",
|
||||
"torrentPathUnavailable": "டொரண்ட் கோப்பு பாதையை அணுக முடியவில்லை",
|
||||
"selectAtLeastOneFile": "குறைந்தது ஒரு கோப்பையாவது தேர்ந்தெடுக்கவும்",
|
||||
"resolvedFiles": "தீர்க்கப்பட்ட கோப்புகள்",
|
||||
"resolvedFiles": "கோப்புப் பட்டியல்",
|
||||
"waitingToResolve": "பகுப்பாய்விற்காகக் காத்திருக்கிறது",
|
||||
"resolvingResource": "வளத்தைப் பகுப்பாய்வு செய்கிறது",
|
||||
"resolvedFileCount": "{count} கோப்புகள் தீர்க்கப்பட்டன",
|
||||
"resolvedCategoryDirectory": "{path} இல் சேமிக்கப்படும்",
|
||||
"resourceHasNoFiles": "வளத்தில் தேர்ந்தெடுக்கக்கூடிய கோப்பு பட்டியல் இல்லை",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "புதுப்பிப்பு நிலை",
|
||||
"trackerSubscriptionTrackerCount": "டிராக்கர் எண்ணிக்கை",
|
||||
"trackerSubscriptionNotUpdated": "இன்னும் புதுப்பிக்கப்படவில்லை",
|
||||
"actions": "செயல்கள்"
|
||||
"actions": "செயல்கள்",
|
||||
"taskGroupDeleteImpact": "{taskCount} பதிவிறக்கங்களையும் {fileCount} கோப்புகளையும் பாதிக்கும்."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "Torrent dosya verileri boş",
|
||||
"torrentPathUnavailable": "Hata dosya yoluna erişmek için kullanılamaz",
|
||||
"selectAtLeastOneFile": "En az bir dosya seçin",
|
||||
"resolvedFiles": "Çözülen Dosyalar",
|
||||
"resolvedFiles": "Dosya listesi",
|
||||
"waitingToResolve": "Çözümleme bekleniyor",
|
||||
"resolvingResource": "Kaynak çözümleniyor",
|
||||
"resolvedFileCount": "{count} dosyaları çözüldü",
|
||||
"resolvedCategoryDirectory": "Şuraya kaydedilecek: {path}",
|
||||
"resourceHasNoFiles": "Kaynak, uygun olmayan dosya listesine sahiptir",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Güncelleme durumu",
|
||||
"trackerSubscriptionTrackerCount": "Tracker sayısı",
|
||||
"trackerSubscriptionNotUpdated": "Henüz güncellenmedi",
|
||||
"actions": "İşlemler"
|
||||
"actions": "İşlemler",
|
||||
"taskGroupDeleteImpact": "{taskCount} indirmeyi ve {fileCount} dosyayı etkiler."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "Порожня інформація про файли, що обертаються",
|
||||
"torrentPathUnavailable": "Неможливо отримати доступ до торрент-файлу доріжка",
|
||||
"selectAtLeastOneFile": "Виберіть принаймні один файл",
|
||||
"resolvedFiles": "Знезалізовані файли",
|
||||
"resolvedFiles": "Список файлів",
|
||||
"waitingToResolve": "Очікування аналізу",
|
||||
"resolvingResource": "Аналіз ресурсу",
|
||||
"resolvedFileCount": "{count} файли вирішені",
|
||||
"resolvedCategoryDirectory": "Буде збережено в {path}",
|
||||
"resourceHasNoFiles": "Ресурс не має вибраного списку файлів",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Стан оновлення",
|
||||
"trackerSubscriptionTrackerCount": "Кількість трекерів",
|
||||
"trackerSubscriptionNotUpdated": "Ще не оновлено",
|
||||
"actions": "Дії"
|
||||
"actions": "Дії",
|
||||
"taskGroupDeleteImpact": "Вплине на {taskCount} завантажень і {fileCount} файлів."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "Dữ liệu về tập tin đã rỗng",
|
||||
"torrentPathUnavailable": "Không thể truy cập đường dẫn tập tin lũ lụt",
|
||||
"selectAtLeastOneFile": "Chọn ít nhất một tập tin",
|
||||
"resolvedFiles": "Các tập tin đã giải quyết",
|
||||
"resolvedFiles": "Danh sách tệp",
|
||||
"waitingToResolve": "Đang chờ phân tích",
|
||||
"resolvingResource": "Đang phân tích tài nguyên",
|
||||
"resolvedFileCount": "{count} tập tin được giải quyết",
|
||||
"resolvedCategoryDirectory": "Sẽ được lưu vào {path}",
|
||||
"resourceHasNoFiles": "Không thể chọn danh sách tập tin",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "Trạng thái cập nhật",
|
||||
"trackerSubscriptionTrackerCount": "Số tracker",
|
||||
"trackerSubscriptionNotUpdated": "Chưa cập nhật",
|
||||
"actions": "Thao tác"
|
||||
"actions": "Thao tác",
|
||||
"taskGroupDeleteImpact": "Ảnh hưởng đến {taskCount} tác vụ tải và {fileCount} tệp."
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "种子文件内容为空",
|
||||
"torrentPathUnavailable": "无法访问种子文件路径",
|
||||
"selectAtLeastOneFile": "请至少选择一个文件",
|
||||
"resolvedFiles": "已解析文件",
|
||||
"resolvedFiles": "文件列表",
|
||||
"waitingToResolve": "等待解析",
|
||||
"resolvingResource": "正在解析资源",
|
||||
"resolvedFileCount": "已解析 {count} 个文件",
|
||||
"resolvedCategoryDirectory": "将保存到 {path}",
|
||||
"resourceHasNoFiles": "该资源没有可选择的文件列表",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "更新状态",
|
||||
"trackerSubscriptionTrackerCount": "Tracker 数量",
|
||||
"trackerSubscriptionNotUpdated": "未更新",
|
||||
"actions": "操作"
|
||||
"actions": "操作",
|
||||
"taskGroupDeleteImpact": "将影响 {taskCount} 个下载任务和 {fileCount} 个文件。"
|
||||
}
|
||||
@@ -372,7 +372,9 @@
|
||||
"torrentDataEmpty": "種子檔案內容為空",
|
||||
"torrentPathUnavailable": "無法訪問種子檔案路徑",
|
||||
"selectAtLeastOneFile": "請至少選擇一個檔案",
|
||||
"resolvedFiles": "已解析檔案",
|
||||
"resolvedFiles": "檔案清單",
|
||||
"waitingToResolve": "等待解析",
|
||||
"resolvingResource": "正在解析資源",
|
||||
"resolvedFileCount": "已解析 {count} 個檔案",
|
||||
"resolvedCategoryDirectory": "將儲存到 {path}",
|
||||
"resourceHasNoFiles": "該資源沒有可選擇的檔案列表",
|
||||
@@ -477,5 +479,6 @@
|
||||
"trackerSubscriptionUpdateStatus": "更新狀態",
|
||||
"trackerSubscriptionTrackerCount": "Tracker 數量",
|
||||
"trackerSubscriptionNotUpdated": "未更新",
|
||||
"actions": "操作"
|
||||
"actions": "操作",
|
||||
"taskGroupDeleteImpact": "將影響 {taskCount} 個下載任務和 {fileCount} 個檔案。"
|
||||
}
|
||||
@@ -1,18 +1,37 @@
|
||||
import 'package:flutter/material.dart' show Tooltip;
|
||||
import 'package:flutter/material.dart' show Tooltip, TooltipState;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// The single product tooltip primitive.
|
||||
///
|
||||
/// Flutter's tooltip is used intentionally because the shadcn tooltip does not
|
||||
/// currently inherit Gopeed's mixed Shadcn/Material theme consistently.
|
||||
class AppTooltip extends StatelessWidget {
|
||||
const AppTooltip({super.key, required this.message, required this.child});
|
||||
class AppTooltip extends StatefulWidget {
|
||||
const AppTooltip({super.key, required this.message, required this.child, this.focusable = false});
|
||||
|
||||
final String message;
|
||||
final Widget child;
|
||||
final bool focusable;
|
||||
|
||||
@override
|
||||
State<AppTooltip> createState() => _AppTooltipState();
|
||||
}
|
||||
|
||||
class _AppTooltipState extends State<AppTooltip> {
|
||||
final _tooltipKey = GlobalKey<TooltipState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(message: message, child: child);
|
||||
final tooltip = Tooltip(key: _tooltipKey, message: widget.message, child: widget.child);
|
||||
if (!widget.focusable) return tooltip;
|
||||
return Focus(
|
||||
onFocusChange: (focused) {
|
||||
if (focused) {
|
||||
_tooltipKey.currentState?.ensureTooltipVisible();
|
||||
} else {
|
||||
Tooltip.dismissAllToolTips();
|
||||
}
|
||||
},
|
||||
child: tooltip,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ class FileTreeItem<T> {
|
||||
required this.name,
|
||||
required this.size,
|
||||
required this.data,
|
||||
this.directoryOnly = false,
|
||||
this.namespace = '',
|
||||
});
|
||||
|
||||
final String key;
|
||||
@@ -23,6 +25,10 @@ class FileTreeItem<T> {
|
||||
final String name;
|
||||
final int size;
|
||||
final T data;
|
||||
final bool directoryOnly;
|
||||
|
||||
/// Keeps directories from independent resources distinct without adding a visible root.
|
||||
final String namespace;
|
||||
}
|
||||
|
||||
class FileTreeNode<T> {
|
||||
@@ -61,6 +67,7 @@ class FileTreeView<T> extends StatefulWidget {
|
||||
this.headerLeading,
|
||||
this.leadingBuilder,
|
||||
this.iconBuilder,
|
||||
this.iconWidgetBuilder,
|
||||
this.trailingBuilder,
|
||||
this.onNodePressed,
|
||||
this.keyPrefix = 'file-tree',
|
||||
@@ -71,12 +78,15 @@ class FileTreeView<T> extends StatefulWidget {
|
||||
this.contentTextStyle,
|
||||
this.iconSize = 18,
|
||||
this.initialExpandedDepth,
|
||||
this.directoryLabels = const {},
|
||||
});
|
||||
|
||||
final List<FileTreeItem<T>> items;
|
||||
final Map<String, String> directoryLabels;
|
||||
final Widget? headerLeading;
|
||||
final FileTreeNodeWidgetBuilder<T>? leadingBuilder;
|
||||
final FileTreeNodeIconBuilder<T>? iconBuilder;
|
||||
final FileTreeNodeWidgetBuilder<T>? iconWidgetBuilder;
|
||||
final FileTreeNodeWidgetBuilder<T>? trailingBuilder;
|
||||
final ValueChanged<FileTreeNode<T>>? onNodePressed;
|
||||
final String keyPrefix;
|
||||
@@ -115,7 +125,11 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nodes = _buildTree(widget.items, expandedDepth: widget.initialExpandedDepth);
|
||||
_nodes = _buildTree(
|
||||
widget.items,
|
||||
expandedDepth: widget.initialExpandedDepth,
|
||||
directoryLabels: widget.directoryLabels,
|
||||
);
|
||||
_allFoldersExpanded = _foldersAreExpanded(_nodes);
|
||||
_transitionController = AnimationController(vsync: this, duration: _treeTransitionDuration, value: 1)
|
||||
..addStatusListener(_onTransitionStatus);
|
||||
@@ -127,8 +141,12 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (identical(widget.items, oldWidget.items)) return;
|
||||
|
||||
final expandedKeys = _expandedFolderKeys(_nodes);
|
||||
var nodes = _restoreExpandedFolders(_buildTree(widget.items), expandedKeys);
|
||||
// During a collapse animation, the pending tree holds the user's target state.
|
||||
final expandedKeys = _expandedFolderKeys(_pendingCollapsedNodes ?? _nodes);
|
||||
var nodes = _restoreExpandedFolders(
|
||||
_buildTree(widget.items, directoryLabels: widget.directoryLabels),
|
||||
expandedKeys,
|
||||
);
|
||||
nodes = _sortNodes(nodes, _sortColumn, _sortDirection);
|
||||
_nodes = nodes;
|
||||
_allFoldersExpanded = _foldersAreExpanded(nodes);
|
||||
@@ -171,7 +189,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
label,
|
||||
() => _measureLabelWidth(label, style: labelStyle, textDirection: textDirection, textScaler: textScaler),
|
||||
);
|
||||
final nameContentWidth = _widestLabelWidth(widget.items, measureLabel);
|
||||
final nameContentWidth = _widestLabelWidth(_nodes, measureLabel);
|
||||
final fixedNameChrome =
|
||||
_treeNodeExpandChrome + widget.iconSize + 4 + (widget.leadingBuilder == null ? 0 : _treeLeadingChrome);
|
||||
|
||||
@@ -179,7 +197,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
builder: (context, constraints) {
|
||||
final treeWidth = constraints.hasBoundedWidth ? constraints.maxWidth : _treeFallbackWidth;
|
||||
final horizontalOverflow = _horizontalOverflow(
|
||||
widget.items,
|
||||
_nodes,
|
||||
baseNameViewport:
|
||||
treeWidth - _treeHorizontalPadding - trailingColumnWidth - _treeTrailingGap - fixedNameChrome,
|
||||
depthIndent: treeItemInset * 3,
|
||||
@@ -283,14 +301,15 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (leading != null) ...[leading, const SizedBox(width: 8)],
|
||||
Icon(
|
||||
widget.iconBuilder?.call(data) ??
|
||||
(data.isFolder
|
||||
? (visuallyExpanded ? BootstrapIcons.folder2Open : BootstrapIcons.folder2)
|
||||
: _fileIcon(data.label)),
|
||||
size: widget.iconSize,
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
widget.iconWidgetBuilder?.call(context, data) ??
|
||||
Icon(
|
||||
widget.iconBuilder?.call(data) ??
|
||||
(data.isFolder
|
||||
? (visuallyExpanded ? BootstrapIcons.folder2Open : BootstrapIcons.folder2)
|
||||
: _fileIcon(data.label)),
|
||||
size: widget.iconSize,
|
||||
color: palette.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
@@ -873,7 +892,11 @@ class _BuildNode<T> {
|
||||
}
|
||||
}
|
||||
|
||||
List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int? expandedDepth}) {
|
||||
List<TreeNode<FileTreeNode<T>>> _buildTree<T>(
|
||||
List<FileTreeItem<T>> items, {
|
||||
int? expandedDepth,
|
||||
Map<String, String> directoryLabels = const {},
|
||||
}) {
|
||||
final root = <_BuildNode<T>>[];
|
||||
final folders = <String, _BuildNode<T>>{};
|
||||
var originalIndex = 0;
|
||||
@@ -885,8 +908,13 @@ List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int?
|
||||
|
||||
for (final part in parts) {
|
||||
currentPath = currentPath.isEmpty ? part : '$currentPath/$part';
|
||||
final folder = folders.putIfAbsent(currentPath, () {
|
||||
final node = _BuildNode<T>(key: 'folder:$currentPath', label: part, originalIndex: originalIndex++);
|
||||
final directoryKey = item.namespace.isEmpty ? currentPath : '${item.namespace}:$currentPath';
|
||||
final folder = folders.putIfAbsent(directoryKey, () {
|
||||
final node = _BuildNode<T>(
|
||||
key: 'folder:$directoryKey',
|
||||
label: directoryLabels[directoryKey] ?? part,
|
||||
originalIndex: originalIndex++,
|
||||
);
|
||||
if (parent == null) {
|
||||
root.add(node);
|
||||
} else {
|
||||
@@ -897,6 +925,8 @@ List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int?
|
||||
parent = folder;
|
||||
}
|
||||
|
||||
if (item.directoryOnly) continue;
|
||||
|
||||
final fileNode = _BuildNode<T>(
|
||||
key: 'file:${item.key}',
|
||||
label: item.name,
|
||||
@@ -945,10 +975,10 @@ List<TreeNode<FileTreeNode<T>>> _sortNodes<T>(
|
||||
sorted.sort((a, b) {
|
||||
final left = a.data;
|
||||
final right = b.data;
|
||||
if (left.isFolder != right.isFolder) return left.isFolder ? -1 : 1;
|
||||
if (column == null || direction == _FileTreeSortDirection.none) {
|
||||
return left.originalIndex.compareTo(right.originalIndex);
|
||||
}
|
||||
if (left.isFolder != right.isFolder) return left.isFolder ? -1 : 1;
|
||||
final comparison = switch (column) {
|
||||
_FileTreeSortColumn.name => left.label.toLowerCase().compareTo(right.label.toLowerCase()),
|
||||
_FileTreeSortColumn.size => left.size.compareTo(right.size),
|
||||
@@ -994,7 +1024,7 @@ Set<String> _expandedFolderKeys<T>(Iterable<TreeNode<FileTreeNode<T>>> nodes) {
|
||||
final keys = <String>{};
|
||||
void collect(TreeNode<FileTreeNode<T>> node) {
|
||||
final item = node as TreeItemNode<FileTreeNode<T>>;
|
||||
if (item.children.isNotEmpty && item.expanded) keys.add(item.data.key);
|
||||
if (item.data.isFolder && item.expanded) keys.add(item.data.key);
|
||||
for (final child in item.children) {
|
||||
collect(child);
|
||||
}
|
||||
@@ -1016,7 +1046,7 @@ List<TreeNode<FileTreeNode<T>>> _restoreExpandedFolders<T>(
|
||||
final children = _restoreExpandedFolders(item.children, expandedKeys);
|
||||
return item
|
||||
.updateChildren(children)
|
||||
.updateState(expanded: item.children.isNotEmpty && expandedKeys.contains(item.data.key));
|
||||
.updateState(expanded: item.data.isFolder && expandedKeys.contains(item.data.key));
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
@@ -1036,13 +1066,12 @@ String _extensionOf(String name) {
|
||||
return name.substring(index + 1).toLowerCase();
|
||||
}
|
||||
|
||||
double _widestLabelWidth<T>(List<FileTreeItem<T>> items, double Function(String label) measureLabel) {
|
||||
double _widestLabelWidth<T>(List<TreeNode<FileTreeNode<T>>> nodes, double Function(String label) measureLabel) {
|
||||
var widest = _treeMinimumNameContentWidth;
|
||||
for (final item in items) {
|
||||
widest = math.max(widest, measureLabel(item.name));
|
||||
for (final segment in item.path.split(RegExp(r'[/\\]+'))) {
|
||||
if (segment.isNotEmpty) widest = math.max(widest, measureLabel(segment));
|
||||
}
|
||||
for (final node in nodes) {
|
||||
final item = node as TreeItemNode<FileTreeNode<T>>;
|
||||
widest = math.max(widest, measureLabel(item.data.label));
|
||||
widest = math.max(widest, _widestLabelWidth(item.children, measureLabel));
|
||||
}
|
||||
return widest;
|
||||
}
|
||||
@@ -1063,20 +1092,27 @@ double _measureLabelWidth(
|
||||
}
|
||||
|
||||
double _horizontalOverflow<T>(
|
||||
List<FileTreeItem<T>> items, {
|
||||
List<TreeNode<FileTreeNode<T>>> nodes, {
|
||||
required double baseNameViewport,
|
||||
required double depthIndent,
|
||||
required double Function(String label) measureLabel,
|
||||
int depth = 0,
|
||||
}) {
|
||||
var overflow = 0.0;
|
||||
for (final item in items) {
|
||||
final segments = item.path.split(RegExp(r'[/\\]+')).where((segment) => segment.isNotEmpty).toList();
|
||||
for (var depth = 0; depth < segments.length; depth++) {
|
||||
final viewport = math.max(_treeMinimumNameViewport, baseNameViewport - (depth * depthIndent));
|
||||
overflow = math.max(overflow, measureLabel(segments[depth]) - viewport);
|
||||
}
|
||||
final fileViewport = math.max(_treeMinimumNameViewport, baseNameViewport - (segments.length * depthIndent));
|
||||
overflow = math.max(overflow, measureLabel(item.name) - fileViewport);
|
||||
final viewport = math.max(_treeMinimumNameViewport, baseNameViewport - depth * depthIndent);
|
||||
for (final node in nodes) {
|
||||
final item = node as TreeItemNode<FileTreeNode<T>>;
|
||||
overflow = math.max(overflow, measureLabel(item.data.label) - viewport);
|
||||
overflow = math.max(
|
||||
overflow,
|
||||
_horizontalOverflow(
|
||||
item.children,
|
||||
baseNameViewport: baseNameViewport,
|
||||
depthIndent: depthIndent,
|
||||
measureLabel: measureLabel,
|
||||
depth: depth + 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
return math.max(0, overflow);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api;
|
||||
import 'package:gopeed/api/model/create_task.dart';
|
||||
import 'package:gopeed/api/model/create_task_batch.dart';
|
||||
import 'package:gopeed/api/model/options.dart';
|
||||
import 'package:gopeed/api/model/patch_task.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
@@ -39,6 +40,32 @@ void main() {
|
||||
expect((createBody['opts'] as Map<String, dynamic>)['selectFiles'], [1]);
|
||||
});
|
||||
|
||||
test('batch sends rid and options and decodes the unified response in both modes', () async {
|
||||
for (final grouped in [false, true]) {
|
||||
final transport = _RecordingTransport();
|
||||
api.setTransportForTesting(transport);
|
||||
final result = await api.createTaskBatch(
|
||||
CreateTaskBatch(
|
||||
group: grouped,
|
||||
groupName: grouped ? 'folder' : '',
|
||||
reqs: [
|
||||
CreateTaskBatchItem(
|
||||
rid: 'resolved-id',
|
||||
opts: Options(selectFiles: [1]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
expect(result.taskIds, ['created-id']);
|
||||
expect(result.parentId, grouped ? 'parent' : null);
|
||||
final body = transport.data as Map<String, dynamic>;
|
||||
final member = (body['reqs'] as List).single as Map<String, dynamic>;
|
||||
expect(member['rid'], 'resolved-id');
|
||||
expect((member['opts'] as Map)['selectFiles'], [1]);
|
||||
expect(body, isNot(contains('groupId')));
|
||||
}
|
||||
});
|
||||
|
||||
test('patch preserves options in its independent request model', () async {
|
||||
final transport = _RecordingTransport();
|
||||
api.setTransportForTesting(transport);
|
||||
@@ -59,6 +86,15 @@ class _RecordingTransport implements GopeedTransport {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
this.data = jsonDecode(jsonEncode(data));
|
||||
if (path == 'api/v1/tasks/batch') {
|
||||
return {
|
||||
'code': 0,
|
||||
'data': {
|
||||
'taskIds': ['created-id'],
|
||||
if (this.data['group'] == true) 'parentId': 'parent',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path == 'api/v1/tasks') {
|
||||
return {'code': 0, 'data': 'created-id'};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/create_task_batch.dart';
|
||||
import 'package:gopeed/api/model/options.dart';
|
||||
import 'package:gopeed/api/model/patch_task.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
@@ -21,6 +22,47 @@ class _Payload {
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('group creation crosses the window boundary without losing directory or child options', () async {
|
||||
final codecs = createAppCapabilityCodecs();
|
||||
CreateTaskBatch? submitted;
|
||||
final registry = CapabilityRegistry(codecs)
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted = batch;
|
||||
return CreateTaskBatchResult(taskIds: ['child'], parentId: 'generated');
|
||||
});
|
||||
final batch = CreateTaskBatch(
|
||||
group: true,
|
||||
groupName: 'folder',
|
||||
opts: Options(path: '/downloads'),
|
||||
reqs: [
|
||||
CreateTaskBatchItem(
|
||||
rid: 'resolved-rid',
|
||||
req: Request(url: 'https://example.com/file'),
|
||||
opts: Options(path: '/downloads/folder/nested', name: 'file.txt'),
|
||||
),
|
||||
],
|
||||
);
|
||||
final result = codecs.decode<CreateTaskBatchResult>(
|
||||
await registry.invoke(GopeedMethods.createTaskBatch.name, codecs.encode(batch)),
|
||||
);
|
||||
expect(result.taskIds, ['child']);
|
||||
expect(result.parentId, 'generated');
|
||||
expect(submitted?.group, isTrue);
|
||||
expect(submitted?.reqs?.single.rid, 'resolved-rid');
|
||||
expect(submitted?.toJson().containsKey('groupId'), isFalse);
|
||||
expect(submitted?.opts?.path, '/downloads');
|
||||
expect(submitted?.reqs?.single.opts?.path, '/downloads/folder/nested');
|
||||
expect(CreateTaskBatch.fromJson({'reqs': []}).group, isFalse);
|
||||
expect(
|
||||
CreateTaskBatchResult.fromJson({
|
||||
'taskIds': ['ordinary'],
|
||||
}).toJson(),
|
||||
{
|
||||
'taskIds': ['ordinary'],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('serialized resolve and patch capabilities keep separate request contracts', () async {
|
||||
final codecs = createAppCapabilityCodecs();
|
||||
String? resolvedUrl;
|
||||
@@ -28,7 +70,10 @@ void main() {
|
||||
final registry = CapabilityRegistry(codecs)
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolvedUrl = request.req?.url;
|
||||
return ResolveResult(id: 'resolved-id', res: Resource(files: []));
|
||||
return ResolveResult(
|
||||
id: 'resolved-id',
|
||||
res: Resource(files: []),
|
||||
);
|
||||
})
|
||||
..bind(GopeedMethods.patchTask, (request) {
|
||||
patch = request;
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api;
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/storage_capability.dart';
|
||||
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart';
|
||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
final host = LocalAppCapabilities.instance;
|
||||
late _ConfigTransport transport;
|
||||
|
||||
setUp(() {
|
||||
transport = _ConfigTransport();
|
||||
api.setTransportForTesting(transport);
|
||||
});
|
||||
|
||||
test('local and child saves serialize the full history read-modify-write', () async {
|
||||
final first = host.capabilities.storage.saveCreateHistory(['A']);
|
||||
await transport.firstPutStarted.future;
|
||||
final second = host.registry.invoke(StorageMethods.saveCreateHistory.name, ['B']);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final readsWhileSaving = transport.reads;
|
||||
transport.releaseFirstPut.complete();
|
||||
await Future.wait<Object?>([first, second]);
|
||||
expect(transport.config.extra.createHistory, ['B', 'A']);
|
||||
expect(readsWhileSaving, 1, reason: 'the next GET must wait for the previous PUT');
|
||||
});
|
||||
|
||||
test('history and config reads wait for previously requested saves', () async {
|
||||
final saving = host.capabilities.storage.saveCreateHistory(['A']);
|
||||
await transport.firstPutStarted.future;
|
||||
final history = host.capabilities.storage.getCreateHistory();
|
||||
final config = host.capabilities.gopeed.getConfig();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
final readsWhileSaving = transport.reads;
|
||||
transport.releaseFirstPut.complete();
|
||||
await saving;
|
||||
expect(await history, ['A']);
|
||||
expect((await config).extra.createHistory, ['A']);
|
||||
expect(readsWhileSaving, 1);
|
||||
});
|
||||
|
||||
for (final historyFirst in [true, false]) {
|
||||
test('overlapping full config and history saves preserve both changes (history first: $historyFirst)', () async {
|
||||
transport.config.extra.createHistory = ['existing'];
|
||||
final snapshot = await host.capabilities.gopeed.getConfig();
|
||||
snapshot.downloadDir = '/chosen';
|
||||
snapshot.maxRunning = 7;
|
||||
final first = historyFirst
|
||||
? host.capabilities.storage.saveCreateHistory(['A'])
|
||||
: host.capabilities.gopeed.putConfig(snapshot);
|
||||
await transport.firstPutStarted.future;
|
||||
final second = historyFirst
|
||||
? host.capabilities.gopeed.putConfig(snapshot)
|
||||
: host.capabilities.storage.saveCreateHistory(['A']);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
transport.releaseFirstPut.complete();
|
||||
await Future.wait([first, second]);
|
||||
expect(transport.config.extra.createHistory, ['A', 'existing']);
|
||||
expect(transport.config.downloadDir, '/chosen');
|
||||
expect(transport.config.maxRunning, 7);
|
||||
});
|
||||
}
|
||||
|
||||
test('an old full config cannot restore removed or cleared history', () async {
|
||||
transport.blockFirstPut = false;
|
||||
transport.config.extra.createHistory = ['A', 'B'];
|
||||
final snapshot = await host.capabilities.gopeed.getConfig();
|
||||
await host.capabilities.storage.removeCreateHistory('A');
|
||||
await host.capabilities.gopeed.putConfig(snapshot);
|
||||
expect(transport.config.extra.createHistory, ['B']);
|
||||
await host.capabilities.storage.clearCreateHistory();
|
||||
await host.capabilities.gopeed.putConfig(snapshot);
|
||||
expect(transport.config.extra.createHistory, isEmpty);
|
||||
expect(snapshot.extra.createHistory, ['A', 'B'], reason: 'saving must not mutate the caller snapshot');
|
||||
});
|
||||
|
||||
test('history save, remove, clear and save follow request order', () async {
|
||||
transport.config.extra.createHistory = ['existing'];
|
||||
final first = host.capabilities.storage.saveCreateHistory(['A']);
|
||||
await transport.firstPutStarted.future;
|
||||
final remove = host.capabilities.storage.removeCreateHistory('existing');
|
||||
final clear = host.capabilities.storage.clearCreateHistory();
|
||||
final second = host.capabilities.storage.saveCreateHistory(['B']);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
transport.releaseFirstPut.complete();
|
||||
await Future.wait([first, remove, clear, second]);
|
||||
expect(transport.config.extra.createHistory, ['B']);
|
||||
});
|
||||
|
||||
test('a failed save reports its error without blocking subsequent saves or reads', () async {
|
||||
transport.failNextPut = true;
|
||||
final first = host.capabilities.storage.saveCreateHistory(['A']);
|
||||
final failed = expectLater(first, throwsA(isA<StateError>()));
|
||||
await transport.firstPutStarted.future;
|
||||
final second = host.capabilities.storage.saveCreateHistory(['B']);
|
||||
final history = host.capabilities.storage.getCreateHistory();
|
||||
transport.releaseFirstPut.complete();
|
||||
await failed;
|
||||
await second;
|
||||
expect(await history, ['B']);
|
||||
});
|
||||
|
||||
testWidgets('confirm A, cancel, confirm B saves both while previews open immediately', (tester) async {
|
||||
tester.view.physicalSize = const Size(900, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(child: const CreateTaskWindowPage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
// The host queue is created outside the widget clock.
|
||||
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||
await tester.pumpAndSettle();
|
||||
final input = find.byKey(const ValueKey('create-task-url-input'));
|
||||
await tester.enterText(input, 'https://example.com/A');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(transport.firstPutStarted.isCompleted, isTrue);
|
||||
expect(transport.releaseFirstPut.isCompleted, isFalse);
|
||||
expect(find.byKey(const ValueKey('resolve-cancel-button')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(input, 'https://example.com/B');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.byKey(const ValueKey('resolve-cancel-button')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(shad.Icons.history));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
transport.releaseFirstPut.complete();
|
||||
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
|
||||
await tester.pumpAndSettle();
|
||||
expect(transport.config.extra.createHistory, ['https://example.com/B', 'https://example.com/A']);
|
||||
for (final url in ['https://example.com/A', 'https://example.com/B']) {
|
||||
expect(find.byKey(ValueKey('create-history-item-$url')), findsOneWidget);
|
||||
}
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
class _ConfigTransport implements GopeedTransport {
|
||||
DownloaderConfig config = DownloaderConfig(downloadDir: '/downloads');
|
||||
final firstPutStarted = Completer<void>();
|
||||
final releaseFirstPut = Completer<void>();
|
||||
bool blockFirstPut = true;
|
||||
bool failNextPut = false;
|
||||
int reads = 0;
|
||||
int writes = 0;
|
||||
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async {
|
||||
if (path == 'api/v1/resolve') {
|
||||
return {
|
||||
'code': 0,
|
||||
'data': {
|
||||
'id': 'rid',
|
||||
'res': {
|
||||
'files': [
|
||||
{'name': 'file.txt'},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path != 'api/v1/config') throw StateError('Unexpected request: $method $path');
|
||||
if (method == 'GET') {
|
||||
reads++;
|
||||
return {'code': 0, 'data': jsonDecode(jsonEncode(config))};
|
||||
}
|
||||
final snapshot = DownloaderConfig.fromJson(jsonDecode(jsonEncode(data)) as Map<String, dynamic>);
|
||||
final shouldFail = failNextPut;
|
||||
failNextPut = false;
|
||||
if (++writes == 1 && blockFirstPut) {
|
||||
firstPutStarted.complete();
|
||||
await releaseFirstPut.future;
|
||||
}
|
||||
if (shouldFail) {
|
||||
throw StateError('config save failed');
|
||||
}
|
||||
config = snapshot;
|
||||
return {'code': 0};
|
||||
}
|
||||
|
||||
@override
|
||||
String join(String path) => path;
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/gestures.dart' show kSecondaryMouseButton;
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:gopeed/api/model/task.dart' as api;
|
||||
import 'package:gopeed/features/home/presentation/pages/home_page.dart';
|
||||
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
|
||||
import 'package:gopeed/features/tasks/domain/task_record.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/pages/task_details_page.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/pages/task_files_page.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_card/task_card.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_drawer.dart';
|
||||
import 'package:gopeed/app/application/app_runtime_controller.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/core/common/start_config.dart';
|
||||
import 'package:gopeed/core/common/api_server_state.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
api.Task member(String id, {String name = 'member.txt', bool bt = false, String status = 'running'}) =>
|
||||
api.Task.fromJson({
|
||||
'id': id,
|
||||
'name': name,
|
||||
'parentId': 'group',
|
||||
'group': {'id': 'group', 'name': 'bundle', 'path': '/downloads/bundle', 'createdAt': '2026-01-01T00:00:00Z'},
|
||||
'protocol': bt ? 'bt' : 'http',
|
||||
'status': status,
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-01T00:00:00Z',
|
||||
'meta': {
|
||||
'req': {'url': bt ? 'magnet:?xt=urn:btih:test' : 'https://example.com/$id'},
|
||||
'opts': {'path': '/downloads/bundle', 'name': name, 'size': 100},
|
||||
'res': {
|
||||
'name': bt ? name : '',
|
||||
'size': 100,
|
||||
'files': [
|
||||
{'name': bt ? 'bt-first.txt' : name, 'size': 50},
|
||||
if (bt) {'name': 'bt-second.txt', 'size': 50},
|
||||
],
|
||||
},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 0, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
|
||||
});
|
||||
|
||||
class GroupTasks extends TasksController {
|
||||
String name = 'member.txt';
|
||||
String childStatus = 'running';
|
||||
final paused = <String>[];
|
||||
final resumed = <String>[];
|
||||
final deleted = <String>[];
|
||||
|
||||
TasksState snapshot() => TasksState(
|
||||
tasks: taskRecordsFromApi([
|
||||
member('child', name: name, status: childStatus),
|
||||
member('torrent', name: 'torrent bundle', bt: true, status: 'pause'),
|
||||
]),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<TasksState> build() async => snapshot();
|
||||
|
||||
void rename() {
|
||||
name = 'renamed.txt';
|
||||
state = AsyncData(snapshot());
|
||||
}
|
||||
|
||||
void completeMember() {
|
||||
childStatus = 'done';
|
||||
state = AsyncData(snapshot());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause(String id) async => paused.add(id);
|
||||
@override
|
||||
Future<void> pauseAll(List<String>? ids) async => paused.addAll(ids!);
|
||||
@override
|
||||
Future<void> resume(String id) async => resumed.add(id);
|
||||
@override
|
||||
Future<void> resumeAll(List<String>? ids) async => resumed.addAll(ids!);
|
||||
@override
|
||||
Future<void> deleteSelected(List<String>? ids, {bool force = false}) async {
|
||||
deleted.addAll(ids!);
|
||||
state = AsyncData(
|
||||
TasksState(
|
||||
tasks: taskRecordsFromApi([
|
||||
if (!deleted.contains('child') && !deleted.contains('group')) member('child', name: name),
|
||||
if (!deleted.contains('torrent') && !deleted.contains('group'))
|
||||
member('torrent', name: 'torrent bundle', bt: true, status: 'pause'),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class GroupRuntime extends AppRuntimeController {
|
||||
@override
|
||||
Future<AppRuntimeState> build() async => AppRuntimeState(
|
||||
startConfig: StartConfig(),
|
||||
apiServerState: const ApiServerState(
|
||||
enabled: false,
|
||||
mcpEnabled: false,
|
||||
running: false,
|
||||
network: '',
|
||||
address: '',
|
||||
runningPort: 0,
|
||||
pendingApply: false,
|
||||
lastError: '',
|
||||
),
|
||||
downloaderConfig: DownloaderConfig(),
|
||||
);
|
||||
@override
|
||||
Future<void> reloadConfig() async {}
|
||||
}
|
||||
|
||||
Future<void> settle(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
Finder card(String id) => find.byWidgetPredicate((widget) => widget is TaskCard && widget.task.id == id);
|
||||
|
||||
Future<({GroupTasks controller, GoRouter router})> showHome(WidgetTester tester, double width) async {
|
||||
tester.view.physicalSize = Size(width, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const HomePage()),
|
||||
GoRoute(
|
||||
path: '/tasks/:id',
|
||||
builder: (_, state) =>
|
||||
TaskDetailsPage(taskId: state.pathParameters['id']!, initialTask: state.extra as TaskRecord?),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tasks/:id/files',
|
||||
builder: (_, state) =>
|
||||
TaskFilesPage(taskId: state.pathParameters['id']!, initialTask: state.extra as TaskRecord?),
|
||||
),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
late GroupTasks controller;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.putConfig, (_) => const RpcUnit());
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
tasksControllerProvider.overrideWith(() => controller = GroupTasks()),
|
||||
appRuntimeControllerProvider.overrideWith(GroupRuntime.new),
|
||||
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
|
||||
],
|
||||
child: shad.ShadcnApp.router(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
routerConfig: router,
|
||||
),
|
||||
),
|
||||
);
|
||||
await settle(tester);
|
||||
return (controller: controller, router: router);
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 700.0, 1100.0]) {
|
||||
testWidgets('group expands actual member tasks and keeps a multi-file BT as one card at $width', (tester) async {
|
||||
final app = await showHome(tester, width);
|
||||
expect(card('group'), findsOneWidget);
|
||||
expect(card('child'), findsNothing);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
expect(card('child'), findsOneWidget);
|
||||
expect(card('torrent'), findsOneWidget);
|
||||
expect(find.text('bt-first.txt'), findsNothing);
|
||||
expect(find.text('bt-second.txt'), findsNothing);
|
||||
expect(find.byType(TaskFileManagerView), findsNothing);
|
||||
expect(find.byType(TaskDetailsView), findsNothing);
|
||||
app.controller.rename();
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskCard>(card('child')).task.name, 'renamed.txt');
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
expect(card('child'), findsNothing);
|
||||
expect(card('torrent'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('member click opens its own details and polling refreshes the selected member at $width', (
|
||||
tester,
|
||||
) async {
|
||||
final app = await showHome(tester, width);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('member.txt'));
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'child');
|
||||
app.controller.rename();
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.name, 'renamed.txt');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('member context menu and card actions target member IDs while group actions keep the group ID', (
|
||||
tester,
|
||||
) async {
|
||||
final app = await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-pause')));
|
||||
await settle(tester);
|
||||
expect(app.controller.paused, ['child']);
|
||||
tester.widget<TaskCard>(card('torrent')).onResume!();
|
||||
await settle(tester);
|
||||
expect(app.controller.resumed, ['torrent']);
|
||||
tester.widget<TaskCard>(card('group')).onPause!();
|
||||
await settle(tester);
|
||||
expect(app.controller.paused, ['child', 'group']);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final width in [390.0, 1100.0]) {
|
||||
testWidgets('group resume button targets the group at $width', (tester) async {
|
||||
final app = await showHome(tester, width);
|
||||
await tester.tap(find.descendant(of: card('group'), matching: find.byIcon(Icons.play_arrow)));
|
||||
await settle(tester);
|
||||
expect(app.controller.resumed, ['group']);
|
||||
expect(card('child'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
for (final open in [true, false]) {
|
||||
testWidgets('member card and menu ${open ? 'open' : 'reveal'} use the member path', (tester) async {
|
||||
final app = await showHome(tester, 1100);
|
||||
app.controller.completeMember();
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
final checkedPaths = <String>[];
|
||||
final child = tester.widget<TaskCard>(card('child'));
|
||||
await IOOverrides.runZoned(
|
||||
() async {
|
||||
(open ? child.onOpen : child.onReveal)!();
|
||||
await settle(tester);
|
||||
expect(checkedPaths.first, '/downloads/bundle/member.txt');
|
||||
checkedPaths.clear();
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(ValueKey(open ? 'task-context-open-file' : 'task-context-open-directory')));
|
||||
await settle(tester);
|
||||
expect(checkedPaths.first, '/downloads/bundle/member.txt');
|
||||
},
|
||||
fseGetType: (path, followLinks) async {
|
||||
checkedPaths.add(path);
|
||||
return FileSystemEntityType.notFound;
|
||||
},
|
||||
);
|
||||
// Allow the failure-path toast to close without launching native tools.
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
await tester.pump(const Duration(seconds: 6));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('member menu details uses the member instead of the group', (tester) async {
|
||||
await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('torrent')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-details')));
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'torrent');
|
||||
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.files, hasLength(2));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('selecting a member through its context menu enables batch actions for that member', (tester) async {
|
||||
final app = await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-select')));
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskCard>(card('child')).selectedInBatch, isTrue);
|
||||
final pause = find.byIcon(Icons.pause_outlined);
|
||||
await tester.tap(pause);
|
||||
await settle(tester);
|
||||
expect(app.controller.paused, ['child']);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('deleting a member leaves its sibling group intact', (tester) async {
|
||||
final app = await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-delete')));
|
||||
await settle(tester);
|
||||
expect(find.byType(shad.DestructiveButton), findsOneWidget);
|
||||
await tester.tap(find.byType(shad.DestructiveButton));
|
||||
await settle(tester);
|
||||
expect(app.controller.deleted, ['child']);
|
||||
expect(card('group'), findsOneWidget);
|
||||
expect(card('child'), findsNothing);
|
||||
expect(card('torrent'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('deleting a selected group and its members counts and submits the group once', (tester) async {
|
||||
final app = await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('group')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-select-all')));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-delete')));
|
||||
await settle(tester);
|
||||
expect(find.text('Delete task?'), findsOneWidget);
|
||||
await tester.tap(find.byType(shad.DestructiveButton));
|
||||
await settle(tester);
|
||||
expect(app.controller.deleted, ['group']);
|
||||
expect(card('group'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('member context file browser polls its member record', (tester) async {
|
||||
final app = await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-browse-files')));
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'member.txt');
|
||||
app.controller.rename();
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'renamed.txt');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('group clicks in batch mode select without collapsing members', (tester) async {
|
||||
await showHome(tester, 1100);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
|
||||
await settle(tester);
|
||||
await tester.tap(find.byKey(const ValueKey('task-context-select')));
|
||||
await settle(tester);
|
||||
await tester.tap(find.text('bundle'));
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskCard>(card('group')).selectedInBatch, isTrue);
|
||||
expect(tester.widget<TaskCard>(card('group')).expanded, isTrue);
|
||||
expect(card('child'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('member detail route resolves the member without an initial snapshot', (tester) async {
|
||||
final app = await showHome(tester, 390);
|
||||
app.router.go('/tasks/child');
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'child');
|
||||
app.controller.rename();
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.name, 'renamed.txt');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('member file page resolves current data from its group without a route snapshot', (tester) async {
|
||||
final app = await showHome(tester, 390);
|
||||
app.router.go('/tasks/child/files');
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'member.txt');
|
||||
app.controller.rename();
|
||||
await settle(tester);
|
||||
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'renamed.txt');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/create_task_batch.dart';
|
||||
import 'package:gopeed/api/model/options.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
import 'package:gopeed/api/model/resource.dart';
|
||||
import 'package:gopeed/api/model/resolve_result.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/features/tasks/application/pending_download.dart';
|
||||
|
||||
PendingDownload source(String id, {String name = 'bundle', String rid = '', int count = 2, String? url}) =>
|
||||
PendingDownload(
|
||||
id: id,
|
||||
request: Request(url: url ?? 'https://example.com/$id'),
|
||||
options: Options(path: '/downloads'),
|
||||
)
|
||||
..result = ResolveResult(
|
||||
id: rid,
|
||||
res: Resource(
|
||||
name: name,
|
||||
files: List.generate(
|
||||
count,
|
||||
(i) => FileInfo(
|
||||
name: '$i.txt',
|
||||
size: 100 + i,
|
||||
req: Request(url: 'https://example.com/$id/$i'),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
..selected.addAll(List.generate(count, (i) => i));
|
||||
|
||||
GopeedService service(CapabilityRegistry registry) => GopeedService(LocalCapabilityInvoker(registry));
|
||||
|
||||
void main() {
|
||||
test('one named extension group and all independent sources produce exactly two batches', () {
|
||||
final group = source('a')..selected.remove(0);
|
||||
final unnamed = source('b', name: '');
|
||||
final torrent = source('c', rid: 'bt-rid')..selected.remove(0);
|
||||
final http = source('d', rid: 'http-rid', count: 1, name: '');
|
||||
final batches = planDownloads([group, unnamed, torrent, http]);
|
||||
expect(batches, hasLength(2));
|
||||
final grouped = batches.first.request;
|
||||
expect(grouped.group, isTrue);
|
||||
expect(grouped.groupName, 'bundle');
|
||||
expect(grouped.opts!.path, '/downloads');
|
||||
expect(grouped.reqs!.single.req!.url, 'https://example.com/a/1');
|
||||
expect(grouped.toJson(), isNot(contains('groupId')));
|
||||
final normal = batches.last.request;
|
||||
expect(normal.group, isFalse);
|
||||
expect(normal.reqs, hasLength(4));
|
||||
expect(normal.reqs!.take(2).every((item) => item.req != null && item.rid == null), isTrue);
|
||||
expect(normal.reqs![2].rid, 'bt-rid');
|
||||
expect(normal.reqs![2].opts!.selectFiles, [1]);
|
||||
expect(normal.reqs![3].rid, 'http-rid');
|
||||
expect(normal.reqs![3].req, isNull);
|
||||
});
|
||||
|
||||
test('successful batches are complete even when only some task IDs are returned', () async {
|
||||
var calls = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.createTaskBatch, (_) {
|
||||
calls++;
|
||||
return CreateTaskBatchResult(taskIds: ['only-one']);
|
||||
});
|
||||
final sources = [source('a', name: ''), source('b', rid: 'rid')];
|
||||
await planDownloads(sources).single.submit(service(registry));
|
||||
expect(sources.every((s) => s.created && s.createError == null), isTrue);
|
||||
expect(planDownloads(sources), isEmpty);
|
||||
expect(calls, 1);
|
||||
});
|
||||
|
||||
test('retry only submits failed batches and never appends to the successful group', () async {
|
||||
final submitted = <CreateTaskBatch>[];
|
||||
var failNormal = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted.add(batch);
|
||||
if (!batch.group && failNormal) throw StateError('all failed');
|
||||
return CreateTaskBatchResult(taskIds: ['created'], parentId: batch.group ? 'parent' : null);
|
||||
});
|
||||
final sources = [source('group'), source('normal', name: '')];
|
||||
for (final batch in planDownloads(sources)) {
|
||||
await batch.submit(service(registry));
|
||||
}
|
||||
expect(sources.first.created, isTrue);
|
||||
expect(sources.last.created, isFalse);
|
||||
expect(sources.last.createError, contains('all failed'));
|
||||
failNormal = false;
|
||||
final retry = planDownloads(sources);
|
||||
expect(retry, hasLength(1));
|
||||
await retry.single.submit(service(registry));
|
||||
expect(submitted.where((batch) => batch.group), hasLength(1));
|
||||
expect(planDownloads(sources), isEmpty);
|
||||
});
|
||||
|
||||
test('single extension keeps nested resource directory and empty base remains empty', () {
|
||||
final single = source('single', count: 1, name: 'album');
|
||||
single.result!.res.files.single.path = 'disc1';
|
||||
expect(single.isGroup, isFalse);
|
||||
expect(planDownloads([single]).single.request.reqs!.single.opts!.path, '/downloads/album/disc1');
|
||||
final plain = source('plain', count: 1, name: '')..options.path = '';
|
||||
expect(plain.creationItems().single.opts!.path, '');
|
||||
});
|
||||
|
||||
for (final parent in ['', 'downloads', './downloads', '/downloads']) {
|
||||
test('group members use paths relative to their group for parent "$parent"', () {
|
||||
final pending = source('group')..options.path = parent;
|
||||
pending.files[1].path = 'nested';
|
||||
final batch = planDownloads([pending]).single.request;
|
||||
expect(batch.group, isTrue);
|
||||
expect(batch.opts!.path, parent);
|
||||
expect(batch.reqs!.map((item) => item.opts!.path), ['', 'nested']);
|
||||
expect(pending.options.path, parent);
|
||||
});
|
||||
}
|
||||
|
||||
test('unnamed extension members retain their ordinary batch destination', () {
|
||||
final pending = source('independent', name: '')..options.path = 'downloads';
|
||||
pending.files[1].path = 'nested';
|
||||
final batch = planDownloads([pending]).single.request;
|
||||
expect(batch.group, isFalse);
|
||||
expect(batch.reqs!.map((item) => item.opts!.path), ['downloads', 'downloads/nested']);
|
||||
});
|
||||
|
||||
test('members preserve directory intent, HTTP sizes and per-file checksum rules', () {
|
||||
final pending = source('group');
|
||||
pending.options.extra = OptsExtraHttp(
|
||||
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
|
||||
).toJson();
|
||||
final members = pending.creationItems();
|
||||
expect(members.first.opts!.size, 100);
|
||||
expect(members.first.opts!.asDefaultPath, isFalse);
|
||||
expect(members.first.opts!.skipAutoCategorize, isTrue);
|
||||
expect(members.first.opts!.extra as Map, isNot(contains('checksum')));
|
||||
pending.selected.remove(0);
|
||||
expect(pending.creationItems().single.opts!.extra as Map, contains('checksum'));
|
||||
expect(pending.options.extra as Map, contains('checksum'));
|
||||
pending.result!.res.files[1].req = Request(url: 'magnet:?xt=urn:btih:test');
|
||||
expect(pending.creationItems().single.opts!.size, 0);
|
||||
});
|
||||
|
||||
test('FTP extension members use FTP options and keep their request TLS mode', () {
|
||||
final pending = source('group');
|
||||
pending.options.extra = OptsExtraHttp(
|
||||
connections: 64,
|
||||
autoExtract: true,
|
||||
archivePassword: 'secret',
|
||||
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
|
||||
).toJson();
|
||||
pending.files[0].req = Request(
|
||||
url: 'ftp://files.example/archive.zip',
|
||||
extra: ReqExtraFtp(tls: 'explicit').toJson(),
|
||||
);
|
||||
pending.selected.remove(1);
|
||||
final item = pending.creationItems().single;
|
||||
expect(item.req!.extra, {'tls': 'explicit'});
|
||||
expect(item.opts!.size, 0);
|
||||
final extra = item.opts!.extra as Map;
|
||||
expect(extra['connections'], 32);
|
||||
expect(extra['autoExtract'], isTrue);
|
||||
expect(extra['archivePassword'], 'secret');
|
||||
expect(extra.containsKey('checksum'), isFalse);
|
||||
expect((pending.options.extra as Map)['connections'], 64);
|
||||
});
|
||||
|
||||
test('member option changes do not affect other members or source options', () {
|
||||
final pending = source('group');
|
||||
pending.options
|
||||
..name = 'custom'
|
||||
..size = 500
|
||||
..selectFiles = [0, 1]
|
||||
..asDefaultPath = true;
|
||||
final original = pending.options.toJson();
|
||||
final members = pending.creationItems();
|
||||
members.first.opts!
|
||||
..name = 'changed.txt'
|
||||
..path = '/elsewhere'
|
||||
..size = 1
|
||||
..selectFiles.add(99);
|
||||
expect(members.last.opts!.name, '1.txt');
|
||||
expect(members.last.opts!.path, '');
|
||||
expect(members.last.opts!.size, 101);
|
||||
expect(members.last.opts!.selectFiles, [0, 1]);
|
||||
expect(pending.options.toJson(), original);
|
||||
});
|
||||
|
||||
for (final rid in ['', 'resolved-rid']) {
|
||||
test('empty resource preserves options and selects the creation method for rid="$rid"', () {
|
||||
final pending = source('empty', rid: rid, count: 0)..selected.add(0);
|
||||
pending.options
|
||||
..name = 'custom'
|
||||
..selectFiles = [2];
|
||||
final item = pending.creationItems().single;
|
||||
expect(item.rid, rid.isEmpty ? null : rid);
|
||||
expect(item.req, rid.isEmpty ? pending.request : null);
|
||||
expect(identical(item.opts, pending.options), isFalse);
|
||||
expect(item.opts!.toJson(), pending.options.toJson());
|
||||
});
|
||||
}
|
||||
|
||||
test('a magnet resolved by an extension keeps its HTTP members and group identity', () {
|
||||
final pending = source('extension', url: 'magnet:?xt=urn:btih:infohash');
|
||||
expect(pending.isBt, isFalse);
|
||||
expect(pending.isGroup, isTrue);
|
||||
final batch = planDownloads([pending]).single.request;
|
||||
expect(batch.group, isTrue);
|
||||
expect(batch.reqs, hasLength(2));
|
||||
expect(batch.reqs!.every((item) => item.rid == null && item.req!.url.startsWith('https:')), isTrue);
|
||||
});
|
||||
|
||||
test('root names use HTTP file names, BT resource names and URL fallback', () {
|
||||
final http = source('http', rid: 'http-rid', count: 1, name: 'unused-resource-name');
|
||||
expect(http.name, '0.txt');
|
||||
final bt = source('bt', rid: 'bt-rid', count: 1, name: 'torrent', url: 'magnet:?xt=urn:btih:infohash');
|
||||
bt.result!.res.hash = 'infohash';
|
||||
expect(bt.isBt, isTrue);
|
||||
expect(bt.name, 'torrent');
|
||||
final unnamed = source('empty', rid: 'rid', count: 0, name: '');
|
||||
expect(unnamed.name, unnamed.request.url);
|
||||
});
|
||||
|
||||
for (final entry in <String, bool>{
|
||||
'ed2k://|file|payload.bin|4|0123456789ABCDEF0123456789ABCDEF|/': false,
|
||||
'https://example.com/file': false,
|
||||
'https://example.com/file.torrent': false,
|
||||
'ftp://files.example/file.torrent': false,
|
||||
'ftps://files.example/file.torrent': false,
|
||||
'FTPES://files.example/file.torrent': false,
|
||||
'magnet:?xt=urn:btih:infohash': true,
|
||||
'MAGNET:?xt=urn:btih:infohash': true,
|
||||
'file:///downloads/file.torrent': true,
|
||||
'/downloads/FILE.TORRENT': true,
|
||||
'file:///downloads/file.torrent?query=1': true,
|
||||
'data:application/x-bittorrent;base64,AAAA': true,
|
||||
'data:application/octet-stream;base64,AAAA': false,
|
||||
}.entries) {
|
||||
test('source protocol determines BT identity for ${entry.key}', () {
|
||||
final pending = source('protocol', rid: 'rid', count: 1, name: 'resource', url: entry.key);
|
||||
if (RegExp(r'^ftp', caseSensitive: false).hasMatch(entry.key)) pending.result!.res.name = '';
|
||||
pending.result!.res.hash = '0123456789ABCDEF0123456789ABCDEF';
|
||||
expect(pending.isBt, entry.value);
|
||||
expect(pending.name, entry.value ? 'resource' : '0.txt');
|
||||
});
|
||||
}
|
||||
|
||||
test('failed resolutions are excluded and each newly opened source resolves fresh', () async {
|
||||
var calls = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (_) {
|
||||
calls++;
|
||||
return ResolveResult(
|
||||
id: 'rid-$calls',
|
||||
res: Resource(files: [FileInfo(name: 'file')]),
|
||||
);
|
||||
});
|
||||
final first = PendingDownload(
|
||||
id: 'a',
|
||||
request: Request(url: 'https://example.com'),
|
||||
options: Options(),
|
||||
);
|
||||
await first.resolve(service(registry));
|
||||
await first.resolve(service(registry));
|
||||
expect(calls, 1);
|
||||
final reopened = PendingDownload(id: 'a', request: first.request, options: Options());
|
||||
await reopened.resolve(service(registry));
|
||||
expect(calls, 2);
|
||||
final failed = PendingDownload(id: 'b', request: first.request, options: Options())..resolveError = 'failed';
|
||||
expect(planDownloads([failed, reopened]).single.sources, [reopened]);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/task.dart' as api;
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
|
||||
|
||||
void main() {
|
||||
test('the list counts a group once and sends group IDs for every action', () async {
|
||||
api.Task task(String id) => api.Task.fromJson({
|
||||
'id': id,
|
||||
'name': '$id.txt',
|
||||
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
|
||||
'parentId': 'group',
|
||||
'protocol': 'http',
|
||||
'status': 'pause',
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-01T00:00:00Z',
|
||||
'meta': {
|
||||
'req': {'url': 'https://example.com/$id'},
|
||||
'opts': {'path': '/downloads/folder', 'size': 100},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 0, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
|
||||
});
|
||||
final raw = [task('first'), task('second')];
|
||||
final actions = <String>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getTasks, (_) => raw)
|
||||
..bind(GopeedMethods.pauseTask, (id) {
|
||||
actions.add('pause:$id');
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.continueTask, (id) {
|
||||
actions.add('resume:$id');
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.pauseTasks, (params) {
|
||||
actions.add('pauseBatch:${params['ids']}');
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.continueTasks, (params) {
|
||||
actions.add('resumeBatch:${params['ids']}');
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(GopeedMethods.deleteTasks, (params) {
|
||||
actions.add('delete:${params['ids']}:${params['force']}');
|
||||
raw.clear();
|
||||
return const RpcUnit();
|
||||
});
|
||||
final container = ProviderContainer(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
final state = await container.read(tasksControllerProvider.future);
|
||||
expect(state.tasks.length, 1);
|
||||
expect(state.tasks.single.members.length, 2);
|
||||
expect(state.allTasks.map((task) => task.id), ['group', 'first', 'second']);
|
||||
expect(state.findTask('group'), same(state.tasks.single));
|
||||
expect(state.findTask('first'), same(state.tasks.single.members.first));
|
||||
expect(state.findTask('missing'), isNull);
|
||||
expect(state.tasks.single.matchesSearch('second.txt'), isTrue);
|
||||
expect(state.tasks.single.progress, 0.25);
|
||||
final controller = container.read(tasksControllerProvider.notifier);
|
||||
await controller.pause('group');
|
||||
await controller.resume('group');
|
||||
await controller.pauseAll(['group']);
|
||||
await controller.resumeAll(['group']);
|
||||
await controller.deleteSelected(['group'], force: true);
|
||||
expect(actions, [
|
||||
'pause:group',
|
||||
'resume:group',
|
||||
'pauseBatch:[group]',
|
||||
'resumeBatch:[group]',
|
||||
'delete:[group]:true',
|
||||
]);
|
||||
expect(container.read(tasksControllerProvider).value?.tasks, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -29,6 +29,219 @@ void main() {
|
||||
'progress': {'used': 0, 'speed': 0, 'downloaded': 10, 'uploadSpeed': 0, 'uploaded': 0},
|
||||
});
|
||||
|
||||
test('members aggregate through parentId without a parent task', () {
|
||||
final records = taskRecordsFromApi([
|
||||
groupTestTask('first', parentId: 'group'),
|
||||
groupTestTask(
|
||||
'second',
|
||||
parentId: 'group',
|
||||
directory: '/downloads/folder/nested',
|
||||
name: 'movie.mp4',
|
||||
size: 300,
|
||||
downloaded: 75,
|
||||
),
|
||||
groupTestTask('standalone'),
|
||||
]);
|
||||
expect(records.map((record) => record.id), ['group', 'standalone']);
|
||||
final record = records.first;
|
||||
expect(record.isGroup, isTrue);
|
||||
expect(record.members.length, 2);
|
||||
expect(record.totalBytes, 400);
|
||||
expect(record.downloadedBytes, 100);
|
||||
expect(record.speedBytes, 20);
|
||||
expect(record.progress, 0.25);
|
||||
expect(record.storagePath, '/downloads/folder');
|
||||
expect(record.files.map((file) => file.path), ['', 'nested']);
|
||||
expect(record.files.last.name, 'movie.mp4');
|
||||
expect(record.matchesSearch('second'), isTrue);
|
||||
expect(record.canUpdateUrl, isFalse);
|
||||
});
|
||||
|
||||
test('group preserves uploading while member upload speed is momentarily zero', () {
|
||||
final member = groupTestTask('uploading', parentId: 'group');
|
||||
member.uploading = true;
|
||||
member.progress.uploadSpeed = 0;
|
||||
final group = taskRecordsFromApi([member]).single;
|
||||
expect(group.uploading, isTrue);
|
||||
expect(group.uploadSpeedBytes, 0);
|
||||
});
|
||||
|
||||
for (final scenario in <(List<String>, TaskStatus, bool)>[
|
||||
(['running', 'error', 'pause'], TaskStatus.downloading, false),
|
||||
(['error', 'pause', 'wait'], TaskStatus.failed, false),
|
||||
(['pause', 'wait', 'done'], TaskStatus.paused, false),
|
||||
(['done', 'done'], TaskStatus.completed, false),
|
||||
(['wait', 'done'], TaskStatus.downloading, true),
|
||||
]) {
|
||||
test('group status priority for ${scenario.$1}', () {
|
||||
final record = taskRecordsFromApi([
|
||||
for (var i = 0; i < scenario.$1.length; i++)
|
||||
groupTestTask('child-$i', parentId: 'group', status: scenario.$1[i]),
|
||||
]).single;
|
||||
expect(record.status, scenario.$2);
|
||||
expect(record.waiting, scenario.$3);
|
||||
if (scenario.$2 == TaskStatus.completed) {
|
||||
expect(record.progress, 1);
|
||||
expect(record.doneAt, DateTime.utc(2026, 1, 3));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('unknown member sizes stay indeterminate and empty files can finish', () {
|
||||
final unknown = groupTestTask('unknown', parentId: 'group', size: 0);
|
||||
final record = taskRecordsFromApi([unknown, groupTestTask('known', parentId: 'group')]).single;
|
||||
expect(record.totalBytes, isNull);
|
||||
expect(record.progress, isNull);
|
||||
unknown.status = api.Status.done;
|
||||
final empty = taskRecordsFromApi([unknown]).single;
|
||||
expect(empty.totalBytes, 0);
|
||||
expect(empty.progress, 1);
|
||||
});
|
||||
|
||||
test('Windows group directories retain their relative file tree', () {
|
||||
final records = taskRecordsFromApi([
|
||||
groupTestTask('child', parentId: 'group', directory: r'C:\Downloads\folder\nested'),
|
||||
]);
|
||||
expect(records.single.files.single.path, 'nested');
|
||||
expect(records.single.files.single.name, 'file.txt');
|
||||
});
|
||||
|
||||
test('folder member file progress spreads the member ratio over its files', () {
|
||||
final member = api.Task.fromJson({
|
||||
'id': 'pack',
|
||||
'name': 'pack',
|
||||
'parentId': 'group',
|
||||
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
|
||||
'protocol': 'http',
|
||||
'status': 'running',
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-02T00:00:00Z',
|
||||
'doneAt': null,
|
||||
'meta': {
|
||||
'req': {'url': 'https://example.com/pack'},
|
||||
'opts': {'path': '/downloads/folder/pack', 'name': 'pack', 'size': 30},
|
||||
'res': {
|
||||
'name': 'pack',
|
||||
'size': 30,
|
||||
'files': [
|
||||
{'name': 'a.bin', 'size': 10},
|
||||
{'name': 'b.bin', 'size': 20},
|
||||
],
|
||||
},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 5, 'downloaded': 15, 'uploadSpeed': 0, 'uploaded': 0},
|
||||
});
|
||||
final record = taskRecordsFromApi([member]).single;
|
||||
expect(record.files.map((file) => file.name), ['a.bin', 'b.bin']);
|
||||
expect(record.files.first.downloadedBytes, 5);
|
||||
expect(record.files.last.downloadedBytes, 10);
|
||||
});
|
||||
|
||||
test('members outside the group root render under their own folder name', () {
|
||||
final folderMember = api.Task.fromJson({
|
||||
'id': 'pack',
|
||||
'name': 'pack',
|
||||
'parentId': 'group',
|
||||
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
|
||||
'protocol': 'http',
|
||||
'status': 'running',
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-02T00:00:00Z',
|
||||
'doneAt': null,
|
||||
'meta': {
|
||||
'req': {'url': 'https://example.com/pack'},
|
||||
'opts': {'path': '/elsewhere/pack', 'name': 'pack', 'size': 30},
|
||||
'res': {
|
||||
'name': 'pack',
|
||||
'size': 30,
|
||||
'files': [
|
||||
{'name': 'a.bin', 'size': 30},
|
||||
],
|
||||
},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 5, 'downloaded': 15, 'uploadSpeed': 0, 'uploaded': 0},
|
||||
});
|
||||
final record = taskRecordsFromApi([
|
||||
groupTestTask('loose', parentId: 'group', directory: '/elsewhere'),
|
||||
folderMember,
|
||||
]).single;
|
||||
for (final file in record.files) {
|
||||
expect(file.path.startsWith('..'), isFalse);
|
||||
}
|
||||
expect(record.files.first.path, '');
|
||||
expect(record.files.first.name, 'file.txt');
|
||||
expect(record.files.last.path, 'pack');
|
||||
expect(record.files.last.name, 'a.bin');
|
||||
});
|
||||
|
||||
test('completed group reports the wall span from creation to finish', () {
|
||||
final record = taskRecordsFromApi([groupTestTask('child', parentId: 'group', status: 'done')]).single;
|
||||
expect(record.downloadDuration, const Duration(days: 2));
|
||||
});
|
||||
|
||||
for (final completed in [false, true]) {
|
||||
test('group timestamps use chronological maxima when completed=$completed', () {
|
||||
final first = groupTestTask('first', parentId: 'group', status: completed ? 'done' : 'running')
|
||||
..updatedAt = DateTime.utc(2026, 1, 6)
|
||||
..doneAt = DateTime.utc(2026, 1, 5);
|
||||
final second = groupTestTask('second', parentId: 'group', status: 'done')
|
||||
..updatedAt = DateTime.utc(2026, 1, 3)
|
||||
..doneAt = DateTime.utc(2026, 1, 4);
|
||||
final legacy = groupTestTask('legacy', parentId: 'group', status: 'done')..doneAt = null;
|
||||
final record = taskRecordsFromApi([first, second, legacy]).single;
|
||||
expect(record.updatedAt, DateTime.utc(2026, 1, 6));
|
||||
expect(record.doneAt, completed ? DateTime.utc(2026, 1, 5) : null);
|
||||
expect(record.downloadDuration, completed ? const Duration(days: 4) : null);
|
||||
});
|
||||
}
|
||||
|
||||
test('group creation bounds updated time and legacy completion stays unknown', () {
|
||||
final member = groupTestTask('legacy', parentId: 'group', status: 'done')..doneAt = null;
|
||||
member.group = api.TaskGroup(
|
||||
id: 'group',
|
||||
name: 'folder',
|
||||
path: '/downloads/folder',
|
||||
createdAt: DateTime.utc(2026, 1, 4),
|
||||
);
|
||||
final record = taskRecordsFromApi([member]).single;
|
||||
expect(record.updatedAt, member.group!.createdAt);
|
||||
expect(record.doneAt, isNull);
|
||||
expect(record.downloadDuration, isNull);
|
||||
});
|
||||
|
||||
test('completed group reports the members average speed instead of 0 B/s', () {
|
||||
final record = taskRecordsFromApi([
|
||||
groupTestTask(
|
||||
'first',
|
||||
parentId: 'group',
|
||||
status: 'done',
|
||||
size: 100,
|
||||
downloaded: 100,
|
||||
usedNanoseconds: 10 * 1000000000,
|
||||
),
|
||||
groupTestTask(
|
||||
'second',
|
||||
parentId: 'group',
|
||||
status: 'done',
|
||||
size: 50,
|
||||
downloaded: 50,
|
||||
usedNanoseconds: 5 * 1000000000,
|
||||
),
|
||||
]).single;
|
||||
// 150 bytes over 15 combined active seconds, not the 0 of the idle aggregate.
|
||||
expect(record.speedBytes, 10);
|
||||
expect(record.speed, '10 B/s');
|
||||
});
|
||||
|
||||
test('paused group keeps the idle aggregate instead of an average speed', () {
|
||||
final record = taskRecordsFromApi([
|
||||
groupTestTask('first', parentId: 'group', status: 'pause', downloaded: 100, usedNanoseconds: 10 * 1000000000),
|
||||
]).single;
|
||||
expect(record.speedBytes, 0);
|
||||
});
|
||||
|
||||
test('selected files retain original indexes for runtime progress', () {
|
||||
final record = TaskRecord.fromApi(buildTask([2, 0], 40));
|
||||
expect(record.files.map((file) => file.name), ['a.txt', 'c.txt']);
|
||||
@@ -97,3 +310,43 @@ void main() {
|
||||
expect(taskFileTypeIcon('Playlist #2.mp4', isFolder: true), GopeedIcons.folder);
|
||||
});
|
||||
}
|
||||
|
||||
api.Task groupTestTask(
|
||||
String id, {
|
||||
String parentId = '',
|
||||
String status = 'running',
|
||||
int size = 100,
|
||||
int downloaded = 25,
|
||||
int usedNanoseconds = 0,
|
||||
String directory = '/downloads/folder',
|
||||
String name = 'file.txt',
|
||||
}) => api.Task.fromJson({
|
||||
'id': id,
|
||||
'name': name,
|
||||
'parentId': parentId,
|
||||
if (parentId.isNotEmpty)
|
||||
'group': {
|
||||
'id': parentId,
|
||||
'name': 'folder',
|
||||
'path': directory.contains('\\') ? r'C:\Downloads\folder' : '/downloads/folder',
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
},
|
||||
'protocol': 'http',
|
||||
'status': status,
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-02T00:00:00Z',
|
||||
'doneAt': status == 'done' ? '2026-01-03T00:00:00Z' : null,
|
||||
'meta': {
|
||||
'req': {'url': 'https://example.com/$id'},
|
||||
'opts': {'path': directory, 'name': name, 'size': size},
|
||||
if (size > 0)
|
||||
'res': {
|
||||
'size': size,
|
||||
'files': [
|
||||
{'name': name, 'size': size},
|
||||
],
|
||||
},
|
||||
},
|
||||
'progress': {'used': usedNanoseconds, 'speed': 10, 'downloaded': downloaded, 'uploadSpeed': 0, 'uploaded': 0},
|
||||
});
|
||||
@@ -0,0 +1,445 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:gopeed/api/model/create_task_batch.dart';
|
||||
import 'package:gopeed/api/model/create_task.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
import 'package:gopeed/api/model/resource.dart';
|
||||
import 'package:gopeed/api/model/resolve_result.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/core/capabilities/storage_capability.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart';
|
||||
import 'package:gopeed/features/tasks/application/pending_create_task.dart';
|
||||
import 'package:gopeed/shared/theme/app_component_themes.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:gopeed/shared/widgets/app_http_headers_editor.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
testWidgets('cancel and confirm again resolves every unsubmitted URL afresh', (tester) async {
|
||||
tester.view.physicalSize = const Size(900, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final resolved = <String>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolved.add(request.req!.url);
|
||||
return ResolveResult(
|
||||
id: 'rid-${resolved.length}',
|
||||
res: Resource(files: [FileInfo(name: 'file')]),
|
||||
);
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(child: const CreateTaskWindowPage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final url = find.byKey(const ValueKey('create-task-url-input'));
|
||||
await tester.enterText(url, 'https://example.com/a\nhttps://example.com/b');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(resolved, ['https://example.com/a', 'https://example.com/b']);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(resolved, [
|
||||
'https://example.com/a',
|
||||
'https://example.com/b',
|
||||
'https://example.com/a',
|
||||
'https://example.com/b',
|
||||
]);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final failGroup in [true, false]) {
|
||||
testWidgets('reopening a preview retries only the failed batch (group=$failGroup)', (tester) async {
|
||||
final resolutions = <String>[];
|
||||
final batches = <CreateTaskBatch>[];
|
||||
var fail = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolutions.add(request.req!.url);
|
||||
return _resolved(request.req!.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
batches.add(batch);
|
||||
if (batch.group == failGroup && fail) throw StateError('batch failed');
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(batches, hasLength(2));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
fail = false;
|
||||
await _openPreview(tester);
|
||||
final failedUrl = 'https://example.com/${failGroup ? 'group' : 'other'}';
|
||||
expect(resolutions, ['https://example.com/group', 'https://example.com/other', failedUrl]);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(batches, hasLength(3));
|
||||
expect(batches.where((batch) => batch.group != failGroup), hasLength(1));
|
||||
expect(find.byType(CreateTaskWindowPage), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('completion survives reordering and distinguishes duplicate source occurrences', (tester) async {
|
||||
final resolutions = <String>[];
|
||||
var groupCalls = 0;
|
||||
var fail = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolutions.add(request.req!.url);
|
||||
return _resolved(request.req!.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
if (batch.group) {
|
||||
groupCalls++;
|
||||
if (groupCalls == 2 && fail) throw StateError('second occurrence failed');
|
||||
}
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other\nhttps://example.com/group');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
fail = false;
|
||||
await _openPreview(tester, urls: 'https://example.com/other\nhttps://example.com/group\nhttps://example.com/group');
|
||||
expect(resolutions, [
|
||||
'https://example.com/group',
|
||||
'https://example.com/other',
|
||||
'https://example.com/group',
|
||||
'https://example.com/group',
|
||||
]);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(groupCalls, 3);
|
||||
expect(find.byType(CreateTaskWindowPage), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('unfinished sources resolve with edited options without recreating a completed group', (tester) async {
|
||||
final resolutions = <Request>[];
|
||||
final batches = <CreateTaskBatch>[];
|
||||
var fail = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolutions.add(request.req!);
|
||||
return _resolved(request.req!.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
batches.add(batch);
|
||||
if (!batch.group && fail) throw StateError('batch failed');
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
tester.widget<AppHttpHeadersEditor>(find.byType(AppHttpHeadersEditor)).controller.replace({'Cookie': 'new-cookie'});
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), '/retry');
|
||||
fail = false;
|
||||
await _openPreview(tester);
|
||||
expect(resolutions.map((request) => request.url), [
|
||||
'https://example.com/group',
|
||||
'https://example.com/other',
|
||||
'https://example.com/other',
|
||||
]);
|
||||
expect(ReqExtraHttp.fromJson(resolutions.last.extra! as Map<String, dynamic>).header['Cookie'], 'new-cookie');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(batches.where((batch) => batch.group), hasLength(1));
|
||||
expect(batches.last.reqs!.single.opts!.path, '/retry');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a new creation window can submit the same source again', (tester) async {
|
||||
var resolutions = 0;
|
||||
var submissions = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolutions++;
|
||||
return _resolved(request.req!.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (_) {
|
||||
submissions++;
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
final router = await _openPage(tester, registry);
|
||||
for (var attempt = 0; attempt < 2; attempt++) {
|
||||
if (attempt > 0) {
|
||||
router.go('/create');
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
await _openPreview(tester, urls: 'https://example.com/group');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.byType(CreateTaskWindowPage), findsNothing);
|
||||
}
|
||||
expect(resolutions, 2);
|
||||
expect(submissions, 2);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final duringSubmission in [false, true]) {
|
||||
testWidgets('a new external task starts a fresh session (during submission: $duringSubmission)', (tester) async {
|
||||
final firstSubmission = Completer<CreateTaskBatchResult>();
|
||||
final resolutions = <Request>[];
|
||||
var groupSubmissions = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
final req = request.req!;
|
||||
resolutions.add(req);
|
||||
return _resolved(req.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
if (batch.group) {
|
||||
if (++groupSubmissions == 1 && duringSubmission) return firstSubmission.future;
|
||||
} else if (!duringSubmission) {
|
||||
throw StateError('ordinary batch failed');
|
||||
}
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
final page = find.byType(CreateTaskWindowPage);
|
||||
final pageState = tester.state(page);
|
||||
final container = ProviderScope.containerOf(tester.element(page));
|
||||
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(groupSubmissions, 1);
|
||||
if (!duringSubmission) {
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
container
|
||||
.read(pendingCreateTaskProvider.notifier)
|
||||
.set(
|
||||
CreateTask(
|
||||
req: Request(
|
||||
url: 'https://example.com/group',
|
||||
extra: ReqExtraHttp(method: 'GET', header: {'Cookie': 'new-intent'}, body: '').toJson(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
if (duringSubmission) {
|
||||
firstSubmission.complete(CreateTaskBatchResult(taskIds: ['old-created']));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.state(page), same(pageState));
|
||||
await _openPreview(tester);
|
||||
expect(resolutions.map((request) => request.url), [
|
||||
'https://example.com/group',
|
||||
'https://example.com/other',
|
||||
'https://example.com/group',
|
||||
]);
|
||||
expect(ReqExtraHttp.fromJson(resolutions.last.extra! as Map<String, dynamic>).header['Cookie'], 'new-intent');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(groupSubmissions, 2);
|
||||
expect(page, findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('a failed stale direct submission releases the new task form', (tester) async {
|
||||
final firstSubmission = Completer<CreateTaskBatchResult>();
|
||||
final requests = <CreateTaskBatch>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
requests.add(batch);
|
||||
if (requests.length == 1) return firstSubmission.future;
|
||||
return CreateTaskBatchResult(taskIds: ['new-created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
final container = ProviderScope.containerOf(tester.element(find.byType(CreateTaskWindowPage)));
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-checkbox')));
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/old');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
expect(requests, hasLength(1));
|
||||
|
||||
container.read(pendingCreateTaskProvider.notifier).set(CreateTask(req: Request(url: 'https://example.com/new')));
|
||||
await tester.pump();
|
||||
firstSubmission.completeError(StateError('old submission failed'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(find.byType(CreateTaskWindowPage), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(requests, hasLength(2));
|
||||
expect(requests.last.reqs!.single.req!.url, 'https://example.com/new');
|
||||
expect(find.byType(CreateTaskWindowPage), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('confirming only completed sources finishes without opening another preview', (tester) async {
|
||||
var resolutions = 0;
|
||||
var submissions = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolutions++;
|
||||
return _resolved(request.req!.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submissions++;
|
||||
if (!batch.group) throw StateError('batch failed');
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/group');
|
||||
await tester.tap(find.text('Advanced'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'invalid');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(resolutions, 2);
|
||||
expect(submissions, 2);
|
||||
expect(find.byType(CreateTaskWindowPage), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('switching to direct download skips completed preview batches', (tester) async {
|
||||
var resolutions = 0;
|
||||
final batches = <CreateTaskBatch>[];
|
||||
var fail = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
resolutions++;
|
||||
return _resolved(request.req!.url);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
batches.add(batch);
|
||||
if (!batch.group && fail) throw StateError('batch failed');
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await _openPage(tester, registry);
|
||||
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-checkbox')));
|
||||
await tester.pump();
|
||||
fail = false;
|
||||
await _openPreview(tester);
|
||||
expect(resolutions, 2);
|
||||
expect(batches, hasLength(3));
|
||||
expect(batches.last.reqs!.single.req!.url, 'https://example.com/other');
|
||||
expect(find.byType(CreateTaskWindowPage), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
ResolveResult _resolved(String url) => url.endsWith('/group')
|
||||
? ResolveResult(
|
||||
res: Resource(
|
||||
name: 'album',
|
||||
files: [
|
||||
FileInfo(
|
||||
name: 'a.txt',
|
||||
req: Request(url: 'https://example.com/a.txt'),
|
||||
),
|
||||
FileInfo(
|
||||
name: 'b.txt',
|
||||
req: Request(url: 'https://example.com/b.txt'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ResolveResult(
|
||||
id: 'rid-$url',
|
||||
res: Resource(files: [FileInfo(name: 'other.txt')]),
|
||||
);
|
||||
|
||||
Future<GoRouter> _openPage(WidgetTester tester, CapabilityRegistry registry) async {
|
||||
tester.view.physicalSize = const Size(900, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
registry
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final router = GoRouter(
|
||||
initialLocation: '/create',
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
|
||||
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
|
||||
],
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
|
||||
child: shad.ShadcnApp.router(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
routerConfig: router,
|
||||
builder: (_, child) => AppComponentThemes(child: child!),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
return router;
|
||||
}
|
||||
|
||||
Future<void> _openPreview(WidgetTester tester, {String? urls}) async {
|
||||
if (urls != null) await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), urls);
|
||||
final confirm = find.byKey(const ValueKey('create-task-confirm-button'));
|
||||
await tester.ensureVisible(confirm);
|
||||
await tester.tap(confirm);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
@@ -36,7 +36,7 @@ import 'package:gopeed/l10n/app_localizations.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
testWidgets('extension file estimates survive batch submission and window serialization', (tester) async {
|
||||
testWidgets('extension file paths and estimates survive batch submission and window serialization', (tester) async {
|
||||
tester.view.physicalSize = const Size(900, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -49,6 +49,7 @@ void main() {
|
||||
(_) => ResolveResult(
|
||||
id: '',
|
||||
res: Resource(
|
||||
name: 'videos',
|
||||
files: [
|
||||
FileInfo(
|
||||
name: 'under.mp4',
|
||||
@@ -57,6 +58,7 @@ void main() {
|
||||
),
|
||||
FileInfo(
|
||||
name: 'over.mp4',
|
||||
path: 'nested',
|
||||
size: 1048576,
|
||||
req: Request(url: 'https://example.com/audio'),
|
||||
),
|
||||
@@ -66,7 +68,7 @@ void main() {
|
||||
)
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted = CreateTaskBatch.fromJson(jsonDecode(jsonEncode(batch)) as Map<String, dynamic>);
|
||||
return ['first', 'second'];
|
||||
return CreateTaskBatchResult(taskIds: ['first', 'second'], parentId: 'generated');
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final router = GoRouter(
|
||||
@@ -97,6 +99,10 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted?.reqs?.map((item) => item.opts?.size), [3, 1048576]);
|
||||
expect(submitted?.reqs?.map((item) => item.req?.url), ['https://example.com/video', 'https://example.com/audio']);
|
||||
expect(submitted?.group, isTrue);
|
||||
expect(submitted?.groupName, 'videos');
|
||||
expect(submitted?.opts?.path, '/downloads');
|
||||
expect(submitted?.reqs?.map((item) => item.opts?.path), ['', 'nested']);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -118,9 +124,9 @@ void main() {
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
|
||||
..bind(GopeedMethods.createTask, (_) {
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
events.add('created');
|
||||
return 'task-id';
|
||||
return CreateTaskBatchResult(taskIds: ['task-id']);
|
||||
})
|
||||
..bind(NavigationMethods.showDownloadingTasks, (_) {
|
||||
events.add('downloads');
|
||||
@@ -163,9 +169,14 @@ void main() {
|
||||
CreateTask? submitted;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(
|
||||
rid: batch.reqs!.single.rid,
|
||||
req: batch.reqs!.single.req,
|
||||
opts: batch.reqs!.single.opts,
|
||||
);
|
||||
submitted = task;
|
||||
return 'created-id';
|
||||
return CreateTaskBatchResult(taskIds: ['created-id']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final container = ProviderContainer(
|
||||
@@ -246,9 +257,14 @@ void main() {
|
||||
GopeedMethods.getConfig,
|
||||
(_) => DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true,
|
||||
)
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(
|
||||
rid: batch.reqs!.single.rid,
|
||||
req: batch.reqs!.single.req,
|
||||
opts: batch.reqs!.single.opts,
|
||||
);
|
||||
submitted = task;
|
||||
return 'created-id';
|
||||
return CreateTaskBatchResult(taskIds: ['created-id']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final container = ProviderContainer(
|
||||
@@ -374,9 +390,15 @@ void main() {
|
||||
..protocolConfig.http.connections = 8;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
..bind(GopeedMethods.putConfig, (_) => const RpcUnit())
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(
|
||||
rid: batch.reqs!.single.rid,
|
||||
req: batch.reqs!.single.req,
|
||||
opts: batch.reqs!.single.opts,
|
||||
);
|
||||
submitted = task;
|
||||
return 'created-task';
|
||||
return CreateTaskBatchResult(taskIds: ['created-task']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
@@ -877,9 +899,14 @@ void main() {
|
||||
CreateTask? submitted;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(
|
||||
rid: batch.reqs!.single.rid,
|
||||
req: batch.reqs!.single.req,
|
||||
opts: batch.reqs!.single.opts,
|
||||
);
|
||||
submitted = task;
|
||||
return 'task-id';
|
||||
return CreateTaskBatchResult(taskIds: ['task-id']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
|
||||
@@ -985,9 +1012,14 @@ void main() {
|
||||
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(
|
||||
rid: batch.reqs!.single.rid,
|
||||
req: batch.reqs!.single.req,
|
||||
opts: batch.reqs!.single.opts,
|
||||
);
|
||||
submitted = task;
|
||||
return 'task-id';
|
||||
return CreateTaskBatchResult(taskIds: ['task-id']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
|
||||
@@ -1140,9 +1172,18 @@ void main() {
|
||||
final createdTasks = <CreateTask>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
createdTasks.add(task);
|
||||
return 'task-id';
|
||||
..bind(
|
||||
GopeedMethods.resolve,
|
||||
(_) => ResolveResult(
|
||||
id: 'resolved',
|
||||
res: Resource(files: [FileInfo(name: 'file.zip')]),
|
||||
),
|
||||
)
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
for (final item in batch.reqs!) {
|
||||
createdTasks.add(CreateTask(rid: item.rid, req: item.req, opts: item.opts));
|
||||
}
|
||||
return CreateTaskBatchResult(taskIds: ['task-id']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
|
||||
@@ -1189,8 +1230,12 @@ void main() {
|
||||
|
||||
// Confirm creation
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(createdTasks, isEmpty);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(createdTasks.length, 2);
|
||||
for (final t in createdTasks) {
|
||||
final extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(t.opts!.extra! as Map));
|
||||
@@ -1345,7 +1390,7 @@ void main() {
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('the resolve dialog shows the suggested category directory', (tester) async {
|
||||
testWidgets('the file list keeps category suggestions out of its header', (tester) async {
|
||||
tester.view.physicalSize = const Size(700, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -1381,8 +1426,8 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
final suggestion = find.byKey(const ValueKey('resolve-category-directory'));
|
||||
expect(suggestion, findsOneWidget);
|
||||
expect(tester.widget<Text>(suggestion).data, contains('/downloads/Program'));
|
||||
expect(suggestion, findsNothing);
|
||||
expect(find.text('File List'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -1437,7 +1482,7 @@ void main() {
|
||||
await _settleToastTimers(tester);
|
||||
});
|
||||
|
||||
testWidgets('the resolve dialog shows the directory the user named, not the suggestion', (tester) async {
|
||||
testWidgets('the file list uses its fixed title for a manual directory', (tester) async {
|
||||
tester.view.physicalSize = const Size(700, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -1475,13 +1520,87 @@ void main() {
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
final shown = find.byKey(const ValueKey('resolve-category-directory'));
|
||||
expect(shown, findsOneWidget);
|
||||
expect(tester.widget<Text>(shown).data, contains('/work'));
|
||||
expect(tester.widget<Text>(shown).data, isNot(contains('/downloads/Program')));
|
||||
expect(shown, findsNothing);
|
||||
expect(find.text('File List'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('a resolved file list is split into tasks that keep the directory structure', (tester) async {
|
||||
testWidgets('a resolved file list keeps structure and saves the selected parent directory', (tester) async {
|
||||
tester.view.physicalSize = const Size(700, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
CreateTaskBatch? batch;
|
||||
final config = DownloaderConfig(downloadDir: '/downloads');
|
||||
DownloaderConfig? savedConfig;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(GopeedMethods.putConfig, (next) {
|
||||
savedConfig = next;
|
||||
return const RpcUnit();
|
||||
})
|
||||
..bind(
|
||||
GopeedMethods.resolve,
|
||||
(_) => ResolveResult(
|
||||
res: Resource(
|
||||
name: 'bundle',
|
||||
files: [
|
||||
FileInfo(name: 'first.zip', path: 'first.zip', size: 1),
|
||||
FileInfo(name: 'second.zip', path: 'second.zip', size: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
..bind(GopeedMethods.createTaskBatch, (request) {
|
||||
batch = request;
|
||||
return CreateTaskBatchResult(taskIds: ['task-1', 'task-2'], parentId: 'generated');
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(child: const CreateTaskWindowPage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), '/chosen');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-remember-download-directory-checkbox')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/bundle');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(batch, isNotNull);
|
||||
expect(batch!.reqs, hasLength(2));
|
||||
expect(batch!.group, isTrue);
|
||||
expect(batch!.groupName, 'bundle');
|
||||
expect(batch!.toJson().containsKey('groupId'), isFalse);
|
||||
expect(batch!.opts?.path, '/chosen');
|
||||
expect(batch!.opts?.asDefaultPath, isTrue);
|
||||
for (final item in batch!.reqs!) {
|
||||
// The split tasks keep the structure of the resolved resource, so they
|
||||
// are never classified into a category or replace the selected parent.
|
||||
expect(item.opts?.skipAutoCategorize, isTrue);
|
||||
expect(item.opts?.asDefaultPath, isFalse);
|
||||
}
|
||||
expect(savedConfig?.downloadDir, '/chosen');
|
||||
expect(batch!.group, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
await _settleToastTimers(tester);
|
||||
});
|
||||
|
||||
testWidgets('a single selected file of a multi file resource is still not classified', (tester) async {
|
||||
tester.view.physicalSize = const Size(700, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
@@ -1504,64 +1623,7 @@ void main() {
|
||||
)
|
||||
..bind(GopeedMethods.createTaskBatch, (request) {
|
||||
batch = request;
|
||||
return ['task-1', 'task-2'];
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: AppComponentThemes(child: const CreateTaskWindowPage()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/bundle');
|
||||
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(batch, isNotNull);
|
||||
expect(batch!.reqs, hasLength(2));
|
||||
for (final item in batch!.reqs!) {
|
||||
// The split tasks keep the structure of the resolved resource, so they
|
||||
// are never classified into a category.
|
||||
expect(item.opts?.skipAutoCategorize, isTrue);
|
||||
}
|
||||
expect(batch!.opts?.skipAutoCategorize, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
await _settleToastTimers(tester);
|
||||
});
|
||||
|
||||
testWidgets('a single selected file of a multi file resource is still not classified', (tester) async {
|
||||
tester.view.physicalSize = const Size(700, 900);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
CreateTaskBatch? batch;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(
|
||||
GopeedMethods.resolve,
|
||||
(_) => ResolveResult(
|
||||
res: Resource(
|
||||
files: [
|
||||
FileInfo(name: 'first.zip', path: 'first.zip', size: 1),
|
||||
FileInfo(name: 'second.zip', path: 'second.zip', size: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
..bind(GopeedMethods.createTaskBatch, (request) {
|
||||
batch = request;
|
||||
return ['task-1'];
|
||||
return CreateTaskBatchResult(taskIds: ['task-1'], parentId: 'generated');
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
@@ -1594,10 +1656,11 @@ void main() {
|
||||
|
||||
expect(batch, isNotNull);
|
||||
expect(batch!.reqs, hasLength(1));
|
||||
expect(batch!.group, isTrue);
|
||||
for (final item in batch!.reqs!) {
|
||||
expect(item.opts?.skipAutoCategorize, isTrue);
|
||||
}
|
||||
expect(batch!.opts?.skipAutoCategorize, isTrue);
|
||||
expect(batch!.group, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
await _settleToastTimers(tester);
|
||||
});
|
||||
@@ -1642,7 +1705,7 @@ void _ftpTests() {
|
||||
|
||||
testWidgets('ftp links offer the TLS mode and carry FTP options through resolve', (tester) async {
|
||||
ResolveTask? resolved;
|
||||
CreateTask? submitted;
|
||||
CreateTaskBatch? submitted;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.resolve, (task) {
|
||||
@@ -1655,9 +1718,9 @@ void _ftpTests() {
|
||||
),
|
||||
);
|
||||
})
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted = task;
|
||||
return 'ftp-task';
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted = batch;
|
||||
return CreateTaskBatchResult(taskIds: ['ftp-task']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
await _pumpCreateRoute(tester, registry);
|
||||
@@ -1693,20 +1756,20 @@ void _ftpTests() {
|
||||
// The TLS mode travels on the request, so the resolve already uses it.
|
||||
expect(resolved?.req?.url, 'FTP://files.example/pub/big.iso');
|
||||
expect(resolved?.req?.extra, {'tls': 'explicit'});
|
||||
expect(submitted?.rid, 'ftp-resolved');
|
||||
final createOptions = submitted?.opts?.extra! as Map<String, dynamic>;
|
||||
expect(submitted?.reqs?.single.rid, 'ftp-resolved');
|
||||
final createOptions = submitted?.reqs?.single.opts?.extra! as Map<String, dynamic>;
|
||||
expect(createOptions['connections'], 32);
|
||||
expect(createOptions.containsKey('tls'), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('an ftp .torrent link restores its TLS mode and is not treated as BitTorrent', (tester) async {
|
||||
CreateTask? submitted;
|
||||
CreateTaskBatch? submitted;
|
||||
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted = task;
|
||||
return 'ftp-task';
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted = batch;
|
||||
return CreateTaskBatchResult(taskIds: ['ftp-task']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
await _pumpCreateRoute(
|
||||
@@ -1733,19 +1796,22 @@ void _ftpTests() {
|
||||
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted?.req?.url, 'ftp://files.example/pub/linux.torrent');
|
||||
expect(submitted?.req?.extra, {'tls': 'implicit'});
|
||||
final options = OptsExtraFtp.fromJson(submitted?.opts?.extra! as Map<String, dynamic>);
|
||||
expect(submitted?.reqs?.single.req?.url, 'ftp://files.example/pub/linux.torrent');
|
||||
expect(submitted?.reqs?.single.req?.extra, {'tls': 'implicit'});
|
||||
final options = OptsExtraFtp.fromJson(submitted?.reqs?.single.opts?.extra! as Map<String, dynamic>);
|
||||
expect(options.connections, 6);
|
||||
});
|
||||
|
||||
testWidgets('mixed links cap only the FTP task at 32 connections', (tester) async {
|
||||
final submitted = <CreateTask>[];
|
||||
final submitted = <CreateTaskBatch>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
submitted.add(task);
|
||||
return 'task-${submitted.length}';
|
||||
..bind(
|
||||
GopeedMethods.getConfig,
|
||||
(_) => DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true,
|
||||
)
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted.add(batch);
|
||||
return CreateTaskBatchResult(taskIds: ['http-task', 'ftp-task']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
await _pumpCreateRoute(tester, registry);
|
||||
@@ -1758,13 +1824,60 @@ void _ftpTests() {
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final byUrl = {for (final task in submitted) task.req!.url: task.opts!.extra! as Map<String, dynamic>};
|
||||
expect(submitted, hasLength(1));
|
||||
final byUrl = {for (final item in submitted.single.reqs!) item.req!.url: item.opts!.extra! as Map<String, dynamic>};
|
||||
expect(byUrl.keys, unorderedEquals(['https://example.com/a.zip', 'ftpes://files.example/b.iso']));
|
||||
expect(byUrl['https://example.com/a.zip']!['connections'], 64);
|
||||
expect(byUrl['https://example.com/a.zip']!.containsKey('tls'), isFalse);
|
||||
expect(byUrl['ftpes://files.example/b.iso']!['connections'], 32);
|
||||
expect(byUrl['ftpes://files.example/b.iso']!.containsKey('tls'), isFalse);
|
||||
expect(submitted.map((task) => task.req!.extra), everyElement(isNull));
|
||||
expect(submitted.single.reqs!.map((item) => item.req!.extra), everyElement(isNull));
|
||||
});
|
||||
testWidgets('mixed resolved HTTP and FTP resources share one batch with separate options', (tester) async {
|
||||
final resolvedUrls = <String>[];
|
||||
CreateTaskBatch? submitted;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
|
||||
..bind(GopeedMethods.resolve, (task) {
|
||||
final url = task.req!.url;
|
||||
resolvedUrls.add(url);
|
||||
final ftp = url.startsWith('ftp');
|
||||
return ResolveResult(
|
||||
id: ftp ? 'ftp-rid' : 'http-rid',
|
||||
res: Resource(
|
||||
name: ftp ? 'folder' : '',
|
||||
files: [
|
||||
FileInfo(name: ftp ? 'movie.mkv' : 'file.zip', size: 10),
|
||||
if (ftp) FileInfo(name: 'extra.txt', size: 2),
|
||||
],
|
||||
),
|
||||
);
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
submitted = batch;
|
||||
return CreateTaskBatchResult(taskIds: ['http-task', 'ftp-task']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
await _pumpCreateRoute(tester, registry);
|
||||
await tester.enterText(find.byKey(urlInput), 'https://example.com/a.zip\nftpes://files.example/folder');
|
||||
await tester.enterText(find.byKey(connectionsInput), '64');
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(resolvedUrls, ['https://example.com/a.zip', 'ftpes://files.example/folder']);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(submitted!.group, isFalse);
|
||||
expect(submitted!.reqs, hasLength(2));
|
||||
final http = submitted!.reqs![0];
|
||||
final ftp = submitted!.reqs![1];
|
||||
expect(http.rid, 'http-rid');
|
||||
expect(ftp.rid, 'ftp-rid');
|
||||
expect((http.opts!.extra as Map)['connections'], 64);
|
||||
expect((ftp.opts!.extra as Map)['connections'], 32);
|
||||
expect(http.opts!.selectFiles, [0]);
|
||||
expect(ftp.opts!.selectFiles, [0, 1]);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1795,9 +1908,10 @@ Future<AppPalette> _pumpCreateTaskForSubmit(
|
||||
..categories = categories;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(GopeedMethods.createTask, (task) {
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(rid: batch.reqs!.single.rid, req: batch.reqs!.single.req, opts: batch.reqs!.single.opts);
|
||||
onCreate(task);
|
||||
return 'created-task';
|
||||
return CreateTaskBatchResult(taskIds: ['created-task']);
|
||||
})
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
@@ -1850,9 +1964,10 @@ Future<AppPalette> _pumpCreateTaskWithCategories(
|
||||
..bind(GopeedMethods.getConfig, (_) => config)
|
||||
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
|
||||
if (onCreate != null) {
|
||||
registry.bind(GopeedMethods.createTask, (task) {
|
||||
registry.bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
final task = CreateTask(rid: batch.reqs!.single.rid, req: batch.reqs!.single.req, opts: batch.reqs!.single.opts);
|
||||
onCreate(task);
|
||||
return 'created-task';
|
||||
return CreateTaskBatchResult(taskIds: ['created-task']);
|
||||
});
|
||||
}
|
||||
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/model/create_task_batch.dart';
|
||||
import 'package:gopeed/api/model/options.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
import 'package:gopeed/api/model/resource.dart';
|
||||
import 'package:gopeed/api/model/resolve_result.dart';
|
||||
import 'package:gopeed/core/capabilities/app_capabilities.dart';
|
||||
import 'package:gopeed/core/capabilities/capability_rpc.dart';
|
||||
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
|
||||
import 'package:gopeed/features/tasks/application/pending_download.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/resolve_download_dialog.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dart';
|
||||
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:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
PendingDownload pending(String id) => PendingDownload(
|
||||
id: id,
|
||||
request: Request(url: 'https://example.com/$id'),
|
||||
options: Options(),
|
||||
);
|
||||
ResolveResult resolved(String name) => ResolveResult(
|
||||
id: name,
|
||||
res: Resource(files: [FileInfo(name: name)]),
|
||||
);
|
||||
|
||||
Future<void> open(
|
||||
WidgetTester tester,
|
||||
List<PendingDownload> sources,
|
||||
CapabilityRegistry registry, {
|
||||
ValueChanged<bool>? onClose,
|
||||
double width = 900,
|
||||
}) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Builder(
|
||||
builder: (context) => shad.PrimaryButton(
|
||||
onPressed: () async {
|
||||
final result = await showResolveDownloadsDialog(
|
||||
context,
|
||||
sources: sources,
|
||||
service: GopeedService(LocalCapabilityInvoker(registry)),
|
||||
);
|
||||
onClose?.call(result);
|
||||
},
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('file list fits and exposes ready selections at width $width', (tester) async {
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (_) => resolved('file.txt'));
|
||||
await open(tester, [pending('source')], registry, width: width);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('File List'), findsOneWidget);
|
||||
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
|
||||
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>()];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
calls.add(request.req!.url);
|
||||
return responses[calls.length - 1].future;
|
||||
});
|
||||
final sources = [pending('a'), pending('b')];
|
||||
await open(tester, sources, registry);
|
||||
expect(find.text('File List'), findsOneWidget);
|
||||
expect(find.text('https://example.com/a'), findsOneWidget);
|
||||
expect(find.text('https://example.com/b'), findsOneWidget);
|
||||
expect(calls, ['https://example.com/a']);
|
||||
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
|
||||
expect(find.byWidgetPredicate((w) => w is AppTooltip && w.message == 'Waiting to resolve'), findsOneWidget);
|
||||
responses.first.complete(resolved('first.txt'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(calls, ['https://example.com/a', 'https://example.com/b']);
|
||||
expect(find.text('first.txt'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:a:0')));
|
||||
await tester.pump();
|
||||
expect(sources.first.selected, isEmpty);
|
||||
responses.last.complete(resolved('second.txt'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sources.first.selected, isEmpty);
|
||||
expect(sources.last.selected, {0});
|
||||
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final filters in [
|
||||
<String>['video'],
|
||||
<String>['video', 'audio'],
|
||||
]) {
|
||||
testWidgets('new files clear type highlights without changing previous selections ($filters)', (tester) async {
|
||||
final response = Completer<ResolveResult>();
|
||||
final first = pending('first')
|
||||
..result = ResolveResult(
|
||||
res: Resource(
|
||||
name: 'bundle',
|
||||
files: [
|
||||
FileInfo(name: 'video.mp4', size: 10),
|
||||
FileInfo(name: 'song.mp3', size: 20),
|
||||
FileInfo(name: 'picture.png', size: 30),
|
||||
],
|
||||
),
|
||||
)
|
||||
..selected.addAll([0, 1, 2]);
|
||||
final second = pending('second');
|
||||
final batches = <CreateTaskBatch>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (_) => response.future)
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
batches.add(batch);
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
await open(tester, [first, second], registry);
|
||||
for (final filter in filters) {
|
||||
await tester.tap(find.byKey(ValueKey('resolve-tree-filter-$filter')));
|
||||
await tester.pump();
|
||||
// Each selection is written back by the parent and rebuilds the tree.
|
||||
expect(typeFilterActive(tester, filter), isTrue);
|
||||
}
|
||||
final expectedFirst = filters.length == 1 ? {0} : {0, 1};
|
||||
expect(first.selected, expectedFirst);
|
||||
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
|
||||
final incoming = filters.length == 1 ? 'new-audio.mp3' : 'new-image.png';
|
||||
response.complete(
|
||||
ResolveResult(
|
||||
id: 'incoming-rid',
|
||||
res: Resource(files: [FileInfo(name: incoming, size: 40)]),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(first.selected, expectedFirst);
|
||||
expect(second.selected, {0});
|
||||
for (final filter in filters) {
|
||||
expect(typeFilterActive(tester, filter), isFalse);
|
||||
}
|
||||
expect(find.text('${expectedFirst.length + 1} / 4 selected'), findsOneWidget);
|
||||
final size = tester.widget<Text>(find.byKey(const ValueKey('resolve-tree-selected-size'))).data;
|
||||
expect(size, '${filters.length == 1 ? 50 : 70} B');
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(batches, hasLength(2));
|
||||
expect(batches.first.group, isTrue);
|
||||
expect(
|
||||
batches.first.reqs!.map((item) => item.opts!.name),
|
||||
filters.length == 1 ? ['video.mp4'] : ['video.mp4', 'song.mp3'],
|
||||
);
|
||||
expect(batches.last.group, isFalse);
|
||||
expect(batches.last.reqs!.single.rid, 'incoming-rid');
|
||||
expect(batches.last.reqs!.single.opts!.selectFiles, [0]);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('a failed source preserves type highlights and the successful source selection', (tester) async {
|
||||
final response = Completer<ResolveResult>();
|
||||
final first = pending('first')
|
||||
..result = ResolveResult(
|
||||
res: Resource(
|
||||
files: [
|
||||
FileInfo(name: 'video.mp4'),
|
||||
FileInfo(name: 'song.mp3'),
|
||||
],
|
||||
),
|
||||
)
|
||||
..selected.addAll([0, 1]);
|
||||
final second = pending('failed');
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (_) => response.future);
|
||||
await open(tester, [first, second], registry);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video')));
|
||||
await tester.pump();
|
||||
expect(typeFilterActive(tester, 'video'), isTrue);
|
||||
response.completeError(StateError('unavailable'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(first.selected, {0});
|
||||
expect(second.selected, isEmpty);
|
||||
expect(typeFilterActive(tester, 'video'), isTrue);
|
||||
expect(find.text('1 / 2 selected'), findsOneWidget);
|
||||
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('same-name sources synchronize individual, root and all-file selections', (tester) async {
|
||||
final sources = [
|
||||
for (final id in ['first', 'second'])
|
||||
pending(id)
|
||||
..result = ResolveResult(
|
||||
res: Resource(
|
||||
name: 'bundle',
|
||||
files: [
|
||||
FileInfo(name: 'a.txt'),
|
||||
FileInfo(name: 'b.txt'),
|
||||
],
|
||||
),
|
||||
)
|
||||
..selected.addAll([0, 1]),
|
||||
];
|
||||
await open(tester, sources, CapabilityRegistry(createAppCapabilityCodecs()));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sources.first.selected, {1});
|
||||
expect(sources.last.selected, {0, 1});
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-folder:second')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sources.first.selected, {1});
|
||||
expect(sources.last.selected, isEmpty);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:1')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(sources.first.selected, isEmpty);
|
||||
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
|
||||
|
||||
final selectAll = find.descendant(of: find.byType(ResolveFileTree), matching: find.byType(shad.Checkbox)).first;
|
||||
await tester.tap(selectAll);
|
||||
await tester.pumpAndSettle();
|
||||
expect(sources.every((source) => source.selected.length == 2 && source.selected.containsAll([0, 1])), isTrue);
|
||||
await tester.tap(selectAll);
|
||||
await tester.pumpAndSettle();
|
||||
expect(sources.every((source) => source.selected.isEmpty), isTrue);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('cancel stops the queue and ignores the late response', (tester) async {
|
||||
final response = Completer<ResolveResult>();
|
||||
var calls = 0;
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (_) {
|
||||
calls++;
|
||||
return response.future;
|
||||
});
|
||||
bool? closed;
|
||||
await open(tester, [pending('a'), pending('b')], registry, onClose: (value) => closed = value);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-close-button')));
|
||||
await tester.pumpAndSettle();
|
||||
response.complete(resolved('late.txt'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(calls, 1);
|
||||
expect(closed, isFalse);
|
||||
expect(find.text('late.txt'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('failed root is disabled and only ready selections enter a single batch', (tester) async {
|
||||
final batches = <CreateTaskBatch>[];
|
||||
final registry = CapabilityRegistry(createAppCapabilityCodecs())
|
||||
..bind(GopeedMethods.resolve, (request) {
|
||||
if (request.req!.url.endsWith('/bad')) throw StateError('unavailable');
|
||||
return resolved('ready.txt');
|
||||
})
|
||||
..bind(GopeedMethods.createTaskBatch, (batch) {
|
||||
batches.add(batch);
|
||||
return CreateTaskBatchResult(taskIds: ['created']);
|
||||
});
|
||||
bool? closed;
|
||||
await open(tester, [pending('bad'), pending('good')], registry, onClose: (value) => closed = value);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('unavailable'), findsNothing);
|
||||
expect(find.byWidgetPredicate((w) => w is AppTooltip && w.message.contains('unavailable')), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(batches.single.reqs!.single.rid, 'ready.txt');
|
||||
expect(closed, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
bool typeFilterActive(WidgetTester tester, String filter) {
|
||||
final decoration =
|
||||
tester
|
||||
.widget<DecoratedBox>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(ValueKey('resolve-tree-filter-$filter')),
|
||||
matching: find.byType(DecoratedBox),
|
||||
)
|
||||
.first,
|
||||
)
|
||||
.decoration
|
||||
as BoxDecoration;
|
||||
return decoration.color != null;
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:gopeed/core/icons/gopeed_icons.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.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;
|
||||
import 'package:gopeed/api/model/options.dart';
|
||||
import 'package:gopeed/api/model/request.dart';
|
||||
import 'package:gopeed/api/model/resource.dart';
|
||||
import 'package:gopeed/api/model/resolve_result.dart';
|
||||
import 'package:gopeed/features/tasks/application/pending_download.dart';
|
||||
|
||||
PendingDownload source(String id) =>
|
||||
PendingDownload(
|
||||
id: id,
|
||||
request: Request(url: 'https://example.com/$id'),
|
||||
options: Options(),
|
||||
)
|
||||
..result = ResolveResult(
|
||||
res: Resource(
|
||||
name: 'bundle',
|
||||
files: [
|
||||
FileInfo(name: '0.txt'),
|
||||
FileInfo(name: '1.txt'),
|
||||
],
|
||||
),
|
||||
)
|
||||
..selected.addAll([0, 1]);
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('live mixed sources keep input order, selection and expansion at $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final sources = [
|
||||
for (final id in ['http', 'group', 'ungrouped', 'bt', 'failed'])
|
||||
PendingDownload(
|
||||
id: id,
|
||||
request: Request(url: 'https://example.com/$id'),
|
||||
options: Options(),
|
||||
),
|
||||
];
|
||||
final http = sources[0];
|
||||
final group = sources[1];
|
||||
final ungrouped = sources[2];
|
||||
final bt = sources[3];
|
||||
bt.request.url = 'magnet:?xt=urn:btih:infohash';
|
||||
Future<void> show() async {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(
|
||||
sources: sources,
|
||||
onSelectionChanged: (keys) {
|
||||
for (final pending in sources) {
|
||||
pending.selected
|
||||
..clear()
|
||||
..addAll(keys.where((key) => key.$1 == pending.id).map((key) => key.$2));
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
List<shad.TreeItemNode<FileTreeNode<PendingFileKey>>> roots() => tester
|
||||
.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
|
||||
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
|
||||
)
|
||||
.nodes
|
||||
.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>();
|
||||
void expectOrder(List<String> labels) => expect(roots().map((node) => node.data.label), labels);
|
||||
|
||||
await show();
|
||||
expectOrder(sources.map((pending) => pending.request.url).toList());
|
||||
http.result = ResolveResult(
|
||||
id: 'http-rid',
|
||||
res: Resource(files: [FileInfo(name: 'z-http.txt')]),
|
||||
);
|
||||
http.selected.add(0);
|
||||
await show();
|
||||
expectOrder(['z-http.txt', ...sources.skip(1).map((pending) => pending.request.url)]);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:http:0')));
|
||||
await tester.pump();
|
||||
expect(http.selected, isEmpty);
|
||||
|
||||
group.result = ResolveResult(
|
||||
res: Resource(
|
||||
name: 'bundle',
|
||||
files: [
|
||||
FileInfo(name: 'one.txt'),
|
||||
FileInfo(name: 'two.txt'),
|
||||
],
|
||||
),
|
||||
);
|
||||
group.selected.addAll([0, 1]);
|
||||
await show();
|
||||
expectOrder(['z-http.txt', 'bundle', ...sources.skip(2).map((pending) => pending.request.url)]);
|
||||
|
||||
ungrouped.result = ResolveResult(
|
||||
res: Resource(
|
||||
files: [
|
||||
FileInfo(name: 'z-first.txt'),
|
||||
FileInfo(name: 'nested.txt', path: 'directory'),
|
||||
FileInfo(name: 'a-last.txt'),
|
||||
],
|
||||
),
|
||||
);
|
||||
ungrouped.selected.addAll([0, 1, 2]);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:group')));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
await show();
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
expect(roots()[1].expanded, isFalse);
|
||||
expectOrder([
|
||||
'z-http.txt',
|
||||
'bundle',
|
||||
'z-first.txt',
|
||||
'directory',
|
||||
'a-last.txt',
|
||||
bt.request.url,
|
||||
sources.last.request.url,
|
||||
]);
|
||||
|
||||
bt.result = ResolveResult(
|
||||
id: 'bt-rid',
|
||||
res: Resource(
|
||||
name: 'torrent',
|
||||
files: [
|
||||
FileInfo(name: 'bt-one'),
|
||||
FileInfo(name: 'bt-two'),
|
||||
],
|
||||
),
|
||||
);
|
||||
bt.selected.addAll([0, 1]);
|
||||
await show();
|
||||
sources.last.resolveError = 'cannot resolve';
|
||||
await show();
|
||||
expectOrder([
|
||||
'z-http.txt',
|
||||
'bundle',
|
||||
'z-first.txt',
|
||||
'directory',
|
||||
'a-last.txt',
|
||||
'torrent',
|
||||
sources.last.request.url,
|
||||
]);
|
||||
expect(roots()[1].expanded, isFalse);
|
||||
expect(http.selected, isEmpty);
|
||||
expect(group.selected, {0, 1});
|
||||
expect(ungrouped.selected, {0, 1, 2});
|
||||
expect(bt.selected, {0, 1});
|
||||
expect(sources.last.selected, isEmpty);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('ED2K uses a file icon and filename while BT uses its resource name and folder icon', (tester) async {
|
||||
final ed2k =
|
||||
PendingDownload(
|
||||
id: 'ed2k',
|
||||
request: Request(url: 'ed2k://|file|payload.bin|4|0123456789ABCDEF0123456789ABCDEF|/'),
|
||||
options: Options(),
|
||||
)
|
||||
..result = ResolveResult(
|
||||
id: 'ed2k-rid',
|
||||
res: Resource(
|
||||
hash: '0123456789ABCDEF0123456789ABCDEF',
|
||||
files: [FileInfo(name: 'payload.bin')],
|
||||
),
|
||||
)
|
||||
..selected.add(0);
|
||||
final bt =
|
||||
PendingDownload(
|
||||
id: 'bt',
|
||||
request: Request(url: 'magnet:?xt=urn:btih:infohash'),
|
||||
options: Options(),
|
||||
)
|
||||
..result = ResolveResult(
|
||||
id: 'bt-rid',
|
||||
res: Resource(
|
||||
name: 'torrent resource',
|
||||
hash: 'infohash',
|
||||
files: [FileInfo(name: 'torrent-file.bin')],
|
||||
),
|
||||
)
|
||||
..selected.add(0);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [ed2k, bt], onSelectionChanged: (_) {}),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('payload.bin'), findsOneWidget);
|
||||
expect(find.text('torrent resource'), findsOneWidget);
|
||||
expect(find.byIcon(GopeedIcons.folderBt), findsOneWidget);
|
||||
expect(find.text(ed2k.request.url), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final directory in [false, true]) {
|
||||
testWidgets('FTP resource ${directory ? 'directory' : 'file'} keeps its actual structure', (tester) async {
|
||||
final pending = source('ftp');
|
||||
pending.request.url = directory ? 'ftp://files.example/folder' : 'ftpes://files.example/file.torrent';
|
||||
pending.result = ResolveResult(
|
||||
id: 'ftp-rid',
|
||||
res: Resource(
|
||||
name: directory ? 'folder' : '',
|
||||
files: [FileInfo(name: 'file.torrent')],
|
||||
),
|
||||
);
|
||||
pending.selected
|
||||
..clear()
|
||||
..add(0);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(pending.isBt, isFalse);
|
||||
expect(pending.isGroup, isFalse);
|
||||
expect(pending.name, directory ? 'folder' : 'file.torrent');
|
||||
expect(find.text('folder'), directory ? findsOneWidget : findsNothing);
|
||||
expect(find.byIcon(shad.BootstrapIcons.folder2), directory ? findsOneWidget : findsNothing);
|
||||
expect(find.byIcon(GopeedIcons.folderBt), findsNothing);
|
||||
expect(find.text('file.torrent'), findsOneWidget);
|
||||
expect(planDownloads([pending]).single.request.reqs!.single.rid, 'ftp-rid');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
for (final extension in [true, false]) {
|
||||
testWidgets('magnet root icon follows the resolved resource (extension=$extension)', (tester) async {
|
||||
final pending = source('magnet');
|
||||
pending.request.url = 'magnet:?xt=urn:btih:infohash';
|
||||
pending.result!.id = extension ? '' : 'bt-rid';
|
||||
for (final file in pending.files) {
|
||||
if (extension) file.req = Request(url: 'https://example.com/${file.name}');
|
||||
}
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(pending.isGroup, extension);
|
||||
expect(find.byIcon(GopeedIcons.folderBt), extension ? findsNothing : findsOneWidget);
|
||||
expect(find.byIcon(GopeedIcons.file), findsNWidgets(2));
|
||||
expect(find.byIcon(shad.BootstrapIcons.folder2), extension ? findsOneWidget : findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('ordinary single files replace URL placeholders without a source directory', (tester) async {
|
||||
final pending = PendingDownload(
|
||||
id: 'http',
|
||||
request: Request(url: 'https://example.com/file'),
|
||||
options: Options(),
|
||||
);
|
||||
Future<void> show() async {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
await show();
|
||||
expect(find.text(pending.request.url), findsOneWidget);
|
||||
pending.result = ResolveResult(
|
||||
id: 'http-rid',
|
||||
res: Resource(
|
||||
name: '',
|
||||
files: [FileInfo(name: 'file.txt')],
|
||||
),
|
||||
);
|
||||
pending.selected.add(0);
|
||||
await show();
|
||||
expect(find.text('file.txt'), findsOneWidget);
|
||||
expect(find.text(pending.request.url), findsNothing);
|
||||
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
|
||||
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
|
||||
);
|
||||
expect(tree.nodes, hasLength(1));
|
||||
expect(tree.nodes.single.children, isEmpty);
|
||||
expect((tree.nodes.single as shad.TreeItemNode<FileTreeNode<PendingFileKey>>).data.item!.data, ('http', 0));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final filePath in ['', 'disc1']) {
|
||||
testWidgets('named single-file extension keeps its resource directory (path=$filePath)', (tester) async {
|
||||
final pending =
|
||||
PendingDownload(
|
||||
id: 'extension',
|
||||
request: Request(url: 'https://example.com/track.mp3'),
|
||||
options: Options(path: '/downloads'),
|
||||
)
|
||||
..result = ResolveResult(
|
||||
res: Resource(
|
||||
name: 'album',
|
||||
files: [
|
||||
FileInfo(
|
||||
name: 'track.mp3',
|
||||
path: filePath,
|
||||
req: Request(url: 'https://cdn.example.com/track.mp3'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
..selected.add(0);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
|
||||
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
|
||||
);
|
||||
final roots = tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>();
|
||||
expect(roots.map((node) => node.data.label), ['album']);
|
||||
expect(roots.single.expanded, isTrue);
|
||||
final child = roots.single.children.single as shad.TreeItemNode<FileTreeNode<PendingFileKey>>;
|
||||
expect(child.data.label, filePath.isEmpty ? 'track.mp3' : 'disc1');
|
||||
final plan = planDownloads([pending]).single.request;
|
||||
expect(plan.group, isFalse);
|
||||
expect(plan.reqs!.single.opts!.path, filePath.isEmpty ? '/downloads/album' : '/downloads/album/disc1');
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('a live group replaces its URL placeholder with an expanded group directory', (tester) async {
|
||||
final pending = PendingDownload(
|
||||
id: 'group',
|
||||
request: Request(url: 'https://example.com/group'),
|
||||
options: Options(),
|
||||
);
|
||||
Future<void> show() async {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
await show();
|
||||
pending.result = ResolveResult(
|
||||
res: Resource(
|
||||
name: 'bundle',
|
||||
files: [
|
||||
FileInfo(name: 'one.txt'),
|
||||
FileInfo(name: 'two.txt'),
|
||||
],
|
||||
),
|
||||
);
|
||||
pending.selected.addAll([0, 1]);
|
||||
await show();
|
||||
expect(find.text('bundle'), findsOneWidget);
|
||||
expect(find.text('one.txt'), findsOneWidget);
|
||||
expect(find.text('two.txt'), findsOneWidget);
|
||||
expect(find.byIcon(shad.BootstrapIcons.folder2), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('ungrouped roots preserve relative directories and source selection at $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final sources = [source('first'), source('second')];
|
||||
for (final pending in sources) {
|
||||
pending.result!.res.name = '';
|
||||
pending.files[0].name = 'same.txt';
|
||||
pending.files[1].name = 'video.mp4';
|
||||
pending.files[1].path = 'nested';
|
||||
}
|
||||
Set<PendingFileKey> selected = {};
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: sources, onSelectionChanged: (keys) => selected = keys),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
|
||||
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
|
||||
);
|
||||
expect(tree.nodes, hasLength(4));
|
||||
expect(tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>().map((node) => node.data.label), [
|
||||
'same.txt',
|
||||
'nested',
|
||||
'same.txt',
|
||||
'nested',
|
||||
]);
|
||||
expect(
|
||||
tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>().map((node) => node.data.key).toSet(),
|
||||
hasLength(4),
|
||||
);
|
||||
expect(find.text('nested'), findsNWidgets(2));
|
||||
expect(find.text('same.txt'), findsNWidgets(2));
|
||||
expect(find.text(sources.first.request.url), findsNothing);
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
|
||||
await tester.pump();
|
||||
expect(selected, {('first', 1), ('second', 0), ('second', 1)});
|
||||
final secondDirectoryKey = (tree.nodes.last as shad.TreeItemNode<FileTreeNode<PendingFileKey>>).data.key;
|
||||
await tester.tap(find.byKey(ValueKey('resolve-tree-name-scroll-$secondDirectoryKey')));
|
||||
await tester.pump();
|
||||
expect(selected, {('first', 1), ('second', 0)});
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video')));
|
||||
await tester.pump();
|
||||
expect(selected, {('first', 1), ('second', 1)});
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('same-name roots isolate file indexes and root selection at $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final first = source('first');
|
||||
final second = source('second');
|
||||
Set<PendingFileKey> selected = {('first', 0), ('first', 1), ('second', 0), ('second', 1)};
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(sources: [first, second], onSelectionChanged: (keys) => selected = keys),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('bundle'), findsNWidgets(2));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
|
||||
await tester.pump();
|
||||
expect(selected, {('first', 1), ('second', 0), ('second', 1)});
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-folder:second')));
|
||||
await tester.pump();
|
||||
expect(selected, {('first', 1)});
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_delete_dialog.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('group deletion confirms affected downloads and files at width $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
bool? keepFiles;
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Builder(
|
||||
builder: (context) => shad.PrimaryButton(
|
||||
onPressed: () async {
|
||||
keepFiles = await showTaskDeleteDialog(
|
||||
context,
|
||||
taskCount: 1,
|
||||
keepFiles: true,
|
||||
memberCount: 65,
|
||||
fileCount: 65,
|
||||
);
|
||||
},
|
||||
child: const Text('Open delete dialog'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open delete dialog'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Affects 65 downloads and 65 files.'), findsOneWidget);
|
||||
expect(keepFiles, isNull);
|
||||
await tester.tap(find.text('Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(keepFiles, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
|
||||
import 'package:gopeed/features/home/presentation/pages/home_page.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:gopeed/api/model/task.dart' as api;
|
||||
import 'package:gopeed/features/tasks/domain/task_record.dart';
|
||||
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_file_browser_dialog.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_card/task_card.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
|
||||
api.Task raw(
|
||||
String id, {
|
||||
String parent = '',
|
||||
String status = 'running',
|
||||
String directory = '/downloads/folder',
|
||||
String name = 'file.txt',
|
||||
}) => api.Task.fromJson({
|
||||
'id': id,
|
||||
'name': name,
|
||||
if (parent.isNotEmpty)
|
||||
'group': {
|
||||
'id': parent,
|
||||
'name': 'folder',
|
||||
'path': directory.contains('\\') ? r'C:\Downloads\folder' : '/downloads/folder',
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
},
|
||||
'parentId': parent,
|
||||
'protocol': 'http',
|
||||
'status': status,
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-01T00:00:00Z',
|
||||
'meta': {
|
||||
'req': {'url': 'https://example.com/$id'},
|
||||
'opts': {'path': directory, 'name': name, 'size': 100},
|
||||
'res': {
|
||||
'size': 100,
|
||||
'files': [
|
||||
{'name': name, 'size': 100},
|
||||
],
|
||||
},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 10, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
|
||||
});
|
||||
TaskRecord grouped({String name = 'first.txt'}) =>
|
||||
taskRecordsFromApi([raw('child', parent: 'group', name: name)]).single;
|
||||
|
||||
class UpdatingTasks extends TasksController {
|
||||
@override
|
||||
Future<TasksState> build() async => TasksState(tasks: [grouped()]);
|
||||
void rename() => state = AsyncData(TasksState(tasks: [grouped(name: 'first (1).txt')]));
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) {
|
||||
testWidgets('tablet group expands its member tasks on $platform', (tester) async {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
tester.view.physicalSize = const Size(1024, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [tasksControllerProvider.overrideWith(UpdatingTasks.new)],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: const HomePage(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.tap(find.text('folder').first);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
expect(find.byType(TaskFileManagerView), findsNothing);
|
||||
expect(find.byType(TaskCard), findsNWidgets(2));
|
||||
expect(find.text('first.txt'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
}
|
||||
test('Windows backend root and child path are rebased on a POSIX client', () {
|
||||
final record = taskRecordsFromApi([raw('child', parent: 'group', directory: r'C:\Downloads\folder\nested')]).single;
|
||||
expect(record.files.single.path, 'nested');
|
||||
expect(record.files.single.name, 'file.txt');
|
||||
});
|
||||
testWidgets('open group file browser updates renamed child from task polling', (tester) async {
|
||||
tester.view.physicalSize = const Size(1100, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
late UpdatingTasks controller;
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [tasksControllerProvider.overrideWith(() => controller = UpdatingTasks())],
|
||||
child: shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final tasks = ref.watch(tasksControllerProvider).value?.tasks;
|
||||
return shad.PrimaryButton(
|
||||
onPressed: tasks == null ? null : () => showTaskFileBrowserDialog(context, tasks.single),
|
||||
child: const Text('Open'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('first.txt'), findsOneWidget);
|
||||
controller.rename();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('first (1).txt'), findsOneWidget);
|
||||
});
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('a running group with paused members offers resume on its card at $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final record = taskRecordsFromApi([
|
||||
raw('running', parent: 'group'),
|
||||
raw('paused', parent: 'group', status: 'pause'),
|
||||
]).single;
|
||||
expect(record.canResume, isTrue);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Center(
|
||||
child: TaskCard(
|
||||
task: record,
|
||||
selected: false,
|
||||
batchMode: false,
|
||||
selectedInBatch: false,
|
||||
onPressed: () {},
|
||||
onToggleBatch: () {},
|
||||
onPause: () {},
|
||||
onResume: () {},
|
||||
onDelete: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byIcon(Icons.play_arrow), findsOneWidget);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/api/api.dart' as api_client;
|
||||
import 'package:gopeed/api/model/task.dart' as api;
|
||||
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
|
||||
import 'package:gopeed/features/tasks/domain/task_record.dart';
|
||||
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class _Transport implements GopeedTransport {
|
||||
@override
|
||||
String join(String path) => 'https://server.example$path';
|
||||
@override
|
||||
Future<dynamic> request(
|
||||
String path, {
|
||||
String method = 'GET',
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) async => null;
|
||||
}
|
||||
|
||||
api.Task fixture(String id, {required String directory, String name = 'episode #2?.txt', bool folder = false}) =>
|
||||
api.Task.fromJson({
|
||||
'id': id,
|
||||
'name': folder ? 'nested-torrent' : name,
|
||||
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
|
||||
'parentId': 'group',
|
||||
'protocol': 'http',
|
||||
'status': 'done',
|
||||
'uploading': false,
|
||||
'createdAt': '2026-01-01T00:00:00Z',
|
||||
'updatedAt': '2026-01-01T00:00:00Z',
|
||||
'meta': {
|
||||
'req': {'url': 'https://example.com/file'},
|
||||
'opts': {'path': directory, 'name': folder ? 'nested-torrent' : name, 'size': 100},
|
||||
'res': {
|
||||
'name': folder ? 'nested-torrent' : '',
|
||||
'size': 100,
|
||||
'files': [
|
||||
{'name': name, 'path': folder ? 'subdir' : '', 'size': 100},
|
||||
],
|
||||
},
|
||||
},
|
||||
'progress': {'used': 0, 'speed': 0, 'downloaded': 100, 'uploadSpeed': 0, 'uploaded': 0},
|
||||
});
|
||||
void main() {
|
||||
for (final folder in [false, true]) {
|
||||
testWidgets('local group actions use member path folder=$folder', (tester) async {
|
||||
tester.view.physicalSize = const Size(900, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
const channel = MethodChannel('dev.fluttercommunity.plus/share');
|
||||
List<dynamic>? shared;
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
|
||||
shared = (call.arguments as Map)['paths'] as List<dynamic>;
|
||||
return 'dev.fluttercommunity.plus/share/unavailable';
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
|
||||
final memberRoot = path.style == path.Style.windows ? r'D:\Other' : '/elsewhere';
|
||||
final group = taskRecordsFromApi([fixture('child', directory: memberRoot, folder: folder)]).single;
|
||||
final expected = folder
|
||||
? path.join(memberRoot, 'nested-torrent', 'subdir', 'episode #2?.txt')
|
||||
: path.join(memberRoot, 'episode #2?.txt');
|
||||
expect(group.files.single.localPath, expected);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: TaskFileManagerView(task: group, webActions: false, desktopActions: false),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
var current = '';
|
||||
for (final segment in group.files.single.path.split('/').where((s) => s.isNotEmpty)) {
|
||||
current += '/$segment';
|
||||
await tester.tap(find.byKey(ValueKey('managed-file-$current')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
await tester.tap(find.byIcon(Icons.share_outlined));
|
||||
await tester.pumpAndSettle();
|
||||
expect(shared, [expected]);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
for (final windows in [false, true]) {
|
||||
for (final legacy in [false, true]) {
|
||||
for (final folder in [false, true]) {
|
||||
testWidgets('member URL windows=$windows legacy=$legacy folder=$folder', (tester) async {
|
||||
tester.view.physicalSize = const Size(900, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
api_client.setTransportForTesting(_Transport());
|
||||
String? launched;
|
||||
const channel = MethodChannel('plugins.flutter.io/url_launcher');
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'launch') {
|
||||
launched = (call.arguments as Map)['url'] as String;
|
||||
return true;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
|
||||
final dir = windows ? r'C:\Downloads\folder\nested' : '/downloads/folder/nested';
|
||||
final group = taskRecordsFromApi([fixture('child #1', directory: dir, folder: folder)]).single;
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: TaskFileManagerView(task: group, webActions: true, desktopActions: false),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
var current = '';
|
||||
for (final segment in group.files.single.path.split('/').where((s) => s.isNotEmpty)) {
|
||||
current += '/$segment';
|
||||
await tester.tap(find.byKey(ValueKey('managed-file-$current')));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
await tester.tap(find.byIcon(Icons.open_in_new));
|
||||
await tester.pumpAndSettle();
|
||||
final expected =
|
||||
'https://server.example/fs/tasks/child%20%231/${folder ? 'subdir/' : ''}episode%20%232%3F.txt';
|
||||
expect(launched, expected);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,72 @@ import 'package:gopeed/shared/theme/app_theme.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
|
||||
|
||||
void main() {
|
||||
for (final width in [390.0, 700.0, 1024.0]) {
|
||||
testWidgets('group tree shows member progress and status at width $width', (tester) async {
|
||||
tester.view.physicalSize = Size(width, 600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final task = TaskRecord(
|
||||
id: 'group',
|
||||
name: 'folder',
|
||||
status: TaskStatus.downloading,
|
||||
downloaded: '150 B',
|
||||
total: '200 B',
|
||||
progress: 0.75,
|
||||
url: '',
|
||||
storagePath: '/downloads/folder',
|
||||
isGroup: true,
|
||||
isFolder: true,
|
||||
uploading: false,
|
||||
files: const [
|
||||
TaskFileNode(
|
||||
path: '',
|
||||
name: 'running.txt',
|
||||
sizeBytes: 100,
|
||||
downloadedBytes: 50,
|
||||
resourceIndex: 0,
|
||||
status: TaskStatus.downloading,
|
||||
),
|
||||
TaskFileNode(
|
||||
path: '',
|
||||
name: 'completed.txt',
|
||||
sizeBytes: 100,
|
||||
downloadedBytes: 100,
|
||||
resourceIndex: 1,
|
||||
status: TaskStatus.completed,
|
||||
),
|
||||
],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TaskFileTree(task: task),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(tester.widget<TaskProgressBar>(find.byKey(const ValueKey('task-files-total-progress'))).value, 0.75);
|
||||
final running = find.descendant(
|
||||
of: find.byKey(const ValueKey('task-file-progress-file:0')),
|
||||
matching: find.byType(shad.CircularProgressIndicator),
|
||||
);
|
||||
expect(tester.widget<shad.CircularProgressIndicator>(running).value, 0.5);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('task-file-progress-file:1')),
|
||||
matching: find.byIcon(shad.BootstrapIcons.checkCircleFill),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
}
|
||||
|
||||
for (final scenario in [
|
||||
(protocol: api.Protocol.http, completed: false),
|
||||
(protocol: api.Protocol.http, completed: true),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart' show MaterialApp, Tooltip;
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/shared/widgets/app_tooltip.dart';
|
||||
|
||||
@@ -16,6 +17,35 @@ void main() {
|
||||
expect(tester.widget<Tooltip>(find.byType(Tooltip)).message, 'Details');
|
||||
});
|
||||
|
||||
testWidgets('focusable status tooltip exposes its message on keyboard focus', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: AppTooltip(message: 'Waiting for resolution', focusable: true, child: SizedBox.square(dimension: 24)),
|
||||
),
|
||||
);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Waiting for resolution'), findsOneWidget);
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Waiting for resolution'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('status tooltip can be read with a touch long press', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Center(
|
||||
child: AppTooltip(message: 'Resolution failed', focusable: true, child: SizedBox.square(dimension: 24)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.longPress(find.byType(AppTooltip));
|
||||
await tester.pump();
|
||||
expect(find.text('Resolution failed'), findsOneWidget);
|
||||
Tooltip.dismissAllToolTips();
|
||||
await tester.pumpAndSettle();
|
||||
});
|
||||
|
||||
test('product source does not instantiate Tooltip directly', () {
|
||||
final violations = <String>[];
|
||||
final tooltipConstructor = RegExp(r'\b(?:\w+\.)?Tooltip\s*\(');
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:gopeed/shared/theme/app_theme.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;
|
||||
|
||||
void main() {
|
||||
testWidgets('clearing an explicit sort restores mixed file and directory input order', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: FileTreeView<int>(
|
||||
items: const [
|
||||
FileTreeItem(key: 'z', path: '', name: 'z.txt', size: 3, data: 0),
|
||||
FileTreeItem(key: 'nested', path: 'folder', name: 'child.txt', size: 2, data: 1),
|
||||
FileTreeItem(key: 'a', path: '', name: 'a.txt', size: 1, data: 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
List<String> labels() => tester
|
||||
.widget<VirtualTreeView<FileTreeNode<int>>>(find.byType(VirtualTreeView<FileTreeNode<int>>))
|
||||
.nodes
|
||||
.cast<shad.TreeItemNode<FileTreeNode<int>>>()
|
||||
.map((node) => node.data.label)
|
||||
.toList();
|
||||
expect(labels(), ['z.txt', 'folder', 'a.txt']);
|
||||
final sort = find.byKey(const ValueKey('file-tree-sort-name-asc'));
|
||||
await tester.tap(sort);
|
||||
await tester.pump();
|
||||
expect(labels(), ['folder', 'a.txt', 'z.txt']);
|
||||
await tester.tap(sort);
|
||||
await tester.pump();
|
||||
expect(labels(), ['folder', 'z.txt', 'a.txt']);
|
||||
await tester.tap(sort);
|
||||
await tester.pump();
|
||||
expect(labels(), ['z.txt', 'folder', 'a.txt']);
|
||||
});
|
||||
|
||||
for (final width in [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;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final name = List.filled(12, 'long-resource-title').join('-');
|
||||
final nestedName = List.filled(14, 'nested-directory-title').join('-');
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: FileTreeView<int>(
|
||||
items: const [FileTreeItem(key: 'child', path: 'source-0/nested', name: 'a.txt', size: 10, data: 0)],
|
||||
directoryLabels: {'source-0': name, 'source-0/nested': nestedName},
|
||||
),
|
||||
),
|
||||
);
|
||||
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(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));
|
||||
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);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('empty directory root keeps its identity and expansion when files arrive', (tester) async {
|
||||
late StateSetter update;
|
||||
var items = [
|
||||
const FileTreeItem(key: 'root', path: 'root', name: 'pending', size: 0, data: -1, directoryOnly: true),
|
||||
];
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
update = setState;
|
||||
return FileTreeView<int>(
|
||||
items: items,
|
||||
directoryLabels: const {'root': 'Source'},
|
||||
initialExpandedDepth: 1,
|
||||
iconWidgetBuilder: (_, node) => node.isFolder ? const Text('status') : const Text('file'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Source'), findsOneWidget);
|
||||
expect(find.text('pending'), findsNothing);
|
||||
expect(find.text('status'), findsOneWidget);
|
||||
update(() => items = [const FileTreeItem(key: 'child', path: 'root', name: 'payload.txt', size: 12, data: 0)]);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Source'), findsOneWidget);
|
||||
expect(find.text('payload.txt'), findsOneWidget);
|
||||
expect(find.text('file'), findsOneWidget);
|
||||
await tester.tap(find.byKey(const ValueKey('file-tree-node-expand-folder:root')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('payload.txt'), findsNothing);
|
||||
expect(find.text('Source'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('live tree updates preserve a collapse requested during its animation', (tester) async {
|
||||
late StateSetter update;
|
||||
var items = const [
|
||||
FileTreeItem(key: 'first', path: 'bundle', name: 'first.txt', size: 1, data: 0),
|
||||
FileTreeItem(key: 'second', path: 'bundle', name: 'second.txt', size: 1, data: 1),
|
||||
FileTreeItem(key: 'pending', path: 'source-b', name: 'source B', size: 0, data: 2, directoryOnly: true),
|
||||
];
|
||||
await tester.pumpWidget(
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
update = setState;
|
||||
return FileTreeView<int>(
|
||||
items: items,
|
||||
directoryLabels: const {'bundle': 'bundle', 'source-b': 'source B'},
|
||||
initialExpandedDepth: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('file-tree-node-expand-folder:bundle')));
|
||||
await tester.pump(const Duration(milliseconds: 50));
|
||||
update(() {
|
||||
items = const [
|
||||
FileTreeItem(key: 'first', path: 'bundle', name: 'first.txt', size: 1, data: 0),
|
||||
FileTreeItem(key: 'second', path: 'bundle', name: 'second.txt', size: 1, data: 1),
|
||||
FileTreeItem(key: 'arrived', path: '', name: 'later.txt', size: 1, data: 3),
|
||||
];
|
||||
});
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
|
||||
final tree = tester.widget<VirtualTreeView<FileTreeNode<int>>>(find.byType(VirtualTreeView<FileTreeNode<int>>));
|
||||
final bundle = tree.nodes.cast<shad.TreeItemNode<FileTreeNode<int>>>().first;
|
||||
expect(bundle.data.label, 'bundle');
|
||||
expect(bundle.expanded, isFalse);
|
||||
expect(find.text('later.txt'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import 'package:gopeed/features/tasks/application/pending_download.dart';
|
||||
import 'package:gopeed/api/model/options.dart' as preview_options;
|
||||
import 'package:gopeed/api/model/request.dart' as preview_request;
|
||||
import 'package:gopeed/api/model/resolve_result.dart' as preview_resolve;
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
@@ -30,6 +34,7 @@ import 'package:gopeed/app/application/app_notification_controller.dart';
|
||||
import 'package:gopeed/app/application/app_platform_controller.dart';
|
||||
import 'package:gopeed/app/application/app_runtime_controller.dart';
|
||||
import 'package:gopeed/api/model/create_task.dart';
|
||||
import 'package:gopeed/api/model/create_task_batch.dart';
|
||||
import 'package:gopeed/api/model/install_extension.dart';
|
||||
import 'package:gopeed/features/extensions/application/pending_extension_install.dart';
|
||||
import 'package:gopeed/api/model/downloader_config.dart';
|
||||
@@ -128,9 +133,9 @@ void main() {
|
||||
),
|
||||
),
|
||||
)
|
||||
..bind(GopeedMethods.createTask, (_) {
|
||||
..bind(GopeedMethods.createTaskBatch, (_) {
|
||||
created++;
|
||||
return 'new-task';
|
||||
return CreateTaskBatchResult(taskIds: ['new-task']);
|
||||
});
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
@@ -3329,7 +3334,7 @@ void main() {
|
||||
shad.ShadcnApp(
|
||||
theme: AppTheme.light(),
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: ResolveFileTree(
|
||||
home: _singleResolveTree(
|
||||
files: [
|
||||
FileInfo(path: '', name: 'root.txt', size: 1),
|
||||
FileInfo(path: 'folder', name: 'first.txt', size: 2),
|
||||
@@ -3349,7 +3354,7 @@ void main() {
|
||||
expect(find.text('third.txt'), findsNothing);
|
||||
expect(selected, [0, 1, 2, 3]);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:folder/nested')));
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:source/folder/nested')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('second.txt'), findsOneWidget);
|
||||
expect(find.text('deeper'), findsOneWidget);
|
||||
@@ -3362,7 +3367,7 @@ void main() {
|
||||
await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('first.txt'), findsNothing);
|
||||
expect(find.text('root.txt'), findsOneWidget);
|
||||
expect(find.text('root.txt'), findsNothing);
|
||||
expect(selected, [0, 1, 2, 3]);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
@@ -3376,7 +3381,7 @@ void main() {
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ResolveFileTree(
|
||||
child: _singleResolveTree(
|
||||
files: [
|
||||
FileInfo(path: 'videos', name: 'clip.mp4', size: 0),
|
||||
FileInfo(path: 'docs', name: 'notes.txt', size: 0),
|
||||
@@ -3446,30 +3451,30 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('clip.mp4'), findsOneWidget);
|
||||
|
||||
final videosExpand = find.byKey(const ValueKey('resolve-tree-node-expand-folder:videos'));
|
||||
final videosExpand = find.byKey(const ValueKey('resolve-tree-node-expand-folder:source/videos'));
|
||||
expect(videosExpand, findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('resolve-tree-node-expand-file:0')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('resolve-tree-node-expand-file:source:0')), findsOneWidget);
|
||||
|
||||
await tester.tap(videosExpand);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
|
||||
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isFalse);
|
||||
expect(find.text('clip.mp4').hitTestable(), findsNothing);
|
||||
expect(find.text('videos').hitTestable(), findsOneWidget);
|
||||
expect(selected, [0, 1, 2]);
|
||||
|
||||
await tester.tap(find.text('videos'));
|
||||
await tester.pump();
|
||||
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
|
||||
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isFalse);
|
||||
expect(selected, [1, 2]);
|
||||
|
||||
await tester.tap(find.text('videos'));
|
||||
await tester.pump();
|
||||
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
|
||||
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isFalse);
|
||||
expect(selected, [0, 1, 2]);
|
||||
|
||||
await tester.tap(videosExpand);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isTrue);
|
||||
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isTrue);
|
||||
expect(find.text('clip.mp4'), findsOneWidget);
|
||||
|
||||
BoxDecoration sortDecoration(String key) {
|
||||
@@ -3520,7 +3525,7 @@ void main() {
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ResolveFileTree(
|
||||
child: _singleResolveTree(
|
||||
files: [
|
||||
FileInfo(path: '', name: 'clip.mp4', size: 100),
|
||||
FileInfo(path: '', name: 'song.mp3', size: 200),
|
||||
@@ -3569,7 +3574,7 @@ void main() {
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ResolveFileTree(
|
||||
child: _singleResolveTree(
|
||||
files: [FileInfo(name: longName, size: 1024)],
|
||||
initialSelection: const [0],
|
||||
onSelectionChanged: (_) {},
|
||||
@@ -3580,7 +3585,7 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final horizontalScroll = find.byKey(const ValueKey('resolve-tree-horizontal-scroll'));
|
||||
final trailing = find.byKey(const ValueKey('resolve-tree-trailing-file:0'));
|
||||
final trailing = find.byKey(const ValueKey('resolve-tree-trailing-file:source:0'));
|
||||
expect(find.byKey(const ValueKey('resolve-tree-horizontal-scrollbar')), findsOneWidget);
|
||||
expect(horizontalScroll, findsOneWidget);
|
||||
expect(trailing, findsOneWidget);
|
||||
@@ -3600,7 +3605,7 @@ void main() {
|
||||
|
||||
position.jumpTo(0);
|
||||
await tester.pumpAndSettle();
|
||||
final nameArea = find.byKey(const ValueKey('resolve-tree-name-scroll-file:0'));
|
||||
final nameArea = find.byKey(const ValueKey('resolve-tree-name-scroll-file:source:0'));
|
||||
await tester.drag(nameArea, const Offset(-100, 0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(position.pixels, greaterThan(0));
|
||||
@@ -3622,7 +3627,7 @@ void main() {
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ResolveFileTree(
|
||||
child: _singleResolveTree(
|
||||
files: [FileInfo(name: shortName, size: 1024)],
|
||||
initialSelection: const [0],
|
||||
onSelectionChanged: (_) {},
|
||||
@@ -3650,7 +3655,7 @@ void main() {
|
||||
materialTheme: AppTheme.materialLight(),
|
||||
home: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ResolveFileTree(files: files, initialSelection: const [], onSelectionChanged: (_) {}),
|
||||
child: _singleResolveTree(files: files, initialSelection: const [], onSelectionChanged: (_) {}),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -6402,3 +6407,25 @@ api_task.Task _apiTransferTask({
|
||||
updatedAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _singleResolveTree({
|
||||
required List<FileInfo> files,
|
||||
required List<int> initialSelection,
|
||||
required ValueChanged<List<int>> onSelectionChanged,
|
||||
}) {
|
||||
final source =
|
||||
PendingDownload(
|
||||
id: 'source',
|
||||
request: preview_request.Request(url: 'https://example.com'),
|
||||
options: preview_options.Options(),
|
||||
)
|
||||
..result = preview_resolve.ResolveResult(
|
||||
res: Resource(name: 'source', files: files),
|
||||
)
|
||||
..selected.addAll(initialSelection);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => onSelectionChanged(initialSelection));
|
||||
return ResolveFileTree(
|
||||
sources: [source],
|
||||
onSelectionChanged: (keys) => onSelectionChanged(keys.map((key) => key.$2).toList()..sort()),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user