feat: implement task patching functionality (#1267)

This commit is contained in:
Levi authored and GitHub committed 2026-02-06 23:42:57 +08:00
1 parent 6de63c3f63
commit 3eb6df55df
22 files changed
+1281 -132

No files matched your search

@@ -59,6 +59,14 @@ final allTrackerSubscribeUrlCdns = {
v: githubMirrorUrls(v, MirrorType.githubSource)
};
/// Represents a task that is pending URL update via listen mode.
class PendingUpdateTask {
final String id;
final String name;
PendingUpdateTask({required this.id, required this.name});
}
class AppController extends GetxController with WindowListener, TrayListener {
static StartConfig? _defaultStartConfig;
@@ -72,6 +80,10 @@ class AppController extends GetxController with WindowListener, TrayListener {
final runningPort = 0.obs;
final downloaderConfig = DownloaderConfig().obs;
/// The task that is pending URL update via listen mode.
/// Stored here in AppController to persist across page navigations.
final pendingUpdateTask = Rxn<PendingUpdateTask>();
late AppLinks _appLinks;
StreamSubscription<Uri>? _linkSubscription;
@@ -320,7 +332,7 @@ class AppController extends GetxController with WindowListener, TrayListener {
final silent = jsonMeta['silent'] as bool? ?? false;
final params = await ctx.readText();
final createTaskParams = CreateTask.fromJson(_decodeParams(params));
if (!silent) {
if (!silent || pendingUpdateTask.value != null) {
await windowManager.show();
_handleToCreate0(createTaskParams);
} else {
@@ -836,6 +836,26 @@ class CreateView extends GetView<CreateController> {
: _urlController.text.trim();
final urls = Util.textToLines(submitUrl);
// Check if there is a pending update task (only for single URL)
if (urls.length == 1) {
final appController = Get.find<AppController>();
final pendingTask = appController.pendingUpdateTask.value;
if (pendingTask != null) {
final shouldUpdate = await _showPendingUpdateDialog(
pendingTask.id, pendingTask.name);
if (shouldUpdate == true) {
// Update the pending task instead of creating a new one
await _updatePendingTask(pendingTask.id, urls.first);
return;
} else if (shouldUpdate == null) {
// User cancelled, don't create task either
return;
}
// shouldUpdate == false, continue to create new task
}
}
// Add url to the history
if (!isWebFileChosen) {
for (final url in urls) {
@@ -890,6 +910,72 @@ class CreateView extends GetView<CreateController> {
}
}
/// Shows a dialog to ask if user wants to update pending task or create new
/// Returns true to update, false to create new, null if cancelled
Future<bool?> _showPendingUpdateDialog(String taskId, String taskName) async {
return showDialog<bool>(
context: Get.context!,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text('pendingUpdateFound'.tr),
content: Text('pendingUpdateConfirm'.trParams({'name': taskName})),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('pendingUpdateNo'.tr),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('pendingUpdateYes'.tr),
),
],
),
);
}
/// Updates the pending task with new URL and headers
Future<void> _updatePendingTask(String taskId, String newUrl) async {
try {
// Build headers from current form
final headers = <String, String>{};
for (final c in _httpHeaderControllers) {
final key = c.name.text.trim();
final value = c.value.text.trim();
if (key.isNotEmpty) {
headers[key] = value;
}
}
// Build ReqExtraHttp
final reqExtra = ReqExtraHttp(header: headers);
// Create patch request
final patchData = ResolveTask(
req: Request(
url: newUrl,
extra: reqExtra.toJson(),
proxy: parseProxy(),
skipVerifyCert: _skipVerifyCertController.value,
),
);
await patchTask(taskId, patchData);
await continueTask(taskId);
// Clear pending update state
final appController = Get.find<AppController>();
appController.pendingUpdateTask.value = null;
Get.rootDelegate.offNamed(Routes.TASK);
} catch (e) {
showErrorMessage(e);
}
}
RequestProxy? parseProxy() {
if (controller.proxyConfig.value?.mode == RequestProxyMode.custom) {
return RequestProxy()
+255 -44
View File
@@ -1,9 +1,11 @@
import 'package:contextmenu_plus/contextmenu_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_context_menu/flutter_context_menu.dart';
import 'package:get/get.dart';
import 'package:styled_widget/styled_widget.dart';
import '../../api/api.dart';
import '../../api/model/request.dart';
import '../../api/model/resolve_task.dart';
import '../../api/model/task.dart';
import '../../util/message.dart';
import '../../util/util.dart';
@@ -116,6 +118,162 @@ class BuildTaskListView extends GetView {
));
}
Future<void> showUpdateUrlDialog(BuildContext context, Task task) async {
final urlController = TextEditingController(text: task.meta.req.url);
final headerControllers =
<MapEntry<TextEditingController, TextEditingController>>[];
// Initialize with existing headers if available
if (task.meta.req.extra != null && task.meta.req.extra is Map) {
final extra = task.meta.req.extra as Map<String, dynamic>;
if (extra.containsKey('header') && extra['header'] is Map) {
final headers = extra['header'] as Map<String, dynamic>;
for (final entry in headers.entries) {
headerControllers.add(MapEntry(
TextEditingController(text: entry.key),
TextEditingController(text: entry.value.toString()),
));
}
}
}
// Add one empty header row by default if none exists
if (headerControllers.isEmpty) {
headerControllers.add(MapEntry(
TextEditingController(),
TextEditingController(),
));
}
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Text('updateUrl'.tr),
content: SizedBox(
width: 400,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: urlController,
decoration: InputDecoration(
labelText: 'downloadLink'.tr,
hintText: 'updateUrlDialogHint'.tr,
icon: const Icon(Icons.link),
),
),
const SizedBox(height: 16),
Text('httpHeader'.tr,
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...headerControllers.asMap().entries.map((entry) {
final index = entry.key;
final controllers = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(
child: TextField(
controller: controllers.key,
decoration: InputDecoration(
hintText: 'httpHeaderName'.tr,
isDense: true,
),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: controllers.value,
decoration: InputDecoration(
hintText: 'httpHeaderValue'.tr,
isDense: true,
),
),
),
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
setState(() {
headerControllers.add(MapEntry(
TextEditingController(),
TextEditingController(),
));
});
},
iconSize: 20,
),
IconButton(
icon: const Icon(Icons.remove),
onPressed: () {
if (headerControllers.length <= 1) {
return;
}
setState(() {
headerControllers.removeAt(index);
});
},
iconSize: 20,
),
],
),
);
}),
],
),
),
),
actions: [
TextButton(
child: Text('cancel'.tr),
onPressed: () => Get.back(),
),
TextButton(
child: Text('confirm'.tr),
onPressed: () async {
try {
// Build headers map
final headers = <String, String>{};
for (final entry in headerControllers) {
final key = entry.key.text.trim();
final value = entry.value.text.trim();
if (key.isNotEmpty) {
headers[key] = value;
}
}
// Build ReqExtraHttp
final reqExtra = ReqExtraHttp(header: headers);
// Create patch request
final patchData = ResolveTask(
req: Request(
url: urlController.text.trim(),
extra: reqExtra.toJson(),
),
);
await patchTask(task.id, patchData);
await continueTask(task.id);
Get.back();
} catch (e) {
showErrorMessage(e);
}
},
),
],
);
},
),
);
}
List<Widget> buildActions() {
final list = <Widget>[];
if (isDone()) {
@@ -159,29 +317,6 @@ class BuildTaskListView extends GetView {
return list;
}
Widget buildContextItem(IconData icon, String label, Function() onTap,
{bool enabled = true}) {
return ListTile(
dense: true,
visualDensity: const VisualDensity(vertical: -1),
minLeadingWidth: 12,
leading: Icon(icon, size: 18),
title: Text(label,
style: const TextStyle(
fontWeight: FontWeight.bold, // Make the text bold
)),
onTap: () async {
Get.back();
try {
await onTap();
} catch (e) {
showErrorMessage(e);
}
},
enabled: enabled,
);
}
double getProgress() {
final totalSize = task.meta.res?.size ?? 0;
return totalSize <= 0 ? 0 : task.progress.downloaded / totalSize;
@@ -258,6 +393,7 @@ class BuildTaskListView extends GetView {
}
}
final appController = Get.find<AppController>();
final taskController = Get.find<TaskController>();
final taskListController = taskController.tabIndex.value == 0
? Get.find<TaskDownloadingController>()
@@ -268,19 +404,24 @@ class BuildTaskListView extends GetView {
.where((id) => tasks.any((task) => task.id == id))
.toList();
return ContextMenuArea(
width: 140,
builder: (context) => [
buildContextItem(Icons.checklist, 'selectAll'.tr, () {
// Build context menu entries
final contextMenuEntries = <ContextMenuEntry>[
MenuItem(
icon: const Icon(Icons.checklist),
label: Text('selectAll'.tr),
onSelected: (_) {
if (tasks.isEmpty) return;
if (selectedTaskIds.isNotEmpty) {
taskListController.selectedTaskIds([]);
} else {
taskListController.selectedTaskIds(tasks.map((e) => e.id).toList());
}
}),
buildContextItem(Icons.check, 'select'.tr, () {
},
),
MenuItem(
icon: const Icon(Icons.check),
label: Text('select'.tr),
onSelected: (_) {
if (isSelect()) {
taskListController.selectedTaskIds(taskListController
.selectedTaskIds
@@ -290,36 +431,89 @@ class BuildTaskListView extends GetView {
taskListController.selectedTaskIds(
[...taskListController.selectedTaskIds, task.id]);
}
}),
const Divider(
indent: 8,
endIndent: 8,
),
buildContextItem(Icons.play_arrow, 'continue'.tr, () async {
},
),
const MenuDivider(),
MenuItem(
icon: const Icon(Icons.play_arrow),
label: Text('continue'.tr),
enabled: !isDone() && !isRunning(),
onSelected: (_) async {
try {
await continueAllTasks(filterSelectedTaskIds(
{...taskListController.selectedTaskIds, task.id}));
} finally {
taskListController.selectedTaskIds([]);
}
}, enabled: !isDone() && !isRunning()),
buildContextItem(Icons.pause, 'pause'.tr, () async {
},
),
MenuItem(
icon: const Icon(Icons.pause),
label: Text('pause'.tr),
enabled: !isDone() && isRunning(),
onSelected: (_) async {
try {
await pauseAllTasks(filterSelectedTaskIds(
{...taskListController.selectedTaskIds, task.id}));
} finally {
taskListController.selectedTaskIds([]);
}
}, enabled: !isDone() && isRunning()),
buildContextItem(Icons.delete, 'delete'.tr, () async {
},
),
MenuItem(
icon: const Icon(Icons.delete),
label: Text('delete'.tr),
onSelected: (_) async {
try {
await showDeleteDialog(filterSelectedTaskIds(
{...taskListController.selectedTaskIds, task.id}));
} finally {
taskListController.selectedTaskIds([]);
}
}),
],
},
),
// Update URL submenu - only enabled for HTTP tasks in pause or error status
const MenuDivider(),
MenuItem.submenu(
icon: const Icon(Icons.link),
label: Text('updateUrl'.tr),
enabled: task.protocol == Protocol.http &&
(task.status == Status.pause || task.status == Status.error),
items: [
MenuItem(
icon: const Icon(Icons.edit_note),
label: Text('updateUrlManual'.tr),
onSelected: (_) async {
await showUpdateUrlDialog(context, task);
},
),
MenuItem(
icon: Icon(appController.pendingUpdateTask.value?.id == task.id
? Icons.cancel
: Icons.sensors),
label: Text(appController.pendingUpdateTask.value?.id == task.id
? 'updateUrlCancelListen'.tr
: 'updateUrlListen'.tr),
onSelected: (_) {
if (appController.pendingUpdateTask.value?.id == task.id) {
appController.pendingUpdateTask.value = null;
} else {
appController.pendingUpdateTask.value =
PendingUpdateTask(id: task.id, name: task.name);
}
},
),
],
),
];
final contextMenu = ContextMenu(
entries: contextMenuEntries,
padding: const EdgeInsets.all(8.0),
);
return ContextMenuRegion(
contextMenu: contextMenu,
child: Obx(
() => Card(
elevation: 4.0,
@@ -344,7 +538,24 @@ class BuildTaskListView extends GetView {
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(task.name),
title: Row(
children: [
Expanded(child: Text(task.name)),
// Show pending update indicator
if (appController.pendingUpdateTask.value?.id ==
task.id)
Tooltip(
message: 'updateUrlListeningTip'.tr,
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(Icons.hearing,
size: 16,
color:
Theme.of(context).colorScheme.primary),
),
),
],
),
leading: Icon(
fileIcon(task.name,
isFolder: isFolderTask(),