diff --git a/_docs/task-batch-api.md b/_docs/task-batch-api.md new file mode 100644 index 00000000..17d3ae79 --- /dev/null +++ b/_docs/task-batch-api.md @@ -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//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. diff --git a/pkg/api/service.go b/pkg/api/service.go index 57663020..182f96f1 100644 --- a/pkg/api/service.go +++ b/pkg/api/service.go @@ -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 { diff --git a/pkg/api/service_test.go b/pkg/api/service_test.go index 6b1dc942..72ad0235 100644 --- a/pkg/api/service_test.go +++ b/pkg/api/service_test.go @@ -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) + } + } +} diff --git a/pkg/base/model.go b/pkg/base/model.go index a61ab69f..eb55f8bb 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -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"` } diff --git a/pkg/download/category.go b/pkg/download/category.go index d4a6ff56..a0e6500b 100644 --- a/pkg/download/category.go +++ b/pkg/download/category.go @@ -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 { diff --git a/pkg/download/category_test.go b/pkg/download/category_test.go index 8d762603..05ca916d 100644 --- a/pkg/download/category_test.go +++ b/pkg/download/category_test.go @@ -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") diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 2f3df18f..e7da2caf 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -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 } diff --git a/pkg/download/downloader_test.go b/pkg/download/downloader_test.go index 91e02a23..962e2855 100644 --- a/pkg/download/downloader_test.go +++ b/pkg/download/downloader_test.go @@ -1367,13 +1367,14 @@ func TestDownloader_GetTasksByFilter(t *testing.T) { }) batchOpts := newTestDownloadOpt(t) - taskIds, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{ + batchResult, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{ Reqs: reqs, Opts: batchOpts, }) if err != nil { t.Fatal(err) } + taskIds := batchResult wg.Wait() @@ -4043,3 +4044,1100 @@ func TestDownloader_PatchTask_NotFound(t *testing.T) { t.Errorf("Patch() error = %v, want %v", err, ErrTaskNotFound) } } + +func newTaskGroupDownloader(t *testing.T, storage Storage, maxRunning int) *Downloader { + t.Helper() + if storage == nil { + storage = NewMemStorage() + } + d := NewDownloader(&DownloaderConfig{Storage: storage, StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&generationTestManager{holdOpen: true}}}) + if err := d.Setup(); err != nil { + t.Fatal(err) + } + d.cfg.MaxRunning = maxRunning + t.Cleanup(func() { d.Close() }) + return d +} + +func taskGroupBatch(root string, count int) *base.CreateTaskBatch { + batch := &base.CreateTaskBatch{Group: true, GroupName: "folder", Opts: &base.Options{Path: root}} + for i := 0; i < count; i++ { + batch.Reqs = append(batch.Reqs, &base.CreateTaskBatchItem{Req: &base.Request{URL: fmt.Sprintf("generation://file/%d", i)}}) + } + return batch +} + +func TestDownloader_TaskGroupMetadataAndRestore(t *testing.T) { + storageDir := t.TempDir() + d := newTaskGroupDownloader(t, NewBoltStorage(storageDir), 0) + root := t.TempDir() + result, err := d.CreateDirectBatchWithResult(taskGroupBatch(root, 2)) + if err != nil { + t.Fatal(err) + } + if result.ParentID == "" || len(result.TaskIDs) != 2 || d.groups[result.ParentID].Path != filepath.Join(root, "folder") { + t.Fatalf("unexpected result: %+v", result) + } + if len(d.GetTasks()) != 2 || d.GetTask(result.ParentID) != nil { + t.Fatal("a group must not be a download task") + } + for _, id := range result.TaskIDs { + task := d.GetTask(id) + if task.ParentID != result.ParentID || task.group == nil || task.Meta.Opts.Path != d.groups[result.ParentID].Path || !task.Meta.Opts.SkipAutoCategorize { + t.Fatalf("incorrect member: %+v", task) + } + wire, err := json.Marshal(task) + if err != nil || !bytes.Contains(wire, []byte(`"group":`)) || bytes.Contains(wire, []byte(`"type":`)) { + t.Fatalf("invalid task query: %s, %v", wire, err) + } + } + if err := d.Close(); err != nil { + t.Fatal(err) + } + restored := newTaskGroupDownloader(t, NewBoltStorage(storageDir), 0) + for _, id := range result.TaskIDs { + task := restored.GetTask(id) + if task == nil || task.group == nil || task.group.Path != d.groups[result.ParentID].Path || task.group.Name != d.groups[result.ParentID].Name || task.ParentID != result.ParentID { + t.Fatalf("lost metadata on restore: %+v", task) + } + } + if err := restored.Delete(&TaskFilter{IDs: []string{result.ParentID, result.TaskIDs[0]}}, false); err != nil { + t.Fatal(err) + } + if len(restored.GetTasks()) != 0 || len(restored.groups) != 0 { + t.Fatal("group deletion left records") + } +} + +func TestDownloader_TaskGroupQueue(t *testing.T) { + for _, existing := range []int{0, 2} { + t.Run(fmt.Sprint(existing), func(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 5) + for i := 0; i < existing; i++ { + if _, err := d.CreateDirect(&base.Request{URL: "generation://independent"}, &base.Options{Path: t.TempDir()}); err != nil { + t.Fatal(err) + } + } + result, err := d.CreateDirectBatchWithResult(taskGroupBatch(t.TempDir(), 6)) + if err != nil { + t.Fatal(err) + } + running, waiting := 0, 0 + for _, id := range result.TaskIDs { + switch d.taskStatus(d.GetTask(id)) { + case base.DownloadStatusRunning: + running++ + case base.DownloadStatusWait: + waiting++ + } + } + if running != 5-existing || waiting != 1+existing { + t.Fatalf("group running=%d waiting=%d, existing=%d", running, waiting, existing) + } + if err := d.Pause(&TaskFilter{IDs: []string{result.ParentID, result.TaskIDs[0]}}); err != nil { + t.Fatal(err) + } + for _, id := range result.TaskIDs { + if d.taskStatus(d.GetTask(id)) != base.DownloadStatusPause { + t.Fatal("group pause did not pause all members") + } + } + d.lock.Lock() + queued := len(d.waitTasks) + d.lock.Unlock() + if queued != 0 { + t.Fatal("paused group still has queued members") + } + if err := d.Continue(&TaskFilter{IDs: []string{result.ParentID, result.TaskIDs[0]}}); err != nil { + t.Fatal(err) + } + if len(d.expandGroupTaskIDs([]string{result.ParentID, result.TaskIDs[0]})) != 6 { + t.Fatal("mixed operation duplicates members") + } + }) + } +} + +type taskGroupTestStorage struct { + Storage + beforePut func(*Task) error + failGroupPut bool + failGroupDelete bool +} + +func (s *taskGroupTestStorage) Put(bucket, key string, value any) error { + if bucket == bucketTaskGroup && s.failGroupPut { + return errors.New("group write failed") + } + if bucket == bucketTask && s.beforePut != nil { + if err := s.beforePut(value.(*Task)); err != nil { + return err + } + } + return s.Storage.Put(bucket, key, value) +} +func (s *taskGroupTestStorage) Delete(bucket, key string) error { + if bucket == bucketTaskGroup && s.failGroupDelete { + return errors.New("group delete failed") + } + return s.Storage.Delete(bucket, key) +} + +func TestDownloader_TaskGroupPartialFailureAndFirstSave(t *testing.T) { + var saved []*Task + storage := &taskGroupTestStorage{Storage: NewMemStorage(), beforePut: func(task *Task) error { + saved = append(saved, task) + if task.Meta.Req.URL == "generation://bad" { + return errors.New("child write failed") + } + return nil + }} + d := newTaskGroupDownloader(t, storage, 0) + batch := taskGroupBatch(t.TempDir(), 3) + batch.Reqs[1].Req.URL = "generation://bad" + result, err := d.CreateDirectBatchWithResult(batch) + if err != nil || len(result.TaskIDs) != 2 { + t.Fatalf("unexpected partial result: %+v, %v", result, err) + } + for _, task := range saved { + if task.ParentID != result.ParentID { + t.Fatal("first save omitted parent ID") + } + } + if len(d.groups) != 1 || len(d.GetTasks()) != 2 { + t.Fatal("partial result lost successful members") + } + parent := t.TempDir() + batch = taskGroupBatch(parent, 1) + batch.Reqs[0].Req.URL = "generation://bad" + failed, err := d.CreateDirectBatchWithResult(batch) + if err == nil || failed != nil { + t.Fatalf("all failure result: %+v, %v", failed, err) + } + if len(d.groups) != 1 { + t.Fatal("all failed batch left empty group metadata") + } + + if _, err := os.Stat(filepath.Join(parent, "folder")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("failed group directory retained") + } +} + +func TestDownloader_TaskGroupDirectoryValidation(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 0) + root := t.TempDir() + for _, name := range []string{"", " ", ".", "..", "../outside", "nested/folder", `nested\folder`, "/absolute", "a\x00b"} { + batch := taskGroupBatch(root, 1) + batch.GroupName = name + if _, err := d.CreateDirectBatchWithResult(batch); err == nil { + t.Fatalf("accepted invalid name %q", name) + } + } + first, err := d.CreateDirectBatchWithResult(taskGroupBatch(root, 1)) + if err != nil { + t.Fatal(err) + } + second, err := d.CreateDirectBatchWithResult(taskGroupBatch(root, 1)) + if err != nil { + t.Fatal(err) + } + if first.ParentID == second.ParentID || d.groups[second.ParentID].Name != "folder (1)" || d.groups[second.ParentID].Path != filepath.Join(root, "folder (1)") { + t.Fatalf("group identity/dedup failed: %+v", second) + } + batch := taskGroupBatch(root, 2) + batch.Reqs[0].Opts = &base.Options{Path: filepath.Join(root, "nested")} + batch.Reqs[1].Opts = &base.Options{Path: filepath.Dir(root)} + result, err := d.CreateDirectBatchWithResult(batch) + if err != nil || len(result.TaskIDs) != 1 { + t.Fatalf("path containment failed: %+v, %v", result, err) + } + if d.GetTask(result.TaskIDs[0]).Meta.Opts.Path != filepath.Join(d.groups[result.ParentID].Path, "nested") { + t.Fatal("nested member path was not rebased") + } + d.cfg.WhiteDownloadDirs = []string{root} + if _, err := d.CreateDirectBatchWithResult(taskGroupBatch(root, 1)); err == nil { + t.Fatal("group directory bypassed whitelist") + } +} + +func TestDownloader_TaskGroupConcurrentNames(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 0) + root := t.TempDir() + results := make(chan *CreateTaskBatchResult, 4) + errs := make(chan error, 4) + for range 4 { + go func() { + result, err := d.CreateDirectBatchWithResult(taskGroupBatch(root, 1)) + results <- result + errs <- err + }() + } + ids, paths := map[string]bool{}, map[string]bool{} + for range 4 { + result := <-results + if err := <-errs; err != nil { + t.Fatal(err) + } + d.lock.Lock() + path := d.groups[result.ParentID].Path + d.lock.Unlock() + if ids[result.ParentID] || paths[path] { + t.Fatal("concurrent groups share identity/directory") + } + ids[result.ParentID] = true + paths[path] = true + } +} + +func TestDownloader_TaskGroupStorageFailures(t *testing.T) { + storage := &taskGroupTestStorage{Storage: NewMemStorage(), failGroupPut: true} + d := newTaskGroupDownloader(t, storage, 0) + root := t.TempDir() + if _, err := d.CreateDirectBatchWithResult(taskGroupBatch(root, 1)); err == nil { + t.Fatal("group write failure ignored") + } + entries, _ := os.ReadDir(root) + if len(entries) != 0 { + t.Fatal("failed metadata write left reserved directory") + } + storage.failGroupPut = false + result, err := d.CreateDirectBatchWithResult(taskGroupBatch(root, 1)) + if err != nil { + t.Fatal(err) + } + storage.failGroupDelete = true + if err := d.Delete(&TaskFilter{IDs: result.TaskIDs}, false); err == nil { + t.Fatal("expected metadata delete failure") + } + if d.groups[result.ParentID] == nil { + t.Fatal("failed metadata deletion discarded retry state") + } + storage.failGroupDelete = false + if err := d.Delete(&TaskFilter{IDs: []string{result.ParentID}}, false); err != nil { + t.Fatal(err) + } + if len(d.groups) != 0 { + t.Fatal("metadata deletion retry failed") + } +} + +func TestDownloader_TaskGroupResponseCompatibility(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 0) + for _, grouped := range []bool{false, true} { + batch := taskGroupBatch(t.TempDir(), 1) + batch.Group = grouped + result, err := d.CreateDirectBatchWithResult(batch) + if err != nil { + t.Fatal(err) + } + wire, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(wire, &fields); err != nil { + t.Fatalf("batch must return object: %s", wire) + } + want := 1 + if grouped { + want = 2 + } + if len(fields) != want || fields["taskIds"] == nil || (grouped && fields["parentId"] == nil) { + t.Fatalf("unexpected response: %s", wire) + } + if grouped { + d.lock.Lock() + delete(d.groups, result.ParentID) + d.lock.Unlock() + if got := d.expandGroupTaskIDs([]string{result.ParentID}); !reflect.DeepEqual(got, result.TaskIDs) { + t.Fatalf("membership lost: %v", got) + } + } + } +} + +type groupFailureDeleteStorage struct{ Storage } + +func (s *groupFailureDeleteStorage) Delete(bucket, key string) error { + if bucket == bucketSave { + return errors.New("save delete failed") + } + return s.Storage.Delete(bucket, key) +} +func TestDownloader_TaskGroupDeletePersistence(t *testing.T) { + storage := &groupFailureDeleteStorage{Storage: NewMemStorage()} + d := newTaskGroupDownloader(t, storage, 0) + result, err := d.CreateDirectBatchWithResult(taskGroupBatch(t.TempDir(), 1)) + if err != nil { + t.Fatal(err) + } + if err := d.Delete(&TaskFilter{IDs: result.TaskIDs}, false); err == nil { + t.Fatal("expected failure") + } + if d.GetTask(result.TaskIDs[0]) == nil { + t.Fatal("missing live task") + } + var saved Task + exists, err := storage.Get(bucketTask, result.TaskIDs[0], &saved) + if err != nil || !exists { + t.Fatalf("retained member is absent from durable storage: exists=%v err=%v", exists, err) + } +} + +type groupFailureConcurrentDeleteStorage struct { + Storage + blockedID string + entered chan struct{} + release chan struct{} +} + +func (s *groupFailureConcurrentDeleteStorage) Delete(bucket, key string) error { + if bucket == bucketTask && key == s.blockedID { + close(s.entered) + <-s.release + return errors.New("member delete failed") + } + return s.Storage.Delete(bucket, key) +} +func TestDownloader_TaskGroupConcurrentDelete(t *testing.T) { + storage := &groupFailureConcurrentDeleteStorage{Storage: NewMemStorage(), entered: make(chan struct{}), release: make(chan struct{})} + d := newTaskGroupDownloader(t, storage, 0) + result, err := d.CreateDirectBatchWithResult(taskGroupBatch(t.TempDir(), 2)) + if err != nil { + t.Fatal(err) + } + storage.blockedID = result.TaskIDs[0] + firstDone := make(chan error, 1) + go func() { firstDone <- d.Delete(&TaskFilter{IDs: []string{result.TaskIDs[0]}}, false) }() + <-storage.entered + if err := d.Delete(&TaskFilter{IDs: []string{result.TaskIDs[1]}}, false); err != nil { + t.Fatal(err) + } + close(storage.release) + if err := <-firstDone; err == nil { + t.Fatal("expected member deletion failure") + } + if d.GetTask(result.TaskIDs[0]) == nil { + t.Fatal("missing retained member") + } + if d.groups[result.ParentID] == nil { + t.Fatal("concurrent cleanup removed group metadata of a failed member") + } +} + +func TestDownloader_TaskGroupForceRetryPathReuse(t *testing.T) { + storage := &taskGroupTestStorage{Storage: NewMemStorage()} + d := newTaskGroupDownloader(t, storage, 0) + root := t.TempDir() + first, err := d.CreateDirectBatchWithResult(taskGroupBatch(root, 1)) + if err != nil { + t.Fatal(err) + } + storage.failGroupDelete = true + _ = d.Delete(&TaskFilter{IDs: []string{first.ParentID}}, true) + storage.failGroupDelete = false + second, err := d.CreateDirectBatchWithResult(taskGroupBatch(root, 1)) + if err != nil { + t.Fatal(err) + } + if d.groups[first.ParentID].Path == d.groups[second.ParentID].Path { + t.Fatal("reserved metadata path was reused") + } + marker := filepath.Join(d.groups[second.ParentID].Path, "second-group.txt") + if err := os.WriteFile(marker, []byte("retain"), 0600); err != nil { + t.Fatal(err) + } + if err := d.Delete(&TaskFilter{IDs: []string{first.ParentID}}, true); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(marker); err != nil { + t.Fatal("old group cleanup deleted new group file", err) + } +} + +func TestDownloader_TaskGroupPlaceholderPath(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 0) + result, err := d.CreateDirectBatchWithResult(taskGroupBatch(filepath.Join(t.TempDir(), "%year%"), 1)) + if err != nil || len(result.TaskIDs) != 1 { + t.Fatalf("placeholder path rejected: %+v, %v", result, err) + } +} +func TestDownloader_TaskGroupDefaultNestedPath(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 0) + d.cfg.DownloadDir = t.TempDir() + batch := taskGroupBatch("", 1) + batch.Reqs[0].Opts = &base.Options{Path: "nested"} + result, err := d.CreateDirectBatchWithResult(batch) + if err != nil || len(result.TaskIDs) != 1 { + t.Fatalf("default nested path rejected: %+v, %v", result, err) + } + if d.GetTask(result.TaskIDs[0]).Meta.Opts.Path != filepath.Join(d.groups[result.ParentID].Path, "nested") { + t.Fatal("default nested path lost") + } +} + +func TestDownloader_TaskGroupDeleteFailureRestoresQueue(t *testing.T) { + storage := &groupFailureConcurrentDeleteStorage{Storage: NewMemStorage(), entered: make(chan struct{}), release: make(chan struct{})} + d := newTaskGroupDownloader(t, storage, 1) + result, err := d.CreateDirectBatchWithResult(taskGroupBatch(t.TempDir(), 2)) + if err != nil { + t.Fatal(err) + } + storage.blockedID = result.TaskIDs[1] + close(storage.release) + if err := d.Delete(&TaskFilter{IDs: []string{storage.blockedID}}, false); err == nil { + t.Fatal("expected deletion failure") + } + d.lock.Lock() + queued := len(d.waitTasks) + d.lock.Unlock() + if queued != 1 { + t.Fatal("failed deletion lost queued member") + } + if err := d.Delete(&TaskFilter{IDs: []string{result.TaskIDs[0]}}, false); err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, d, storage.blockedID, base.DownloadStatusRunning, time.Second) +} + +func TestDownloader_ResolvedCreateFailureRetainsResourceID(t *testing.T) { + fail := true + storage := &taskGroupTestStorage{Storage: NewMemStorage(), beforePut: func(*Task) error { + if fail { + return errors.New("task write failed") + } + return nil + }} + d := newTaskGroupDownloader(t, storage, 0) + resolved, err := d.Resolve(&base.Request{URL: "generation://resolved"}) + if err != nil { + t.Fatal(err) + } + if id, err := d.CreateWithOptions(resolved.ID, &base.Options{Path: t.TempDir()}); id != "" || err == nil { + t.Fatalf("expected save failure: %s %v", id, err) + } + fail = false + if id, err := d.CreateWithOptions(resolved.ID, &base.Options{Path: t.TempDir()}); id == "" || err != nil { + t.Fatalf("resolved retry failed: %s %v", id, err) + } +} + +func TestDownloader_TaskGroupExtensionBatchRestore(t *testing.T) { + payload := []byte("folder download") + url := startCategoryFileServer(t, "original.bin", payload) + storageDir := t.TempDir() + parentDir := t.TempDir() + output := filepath.Join(parentDir, "folder") + d := NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(storageDir), StorageDir: t.TempDir(), TempDir: t.TempDir()}) + if err := d.Setup(); err != nil { + t.Fatal(err) + } + defer func() { d.Close() }() + defaultDir := t.TempDir() + categoryDir := putCategoryConfig(t, d, defaultDir, true, []string{"txt", "mp4"}) + cfg, err := d.GetConfig() + if err != nil { + t.Fatal(err) + } + cfg.MaxRunning = 0 + if err := d.PutConfig(cfg); err != nil { + t.Fatal(err) + } + + extDir := t.TempDir() + manifest := `{"name":"task-group","author":"test","title":"Task group","version":"1.0.0","scripts":[{"event":"onResolve","match":{"urls":["https://example.com/folder"]},"entry":"index.js"}]}` + script := fmt.Sprintf(`gopeed.events.onResolve(ctx => { + ctx.res = {name: 'folder', files: [ + {name: 'report.txt', path: '', size: %d, req: {url: %q}}, + {name: 'movie.mp4', path: 'nested', size: %d, req: {url: %q}} + ]}; +});`, len(payload), url, len(payload), url) + for name, content := range map[string]string{"manifest.json": manifest, "index.js": script} { + if err := os.WriteFile(filepath.Join(extDir, name), []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + if _, err := d.InstallExtensionByFolder(extDir, true); err != nil { + t.Fatal(err) + } + resolved, err := d.Resolve(&base.Request{URL: "https://example.com/folder"}) + if err != nil { + t.Fatal(err) + } + if resolved.ID != "" || len(resolved.Res.Files) != 2 { + t.Fatalf("expected extension files for batch creation, got %+v", resolved) + } + batch := &base.CreateTaskBatch{Group: true, GroupName: resolved.Res.Name, Opts: &base.Options{Path: parentDir}} + for _, file := range resolved.Res.Files { + batch.Reqs = append(batch.Reqs, &base.CreateTaskBatchItem{ + Req: file.Req, + Opts: &base.Options{ + Name: file.Name, Path: filepath.Join(parentDir, file.Path), Size: file.Size, + SkipAutoCategorize: true, + }, + }) + } + result, err := d.CreateDirectBatchWithResult(batch) + if err != nil { + t.Fatal(err) + } + ids := result.TaskIDs + if len(ids) != len(batch.Reqs) { + t.Fatalf("child count = %d, want %d", len(ids), len(batch.Reqs)) + } + for _, id := range ids { + status, err := d.RuntimeStatus(id) + if err != nil || status.Status != base.DownloadStatusWait || status.Total != int64(len(payload)) { + t.Fatalf("queued child status = %+v, error = %v", status, err) + } + } + if err := d.Close(); err != nil { + t.Fatal(err) + } + + d = NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(storageDir), StorageDir: t.TempDir(), TempDir: t.TempDir()}) + if err := d.Setup(); err != nil { + t.Fatal(err) + } + group := d.groups[result.ParentID] + if group == nil || group.Name != "folder" || group.Path != output { + t.Fatalf("group did not survive restart: %+v", group) + } + for i, id := range ids { + child := d.GetTask(id) + opts := batch.Reqs[i].Opts + if child == nil || child.ParentID != group.ID || child.Meta.Opts.Path != filepath.Join(output, resolved.Res.Files[i].Path) || child.Meta.Opts.Name != opts.Name || child.Meta.Opts.Size != opts.Size { + t.Fatalf("child options or membership lost after restart: %+v", child) + } + } + cfg, err = d.GetConfig() + if err != nil { + t.Fatal(err) + } + cfg.MaxRunning = 1 + if err := d.PutConfig(cfg); err != nil { + t.Fatal(err) + } + done := make(chan *Event, len(ids)) + d.Listener(func(event *Event) { + if event.Key == EventKeyDone || event.Key == EventKeyError { + done <- event + } + }) + if err := d.Continue(&TaskFilter{IDs: []string{group.ID}}); err != nil { + t.Fatal(err) + } + for range ids { + select { + case event := <-done: + if event.Key != EventKeyDone { + t.Fatalf("child download failed: %v", event.Err) + } + case <-time.After(10 * time.Second): + t.Fatal("group members did not finish with one download slot") + } + } + for i, item := range batch.Reqs { + path := filepath.Join(output, resolved.Res.Files[i].Path, item.Opts.Name) + content, err := os.ReadFile(path) + if err != nil || string(content) != string(payload) { + t.Fatalf("downloaded file %s = %q, error = %v", path, content, err) + } + } + if _, err := os.Stat(categoryDir); !os.IsNotExist(err) { + t.Fatalf("explicit folder download was categorized: %v", err) + } + cfg, err = d.GetConfig() + if err != nil || cfg.DownloadDir != defaultDir { + t.Fatalf("folder download changed the default directory: %+v, error = %v", cfg, err) + } +} + +func TestDownloader_TaskGroupSeparateBatchCreatesSeparateGroups(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 0) + parent := t.TempDir() + first, err := d.CreateDirectBatchWithResult(taskGroupBatch(parent, 2)) + if err != nil { + t.Fatal(err) + } + secondBatch := taskGroupBatch(parent, 1) + secondBatch.Reqs[0].Req.URL = "generation://retry" + second, err := d.CreateDirectBatchWithResult(secondBatch) + if err != nil { + t.Fatal(err) + } + if first.ParentID == second.ParentID || d.groups[first.ParentID].Path == d.groups[second.ParentID].Path { + t.Fatalf("separate batch calls shared a group: first=%+v second=%+v", first, second) + } + if d.GetTask(second.TaskIDs[0]).ParentID != second.ParentID { + t.Fatal("second batch member has wrong group") + } +} + +func TestDownloader_TaskGroupRejectsEmptyParentAndWindowsNames(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 0) + d.cfg.DownloadDir = "" + if _, err := d.CreateDirectBatchWithResult(taskGroupBatch("", 1)); err == nil { + t.Fatal("empty parent accepted") + } + for _, name := range []string{"bad:name", "bad*name", "bad?name", "bad.", "bad"} { + batch := taskGroupBatch(t.TempDir(), 1) + batch.GroupName = name + if _, err := d.CreateDirectBatchWithResult(batch); err == nil { + t.Fatalf("invalid group name accepted: %q", name) + } + } +} + +func TestDownloader_TaskGroupNestedWhitelist(t *testing.T) { + for _, test := range []struct { + name string + restart bool + revoke bool + }{ + {name: "pause and resume"}, + {name: "restart and resume", restart: true}, + {name: "pause and revoke permission", revoke: true}, + {name: "restart and revoke permission", restart: true, revoke: true}, + } { + t.Run(test.name, func(t *testing.T) { + storageDir := t.TempDir() + d := newTaskGroupDownloader(t, NewBoltStorage(storageDir), 1) + root := t.TempDir() + d.cfg.WhiteDownloadDirs = []string{filepath.Join(root, "*")} + events := make(chan EventKey, 2) + listener := func(event *Event) { + if event.Key == EventKeyStart || event.Key == EventKeyError { + events <- event.Key + } + } + d.Listener(listener) + batch := taskGroupBatch(root, 1) + batch.Reqs[0].Opts = &base.Options{Path: "nested"} + result, err := d.CreateDirectBatchWithResult(batch) + if err != nil { + t.Fatal(err) + } + select { + case event := <-events: + if event != EventKeyStart { + t.Fatalf("initial start failed: %s", event) + } + case <-time.After(3 * time.Second): + t.Fatal("initial member did not start") + } + if err := d.Pause(&TaskFilter{IDs: []string{result.ParentID}}); err != nil { + t.Fatal(err) + } + if test.restart { + if err := d.Close(); err != nil { + t.Fatal(err) + } + d = newTaskGroupDownloader(t, NewBoltStorage(storageDir), 1) + d.cfg.WhiteDownloadDirs = []string{filepath.Join(root, "*")} + d.Listener(listener) + } + task := d.GetTask(result.TaskIDs[0]) + wantPath := filepath.Join(root, "folder", "nested") + if task.NeedsPreparation || task.group == nil || task.Meta.Opts.Path != wantPath { + t.Fatalf("prepared group member was not preserved: %+v", task) + } + if test.revoke { + d.cfg.WhiteDownloadDirs = []string{filepath.Join(t.TempDir(), "*")} + } + if err := d.Continue(&TaskFilter{IDs: []string{result.ParentID}}); err != nil { + t.Fatal(err) + } + wantEvent := EventKey(EventKeyStart) + if test.revoke { + wantEvent = EventKeyError + } + select { + case event := <-events: + if event != wantEvent { + t.Fatalf("resume event = %s, want %s", event, wantEvent) + } + case <-time.After(3 * time.Second): + t.Fatal("member did not finish starting") + } + }) + } +} + +func TestDownloader_CreateDirectBatchSignatureCompatibility(t *testing.T) { + var createBatch func(*Downloader, *base.CreateTaskBatch) ([]string, error) = (*Downloader).CreateDirectBatch + d := newTaskGroupDownloader(t, nil, 0) + if createBatch == nil { + t.Fatal("CreateDirectBatch compatibility method is nil") + } + ids, err := createBatch(d, &base.CreateTaskBatch{}) + if err == nil || ids != nil { + t.Fatalf("empty batch result = %v, %v; want nil IDs and an error", ids, err) + } +} + +func TestDownloader_TaskGroupNamePlaceholderIsStable(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 0) + parent := t.TempDir() + batch := taskGroupBatch(parent, 1) + batch.GroupName = "album-%year%" + result, err := d.CreateDirectBatchWithResult(batch) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(parent, "album-%year%") + task := d.GetTask(result.TaskIDs[0]) + if err := task.fetcher.Resolve(task.Meta.Req); err != nil { + t.Fatal(err) + } + if err := d.prepareTask(task); err != nil { + t.Fatal(err) + } + if d.groups[result.ParentID].Path != want || task.Meta.Opts.Path != want { + t.Fatalf("reserved=%q task=%q want=%q", d.groups[result.ParentID].Path, task.Meta.Opts.Path, want) + } +} + +func TestDownloader_BatchMixedResolvedAndDirect(t *testing.T) { + for _, grouped := range []bool{false, true} { + t.Run(fmt.Sprint(grouped), func(t *testing.T) { + storage := &taskGroupTestStorage{Storage: NewMemStorage()} + d := newTaskGroupDownloader(t, storage, 0) + rr, err := d.Resolve(&base.Request{URL: "generation://resolved"}) + if err != nil { + t.Fatal(err) + } + // A resolved multi-file resource must retain original selection indexes. + d.fetcherCache[rr.ID].Meta().Res = &base.Resource{Name: "bt-folder", Files: []*base.FileInfo{{Name: "a", Size: 1}, {Name: "b", Size: 2}}} + savedParents := []string{} + storage.beforePut = func(task *Task) error { savedParents = append(savedParents, task.ParentID); return nil } + batch := &base.CreateTaskBatch{Group: grouped, GroupName: "folder", Opts: &base.Options{Path: t.TempDir()}, Reqs: []*base.CreateTaskBatchItem{ + {Rid: "missing"}, {Rid: rr.ID, Req: &base.Request{URL: "unsupported://ignored"}, Opts: &base.Options{SelectFiles: []int{1}}}, + {Req: &base.Request{URL: "generation://direct"}}, {}, + }} + result, err := d.CreateDirectBatchWithResult(batch) + if err != nil || len(result.TaskIDs) != 2 { + t.Fatalf("mixed result=%+v err=%v", result, err) + } + if grouped && result.ParentID == "" { + t.Fatal("missing parent") + } + for _, parent := range savedParents { + if parent != result.ParentID { + t.Fatal("membership missing in first save") + } + } + task := d.GetTask(result.TaskIDs[0]) + if !reflect.DeepEqual(task.Meta.Opts.SelectFiles, []int{1}) { + t.Fatal("lost selection") + } + if _, ok := d.fetcherCache[rr.ID]; ok { + t.Fatal("successful rid not consumed") + } + if _, err := d.CreateDirectBatchWithResult(batch); err != nil { + t.Fatal("valid req after consumed rid must still be visited", err) + } + }) + } +} + +func TestDownloader_BatchAllFailedAndResolvedSelection(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 0) + rr, err := d.Resolve(&base.Request{URL: "generation://resolved"}) + if err != nil { + t.Fatal(err) + } + for _, grouped := range []bool{false, true} { + parent := t.TempDir() + result, err := d.CreateDirectBatchWithResult(&base.CreateTaskBatch{Group: grouped, GroupName: "failed", Opts: &base.Options{Path: parent}, Reqs: []*base.CreateTaskBatchItem{ + {Rid: rr.ID, Opts: &base.Options{SelectFiles: []int{9}}}, {Req: &base.Request{URL: "unsupported://bad"}}, {}, + }}) + if err == nil || result != nil { + t.Fatalf("all-failed result=%+v err=%v", result, err) + } + if _, ok := d.fetcherCache[rr.ID]; !ok { + t.Fatal("failed selection consumed rid") + } + if len(d.groups) != 0 { + t.Fatal("empty groups retained") + } + entries, err := os.ReadDir(parent) + if err != nil || len(entries) != 0 { + t.Fatal("reserved directory retained") + } + } +} + +func TestDownloader_BatchStartFailureKeepsCreatedTasks(t *testing.T) { + for _, grouped := range []bool{false, true} { + t.Run(fmt.Sprint(grouped), func(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 2) + manager := d.cfg.FetchManagers[0].(*generationTestManager) + manager.resolveErr = errors.New("download preparation failed") + batch := taskGroupBatch(t.TempDir(), 2) + batch.Group = grouped + result, err := d.CreateDirectBatchWithResult(batch) + if err != nil || len(result.TaskIDs) != 2 { + t.Fatalf("persisted tasks must count as success: %+v, %v", result, err) + } + for _, id := range result.TaskIDs { + waitForTaskStatus(t, d, id, base.DownloadStatusError, time.Second) + if d.GetTask(id).ParentID != result.ParentID { + t.Fatal("failed task lost group membership") + } + } + if len(d.GetTasks()) != 2 { + t.Fatal("failed starts rolled back persisted tasks") + } + }) + } +} + +// The first Patch blocks after the resolved fetcher has received creation options. +type resolvedCreateTestManager struct{ generationTestManager } + +func (*resolvedCreateTestManager) Name() string { return "http" } + +type resolvedCreateTestFetcher struct { + fetcher.Fetcher + calls atomic.Int32 + entered chan struct{} + release chan struct{} +} + +func (f *resolvedCreateTestFetcher) Patch(*base.Request, *base.Options) error { + if f.calls.Add(1) == 1 { + close(f.entered) + <-f.release + } + return nil +} + +func TestDownloader_ConcurrentResolvedCreation(t *testing.T) { + for _, modes := range []struct{ firstGroup, secondGroup bool }{{true, true}, {true, false}, {false, true}, {false, false}} { + t.Run(fmt.Sprintf("group=%t/competing-group=%t", modes.firstGroup, modes.secondGroup), func(t *testing.T) { + d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&resolvedCreateTestManager{}}}) + if err := d.Setup(); err != nil { + t.Fatal(err) + } + d.cfg.MaxRunning = 0 + t.Cleanup(func() { d.Close() }) + resolved, err := d.Resolve(&base.Request{URL: "generation://shared"}) + if err != nil { + t.Fatal(err) + } + gate := &resolvedCreateTestFetcher{Fetcher: d.fetcherCache[resolved.ID], entered: make(chan struct{}), release: make(chan struct{})} + gate.Meta().Res = &base.Resource{Files: []*base.FileInfo{{Name: "a", Size: 1}, {Name: "b", Size: 2}}} + d.fetcherCache[resolved.ID] = gate + release := sync.OnceFunc(func() { close(gate.release) }) + t.Cleanup(release) + other, err := d.Resolve(&base.Request{URL: "generation://other"}) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + create := func(grouped bool, name string) (string, error) { + opts := &base.Options{Path: root, Size: 1, SelectFiles: []int{1}} + if !grouped { + return d.CreateWithOptions(resolved.ID, opts) + } + result, err := d.CreateDirectBatchWithResult(&base.CreateTaskBatch{Group: true, GroupName: name, Opts: &base.Options{Path: root}, Reqs: []*base.CreateTaskBatchItem{{Rid: resolved.ID, Opts: opts}}}) + if err != nil { + return "", err + } + return result.TaskIDs[0], nil + } + type outcome struct { + id string + err error + } + firstDone, secondDone := make(chan outcome, 1), make(chan outcome, 1) + go func() { id, err := create(modes.firstGroup, "first"); firstDone <- outcome{id, err} }() + select { + case <-gate.entered: + case <-time.After(3 * time.Second): + t.Fatal("first creation did not reach Patch") + } + go func() { id, err := create(modes.secondGroup, "second"); secondDone <- outcome{id, err} }() + select { + case second := <-secondDone: + if second.id != "" || second.err == nil { + t.Errorf("same rid was consumed twice: %+v", second) + } + case <-time.After(3 * time.Second): + t.Fatal("competing creation did not reject an occupied rid") + } + // Occupying one rid must not block an unrelated resolved resource. + if _, err := d.CreateWithOptions(other.ID, &base.Options{Path: root}); err != nil { + t.Fatal(err) + } + release() + var first outcome + select { + case first = <-firstDone: + case <-time.After(3 * time.Second): + t.Fatal("first creation did not finish") + } + if first.err != nil || first.id == "" { + t.Fatalf("first creation failed: %+v", first) + } + if len(d.GetTasks()) != 2 { + t.Fatalf("created %d tasks; want one per rid", len(d.GetTasks())) + } + task := d.GetTask(first.id) + wantPath := root + if modes.firstGroup { + wantPath = filepath.Join(root, "first") + } + if task.Meta.Opts.Path != wantPath || !reflect.DeepEqual(task.Meta.Opts.SelectFiles, []int{1}) { + t.Fatalf("creation options changed: %+v", task.Meta.Opts) + } + if _, err := d.CreateWithOptions(resolved.ID, nil); err == nil { + t.Fatal("created rid is still available") + } + if modes.secondGroup { + if _, err := os.Stat(filepath.Join(root, "second")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rejected creation retained its directory: %v", err) + } + } + }) + } +} + +func TestDownloader_ResolvedBatchSaveFailureRetainsResourceID(t *testing.T) { + for _, grouped := range []bool{false, true} { + t.Run(fmt.Sprint(grouped), func(t *testing.T) { + fail := true + storage := &taskGroupTestStorage{Storage: NewMemStorage(), beforePut: func(*Task) error { + if fail { + return errors.New("save failed") + } + return nil + }} + d := newTaskGroupDownloader(t, storage, 0) + resolved, err := d.Resolve(&base.Request{URL: "generation://retry"}) + if err != nil { + t.Fatal(err) + } + d.fetcherCache[resolved.ID].Meta().Res = &base.Resource{Files: []*base.FileInfo{{Name: "a", Size: 1}, {Name: "b", Size: 2}}} + batch := &base.CreateTaskBatch{Group: grouped, GroupName: "folder", Opts: &base.Options{Path: t.TempDir()}, Reqs: []*base.CreateTaskBatchItem{{Rid: resolved.ID, Opts: &base.Options{SelectFiles: []int{1}}}}} + if result, err := d.CreateDirectBatchWithResult(batch); err == nil || result != nil { + t.Fatalf("expected save failure: %+v, %v", result, err) + } + fail = false + result, err := d.CreateDirectBatchWithResult(batch) + if err != nil { + t.Fatal(err) + } + task := d.GetTask(result.TaskIDs[0]) + if !reflect.DeepEqual(task.Meta.Opts.SelectFiles, []int{1}) || task.ParentID != result.ParentID { + t.Fatalf("retry lost creation options: %+v", task) + } + if _, err := d.CreateDirectBatchWithResult(batch); err == nil { + t.Fatal("successful retry did not consume rid") + } + }) + } +} + +func TestDownloader_TaskGroupRelativeMemberPath(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 0) + root := t.TempDir() + batch := taskGroupBatch(root, 1) + batch.Reqs[0].Opts = &base.Options{Path: "nested"} + result, err := d.CreateDirectBatchWithResult(batch) + if err != nil { + t.Fatal(err) + } + if got, want := d.GetTask(result.TaskIDs[0]).Meta.Opts.Path, filepath.Join(d.groups[result.ParentID].Path, "nested"); got != want { + t.Fatalf("member path=%q want=%q", got, want) + } +} + +func TestDownloader_TaskGroupRelativeParentPath(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 0) + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + relative, err := filepath.Rel(cwd, t.TempDir()) + if err != nil { + t.Fatal(err) + } + batch := taskGroupBatch(relative, 1) + result, err := d.CreateDirectBatchWithResult(batch) + if err != nil { + t.Fatal(err) + } + if task := d.GetTask(result.TaskIDs[0]); task.Meta.Opts.Path != d.groups[result.ParentID].Path { + t.Fatalf("inherited parent path was appended again: %q", task.Meta.Opts.Path) + } +} + +func TestDownloader_TaskGroupMemberPathOptions(t *testing.T) { + t.Chdir(t.TempDir()) + for _, parent := range []string{"downloads", "./downloads", filepath.Join(t.TempDir(), "downloads")} { + t.Run(parent, func(t *testing.T) { + absoluteParent, err := filepath.Abs(parent) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + opts *base.Options + relative string + }{ + {name: "inherited"}, + {name: "empty", opts: &base.Options{}}, + {name: "relative nested", opts: &base.Options{Path: "nested"}, relative: "nested"}, + {name: "same name as parent", opts: &base.Options{Path: "downloads"}, relative: "downloads"}, + {name: "absolute parent", opts: &base.Options{Path: absoluteParent}}, + {name: "absolute nested", opts: &base.Options{Path: filepath.Join(absoluteParent, "nested")}, relative: "nested"}, + } { + t.Run(test.name, func(t *testing.T) { + d := newTaskGroupDownloader(t, nil, 0) + batch := taskGroupBatch(parent, 1) + batch.Reqs[0].Opts = test.opts + result, err := d.CreateDirectBatchWithResult(batch) + if err != nil { + t.Fatal(err) + } + got := d.GetTask(result.TaskIDs[0]).Meta.Opts.Path + want := filepath.Join(d.groups[result.ParentID].Path, test.relative) + if got != want { + t.Fatalf("member path=%q want=%q", got, want) + } + }) + } + }) + } +} + +func TestDownloader_ResolvedBatchPreparationFailureConsumesResourceID(t *testing.T) { + for _, grouped := range []bool{false, true} { + t.Run(fmt.Sprint(grouped), func(t *testing.T) { + storage := &taskGroupTestStorage{Storage: NewMemStorage(), beforePut: func(task *Task) error { + if !task.NeedsPreparation { + return errors.New("preparation save failed") + } + return nil + }} + d := newTaskGroupDownloader(t, storage, 1) + resolved, err := d.Resolve(&base.Request{URL: "generation://start-failure"}) + if err != nil { + t.Fatal(err) + } + result, err := d.CreateDirectBatchWithResult(&base.CreateTaskBatch{Group: grouped, GroupName: "folder", Opts: &base.Options{Path: t.TempDir()}, Reqs: []*base.CreateTaskBatchItem{{Rid: resolved.ID}}}) + if err != nil { + t.Fatal(err) + } + waitForTaskStatus(t, d, result.TaskIDs[0], base.DownloadStatusError, time.Second) + if _, err := d.CreateWithOptions(resolved.ID, nil); err == nil { + t.Fatal("download error made a persisted rid available again") + } + if len(d.GetTasks()) != 1 { + t.Fatal("download error caused recreation") + } + }) + } +} diff --git a/pkg/download/extension_resolve_size_test.go b/pkg/download/extension_resolve_size_test.go index 7f09d5b6..1e5fa682 100644 --- a/pkg/download/extension_resolve_size_test.go +++ b/pkg/download/extension_resolve_size_test.go @@ -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 { diff --git a/pkg/download/extension_test.go b/pkg/download/extension_test.go index 38288cae..5637ed92 100644 --- a/pkg/download/extension_test.go +++ b/pkg/download/extension_test.go @@ -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) } diff --git a/pkg/download/model.go b/pkg/download/model.go index 38575382..766a7a69 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -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) } diff --git a/pkg/rest/api.go b/pkg/rest/api.go index 64e46582..4d119276 100644 --- a/pkg/rest/api.go +++ b/pkg/rest/api.go @@ -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 diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go index b3e300cc..55a386dd 100644 --- a/pkg/rest/server_test.go +++ b/pkg/rest/server_test.go @@ -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 diff --git a/ui/flutter/docs/window-capability-rpc.md b/ui/flutter/docs/window-capability-rpc.md index 1d90a8de..a71ffa74 100644 --- a/ui/flutter/docs/window-capability-rpc.md +++ b/ui/flutter/docs/window-capability-rpc.md @@ -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)` 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. diff --git a/ui/flutter/lib/api/api.dart b/ui/flutter/lib/api/api.dart index 77a24563..c2bf519a 100644 --- a/ui/flutter/lib/api/api.dart +++ b/ui/flutter/lib/api/api.dart @@ -85,10 +85,10 @@ Future createTask(CreateTask createTask) { return _parse(() => _request('api/v1/tasks', method: 'POST', data: createTask), (data) => data as String); } -Future> createTaskBatch(CreateTaskBatch createTaskBatch) { - return _parse>( +Future createTaskBatch(CreateTaskBatch createTaskBatch) { + return _parse( () => _request('api/v1/tasks/batch', method: 'POST', data: createTaskBatch), - (data) => (data as List).map((e) => e as String).toList(), + (json) => CreateTaskBatchResult.fromJson(json as Map), ); } diff --git a/ui/flutter/lib/api/model/create_task_batch.dart b/ui/flutter/lib/api/model/create_task_batch.dart index 4e12a4ce..071fb0f4 100644 --- a/ui/flutter/lib/api/model/create_task_batch.dart +++ b/ui/flutter/lib/api/model/create_task_batch.dart @@ -10,7 +10,10 @@ class CreateTaskBatch { List? 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 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 json) => _$CreateTaskBatchItemFromJson(json); Map toJson() => _$CreateTaskBatchItemToJson(this); } + +@JsonSerializable() +class CreateTaskBatchResult { + final List taskIds; + final String? parentId; + + CreateTaskBatchResult({required this.taskIds, this.parentId}); + + factory CreateTaskBatchResult.fromJson(Map json) => _$CreateTaskBatchResultFromJson(json); + Map toJson() => _$CreateTaskBatchResultToJson(this); +} diff --git a/ui/flutter/lib/api/model/create_task_batch.g.dart b/ui/flutter/lib/api/model/create_task_batch.g.dart index 3b1b07a1..21b246e5 100644 --- a/ui/flutter/lib/api/model/create_task_batch.g.dart +++ b/ui/flutter/lib/api/model/create_task_batch.g.dart @@ -6,22 +6,55 @@ part of 'create_task_batch.dart'; // JsonSerializableGenerator // ************************************************************************** -CreateTaskBatch _$CreateTaskBatchFromJson(Map json) => CreateTaskBatch( - reqs: (json['reqs'] as List?)?.map((e) => CreateTaskBatchItem.fromJson(e as Map)).toList(), - opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map), -); +CreateTaskBatch _$CreateTaskBatchFromJson(Map json) => + CreateTaskBatch( + reqs: (json['reqs'] as List?) + ?.map((e) => CreateTaskBatchItem.fromJson(e as Map)) + .toList(), + opts: json['opts'] == null + ? null + : Options.fromJson(json['opts'] as Map), + group: json['group'] as bool? ?? false, + groupName: json['groupName'] as String? ?? '', + ); -Map _$CreateTaskBatchToJson(CreateTaskBatch instance) => { - 'reqs': ?instance.reqs?.map((e) => e.toJson()).toList(), - 'opts': ?instance.opts?.toJson(), -}; +Map _$CreateTaskBatchToJson(CreateTaskBatch instance) => + { + 'reqs': ?instance.reqs?.map((e) => e.toJson()).toList(), + 'opts': ?instance.opts?.toJson(), + 'group': instance.group, + 'groupName': instance.groupName, + }; -CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map json) => CreateTaskBatchItem( - req: json['req'] == null ? null : Request.fromJson(json['req'] as Map), - opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map), -); +CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map json) => + CreateTaskBatchItem( + rid: json['rid'] as String?, + req: json['req'] == null + ? null + : Request.fromJson(json['req'] as Map), + opts: json['opts'] == null + ? null + : Options.fromJson(json['opts'] as Map), + ); -Map _$CreateTaskBatchItemToJson(CreateTaskBatchItem instance) => { +Map _$CreateTaskBatchItemToJson( + CreateTaskBatchItem instance, +) => { + 'rid': ?instance.rid, 'req': ?instance.req, 'opts': ?instance.opts, }; + +CreateTaskBatchResult _$CreateTaskBatchResultFromJson( + Map json, +) => CreateTaskBatchResult( + taskIds: (json['taskIds'] as List).map((e) => e as String).toList(), + parentId: json['parentId'] as String?, +); + +Map _$CreateTaskBatchResultToJson( + CreateTaskBatchResult instance, +) => { + 'taskIds': instance.taskIds, + 'parentId': ?instance.parentId, +}; diff --git a/ui/flutter/lib/api/model/task.dart b/ui/flutter/lib/api/model/task.dart index 0ea877dc..6eabd494 100644 --- a/ui/flutter/lib/api/model/task.dart +++ b/ui/flutter/lib/api/model/task.dart @@ -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 json) => _$TaskFromJson(json); @@ -54,6 +59,18 @@ class Task { Map 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 json) => _$TaskGroupFromJson(json); + Map toJson() => _$TaskGroupToJson(this); +} + @JsonSerializable() class Progress { int used; diff --git a/ui/flutter/lib/api/model/task.g.dart b/ui/flutter/lib/api/model/task.g.dart index 06974c39..3e6323cf 100644 --- a/ui/flutter/lib/api/model/task.g.dart +++ b/ui/flutter/lib/api/model/task.g.dart @@ -16,12 +16,16 @@ Task _$TaskFromJson(Map 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), + parentId: json['parentId'] as String? ?? '', +)..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol'], unknownValue: JsonKey.nullForUndefinedEnumValue); Map _$TaskToJson(Task instance) => { '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 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 _$TaskGroupToJson(TaskGroup instance) => { + 'id': instance.id, + 'name': instance.name, + 'path': instance.path, + 'createdAt': instance.createdAt.toIso8601String(), +}; + Progress _$ProgressFromJson(Map json) => Progress( used: (json['used'] as num).toInt(), speed: (json['speed'] as num).toInt(), diff --git a/ui/flutter/lib/core/capabilities/app_capabilities.dart b/ui/flutter/lib/core/capabilities/app_capabilities.dart index cbe4748a..a760dcfa 100644 --- a/ui/flutter/lib/core/capabilities/app_capabilities.dart +++ b/ui/flutter/lib/core/capabilities/app_capabilities.dart @@ -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((json) => ResolveResult.fromJson(json! as Map)) ..register((json) => CreateTask.fromJson(json! as Map)) ..register((json) => CreateTaskBatch.fromJson(json! as Map)) + ..register((json) => CreateTaskBatchResult.fromJson(json! as Map)) ..register((json) => DownloaderConfig.fromJson(json! as Map)) ..register((json) => TaskRuntimeStatus.fromJson(json! as Map)) ..register>( @@ -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 _tail = Future.value(); + + Future _run(Future Function() operation) { + final result = _tail.then((_) => operation()); + _tail = result.then((_) {}, onError: (Object _, StackTrace _) {}); + return result; + } + + Future get() => _run(api.getConfig); + + Future 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 updateHistory(void Function(List) mutation) => _run(() async { + final config = await api.getConfig(); + final history = List.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.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.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(); }); } diff --git a/ui/flutter/lib/core/capabilities/gopeed_capability.dart b/ui/flutter/lib/core/capabilities/gopeed_capability.dart index 6c3d2453..6254040f 100644 --- a/ui/flutter/lib/core/capabilities/gopeed_capability.dart +++ b/ui/flutter/lib/core/capabilities/gopeed_capability.dart @@ -17,7 +17,7 @@ abstract final class GopeedMethods { static const getMirrorCandidates = RpcMethod, List>('gopeed.mirrors.candidates'); static const resolve = RpcMethod('gopeed.resolve'); static const createTask = RpcMethod('gopeed.task.create'); - static const createTaskBatch = RpcMethod>('gopeed.task.createBatch'); + static const createTaskBatch = RpcMethod('gopeed.task.createBatch'); static const patchTask = RpcMethod, RpcUnit>('gopeed.task.patch'); static const getTasks = RpcMethod, List>('gopeed.task.list'); static const getTaskStatus = RpcMethod('gopeed.task.status'); @@ -53,7 +53,7 @@ class GopeedService { Future createTask(CreateTask request) => _invoker.invoke(GopeedMethods.createTask, request); - Future> createTaskBatch(CreateTaskBatch request) => + Future createTaskBatch(CreateTaskBatch request) => _invoker.invoke(GopeedMethods.createTaskBatch, request); Future patchTask(String id, PatchTask request) async { diff --git a/ui/flutter/lib/features/home/presentation/pages/home_page.dart b/ui/flutter/lib/features/home/presentation/pages/home_page.dart index a4b3b2aa..6d404f8d 100644 --- a/ui/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/ui/flutter/lib/features/home/presentation/pages/home_page.dart @@ -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 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 { _TaskFilter _activeFilter = _TaskFilter.downloading; TaskRecord? _selectedTask; bool _batchMode = false; + final Set _expandedGroupIds = {}; final TaskBatchSelectionController _batchSelection = TaskBatchSelectionController(); @override @@ -96,11 +98,16 @@ class _HomePageState extends ConsumerState { final tasksState = tasksAsync.value; final tasks = tasksState?.tasks ?? const []; 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 { 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 { ) : 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 { 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 { ), 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 { _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 tasks) { + List _visibleTasks(List 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> _taskNavigationItems(List tasks) { @@ -376,7 +390,13 @@ class _HomePageState extends ConsumerState { 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 { 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 { 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 _selectedTaskIdsFor(bool Function(TaskRecord task) test) { - final tasks = ref.read(tasksControllerProvider).value?.tasks ?? const []; + final tasks = ref.read(tasksControllerProvider).value?.allTasks ?? const []; return tasks.where((task) => _batchSelection.contains(task.id) && test(task)).map((task) => task.id).toList(); } @@ -479,8 +497,8 @@ class _HomePageState extends ConsumerState { } List _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 {..._batchSelection.where(visibleIds.contains), task.id}.toList(growable: false); } @@ -502,9 +520,27 @@ class _HomePageState extends ConsumerState { 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() ?? + []; + 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(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(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 { 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 { Future _updateTaskUrl(String id, String url, {Map headers = const {}}) async { try { await ref.read(tasksControllerProvider.notifier).updateUrl(id, url, headers: headers); - final updatedTasks = ref.read(tasksControllerProvider).value?.tasks ?? const []; - 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 { } Future _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 tasks; + final Set memberIds; + final Set 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, diff --git a/ui/flutter/lib/features/tasks/application/pending_download.dart b/ui/flutter/lib/features/tasks/application/pending_download.dart new file mode 100644 index 00000000..a23e75d2 --- /dev/null +++ b/ui/flutter/lib/features/tasks/application/pending_download.dart @@ -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 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 get files => result?.res.files ?? const []; + bool get hasSelection => !created && result != null && selected.isNotEmpty; + + Future 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 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.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.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 sources; + final CreateTaskBatch request; + + Future 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 planDownloads(List sources) { + final groups = []; + final independent = []; + 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()]), + ), + ]; +} diff --git a/ui/flutter/lib/features/tasks/application/tasks_controller.dart b/ui/flutter/lib/features/tasks/application/tasks_controller.dart index 43071f32..f883c8c2 100644 --- a/ui/flutter/lib/features/tasks/application/tasks_controller.dart +++ b/ui/flutter/lib/features/tasks/application/tasks_controller.dart @@ -23,6 +23,10 @@ class TasksState { final int uploadSpeedBytesPerSecond; final Object? lastError; + Iterable get allTasks => tasks.expand((task) => [task, ...task.members]); + + TaskRecord? findTask(String id) => allTasks.where((task) => task.id == id).firstOrNull; + TasksState copyWith({ List? tasks, int? downloadSpeedBytesPerSecond, @@ -122,13 +126,14 @@ class TasksController extends AsyncNotifier { Future _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, ); diff --git a/ui/flutter/lib/features/tasks/domain/task_record.dart b/ui/flutter/lib/features/tasks/domain/task_record.dart index 4c2a1368..813c78cc 100644 --- a/ui/flutter/lib/features/tasks/domain/task_record.dart +++ b/ui/flutter/lib/features/tasks/domain/task_record.dart @@ -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 files; final bool uploading; final bool isFolder; + final bool isGroup; + final List members; final api_task.Protocol? protocol; final Map 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 taskRecordsFromApi(List tasks) { + final members = >{}; + final metadata = {}; + final records = {}; + 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 = {}; + 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 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 = []; + 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(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 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 _requestHeaders(Object? extra) { if (extra is! Map) return const {}; final header = extra['header']; diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index 3544b52b..398ea006 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -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 { bool _directDownload = false; int _protocolTab = 0; bool _creating = false; + final Map _completedSubmissionCounts = {}; + int _createSession = 0; bool _draggingTorrent = false; bool _asDefaultPath = false; String _configuredDownloadDirectory = ''; @@ -248,6 +249,8 @@ class _CreateTaskWindowPageState extends ConsumerState { 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 { await windowManager.close(); } - Future _finishCreation() async { + Future _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 { } else { ref.read(taskListNavigationProvider.notifier).showDownloading(); } + if (!mounted || session != _createSession) return; await _closeWindow(); } @@ -989,53 +1006,74 @@ class _CreateTaskWindowPageState extends ConsumerState { _showToast(context.l10n.downloadLinkValid); return; } - if (!_validateAdvancedOptions()) { + final occurrences = {}; + final pendingUrls = []; + 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 { _applyCategoryHighlight(); } - Future _submitResolved(Request request, ResolveResult result, List 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 _showResolveDialog(Request request, ResolveResult result) async { - final initialSelection = List.generate(result.res.files.length, (index) => index); - var selectedIndexes = List.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( - 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 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 _showCreateHistory() async { List histories; try { diff --git a/ui/flutter/lib/features/tasks/presentation/pages/task_details_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/task_details_page.dart index 9fd4b4f0..3496be2f 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/task_details_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/task_details_page.dart @@ -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 []) { - 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); diff --git a/ui/flutter/lib/features/tasks/presentation/pages/task_files_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/task_files_page.dart index a50eb5f6..f3c7b138 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/task_files_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/task_files_page.dart @@ -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 []) { - 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( diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/create_task_action_buttons.dart b/ui/flutter/lib/features/tasks/presentation/widgets/create_task_action_buttons.dart index 6de2ce88..57d512a9 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/create_task_action_buttons.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/create_task_action_buttons.dart @@ -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; diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_download_dialog.dart b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_download_dialog.dart new file mode 100644 index 00000000..92a83d92 --- /dev/null +++ b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_download_dialog.dart @@ -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 showResolveDownloadsDialog( + BuildContext context, { + required List sources, + required GopeedService service, + ValueChanged>? onBatchCreated, +}) async { + final dialog = const DialogOverlayHandler().show( + 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 sources; + final GopeedService service; + final ValueChanged>? 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 _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 _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().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 = >{}; + 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'), + ), + ), + ], + ); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart index 9ad5e482..47bbb9e7 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/resolve_file_tree.dart @@ -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 files; - final List initialSelection; - final ValueChanged> onSelectionChanged; +class ResolveFileTree extends StatefulWidget { + const ResolveFileTree({super.key, required this.sources, required this.onSelectionChanged}); + + final List sources; + final ValueChanged> onSelectionChanged; @override State createState() => _ResolveFileTreeState(); } class _ResolveFileTreeState extends State { - late List> _treeItems; - late List _allFileIndexes; - late final Set _selectedIndexes; + late List> _treeItems; + late Map _files; + late Map _labels; + late Map _sourcesById; + late Map _sourcesByRootKey; + final Set _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( + 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(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( + child: FileTreeView( 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 { ); } - bool _isValidIndex(int index) => index >= 0 && index < widget.files.length; - - void _rebuildTreeItems() { - _allFileIndexes = List.generate(widget.files.length, (index) => index, growable: false); - _treeItems = widget.files - .asMap() - .entries - .map( - (entry) => FileTreeItem( - 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 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 indexes, bool selected) { + void _setSelection(Iterable 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 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 = {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)); } } diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card.dart index 9a65e243..5c4ef804 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card.dart @@ -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), + ), + ); } } diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart index 004092bd..38de2095 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_desktop.dart @@ -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 { _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 { ) : 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 _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( diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart index 8724f0d0..3b6843ca 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_mobile.dart @@ -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 { 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 { ) : 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 { } List _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( diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart index 51c27537..9bc92b37 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_card/task_card_parts.dart @@ -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)), + ), + ), ), - ), + ], ); } } diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_context_menu.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_context_menu.dart index 05216d7c..271a7713 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_context_menu.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_context_menu.dart @@ -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), ), diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_delete_dialog.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_delete_dialog.dart index f78cabda..13ac593f 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_delete_dialog.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_delete_dialog.dart @@ -5,7 +5,13 @@ import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../l10n/l10n.dart'; -Future showTaskDeleteDialog(BuildContext context, {required int taskCount, required bool keepFiles}) async { +Future showTaskDeleteDialog( + BuildContext context, { + required int taskCount, + required bool keepFiles, + int? memberCount, + int? fileCount, +}) async { var currentKeepFiles = keepFiles; final dialog = const DialogOverlayHandler().show( context: context, @@ -32,34 +38,46 @@ Future 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: [ diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart index c668ff9b..3723ac35 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart @@ -121,7 +121,9 @@ class _TaskDetailsViewState extends ConsumerState { 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 { 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, diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_browser_dialog.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_browser_dialog.dart index ad695501..52ddc4ec 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_browser_dialog.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_browser_dialog.dart @@ -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 browseTaskFiles(BuildContext context, TaskRecord task, {required bool mobile}) async { @@ -49,7 +51,16 @@ Future 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); + }, + ), ), ); }, diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_manager.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_manager.dart index 84e4f282..c32c1d62 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_manager.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_manager.dart @@ -120,14 +120,19 @@ class _TaskFileManagerViewState extends State { } 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) { diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart index 1b1f5c09..fda35580 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_file_tree.dart @@ -42,6 +42,13 @@ class _TaskFileTreeState extends State { Widget build(BuildContext context) { final progressByIndex = { for (final file in widget.runtimeStatus?.files ?? const []) 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().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), diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index ff230392..54faaeb4 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -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} من الملفات." } diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index 69021d4e..7e966e87 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -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." } diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index 8c322739..342c5b9b 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -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." } diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index 109a7d8c..aa0914a6 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -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." } diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 8381f4c3..88072e1c 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -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" + } + } + } } diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index bb951104..d70bf3f0 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -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." } diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index aaaaefb8..081664fc 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -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} پرونده تأثیر می‌گذارد." } diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index a8bdf3bc..6350d033 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -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." } diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index 000bbdc6..79bab34a 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -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." } diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 613101f0..ef55b4b4 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -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." } diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 1f318933..f7887f2b 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -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." } diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index 7853b306..f0f12448 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -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} 個のファイルが対象です。" } diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index e8250ffb..1631f70c 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -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}개에 영향을 줍니다." } diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index b2f98ee6..6303c48e 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -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." } diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index 9564a338..cae88e04 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -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." } diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index 264d87fc..64c2c82c 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -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} файлов." } diff --git a/ui/flutter/lib/l10n/app_sk.arb b/ui/flutter/lib/l10n/app_sk.arb index cd9e16ef..c51e89d1 100644 --- a/ui/flutter/lib/l10n/app_sk.arb +++ b/ui/flutter/lib/l10n/app_sk.arb @@ -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." } diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index 8f26f20a..2fc4e197 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -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} கோப்புகளையும் பாதிக்கும்." } diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index 3e2dce2f..015a647f 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -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." } diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index db78b2e7..a82e5480 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -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} файлів." } diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index 072407fb..a85c12c0 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -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." } diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index af40b37f..c9a4ea87 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -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} 个文件。" } diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index 41379408..da3561dd 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -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} 個檔案。" } diff --git a/ui/flutter/lib/shared/widgets/app_tooltip.dart b/ui/flutter/lib/shared/widgets/app_tooltip.dart index 339e7f95..f9fb4c8f 100644 --- a/ui/flutter/lib/shared/widgets/app_tooltip.dart +++ b/ui/flutter/lib/shared/widgets/app_tooltip.dart @@ -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 createState() => _AppTooltipState(); +} + +class _AppTooltipState extends State { + final _tooltipKey = GlobalKey(); @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, + ); } } diff --git a/ui/flutter/lib/shared/widgets/file_tree_view.dart b/ui/flutter/lib/shared/widgets/file_tree_view.dart index 1d71a767..0f4bc882 100644 --- a/ui/flutter/lib/shared/widgets/file_tree_view.dart +++ b/ui/flutter/lib/shared/widgets/file_tree_view.dart @@ -16,6 +16,8 @@ class FileTreeItem { required this.name, required this.size, required this.data, + this.directoryOnly = false, + this.namespace = '', }); final String key; @@ -23,6 +25,10 @@ class FileTreeItem { 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 { @@ -61,6 +67,7 @@ class FileTreeView extends StatefulWidget { this.headerLeading, this.leadingBuilder, this.iconBuilder, + this.iconWidgetBuilder, this.trailingBuilder, this.onNodePressed, this.keyPrefix = 'file-tree', @@ -71,12 +78,15 @@ class FileTreeView extends StatefulWidget { this.contentTextStyle, this.iconSize = 18, this.initialExpandedDepth, + this.directoryLabels = const {}, }); final List> items; + final Map directoryLabels; final Widget? headerLeading; final FileTreeNodeWidgetBuilder? leadingBuilder; final FileTreeNodeIconBuilder? iconBuilder; + final FileTreeNodeWidgetBuilder? iconWidgetBuilder; final FileTreeNodeWidgetBuilder? trailingBuilder; final ValueChanged>? onNodePressed; final String keyPrefix; @@ -115,7 +125,11 @@ class _FileTreeViewState extends State> 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 extends State> 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 extends State> 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 extends State> 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 extends State> 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 { } } -List>> _buildTree(List> items, {int? expandedDepth}) { +List>> _buildTree( + List> items, { + int? expandedDepth, + Map directoryLabels = const {}, +}) { final root = <_BuildNode>[]; final folders = >{}; var originalIndex = 0; @@ -885,8 +908,13 @@ List>> _buildTree(List> items, {int? for (final part in parts) { currentPath = currentPath.isEmpty ? part : '$currentPath/$part'; - final folder = folders.putIfAbsent(currentPath, () { - final node = _BuildNode(key: 'folder:$currentPath', label: part, originalIndex: originalIndex++); + final directoryKey = item.namespace.isEmpty ? currentPath : '${item.namespace}:$currentPath'; + final folder = folders.putIfAbsent(directoryKey, () { + final node = _BuildNode( + key: 'folder:$directoryKey', + label: directoryLabels[directoryKey] ?? part, + originalIndex: originalIndex++, + ); if (parent == null) { root.add(node); } else { @@ -897,6 +925,8 @@ List>> _buildTree(List> items, {int? parent = folder; } + if (item.directoryOnly) continue; + final fileNode = _BuildNode( key: 'file:${item.key}', label: item.name, @@ -945,10 +975,10 @@ List>> _sortNodes( 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 _expandedFolderKeys(Iterable>> nodes) { final keys = {}; void collect(TreeNode> node) { final item = node as TreeItemNode>; - 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>> _restoreExpandedFolders( 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(List> items, double Function(String label) measureLabel) { +double _widestLabelWidth(List>> 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>; + 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( - List> items, { + List>> 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>; + 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); } diff --git a/ui/flutter/test/api/resolve_test.dart b/ui/flutter/test/api/resolve_test.dart index 02d0ff23..54e23bf7 100644 --- a/ui/flutter/test/api/resolve_test.dart +++ b/ui/flutter/test/api/resolve_test.dart @@ -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)['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; + final member = (body['reqs'] as List).single as Map; + 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? 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'}; } diff --git a/ui/flutter/test/core/capabilities/capability_rpc_test.dart b/ui/flutter/test/core/capabilities/capability_rpc_test.dart index c3c5a324..1e6de8c3 100644 --- a/ui/flutter/test/core/capabilities/capability_rpc_test.dart +++ b/ui/flutter/test/core/capabilities/capability_rpc_test.dart @@ -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( + 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; diff --git a/ui/flutter/test/core/capabilities/create_history_test.dart b/ui/flutter/test/core/capabilities/create_history_test.dart new file mode 100644 index 00000000..13affb15 --- /dev/null +++ b/ui/flutter/test/core/capabilities/create_history_test.dart @@ -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.delayed(Duration.zero); + final readsWhileSaving = transport.reads; + transport.releaseFirstPut.complete(); + await Future.wait([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.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.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.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())); + 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.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.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.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.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(); + final releaseFirstPut = Completer(); + bool blockFirstPut = true; + bool failNextPut = false; + int reads = 0; + int writes = 0; + + @override + Future request( + String path, { + String method = 'GET', + dynamic data, + Map? 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); + 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; +} diff --git a/ui/flutter/test/features/home/presentation/home_page_test.dart b/ui/flutter/test/features/home/presentation/home_page_test.dart new file mode 100644 index 00000000..eeb2943c --- /dev/null +++ b/ui/flutter/test/features/home/presentation/home_page_test.dart @@ -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 = []; + final resumed = []; + final deleted = []; + + TasksState snapshot() => TasksState( + tasks: taskRecordsFromApi([ + member('child', name: name, status: childStatus), + member('torrent', name: 'torrent bundle', bt: true, status: 'pause'), + ]), + ); + + @override + Future build() async => snapshot(); + + void rename() { + name = 'renamed.txt'; + state = AsyncData(snapshot()); + } + + void completeMember() { + childStatus = 'done'; + state = AsyncData(snapshot()); + } + + @override + Future pause(String id) async => paused.add(id); + @override + Future pauseAll(List? ids) async => paused.addAll(ids!); + @override + Future resume(String id) async => resumed.add(id); + @override + Future resumeAll(List? ids) async => resumed.addAll(ids!); + @override + Future deleteSelected(List? 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 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 reloadConfig() async {} +} + +Future 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(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(find.byType(TaskDetailsView).last).task.id, 'child'); + app.controller.rename(); + await settle(tester); + expect(tester.widget(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(card('torrent')).onResume!(); + await settle(tester); + expect(app.controller.resumed, ['torrent']); + tester.widget(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 = []; + final child = tester.widget(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(find.byType(TaskDetailsView).last).task.id, 'torrent'); + expect(tester.widget(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(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(find.byType(TaskFileManagerView)).task.name, 'member.txt'); + app.controller.rename(); + await settle(tester); + expect(tester.widget(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(card('group')).selectedInBatch, isTrue); + expect(tester.widget(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(find.byType(TaskDetailsView).last).task.id, 'child'); + app.controller.rename(); + await settle(tester); + expect(tester.widget(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(find.byType(TaskFileManagerView)).task.name, 'member.txt'); + app.controller.rename(); + await settle(tester); + expect(tester.widget(find.byType(TaskFileManagerView)).task.name, 'renamed.txt'); + expect(tester.takeException(), isNull); + }); +} diff --git a/ui/flutter/test/features/tasks/application/pending_download_test.dart b/ui/flutter/test/features/tasks/application/pending_download_test.dart new file mode 100644 index 00000000..5b92b41a --- /dev/null +++ b/ui/flutter/test/features/tasks/application/pending_download_test.dart @@ -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 = []; + 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 { + '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]); + }); +} diff --git a/ui/flutter/test/features/tasks/application/tasks_controller_test.dart b/ui/flutter/test/features/tasks/application/tasks_controller_test.dart new file mode 100644 index 00000000..25b3c74d --- /dev/null +++ b/ui/flutter/test/features/tasks/application/tasks_controller_test.dart @@ -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 = []; + 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); + }); +} diff --git a/ui/flutter/test/features/tasks/domain/task_record_test.dart b/ui/flutter/test/features/tasks/domain/task_record_test.dart index cb809350..f989a362 100644 --- a/ui/flutter/test/features/tasks/domain/task_record_test.dart +++ b/ui/flutter/test/features/tasks/domain/task_record_test.dart @@ -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, 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}, +}); diff --git a/ui/flutter/test/features/tasks/presentation/create_task_retry_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_retry_test.dart new file mode 100644 index 00000000..cbc2eb35 --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/create_task_retry_test.dart @@ -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 = []; + 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 = []; + final batches = []; + 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 = []; + 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 = []; + final batches = []; + 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(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).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(); + final resolutions = []; + 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).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(); + final requests = []; + 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 = []; + 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 _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 _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)); +} diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index b405c96c..7bb8534e 100644 --- a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -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); - 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 = []; 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.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(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(shown).data, contains('/work')); - expect(tester.widget(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; + expect(submitted?.reqs?.single.rid, 'ftp-resolved'); + final createOptions = submitted?.reqs?.single.opts?.extra! as Map; 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); + 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); expect(options.connections, 6); }); testWidgets('mixed links cap only the FTP task at 32 connections', (tester) async { - final submitted = []; + final submitted = []; 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}; + expect(submitted, hasLength(1)); + final byUrl = {for (final item in submitted.single.reqs!) item.req!.url: item.opts!.extra! as Map}; 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 = []; + 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 _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 _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)); diff --git a/ui/flutter/test/features/tasks/presentation/resolve_download_dialog_test.dart b/ui/flutter/test/features/tasks/presentation/resolve_download_dialog_test.dart new file mode 100644 index 00000000..d484ab83 --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/resolve_download_dialog_test.dart @@ -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 open( + WidgetTester tester, + List sources, + CapabilityRegistry registry, { + ValueChanged? 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(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 = []; + final responses = [Completer(), Completer()]; + 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(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(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull); + expect(tester.takeException(), isNull); + }); + + for (final filters in [ + ['video'], + ['video', 'audio'], + ]) { + testWidgets('new files clear type highlights without changing previous selections ($filters)', (tester) async { + final response = Completer(); + 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 = []; + 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(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(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(); + 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(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(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(); + 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 = []; + 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( + find + .descendant( + of: find.byKey(ValueKey('resolve-tree-filter-$filter')), + matching: find.byType(DecoratedBox), + ) + .first, + ) + .decoration + as BoxDecoration; + return decoration.color != null; +} diff --git a/ui/flutter/test/features/tasks/presentation/resolve_sources_test.dart b/ui/flutter/test/features/tasks/presentation/resolve_sources_test.dart new file mode 100644 index 00000000..93cbb3ed --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/resolve_sources_test.dart @@ -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 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>> roots() => tester + .widget>>( + find.byType(VirtualTreeView>), + ) + .nodes + .cast>>(); + void expectOrder(List 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 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>>( + find.byType(VirtualTreeView>), + ); + expect(tree.nodes, hasLength(1)); + expect(tree.nodes.single.children, isEmpty); + expect((tree.nodes.single as shad.TreeItemNode>).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>>( + find.byType(VirtualTreeView>), + ); + final roots = tree.nodes.cast>>(); + expect(roots.map((node) => node.data.label), ['album']); + expect(roots.single.expanded, isTrue); + final child = roots.single.children.single as shad.TreeItemNode>; + 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 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 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>>( + find.byType(VirtualTreeView>), + ); + expect(tree.nodes, hasLength(4)); + expect(tree.nodes.cast>>().map((node) => node.data.label), [ + 'same.txt', + 'nested', + 'same.txt', + 'nested', + ]); + expect( + tree.nodes.cast>>().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>).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 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); + }); + } +} diff --git a/ui/flutter/test/features/tasks/presentation/task_delete_dialog_test.dart b/ui/flutter/test/features/tasks/presentation/task_delete_dialog_test.dart new file mode 100644 index 00000000..cb9d96f1 --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/task_delete_dialog_test.dart @@ -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); + }); + } +} diff --git a/ui/flutter/test/features/tasks/presentation/task_file_browser_dialog_test.dart b/ui/flutter/test/features/tasks/presentation/task_file_browser_dialog_test.dart new file mode 100644 index 00000000..1bf8d6c7 --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/task_file_browser_dialog_test.dart @@ -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 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()); + }); + } +} diff --git a/ui/flutter/test/features/tasks/presentation/task_file_manager_test.dart b/ui/flutter/test/features/tasks/presentation/task_file_manager_test.dart new file mode 100644 index 00000000..5825c49e --- /dev/null +++ b/ui/flutter/test/features/tasks/presentation/task_file_manager_test.dart @@ -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 request( + String path, { + String method = 'GET', + dynamic data, + Map? 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? shared; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + shared = (call.arguments as Map)['paths'] as List; + 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); + }); + } + } + } +} diff --git a/ui/flutter/test/features/tasks/presentation/task_file_tree_size_hint_test.dart b/ui/flutter/test/features/tasks/presentation/task_file_tree_size_hint_test.dart index 629dae9b..54c9bf7a 100644 --- a/ui/flutter/test/features/tasks/presentation/task_file_tree_size_hint_test.dart +++ b/ui/flutter/test/features/tasks/presentation/task_file_tree_size_hint_test.dart @@ -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(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(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), diff --git a/ui/flutter/test/shared/widgets/app_tooltip_test.dart b/ui/flutter/test/shared/widgets/app_tooltip_test.dart index e9a49a1a..228106d2 100644 --- a/ui/flutter/test/shared/widgets/app_tooltip_test.dart +++ b/ui/flutter/test/shared/widgets/app_tooltip_test.dart @@ -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(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 = []; final tooltipConstructor = RegExp(r'\b(?:\w+\.)?Tooltip\s*\('); diff --git a/ui/flutter/test/shared/widgets/file_tree_view_test.dart b/ui/flutter/test/shared/widgets/file_tree_view_test.dart new file mode 100644 index 00000000..98473848 --- /dev/null +++ b/ui/flutter/test/shared/widgets/file_tree_view_test.dart @@ -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( + 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 labels() => tester + .widget>>(find.byType(VirtualTreeView>)) + .nodes + .cast>>() + .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( + 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(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( + 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( + 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>>(find.byType(VirtualTreeView>)); + final bundle = tree.nodes.cast>>().first; + expect(bundle.data.label, 'bundle'); + expect(bundle.expanded, isFalse); + expect(find.text('later.txt'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 613f0d14..004d9600 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -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>(treeFinder).nodes.first.expanded, isFalse); + expect((tester.widget>(treeFinder).nodes.first as shad.TreeItemNode).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>(treeFinder).nodes.first.expanded, isFalse); + expect((tester.widget>(treeFinder).nodes.first as shad.TreeItemNode).children.first.expanded, isFalse); expect(selected, [1, 2]); await tester.tap(find.text('videos')); await tester.pump(); - expect(tester.widget>(treeFinder).nodes.first.expanded, isFalse); + expect((tester.widget>(treeFinder).nodes.first as shad.TreeItemNode).children.first.expanded, isFalse); expect(selected, [0, 1, 2]); await tester.tap(videosExpand); await tester.pumpAndSettle(); - expect(tester.widget>(treeFinder).nodes.first.expanded, isTrue); + expect((tester.widget>(treeFinder).nodes.first as shad.TreeItemNode).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 files, + required List initialSelection, + required ValueChanged> 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()), + ); +}