feat(tasks): group extension folder downloads (#1594)

This commit is contained in:
WuFeng authored and GitHub committed 2026-11-02 15:39:08 +08:00
1 parent fc0a1e0ef9
commit 1e36599d52
84 files changed
+6690 -881

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
# Batch task creation
`POST /api/v1/tasks/batch` accepts ordinary and grouped batches:
```json
{
"group": true,
"groupName": "album",
"opts": {"path": "/downloads"},
"reqs": [
{"rid": "resolved-resource-id", "opts": {"selectFiles": [1]}},
{"req": {"url": "https://example.com/cover.jpg"}}
]
}
```
Each member accepts `rid` or `req`. A nonempty `rid` takes priority and creates a
task from the cached resolve result. A member's `opts`, when present, replaces
batch `opts` as a whole; omitted fields do not inherit individual batch values.
Members without `opts` use batch `opts`. BT file selections use the indexes from
the resolve result. Requests without `rid` continue to work.
For groups, batch `opts.path` selects the parent directory. Members inheriting
batch options, or with an empty path, use the reserved group directory. Explicit
relative member paths are resolved under that directory; do not prefix them with
the batch parent path. For example, parent `downloads` and member path `nested`
place the member in `downloads/<group name>/nested`. Absolute paths under the
parent directory preserve their relative structure within the group. In ordinary
batches, an empty member path uses the configured default download directory.
All members are attempted. If at least one task is persisted, the response is
successful and contains the created task IDs. A task that fails to start after
being persisted is included; use the existing task controls to retry it.
Successful tasks are kept when other members fail. If every member fails, the
API returns an error and removes any newly reserved empty group and directory.
Both ordinary and grouped batches now return an object in `data`:
```json
{"code": 0, "msg": "", "data": {"taskIds": ["task-1", "task-2"], "parentId": "group-id"}}
```
Ordinary batches omit `parentId`. **This replaces the former array response for
ordinary batches.** The outer `code`/`msg`/`data` envelope is unchanged. Clients
must read `data.taskIds`; there are no per-member results. A successful response
completes the batch, including partial success, so clients must not automatically
resubmit missing members.
With `group: true`, the server generates the group ID and reserves its directory.
Creation does not accept a client group ID or append to an existing group. Group
metadata remains available through the existing task list and detail responses;
`parentId` identifies membership for group operations.
+2 -2
View File
@@ -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 {
+62
View File
@@ -267,3 +267,65 @@ func TestFullConfigAndJobTriggerShareProtectedProtocolState(t *testing.T) {
t.Fatalf("job state overwritten: %s", raw)
}
}
func TestBatchResolvedAndDirectCreation(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "4")
_, _ = w.Write([]byte("test"))
}))
t.Cleanup(upstream.Close)
d := download.NewDownloader(&download.DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir(), FetchManagers: []fetcher.FetcherManager{&httpProtocol.FetcherManager{}}})
if err := d.Setup(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
cfg, err := d.GetConfig()
if err != nil {
t.Fatal(err)
}
cfg.MaxRunning = 0
if err := d.PutConfig(cfg); err != nil {
t.Fatal(err)
}
service, err := NewService(d)
if err != nil {
t.Fatal(err)
}
for _, grouped := range []bool{false, true} {
resolved := serviceTestRequest[*download.ResolveResult](t, service, http.MethodPost, "/api/v1/resolve", &restmodel.ResolveTask{Req: &base.Request{URL: upstream.URL + "/resolved"}})
result := serviceTestRequest[map[string]json.RawMessage](t, service, http.MethodPost, "/api/v1/tasks/batch", &base.CreateTaskBatch{
Group: grouped, GroupName: "folder", Opts: &base.Options{Path: t.TempDir()},
Reqs: []*base.CreateTaskBatchItem{
{Rid: "invalid"},
{Rid: resolved.ID, Req: &base.Request{URL: "unsupported://ignored"}, Opts: &base.Options{SelectFiles: []int{0}}},
{Req: &base.Request{URL: upstream.URL + "/direct"}},
},
})
var ids []string
if err := json.Unmarshal(result["taskIds"], &ids); err != nil || len(ids) != 2 {
t.Fatalf("unexpected task IDs: %+v, %v", result, err)
}
wantFields := 1
if grouped {
wantFields = 2
}
if len(result) != wantFields {
t.Fatalf("unexpected response fields: %+v", result)
}
var parentID string
if grouped {
if err := json.Unmarshal(result["parentId"], &parentID); err != nil || parentID == "" {
t.Fatalf("missing parent ID: %+v", result)
}
}
task := serviceTestRequest[*download.Task](t, service, http.MethodGet, "/api/v1/tasks/"+ids[0], nil)
if task.ParentID != parentID || !reflect.DeepEqual(task.Meta.Opts.SelectFiles, []int{0}) {
t.Fatalf("resolved task lost membership or selection: %+v", task)
}
status, response := service.JSON(http.MethodPost, "/api/v1/tasks/batch", nil, []byte(`{"reqs":[{"rid":"missing"},{"req":{"url":"unsupported://bad"}}]}`), nil)
var failed restmodel.Result[any]
if err := json.Unmarshal(response, &failed); err != nil || status != http.StatusOK || failed.Code == restmodel.CodeOk {
t.Fatalf("all-failed batch succeeded: %d %s", status, response)
}
}
}
+5 -2
View File
@@ -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"`
}
+7
View File
@@ -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 {
+32
View File
@@ -234,6 +234,38 @@ func TestDecideDownloadDirWhiteList(t *testing.T) {
})
}
func TestDecideGroupDownloadDirWhiteList(t *testing.T) {
parent := t.TempDir()
root := filepath.Join(parent, "folder")
nested := filepath.Join(root, "nested")
for _, test := range []struct {
name string
path string
group *TaskGroup
allowed string
wantErr bool
}{
{name: "group root", path: root, group: &TaskGroup{Path: root}, allowed: filepath.Join(parent, "*")},
{name: "nested member", path: nested, group: &TaskGroup{Path: root}, allowed: filepath.Join(parent, "*")},
{name: "ordinary nested task", path: nested, allowed: filepath.Join(parent, "*"), wantErr: true},
{name: "member outside group", path: filepath.Join(parent, "other"), group: &TaskGroup{Path: root}, allowed: filepath.Join(parent, "*"), wantErr: true},
{name: "group permission revoked", path: nested, group: &TaskGroup{Path: root}, allowed: nested, wantErr: true},
{name: "literal group name", path: filepath.Join(parent, "%year%", "nested"), group: &TaskGroup{Path: filepath.Join(parent, "%year%")}, allowed: filepath.Join(parent, "*")},
} {
t.Run(test.name, func(t *testing.T) {
d := newUnitCategoryDownloader(newTestCategoryConfig())
d.cfg.WhiteDownloadDirs = []string{test.allowed}
got, err := d.decideDownloadDirForTask(&base.Options{Path: test.path, SkipAutoCategorize: true}, singleFileResource("file.txt"), test.group)
if (err != nil) != test.wantErr {
t.Fatalf("decideDownloadDirForTask() = %q, %v; want error = %v", got, err, test.wantErr)
}
if err == nil && got != test.path {
t.Fatalf("path = %q, want %q", got, test.path)
}
})
}
}
func TestSuggestedDownloadDir(t *testing.T) {
programDir := filepath.Join("./downloads", "Program")
+490 -122
View File
@@ -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 = &copyFilter
}
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
}
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -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 {
+9 -7
View File
@@ -2044,8 +2044,13 @@ func waitForTaskStatus(t *testing.T, downloader *Downloader, id string, status b
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
task := downloader.GetTask(id)
if task != nil && task.Status == status {
return
if task != nil && downloader.taskStatus(task) == status {
if status != base.DownloadStatusDone {
return
}
if _, watching := downloader.watchedTasks.Load(id); !watching {
return
}
}
time.Sleep(10 * time.Millisecond)
}
@@ -2053,9 +2058,6 @@ func waitForTaskStatus(t *testing.T, downloader *Downloader, id string, status b
if task == nil {
t.Fatalf("timeout waiting for task %s status %s: task not found", id, status)
}
total := int64(0)
if task.Meta != nil && task.Meta.Res != nil {
total = task.Meta.Res.Size
}
t.Fatalf("timeout waiting for task %s status %s: got %s downloaded=%d total=%d", id, status, task.Status, task.Progress.Downloaded, total)
snapshot, err := downloader.RuntimeStatus(id)
t.Fatalf("timeout waiting for task %s status %s: got %+v, error=%v", id, status, snapshot, err)
}
+29 -9
View File
@@ -29,18 +29,34 @@ type ResolveResult struct {
SuggestedDownloadDir string `json:"suggestedDownloadDir,omitempty"`
}
// TaskGroup stores the directory shared by independently scheduled tasks.
type TaskGroup struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
CreatedAt time.Time `json:"createdAt"`
}
// CreateTaskBatchResult is shared by ordinary and grouped batches.
type CreateTaskBatchResult struct {
TaskIDs []string `json:"taskIds"`
ParentID string `json:"parentId,omitempty"`
}
// Task represents a download unit.
// Options flow path:
// REST Request (model.CreateTask/ResolveTask) -> pkg/download Downloader -> Task.Meta.Opts (base.Options) -> Fetcher.Resolve/Start (e.g. internal/protocol/http/fetcher.go)
type Task struct {
ID string `json:"id"`
Protocol string `json:"protocol"`
Meta *fetcher.FetcherMeta `json:"meta"`
Status base.Status `json:"status"`
Uploading bool `json:"uploading"`
Progress *Progress `json:"progress"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ID string `json:"id"`
Protocol string `json:"protocol"`
Meta *fetcher.FetcherMeta `json:"meta"`
// ParentID identifies the independently stored group.
ParentID string `json:"parentId,omitempty"`
Status base.Status `json:"status"`
Uploading bool `json:"uploading"`
Progress *Progress `json:"progress"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// DoneAt is the time when the task completed; nil if it has never
// finished. A done task never leaves the done state, so the value is
// written once and kept.
@@ -54,6 +70,8 @@ type Task struct {
// does not have it and keeps its directory and name.
NeedsPreparation bool `json:"needsPreparation,omitempty"`
group *TaskGroup
producer production.Source
producerReceived int64
@@ -144,10 +162,12 @@ func (t *Task) MarshalJSON() ([]byte, error) {
type rawTaskType Task
jsonTask := struct {
*rawTaskType
Name string `json:"name"`
Name string `json:"name"`
Group *TaskGroup `json:"group,omitempty"`
}{
(*rawTaskType)(t),
t.Name(),
t.group,
}
return json.Marshal(jsonTask)
}
+1 -1
View File
@@ -63,7 +63,7 @@ func CreateTaskBatch(w http.ResponseWriter, r *http.Request) {
WriteJson(w, model.NewErrorResult("param invalid: reqs", model.CodeInvalidParam))
return
}
taskIds, err := Downloader.CreateDirectBatch(&req)
taskIds, err := Downloader.CreateDirectBatchWithResult(&req)
if err != nil {
WriteJson(w, model.NewErrorResult(err.Error()))
return
+38 -2
View File
@@ -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
+15
View File
@@ -94,6 +94,8 @@ State broadcasting must observe the owning Riverpod state centrally. Do not broa
- Storage RPC methods expose business capabilities such as create-history operations, not raw `box.get` or `box.put` access.
- Prefer batch mutations across the window boundary. For example, save all parsed URLs in one `saveCreateHistory(List<String>)` call.
- New child-window storage requirements must be added to `StorageMethods` and bound in the main registry.
- Config reads, full config saves, and create-history mutations share one main-window queue. Each history mutation reads, changes, and writes the config within that queue. History reads wait for earlier saves.
- `StorageMethods` owns `extra.createHistory`; full config saves preserve its latest value inside the queue so stale settings or child-window snapshots cannot overwrite history.
## 7. Gopeed Backend Migration
@@ -128,3 +130,16 @@ Child-window code must not:
- Parse RPC JSON directly inside a page or widget.
These constraints keep all backend and persistence ownership in the main window and allow multiple child windows to operate concurrently without database locks or duplicated Gopeed runtimes.
## 10. Batch Task Creation
`GopeedMethods.createTaskBatch` carries `CreateTaskBatch` through the existing
codec registry. Each member accepts either `rid` from resolve or `req`, plus
creation `opts`. A nonempty `rid` takes priority. BT selections retain their
original file indexes in `opts.selectFiles`.
Both ordinary and grouped creation return `CreateTaskBatchResult`, an object
with `taskIds` and an optional `parentId`. A successful response completes the
batch, including partial success. The preview tracks completed batches so a
failed batch can be retried without repeating successful batches. Creation does
not accept a group ID to append members to an existing group.
+3 -3
View File
@@ -85,10 +85,10 @@ Future<String> createTask(CreateTask createTask) {
return _parse<String>(() => _request('api/v1/tasks', method: 'POST', data: createTask), (data) => data as String);
}
Future<List<String>> createTaskBatch(CreateTaskBatch createTaskBatch) {
return _parse<List<String>>(
Future<CreateTaskBatchResult> createTaskBatch(CreateTaskBatch createTaskBatch) {
return _parse<CreateTaskBatchResult>(
() => _request('api/v1/tasks/batch', method: 'POST', data: createTaskBatch),
(data) => (data as List<dynamic>).map((e) => e as String).toList(),
(json) => CreateTaskBatchResult.fromJson(json as Map<String, dynamic>),
);
}
@@ -10,7 +10,10 @@ class CreateTaskBatch {
List<CreateTaskBatchItem>? reqs;
Options? opts;
CreateTaskBatch({this.reqs, this.opts});
bool group;
String groupName;
CreateTaskBatch({this.reqs, this.opts, this.group = false, this.groupName = ''});
factory CreateTaskBatch.fromJson(Map<String, dynamic> json) => _$CreateTaskBatchFromJson(json);
@@ -19,11 +22,23 @@ class CreateTaskBatch {
@JsonSerializable()
class CreateTaskBatchItem {
String? rid;
Request? req;
Options? opts;
CreateTaskBatchItem({this.req, this.opts});
CreateTaskBatchItem({this.rid, this.req, this.opts});
factory CreateTaskBatchItem.fromJson(Map<String, dynamic> json) => _$CreateTaskBatchItemFromJson(json);
Map<String, dynamic> toJson() => _$CreateTaskBatchItemToJson(this);
}
@JsonSerializable()
class CreateTaskBatchResult {
final List<String> taskIds;
final String? parentId;
CreateTaskBatchResult({required this.taskIds, this.parentId});
factory CreateTaskBatchResult.fromJson(Map<String, dynamic> json) => _$CreateTaskBatchResultFromJson(json);
Map<String, dynamic> toJson() => _$CreateTaskBatchResultToJson(this);
}
@@ -6,22 +6,55 @@ part of 'create_task_batch.dart';
// JsonSerializableGenerator
// **************************************************************************
CreateTaskBatch _$CreateTaskBatchFromJson(Map<String, dynamic> json) => CreateTaskBatch(
reqs: (json['reqs'] as List<dynamic>?)?.map((e) => CreateTaskBatchItem.fromJson(e as Map<String, dynamic>)).toList(),
opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map<String, dynamic>),
);
CreateTaskBatch _$CreateTaskBatchFromJson(Map<String, dynamic> json) =>
CreateTaskBatch(
reqs: (json['reqs'] as List<dynamic>?)
?.map((e) => CreateTaskBatchItem.fromJson(e as Map<String, dynamic>))
.toList(),
opts: json['opts'] == null
? null
: Options.fromJson(json['opts'] as Map<String, dynamic>),
group: json['group'] as bool? ?? false,
groupName: json['groupName'] as String? ?? '',
);
Map<String, dynamic> _$CreateTaskBatchToJson(CreateTaskBatch instance) => <String, dynamic>{
'reqs': ?instance.reqs?.map((e) => e.toJson()).toList(),
'opts': ?instance.opts?.toJson(),
};
Map<String, dynamic> _$CreateTaskBatchToJson(CreateTaskBatch instance) =>
<String, dynamic>{
'reqs': ?instance.reqs?.map((e) => e.toJson()).toList(),
'opts': ?instance.opts?.toJson(),
'group': instance.group,
'groupName': instance.groupName,
};
CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map<String, dynamic> json) => CreateTaskBatchItem(
req: json['req'] == null ? null : Request.fromJson(json['req'] as Map<String, dynamic>),
opts: json['opts'] == null ? null : Options.fromJson(json['opts'] as Map<String, dynamic>),
);
CreateTaskBatchItem _$CreateTaskBatchItemFromJson(Map<String, dynamic> json) =>
CreateTaskBatchItem(
rid: json['rid'] as String?,
req: json['req'] == null
? null
: Request.fromJson(json['req'] as Map<String, dynamic>),
opts: json['opts'] == null
? null
: Options.fromJson(json['opts'] as Map<String, dynamic>),
);
Map<String, dynamic> _$CreateTaskBatchItemToJson(CreateTaskBatchItem instance) => <String, dynamic>{
Map<String, dynamic> _$CreateTaskBatchItemToJson(
CreateTaskBatchItem instance,
) => <String, dynamic>{
'rid': ?instance.rid,
'req': ?instance.req,
'opts': ?instance.opts,
};
CreateTaskBatchResult _$CreateTaskBatchResultFromJson(
Map<String, dynamic> json,
) => CreateTaskBatchResult(
taskIds: (json['taskIds'] as List<dynamic>).map((e) => e as String).toList(),
parentId: json['parentId'] as String?,
);
Map<String, dynamic> _$CreateTaskBatchResultToJson(
CreateTaskBatchResult instance,
) => <String, dynamic>{
'taskIds': instance.taskIds,
'parentId': ?instance.parentId,
};
+17
View File
@@ -28,7 +28,10 @@ enum ExtractStatus {
class Task {
String id;
String name;
@JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue)
Protocol? protocol;
TaskGroup? group;
String parentId;
Meta meta;
Status status;
bool uploading;
@@ -47,6 +50,8 @@ class Task {
required this.createdAt,
required this.updatedAt,
this.doneAt,
this.group,
this.parentId = '',
});
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
@@ -54,6 +59,18 @@ class Task {
Map<String, dynamic> toJson() => _$TaskToJson(this);
}
@JsonSerializable()
class TaskGroup {
final String id;
final String name;
final String path;
final DateTime createdAt;
TaskGroup({required this.id, required this.name, required this.path, required this.createdAt});
factory TaskGroup.fromJson(Map<String, dynamic> json) => _$TaskGroupFromJson(json);
Map<String, dynamic> toJson() => _$TaskGroupToJson(this);
}
@JsonSerializable()
class Progress {
int used;
+19 -1
View File
@@ -16,12 +16,16 @@ Task _$TaskFromJson(Map<String, dynamic> json) => Task(
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
doneAt: json['doneAt'] == null ? null : DateTime.parse(json['doneAt'] as String),
)..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol']);
group: json['group'] == null ? null : TaskGroup.fromJson(json['group'] as Map<String, dynamic>),
parentId: json['parentId'] as String? ?? '',
)..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol'], unknownValue: JsonKey.nullForUndefinedEnumValue);
Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'protocol': ?_$ProtocolEnumMap[instance.protocol],
'group': ?instance.group?.toJson(),
'parentId': instance.parentId,
'meta': instance.meta.toJson(),
'status': _$StatusEnumMap[instance.status]!,
'uploading': instance.uploading,
@@ -48,6 +52,20 @@ const _$ProtocolEnumMap = {
Protocol.ftp: 'ftp',
};
TaskGroup _$TaskGroupFromJson(Map<String, dynamic> json) => TaskGroup(
id: json['id'] as String,
name: json['name'] as String,
path: json['path'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
);
Map<String, dynamic> _$TaskGroupToJson(TaskGroup instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'path': instance.path,
'createdAt': instance.createdAt.toIso8601String(),
};
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
used: (json['used'] as num).toInt(),
speed: (json['speed'] as num).toInt(),
@@ -34,8 +34,9 @@ class AppCapabilities {
class LocalAppCapabilities {
LocalAppCapabilities._() : codecs = createAppCapabilityCodecs() {
registry = CapabilityRegistry(codecs);
_bindGopeed(registry);
_bindStorage(registry);
final config = _ConfigStore();
_bindGopeed(registry, config);
_bindStorage(registry, config);
registry.bind(NavigationMethods.showDownloadingTasks, (_) {
AppRouter.showDownloadingTasks();
return const RpcUnit();
@@ -62,6 +63,7 @@ RpcCodecRegistry createAppCapabilityCodecs() {
..register<ResolveResult>((json) => ResolveResult.fromJson(json! as Map<String, dynamic>))
..register<CreateTask>((json) => CreateTask.fromJson(json! as Map<String, dynamic>))
..register<CreateTaskBatch>((json) => CreateTaskBatch.fromJson(json! as Map<String, dynamic>))
..register<CreateTaskBatchResult>((json) => CreateTaskBatchResult.fromJson(json! as Map<String, dynamic>))
..register<DownloaderConfig>((json) => DownloaderConfig.fromJson(json! as Map<String, dynamic>))
..register<TaskRuntimeStatus>((json) => TaskRuntimeStatus.fromJson(json! as Map<String, dynamic>))
..register<List<Task>>(
@@ -80,7 +82,7 @@ RpcCodecRegistry createAppCapabilityCodecs() {
return codecs;
}
void _bindGopeed(CapabilityRegistry registry) {
void _bindGopeed(CapabilityRegistry registry, _ConfigStore configStore) {
registry
..bind(GopeedMethods.triggerProtocolJob, (p) async {
await api.triggerProtocolJob(p['protocol'] as String, p['name'] as String);
@@ -127,9 +129,9 @@ void _bindGopeed(CapabilityRegistry registry) {
await api.deleteTasks(_optionalStringList(params['ids']), params['force'] as bool? ?? false);
return const RpcUnit();
})
..bind(GopeedMethods.getConfig, (_) => api.getConfig())
..bind(GopeedMethods.getConfig, (_) => configStore.get())
..bind(GopeedMethods.putConfig, (config) async {
await api.putConfig(config);
await configStore.put(config);
return const RpcUnit();
})
..bind(GopeedMethods.installExtension, api.installExtension)
@@ -163,35 +165,59 @@ void _bindGopeed(CapabilityRegistry registry) {
});
}
void _bindStorage(CapabilityRegistry registry) {
// Shared by local and child-window handlers. History owns its config field;
// full config snapshots must preserve the latest history inside the same queue.
class _ConfigStore {
Future<void> _tail = Future<void>.value();
Future<T> _run<T>(Future<T> Function() operation) {
final result = _tail.then((_) => operation());
_tail = result.then<void>((_) {}, onError: (Object _, StackTrace _) {});
return result;
}
Future<DownloaderConfig> get() => _run(api.getConfig);
Future<void> put(DownloaderConfig config) {
final snapshot = DownloaderConfig.fromJson(config.toJson());
return _run(() async {
final latest = await api.getConfig();
snapshot.extra.createHistory = latest.extra.createHistory;
await api.putConfig(snapshot);
});
}
Future<void> updateHistory(void Function(List<String>) mutation) => _run(() async {
final config = await api.getConfig();
final history = List<String>.of(config.extra.createHistory);
mutation(history);
config.extra.createHistory = history;
await api.putConfig(config);
});
}
void _bindStorage(CapabilityRegistry registry, _ConfigStore configStore) {
registry
..bind(StorageMethods.getCreateHistory, (_) async {
final config = await api.getConfig();
final config = await configStore.get();
return config.extra.createHistory;
})
..bind(StorageMethods.saveCreateHistory, (urls) async {
final config = await api.getConfig();
final history = List<String>.of(config.extra.createHistory);
for (final url in urls) {
history.remove(url);
history.insert(0, url);
if (history.length > 64) history.removeLast();
}
config.extra.createHistory = history;
await api.putConfig(config);
await configStore.updateHistory((history) {
for (final url in urls) {
history.remove(url);
history.insert(0, url);
if (history.length > 64) history.removeLast();
}
});
return const RpcUnit();
})
..bind(StorageMethods.removeCreateHistory, (url) async {
final config = await api.getConfig();
final history = List<String>.of(config.extra.createHistory)..remove(url);
config.extra.createHistory = history;
await api.putConfig(config);
await configStore.updateHistory((history) => history.remove(url));
return const RpcUnit();
})
..bind(StorageMethods.clearCreateHistory, (_) async {
final config = await api.getConfig();
config.extra.createHistory = const [];
await api.putConfig(config);
await configStore.updateHistory((history) => history.clear());
return const RpcUnit();
});
}
@@ -17,7 +17,7 @@ abstract final class GopeedMethods {
static const getMirrorCandidates = RpcMethod<Map<String, dynamic>, List<String>>('gopeed.mirrors.candidates');
static const resolve = RpcMethod<ResolveTask, ResolveResult>('gopeed.resolve');
static const createTask = RpcMethod<CreateTask, String>('gopeed.task.create');
static const createTaskBatch = RpcMethod<CreateTaskBatch, List<String>>('gopeed.task.createBatch');
static const createTaskBatch = RpcMethod<CreateTaskBatch, CreateTaskBatchResult>('gopeed.task.createBatch');
static const patchTask = RpcMethod<Map<String, dynamic>, RpcUnit>('gopeed.task.patch');
static const getTasks = RpcMethod<List<String>, List<Task>>('gopeed.task.list');
static const getTaskStatus = RpcMethod<String, TaskRuntimeStatus>('gopeed.task.status');
@@ -53,7 +53,7 @@ class GopeedService {
Future<String> createTask(CreateTask request) => _invoker.invoke(GopeedMethods.createTask, request);
Future<List<String>> createTaskBatch(CreateTaskBatch request) =>
Future<CreateTaskBatchResult> createTaskBatch(CreateTaskBatch request) =>
_invoker.invoke(GopeedMethods.createTaskBatch, request);
Future<void> patchTask(String id, PatchTask request) async {
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show TargetPlatform, defaultTargetPlatform;
import 'package:flutter/material.dart' show Colors, Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -48,10 +49,10 @@ class _BatchActionAvailability {
_BatchActionAvailability _batchActionAvailability(Iterable<TaskRecord> tasks, TaskBatchSelectionController selection) {
var canPause = false;
var canResume = false;
for (final task in tasks) {
for (final task in tasks.expand((task) => [task, ...task.members])) {
if (!selection.contains(task.id)) continue;
canPause = canPause || task.status == TaskStatus.downloading;
canResume = canResume || task.status == TaskStatus.paused || task.status == TaskStatus.failed;
canPause = canPause || task.canPause;
canResume = canResume || task.canResume;
if (canPause && canResume) break;
}
return _BatchActionAvailability(canPause: canPause, canResume: canResume);
@@ -69,6 +70,7 @@ class _HomePageState extends ConsumerState<HomePage> {
_TaskFilter _activeFilter = _TaskFilter.downloading;
TaskRecord? _selectedTask;
bool _batchMode = false;
final Set<String> _expandedGroupIds = {};
final TaskBatchSelectionController _batchSelection = TaskBatchSelectionController();
@override
@@ -96,11 +98,16 @@ class _HomePageState extends ConsumerState<HomePage> {
final tasksState = tasksAsync.value;
final tasks = tasksState?.tasks ?? const <TaskRecord>[];
final filteredTasks = _filteredTasks(tasks);
final visibleTasks = _visibleTasks(filteredTasks);
final memberIds = {
for (final task in tasks)
for (final member in task.members) member.id,
};
final emptyStateMessage = _searchController.text.trim().isEmpty
? context.l10n.emptyTaskList
: context.l10n.noMatchingTasks;
final selectedTask = _latestSelectedTask(tasks);
final visibleTaskIds = filteredTasks.map((task) => task.id).toList(growable: false);
final selectedTask = _latestSelectedTask();
final visibleTaskIds = visibleTasks.map((task) => task.id).toList(growable: false);
final pendingUpdateTask = ref.watch(pendingUpdateTaskProvider);
final navigationItems = _taskNavigationItems(tasks);
final desktopBody = shad.Scaffold(
@@ -158,7 +165,7 @@ class _HomePageState extends ConsumerState<HomePage> {
listenable: _batchSelection,
builder: (context, _) => _TaskBatchRow(
selectedCount: _batchSelection.count,
totalCount: filteredTasks.length,
totalCount: visibleTasks.length,
allSelected: _batchSelection.areAllSelected(visibleTaskIds),
someSelected:
_batchSelection.isNotEmpty &&
@@ -179,16 +186,19 @@ class _HomePageState extends ConsumerState<HomePage> {
)
: ListView.separated(
padding: const EdgeInsets.only(bottom: 16),
itemCount: filteredTasks.length,
itemCount: visibleTasks.length,
separatorBuilder: (_, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final task = filteredTasks[index];
final task = visibleTasks[index];
return TaskBatchSelectionBuilder(
key: ValueKey(task.id),
controller: _batchSelection,
taskId: task.id,
visibleTaskIds: visibleTaskIds,
builder: (context, selectedInBatch, allSelected) => TaskCard(
task: task,
nested: memberIds.contains(task.id),
expanded: _expandedGroupIds.contains(task.id),
selected: _selectedTask?.id == task.id,
batchMode: _batchMode,
selectedInBatch: selectedInBatch,
@@ -242,6 +252,8 @@ class _HomePageState extends ConsumerState<HomePage> {
final mobileBody = tasksAsync.when(
loading: () => _MobileTasksView(
tasks: const [],
memberIds: const {},
expandedGroupIds: _expandedGroupIds,
batchMode: _batchMode,
batchSelection: _batchSelection,
loading: true,
@@ -262,7 +274,9 @@ class _HomePageState extends ConsumerState<HomePage> {
),
error: (error, _) => ColoredBox(color: palette.bg, child: const TaskEmptyState()),
data: (_) => _MobileTasksView(
tasks: filteredTasks,
tasks: visibleTasks,
memberIds: memberIds,
expandedGroupIds: _expandedGroupIds,
emptyStateMessage: emptyStateMessage,
onCreateTask: _searchController.text.trim().isEmpty ? _handleAddTask : null,
batchMode: _batchMode,
@@ -331,20 +345,20 @@ class _HomePageState extends ConsumerState<HomePage> {
_TaskFilter.completed => task.status == TaskStatus.completed,
_TaskFilter.failed => task.status == TaskStatus.failed,
};
final passesSearch =
query.isEmpty || task.name.toLowerCase().contains(query) || task.url.toLowerCase().contains(query);
final passesSearch = task.matchesSearch(query);
return passesFilter && passesSearch;
})
.toList(growable: false);
}
TaskRecord? _latestSelectedTask(List<TaskRecord> tasks) {
List<TaskRecord> _visibleTasks(List<TaskRecord> tasks) => [
for (final task in tasks) ...[task, if (_expandedGroupIds.contains(task.id)) ...task.members],
];
TaskRecord? _latestSelectedTask() {
final selectedTask = _selectedTask;
if (selectedTask == null) return null;
for (final task in tasks) {
if (task.id == selectedTask.id) return task;
}
return selectedTask;
return ref.read(tasksControllerProvider).value?.findTask(selectedTask.id);
}
List<ResponsiveNavigationItem<_TaskFilter>> _taskNavigationItems(List<TaskRecord> tasks) {
@@ -376,7 +390,13 @@ class _HomePageState extends ConsumerState<HomePage> {
if (_batchMode) {
_toggleBatchTask(task.id);
} else {
_showTaskDetails(task);
if (task.isGroup) {
setState(() {
if (!_expandedGroupIds.remove(task.id)) _expandedGroupIds.add(task.id);
});
} else {
_showTaskDetails(task);
}
}
}
@@ -406,8 +426,8 @@ class _HomePageState extends ConsumerState<HomePage> {
void _toggleBatchTask(String taskId) => _batchSelection.toggle(taskId);
void _toggleSelectAll() {
final visibleIds = _filteredTasks(
ref.read(tasksControllerProvider).value?.tasks ?? const [],
final visibleIds = _visibleTasks(
_filteredTasks(ref.read(tasksControllerProvider).value?.tasks ?? const []),
).map((task) => task.id).toList(growable: false);
if (_batchSelection.areAllSelected(visibleIds)) {
_batchSelection.clear();
@@ -430,22 +450,20 @@ class _HomePageState extends ConsumerState<HomePage> {
void _pauseSelectedTasks() {
if (_batchMode && _batchSelection.isEmpty) return;
final ids = _batchMode ? _selectedTaskIdsFor((task) => task.status == TaskStatus.downloading) : null;
final ids = _batchMode ? _selectedTaskIdsFor((task) => task.canPause) : null;
if (_batchMode && ids!.isEmpty) return;
unawaited(_runTaskAction(() => ref.read(tasksControllerProvider.notifier).pauseAll(ids)));
}
void _resumeSelectedTasks() {
if (_batchMode && _batchSelection.isEmpty) return;
final ids = _batchMode
? _selectedTaskIdsFor((task) => task.status == TaskStatus.paused || task.status == TaskStatus.failed)
: null;
final ids = _batchMode ? _selectedTaskIdsFor((task) => task.canResume) : null;
if (_batchMode && ids!.isEmpty) return;
unawaited(_runTaskAction(() => ref.read(tasksControllerProvider.notifier).resumeAll(ids)));
}
List<String> _selectedTaskIdsFor(bool Function(TaskRecord task) test) {
final tasks = ref.read(tasksControllerProvider).value?.tasks ?? const <TaskRecord>[];
final tasks = ref.read(tasksControllerProvider).value?.allTasks ?? const <TaskRecord>[];
return tasks.where((task) => _batchSelection.contains(task.id) && test(task)).map((task) => task.id).toList();
}
@@ -479,8 +497,8 @@ class _HomePageState extends ConsumerState<HomePage> {
}
List<String> _contextTaskIds(TaskRecord task) {
final visibleIds = _filteredTasks(
ref.read(tasksControllerProvider).value?.tasks ?? const [],
final visibleIds = _visibleTasks(
_filteredTasks(ref.read(tasksControllerProvider).value?.tasks ?? const []),
).map((item) => item.id).toSet();
return <String>{..._batchSelection.where(visibleIds.contains), task.id}.toList(growable: false);
}
@@ -502,9 +520,27 @@ class _HomePageState extends ConsumerState<HomePage> {
if (ids.isEmpty) return;
final runtime = ref.read(appRuntimeControllerProvider).value;
final config = runtime?.downloaderConfig;
final selected =
ref.read(tasksControllerProvider).value?.allTasks.where((task) => ids.contains(task.id)).toList() ??
<TaskRecord>[];
final groupedMemberIds = {
for (final task in selected)
for (final member in task.members) member.id,
};
ids = ids.where((id) => !groupedMemberIds.contains(id)).toList();
selected.removeWhere((task) => groupedMemberIds.contains(task.id));
final hasGroups = selected.any((task) => task.isGroup);
final keepFiles = await showTaskDeleteDialog(
context,
taskCount: ids.length,
memberCount: !hasGroups
? null
: selected.fold<int>(0, (total, task) => total + (task.isGroup ? task.members.length : 1)),
// A task always downloads at least one file; records whose resource
// has no file list (plain single-file downloads) still count as one.
fileCount: !hasGroups
? null
: selected.fold<int>(0, (total, task) => total + (task.files.isEmpty ? 1 : task.files.length)),
keepFiles: config?.extra.lastDeleteTaskKeep ?? false,
);
if (keepFiles == null || !mounted) return;
@@ -518,10 +554,11 @@ class _HomePageState extends ConsumerState<HomePage> {
await ref.read(appRuntimeControllerProvider.notifier).reloadConfig();
});
if (!mounted) return;
_batchSelection.removeAll(ids);
_batchSelection.removeAll({...ids, ...groupedMemberIds});
setState(() {
_batchMode = false;
if (_selectedTask != null && ids.contains(_selectedTask!.id)) {
_expandedGroupIds.removeAll(ids);
if (_selectedTask != null && (ids.contains(_selectedTask!.id) || groupedMemberIds.contains(_selectedTask!.id))) {
_selectedTask = null;
}
});
@@ -569,14 +606,8 @@ class _HomePageState extends ConsumerState<HomePage> {
Future<void> _updateTaskUrl(String id, String url, {Map<String, String> headers = const {}}) async {
try {
await ref.read(tasksControllerProvider.notifier).updateUrl(id, url, headers: headers);
final updatedTasks = ref.read(tasksControllerProvider).value?.tasks ?? const <TaskRecord>[];
for (final task in updatedTasks) {
if (task.id == id) {
if (mounted) {
setState(() => _selectedTask = task);
}
break;
}
if (mounted) {
setState(() => _selectedTask = ref.read(tasksControllerProvider).value?.findTask(id));
}
} catch (error) {
if (!mounted) rethrow;
@@ -586,6 +617,16 @@ class _HomePageState extends ConsumerState<HomePage> {
}
Future<void> _openTask(TaskRecord task) async {
final nativeDirectory =
!Util.isWeb() &&
switch (defaultTargetPlatform) {
TargetPlatform.windows || TargetPlatform.macOS || TargetPlatform.linux => true,
_ => false,
};
if (task.isGroup && !nativeDirectory) {
await browseTaskFiles(context, task, mobile: MediaQuery.sizeOf(context).width < Breakpoints.mobile);
return;
}
final opened = task.isFolder
? await FileExplorer.reveal(task.storagePath)
: await FileExplorer.open(task.storagePath);
@@ -674,6 +715,8 @@ class _TaskBatchRow extends StatelessWidget {
class _MobileTasksView extends StatelessWidget {
const _MobileTasksView({
required this.tasks,
required this.memberIds,
required this.expandedGroupIds,
required this.batchMode,
required this.batchSelection,
required this.onToggleSelectAll,
@@ -695,6 +738,8 @@ class _MobileTasksView extends StatelessWidget {
});
final List<TaskRecord> tasks;
final Set<String> memberIds;
final Set<String> expandedGroupIds;
final bool batchMode;
final TaskBatchSelectionController batchSelection;
final VoidCallback onToggleSelectAll;
@@ -760,11 +805,14 @@ class _MobileTasksView extends StatelessWidget {
itemBuilder: (context, index) {
final task = tasks[index];
return TaskBatchSelectionBuilder(
key: ValueKey(task.id),
controller: batchSelection,
taskId: task.id,
visibleTaskIds: visibleTaskIds,
builder: (context, selectedInBatch, allSelected) => TaskCard(
task: task,
nested: memberIds.contains(task.id),
expanded: expandedGroupIds.contains(task.id),
selected: false,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
@@ -0,0 +1,166 @@
import 'package:path/path.dart' as path;
import '../../../api/model/create_task_batch.dart';
import '../../../api/model/options.dart';
import '../../../api/model/request.dart';
import '../../../api/model/resource.dart';
import '../../../api/model/resolve_result.dart';
import '../../../api/model/resolve_task.dart';
import '../../../core/capabilities/gopeed_capability.dart';
/// IDs identify sources within one preview and are never sent as group IDs.
class PendingDownload {
PendingDownload({required this.id, required this.request, required this.options});
final String id;
final Request request;
final Options options;
ResolveResult? result;
String? resolveError;
String? createError;
final Set<int> selected = {};
bool created = false;
bool resolving = false;
bool get loading => result == null && resolveError == null;
bool get isGroup => result != null && result!.id.isEmpty && result!.res.name.trim().isNotEmpty && files.length > 1;
bool get isFtp => RegExp(r'^(ftps?|ftpes):', caseSensitive: false).hasMatch(request.url.trim());
bool get isBt {
if (result?.id.isEmpty == true) return false;
final url = request.url.trim().toLowerCase();
if (url.startsWith('http:') || url.startsWith('https:') || isFtp) return false;
return url.startsWith('magnet:') ||
url.startsWith('data:application/x-bittorrent;base64,') ||
url.split(RegExp(r'[?#]')).first.endsWith('.torrent');
}
String get name {
if (!isBt &&
(!isFtp || result?.res.name.trim().isEmpty == true) &&
result?.id.isNotEmpty == true &&
files.length == 1 &&
files.single.name.isNotEmpty) {
return files.single.name;
}
if (result?.res.name.trim().isNotEmpty == true) return result!.res.name;
return request.url;
}
List<FileInfo> get files => result?.res.files ?? const [];
bool get hasSelection => !created && result != null && selected.isNotEmpty;
Future<void> resolve(GopeedService service) async {
if (result != null || resolving) return;
resolving = true;
resolveError = null;
try {
result = await service.resolve(ResolveTask(req: request));
selected.addAll(List.generate(files.isEmpty ? 1 : files.length, (index) => index));
} catch (error) {
resolveError = error.toString();
} finally {
resolving = false;
}
}
List<CreateTaskBatchItem> creationItems() {
final indexes = selected.toList()..sort();
if (result!.id.isNotEmpty || files.isEmpty) {
final opts = Options.fromJson(options.toJson());
if (files.isNotEmpty) opts.selectFiles = indexes;
if (indexes.length > 1) _omitChecksum(opts);
return [
CreateTaskBatchItem(
rid: result!.id.isEmpty ? null : result!.id,
req: result!.id.isEmpty ? request : null,
opts: opts,
),
];
}
final group = isGroup;
final resourceName = result!.res.name;
final parentSegments = [
if (!group && options.path.isNotEmpty) options.path,
if (!group && resourceName.trim().isNotEmpty) resourceName,
];
final skipAutoCategorize = options.skipAutoCategorize || resourceName.isNotEmpty || files.length > 1;
final httpUrl = RegExp(r'^https?:', caseSensitive: false);
return indexes.map((index) {
final file = files[index];
final member = Options.fromJson(options.toJson());
member.name = files.length == 1 && options.name.isNotEmpty ? options.name : file.name;
final segments = [...parentSegments, if (file.path.isNotEmpty) file.path];
member.path = segments.isEmpty ? '' : path.joinAll(segments);
// The selected parent directory is persisted separately from members.
member.asDefaultPath = false;
member.skipAutoCategorize = skipAutoCategorize;
final req = file.req ?? request;
member.size = httpUrl.hasMatch(req.url) ? file.size : 0;
if (RegExp(r'^(ftps?|ftpes):', caseSensitive: false).hasMatch(req.url)) {
final extra = OptsExtraFtp.fromJson(Map<String, dynamic>.from(member.extra as Map? ?? const {}));
extra.connections = extra.connections.clamp(0, 32);
member.extra = extra.toJson();
}
if (indexes.length > 1) _omitChecksum(member);
return CreateTaskBatchItem(req: req, opts: member);
}).toList();
}
void _omitChecksum(Options opts) {
if (opts.extra is Map) opts.extra = Map<String, dynamic>.from(opts.extra! as Map)..remove('checksum');
}
}
/// A successful response completes the entire batch, including partial success.
class DownloadSubmission {
DownloadSubmission({required this.sources, required this.request});
final List<PendingDownload> sources;
final CreateTaskBatch request;
Future<void> submit(GopeedService service) async {
for (final source in sources) {
source.createError = null;
}
try {
await service.createTaskBatch(request);
for (final source in sources) {
source.created = true;
}
} catch (error) {
for (final source in sources) {
source.createError = error.toString();
}
}
}
}
List<DownloadSubmission> planDownloads(List<PendingDownload> sources) {
final groups = <DownloadSubmission>[];
final independent = <PendingDownload>[];
for (final source in sources.where((source) => source.hasSelection)) {
if (source.isGroup) {
groups.add(
DownloadSubmission(
sources: [source],
request: CreateTaskBatch(
group: true,
groupName: source.options.name.isNotEmpty ? source.options.name : source.result!.res.name.trim(),
opts: source.options,
reqs: source.creationItems(),
),
),
);
} else {
independent.add(source);
}
}
return [
...groups,
if (independent.isNotEmpty)
DownloadSubmission(
sources: independent,
request: CreateTaskBatch(reqs: [for (final source in independent) ...source.creationItems()]),
),
];
}
@@ -23,6 +23,10 @@ class TasksState {
final int uploadSpeedBytesPerSecond;
final Object? lastError;
Iterable<TaskRecord> get allTasks => tasks.expand((task) => [task, ...task.members]);
TaskRecord? findTask(String id) => allTasks.where((task) => task.id == id).firstOrNull;
TasksState copyWith({
List<TaskRecord>? tasks,
int? downloadSpeedBytesPerSecond,
@@ -122,13 +126,14 @@ class TasksController extends AsyncNotifier<TasksState> {
Future<TasksState> _fetch() async {
final tasks = await ref.read(gopeedServiceProvider).getTasks(api_model.Status.values);
final transferSpeeds = aggregateTaskTransferSpeeds(tasks);
tasks.sort((a, b) {
if (a.status == api_model.Status.running && b.status != api_model.Status.running) return -1;
if (a.status != api_model.Status.running && b.status == api_model.Status.running) return 1;
return b.updatedAt.compareTo(a.updatedAt);
});
return TasksState(
tasks: tasks.map(TaskRecord.fromApi).toList(growable: false),
tasks: taskRecordsFromApi(tasks)
..sort((a, b) {
final aRunning = a.status == TaskStatus.downloading && !a.waiting;
final bRunning = b.status == TaskStatus.downloading && !b.waiting;
if (aRunning != bRunning) return aRunning ? -1 : 1;
return (b.updatedAt ?? b.createdAt!).compareTo(a.updatedAt ?? a.createdAt!);
}),
downloadSpeedBytesPerSecond: transferSpeeds.downloadBytesPerSecond,
uploadSpeedBytesPerSecond: transferSpeeds.uploadBytesPerSecond,
);
@@ -42,6 +42,10 @@ class TaskFileNode {
required this.sizeBytes,
this.downloadedBytes,
this.resourceIndex,
this.status,
this.taskId,
this.accessPath,
this.localPath,
});
final int? resourceIndex;
@@ -49,6 +53,10 @@ class TaskFileNode {
final String name;
final int sizeBytes;
final int? downloadedBytes;
final TaskStatus? status;
final String? taskId;
final String? accessPath;
final String? localPath;
double? get progress {
final downloaded = downloadedBytes;
@@ -68,6 +76,8 @@ class TaskRecord {
required this.files,
required this.uploading,
this.isFolder = false,
this.isGroup = false,
this.members = const [],
this.protocol,
this.requestHeaders = const {},
this.total,
@@ -84,6 +94,8 @@ class TaskRecord {
this.totalBytes,
this.speedBytes,
this.uploadedBytes,
this.uploadSpeedBytes,
this.updatedAt,
this.remainingSeconds,
this.waiting = false,
}) : assert(!uploading || uploadSpeed != null);
@@ -108,16 +120,31 @@ class TaskRecord {
final int? totalBytes;
final int? speedBytes;
final int? uploadedBytes;
final int? uploadSpeedBytes;
final DateTime? updatedAt;
final int? remainingSeconds;
final bool waiting;
final List<TaskFileNode> files;
final bool uploading;
final bool isFolder;
final bool isGroup;
final List<TaskRecord> members;
final api_task.Protocol? protocol;
final Map<String, String> requestHeaders;
bool get canUpdateUrl =>
protocol == api_task.Protocol.http && (status == TaskStatus.paused || status == TaskStatus.failed);
!isGroup && protocol == api_task.Protocol.http && (status == TaskStatus.paused || status == TaskStatus.failed);
bool get canPause => isGroup ? members.any((task) => task.canPause) : status == TaskStatus.downloading;
bool get canResume =>
isGroup ? members.any((task) => task.canResume) : status == TaskStatus.paused || status == TaskStatus.failed;
bool matchesSearch(String query) =>
query.isEmpty ||
name.toLowerCase().contains(query) ||
url.toLowerCase().contains(query) ||
files.any((file) => '${file.path}/${file.name}'.toLowerCase().contains(query)) ||
members.any((member) => member.matchesSearch(query));
bool get isIndeterminate => status == TaskStatus.downloading && !waiting && total == null;
@@ -147,6 +174,7 @@ class TaskRecord {
remainingSeconds: _remainingSeconds(task, totalBytes, downloadedBytes),
downloadDuration: task.progress.used > 0 ? Duration(microseconds: (task.progress.used + 999) ~/ 1000) : null,
createdAt: task.createdAt,
updatedAt: task.updatedAt,
doneAt: task.doneAt,
waiting: task.status == api_task.Status.wait,
progress: progress,
@@ -156,6 +184,7 @@ class TaskRecord {
totalBytes: totalBytes > 0 ? totalBytes : null,
speedBytes: task.progress.speed,
uploadedBytes: task.progress.uploaded,
uploadSpeedBytes: task.progress.uploadSpeed,
files: _fileNodes(task.meta.res?.files, task.meta.opts.selectFiles),
uploading: task.uploading,
isFolder: task.meta.res?.name.isNotEmpty ?? false,
@@ -165,6 +194,186 @@ class TaskRecord {
}
}
List<TaskRecord> taskRecordsFromApi(List<api_task.Task> tasks) {
final members = <String, List<TaskRecord>>{};
final metadata = <String, api_task.TaskGroup>{};
final records = <String, TaskRecord>{};
for (final task in tasks) {
final record = TaskRecord.fromApi(task);
records[task.id] = record;
if (task.parentId.isNotEmpty) {
members.putIfAbsent(task.parentId, () => []).add(record);
if (task.group != null) metadata[task.parentId] = task.group!;
}
}
final emitted = <String>{};
return [
for (final task in tasks)
if (task.parentId.isEmpty || !metadata.containsKey(task.parentId))
records[task.id]!
else if (emitted.add(task.parentId))
_groupRecord(metadata[task.parentId]!, members[task.parentId]!),
];
}
TaskRecord _groupRecord(api_task.TaskGroup group, List<TaskRecord> members) {
final running = members.any((task) => task.status == TaskStatus.downloading && !task.waiting);
final failed = members.any((task) => task.status == TaskStatus.failed);
final paused = members.isEmpty || members.any((task) => task.status == TaskStatus.paused);
final completed = members.isNotEmpty && members.every((task) => task.status == TaskStatus.completed);
final status = running
? TaskStatus.downloading
: failed
? TaskStatus.failed
: paused
? TaskStatus.paused
: completed
? TaskStatus.completed
: TaskStatus.downloading;
final downloaded = members.fold(0, (total, task) => total + (task.downloadedBytes ?? 0));
final total = members.every((task) => task.totalBytes != null || task.status == TaskStatus.completed)
? members.fold(0, (total, task) => total + (task.totalBytes ?? 0))
: null;
final speed = members.fold(
0,
(total, task) => total + (task.status == TaskStatus.downloading && !task.waiting ? task.speedBytes ?? 0 : 0),
);
// The backend freezes a finished single task's speed at bytes over active
// time (markTaskDone); a finished group reports the same ratio across its
// members instead of collapsing to "0 B/s" when the last member completes.
final reportedSpeed = speed > 0 ? speed : (completed ? _averageSpeed(members) ?? 0 : 0);
final uploadSpeed = members.fold(0, (total, task) => total + (task.uploading ? task.uploadSpeedBytes ?? 0 : 0));
final uploading = members.any((task) => task.uploading);
final uploaded = members.fold(0, (total, task) => total + (task.uploadedBytes ?? 0));
final pathContext = path.Context(
style: group.path.contains('\\') || members.any((task) => task.storagePath.contains('\\'))
? path.Style.windows
: path.Style.posix,
);
final root = group.path;
final files = <TaskFileNode>[];
for (final member in members) {
final sourceFiles = member.isFolder
? member.files
: [TaskFileNode(path: '', name: member.name, sizeBytes: member.totalBytes ?? 0)];
// Folder members do not carry per-file progress from the backend; spread
// the member's overall ratio over its files so the tree keeps moving
// instead of showing flat zeros.
final folderTotal = member.isFolder ? member.files.fold<int>(0, (total, file) => total + file.sizeBytes) : 0;
final folderRatio = folderTotal > 0 && member.downloadedBytes != null
? (member.downloadedBytes! / folderTotal).clamp(0.0, 1.0)
: null;
for (final file in sourceFiles) {
final fullPath = member.isFolder
? pathContext.join(member.storagePath, file.path, file.name)
: member.storagePath;
final relative = pathContext.relative(fullPath, from: root).replaceAll('\\', '/');
final String nodePath;
final String nodeName;
if (pathContext.isAbsolute(relative) || relative == '..' || relative.startsWith('../')) {
// Keep unexpected stored paths within the displayed tree.
if (member.isFolder) {
nodePath = path.posix.join(pathContext.basename(member.storagePath), file.path.replaceAll('\\', '/'));
nodeName = file.name;
} else {
nodePath = '';
nodeName = member.name;
}
} else {
nodePath = path.posix.dirname(relative) == '.' ? '' : path.posix.dirname(relative);
nodeName = path.posix.basename(relative);
}
files.add(
TaskFileNode(
resourceIndex: files.length,
path: nodePath,
name: nodeName,
sizeBytes: file.sizeBytes,
downloadedBytes: member.isFolder
? folderRatio != null && file.sizeBytes > 0
? (file.sizeBytes * folderRatio).round()
: null
: member.downloadedBytes,
status: member.status,
taskId: member.id,
localPath: fullPath,
accessPath: member.isFolder
? path.posix.join(file.path.replaceAll('\\', '/'), file.name)
: pathContext.relative(fullPath, from: pathContext.dirname(member.storagePath)).replaceAll('\\', '/'),
),
);
}
}
var updatedAt = group.createdAt;
DateTime? doneAt;
for (final member in members) {
final update = member.updatedAt;
if (update != null && update.isAfter(updatedAt)) updatedAt = update;
if (completed) {
final done = member.doneAt;
if (done != null && (doneAt == null || done.isAfter(doneAt))) doneAt = done;
}
}
// The folder card answers "how long did this download take end to end", so
// a completed group reports its wall-clock span; single tasks keep their
// active-time semantics.
final createdAt = group.createdAt;
final downloadDuration = doneAt != null && doneAt.isAfter(createdAt) ? doneAt.difference(createdAt) : null;
return TaskRecord(
id: group.id,
name: group.name,
status: status,
downloaded: _formatBytes(downloaded),
total: total == null ? null : _formatBytes(total),
downloadedBytes: downloaded,
totalBytes: total,
speed: TransferRateFormatter.format(reportedSpeed).text,
speedBytes: reportedSpeed,
uploadSpeed: uploading ? TransferRateFormatter.format(uploadSpeed).text : null,
uploadedBytes: uploaded,
uploadSpeedBytes: uploadSpeed,
uploading: uploading,
progress: completed
? 1
: total != null && total > 0
? (downloaded / total).clamp(0.0, 1.0)
: null,
remainingSeconds: total != null && speed > 0 && total > downloaded ? ((total - downloaded) / speed).ceil() : null,
waiting: status == TaskStatus.downloading && !running,
downloadDuration: downloadDuration,
url: '',
storagePath: root,
files: files,
isFolder: true,
isGroup: true,
members: members,
createdAt: group.createdAt,
updatedAt: updatedAt,
doneAt: doneAt,
);
}
// The combined average rate of a set of members: downloaded bytes over active
// download time, the same ratio the backend freezes into a finished single
// task's progress. Members without a measurable duration are skipped because
// they would inflate the rate.
int? _averageSpeed(List<TaskRecord> members) {
var downloaded = 0;
var microseconds = 0;
for (final member in members) {
final duration = member.downloadDuration;
if (duration == null || duration.inMicroseconds <= 0) {
continue;
}
downloaded += member.downloadedBytes ?? 0;
microseconds += duration.inMicroseconds;
}
if (microseconds <= 0) {
return null;
}
return (downloaded * Duration.microsecondsPerSecond / microseconds).round();
}
Map<String, String> _requestHeaders(Object? extra) {
if (extra is! Map) return const {};
final header = extra['header'];
@@ -19,8 +19,6 @@ import '../../../../api/model/create_task_batch.dart';
import '../../../../api/model/downloader_config.dart';
import '../../../../api/model/options.dart' as api_options;
import '../../../../api/model/request.dart';
import '../../../../api/model/resolve_result.dart';
import '../../../../api/model/resolve_task.dart';
import '../../../../core/capabilities/app_capabilities.dart';
import '../../../../core/utils/breakpoints.dart';
import '../../../../core/utils/text_wrap.dart';
@@ -39,10 +37,11 @@ import '../../../../shared/widgets/app_toast.dart';
import '../../../../l10n/l10n.dart';
import '../../../../util/util.dart';
import '../../application/pending_create_task.dart';
import '../../application/pending_download.dart';
import '../../application/task_list_navigation.dart';
import '../widgets/history_search_field.dart';
import '../widgets/create_task_action_buttons.dart';
import '../widgets/resolve_file_tree.dart';
import '../widgets/resolve_download_dialog.dart';
class CreateTaskWindowPage extends ConsumerStatefulWidget {
const CreateTaskWindowPage({super.key, this.windowController, this.initialTask});
@@ -92,6 +91,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
bool _directDownload = false;
int _protocolTab = 0;
bool _creating = false;
final Map<String, int> _completedSubmissionCounts = {};
int _createSession = 0;
bool _draggingTorrent = false;
bool _asDefaultPath = false;
String _configuredDownloadDirectory = '';
@@ -248,6 +249,8 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
if (task == null || !mounted) return;
final req = task.req;
final opts = task.opts ?? api_options.Options();
_createSession++;
_completedSubmissionCounts.clear();
setState(() {
_initialSize = 0;
_checksumAlgorithm = 'md5';
@@ -871,7 +874,20 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
await windowManager.close();
}
Future<void> _finishCreation() async {
Future<void> _finishCreation(int session) async {
if (!mounted || session != _createSession) return;
if (_asDefaultPath && _directoryManuallySet && _directoryController.text.trim().isNotEmpty) {
try {
final service = ref.read(gopeedServiceProvider);
final config = await service.getConfig();
if (!mounted || session != _createSession) return;
config.downloadDir = _directoryController.text.trim();
await service.putConfig(config);
} catch (error) {
if (mounted && session == _createSession) _showToast(error.toString());
}
}
if (!mounted || session != _createSession) return;
if (widget.windowController != null) {
// Navigation is best-effort after a successful submission. A navigation
// failure must not leave a completed form available for resubmission.
@@ -883,6 +899,7 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
} else {
ref.read(taskListNavigationProvider.notifier).showDownloading();
}
if (!mounted || session != _createSession) return;
await _closeWindow();
}
@@ -989,53 +1006,74 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_showToast(context.l10n.downloadLinkValid);
return;
}
if (!_validateAdvancedOptions()) {
final occurrences = <String, int>{};
final pendingUrls = <String>[];
for (final url in urls) {
final occurrence = (occurrences[url] ?? 0) + 1;
occurrences[url] = occurrence;
if (occurrence > (_completedSubmissionCounts[url] ?? 0)) {
pendingUrls.add(url);
}
}
if (pendingUrls.isNotEmpty && !_validateAdvancedOptions()) {
return;
}
final session = _createSession;
setState(() {
_creating = true;
_draggingTorrent = false;
});
try {
await _saveCreateHistory(urls);
final isDirect = _directDownload || urls.length > 1;
if (isDirect) {
await Future.wait(
urls.map((url) {
return ref
.read(gopeedServiceProvider)
.createTask(
CreateTask(
req: _buildRequest(url, protocolUrl: _protocolUrlFor(url)),
opts: _buildOptions(
url: _protocolUrlFor(url),
name: urls.length > 1 ? '' : _renameController.text.trim(),
),
),
);
}),
unawaited(
_saveCreateHistory(urls).catchError((Object error) {
if (mounted && session == _createSession) _showToast(error.toString());
}),
);
if (pendingUrls.isEmpty) {
await _finishCreation(session);
return;
}
final sources = [
for (final entry in pendingUrls.indexed)
PendingDownload(
id: 'source-${entry.$1}',
request: _buildRequest(entry.$2, protocolUrl: _protocolUrlFor(entry.$2)),
options: _buildOptions(
url: _protocolUrlFor(entry.$2),
name: urls.length == 1 ? _renameController.text.trim() : '',
omitChecksum: urls.length > 1,
),
),
];
final service = ref.read(gopeedServiceProvider);
if (_directDownload) {
await service.createTaskBatch(
CreateTaskBatch(
reqs: [for (final source in sources) CreateTaskBatchItem(req: source.request, opts: source.options)],
),
);
await _finishCreation();
await _finishCreation(session);
return;
}
final request = _buildRequest(urls.first, protocolUrl: _protocolUrlFor(urls.first));
final result = await ref.read(gopeedServiceProvider).resolve(ResolveTask(req: request));
if (!mounted) return;
final created = await _showResolveDialog(request, result);
if (!created) {
if (mounted) {
setState(() => _creating = false);
}
return;
}
await _finishCreation();
final created = await showResolveDownloadsDialog(
context,
sources: sources,
service: service,
onBatchCreated: (sources) {
if (!mounted || session != _createSession) return;
for (final source in sources) {
_completedSubmissionCounts.update(source.request.url, (count) => count + 1, ifAbsent: () => 1);
}
},
);
if (!mounted || session != _createSession || !created) return;
await _finishCreation(session);
} catch (error) {
if (mounted) {
_showToast(error.toString());
setState(() => _creating = false);
}
if (mounted && session == _createSession) _showToast(error.toString());
} finally {
if (mounted) setState(() => _creating = false);
}
}
@@ -1298,190 +1336,6 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
_applyCategoryHighlight();
}
Future<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async {
final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList())
..sort();
final isMultiFile = selected.length > 1;
if (result.id.isNotEmpty) {
await ref
.read(gopeedServiceProvider)
.createTask(
CreateTask(
rid: result.id,
opts: _buildOptions(url: request.url, selectFiles: selected, omitChecksum: isMultiFile),
),
);
return;
}
if (result.res.files.isNotEmpty) {
// The extension returned a file list, so the resource keeps a directory
// structure. The split tasks must not be classified into a category, the
// structure of the resolved resource is kept. The decision is based on
// the resolved resource, not on the number of selected files.
final keepStructure = _directoryManuallySet || result.res.name.isNotEmpty || result.res.files.length > 1;
final reqs = selected.map((index) {
final file = result.res.files[index];
final fileRequest = file.req ?? request;
return CreateTaskBatchItem(
req: fileRequest,
opts: api_options.Options(
size: _parseProtocol(fileRequest.url) == _TaskProtocol.http ? file.size : 0,
name: file.name,
path: path.join(_directoryController.text.trim(), result.res.name, file.path),
skipAutoCategorize: keepStructure,
extra: _buildOptions(url: fileRequest.url, omitChecksum: isMultiFile).extra,
),
);
}).toList();
await ref
.read(gopeedServiceProvider)
.createTaskBatch(
CreateTaskBatch(
reqs: reqs,
opts: _buildOptions(url: request.url, omitChecksum: isMultiFile, skipAutoCategorize: keepStructure),
),
);
return;
}
await ref
.read(gopeedServiceProvider)
.createTask(
CreateTask(
req: request,
opts: _buildOptions(url: request.url),
),
);
}
Future<bool> _showResolveDialog(Request request, ResolveResult result) async {
final initialSelection = List<int>.generate(result.res.files.length, (index) => index);
var selectedIndexes = List<int>.of(initialSelection);
var submitting = false;
String? errorText;
// The directory the task will be saved to. A directory the user named
// wins over the category suggestion of the resolve response, the backend
// keeps it and does not classify the task.
final effectiveDownloadDir = _directoryManuallySet
? _directoryController.text.trim()
: (result.suggestedDownloadDir.isNotEmpty ? result.suggestedDownloadDir : _directoryController.text.trim());
final dialog = const DialogOverlayHandler().show<bool>(
context: context,
alignment: Alignment.center,
barrierDismissable: !submitting,
builder: (dialogContext) {
final palette = AppPalette.of(dialogContext);
final screenSize = MediaQuery.sizeOf(dialogContext);
final dialogWidth = screenSize.width < 760 ? screenSize.width - 32 : 720.0;
final treeHeight = screenSize.height < 700 ? screenSize.height * 0.55 : 440.0;
return StatefulBuilder(
builder: (dialogContext, setDialogState) {
Future<void> submit() async {
if (result.res.files.isNotEmpty && selectedIndexes.isEmpty) {
setDialogState(() => errorText = dialogContext.l10n.selectAtLeastOneFile);
return;
}
setDialogState(() {
submitting = true;
errorText = null;
});
try {
await _submitResolved(request, result, selectedIndexes);
if (dialogContext.mounted) {
await closeOverlay(dialogContext, true);
}
} catch (error) {
if (!dialogContext.mounted) return;
setDialogState(() {
submitting = false;
errorText = error.toString();
});
}
}
return AlertDialog(
padding: const EdgeInsets.all(18),
title: Text(
result.res.name.trim().isEmpty ? dialogContext.l10n.resolvedFiles : result.res.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
content: SizedBox(
width: dialogWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
result.res.files.isEmpty
? dialogContext.l10n.resourceHasNoFiles
: dialogContext.l10n.resolvedFileCount(result.res.files.length),
style: TextStyle(color: palette.textSecondary, fontSize: 12),
),
if (effectiveDownloadDir.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
dialogContext.l10n.resolvedCategoryDirectory(effectiveDownloadDir),
key: const ValueKey('resolve-category-directory'),
style: TextStyle(color: palette.textSecondary, fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 12),
SizedBox(
height: treeHeight.clamp(260.0, 460.0),
child: result.res.files.isEmpty
? Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: palette.cardBg,
border: Border.all(color: palette.border),
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
),
child: Text(
dialogContext.l10n.noFiles,
style: TextStyle(color: palette.textMuted, fontSize: 13),
),
)
: ResolveFileTree(
files: result.res.files,
initialSelection: initialSelection,
onSelectionChanged: (values) => selectedIndexes = values,
),
),
if (errorText != null) ...[
const SizedBox(height: 10),
Text(errorText!, style: TextStyle(color: palette.error, fontSize: 12)),
],
],
),
),
actions: [
flutter.Flexible(
child: CreateTaskActionButtons(
submitting: submitting,
onCancel: () => closeOverlay(dialogContext, false),
onSubmit: submit,
cancelLabel: dialogContext.l10n.cancel,
submitLabel: dialogContext.l10n.createAction,
cancelButtonKey: const ValueKey('resolve-cancel-button'),
submitButtonKey: const ValueKey('resolve-create-button'),
),
),
],
);
},
);
},
);
final created = await dialog.future;
return created == true;
}
Future<void> _showCreateHistory() async {
List<String> histories;
try {
@@ -21,13 +21,8 @@ class TaskDetailsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasksAsync = ref.watch(tasksControllerProvider);
TaskRecord? task = initialTask;
for (final item in tasksAsync.value?.tasks ?? const <TaskRecord>[]) {
if (item.id == taskId) {
task = item;
break;
}
}
final state = tasksAsync.value;
final task = state == null ? initialTask : state.findTask(taskId);
final palette = AppPalette.of(context);
return AppDetailPage(
@@ -43,12 +38,12 @@ class TaskDetailsPage extends ConsumerWidget {
key: ValueKey(task.id),
task: task,
mobile: true,
onOpenStorage: () => FileExplorer.reveal(task!.storagePath),
onOpenStorage: () => FileExplorer.reveal(task.storagePath),
onUpdateUrl: (url) async {
try {
await ref
.read(tasksControllerProvider.notifier)
.updateUrl(task!.id, url, headers: task.requestHeaders);
.updateUrl(task.id, url, headers: task.requestHeaders);
} catch (error) {
if (context.mounted) {
showAppToast(context, error.toString(), type: AppToastType.error);
@@ -20,13 +20,8 @@ class TaskFilesPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tasksAsync = ref.watch(tasksControllerProvider);
TaskRecord? task = initialTask;
for (final item in tasksAsync.value?.tasks ?? const <TaskRecord>[]) {
if (item.id == taskId) {
task = item;
break;
}
}
final state = tasksAsync.value;
final task = state == null ? initialTask : state.findTask(taskId);
final palette = AppPalette.of(context);
return shad.Scaffold(
@@ -18,7 +18,7 @@ class CreateTaskActionButtons extends StatelessWidget {
final bool submitting;
final VoidCallback onCancel;
final VoidCallback onSubmit;
final VoidCallback? onSubmit;
final String cancelLabel;
final String submitLabel;
final Key? cancelButtonKey;
@@ -0,0 +1,177 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter/widgets.dart' as flutter show Flexible;
import '../../../../core/capabilities/gopeed_capability.dart';
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import '../../application/pending_download.dart';
import 'create_task_action_buttons.dart';
import 'resolve_file_tree.dart';
Future<bool> showResolveDownloadsDialog(
BuildContext context, {
required List<PendingDownload> sources,
required GopeedService service,
ValueChanged<List<PendingDownload>>? onBatchCreated,
}) async {
final dialog = const DialogOverlayHandler().show<bool>(
context: context,
alignment: Alignment.center,
barrierDismissable: false,
builder: (_) => _ResolveDownloadsDialog(sources: sources, service: service, onBatchCreated: onBatchCreated),
);
return await dialog.future == true;
}
class _ResolveDownloadsDialog extends StatefulWidget {
const _ResolveDownloadsDialog({required this.sources, required this.service, required this.onBatchCreated});
final List<PendingDownload> sources;
final GopeedService service;
final ValueChanged<List<PendingDownload>>? onBatchCreated;
@override
State<_ResolveDownloadsDialog> createState() => _ResolveDownloadsDialogState();
}
class _ResolveDownloadsDialogState extends State<_ResolveDownloadsDialog> {
bool _cancelled = false;
bool _submitting = false;
String? _errorText;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _resolveAll());
}
@override
void dispose() {
_cancelled = true;
super.dispose();
}
Future<void> _resolveAll() async {
for (final source in widget.sources) {
if (_cancelled || !mounted) return;
if (source.result != null || source.resolveError != null) continue;
final resolving = source.resolve(widget.service);
setState(() {});
await resolving;
if (_cancelled || !mounted) return;
setState(() {});
}
}
void _cancel() {
_cancelled = true;
closeOverlay(context, false);
}
Future<void> _submit() async {
if (_submitting || widget.sources.any((source) => source.loading)) return;
final batches = planDownloads(widget.sources);
if (batches.isEmpty) return;
setState(() {
_submitting = true;
_errorText = null;
for (final source in widget.sources) {
source.createError = null;
}
});
for (final batch in batches) {
await batch.submit(widget.service);
if (!mounted) return;
if (batch.sources.every((source) => source.created)) {
widget.onBatchCreated?.call(batch.sources);
}
}
final errors = widget.sources.map((source) => source.createError).whereType<String>().toSet();
if (errors.isEmpty) {
await closeOverlay(context, true);
} else {
setState(() {
_submitting = false;
_errorText = errors.join('\n');
});
}
}
@override
Widget build(BuildContext context) {
final screen = MediaQuery.sizeOf(context);
final canCreate =
!_submitting &&
!widget.sources.any((source) => source.loading) &&
widget.sources.any((source) => source.hasSelection);
return AlertDialog(
padding: const EdgeInsets.all(18),
title: Row(
children: [
Expanded(child: Text(context.l10n.resolvedFiles)),
AppTooltip(
message: context.l10n.close,
child: GhostButton(
key: const ValueKey('resolve-close-button'),
onPressed: _submitting ? null : _cancel,
child: const Icon(Icons.close, size: 18),
),
),
],
),
content: SizedBox(
width: screen.width < 760 ? screen.width - 68 : 720,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: (screen.height * 0.48).clamp(120.0, 440.0),
child: IgnorePointer(
ignoring: _submitting,
child: ResolveFileTree(
sources: List.of(widget.sources),
onSelectionChanged: (selection) {
final selectedBySource = <String, List<int>>{};
for (final (sourceId, index) in selection) {
selectedBySource.putIfAbsent(sourceId, () => []).add(index);
}
setState(() {
for (final source in widget.sources) {
if (source.result == null || source.created) continue;
source.selected
..clear()
..addAll(selectedBySource[source.id] ?? const []);
}
});
},
),
),
),
if (_errorText != null)
ConstrainedBox(
constraints: BoxConstraints(maxHeight: screen.height * 0.15),
child: SingleChildScrollView(
child: Text(_errorText!, style: TextStyle(color: AppPalette.of(context).error, fontSize: 12)),
),
),
],
),
),
actions: [
flutter.Flexible(
child: CreateTaskActionButtons(
submitting: _submitting,
onCancel: _cancel,
onSubmit: canCreate ? _submit : null,
cancelLabel: context.l10n.cancel,
submitLabel: context.l10n.createAction,
cancelButtonKey: const ValueKey('resolve-cancel-button'),
submitButtonKey: const ValueKey('resolve-create-button'),
),
),
],
);
}
}
@@ -2,99 +2,150 @@ import 'package:flutter/widgets.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Column, Expanded, Row;
import '../../../../api/model/resource.dart';
import '../../../../core/icons/gopeed_icons.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import '../../application/pending_download.dart';
import '../../../../l10n/l10n.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/widgets/file_tree_view.dart';
import '../../../../util/util.dart';
class ResolveFileTree extends StatefulWidget {
const ResolveFileTree({
super.key,
required this.files,
required this.initialSelection,
required this.onSelectionChanged,
});
typedef PendingFileKey = (String, int);
final List<FileInfo> files;
final List<int> initialSelection;
final ValueChanged<List<int>> onSelectionChanged;
class ResolveFileTree extends StatefulWidget {
const ResolveFileTree({super.key, required this.sources, required this.onSelectionChanged});
final List<PendingDownload> sources;
final ValueChanged<Set<PendingFileKey>> onSelectionChanged;
@override
State<ResolveFileTree> createState() => _ResolveFileTreeState();
}
class _ResolveFileTreeState extends State<ResolveFileTree> {
late List<FileTreeItem<int>> _treeItems;
late List<int> _allFileIndexes;
late final Set<int> _selectedIndexes;
late List<FileTreeItem<PendingFileKey>> _treeItems;
late Map<PendingFileKey, FileInfo> _files;
late Map<String, String> _labels;
late Map<String, PendingDownload> _sourcesById;
late Map<String, PendingDownload> _sourcesByRootKey;
final Set<PendingFileKey> _selected = {};
final Set<_FileTypeFilter> _activeTypeFilters = {};
@override
void initState() {
super.initState();
_selectedIndexes = widget.initialSelection.where(_isValidIndex).toSet();
_rebuildTreeItems();
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySelection());
}
@override
void didUpdateWidget(covariant ResolveFileTree oldWidget) {
super.didUpdateWidget(oldWidget);
if (identical(widget.files, oldWidget.files)) return;
_selectedIndexes.removeWhere((index) => !_isValidIndex(index));
_activeTypeFilters.clear();
final previousFiles = _files;
_rebuildTreeItems();
WidgetsBinding.instance.addPostFrameCallback((_) => _notifySelection());
if (previousFiles.length != _files.length || !previousFiles.keys.every(_files.containsKey)) {
_activeTypeFilters.clear();
}
}
void _rebuildTreeItems() {
_files = {};
_labels = {};
_treeItems = [];
_sourcesById = {for (final source in widget.sources) source.id: source};
_sourcesByRootKey = {for (final source in widget.sources) 'folder:${source.id}': source};
_selected.clear();
for (final source in widget.sources) {
if (source.result == null) {
_labels[source.id] = source.name;
_treeItems.add(
FileTreeItem(
key: source.id,
path: source.id,
name: source.name,
size: 0,
data: (source.id, -1),
directoryOnly: true,
),
);
continue;
}
final files = source.files.isEmpty ? [FileInfo(name: source.name)] : source.files;
// A non-empty resource name is a real directory, even for one file.
final hasRoot = source.result!.res.name.trim().isNotEmpty;
if (hasRoot) _labels[source.id] = source.name;
for (final entry in files.indexed) {
final key = (source.id, entry.$1);
final file = entry.$2;
if (!source.created) {
_files[key] = file;
if (source.selected.contains(entry.$1)) _selected.add(key);
}
_treeItems.add(
FileTreeItem<PendingFileKey>(
key: '${source.id}:${entry.$1}',
path: hasRoot ? '${source.id}/${file.path}' : file.path,
namespace: hasRoot ? '' : source.id,
name: file.name,
size: file.size,
data: key,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final selectedCount = _selectedIndexes.length;
final allSelected = _allFileIndexes.isNotEmpty && selectedCount == _allFileIndexes.length;
final partiallySelected = selectedCount > 0 && !allSelected;
final selectedSize = _selectedIndexes.fold<int>(0, (sum, index) => sum + widget.files[index].size);
final allSelected = _files.isNotEmpty && _selected.length == _files.length;
final partial = _selected.isNotEmpty && !allSelected;
return Column(
children: [
Expanded(
child: FileTreeView<int>(
child: FileTreeView<PendingFileKey>(
items: _treeItems,
initialExpandedDepth: 1,
directoryLabels: _labels,
initialExpandedDepth: 2,
keyPrefix: 'resolve-tree',
iconWidgetBuilder: _sourceIcon,
rowHeight: _resolveTreeRowHeight,
contentTextStyle: _resolveFileNameStyle,
headerLeading: ExcludeFocus(
child: Checkbox(
state: allSelected
? CheckboxState.checked
: (partiallySelected ? CheckboxState.indeterminate : CheckboxState.unchecked),
onChanged: _allFileIndexes.isEmpty ? null : (_) => _setFileSelection(_allFileIndexes, !allSelected),
: partial
? CheckboxState.indeterminate
: CheckboxState.unchecked,
onChanged: _files.isEmpty ? null : (_) => _setSelection(_files.keys, !allSelected),
),
),
leadingBuilder: (context, node) {
final indexes = node.leafItems.map((item) => item.data).toList(growable: false);
final nodeSelectedCount = indexes.where(_selectedIndexes.contains).length;
final selected = indexes.isNotEmpty && nodeSelectedCount == indexes.length;
final partial = nodeSelectedCount > 0 && !selected;
final keys = node.leafItems.map((item) => item.data).where(_files.containsKey).toList();
final count = keys.where(_selected.contains).length;
return ExcludeFocus(
child: Checkbox(
state: selected
state: keys.isNotEmpty && count == keys.length
? CheckboxState.checked
: (partial ? CheckboxState.indeterminate : CheckboxState.unchecked),
onChanged: (_) => _setFileSelection(indexes, !selected),
: count > 0
? CheckboxState.indeterminate
: CheckboxState.unchecked,
onChanged: keys.isEmpty ? null : (_) => _setSelection(keys, count != keys.length),
),
);
},
onNodePressed: _toggleNodeSelection,
onNodePressed: (node) {
final keys = node.leafItems.map((item) => item.data).where(_files.containsKey).toList();
if (keys.isNotEmpty) _setSelection(keys, !keys.every(_selected.contains));
},
),
),
const SizedBox(height: 12),
_TreeFooter(
selectedCount: selectedCount,
totalCount: _allFileIndexes.length,
selectedSize: selectedSize,
unknownSize: selectedCount > 0 && selectedSize == 0,
selectedCount: _selected.length,
totalCount: _files.length,
selectedSize: _selected.fold(0, (sum, key) => sum + _files[key]!.size),
unknownSize: _selected.any((key) => _files[key]!.size <= 0),
activeTypeFilters: _activeTypeFilters,
onSelectVideo: () => _toggleTypeFilter(_FileTypeFilter.video),
onSelectAudio: () => _toggleTypeFilter(_FileTypeFilter.audio),
@@ -104,65 +155,63 @@ class _ResolveFileTreeState extends State<ResolveFileTree> {
);
}
bool _isValidIndex(int index) => index >= 0 && index < widget.files.length;
void _rebuildTreeItems() {
_allFileIndexes = List<int>.generate(widget.files.length, (index) => index, growable: false);
_treeItems = widget.files
.asMap()
.entries
.map(
(entry) => FileTreeItem<int>(
key: entry.key.toString(),
path: entry.value.path,
name: entry.value.name,
size: entry.value.size,
data: entry.key,
),
)
.toList(growable: false);
Widget _sourceIcon(BuildContext context, FileTreeNode<PendingFileKey> node) {
final source = node.item == null ? _sourcesByRootKey[node.key] : _sourcesById[node.item!.data.$1];
final palette = AppPalette.of(context);
if (source == null) {
return Icon(node.isFolder ? BootstrapIcons.folder2 : GopeedIcons.file, size: 18, color: palette.textSecondary);
}
final pending = source;
final message = pending.loading
? (pending.resolving ? context.l10n.resolvingResource : context.l10n.waitingToResolve)
: pending.resolveError ?? pending.request.url;
return AppTooltip(
focusable: true,
message: message,
child: Semantics(
label: message,
child: pending.loading
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator())
: Icon(
pending.resolveError != null
? Icons.error_outline
: node.isFolder && pending.isBt
? GopeedIcons.folderBt
: node.isFolder
? BootstrapIcons.folder2
: GopeedIcons.file,
size: 18,
color: pending.resolveError != null ? palette.error : palette.textSecondary,
),
),
);
}
void _setFileSelection(Iterable<int> indexes, bool selected) {
void _setSelection(Iterable<PendingFileKey> keys, bool selected) {
setState(() {
_activeTypeFilters.clear();
if (selected) {
_selectedIndexes.addAll(indexes);
_selected.addAll(keys);
} else {
_selectedIndexes.removeAll(indexes);
_selected.removeAll(keys);
}
});
_notifySelection();
}
void _toggleNodeSelection(FileTreeNode<int> node) {
final indexes = node.leafItems.map((item) => item.data).toList(growable: false);
final selected = indexes.isNotEmpty && indexes.every(_selectedIndexes.contains);
_setFileSelection(indexes, !selected);
widget.onSelectionChanged(Set.of(_selected));
}
void _toggleTypeFilter(_FileTypeFilter filter) {
setState(() {
if (!_activeTypeFilters.remove(filter)) {
_activeTypeFilters.add(filter);
}
final extensions = <String>{for (final activeFilter in _activeTypeFilters) ...activeFilter.extensions};
_selectedIndexes
if (!_activeTypeFilters.remove(filter)) _activeTypeFilters.add(filter);
final extensions = {for (final active in _activeTypeFilters) ...active.extensions};
_selected
..clear()
..addAll(
widget.files
.asMap()
.entries
_files.entries
.where((entry) => extensions.contains(_extensionOf(entry.value.name)))
.map((entry) => entry.key),
);
});
_notifySelection();
}
void _notifySelection() {
final values = _selectedIndexes.toList()..sort();
widget.onSelectionChanged(values);
widget.onSelectionChanged(Set.of(_selected));
}
}
@@ -1,6 +1,7 @@
import 'package:flutter/widgets.dart';
import '../../../../../core/utils/breakpoints.dart';
import '../../../../../shared/theme/app_design_tokens.dart';
import '../../../domain/task_record.dart';
import '../task_context_menu.dart';
import 'task_card_desktop.dart';
@@ -10,6 +11,8 @@ class TaskCard extends StatelessWidget {
const TaskCard({
super.key,
required this.task,
this.expanded = false,
this.nested = false,
required this.selected,
required this.batchMode,
required this.selectedInBatch,
@@ -25,6 +28,8 @@ class TaskCard extends StatelessWidget {
});
final TaskRecord task;
final bool expanded;
final bool nested;
final bool selected;
final bool batchMode;
final bool selectedInBatch;
@@ -42,40 +47,44 @@ class TaskCard extends StatelessWidget {
Widget build(BuildContext context) {
final isMobile = MediaQuery.sizeOf(context).width < Breakpoints.mobile;
if (isMobile) {
final card = TaskCardMobile(
task: task,
selected: selected,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
onPressed: onPressed,
onToggleBatch: onToggleBatch,
onPause: onPause,
onResume: onResume,
onDelete: onDelete,
onOpen: onOpen,
onReveal: onReveal,
listeningForUpdate: listeningForUpdate,
);
final actions = contextActions;
return actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card);
}
final card = TaskCardDesktop(
task: task,
selected: selected,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
onPressed: onPressed,
onToggleBatch: onToggleBatch,
onPause: onPause,
onResume: onResume,
onDelete: onDelete,
onOpen: onOpen,
onReveal: onReveal,
listeningForUpdate: listeningForUpdate,
);
final card = isMobile
? TaskCardMobile(
task: task,
expanded: expanded,
selected: selected,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
onPressed: onPressed,
onToggleBatch: onToggleBatch,
onPause: task.canPause ? onPause : null,
onResume: task.canResume ? onResume : null,
onDelete: onDelete,
onOpen: onOpen,
onReveal: onReveal,
listeningForUpdate: listeningForUpdate,
)
: TaskCardDesktop(
task: task,
expanded: expanded,
selected: selected,
batchMode: batchMode,
selectedInBatch: selectedInBatch,
onPressed: onPressed,
onToggleBatch: onToggleBatch,
onPause: task.canPause ? onPause : null,
onResume: task.canResume ? onResume : null,
onDelete: onDelete,
onOpen: onOpen,
onReveal: onReveal,
listeningForUpdate: listeningForUpdate,
);
final actions = contextActions;
return actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card);
return Padding(
padding: EdgeInsetsDirectional.only(start: nested ? AppDesignTokens.space24 : 0),
child: Semantics(
expanded: task.isGroup ? expanded : null,
child: actions == null ? card : TaskContextMenu(task: task, actions: actions, child: card),
),
);
}
}
@@ -14,6 +14,7 @@ class TaskCardDesktop extends StatefulWidget {
const TaskCardDesktop({
super.key,
required this.task,
this.expanded = false,
required this.selected,
required this.batchMode,
required this.selectedInBatch,
@@ -28,6 +29,7 @@ class TaskCardDesktop extends StatefulWidget {
});
final TaskRecord task;
final bool expanded;
final bool selected;
final bool batchMode;
final bool selectedInBatch;
@@ -64,7 +66,7 @@ class _TaskCardDesktopState extends State<TaskCardDesktop> {
_lastTapTime = isDoubleTap ? null : now;
_lastTapPosition = isDoubleTap ? null : details.globalPosition;
widget.onPressed();
if (isDoubleTap) {
if (isDoubleTap && !widget.task.isGroup) {
widget.onOpen?.call();
}
}
@@ -134,7 +136,7 @@ class _TaskCardDesktopState extends State<TaskCardDesktop> {
)
: const SizedBox.shrink(),
),
TaskCardIcon(task: task),
TaskCardIcon(task: task, expanded: widget.expanded),
const SizedBox(width: 16),
Expanded(
child: SizedBox(
@@ -234,15 +236,21 @@ class _ProgressContent extends StatelessWidget {
}
List<Widget> _buildActionButtons(TaskRecord task, AppPalette palette, bool isDark) {
final actions = switch (task.status) {
TaskStatus.downloading => [(Icons.pause, onPause), (Icons.close, onDelete)],
TaskStatus.paused => [(Icons.play_arrow, onResume), (Icons.close, onDelete)],
TaskStatus.failed => [(Icons.refresh, onResume), (Icons.delete_outline, onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, null),
(Icons.delete_outline, onDelete),
],
};
final actions = task.isGroup && task.status != TaskStatus.completed
? [
if (task.canPause) (Icons.pause, onPause),
if (task.canResume) (Icons.play_arrow, onResume),
(Icons.close, onDelete),
]
: switch (task.status) {
TaskStatus.downloading => [(Icons.pause, onPause), (Icons.close, onDelete)],
TaskStatus.paused => [(Icons.play_arrow, onResume), (Icons.close, onDelete)],
TaskStatus.failed => [(Icons.refresh, onResume), (Icons.delete_outline, onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, null),
(Icons.delete_outline, onDelete),
],
};
return actions
.map(
@@ -13,6 +13,7 @@ class TaskCardMobile extends StatefulWidget {
const TaskCardMobile({
super.key,
required this.task,
this.expanded = false,
required this.selected,
required this.batchMode,
required this.selectedInBatch,
@@ -27,6 +28,7 @@ class TaskCardMobile extends StatefulWidget {
});
final TaskRecord task;
final bool expanded;
final bool selected;
final bool batchMode;
final bool selectedInBatch;
@@ -62,7 +64,7 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onPressed,
onDoubleTap: !widget.batchMode && task.status == TaskStatus.completed ? widget.onOpen : null,
onDoubleTap: !widget.batchMode && !task.isGroup && task.status == TaskStatus.completed ? widget.onOpen : null,
child: Container(
key: const ValueKey('task-card-mobile-surface'),
decoration: BoxDecoration(
@@ -117,7 +119,7 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
)
: const SizedBox.shrink(),
),
TaskCardIcon(task: task),
TaskCardIcon(task: task, expanded: widget.expanded),
const SizedBox(width: 12),
Expanded(
child: Text(
@@ -168,15 +170,21 @@ class _TaskCardMobileState extends State<TaskCardMobile> {
}
List<Widget> _buildActionButtons(TaskRecord task, AppPalette palette, bool isDark) {
final actions = switch (task.status) {
TaskStatus.downloading => [(Icons.pause, widget.onPause), (Icons.close, widget.onDelete)],
TaskStatus.paused => [(Icons.play_arrow, widget.onResume), (Icons.close, widget.onDelete)],
TaskStatus.failed => [(Icons.refresh, widget.onResume), (Icons.delete_outline, widget.onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, widget.onReveal),
(Icons.delete_outline, widget.onDelete),
],
};
final actions = task.isGroup && task.status != TaskStatus.completed
? [
if (task.canPause) (Icons.pause, widget.onPause),
if (task.canResume) (Icons.play_arrow, widget.onResume),
(Icons.close, widget.onDelete),
]
: switch (task.status) {
TaskStatus.downloading => [(Icons.pause, widget.onPause), (Icons.close, widget.onDelete)],
TaskStatus.paused => [(Icons.play_arrow, widget.onResume), (Icons.close, widget.onDelete)],
TaskStatus.failed => [(Icons.refresh, widget.onResume), (Icons.delete_outline, widget.onDelete)],
TaskStatus.completed => [
if (AppWindowChrome.isDesktopWindow) (Icons.folder_open_outlined, widget.onReveal),
(Icons.delete_outline, widget.onDelete),
],
};
return actions
.map(
@@ -46,25 +46,35 @@ class TaskUpdateListeningIndicator extends StatelessWidget {
}
class TaskCardIcon extends StatelessWidget {
const TaskCardIcon({super.key, required this.task});
const TaskCardIcon({super.key, required this.task, this.expanded = false});
final TaskRecord task;
final bool expanded;
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
return SizedBox(
width: 32,
height: 32,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
clipBehavior: Clip.hardEdge,
child: ColoredBox(
color: palette.taskCardIconBg,
child: Center(child: Icon(task.icon, size: 20, color: palette.textSecondary)),
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (task.isGroup) ...[
Icon(expanded ? Icons.expand_more : Icons.chevron_right, size: 18, color: palette.textSecondary),
const SizedBox(width: AppDesignTokens.space4),
],
SizedBox(
width: 32,
height: 32,
child: ClipRRect(
borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius),
clipBehavior: Clip.hardEdge,
child: ColoredBox(
color: palette.taskCardIconBg,
child: Center(child: Icon(task.icon, size: 20, color: palette.textSecondary)),
),
),
),
),
],
);
}
}
@@ -69,14 +69,14 @@ class TaskContextMenu extends StatelessWidget {
shad.MenuButton(
key: const ValueKey('task-context-resume'),
leading: const Icon(Icons.play_arrow, size: 16),
enabled: task.status == TaskStatus.paused || task.status == TaskStatus.failed,
enabled: task.canResume,
onPressed: (_) => actions.onResume(),
child: Text(context.l10n.continueAction),
),
shad.MenuButton(
key: const ValueKey('task-context-pause'),
leading: const Icon(Icons.pause, size: 16),
enabled: task.status == TaskStatus.downloading,
enabled: task.canPause,
onPressed: (_) => actions.onPause(),
child: Text(context.l10n.pause),
),
@@ -5,7 +5,13 @@ import '../../../../shared/theme/app_palette.dart';
import '../../../../shared/theme/app_design_tokens.dart';
import '../../../../l10n/l10n.dart';
Future<bool?> showTaskDeleteDialog(BuildContext context, {required int taskCount, required bool keepFiles}) async {
Future<bool?> showTaskDeleteDialog(
BuildContext context, {
required int taskCount,
required bool keepFiles,
int? memberCount,
int? fileCount,
}) async {
var currentKeepFiles = keepFiles;
final dialog = const DialogOverlayHandler().show<bool>(
context: context,
@@ -32,34 +38,46 @@ Future<bool?> showTaskDeleteDialog(BuildContext context, {required int taskCount
title: Text(dialogContext.l10n.deleteTasksTitle(taskCount)),
content: SizedBox(
width: contentWidth,
child: AnimatedContainer(
key: const ValueKey('task-delete-keep-files-option'),
duration: const Duration(milliseconds: 160),
padding: const EdgeInsets.symmetric(
horizontal: AppDesignTokens.space16,
vertical: AppDesignTokens.space12,
),
decoration: BoxDecoration(
color: currentKeepFiles ? palette.brandSoft : palette.surfaceSoft,
border: Border.all(color: currentKeepFiles ? palette.brand.withValues(alpha: 0.45) : palette.border),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
dialogContext.l10n.deleteTaskTip,
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w500),
),
),
const SizedBox(width: AppDesignTokens.space16),
Switch(
key: const ValueKey('task-delete-keep-files-switch'),
value: currentKeepFiles,
onChanged: (value) => setDialogState(() => currentKeepFiles = value),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (memberCount != null) ...[
Text(dialogContext.l10n.taskGroupDeleteImpact(memberCount, fileCount ?? 0)),
const SizedBox(height: AppDesignTokens.space16),
],
),
AnimatedContainer(
key: const ValueKey('task-delete-keep-files-option'),
duration: const Duration(milliseconds: 160),
padding: const EdgeInsets.symmetric(
horizontal: AppDesignTokens.space16,
vertical: AppDesignTokens.space12,
),
decoration: BoxDecoration(
color: currentKeepFiles ? palette.brandSoft : palette.surfaceSoft,
border: Border.all(
color: currentKeepFiles ? palette.brand.withValues(alpha: 0.45) : palette.border,
),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
dialogContext.l10n.deleteTaskTip,
style: TextStyle(color: palette.textPrimary, fontSize: 14, fontWeight: FontWeight.w500),
),
),
const SizedBox(width: AppDesignTokens.space16),
Switch(
key: const ValueKey('task-delete-keep-files-switch'),
value: currentKeepFiles,
onChanged: (value) => setDialogState(() => currentKeepFiles = value),
),
],
),
),
],
),
),
actions: [
@@ -121,7 +121,9 @@ class _TaskDetailsViewState extends ConsumerState<TaskDetailsView> {
final contentPadding = widget.mobile
? AppDesignTokens.taskDetailsMobilePadding
: AppDesignTokens.taskDetailsDesktopPadding;
final runtimeStatus = _tabIndex == 1 ? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value : null;
final runtimeStatus = _tabIndex == 1 && !widget.task.isGroup
? ref.watch(taskRuntimeStatusProvider(widget.task.id)).value
: null;
final content = Column(
children: [
Container(
@@ -148,12 +150,14 @@ class _TaskDetailsViewState extends ConsumerState<TaskDetailsView> {
active: _tabIndex == 1,
onTap: () => setState(() => _tabIndex = 1),
),
const SizedBox(width: 24),
_DrawerTabButton(
label: context.l10n.taskDetailsStatsTab,
active: _tabIndex == 2,
onTap: () => setState(() => _tabIndex = 2),
),
if (!widget.task.isGroup) ...[
const SizedBox(width: 24),
_DrawerTabButton(
label: context.l10n.taskDetailsStatsTab,
active: _tabIndex == 2,
onTap: () => setState(() => _tabIndex = 2),
),
],
],
),
),
@@ -340,7 +344,7 @@ class _TaskInfoTab extends StatelessWidget {
onCancel: onCancelEditUrl,
onSave: onSaveUrl,
)
else
else if (!task.isGroup)
_CopyableInfoBlock(
label: context.l10n.downloadLink,
value: task.url,
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
@@ -7,6 +8,7 @@ import '../../../../l10n/l10n.dart';
import '../../../../shared/widgets/app_tooltip.dart';
import '../../../../util/util.dart';
import '../../domain/task_record.dart';
import '../../application/tasks_controller.dart';
import 'task_file_manager.dart';
Future<void> browseTaskFiles(BuildContext context, TaskRecord task, {required bool mobile}) async {
@@ -49,7 +51,16 @@ Future<void> showTaskFileBrowserDialog(BuildContext context, TaskRecord task) as
content: SizedBox(
width: dialogWidth,
height: dialogHeight,
child: TaskFileManagerView(task: task),
child: Consumer(
builder: (context, ref, _) {
final state = ref.watch(tasksControllerProvider).value;
final current = state?.findTask(task.id);
if (state != null && current == null) {
return Center(child: Text(context.l10n.taskNotFound));
}
return TaskFileManagerView(task: current ?? task);
},
),
),
);
},
@@ -120,14 +120,19 @@ class _TaskFileManagerViewState extends State<TaskFileManagerView> {
}
String _localPath(_TaskFileEntry entry) {
if (entry.localPath != null) return entry.localPath!;
if (!widget.task.isFolder && _index.fileCount == 1) return widget.task.storagePath;
final segments = entry.fullPath.split('/').where((part) => part.isNotEmpty);
return path.joinAll([widget.task.storagePath, ...segments]);
}
String _accessUrl(_TaskFileEntry entry) {
final encodedPath = entry.fullPath.split('/').where((part) => part.isNotEmpty).map(Uri.encodeComponent).join('/');
return api.join('/fs/tasks/${Uri.encodeComponent(widget.task.id)}/$encodedPath');
final encodedPath = (entry.source?.accessPath ?? entry.fullPath)
.split('/')
.where((part) => part.isNotEmpty)
.map(Uri.encodeComponent)
.join('/');
return api.join('/fs/tasks/${Uri.encodeComponent(entry.source?.taskId ?? widget.task.id)}/$encodedPath');
}
}
@@ -304,7 +309,7 @@ class _FileActionButton extends StatelessWidget {
class _TaskFileDirectoryIndex {
_TaskFileDirectoryIndex(TaskRecord task) {
final sourceFiles = task.files.isEmpty
final sourceFiles = task.files.isEmpty && !task.isGroup
? [TaskFileNode(path: '/', name: task.name, sizeBytes: task.totalBytes ?? 0)]
: task.files;
fileCount = sourceFiles.length;
@@ -313,8 +318,18 @@ class _TaskFileDirectoryIndex {
final directory = _normalizeDirectory(file.path);
final name = singleFile ? path.basename(task.storagePath) : file.name;
final fullPath = directory == '/' ? '/$name' : '$directory/$name';
_add(directory, _TaskFileEntry(directory: false, fullPath: fullPath, name: name, size: file.sizeBytes));
_addParentDirectories(directory);
_add(
directory,
_TaskFileEntry(
directory: false,
fullPath: fullPath,
name: name,
size: file.sizeBytes,
source: file,
localPath: file.localPath,
),
);
_addParentDirectories(directory, file.localPath == null ? null : path.dirname(file.localPath!));
}
for (final entries in _entries.values) {
entries.sort((left, right) {
@@ -331,15 +346,19 @@ class _TaskFileDirectoryIndex {
List<_TaskFileEntry> entries(String directory) => _entries[directory] ?? const [];
void _addParentDirectories(String directory) {
void _addParentDirectories(String directory, String? localPath) {
var current = directory;
while (current != '/') {
final parent = path.posix.dirname(current);
final normalizedParent = parent == '.' ? '/' : parent;
final name = path.posix.basename(current);
_add(normalizedParent, _TaskFileEntry(directory: true, fullPath: current, name: name, size: 0));
_add(
normalizedParent,
_TaskFileEntry(directory: true, fullPath: current, name: name, size: 0, localPath: localPath),
);
_entries.putIfAbsent(current, () => []);
current = normalizedParent;
if (localPath != null) localPath = path.dirname(localPath);
}
}
@@ -350,12 +369,21 @@ class _TaskFileDirectoryIndex {
}
class _TaskFileEntry {
const _TaskFileEntry({required this.directory, required this.fullPath, required this.name, required this.size});
const _TaskFileEntry({
required this.directory,
required this.fullPath,
required this.name,
required this.size,
this.source,
this.localPath,
});
final bool directory;
final String fullPath;
final String name;
final int size;
final TaskFileNode? source;
final String? localPath;
}
String _normalizeDirectory(String value) {
@@ -42,6 +42,13 @@ class _TaskFileTreeState extends State<TaskFileTree> {
Widget build(BuildContext context) {
final progressByIndex = {
for (final file in widget.runtimeStatus?.files ?? const <FileRuntimeStatus>[]) file.index: file,
if (widget.task.isGroup)
for (final (index, file) in widget.task.files.indexed)
file.resourceIndex ?? index: FileRuntimeStatus(
index: file.resourceIndex ?? index,
size: file.sizeBytes,
downloaded: file.downloadedBytes ?? 0,
),
};
return Column(
@@ -179,7 +186,17 @@ class _FileDownloadProgress extends StatelessWidget {
@override
Widget build(BuildContext context) {
final palette = AppPalette.of(context);
final taskStateIcon = switch (taskStatus) {
final statuses = node.leafItems.map((item) => item.data.status).whereType<TaskStatus>().toSet();
final status = statuses.isEmpty
? taskStatus
: statuses.contains(TaskStatus.downloading)
? TaskStatus.downloading
: statuses.contains(TaskStatus.failed)
? TaskStatus.failed
: statuses.contains(TaskStatus.paused)
? TaskStatus.paused
: TaskStatus.completed;
final taskStateIcon = switch (status) {
TaskStatus.paused => Semantics(
label: context.l10n.pause,
child: Icon(Icons.pause_circle_filled, size: 16, color: palette.textMuted),
+5 -2
View File
@@ -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} من الملفات."
}
+5 -2
View File
@@ -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."
}
+5 -2
View File
@@ -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."
}
+5 -2
View File
@@ -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."
}
+15 -2
View File
@@ -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"
}
}
}
}
+5 -2
View File
@@ -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."
}
+5 -2
View File
@@ -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} پرونده تأثیر می‌گذارد."
}
+5 -2
View File
@@ -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."
}
+5 -2
View File
@@ -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."
}
+5 -2
View File
@@ -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."
}
+5 -2
View File
@@ -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."
}
+5 -2
View File
@@ -372,7 +372,9 @@
"torrentDataEmpty": "トレントファイルデータが空です",
"torrentPathUnavailable": "トレントファイルパスにアクセスできない",
"selectAtLeastOneFile": "少なくとも1つのファイルを選択します",
"resolvedFiles": "解決されたファイル",
"resolvedFiles": "ファイル一覧",
"waitingToResolve": "解析待ち",
"resolvingResource": "リソースを解析中",
"resolvedFileCount": "{count} ファイルが解決しました",
"resolvedCategoryDirectory": "{path} に保存されます",
"resourceHasNoFiles": "リソースには、選択可能なファイルリストはありません",
@@ -477,5 +479,6 @@
"trackerSubscriptionUpdateStatus": "更新状況",
"trackerSubscriptionTrackerCount": "Tracker 数",
"trackerSubscriptionNotUpdated": "未更新",
"actions": "操作"
"actions": "操作",
"taskGroupDeleteImpact": "{taskCount} 件のダウンロードと {fileCount} 個のファイルが対象です。"
}
+5 -2
View File
@@ -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}개에 영향을 줍니다."
}
+5 -2
View File
@@ -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."
}
+5 -2
View File
@@ -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."
}
+5 -2
View File
@@ -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} файлов."
}
+5 -2
View File
@@ -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."
}
+5 -2
View File
@@ -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} கோப்புகளையும் பாதிக்கும்."
}
+5 -2
View File
@@ -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."
}
+5 -2
View File
@@ -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} файлів."
}
+5 -2
View File
@@ -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."
}
+5 -2
View File
@@ -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} 个文件。"
}
+5 -2
View File
@@ -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} 個檔案。"
}
+23 -4
View File
@@ -1,18 +1,37 @@
import 'package:flutter/material.dart' show Tooltip;
import 'package:flutter/material.dart' show Tooltip, TooltipState;
import 'package:flutter/widgets.dart';
/// The single product tooltip primitive.
///
/// Flutter's tooltip is used intentionally because the shadcn tooltip does not
/// currently inherit Gopeed's mixed Shadcn/Material theme consistently.
class AppTooltip extends StatelessWidget {
const AppTooltip({super.key, required this.message, required this.child});
class AppTooltip extends StatefulWidget {
const AppTooltip({super.key, required this.message, required this.child, this.focusable = false});
final String message;
final Widget child;
final bool focusable;
@override
State<AppTooltip> createState() => _AppTooltipState();
}
class _AppTooltipState extends State<AppTooltip> {
final _tooltipKey = GlobalKey<TooltipState>();
@override
Widget build(BuildContext context) {
return Tooltip(message: message, child: child);
final tooltip = Tooltip(key: _tooltipKey, message: widget.message, child: widget.child);
if (!widget.focusable) return tooltip;
return Focus(
onFocusChange: (focused) {
if (focused) {
_tooltipKey.currentState?.ensureTooltipVisible();
} else {
Tooltip.dismissAllToolTips();
}
},
child: tooltip,
);
}
}
@@ -16,6 +16,8 @@ class FileTreeItem<T> {
required this.name,
required this.size,
required this.data,
this.directoryOnly = false,
this.namespace = '',
});
final String key;
@@ -23,6 +25,10 @@ class FileTreeItem<T> {
final String name;
final int size;
final T data;
final bool directoryOnly;
/// Keeps directories from independent resources distinct without adding a visible root.
final String namespace;
}
class FileTreeNode<T> {
@@ -61,6 +67,7 @@ class FileTreeView<T> extends StatefulWidget {
this.headerLeading,
this.leadingBuilder,
this.iconBuilder,
this.iconWidgetBuilder,
this.trailingBuilder,
this.onNodePressed,
this.keyPrefix = 'file-tree',
@@ -71,12 +78,15 @@ class FileTreeView<T> extends StatefulWidget {
this.contentTextStyle,
this.iconSize = 18,
this.initialExpandedDepth,
this.directoryLabels = const {},
});
final List<FileTreeItem<T>> items;
final Map<String, String> directoryLabels;
final Widget? headerLeading;
final FileTreeNodeWidgetBuilder<T>? leadingBuilder;
final FileTreeNodeIconBuilder<T>? iconBuilder;
final FileTreeNodeWidgetBuilder<T>? iconWidgetBuilder;
final FileTreeNodeWidgetBuilder<T>? trailingBuilder;
final ValueChanged<FileTreeNode<T>>? onNodePressed;
final String keyPrefix;
@@ -115,7 +125,11 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
@override
void initState() {
super.initState();
_nodes = _buildTree(widget.items, expandedDepth: widget.initialExpandedDepth);
_nodes = _buildTree(
widget.items,
expandedDepth: widget.initialExpandedDepth,
directoryLabels: widget.directoryLabels,
);
_allFoldersExpanded = _foldersAreExpanded(_nodes);
_transitionController = AnimationController(vsync: this, duration: _treeTransitionDuration, value: 1)
..addStatusListener(_onTransitionStatus);
@@ -127,8 +141,12 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
super.didUpdateWidget(oldWidget);
if (identical(widget.items, oldWidget.items)) return;
final expandedKeys = _expandedFolderKeys(_nodes);
var nodes = _restoreExpandedFolders(_buildTree(widget.items), expandedKeys);
// During a collapse animation, the pending tree holds the user's target state.
final expandedKeys = _expandedFolderKeys(_pendingCollapsedNodes ?? _nodes);
var nodes = _restoreExpandedFolders(
_buildTree(widget.items, directoryLabels: widget.directoryLabels),
expandedKeys,
);
nodes = _sortNodes(nodes, _sortColumn, _sortDirection);
_nodes = nodes;
_allFoldersExpanded = _foldersAreExpanded(nodes);
@@ -171,7 +189,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
label,
() => _measureLabelWidth(label, style: labelStyle, textDirection: textDirection, textScaler: textScaler),
);
final nameContentWidth = _widestLabelWidth(widget.items, measureLabel);
final nameContentWidth = _widestLabelWidth(_nodes, measureLabel);
final fixedNameChrome =
_treeNodeExpandChrome + widget.iconSize + 4 + (widget.leadingBuilder == null ? 0 : _treeLeadingChrome);
@@ -179,7 +197,7 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
builder: (context, constraints) {
final treeWidth = constraints.hasBoundedWidth ? constraints.maxWidth : _treeFallbackWidth;
final horizontalOverflow = _horizontalOverflow(
widget.items,
_nodes,
baseNameViewport:
treeWidth - _treeHorizontalPadding - trailingColumnWidth - _treeTrailingGap - fixedNameChrome,
depthIndent: treeItemInset * 3,
@@ -283,14 +301,15 @@ class _FileTreeViewState<T> extends State<FileTreeView<T>> with SingleTickerProv
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (leading != null) ...[leading, const SizedBox(width: 8)],
Icon(
widget.iconBuilder?.call(data) ??
(data.isFolder
? (visuallyExpanded ? BootstrapIcons.folder2Open : BootstrapIcons.folder2)
: _fileIcon(data.label)),
size: widget.iconSize,
color: palette.textSecondary,
),
widget.iconWidgetBuilder?.call(context, data) ??
Icon(
widget.iconBuilder?.call(data) ??
(data.isFolder
? (visuallyExpanded ? BootstrapIcons.folder2Open : BootstrapIcons.folder2)
: _fileIcon(data.label)),
size: widget.iconSize,
color: palette.textSecondary,
),
const SizedBox(width: 4),
Expanded(
child: GestureDetector(
@@ -873,7 +892,11 @@ class _BuildNode<T> {
}
}
List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int? expandedDepth}) {
List<TreeNode<FileTreeNode<T>>> _buildTree<T>(
List<FileTreeItem<T>> items, {
int? expandedDepth,
Map<String, String> directoryLabels = const {},
}) {
final root = <_BuildNode<T>>[];
final folders = <String, _BuildNode<T>>{};
var originalIndex = 0;
@@ -885,8 +908,13 @@ List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int?
for (final part in parts) {
currentPath = currentPath.isEmpty ? part : '$currentPath/$part';
final folder = folders.putIfAbsent(currentPath, () {
final node = _BuildNode<T>(key: 'folder:$currentPath', label: part, originalIndex: originalIndex++);
final directoryKey = item.namespace.isEmpty ? currentPath : '${item.namespace}:$currentPath';
final folder = folders.putIfAbsent(directoryKey, () {
final node = _BuildNode<T>(
key: 'folder:$directoryKey',
label: directoryLabels[directoryKey] ?? part,
originalIndex: originalIndex++,
);
if (parent == null) {
root.add(node);
} else {
@@ -897,6 +925,8 @@ List<TreeNode<FileTreeNode<T>>> _buildTree<T>(List<FileTreeItem<T>> items, {int?
parent = folder;
}
if (item.directoryOnly) continue;
final fileNode = _BuildNode<T>(
key: 'file:${item.key}',
label: item.name,
@@ -945,10 +975,10 @@ List<TreeNode<FileTreeNode<T>>> _sortNodes<T>(
sorted.sort((a, b) {
final left = a.data;
final right = b.data;
if (left.isFolder != right.isFolder) return left.isFolder ? -1 : 1;
if (column == null || direction == _FileTreeSortDirection.none) {
return left.originalIndex.compareTo(right.originalIndex);
}
if (left.isFolder != right.isFolder) return left.isFolder ? -1 : 1;
final comparison = switch (column) {
_FileTreeSortColumn.name => left.label.toLowerCase().compareTo(right.label.toLowerCase()),
_FileTreeSortColumn.size => left.size.compareTo(right.size),
@@ -994,7 +1024,7 @@ Set<String> _expandedFolderKeys<T>(Iterable<TreeNode<FileTreeNode<T>>> nodes) {
final keys = <String>{};
void collect(TreeNode<FileTreeNode<T>> node) {
final item = node as TreeItemNode<FileTreeNode<T>>;
if (item.children.isNotEmpty && item.expanded) keys.add(item.data.key);
if (item.data.isFolder && item.expanded) keys.add(item.data.key);
for (final child in item.children) {
collect(child);
}
@@ -1016,7 +1046,7 @@ List<TreeNode<FileTreeNode<T>>> _restoreExpandedFolders<T>(
final children = _restoreExpandedFolders(item.children, expandedKeys);
return item
.updateChildren(children)
.updateState(expanded: item.children.isNotEmpty && expandedKeys.contains(item.data.key));
.updateState(expanded: item.data.isFolder && expandedKeys.contains(item.data.key));
})
.toList(growable: false);
}
@@ -1036,13 +1066,12 @@ String _extensionOf(String name) {
return name.substring(index + 1).toLowerCase();
}
double _widestLabelWidth<T>(List<FileTreeItem<T>> items, double Function(String label) measureLabel) {
double _widestLabelWidth<T>(List<TreeNode<FileTreeNode<T>>> nodes, double Function(String label) measureLabel) {
var widest = _treeMinimumNameContentWidth;
for (final item in items) {
widest = math.max(widest, measureLabel(item.name));
for (final segment in item.path.split(RegExp(r'[/\\]+'))) {
if (segment.isNotEmpty) widest = math.max(widest, measureLabel(segment));
}
for (final node in nodes) {
final item = node as TreeItemNode<FileTreeNode<T>>;
widest = math.max(widest, measureLabel(item.data.label));
widest = math.max(widest, _widestLabelWidth(item.children, measureLabel));
}
return widest;
}
@@ -1063,20 +1092,27 @@ double _measureLabelWidth(
}
double _horizontalOverflow<T>(
List<FileTreeItem<T>> items, {
List<TreeNode<FileTreeNode<T>>> nodes, {
required double baseNameViewport,
required double depthIndent,
required double Function(String label) measureLabel,
int depth = 0,
}) {
var overflow = 0.0;
for (final item in items) {
final segments = item.path.split(RegExp(r'[/\\]+')).where((segment) => segment.isNotEmpty).toList();
for (var depth = 0; depth < segments.length; depth++) {
final viewport = math.max(_treeMinimumNameViewport, baseNameViewport - (depth * depthIndent));
overflow = math.max(overflow, measureLabel(segments[depth]) - viewport);
}
final fileViewport = math.max(_treeMinimumNameViewport, baseNameViewport - (segments.length * depthIndent));
overflow = math.max(overflow, measureLabel(item.name) - fileViewport);
final viewport = math.max(_treeMinimumNameViewport, baseNameViewport - depth * depthIndent);
for (final node in nodes) {
final item = node as TreeItemNode<FileTreeNode<T>>;
overflow = math.max(overflow, measureLabel(item.data.label) - viewport);
overflow = math.max(
overflow,
_horizontalOverflow(
item.children,
baseNameViewport: baseNameViewport,
depthIndent: depthIndent,
measureLabel: measureLabel,
depth: depth + 1,
),
);
}
return math.max(0, overflow);
}
+36
View File
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/model/create_task.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/patch_task.dart';
import 'package:gopeed/api/model/request.dart';
@@ -39,6 +40,32 @@ void main() {
expect((createBody['opts'] as Map<String, dynamic>)['selectFiles'], [1]);
});
test('batch sends rid and options and decodes the unified response in both modes', () async {
for (final grouped in [false, true]) {
final transport = _RecordingTransport();
api.setTransportForTesting(transport);
final result = await api.createTaskBatch(
CreateTaskBatch(
group: grouped,
groupName: grouped ? 'folder' : '',
reqs: [
CreateTaskBatchItem(
rid: 'resolved-id',
opts: Options(selectFiles: [1]),
),
],
),
);
expect(result.taskIds, ['created-id']);
expect(result.parentId, grouped ? 'parent' : null);
final body = transport.data as Map<String, dynamic>;
final member = (body['reqs'] as List).single as Map<String, dynamic>;
expect(member['rid'], 'resolved-id');
expect((member['opts'] as Map)['selectFiles'], [1]);
expect(body, isNot(contains('groupId')));
}
});
test('patch preserves options in its independent request model', () async {
final transport = _RecordingTransport();
api.setTransportForTesting(transport);
@@ -59,6 +86,15 @@ class _RecordingTransport implements GopeedTransport {
Map<String, dynamic>? queryParameters,
}) async {
this.data = jsonDecode(jsonEncode(data));
if (path == 'api/v1/tasks/batch') {
return {
'code': 0,
'data': {
'taskIds': ['created-id'],
if (this.data['group'] == true) 'parentId': 'parent',
},
};
}
if (path == 'api/v1/tasks') {
return {'code': 0, 'data': 'created-id'};
}
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/patch_task.dart';
import 'package:gopeed/api/model/request.dart';
@@ -21,6 +22,47 @@ class _Payload {
}
void main() {
test('group creation crosses the window boundary without losing directory or child options', () async {
final codecs = createAppCapabilityCodecs();
CreateTaskBatch? submitted;
final registry = CapabilityRegistry(codecs)
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = batch;
return CreateTaskBatchResult(taskIds: ['child'], parentId: 'generated');
});
final batch = CreateTaskBatch(
group: true,
groupName: 'folder',
opts: Options(path: '/downloads'),
reqs: [
CreateTaskBatchItem(
rid: 'resolved-rid',
req: Request(url: 'https://example.com/file'),
opts: Options(path: '/downloads/folder/nested', name: 'file.txt'),
),
],
);
final result = codecs.decode<CreateTaskBatchResult>(
await registry.invoke(GopeedMethods.createTaskBatch.name, codecs.encode(batch)),
);
expect(result.taskIds, ['child']);
expect(result.parentId, 'generated');
expect(submitted?.group, isTrue);
expect(submitted?.reqs?.single.rid, 'resolved-rid');
expect(submitted?.toJson().containsKey('groupId'), isFalse);
expect(submitted?.opts?.path, '/downloads');
expect(submitted?.reqs?.single.opts?.path, '/downloads/folder/nested');
expect(CreateTaskBatch.fromJson({'reqs': []}).group, isFalse);
expect(
CreateTaskBatchResult.fromJson({
'taskIds': ['ordinary'],
}).toJson(),
{
'taskIds': ['ordinary'],
},
);
});
test('serialized resolve and patch capabilities keep separate request contracts', () async {
final codecs = createAppCapabilityCodecs();
String? resolvedUrl;
@@ -28,7 +70,10 @@ void main() {
final registry = CapabilityRegistry(codecs)
..bind(GopeedMethods.resolve, (request) {
resolvedUrl = request.req?.url;
return ResolveResult(id: 'resolved-id', res: Resource(files: []));
return ResolveResult(
id: 'resolved-id',
res: Resource(files: []),
);
})
..bind(GopeedMethods.patchTask, (request) {
patch = request;
@@ -0,0 +1,213 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api;
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/storage_capability.dart';
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
final host = LocalAppCapabilities.instance;
late _ConfigTransport transport;
setUp(() {
transport = _ConfigTransport();
api.setTransportForTesting(transport);
});
test('local and child saves serialize the full history read-modify-write', () async {
final first = host.capabilities.storage.saveCreateHistory(['A']);
await transport.firstPutStarted.future;
final second = host.registry.invoke(StorageMethods.saveCreateHistory.name, ['B']);
await Future<void>.delayed(Duration.zero);
final readsWhileSaving = transport.reads;
transport.releaseFirstPut.complete();
await Future.wait<Object?>([first, second]);
expect(transport.config.extra.createHistory, ['B', 'A']);
expect(readsWhileSaving, 1, reason: 'the next GET must wait for the previous PUT');
});
test('history and config reads wait for previously requested saves', () async {
final saving = host.capabilities.storage.saveCreateHistory(['A']);
await transport.firstPutStarted.future;
final history = host.capabilities.storage.getCreateHistory();
final config = host.capabilities.gopeed.getConfig();
await Future<void>.delayed(Duration.zero);
final readsWhileSaving = transport.reads;
transport.releaseFirstPut.complete();
await saving;
expect(await history, ['A']);
expect((await config).extra.createHistory, ['A']);
expect(readsWhileSaving, 1);
});
for (final historyFirst in [true, false]) {
test('overlapping full config and history saves preserve both changes (history first: $historyFirst)', () async {
transport.config.extra.createHistory = ['existing'];
final snapshot = await host.capabilities.gopeed.getConfig();
snapshot.downloadDir = '/chosen';
snapshot.maxRunning = 7;
final first = historyFirst
? host.capabilities.storage.saveCreateHistory(['A'])
: host.capabilities.gopeed.putConfig(snapshot);
await transport.firstPutStarted.future;
final second = historyFirst
? host.capabilities.gopeed.putConfig(snapshot)
: host.capabilities.storage.saveCreateHistory(['A']);
await Future<void>.delayed(Duration.zero);
transport.releaseFirstPut.complete();
await Future.wait([first, second]);
expect(transport.config.extra.createHistory, ['A', 'existing']);
expect(transport.config.downloadDir, '/chosen');
expect(transport.config.maxRunning, 7);
});
}
test('an old full config cannot restore removed or cleared history', () async {
transport.blockFirstPut = false;
transport.config.extra.createHistory = ['A', 'B'];
final snapshot = await host.capabilities.gopeed.getConfig();
await host.capabilities.storage.removeCreateHistory('A');
await host.capabilities.gopeed.putConfig(snapshot);
expect(transport.config.extra.createHistory, ['B']);
await host.capabilities.storage.clearCreateHistory();
await host.capabilities.gopeed.putConfig(snapshot);
expect(transport.config.extra.createHistory, isEmpty);
expect(snapshot.extra.createHistory, ['A', 'B'], reason: 'saving must not mutate the caller snapshot');
});
test('history save, remove, clear and save follow request order', () async {
transport.config.extra.createHistory = ['existing'];
final first = host.capabilities.storage.saveCreateHistory(['A']);
await transport.firstPutStarted.future;
final remove = host.capabilities.storage.removeCreateHistory('existing');
final clear = host.capabilities.storage.clearCreateHistory();
final second = host.capabilities.storage.saveCreateHistory(['B']);
await Future<void>.delayed(Duration.zero);
transport.releaseFirstPut.complete();
await Future.wait([first, remove, clear, second]);
expect(transport.config.extra.createHistory, ['B']);
});
test('a failed save reports its error without blocking subsequent saves or reads', () async {
transport.failNextPut = true;
final first = host.capabilities.storage.saveCreateHistory(['A']);
final failed = expectLater(first, throwsA(isA<StateError>()));
await transport.firstPutStarted.future;
final second = host.capabilities.storage.saveCreateHistory(['B']);
final history = host.capabilities.storage.getCreateHistory();
transport.releaseFirstPut.complete();
await failed;
await second;
expect(await history, ['B']);
});
testWidgets('confirm A, cancel, confirm B saves both while previews open immediately', (tester) async {
tester.view.physicalSize = const Size(900, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ProviderScope(
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
// The host queue is created outside the widget clock.
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
final input = find.byKey(const ValueKey('create-task-url-input'));
await tester.enterText(input, 'https://example.com/A');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(transport.firstPutStarted.isCompleted, isTrue);
expect(transport.releaseFirstPut.isCompleted, isFalse);
expect(find.byKey(const ValueKey('resolve-cancel-button')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.enterText(input, 'https://example.com/B');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byKey(const ValueKey('resolve-cancel-button')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(shad.Icons.history));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
transport.releaseFirstPut.complete();
await tester.runAsync(() => Future<void>.delayed(Duration.zero));
await tester.pumpAndSettle();
expect(transport.config.extra.createHistory, ['https://example.com/B', 'https://example.com/A']);
for (final url in ['https://example.com/A', 'https://example.com/B']) {
expect(find.byKey(ValueKey('create-history-item-$url')), findsOneWidget);
}
expect(tester.takeException(), isNull);
});
}
class _ConfigTransport implements GopeedTransport {
DownloaderConfig config = DownloaderConfig(downloadDir: '/downloads');
final firstPutStarted = Completer<void>();
final releaseFirstPut = Completer<void>();
bool blockFirstPut = true;
bool failNextPut = false;
int reads = 0;
int writes = 0;
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async {
if (path == 'api/v1/resolve') {
return {
'code': 0,
'data': {
'id': 'rid',
'res': {
'files': [
{'name': 'file.txt'},
],
},
},
};
}
if (path != 'api/v1/config') throw StateError('Unexpected request: $method $path');
if (method == 'GET') {
reads++;
return {'code': 0, 'data': jsonDecode(jsonEncode(config))};
}
final snapshot = DownloaderConfig.fromJson(jsonDecode(jsonEncode(data)) as Map<String, dynamic>);
final shouldFail = failNextPut;
failNextPut = false;
if (++writes == 1 && blockFirstPut) {
firstPutStarted.complete();
await releaseFirstPut.future;
}
if (shouldFail) {
throw StateError('config save failed');
}
config = snapshot;
return {'code': 0};
}
@override
String join(String path) => path;
}
@@ -0,0 +1,396 @@
import 'dart:io';
import 'package:flutter/gestures.dart' show kSecondaryMouseButton;
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:gopeed/api/model/task.dart' as api;
import 'package:gopeed/features/home/presentation/pages/home_page.dart';
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
import 'package:gopeed/features/tasks/domain/task_record.dart';
import 'package:gopeed/features/tasks/presentation/pages/task_details_page.dart';
import 'package:gopeed/features/tasks/presentation/pages/task_files_page.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_card/task_card.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_drawer.dart';
import 'package:gopeed/app/application/app_runtime_controller.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/core/common/start_config.dart';
import 'package:gopeed/core/common/api_server_state.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
api.Task member(String id, {String name = 'member.txt', bool bt = false, String status = 'running'}) =>
api.Task.fromJson({
'id': id,
'name': name,
'parentId': 'group',
'group': {'id': 'group', 'name': 'bundle', 'path': '/downloads/bundle', 'createdAt': '2026-01-01T00:00:00Z'},
'protocol': bt ? 'bt' : 'http',
'status': status,
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-01T00:00:00Z',
'meta': {
'req': {'url': bt ? 'magnet:?xt=urn:btih:test' : 'https://example.com/$id'},
'opts': {'path': '/downloads/bundle', 'name': name, 'size': 100},
'res': {
'name': bt ? name : '',
'size': 100,
'files': [
{'name': bt ? 'bt-first.txt' : name, 'size': 50},
if (bt) {'name': 'bt-second.txt', 'size': 50},
],
},
},
'progress': {'used': 0, 'speed': 0, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
});
class GroupTasks extends TasksController {
String name = 'member.txt';
String childStatus = 'running';
final paused = <String>[];
final resumed = <String>[];
final deleted = <String>[];
TasksState snapshot() => TasksState(
tasks: taskRecordsFromApi([
member('child', name: name, status: childStatus),
member('torrent', name: 'torrent bundle', bt: true, status: 'pause'),
]),
);
@override
Future<TasksState> build() async => snapshot();
void rename() {
name = 'renamed.txt';
state = AsyncData(snapshot());
}
void completeMember() {
childStatus = 'done';
state = AsyncData(snapshot());
}
@override
Future<void> pause(String id) async => paused.add(id);
@override
Future<void> pauseAll(List<String>? ids) async => paused.addAll(ids!);
@override
Future<void> resume(String id) async => resumed.add(id);
@override
Future<void> resumeAll(List<String>? ids) async => resumed.addAll(ids!);
@override
Future<void> deleteSelected(List<String>? ids, {bool force = false}) async {
deleted.addAll(ids!);
state = AsyncData(
TasksState(
tasks: taskRecordsFromApi([
if (!deleted.contains('child') && !deleted.contains('group')) member('child', name: name),
if (!deleted.contains('torrent') && !deleted.contains('group'))
member('torrent', name: 'torrent bundle', bt: true, status: 'pause'),
]),
),
);
}
}
class GroupRuntime extends AppRuntimeController {
@override
Future<AppRuntimeState> build() async => AppRuntimeState(
startConfig: StartConfig(),
apiServerState: const ApiServerState(
enabled: false,
mcpEnabled: false,
running: false,
network: '',
address: '',
runningPort: 0,
pendingApply: false,
lastError: '',
),
downloaderConfig: DownloaderConfig(),
);
@override
Future<void> reloadConfig() async {}
}
Future<void> settle(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
}
Finder card(String id) => find.byWidgetPredicate((widget) => widget is TaskCard && widget.task.id == id);
Future<({GroupTasks controller, GoRouter router})> showHome(WidgetTester tester, double width) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final router = GoRouter(
routes: [
GoRoute(path: '/', builder: (_, _) => const HomePage()),
GoRoute(
path: '/tasks/:id',
builder: (_, state) =>
TaskDetailsPage(taskId: state.pathParameters['id']!, initialTask: state.extra as TaskRecord?),
),
GoRoute(
path: '/tasks/:id/files',
builder: (_, state) =>
TaskFilesPage(taskId: state.pathParameters['id']!, initialTask: state.extra as TaskRecord?),
),
],
);
addTearDown(router.dispose);
late GroupTasks controller;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.putConfig, (_) => const RpcUnit());
await tester.pumpWidget(
ProviderScope(
overrides: [
tasksControllerProvider.overrideWith(() => controller = GroupTasks()),
appRuntimeControllerProvider.overrideWith(GroupRuntime.new),
appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry))),
],
child: shad.ShadcnApp.router(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
routerConfig: router,
),
),
);
await settle(tester);
return (controller: controller, router: router);
}
void main() {
for (final width in [390.0, 700.0, 1100.0]) {
testWidgets('group expands actual member tasks and keeps a multi-file BT as one card at $width', (tester) async {
final app = await showHome(tester, width);
expect(card('group'), findsOneWidget);
expect(card('child'), findsNothing);
await tester.tap(find.text('bundle'));
await settle(tester);
expect(card('child'), findsOneWidget);
expect(card('torrent'), findsOneWidget);
expect(find.text('bt-first.txt'), findsNothing);
expect(find.text('bt-second.txt'), findsNothing);
expect(find.byType(TaskFileManagerView), findsNothing);
expect(find.byType(TaskDetailsView), findsNothing);
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskCard>(card('child')).task.name, 'renamed.txt');
await tester.tap(find.text('bundle'));
await settle(tester);
expect(card('child'), findsNothing);
expect(card('torrent'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('member click opens its own details and polling refreshes the selected member at $width', (
tester,
) async {
final app = await showHome(tester, width);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tap(find.text('member.txt'));
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'child');
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.name, 'renamed.txt');
expect(tester.takeException(), isNull);
});
}
testWidgets('member context menu and card actions target member IDs while group actions keep the group ID', (
tester,
) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-pause')));
await settle(tester);
expect(app.controller.paused, ['child']);
tester.widget<TaskCard>(card('torrent')).onResume!();
await settle(tester);
expect(app.controller.resumed, ['torrent']);
tester.widget<TaskCard>(card('group')).onPause!();
await settle(tester);
expect(app.controller.paused, ['child', 'group']);
expect(tester.takeException(), isNull);
});
for (final width in [390.0, 1100.0]) {
testWidgets('group resume button targets the group at $width', (tester) async {
final app = await showHome(tester, width);
await tester.tap(find.descendant(of: card('group'), matching: find.byIcon(Icons.play_arrow)));
await settle(tester);
expect(app.controller.resumed, ['group']);
expect(card('child'), findsNothing);
expect(tester.takeException(), isNull);
});
}
for (final open in [true, false]) {
testWidgets('member card and menu ${open ? 'open' : 'reveal'} use the member path', (tester) async {
final app = await showHome(tester, 1100);
app.controller.completeMember();
await settle(tester);
await tester.tap(find.text('bundle'));
await settle(tester);
final checkedPaths = <String>[];
final child = tester.widget<TaskCard>(card('child'));
await IOOverrides.runZoned(
() async {
(open ? child.onOpen : child.onReveal)!();
await settle(tester);
expect(checkedPaths.first, '/downloads/bundle/member.txt');
checkedPaths.clear();
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(ValueKey(open ? 'task-context-open-file' : 'task-context-open-directory')));
await settle(tester);
expect(checkedPaths.first, '/downloads/bundle/member.txt');
},
fseGetType: (path, followLinks) async {
checkedPaths.add(path);
return FileSystemEntityType.notFound;
},
);
// Allow the failure-path toast to close without launching native tools.
await tester.pumpWidget(const SizedBox());
await tester.pump(const Duration(seconds: 6));
expect(tester.takeException(), isNull);
});
}
testWidgets('member menu details uses the member instead of the group', (tester) async {
await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('torrent')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-details')));
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'torrent');
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.files, hasLength(2));
expect(tester.takeException(), isNull);
});
testWidgets('selecting a member through its context menu enables batch actions for that member', (tester) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-select')));
await settle(tester);
expect(tester.widget<TaskCard>(card('child')).selectedInBatch, isTrue);
final pause = find.byIcon(Icons.pause_outlined);
await tester.tap(pause);
await settle(tester);
expect(app.controller.paused, ['child']);
expect(tester.takeException(), isNull);
});
testWidgets('deleting a member leaves its sibling group intact', (tester) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-delete')));
await settle(tester);
expect(find.byType(shad.DestructiveButton), findsOneWidget);
await tester.tap(find.byType(shad.DestructiveButton));
await settle(tester);
expect(app.controller.deleted, ['child']);
expect(card('group'), findsOneWidget);
expect(card('child'), findsNothing);
expect(card('torrent'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('deleting a selected group and its members counts and submits the group once', (tester) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('group')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-select-all')));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-delete')));
await settle(tester);
expect(find.text('Delete task?'), findsOneWidget);
await tester.tap(find.byType(shad.DestructiveButton));
await settle(tester);
expect(app.controller.deleted, ['group']);
expect(card('group'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('member context file browser polls its member record', (tester) async {
final app = await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-browse-files')));
await settle(tester);
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'member.txt');
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'renamed.txt');
expect(tester.takeException(), isNull);
});
testWidgets('group clicks in batch mode select without collapsing members', (tester) async {
await showHome(tester, 1100);
await tester.tap(find.text('bundle'));
await settle(tester);
await tester.tapAt(tester.getCenter(card('child')), buttons: kSecondaryMouseButton);
await settle(tester);
await tester.tap(find.byKey(const ValueKey('task-context-select')));
await settle(tester);
await tester.tap(find.text('bundle'));
await settle(tester);
expect(tester.widget<TaskCard>(card('group')).selectedInBatch, isTrue);
expect(tester.widget<TaskCard>(card('group')).expanded, isTrue);
expect(card('child'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('member detail route resolves the member without an initial snapshot', (tester) async {
final app = await showHome(tester, 390);
app.router.go('/tasks/child');
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.id, 'child');
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskDetailsView>(find.byType(TaskDetailsView).last).task.name, 'renamed.txt');
expect(tester.takeException(), isNull);
});
testWidgets('member file page resolves current data from its group without a route snapshot', (tester) async {
final app = await showHome(tester, 390);
app.router.go('/tasks/child/files');
await settle(tester);
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'member.txt');
app.controller.rename();
await settle(tester);
expect(tester.widget<TaskFileManagerView>(find.byType(TaskFileManagerView)).task.name, 'renamed.txt');
expect(tester.takeException(), isNull);
});
}
@@ -0,0 +1,272 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/api/model/resolve_result.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/features/tasks/application/pending_download.dart';
PendingDownload source(String id, {String name = 'bundle', String rid = '', int count = 2, String? url}) =>
PendingDownload(
id: id,
request: Request(url: url ?? 'https://example.com/$id'),
options: Options(path: '/downloads'),
)
..result = ResolveResult(
id: rid,
res: Resource(
name: name,
files: List.generate(
count,
(i) => FileInfo(
name: '$i.txt',
size: 100 + i,
req: Request(url: 'https://example.com/$id/$i'),
),
),
),
)
..selected.addAll(List.generate(count, (i) => i));
GopeedService service(CapabilityRegistry registry) => GopeedService(LocalCapabilityInvoker(registry));
void main() {
test('one named extension group and all independent sources produce exactly two batches', () {
final group = source('a')..selected.remove(0);
final unnamed = source('b', name: '');
final torrent = source('c', rid: 'bt-rid')..selected.remove(0);
final http = source('d', rid: 'http-rid', count: 1, name: '');
final batches = planDownloads([group, unnamed, torrent, http]);
expect(batches, hasLength(2));
final grouped = batches.first.request;
expect(grouped.group, isTrue);
expect(grouped.groupName, 'bundle');
expect(grouped.opts!.path, '/downloads');
expect(grouped.reqs!.single.req!.url, 'https://example.com/a/1');
expect(grouped.toJson(), isNot(contains('groupId')));
final normal = batches.last.request;
expect(normal.group, isFalse);
expect(normal.reqs, hasLength(4));
expect(normal.reqs!.take(2).every((item) => item.req != null && item.rid == null), isTrue);
expect(normal.reqs![2].rid, 'bt-rid');
expect(normal.reqs![2].opts!.selectFiles, [1]);
expect(normal.reqs![3].rid, 'http-rid');
expect(normal.reqs![3].req, isNull);
});
test('successful batches are complete even when only some task IDs are returned', () async {
var calls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.createTaskBatch, (_) {
calls++;
return CreateTaskBatchResult(taskIds: ['only-one']);
});
final sources = [source('a', name: ''), source('b', rid: 'rid')];
await planDownloads(sources).single.submit(service(registry));
expect(sources.every((s) => s.created && s.createError == null), isTrue);
expect(planDownloads(sources), isEmpty);
expect(calls, 1);
});
test('retry only submits failed batches and never appends to the successful group', () async {
final submitted = <CreateTaskBatch>[];
var failNormal = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted.add(batch);
if (!batch.group && failNormal) throw StateError('all failed');
return CreateTaskBatchResult(taskIds: ['created'], parentId: batch.group ? 'parent' : null);
});
final sources = [source('group'), source('normal', name: '')];
for (final batch in planDownloads(sources)) {
await batch.submit(service(registry));
}
expect(sources.first.created, isTrue);
expect(sources.last.created, isFalse);
expect(sources.last.createError, contains('all failed'));
failNormal = false;
final retry = planDownloads(sources);
expect(retry, hasLength(1));
await retry.single.submit(service(registry));
expect(submitted.where((batch) => batch.group), hasLength(1));
expect(planDownloads(sources), isEmpty);
});
test('single extension keeps nested resource directory and empty base remains empty', () {
final single = source('single', count: 1, name: 'album');
single.result!.res.files.single.path = 'disc1';
expect(single.isGroup, isFalse);
expect(planDownloads([single]).single.request.reqs!.single.opts!.path, '/downloads/album/disc1');
final plain = source('plain', count: 1, name: '')..options.path = '';
expect(plain.creationItems().single.opts!.path, '');
});
for (final parent in ['', 'downloads', './downloads', '/downloads']) {
test('group members use paths relative to their group for parent "$parent"', () {
final pending = source('group')..options.path = parent;
pending.files[1].path = 'nested';
final batch = planDownloads([pending]).single.request;
expect(batch.group, isTrue);
expect(batch.opts!.path, parent);
expect(batch.reqs!.map((item) => item.opts!.path), ['', 'nested']);
expect(pending.options.path, parent);
});
}
test('unnamed extension members retain their ordinary batch destination', () {
final pending = source('independent', name: '')..options.path = 'downloads';
pending.files[1].path = 'nested';
final batch = planDownloads([pending]).single.request;
expect(batch.group, isFalse);
expect(batch.reqs!.map((item) => item.opts!.path), ['downloads', 'downloads/nested']);
});
test('members preserve directory intent, HTTP sizes and per-file checksum rules', () {
final pending = source('group');
pending.options.extra = OptsExtraHttp(
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
).toJson();
final members = pending.creationItems();
expect(members.first.opts!.size, 100);
expect(members.first.opts!.asDefaultPath, isFalse);
expect(members.first.opts!.skipAutoCategorize, isTrue);
expect(members.first.opts!.extra as Map, isNot(contains('checksum')));
pending.selected.remove(0);
expect(pending.creationItems().single.opts!.extra as Map, contains('checksum'));
expect(pending.options.extra as Map, contains('checksum'));
pending.result!.res.files[1].req = Request(url: 'magnet:?xt=urn:btih:test');
expect(pending.creationItems().single.opts!.size, 0);
});
test('FTP extension members use FTP options and keep their request TLS mode', () {
final pending = source('group');
pending.options.extra = OptsExtraHttp(
connections: 64,
autoExtract: true,
archivePassword: 'secret',
checksum: ChecksumOption(algorithm: 'md5', expected: 'abc'),
).toJson();
pending.files[0].req = Request(
url: 'ftp://files.example/archive.zip',
extra: ReqExtraFtp(tls: 'explicit').toJson(),
);
pending.selected.remove(1);
final item = pending.creationItems().single;
expect(item.req!.extra, {'tls': 'explicit'});
expect(item.opts!.size, 0);
final extra = item.opts!.extra as Map;
expect(extra['connections'], 32);
expect(extra['autoExtract'], isTrue);
expect(extra['archivePassword'], 'secret');
expect(extra.containsKey('checksum'), isFalse);
expect((pending.options.extra as Map)['connections'], 64);
});
test('member option changes do not affect other members or source options', () {
final pending = source('group');
pending.options
..name = 'custom'
..size = 500
..selectFiles = [0, 1]
..asDefaultPath = true;
final original = pending.options.toJson();
final members = pending.creationItems();
members.first.opts!
..name = 'changed.txt'
..path = '/elsewhere'
..size = 1
..selectFiles.add(99);
expect(members.last.opts!.name, '1.txt');
expect(members.last.opts!.path, '');
expect(members.last.opts!.size, 101);
expect(members.last.opts!.selectFiles, [0, 1]);
expect(pending.options.toJson(), original);
});
for (final rid in ['', 'resolved-rid']) {
test('empty resource preserves options and selects the creation method for rid="$rid"', () {
final pending = source('empty', rid: rid, count: 0)..selected.add(0);
pending.options
..name = 'custom'
..selectFiles = [2];
final item = pending.creationItems().single;
expect(item.rid, rid.isEmpty ? null : rid);
expect(item.req, rid.isEmpty ? pending.request : null);
expect(identical(item.opts, pending.options), isFalse);
expect(item.opts!.toJson(), pending.options.toJson());
});
}
test('a magnet resolved by an extension keeps its HTTP members and group identity', () {
final pending = source('extension', url: 'magnet:?xt=urn:btih:infohash');
expect(pending.isBt, isFalse);
expect(pending.isGroup, isTrue);
final batch = planDownloads([pending]).single.request;
expect(batch.group, isTrue);
expect(batch.reqs, hasLength(2));
expect(batch.reqs!.every((item) => item.rid == null && item.req!.url.startsWith('https:')), isTrue);
});
test('root names use HTTP file names, BT resource names and URL fallback', () {
final http = source('http', rid: 'http-rid', count: 1, name: 'unused-resource-name');
expect(http.name, '0.txt');
final bt = source('bt', rid: 'bt-rid', count: 1, name: 'torrent', url: 'magnet:?xt=urn:btih:infohash');
bt.result!.res.hash = 'infohash';
expect(bt.isBt, isTrue);
expect(bt.name, 'torrent');
final unnamed = source('empty', rid: 'rid', count: 0, name: '');
expect(unnamed.name, unnamed.request.url);
});
for (final entry in <String, bool>{
'ed2k://|file|payload.bin|4|0123456789ABCDEF0123456789ABCDEF|/': false,
'https://example.com/file': false,
'https://example.com/file.torrent': false,
'ftp://files.example/file.torrent': false,
'ftps://files.example/file.torrent': false,
'FTPES://files.example/file.torrent': false,
'magnet:?xt=urn:btih:infohash': true,
'MAGNET:?xt=urn:btih:infohash': true,
'file:///downloads/file.torrent': true,
'/downloads/FILE.TORRENT': true,
'file:///downloads/file.torrent?query=1': true,
'data:application/x-bittorrent;base64,AAAA': true,
'data:application/octet-stream;base64,AAAA': false,
}.entries) {
test('source protocol determines BT identity for ${entry.key}', () {
final pending = source('protocol', rid: 'rid', count: 1, name: 'resource', url: entry.key);
if (RegExp(r'^ftp', caseSensitive: false).hasMatch(entry.key)) pending.result!.res.name = '';
pending.result!.res.hash = '0123456789ABCDEF0123456789ABCDEF';
expect(pending.isBt, entry.value);
expect(pending.name, entry.value ? 'resource' : '0.txt');
});
}
test('failed resolutions are excluded and each newly opened source resolves fresh', () async {
var calls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) {
calls++;
return ResolveResult(
id: 'rid-$calls',
res: Resource(files: [FileInfo(name: 'file')]),
);
});
final first = PendingDownload(
id: 'a',
request: Request(url: 'https://example.com'),
options: Options(),
);
await first.resolve(service(registry));
await first.resolve(service(registry));
expect(calls, 1);
final reopened = PendingDownload(id: 'a', request: first.request, options: Options());
await reopened.resolve(service(registry));
expect(calls, 2);
final failed = PendingDownload(id: 'b', request: first.request, options: Options())..resolveError = 'failed';
expect(planDownloads([failed, reopened]).single.sources, [reopened]);
});
}
@@ -0,0 +1,80 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/task.dart' as api;
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
void main() {
test('the list counts a group once and sends group IDs for every action', () async {
api.Task task(String id) => api.Task.fromJson({
'id': id,
'name': '$id.txt',
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
'parentId': 'group',
'protocol': 'http',
'status': 'pause',
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-01T00:00:00Z',
'meta': {
'req': {'url': 'https://example.com/$id'},
'opts': {'path': '/downloads/folder', 'size': 100},
},
'progress': {'used': 0, 'speed': 0, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
});
final raw = [task('first'), task('second')];
final actions = <String>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getTasks, (_) => raw)
..bind(GopeedMethods.pauseTask, (id) {
actions.add('pause:$id');
return const RpcUnit();
})
..bind(GopeedMethods.continueTask, (id) {
actions.add('resume:$id');
return const RpcUnit();
})
..bind(GopeedMethods.pauseTasks, (params) {
actions.add('pauseBatch:${params['ids']}');
return const RpcUnit();
})
..bind(GopeedMethods.continueTasks, (params) {
actions.add('resumeBatch:${params['ids']}');
return const RpcUnit();
})
..bind(GopeedMethods.deleteTasks, (params) {
actions.add('delete:${params['ids']}:${params['force']}');
raw.clear();
return const RpcUnit();
});
final container = ProviderContainer(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
);
addTearDown(container.dispose);
final state = await container.read(tasksControllerProvider.future);
expect(state.tasks.length, 1);
expect(state.tasks.single.members.length, 2);
expect(state.allTasks.map((task) => task.id), ['group', 'first', 'second']);
expect(state.findTask('group'), same(state.tasks.single));
expect(state.findTask('first'), same(state.tasks.single.members.first));
expect(state.findTask('missing'), isNull);
expect(state.tasks.single.matchesSearch('second.txt'), isTrue);
expect(state.tasks.single.progress, 0.25);
final controller = container.read(tasksControllerProvider.notifier);
await controller.pause('group');
await controller.resume('group');
await controller.pauseAll(['group']);
await controller.resumeAll(['group']);
await controller.deleteSelected(['group'], force: true);
expect(actions, [
'pause:group',
'resume:group',
'pauseBatch:[group]',
'resumeBatch:[group]',
'delete:[group]:true',
]);
expect(container.read(tasksControllerProvider).value?.tasks, isEmpty);
});
}
@@ -29,6 +29,219 @@ void main() {
'progress': {'used': 0, 'speed': 0, 'downloaded': 10, 'uploadSpeed': 0, 'uploaded': 0},
});
test('members aggregate through parentId without a parent task', () {
final records = taskRecordsFromApi([
groupTestTask('first', parentId: 'group'),
groupTestTask(
'second',
parentId: 'group',
directory: '/downloads/folder/nested',
name: 'movie.mp4',
size: 300,
downloaded: 75,
),
groupTestTask('standalone'),
]);
expect(records.map((record) => record.id), ['group', 'standalone']);
final record = records.first;
expect(record.isGroup, isTrue);
expect(record.members.length, 2);
expect(record.totalBytes, 400);
expect(record.downloadedBytes, 100);
expect(record.speedBytes, 20);
expect(record.progress, 0.25);
expect(record.storagePath, '/downloads/folder');
expect(record.files.map((file) => file.path), ['', 'nested']);
expect(record.files.last.name, 'movie.mp4');
expect(record.matchesSearch('second'), isTrue);
expect(record.canUpdateUrl, isFalse);
});
test('group preserves uploading while member upload speed is momentarily zero', () {
final member = groupTestTask('uploading', parentId: 'group');
member.uploading = true;
member.progress.uploadSpeed = 0;
final group = taskRecordsFromApi([member]).single;
expect(group.uploading, isTrue);
expect(group.uploadSpeedBytes, 0);
});
for (final scenario in <(List<String>, TaskStatus, bool)>[
(['running', 'error', 'pause'], TaskStatus.downloading, false),
(['error', 'pause', 'wait'], TaskStatus.failed, false),
(['pause', 'wait', 'done'], TaskStatus.paused, false),
(['done', 'done'], TaskStatus.completed, false),
(['wait', 'done'], TaskStatus.downloading, true),
]) {
test('group status priority for ${scenario.$1}', () {
final record = taskRecordsFromApi([
for (var i = 0; i < scenario.$1.length; i++)
groupTestTask('child-$i', parentId: 'group', status: scenario.$1[i]),
]).single;
expect(record.status, scenario.$2);
expect(record.waiting, scenario.$3);
if (scenario.$2 == TaskStatus.completed) {
expect(record.progress, 1);
expect(record.doneAt, DateTime.utc(2026, 1, 3));
}
});
}
test('unknown member sizes stay indeterminate and empty files can finish', () {
final unknown = groupTestTask('unknown', parentId: 'group', size: 0);
final record = taskRecordsFromApi([unknown, groupTestTask('known', parentId: 'group')]).single;
expect(record.totalBytes, isNull);
expect(record.progress, isNull);
unknown.status = api.Status.done;
final empty = taskRecordsFromApi([unknown]).single;
expect(empty.totalBytes, 0);
expect(empty.progress, 1);
});
test('Windows group directories retain their relative file tree', () {
final records = taskRecordsFromApi([
groupTestTask('child', parentId: 'group', directory: r'C:\Downloads\folder\nested'),
]);
expect(records.single.files.single.path, 'nested');
expect(records.single.files.single.name, 'file.txt');
});
test('folder member file progress spreads the member ratio over its files', () {
final member = api.Task.fromJson({
'id': 'pack',
'name': 'pack',
'parentId': 'group',
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
'protocol': 'http',
'status': 'running',
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-02T00:00:00Z',
'doneAt': null,
'meta': {
'req': {'url': 'https://example.com/pack'},
'opts': {'path': '/downloads/folder/pack', 'name': 'pack', 'size': 30},
'res': {
'name': 'pack',
'size': 30,
'files': [
{'name': 'a.bin', 'size': 10},
{'name': 'b.bin', 'size': 20},
],
},
},
'progress': {'used': 0, 'speed': 5, 'downloaded': 15, 'uploadSpeed': 0, 'uploaded': 0},
});
final record = taskRecordsFromApi([member]).single;
expect(record.files.map((file) => file.name), ['a.bin', 'b.bin']);
expect(record.files.first.downloadedBytes, 5);
expect(record.files.last.downloadedBytes, 10);
});
test('members outside the group root render under their own folder name', () {
final folderMember = api.Task.fromJson({
'id': 'pack',
'name': 'pack',
'parentId': 'group',
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
'protocol': 'http',
'status': 'running',
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-02T00:00:00Z',
'doneAt': null,
'meta': {
'req': {'url': 'https://example.com/pack'},
'opts': {'path': '/elsewhere/pack', 'name': 'pack', 'size': 30},
'res': {
'name': 'pack',
'size': 30,
'files': [
{'name': 'a.bin', 'size': 30},
],
},
},
'progress': {'used': 0, 'speed': 5, 'downloaded': 15, 'uploadSpeed': 0, 'uploaded': 0},
});
final record = taskRecordsFromApi([
groupTestTask('loose', parentId: 'group', directory: '/elsewhere'),
folderMember,
]).single;
for (final file in record.files) {
expect(file.path.startsWith('..'), isFalse);
}
expect(record.files.first.path, '');
expect(record.files.first.name, 'file.txt');
expect(record.files.last.path, 'pack');
expect(record.files.last.name, 'a.bin');
});
test('completed group reports the wall span from creation to finish', () {
final record = taskRecordsFromApi([groupTestTask('child', parentId: 'group', status: 'done')]).single;
expect(record.downloadDuration, const Duration(days: 2));
});
for (final completed in [false, true]) {
test('group timestamps use chronological maxima when completed=$completed', () {
final first = groupTestTask('first', parentId: 'group', status: completed ? 'done' : 'running')
..updatedAt = DateTime.utc(2026, 1, 6)
..doneAt = DateTime.utc(2026, 1, 5);
final second = groupTestTask('second', parentId: 'group', status: 'done')
..updatedAt = DateTime.utc(2026, 1, 3)
..doneAt = DateTime.utc(2026, 1, 4);
final legacy = groupTestTask('legacy', parentId: 'group', status: 'done')..doneAt = null;
final record = taskRecordsFromApi([first, second, legacy]).single;
expect(record.updatedAt, DateTime.utc(2026, 1, 6));
expect(record.doneAt, completed ? DateTime.utc(2026, 1, 5) : null);
expect(record.downloadDuration, completed ? const Duration(days: 4) : null);
});
}
test('group creation bounds updated time and legacy completion stays unknown', () {
final member = groupTestTask('legacy', parentId: 'group', status: 'done')..doneAt = null;
member.group = api.TaskGroup(
id: 'group',
name: 'folder',
path: '/downloads/folder',
createdAt: DateTime.utc(2026, 1, 4),
);
final record = taskRecordsFromApi([member]).single;
expect(record.updatedAt, member.group!.createdAt);
expect(record.doneAt, isNull);
expect(record.downloadDuration, isNull);
});
test('completed group reports the members average speed instead of 0 B/s', () {
final record = taskRecordsFromApi([
groupTestTask(
'first',
parentId: 'group',
status: 'done',
size: 100,
downloaded: 100,
usedNanoseconds: 10 * 1000000000,
),
groupTestTask(
'second',
parentId: 'group',
status: 'done',
size: 50,
downloaded: 50,
usedNanoseconds: 5 * 1000000000,
),
]).single;
// 150 bytes over 15 combined active seconds, not the 0 of the idle aggregate.
expect(record.speedBytes, 10);
expect(record.speed, '10 B/s');
});
test('paused group keeps the idle aggregate instead of an average speed', () {
final record = taskRecordsFromApi([
groupTestTask('first', parentId: 'group', status: 'pause', downloaded: 100, usedNanoseconds: 10 * 1000000000),
]).single;
expect(record.speedBytes, 0);
});
test('selected files retain original indexes for runtime progress', () {
final record = TaskRecord.fromApi(buildTask([2, 0], 40));
expect(record.files.map((file) => file.name), ['a.txt', 'c.txt']);
@@ -97,3 +310,43 @@ void main() {
expect(taskFileTypeIcon('Playlist #2.mp4', isFolder: true), GopeedIcons.folder);
});
}
api.Task groupTestTask(
String id, {
String parentId = '',
String status = 'running',
int size = 100,
int downloaded = 25,
int usedNanoseconds = 0,
String directory = '/downloads/folder',
String name = 'file.txt',
}) => api.Task.fromJson({
'id': id,
'name': name,
'parentId': parentId,
if (parentId.isNotEmpty)
'group': {
'id': parentId,
'name': 'folder',
'path': directory.contains('\\') ? r'C:\Downloads\folder' : '/downloads/folder',
'createdAt': '2026-01-01T00:00:00Z',
},
'protocol': 'http',
'status': status,
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-02T00:00:00Z',
'doneAt': status == 'done' ? '2026-01-03T00:00:00Z' : null,
'meta': {
'req': {'url': 'https://example.com/$id'},
'opts': {'path': directory, 'name': name, 'size': size},
if (size > 0)
'res': {
'size': size,
'files': [
{'name': name, 'size': size},
],
},
},
'progress': {'used': usedNanoseconds, 'speed': 10, 'downloaded': downloaded, 'uploadSpeed': 0, 'uploaded': 0},
});
@@ -0,0 +1,445 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/create_task.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/downloader_config.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/api/model/resolve_result.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/core/capabilities/storage_capability.dart';
import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart';
import 'package:gopeed/features/tasks/application/pending_create_task.dart';
import 'package:gopeed/shared/theme/app_component_themes.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_http_headers_editor.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
testWidgets('cancel and confirm again resolves every unsubmitted URL afresh', (tester) async {
tester.view.physicalSize = const Size(900, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final resolved = <String>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
..bind(GopeedMethods.resolve, (request) {
resolved.add(request.req!.url);
return ResolveResult(
id: 'rid-${resolved.length}',
res: Resource(files: [FileInfo(name: 'file')]),
);
});
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
await tester.pumpAndSettle();
final url = find.byKey(const ValueKey('create-task-url-input'));
await tester.enterText(url, 'https://example.com/a\nhttps://example.com/b');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(resolved, ['https://example.com/a', 'https://example.com/b']);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(resolved, [
'https://example.com/a',
'https://example.com/b',
'https://example.com/a',
'https://example.com/b',
]);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
for (final failGroup in [true, false]) {
testWidgets('reopening a preview retries only the failed batch (group=$failGroup)', (tester) async {
final resolutions = <String>[];
final batches = <CreateTaskBatch>[];
var fail = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions.add(request.req!.url);
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
if (batch.group == failGroup && fail) throw StateError('batch failed');
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(batches, hasLength(2));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
fail = false;
await _openPreview(tester);
final failedUrl = 'https://example.com/${failGroup ? 'group' : 'other'}';
expect(resolutions, ['https://example.com/group', 'https://example.com/other', failedUrl]);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(batches, hasLength(3));
expect(batches.where((batch) => batch.group != failGroup), hasLength(1));
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('completion survives reordering and distinguishes duplicate source occurrences', (tester) async {
final resolutions = <String>[];
var groupCalls = 0;
var fail = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions.add(request.req!.url);
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
if (batch.group) {
groupCalls++;
if (groupCalls == 2 && fail) throw StateError('second occurrence failed');
}
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other\nhttps://example.com/group');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
fail = false;
await _openPreview(tester, urls: 'https://example.com/other\nhttps://example.com/group\nhttps://example.com/group');
expect(resolutions, [
'https://example.com/group',
'https://example.com/other',
'https://example.com/group',
'https://example.com/group',
]);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(groupCalls, 3);
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('unfinished sources resolve with edited options without recreating a completed group', (tester) async {
final resolutions = <Request>[];
final batches = <CreateTaskBatch>[];
var fail = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions.add(request.req!);
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
if (!batch.group && fail) throw StateError('batch failed');
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
tester.widget<AppHttpHeadersEditor>(find.byType(AppHttpHeadersEditor)).controller.replace({'Cookie': 'new-cookie'});
await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), '/retry');
fail = false;
await _openPreview(tester);
expect(resolutions.map((request) => request.url), [
'https://example.com/group',
'https://example.com/other',
'https://example.com/other',
]);
expect(ReqExtraHttp.fromJson(resolutions.last.extra! as Map<String, dynamic>).header['Cookie'], 'new-cookie');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(batches.where((batch) => batch.group), hasLength(1));
expect(batches.last.reqs!.single.opts!.path, '/retry');
expect(tester.takeException(), isNull);
});
testWidgets('a new creation window can submit the same source again', (tester) async {
var resolutions = 0;
var submissions = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions++;
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (_) {
submissions++;
return CreateTaskBatchResult(taskIds: ['created']);
});
final router = await _openPage(tester, registry);
for (var attempt = 0; attempt < 2; attempt++) {
if (attempt > 0) {
router.go('/create');
await tester.pumpAndSettle();
}
await _openPreview(tester, urls: 'https://example.com/group');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(CreateTaskWindowPage), findsNothing);
}
expect(resolutions, 2);
expect(submissions, 2);
expect(tester.takeException(), isNull);
});
for (final duringSubmission in [false, true]) {
testWidgets('a new external task starts a fresh session (during submission: $duringSubmission)', (tester) async {
final firstSubmission = Completer<CreateTaskBatchResult>();
final resolutions = <Request>[];
var groupSubmissions = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
final req = request.req!;
resolutions.add(req);
return _resolved(req.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
if (batch.group) {
if (++groupSubmissions == 1 && duringSubmission) return firstSubmission.future;
} else if (!duringSubmission) {
throw StateError('ordinary batch failed');
}
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
final page = find.byType(CreateTaskWindowPage);
final pageState = tester.state(page);
final container = ProviderScope.containerOf(tester.element(page));
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(groupSubmissions, 1);
if (!duringSubmission) {
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
}
container
.read(pendingCreateTaskProvider.notifier)
.set(
CreateTask(
req: Request(
url: 'https://example.com/group',
extra: ReqExtraHttp(method: 'GET', header: {'Cookie': 'new-intent'}, body: '').toJson(),
),
),
);
await tester.pump();
if (duringSubmission) {
firstSubmission.complete(CreateTaskBatchResult(taskIds: ['old-created']));
await tester.pump();
}
await tester.pumpAndSettle();
expect(tester.state(page), same(pageState));
await _openPreview(tester);
expect(resolutions.map((request) => request.url), [
'https://example.com/group',
'https://example.com/other',
'https://example.com/group',
]);
expect(ReqExtraHttp.fromJson(resolutions.last.extra! as Map<String, dynamic>).header['Cookie'], 'new-intent');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(groupSubmissions, 2);
expect(page, findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('a failed stale direct submission releases the new task form', (tester) async {
final firstSubmission = Completer<CreateTaskBatchResult>();
final requests = <CreateTaskBatch>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.createTaskBatch, (batch) {
requests.add(batch);
if (requests.length == 1) return firstSubmission.future;
return CreateTaskBatchResult(taskIds: ['new-created']);
});
await _openPage(tester, registry);
final container = ProviderScope.containerOf(tester.element(find.byType(CreateTaskWindowPage)));
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-checkbox')));
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/old');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
expect(requests, hasLength(1));
container.read(pendingCreateTaskProvider.notifier).set(CreateTask(req: Request(url: 'https://example.com/new')));
await tester.pump();
firstSubmission.completeError(StateError('old submission failed'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(CreateTaskWindowPage), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(requests, hasLength(2));
expect(requests.last.reqs!.single.req!.url, 'https://example.com/new');
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('confirming only completed sources finishes without opening another preview', (tester) async {
var resolutions = 0;
var submissions = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions++;
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
submissions++;
if (!batch.group) throw StateError('batch failed');
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/group');
await tester.tap(find.text('Advanced'));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-checksum-expected')), 'invalid');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(resolutions, 2);
expect(submissions, 2);
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('switching to direct download skips completed preview batches', (tester) async {
var resolutions = 0;
final batches = <CreateTaskBatch>[];
var fail = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
resolutions++;
return _resolved(request.req!.url);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
if (!batch.group && fail) throw StateError('batch failed');
return CreateTaskBatchResult(taskIds: ['created']);
});
await _openPage(tester, registry);
await _openPreview(tester, urls: 'https://example.com/group\nhttps://example.com/other');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('create-task-direct-download-checkbox')));
await tester.pump();
fail = false;
await _openPreview(tester);
expect(resolutions, 2);
expect(batches, hasLength(3));
expect(batches.last.reqs!.single.req!.url, 'https://example.com/other');
expect(find.byType(CreateTaskWindowPage), findsNothing);
expect(tester.takeException(), isNull);
});
}
ResolveResult _resolved(String url) => url.endsWith('/group')
? ResolveResult(
res: Resource(
name: 'album',
files: [
FileInfo(
name: 'a.txt',
req: Request(url: 'https://example.com/a.txt'),
),
FileInfo(
name: 'b.txt',
req: Request(url: 'https://example.com/b.txt'),
),
],
),
)
: ResolveResult(
id: 'rid-$url',
res: Resource(files: [FileInfo(name: 'other.txt')]),
);
Future<GoRouter> _openPage(WidgetTester tester, CapabilityRegistry registry) async {
tester.view.physicalSize = const Size(900, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
registry
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final router = GoRouter(
initialLocation: '/create',
routes: [
GoRoute(path: '/', builder: (_, _) => const SizedBox()),
GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()),
],
);
addTearDown(router.dispose);
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))],
child: shad.ShadcnApp.router(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
routerConfig: router,
builder: (_, child) => AppComponentThemes(child: child!),
),
),
);
await tester.pumpAndSettle();
return router;
}
Future<void> _openPreview(WidgetTester tester, {String? urls}) async {
if (urls != null) await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), urls);
final confirm = find.byKey(const ValueKey('create-task-confirm-button'));
await tester.ensureVisible(confirm);
await tester.tap(confirm);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
@@ -36,7 +36,7 @@ import 'package:gopeed/l10n/app_localizations.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
testWidgets('extension file estimates survive batch submission and window serialization', (tester) async {
testWidgets('extension file paths and estimates survive batch submission and window serialization', (tester) async {
tester.view.physicalSize = const Size(900, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -49,6 +49,7 @@ void main() {
(_) => ResolveResult(
id: '',
res: Resource(
name: 'videos',
files: [
FileInfo(
name: 'under.mp4',
@@ -57,6 +58,7 @@ void main() {
),
FileInfo(
name: 'over.mp4',
path: 'nested',
size: 1048576,
req: Request(url: 'https://example.com/audio'),
),
@@ -66,7 +68,7 @@ void main() {
)
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = CreateTaskBatch.fromJson(jsonDecode(jsonEncode(batch)) as Map<String, dynamic>);
return ['first', 'second'];
return CreateTaskBatchResult(taskIds: ['first', 'second'], parentId: 'generated');
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final router = GoRouter(
@@ -97,6 +99,10 @@ void main() {
await tester.pumpAndSettle();
expect(submitted?.reqs?.map((item) => item.opts?.size), [3, 1048576]);
expect(submitted?.reqs?.map((item) => item.req?.url), ['https://example.com/video', 'https://example.com/audio']);
expect(submitted?.group, isTrue);
expect(submitted?.groupName, 'videos');
expect(submitted?.opts?.path, '/downloads');
expect(submitted?.reqs?.map((item) => item.opts?.path), ['', 'nested']);
expect(tester.takeException(), isNull);
});
@@ -118,9 +124,9 @@ void main() {
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit())
..bind(GopeedMethods.createTask, (_) {
..bind(GopeedMethods.createTaskBatch, (batch) {
events.add('created');
return 'task-id';
return CreateTaskBatchResult(taskIds: ['task-id']);
})
..bind(NavigationMethods.showDownloadingTasks, (_) {
events.add('downloads');
@@ -163,9 +169,14 @@ void main() {
CreateTask? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'created-id';
return CreateTaskBatchResult(taskIds: ['created-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final container = ProviderContainer(
@@ -246,9 +257,14 @@ void main() {
GopeedMethods.getConfig,
(_) => DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true,
)
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'created-id';
return CreateTaskBatchResult(taskIds: ['created-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final container = ProviderContainer(
@@ -374,9 +390,15 @@ void main() {
..protocolConfig.http.connections = 8;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.putConfig, (_) => const RpcUnit())
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'created-task';
return CreateTaskBatchResult(taskIds: ['created-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
@@ -877,9 +899,14 @@ void main() {
CreateTask? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'task-id';
return CreateTaskBatchResult(taskIds: ['task-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
@@ -985,9 +1012,14 @@ void main() {
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(
rid: batch.reqs!.single.rid,
req: batch.reqs!.single.req,
opts: batch.reqs!.single.opts,
);
submitted = task;
return 'task-id';
return CreateTaskBatchResult(taskIds: ['task-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
@@ -1140,9 +1172,18 @@ void main() {
final createdTasks = <CreateTask>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
createdTasks.add(task);
return 'task-id';
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
id: 'resolved',
res: Resource(files: [FileInfo(name: 'file.zip')]),
),
)
..bind(GopeedMethods.createTaskBatch, (batch) {
for (final item in batch.reqs!) {
createdTasks.add(CreateTask(rid: item.rid, req: item.req, opts: item.opts));
}
return CreateTaskBatchResult(taskIds: ['task-id']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
@@ -1189,8 +1230,12 @@ void main() {
// Confirm creation
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(createdTasks, isEmpty);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(createdTasks.length, 2);
for (final t in createdTasks) {
final extra = OptsExtraHttp.fromJson(Map<String, dynamic>.from(t.opts!.extra! as Map));
@@ -1345,7 +1390,7 @@ void main() {
expect(tester.takeException(), isNull);
});
testWidgets('the resolve dialog shows the suggested category directory', (tester) async {
testWidgets('the file list keeps category suggestions out of its header', (tester) async {
tester.view.physicalSize = const Size(700, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -1381,8 +1426,8 @@ void main() {
await tester.pump(const Duration(milliseconds: 300));
final suggestion = find.byKey(const ValueKey('resolve-category-directory'));
expect(suggestion, findsOneWidget);
expect(tester.widget<Text>(suggestion).data, contains('/downloads/Program'));
expect(suggestion, findsNothing);
expect(find.text('File List'), findsOneWidget);
expect(tester.takeException(), isNull);
});
@@ -1437,7 +1482,7 @@ void main() {
await _settleToastTimers(tester);
});
testWidgets('the resolve dialog shows the directory the user named, not the suggestion', (tester) async {
testWidgets('the file list uses its fixed title for a manual directory', (tester) async {
tester.view.physicalSize = const Size(700, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -1475,13 +1520,87 @@ void main() {
await tester.pump(const Duration(milliseconds: 300));
final shown = find.byKey(const ValueKey('resolve-category-directory'));
expect(shown, findsOneWidget);
expect(tester.widget<Text>(shown).data, contains('/work'));
expect(tester.widget<Text>(shown).data, isNot(contains('/downloads/Program')));
expect(shown, findsNothing);
expect(find.text('File List'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('a resolved file list is split into tasks that keep the directory structure', (tester) async {
testWidgets('a resolved file list keeps structure and saves the selected parent directory', (tester) async {
tester.view.physicalSize = const Size(700, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
CreateTaskBatch? batch;
final config = DownloaderConfig(downloadDir: '/downloads');
DownloaderConfig? savedConfig;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.putConfig, (next) {
savedConfig = next;
return const RpcUnit();
})
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'first.zip', path: 'first.zip', size: 1),
FileInfo(name: 'second.zip', path: 'second.zip', size: 1),
],
),
),
)
..bind(GopeedMethods.createTaskBatch, (request) {
batch = request;
return CreateTaskBatchResult(taskIds: ['task-1', 'task-2'], parentId: 'generated');
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), '/chosen');
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('create-task-remember-download-directory-checkbox')));
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/bundle');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(batch, isNotNull);
expect(batch!.reqs, hasLength(2));
expect(batch!.group, isTrue);
expect(batch!.groupName, 'bundle');
expect(batch!.toJson().containsKey('groupId'), isFalse);
expect(batch!.opts?.path, '/chosen');
expect(batch!.opts?.asDefaultPath, isTrue);
for (final item in batch!.reqs!) {
// The split tasks keep the structure of the resolved resource, so they
// are never classified into a category or replace the selected parent.
expect(item.opts?.skipAutoCategorize, isTrue);
expect(item.opts?.asDefaultPath, isFalse);
}
expect(savedConfig?.downloadDir, '/chosen');
expect(batch!.group, isTrue);
expect(tester.takeException(), isNull);
await _settleToastTimers(tester);
});
testWidgets('a single selected file of a multi file resource is still not classified', (tester) async {
tester.view.physicalSize = const Size(700, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -1504,64 +1623,7 @@ void main() {
)
..bind(GopeedMethods.createTaskBatch, (request) {
batch = request;
return ['task-1', 'task-2'];
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
await tester.pumpWidget(
ProviderScope(
overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: AppComponentThemes(child: const CreateTaskWindowPage()),
),
),
);
await tester.pumpAndSettle();
await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/bundle');
await tester.tap(find.byKey(const ValueKey('create-task-confirm-button')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(batch, isNotNull);
expect(batch!.reqs, hasLength(2));
for (final item in batch!.reqs!) {
// The split tasks keep the structure of the resolved resource, so they
// are never classified into a category.
expect(item.opts?.skipAutoCategorize, isTrue);
}
expect(batch!.opts?.skipAutoCategorize, isTrue);
expect(tester.takeException(), isNull);
await _settleToastTimers(tester);
});
testWidgets('a single selected file of a multi file resource is still not classified', (tester) async {
tester.view.physicalSize = const Size(700, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
CreateTaskBatch? batch;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(
GopeedMethods.resolve,
(_) => ResolveResult(
res: Resource(
files: [
FileInfo(name: 'first.zip', path: 'first.zip', size: 1),
FileInfo(name: 'second.zip', path: 'second.zip', size: 1),
],
),
),
)
..bind(GopeedMethods.createTaskBatch, (request) {
batch = request;
return ['task-1'];
return CreateTaskBatchResult(taskIds: ['task-1'], parentId: 'generated');
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
@@ -1594,10 +1656,11 @@ void main() {
expect(batch, isNotNull);
expect(batch!.reqs, hasLength(1));
expect(batch!.group, isTrue);
for (final item in batch!.reqs!) {
expect(item.opts?.skipAutoCategorize, isTrue);
}
expect(batch!.opts?.skipAutoCategorize, isTrue);
expect(batch!.group, isTrue);
expect(tester.takeException(), isNull);
await _settleToastTimers(tester);
});
@@ -1642,7 +1705,7 @@ void _ftpTests() {
testWidgets('ftp links offer the TLS mode and carry FTP options through resolve', (tester) async {
ResolveTask? resolved;
CreateTask? submitted;
CreateTaskBatch? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.resolve, (task) {
@@ -1655,9 +1718,9 @@ void _ftpTests() {
),
);
})
..bind(GopeedMethods.createTask, (task) {
submitted = task;
return 'ftp-task';
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = batch;
return CreateTaskBatchResult(taskIds: ['ftp-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(tester, registry);
@@ -1693,20 +1756,20 @@ void _ftpTests() {
// The TLS mode travels on the request, so the resolve already uses it.
expect(resolved?.req?.url, 'FTP://files.example/pub/big.iso');
expect(resolved?.req?.extra, {'tls': 'explicit'});
expect(submitted?.rid, 'ftp-resolved');
final createOptions = submitted?.opts?.extra! as Map<String, dynamic>;
expect(submitted?.reqs?.single.rid, 'ftp-resolved');
final createOptions = submitted?.reqs?.single.opts?.extra! as Map<String, dynamic>;
expect(createOptions['connections'], 32);
expect(createOptions.containsKey('tls'), isFalse);
});
testWidgets('an ftp .torrent link restores its TLS mode and is not treated as BitTorrent', (tester) async {
CreateTask? submitted;
CreateTaskBatch? submitted;
final config = DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
submitted = task;
return 'ftp-task';
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = batch;
return CreateTaskBatchResult(taskIds: ['ftp-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(
@@ -1733,19 +1796,22 @@ void _ftpTests() {
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
expect(submitted?.req?.url, 'ftp://files.example/pub/linux.torrent');
expect(submitted?.req?.extra, {'tls': 'implicit'});
final options = OptsExtraFtp.fromJson(submitted?.opts?.extra! as Map<String, dynamic>);
expect(submitted?.reqs?.single.req?.url, 'ftp://files.example/pub/linux.torrent');
expect(submitted?.reqs?.single.req?.extra, {'tls': 'implicit'});
final options = OptsExtraFtp.fromJson(submitted?.reqs?.single.opts?.extra! as Map<String, dynamic>);
expect(options.connections, 6);
});
testWidgets('mixed links cap only the FTP task at 32 connections', (tester) async {
final submitted = <CreateTask>[];
final submitted = <CreateTaskBatch>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.createTask, (task) {
submitted.add(task);
return 'task-${submitted.length}';
..bind(
GopeedMethods.getConfig,
(_) => DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true,
)
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted.add(batch);
return CreateTaskBatchResult(taskIds: ['http-task', 'ftp-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(tester, registry);
@@ -1758,13 +1824,60 @@ void _ftpTests() {
await tester.tap(find.text('Confirm'));
await tester.pumpAndSettle();
final byUrl = {for (final task in submitted) task.req!.url: task.opts!.extra! as Map<String, dynamic>};
expect(submitted, hasLength(1));
final byUrl = {for (final item in submitted.single.reqs!) item.req!.url: item.opts!.extra! as Map<String, dynamic>};
expect(byUrl.keys, unorderedEquals(['https://example.com/a.zip', 'ftpes://files.example/b.iso']));
expect(byUrl['https://example.com/a.zip']!['connections'], 64);
expect(byUrl['https://example.com/a.zip']!.containsKey('tls'), isFalse);
expect(byUrl['ftpes://files.example/b.iso']!['connections'], 32);
expect(byUrl['ftpes://files.example/b.iso']!.containsKey('tls'), isFalse);
expect(submitted.map((task) => task.req!.extra), everyElement(isNull));
expect(submitted.single.reqs!.map((item) => item.req!.extra), everyElement(isNull));
});
testWidgets('mixed resolved HTTP and FTP resources share one batch with separate options', (tester) async {
final resolvedUrls = <String>[];
CreateTaskBatch? submitted;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads'))
..bind(GopeedMethods.resolve, (task) {
final url = task.req!.url;
resolvedUrls.add(url);
final ftp = url.startsWith('ftp');
return ResolveResult(
id: ftp ? 'ftp-rid' : 'http-rid',
res: Resource(
name: ftp ? 'folder' : '',
files: [
FileInfo(name: ftp ? 'movie.mkv' : 'file.zip', size: 10),
if (ftp) FileInfo(name: 'extra.txt', size: 2),
],
),
);
})
..bind(GopeedMethods.createTaskBatch, (batch) {
submitted = batch;
return CreateTaskBatchResult(taskIds: ['http-task', 'ftp-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
await _pumpCreateRoute(tester, registry);
await tester.enterText(find.byKey(urlInput), 'https://example.com/a.zip\nftpes://files.example/folder');
await tester.enterText(find.byKey(connectionsInput), '64');
await tester.tap(find.text('Confirm'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(resolvedUrls, ['https://example.com/a.zip', 'ftpes://files.example/folder']);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(submitted!.group, isFalse);
expect(submitted!.reqs, hasLength(2));
final http = submitted!.reqs![0];
final ftp = submitted!.reqs![1];
expect(http.rid, 'http-rid');
expect(ftp.rid, 'ftp-rid');
expect((http.opts!.extra as Map)['connections'], 64);
expect((ftp.opts!.extra as Map)['connections'], 32);
expect(http.opts!.selectFiles, [0]);
expect(ftp.opts!.selectFiles, [0, 1]);
expect(tester.takeException(), isNull);
});
}
@@ -1795,9 +1908,10 @@ Future<AppPalette> _pumpCreateTaskForSubmit(
..categories = categories;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.getConfig, (_) => config)
..bind(GopeedMethods.createTask, (task) {
..bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(rid: batch.reqs!.single.rid, req: batch.reqs!.single.req, opts: batch.reqs!.single.opts);
onCreate(task);
return 'created-task';
return CreateTaskBatchResult(taskIds: ['created-task']);
})
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
@@ -1850,9 +1964,10 @@ Future<AppPalette> _pumpCreateTaskWithCategories(
..bind(GopeedMethods.getConfig, (_) => config)
..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit());
if (onCreate != null) {
registry.bind(GopeedMethods.createTask, (task) {
registry.bind(GopeedMethods.createTaskBatch, (batch) {
final task = CreateTask(rid: batch.reqs!.single.rid, req: batch.reqs!.single.req, opts: batch.reqs!.single.opts);
onCreate(task);
return 'created-task';
return CreateTaskBatchResult(taskIds: ['created-task']);
});
}
final capabilities = AppCapabilities(LocalCapabilityInvoker(registry));
@@ -0,0 +1,310 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/api/model/resolve_result.dart';
import 'package:gopeed/core/capabilities/app_capabilities.dart';
import 'package:gopeed/core/capabilities/capability_rpc.dart';
import 'package:gopeed/core/capabilities/gopeed_capability.dart';
import 'package:gopeed/features/tasks/application/pending_download.dart';
import 'package:gopeed/features/tasks/presentation/widgets/resolve_download_dialog.dart';
import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/app_loading_button.dart';
import 'package:gopeed/shared/widgets/app_tooltip.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
PendingDownload pending(String id) => PendingDownload(
id: id,
request: Request(url: 'https://example.com/$id'),
options: Options(),
);
ResolveResult resolved(String name) => ResolveResult(
id: name,
res: Resource(files: [FileInfo(name: name)]),
);
Future<void> open(
WidgetTester tester,
List<PendingDownload> sources,
CapabilityRegistry registry, {
ValueChanged<bool>? onClose,
double width = 900,
}) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => shad.PrimaryButton(
onPressed: () async {
final result = await showResolveDownloadsDialog(
context,
sources: sources,
service: GopeedService(LocalCapabilityInvoker(registry)),
);
onClose?.call(result);
},
child: const Text('Open'),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
void main() {
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('file list fits and exposes ready selections at width $width', (tester) async {
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) => resolved('file.txt'));
await open(tester, [pending('source')], registry, width: width);
await tester.pumpAndSettle();
expect(find.text('File List'), findsOneWidget);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
expect(tester.takeException(), isNull);
});
}
testWidgets('shows all roots immediately and resolves sequentially while preserving choices', (tester) async {
final calls = <String>[];
final responses = [Completer<ResolveResult>(), Completer<ResolveResult>()];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
calls.add(request.req!.url);
return responses[calls.length - 1].future;
});
final sources = [pending('a'), pending('b')];
await open(tester, sources, registry);
expect(find.text('File List'), findsOneWidget);
expect(find.text('https://example.com/a'), findsOneWidget);
expect(find.text('https://example.com/b'), findsOneWidget);
expect(calls, ['https://example.com/a']);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
expect(find.byWidgetPredicate((w) => w is AppTooltip && w.message == 'Waiting to resolve'), findsOneWidget);
responses.first.complete(resolved('first.txt'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(calls, ['https://example.com/a', 'https://example.com/b']);
expect(find.text('first.txt'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:a:0')));
await tester.pump();
expect(sources.first.selected, isEmpty);
responses.last.complete(resolved('second.txt'));
await tester.pumpAndSettle();
expect(sources.first.selected, isEmpty);
expect(sources.last.selected, {0});
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
expect(tester.takeException(), isNull);
});
for (final filters in [
<String>['video'],
<String>['video', 'audio'],
]) {
testWidgets('new files clear type highlights without changing previous selections ($filters)', (tester) async {
final response = Completer<ResolveResult>();
final first = pending('first')
..result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'video.mp4', size: 10),
FileInfo(name: 'song.mp3', size: 20),
FileInfo(name: 'picture.png', size: 30),
],
),
)
..selected.addAll([0, 1, 2]);
final second = pending('second');
final batches = <CreateTaskBatch>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) => response.future)
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
return CreateTaskBatchResult(taskIds: ['created']);
});
await open(tester, [first, second], registry);
for (final filter in filters) {
await tester.tap(find.byKey(ValueKey('resolve-tree-filter-$filter')));
await tester.pump();
// Each selection is written back by the parent and rebuilds the tree.
expect(typeFilterActive(tester, filter), isTrue);
}
final expectedFirst = filters.length == 1 ? {0} : {0, 1};
expect(first.selected, expectedFirst);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
final incoming = filters.length == 1 ? 'new-audio.mp3' : 'new-image.png';
response.complete(
ResolveResult(
id: 'incoming-rid',
res: Resource(files: [FileInfo(name: incoming, size: 40)]),
),
);
await tester.pumpAndSettle();
expect(first.selected, expectedFirst);
expect(second.selected, {0});
for (final filter in filters) {
expect(typeFilterActive(tester, filter), isFalse);
}
expect(find.text('${expectedFirst.length + 1} / 4 selected'), findsOneWidget);
final size = tester.widget<Text>(find.byKey(const ValueKey('resolve-tree-selected-size'))).data;
expect(size, '${filters.length == 1 ? 50 : 70} B');
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(batches, hasLength(2));
expect(batches.first.group, isTrue);
expect(
batches.first.reqs!.map((item) => item.opts!.name),
filters.length == 1 ? ['video.mp4'] : ['video.mp4', 'song.mp3'],
);
expect(batches.last.group, isFalse);
expect(batches.last.reqs!.single.rid, 'incoming-rid');
expect(batches.last.reqs!.single.opts!.selectFiles, [0]);
expect(tester.takeException(), isNull);
});
}
testWidgets('a failed source preserves type highlights and the successful source selection', (tester) async {
final response = Completer<ResolveResult>();
final first = pending('first')
..result = ResolveResult(
res: Resource(
files: [
FileInfo(name: 'video.mp4'),
FileInfo(name: 'song.mp3'),
],
),
)
..selected.addAll([0, 1]);
final second = pending('failed');
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) => response.future);
await open(tester, [first, second], registry);
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video')));
await tester.pump();
expect(typeFilterActive(tester, 'video'), isTrue);
response.completeError(StateError('unavailable'));
await tester.pumpAndSettle();
expect(first.selected, {0});
expect(second.selected, isEmpty);
expect(typeFilterActive(tester, 'video'), isTrue);
expect(find.text('1 / 2 selected'), findsOneWidget);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNotNull);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('same-name sources synchronize individual, root and all-file selections', (tester) async {
final sources = [
for (final id in ['first', 'second'])
pending(id)
..result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'a.txt'),
FileInfo(name: 'b.txt'),
],
),
)
..selected.addAll([0, 1]),
];
await open(tester, sources, CapabilityRegistry(createAppCapabilityCodecs()));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
await tester.pumpAndSettle();
expect(sources.first.selected, {1});
expect(sources.last.selected, {0, 1});
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-folder:second')));
await tester.pumpAndSettle();
expect(sources.first.selected, {1});
expect(sources.last.selected, isEmpty);
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:1')));
await tester.pumpAndSettle();
expect(sources.first.selected, isEmpty);
expect(tester.widget<AppLoadingButton>(find.byKey(const ValueKey('resolve-create-button'))).onPressed, isNull);
final selectAll = find.descendant(of: find.byType(ResolveFileTree), matching: find.byType(shad.Checkbox)).first;
await tester.tap(selectAll);
await tester.pumpAndSettle();
expect(sources.every((source) => source.selected.length == 2 && source.selected.containsAll([0, 1])), isTrue);
await tester.tap(selectAll);
await tester.pumpAndSettle();
expect(sources.every((source) => source.selected.isEmpty), isTrue);
await tester.tap(find.byKey(const ValueKey('resolve-cancel-button')));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('cancel stops the queue and ignores the late response', (tester) async {
final response = Completer<ResolveResult>();
var calls = 0;
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (_) {
calls++;
return response.future;
});
bool? closed;
await open(tester, [pending('a'), pending('b')], registry, onClose: (value) => closed = value);
await tester.tap(find.byKey(const ValueKey('resolve-close-button')));
await tester.pumpAndSettle();
response.complete(resolved('late.txt'));
await tester.pumpAndSettle();
expect(calls, 1);
expect(closed, isFalse);
expect(find.text('late.txt'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('failed root is disabled and only ready selections enter a single batch', (tester) async {
final batches = <CreateTaskBatch>[];
final registry = CapabilityRegistry(createAppCapabilityCodecs())
..bind(GopeedMethods.resolve, (request) {
if (request.req!.url.endsWith('/bad')) throw StateError('unavailable');
return resolved('ready.txt');
})
..bind(GopeedMethods.createTaskBatch, (batch) {
batches.add(batch);
return CreateTaskBatchResult(taskIds: ['created']);
});
bool? closed;
await open(tester, [pending('bad'), pending('good')], registry, onClose: (value) => closed = value);
await tester.pumpAndSettle();
expect(find.textContaining('unavailable'), findsNothing);
expect(find.byWidgetPredicate((w) => w is AppTooltip && w.message.contains('unavailable')), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('resolve-create-button')));
await tester.pumpAndSettle();
expect(batches.single.reqs!.single.rid, 'ready.txt');
expect(closed, isTrue);
expect(tester.takeException(), isNull);
});
}
bool typeFilterActive(WidgetTester tester, String filter) {
final decoration =
tester
.widget<DecoratedBox>(
find
.descendant(
of: find.byKey(ValueKey('resolve-tree-filter-$filter')),
matching: find.byType(DecoratedBox),
)
.first,
)
.decoration
as BoxDecoration;
return decoration.color != null;
}
@@ -0,0 +1,467 @@
import 'package:flutter/widgets.dart';
import 'package:gopeed/core/icons/gopeed_icons.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/file_tree_view.dart';
import 'package:gopeed/shared/widgets/virtual_tree_view.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import 'package:gopeed/api/model/options.dart';
import 'package:gopeed/api/model/request.dart';
import 'package:gopeed/api/model/resource.dart';
import 'package:gopeed/api/model/resolve_result.dart';
import 'package:gopeed/features/tasks/application/pending_download.dart';
PendingDownload source(String id) =>
PendingDownload(
id: id,
request: Request(url: 'https://example.com/$id'),
options: Options(),
)
..result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: '0.txt'),
FileInfo(name: '1.txt'),
],
),
)
..selected.addAll([0, 1]);
void main() {
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('live mixed sources keep input order, selection and expansion at $width', (tester) async {
tester.view.physicalSize = Size(width, 1000);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final sources = [
for (final id in ['http', 'group', 'ungrouped', 'bt', 'failed'])
PendingDownload(
id: id,
request: Request(url: 'https://example.com/$id'),
options: Options(),
),
];
final http = sources[0];
final group = sources[1];
final ungrouped = sources[2];
final bt = sources[3];
bt.request.url = 'magnet:?xt=urn:btih:infohash';
Future<void> show() async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(
sources: sources,
onSelectionChanged: (keys) {
for (final pending in sources) {
pending.selected
..clear()
..addAll(keys.where((key) => key.$1 == pending.id).map((key) => key.$2));
}
},
),
),
);
await tester.pump();
}
List<shad.TreeItemNode<FileTreeNode<PendingFileKey>>> roots() => tester
.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
)
.nodes
.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>();
void expectOrder(List<String> labels) => expect(roots().map((node) => node.data.label), labels);
await show();
expectOrder(sources.map((pending) => pending.request.url).toList());
http.result = ResolveResult(
id: 'http-rid',
res: Resource(files: [FileInfo(name: 'z-http.txt')]),
);
http.selected.add(0);
await show();
expectOrder(['z-http.txt', ...sources.skip(1).map((pending) => pending.request.url)]);
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:http:0')));
await tester.pump();
expect(http.selected, isEmpty);
group.result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'one.txt'),
FileInfo(name: 'two.txt'),
],
),
);
group.selected.addAll([0, 1]);
await show();
expectOrder(['z-http.txt', 'bundle', ...sources.skip(2).map((pending) => pending.request.url)]);
ungrouped.result = ResolveResult(
res: Resource(
files: [
FileInfo(name: 'z-first.txt'),
FileInfo(name: 'nested.txt', path: 'directory'),
FileInfo(name: 'a-last.txt'),
],
),
);
ungrouped.selected.addAll([0, 1, 2]);
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:group')));
await tester.pump(const Duration(milliseconds: 50));
await show();
await tester.pump(const Duration(milliseconds: 250));
expect(roots()[1].expanded, isFalse);
expectOrder([
'z-http.txt',
'bundle',
'z-first.txt',
'directory',
'a-last.txt',
bt.request.url,
sources.last.request.url,
]);
bt.result = ResolveResult(
id: 'bt-rid',
res: Resource(
name: 'torrent',
files: [
FileInfo(name: 'bt-one'),
FileInfo(name: 'bt-two'),
],
),
);
bt.selected.addAll([0, 1]);
await show();
sources.last.resolveError = 'cannot resolve';
await show();
expectOrder([
'z-http.txt',
'bundle',
'z-first.txt',
'directory',
'a-last.txt',
'torrent',
sources.last.request.url,
]);
expect(roots()[1].expanded, isFalse);
expect(http.selected, isEmpty);
expect(group.selected, {0, 1});
expect(ungrouped.selected, {0, 1, 2});
expect(bt.selected, {0, 1});
expect(sources.last.selected, isEmpty);
expect(tester.takeException(), isNull);
});
}
testWidgets('ED2K uses a file icon and filename while BT uses its resource name and folder icon', (tester) async {
final ed2k =
PendingDownload(
id: 'ed2k',
request: Request(url: 'ed2k://|file|payload.bin|4|0123456789ABCDEF0123456789ABCDEF|/'),
options: Options(),
)
..result = ResolveResult(
id: 'ed2k-rid',
res: Resource(
hash: '0123456789ABCDEF0123456789ABCDEF',
files: [FileInfo(name: 'payload.bin')],
),
)
..selected.add(0);
final bt =
PendingDownload(
id: 'bt',
request: Request(url: 'magnet:?xt=urn:btih:infohash'),
options: Options(),
)
..result = ResolveResult(
id: 'bt-rid',
res: Resource(
name: 'torrent resource',
hash: 'infohash',
files: [FileInfo(name: 'torrent-file.bin')],
),
)
..selected.add(0);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [ed2k, bt], onSelectionChanged: (_) {}),
),
);
await tester.pumpAndSettle();
expect(find.text('payload.bin'), findsOneWidget);
expect(find.text('torrent resource'), findsOneWidget);
expect(find.byIcon(GopeedIcons.folderBt), findsOneWidget);
expect(find.text(ed2k.request.url), findsNothing);
expect(tester.takeException(), isNull);
});
for (final directory in [false, true]) {
testWidgets('FTP resource ${directory ? 'directory' : 'file'} keeps its actual structure', (tester) async {
final pending = source('ftp');
pending.request.url = directory ? 'ftp://files.example/folder' : 'ftpes://files.example/file.torrent';
pending.result = ResolveResult(
id: 'ftp-rid',
res: Resource(
name: directory ? 'folder' : '',
files: [FileInfo(name: 'file.torrent')],
),
);
pending.selected
..clear()
..add(0);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pumpAndSettle();
expect(pending.isBt, isFalse);
expect(pending.isGroup, isFalse);
expect(pending.name, directory ? 'folder' : 'file.torrent');
expect(find.text('folder'), directory ? findsOneWidget : findsNothing);
expect(find.byIcon(shad.BootstrapIcons.folder2), directory ? findsOneWidget : findsNothing);
expect(find.byIcon(GopeedIcons.folderBt), findsNothing);
expect(find.text('file.torrent'), findsOneWidget);
expect(planDownloads([pending]).single.request.reqs!.single.rid, 'ftp-rid');
expect(tester.takeException(), isNull);
});
}
for (final extension in [true, false]) {
testWidgets('magnet root icon follows the resolved resource (extension=$extension)', (tester) async {
final pending = source('magnet');
pending.request.url = 'magnet:?xt=urn:btih:infohash';
pending.result!.id = extension ? '' : 'bt-rid';
for (final file in pending.files) {
if (extension) file.req = Request(url: 'https://example.com/${file.name}');
}
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pumpAndSettle();
expect(pending.isGroup, extension);
expect(find.byIcon(GopeedIcons.folderBt), extension ? findsNothing : findsOneWidget);
expect(find.byIcon(GopeedIcons.file), findsNWidgets(2));
expect(find.byIcon(shad.BootstrapIcons.folder2), extension ? findsOneWidget : findsNothing);
expect(tester.takeException(), isNull);
});
}
testWidgets('ordinary single files replace URL placeholders without a source directory', (tester) async {
final pending = PendingDownload(
id: 'http',
request: Request(url: 'https://example.com/file'),
options: Options(),
);
Future<void> show() async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pump();
}
await show();
expect(find.text(pending.request.url), findsOneWidget);
pending.result = ResolveResult(
id: 'http-rid',
res: Resource(
name: '',
files: [FileInfo(name: 'file.txt')],
),
);
pending.selected.add(0);
await show();
expect(find.text('file.txt'), findsOneWidget);
expect(find.text(pending.request.url), findsNothing);
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
);
expect(tree.nodes, hasLength(1));
expect(tree.nodes.single.children, isEmpty);
expect((tree.nodes.single as shad.TreeItemNode<FileTreeNode<PendingFileKey>>).data.item!.data, ('http', 0));
expect(tester.takeException(), isNull);
});
for (final filePath in ['', 'disc1']) {
testWidgets('named single-file extension keeps its resource directory (path=$filePath)', (tester) async {
final pending =
PendingDownload(
id: 'extension',
request: Request(url: 'https://example.com/track.mp3'),
options: Options(path: '/downloads'),
)
..result = ResolveResult(
res: Resource(
name: 'album',
files: [
FileInfo(
name: 'track.mp3',
path: filePath,
req: Request(url: 'https://cdn.example.com/track.mp3'),
),
],
),
)
..selected.add(0);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pumpAndSettle();
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
);
final roots = tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>();
expect(roots.map((node) => node.data.label), ['album']);
expect(roots.single.expanded, isTrue);
final child = roots.single.children.single as shad.TreeItemNode<FileTreeNode<PendingFileKey>>;
expect(child.data.label, filePath.isEmpty ? 'track.mp3' : 'disc1');
final plan = planDownloads([pending]).single.request;
expect(plan.group, isFalse);
expect(plan.reqs!.single.opts!.path, filePath.isEmpty ? '/downloads/album' : '/downloads/album/disc1');
expect(tester.takeException(), isNull);
});
}
testWidgets('a live group replaces its URL placeholder with an expanded group directory', (tester) async {
final pending = PendingDownload(
id: 'group',
request: Request(url: 'https://example.com/group'),
options: Options(),
);
Future<void> show() async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [pending], onSelectionChanged: (_) {}),
),
);
await tester.pump();
}
await show();
pending.result = ResolveResult(
res: Resource(
name: 'bundle',
files: [
FileInfo(name: 'one.txt'),
FileInfo(name: 'two.txt'),
],
),
);
pending.selected.addAll([0, 1]);
await show();
expect(find.text('bundle'), findsOneWidget);
expect(find.text('one.txt'), findsOneWidget);
expect(find.text('two.txt'), findsOneWidget);
expect(find.byIcon(shad.BootstrapIcons.folder2), findsOneWidget);
expect(tester.takeException(), isNull);
});
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('ungrouped roots preserve relative directories and source selection at $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final sources = [source('first'), source('second')];
for (final pending in sources) {
pending.result!.res.name = '';
pending.files[0].name = 'same.txt';
pending.files[1].name = 'video.mp4';
pending.files[1].path = 'nested';
}
Set<PendingFileKey> selected = {};
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: sources, onSelectionChanged: (keys) => selected = keys),
),
);
await tester.pumpAndSettle();
final tree = tester.widget<VirtualTreeView<FileTreeNode<PendingFileKey>>>(
find.byType(VirtualTreeView<FileTreeNode<PendingFileKey>>),
);
expect(tree.nodes, hasLength(4));
expect(tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>().map((node) => node.data.label), [
'same.txt',
'nested',
'same.txt',
'nested',
]);
expect(
tree.nodes.cast<shad.TreeItemNode<FileTreeNode<PendingFileKey>>>().map((node) => node.data.key).toSet(),
hasLength(4),
);
expect(find.text('nested'), findsNWidgets(2));
expect(find.text('same.txt'), findsNWidgets(2));
expect(find.text(sources.first.request.url), findsNothing);
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
await tester.pump();
expect(selected, {('first', 1), ('second', 0), ('second', 1)});
final secondDirectoryKey = (tree.nodes.last as shad.TreeItemNode<FileTreeNode<PendingFileKey>>).data.key;
await tester.tap(find.byKey(ValueKey('resolve-tree-name-scroll-$secondDirectoryKey')));
await tester.pump();
expect(selected, {('first', 1), ('second', 0)});
await tester.tap(find.byKey(const ValueKey('resolve-tree-filter-video')));
await tester.pump();
expect(selected, {('first', 1), ('second', 1)});
expect(tester.takeException(), isNull);
});
}
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('same-name roots isolate file indexes and root selection at $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final first = source('first');
final second = source('second');
Set<PendingFileKey> selected = {('first', 0), ('first', 1), ('second', 0), ('second', 1)};
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(sources: [first, second], onSelectionChanged: (keys) => selected = keys),
),
);
await tester.pumpAndSettle();
expect(find.text('bundle'), findsNWidgets(2));
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-file:first:0')));
await tester.pump();
expect(selected, {('first', 1), ('second', 0), ('second', 1)});
await tester.tap(find.byKey(const ValueKey('resolve-tree-name-scroll-folder:second')));
await tester.pump();
expect(selected, {('first', 1)});
expect(tester.takeException(), isNull);
});
}
}
@@ -0,0 +1,45 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_delete_dialog.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('group deletion confirms affected downloads and files at width $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
bool? keepFiles;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Builder(
builder: (context) => shad.PrimaryButton(
onPressed: () async {
keepFiles = await showTaskDeleteDialog(
context,
taskCount: 1,
keepFiles: true,
memberCount: 65,
fileCount: 65,
);
},
child: const Text('Open delete dialog'),
),
),
),
);
await tester.tap(find.text('Open delete dialog'));
await tester.pumpAndSettle();
expect(find.text('Affects 65 downloads and 65 files.'), findsOneWidget);
expect(keepFiles, isNull);
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
expect(keepFiles, isTrue);
expect(tester.takeException(), isNull);
});
}
}
@@ -0,0 +1,163 @@
import 'package:flutter/widgets.dart';
import 'package:flutter/foundation.dart' show TargetPlatform, debugDefaultTargetPlatformOverride;
import 'package:gopeed/features/home/presentation/pages/home_page.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gopeed/api/model/task.dart' as api;
import 'package:gopeed/features/tasks/domain/task_record.dart';
import 'package:gopeed/features/tasks/application/tasks_controller.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_browser_dialog.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_card/task_card.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import 'package:flutter/material.dart' show Icons;
api.Task raw(
String id, {
String parent = '',
String status = 'running',
String directory = '/downloads/folder',
String name = 'file.txt',
}) => api.Task.fromJson({
'id': id,
'name': name,
if (parent.isNotEmpty)
'group': {
'id': parent,
'name': 'folder',
'path': directory.contains('\\') ? r'C:\Downloads\folder' : '/downloads/folder',
'createdAt': '2026-01-01T00:00:00Z',
},
'parentId': parent,
'protocol': 'http',
'status': status,
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-01T00:00:00Z',
'meta': {
'req': {'url': 'https://example.com/$id'},
'opts': {'path': directory, 'name': name, 'size': 100},
'res': {
'size': 100,
'files': [
{'name': name, 'size': 100},
],
},
},
'progress': {'used': 0, 'speed': 10, 'downloaded': 25, 'uploaded': 0, 'uploadSpeed': 0},
});
TaskRecord grouped({String name = 'first.txt'}) =>
taskRecordsFromApi([raw('child', parent: 'group', name: name)]).single;
class UpdatingTasks extends TasksController {
@override
Future<TasksState> build() async => TasksState(tasks: [grouped()]);
void rename() => state = AsyncData(TasksState(tasks: [grouped(name: 'first (1).txt')]));
}
void main() {
for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) {
testWidgets('tablet group expands its member tasks on $platform', (tester) async {
debugDefaultTargetPlatformOverride = platform;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
tester.view.physicalSize = const Size(1024, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ProviderScope(
overrides: [tasksControllerProvider.overrideWith(UpdatingTasks.new)],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: const HomePage(),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.tap(find.text('folder').first);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(TaskFileManagerView), findsNothing);
expect(find.byType(TaskCard), findsNWidgets(2));
expect(find.text('first.txt'), findsOneWidget);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
debugDefaultTargetPlatformOverride = null;
});
}
test('Windows backend root and child path are rebased on a POSIX client', () {
final record = taskRecordsFromApi([raw('child', parent: 'group', directory: r'C:\Downloads\folder\nested')]).single;
expect(record.files.single.path, 'nested');
expect(record.files.single.name, 'file.txt');
});
testWidgets('open group file browser updates renamed child from task polling', (tester) async {
tester.view.physicalSize = const Size(1100, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
late UpdatingTasks controller;
await tester.pumpWidget(
ProviderScope(
overrides: [tasksControllerProvider.overrideWith(() => controller = UpdatingTasks())],
child: shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Consumer(
builder: (context, ref, _) {
final tasks = ref.watch(tasksControllerProvider).value?.tasks;
return shad.PrimaryButton(
onPressed: tasks == null ? null : () => showTaskFileBrowserDialog(context, tasks.single),
child: const Text('Open'),
);
},
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('first.txt'), findsOneWidget);
controller.rename();
await tester.pumpAndSettle();
expect(find.text('first (1).txt'), findsOneWidget);
});
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('a running group with paused members offers resume on its card at $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final record = taskRecordsFromApi([
raw('running', parent: 'group'),
raw('paused', parent: 'group', status: 'pause'),
]).single;
expect(record.canResume, isTrue);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Center(
child: TaskCard(
task: record,
selected: false,
batchMode: false,
selectedInBatch: false,
onPressed: () {},
onToggleBatch: () {},
onPause: () {},
onResume: () {},
onDelete: () {},
),
),
),
);
await tester.pump();
expect(find.byIcon(Icons.play_arrow), findsOneWidget);
await tester.pumpWidget(const SizedBox());
});
}
}
@@ -0,0 +1,135 @@
import 'package:path/path.dart' as path;
import 'package:flutter/material.dart' show Icons;
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/api/api.dart' as api_client;
import 'package:gopeed/api/model/task.dart' as api;
import 'package:gopeed/core/network/gopeed/gopeed_transport.dart';
import 'package:gopeed/features/tasks/domain/task_record.dart';
import 'package:gopeed/features/tasks/presentation/widgets/task_file_manager.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
import 'package:flutter/services.dart';
class _Transport implements GopeedTransport {
@override
String join(String path) => 'https://server.example$path';
@override
Future<dynamic> request(
String path, {
String method = 'GET',
dynamic data,
Map<String, dynamic>? queryParameters,
}) async => null;
}
api.Task fixture(String id, {required String directory, String name = 'episode #2?.txt', bool folder = false}) =>
api.Task.fromJson({
'id': id,
'name': folder ? 'nested-torrent' : name,
'group': {'id': 'group', 'name': 'folder', 'path': '/downloads/folder', 'createdAt': '2026-01-01T00:00:00Z'},
'parentId': 'group',
'protocol': 'http',
'status': 'done',
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-01T00:00:00Z',
'meta': {
'req': {'url': 'https://example.com/file'},
'opts': {'path': directory, 'name': folder ? 'nested-torrent' : name, 'size': 100},
'res': {
'name': folder ? 'nested-torrent' : '',
'size': 100,
'files': [
{'name': name, 'path': folder ? 'subdir' : '', 'size': 100},
],
},
},
'progress': {'used': 0, 'speed': 0, 'downloaded': 100, 'uploadSpeed': 0, 'uploaded': 0},
});
void main() {
for (final folder in [false, true]) {
testWidgets('local group actions use member path folder=$folder', (tester) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const channel = MethodChannel('dev.fluttercommunity.plus/share');
List<dynamic>? shared;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
shared = (call.arguments as Map)['paths'] as List<dynamic>;
return 'dev.fluttercommunity.plus/share/unavailable';
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
final memberRoot = path.style == path.Style.windows ? r'D:\Other' : '/elsewhere';
final group = taskRecordsFromApi([fixture('child', directory: memberRoot, folder: folder)]).single;
final expected = folder
? path.join(memberRoot, 'nested-torrent', 'subdir', 'episode #2?.txt')
: path.join(memberRoot, 'episode #2?.txt');
expect(group.files.single.localPath, expected);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: TaskFileManagerView(task: group, webActions: false, desktopActions: false),
),
);
await tester.pumpAndSettle();
var current = '';
for (final segment in group.files.single.path.split('/').where((s) => s.isNotEmpty)) {
current += '/$segment';
await tester.tap(find.byKey(ValueKey('managed-file-$current')));
await tester.pumpAndSettle();
}
await tester.tap(find.byIcon(Icons.share_outlined));
await tester.pumpAndSettle();
expect(shared, [expected]);
expect(tester.takeException(), isNull);
});
}
for (final windows in [false, true]) {
for (final legacy in [false, true]) {
for (final folder in [false, true]) {
testWidgets('member URL windows=$windows legacy=$legacy folder=$folder', (tester) async {
tester.view.physicalSize = const Size(900, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
api_client.setTransportForTesting(_Transport());
String? launched;
const channel = MethodChannel('plugins.flutter.io/url_launcher');
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'launch') {
launched = (call.arguments as Map)['url'] as String;
return true;
}
return null;
});
addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null));
final dir = windows ? r'C:\Downloads\folder\nested' : '/downloads/folder/nested';
final group = taskRecordsFromApi([fixture('child #1', directory: dir, folder: folder)]).single;
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: TaskFileManagerView(task: group, webActions: true, desktopActions: false),
),
);
await tester.pumpAndSettle();
var current = '';
for (final segment in group.files.single.path.split('/').where((s) => s.isNotEmpty)) {
current += '/$segment';
await tester.tap(find.byKey(ValueKey('managed-file-$current')));
await tester.pumpAndSettle();
}
await tester.tap(find.byIcon(Icons.open_in_new));
await tester.pumpAndSettle();
final expected =
'https://server.example/fs/tasks/child%20%231/${folder ? 'subdir/' : ''}episode%20%232%3F.txt';
expect(launched, expected);
expect(tester.takeException(), isNull);
});
}
}
}
}
@@ -8,6 +8,72 @@ import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('group tree shows member progress and status at width $width', (tester) async {
tester.view.physicalSize = Size(width, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final task = TaskRecord(
id: 'group',
name: 'folder',
status: TaskStatus.downloading,
downloaded: '150 B',
total: '200 B',
progress: 0.75,
url: '',
storagePath: '/downloads/folder',
isGroup: true,
isFolder: true,
uploading: false,
files: const [
TaskFileNode(
path: '',
name: 'running.txt',
sizeBytes: 100,
downloadedBytes: 50,
resourceIndex: 0,
status: TaskStatus.downloading,
),
TaskFileNode(
path: '',
name: 'completed.txt',
sizeBytes: 100,
downloadedBytes: 100,
resourceIndex: 1,
status: TaskStatus.completed,
),
],
);
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(16),
child: TaskFileTree(task: task),
),
),
);
await tester.pump();
expect(tester.widget<TaskProgressBar>(find.byKey(const ValueKey('task-files-total-progress'))).value, 0.75);
final running = find.descendant(
of: find.byKey(const ValueKey('task-file-progress-file:0')),
matching: find.byType(shad.CircularProgressIndicator),
);
expect(tester.widget<shad.CircularProgressIndicator>(running).value, 0.5);
expect(
find.descendant(
of: find.byKey(const ValueKey('task-file-progress-file:1')),
matching: find.byIcon(shad.BootstrapIcons.checkCircleFill),
),
findsOneWidget,
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox());
});
}
for (final scenario in [
(protocol: api.Protocol.http, completed: false),
(protocol: api.Protocol.http, completed: true),
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:flutter/material.dart' show MaterialApp, Tooltip;
import 'package:flutter/widgets.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/shared/widgets/app_tooltip.dart';
@@ -16,6 +17,35 @@ void main() {
expect(tester.widget<Tooltip>(find.byType(Tooltip)).message, 'Details');
});
testWidgets('focusable status tooltip exposes its message on keyboard focus', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: AppTooltip(message: 'Waiting for resolution', focusable: true, child: SizedBox.square(dimension: 24)),
),
);
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.pumpAndSettle();
expect(find.text('Waiting for resolution'), findsOneWidget);
FocusManager.instance.primaryFocus?.unfocus();
await tester.pumpAndSettle();
expect(find.text('Waiting for resolution'), findsNothing);
});
testWidgets('status tooltip can be read with a touch long press', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Center(
child: AppTooltip(message: 'Resolution failed', focusable: true, child: SizedBox.square(dimension: 24)),
),
),
);
await tester.longPress(find.byType(AppTooltip));
await tester.pump();
expect(find.text('Resolution failed'), findsOneWidget);
Tooltip.dismissAllToolTips();
await tester.pumpAndSettle();
});
test('product source does not instantiate Tooltip directly', () {
final violations = <String>[];
final tooltipConstructor = RegExp(r'\b(?:\w+\.)?Tooltip\s*\(');
@@ -0,0 +1,161 @@
import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gopeed/shared/theme/app_theme.dart';
import 'package:gopeed/shared/widgets/file_tree_view.dart';
import 'package:gopeed/shared/widgets/virtual_tree_view.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' as shad;
void main() {
testWidgets('clearing an explicit sort restores mixed file and directory input order', (tester) async {
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: FileTreeView<int>(
items: const [
FileTreeItem(key: 'z', path: '', name: 'z.txt', size: 3, data: 0),
FileTreeItem(key: 'nested', path: 'folder', name: 'child.txt', size: 2, data: 1),
FileTreeItem(key: 'a', path: '', name: 'a.txt', size: 1, data: 2),
],
),
),
);
await tester.pumpAndSettle();
List<String> labels() => tester
.widget<VirtualTreeView<FileTreeNode<int>>>(find.byType(VirtualTreeView<FileTreeNode<int>>))
.nodes
.cast<shad.TreeItemNode<FileTreeNode<int>>>()
.map((node) => node.data.label)
.toList();
expect(labels(), ['z.txt', 'folder', 'a.txt']);
final sort = find.byKey(const ValueKey('file-tree-sort-name-asc'));
await tester.tap(sort);
await tester.pump();
expect(labels(), ['folder', 'a.txt', 'z.txt']);
await tester.tap(sort);
await tester.pump();
expect(labels(), ['folder', 'z.txt', 'a.txt']);
await tester.tap(sort);
await tester.pump();
expect(labels(), ['z.txt', 'folder', 'a.txt']);
});
for (final width in [390.0, 700.0, 1024.0]) {
testWidgets('long directory display labels remain readable at $width', (tester) async {
tester.view.physicalSize = Size(width, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final name = List.filled(12, 'long-resource-title').join('-');
final nestedName = List.filled(14, 'nested-directory-title').join('-');
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: FileTreeView<int>(
items: const [FileTreeItem(key: 'child', path: 'source-0/nested', name: 'a.txt', size: 10, data: 0)],
directoryLabels: {'source-0': name, 'source-0/nested': nestedName},
),
),
);
await tester.pumpAndSettle();
for (final label in [name, nestedName]) {
final paragraph = tester.renderObject<RenderParagraph>(find.text(label));
expect(paragraph.size.width, greaterThanOrEqualTo(paragraph.getMaxIntrinsicWidth(double.infinity)));
}
expect(find.byKey(const ValueKey('file-tree-horizontal-scrollbar')), findsOneWidget);
final root = find.byKey(const ValueKey('file-tree-name-scroll-folder:source-0'));
final before = tester.getTopLeft(find.text(name));
final trailing = tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0')));
await tester.drag(root, const Offset(-120, 0));
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.text(name)).dx, lessThan(before.dx));
expect(tester.getRect(find.byKey(const ValueKey('file-tree-trailing-folder:source-0'))), trailing);
expect(tester.takeException(), isNull);
});
}
testWidgets('empty directory root keeps its identity and expansion when files arrive', (tester) async {
late StateSetter update;
var items = [
const FileTreeItem(key: 'root', path: 'root', name: 'pending', size: 0, data: -1, directoryOnly: true),
];
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: StatefulBuilder(
builder: (context, setState) {
update = setState;
return FileTreeView<int>(
items: items,
directoryLabels: const {'root': 'Source'},
initialExpandedDepth: 1,
iconWidgetBuilder: (_, node) => node.isFolder ? const Text('status') : const Text('file'),
);
},
),
),
);
await tester.pumpAndSettle();
expect(find.text('Source'), findsOneWidget);
expect(find.text('pending'), findsNothing);
expect(find.text('status'), findsOneWidget);
update(() => items = [const FileTreeItem(key: 'child', path: 'root', name: 'payload.txt', size: 12, data: 0)]);
await tester.pumpAndSettle();
expect(find.text('Source'), findsOneWidget);
expect(find.text('payload.txt'), findsOneWidget);
expect(find.text('file'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('file-tree-node-expand-folder:root')));
await tester.pumpAndSettle();
expect(find.text('payload.txt'), findsNothing);
expect(find.text('Source'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('live tree updates preserve a collapse requested during its animation', (tester) async {
late StateSetter update;
var items = const [
FileTreeItem(key: 'first', path: 'bundle', name: 'first.txt', size: 1, data: 0),
FileTreeItem(key: 'second', path: 'bundle', name: 'second.txt', size: 1, data: 1),
FileTreeItem(key: 'pending', path: 'source-b', name: 'source B', size: 0, data: 2, directoryOnly: true),
];
await tester.pumpWidget(
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: StatefulBuilder(
builder: (context, setState) {
update = setState;
return FileTreeView<int>(
items: items,
directoryLabels: const {'bundle': 'bundle', 'source-b': 'source B'},
initialExpandedDepth: 1,
);
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('file-tree-node-expand-folder:bundle')));
await tester.pump(const Duration(milliseconds: 50));
update(() {
items = const [
FileTreeItem(key: 'first', path: 'bundle', name: 'first.txt', size: 1, data: 0),
FileTreeItem(key: 'second', path: 'bundle', name: 'second.txt', size: 1, data: 1),
FileTreeItem(key: 'arrived', path: '', name: 'later.txt', size: 1, data: 3),
];
});
await tester.pump();
await tester.pump(const Duration(milliseconds: 250));
final tree = tester.widget<VirtualTreeView<FileTreeNode<int>>>(find.byType(VirtualTreeView<FileTreeNode<int>>));
final bundle = tree.nodes.cast<shad.TreeItemNode<FileTreeNode<int>>>().first;
expect(bundle.data.label, 'bundle');
expect(bundle.expanded, isFalse);
expect(find.text('later.txt'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}
+45 -18
View File
@@ -1,3 +1,7 @@
import 'package:gopeed/features/tasks/application/pending_download.dart';
import 'package:gopeed/api/model/options.dart' as preview_options;
import 'package:gopeed/api/model/request.dart' as preview_request;
import 'package:gopeed/api/model/resolve_result.dart' as preview_resolve;
import 'dart:async';
import 'dart:io';
@@ -30,6 +34,7 @@ import 'package:gopeed/app/application/app_notification_controller.dart';
import 'package:gopeed/app/application/app_platform_controller.dart';
import 'package:gopeed/app/application/app_runtime_controller.dart';
import 'package:gopeed/api/model/create_task.dart';
import 'package:gopeed/api/model/create_task_batch.dart';
import 'package:gopeed/api/model/install_extension.dart';
import 'package:gopeed/features/extensions/application/pending_extension_install.dart';
import 'package:gopeed/api/model/downloader_config.dart';
@@ -128,9 +133,9 @@ void main() {
),
),
)
..bind(GopeedMethods.createTask, (_) {
..bind(GopeedMethods.createTaskBatch, (_) {
created++;
return 'new-task';
return CreateTaskBatchResult(taskIds: ['new-task']);
});
final container = ProviderContainer(
overrides: [
@@ -3329,7 +3334,7 @@ void main() {
shad.ShadcnApp(
theme: AppTheme.light(),
materialTheme: AppTheme.materialLight(),
home: ResolveFileTree(
home: _singleResolveTree(
files: [
FileInfo(path: '', name: 'root.txt', size: 1),
FileInfo(path: 'folder', name: 'first.txt', size: 2),
@@ -3349,7 +3354,7 @@ void main() {
expect(find.text('third.txt'), findsNothing);
expect(selected, [0, 1, 2, 3]);
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:folder/nested')));
await tester.tap(find.byKey(const ValueKey('resolve-tree-node-expand-folder:source/folder/nested')));
await tester.pumpAndSettle();
expect(find.text('second.txt'), findsOneWidget);
expect(find.text('deeper'), findsOneWidget);
@@ -3362,7 +3367,7 @@ void main() {
await tester.tap(find.byKey(const ValueKey('resolve-tree-expand-toggle')));
await tester.pumpAndSettle();
expect(find.text('first.txt'), findsNothing);
expect(find.text('root.txt'), findsOneWidget);
expect(find.text('root.txt'), findsNothing);
expect(selected, [0, 1, 2, 3]);
expect(tester.takeException(), isNull);
});
@@ -3376,7 +3381,7 @@ void main() {
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(
child: _singleResolveTree(
files: [
FileInfo(path: 'videos', name: 'clip.mp4', size: 0),
FileInfo(path: 'docs', name: 'notes.txt', size: 0),
@@ -3446,30 +3451,30 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('clip.mp4'), findsOneWidget);
final videosExpand = find.byKey(const ValueKey('resolve-tree-node-expand-folder:videos'));
final videosExpand = find.byKey(const ValueKey('resolve-tree-node-expand-folder:source/videos'));
expect(videosExpand, findsOneWidget);
expect(find.byKey(const ValueKey('resolve-tree-node-expand-file:0')), findsOneWidget);
expect(find.byKey(const ValueKey('resolve-tree-node-expand-file:source:0')), findsOneWidget);
await tester.tap(videosExpand);
await tester.pumpAndSettle();
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isFalse);
expect(find.text('clip.mp4').hitTestable(), findsNothing);
expect(find.text('videos').hitTestable(), findsOneWidget);
expect(selected, [0, 1, 2]);
await tester.tap(find.text('videos'));
await tester.pump();
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isFalse);
expect(selected, [1, 2]);
await tester.tap(find.text('videos'));
await tester.pump();
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isFalse);
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isFalse);
expect(selected, [0, 1, 2]);
await tester.tap(videosExpand);
await tester.pumpAndSettle();
expect(tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first.expanded, isTrue);
expect((tester.widget<VirtualTreeView<dynamic>>(treeFinder).nodes.first as shad.TreeItemNode<dynamic>).children.first.expanded, isTrue);
expect(find.text('clip.mp4'), findsOneWidget);
BoxDecoration sortDecoration(String key) {
@@ -3520,7 +3525,7 @@ void main() {
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(
child: _singleResolveTree(
files: [
FileInfo(path: '', name: 'clip.mp4', size: 100),
FileInfo(path: '', name: 'song.mp3', size: 200),
@@ -3569,7 +3574,7 @@ void main() {
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(
child: _singleResolveTree(
files: [FileInfo(name: longName, size: 1024)],
initialSelection: const [0],
onSelectionChanged: (_) {},
@@ -3580,7 +3585,7 @@ void main() {
await tester.pumpAndSettle();
final horizontalScroll = find.byKey(const ValueKey('resolve-tree-horizontal-scroll'));
final trailing = find.byKey(const ValueKey('resolve-tree-trailing-file:0'));
final trailing = find.byKey(const ValueKey('resolve-tree-trailing-file:source:0'));
expect(find.byKey(const ValueKey('resolve-tree-horizontal-scrollbar')), findsOneWidget);
expect(horizontalScroll, findsOneWidget);
expect(trailing, findsOneWidget);
@@ -3600,7 +3605,7 @@ void main() {
position.jumpTo(0);
await tester.pumpAndSettle();
final nameArea = find.byKey(const ValueKey('resolve-tree-name-scroll-file:0'));
final nameArea = find.byKey(const ValueKey('resolve-tree-name-scroll-file:source:0'));
await tester.drag(nameArea, const Offset(-100, 0));
await tester.pumpAndSettle();
expect(position.pixels, greaterThan(0));
@@ -3622,7 +3627,7 @@ void main() {
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(
child: _singleResolveTree(
files: [FileInfo(name: shortName, size: 1024)],
initialSelection: const [0],
onSelectionChanged: (_) {},
@@ -3650,7 +3655,7 @@ void main() {
materialTheme: AppTheme.materialLight(),
home: Padding(
padding: const EdgeInsets.all(24),
child: ResolveFileTree(files: files, initialSelection: const [], onSelectionChanged: (_) {}),
child: _singleResolveTree(files: files, initialSelection: const [], onSelectionChanged: (_) {}),
),
),
);
@@ -6402,3 +6407,25 @@ api_task.Task _apiTransferTask({
updatedAt: DateTime.fromMillisecondsSinceEpoch(0),
);
}
Widget _singleResolveTree({
required List<FileInfo> files,
required List<int> initialSelection,
required ValueChanged<List<int>> onSelectionChanged,
}) {
final source =
PendingDownload(
id: 'source',
request: preview_request.Request(url: 'https://example.com'),
options: preview_options.Options(),
)
..result = preview_resolve.ResolveResult(
res: Resource(name: 'source', files: files),
)
..selected.addAll(initialSelection);
WidgetsBinding.instance.addPostFrameCallback((_) => onSelectionChanged(initialSelection));
return ResolveFileTree(
sources: [source],
onSelectionChanged: (keys) => onSelectionChanged(keys.map((key) => key.$2).toList()..sort()),
);
}