дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
fix(tasks): Make every background task cancellable, queued or running (#177)
Background tasks often couldn't be cancelled, or cancelling didn't stop them: - A cancel that arrived before the backend registered the request was dropped (build while saving files, multi-repository fetch/pull between repositories). The host now remembers early cancels (bounded, 2 min), and request ids start at a random value per page. - There was no queue and no common cancel path: backend-reported tasks (LSP progress, deployment transfers) couldn't be cancelled, and tool installs, VCS operations and update checks had no cancel button in Background Tasks. - Only the direct child process was killed, and JDK / tool archives were extracted without checking for cancellation. Tool installs and JDK downloads now share a downloads queue; cancelling a queued task removes it without starting any work. Every task cancels through one path that shows "Cancelling…" and then removes it; tasks that can't be interrupted show a disabled cancel button with the reason. IdeEvent::Task carries `cancellable` with a cancel_task command, cancellable LSP progress sends window/workDoneProgress/cancel, installers, curl and tar run in their own process group and are killed as a group, and extraction checks for cancellation between entries. Claude-Session: https://claude.ai/code/session_01Dq6jQQn8ZhRfkkyj3PRutA
Цей коміт міститься в:
1 джерело
c6621c5c36
коміт
1c46a1cd04
40 files changed
+888
-148
No files matched your search
@@ -56,6 +56,11 @@ export const commands = {
|
||||
*/
|
||||
projectRoots: () => typedError<ProjectRoots, IdeError>(__TAURI_INVOKE("project_roots")),
|
||||
cancelRequest: (id: RequestId) => __TAURI_INVOKE<void>("cancel_request", { id }),
|
||||
/**
|
||||
* Stops a background task the backend reported with `cancellable` set; false when it
|
||||
* has ended or can't be cancelled.
|
||||
*/
|
||||
cancelTask: (id: TaskId) => __TAURI_INVOKE<boolean>("cancel_task", { id }),
|
||||
/** Routes frontend logs into the backend `tracing` pipeline. */
|
||||
logFrontend: (level: LogLevel, message: string) => __TAURI_INVOKE<void>("log_frontend", { level, message }),
|
||||
/** Streams the project's agent events to this window; returns every task. */
|
||||
@@ -3237,7 +3242,9 @@ export type IdeEvent =
|
||||
/** Full replacement of diagnostics for one file from one producer. */
|
||||
{ type: "diagnostics"; path: IpcPath; source: string; items: Diagnostic[] } |
|
||||
/** Progress of a background task; `fraction` is `None` when indeterminate. */
|
||||
{ type: "task"; id: TaskId; title: string; message: string | null; fraction: number | null; done: boolean } |
|
||||
{ type: "task"; id: TaskId; title: string; message: string | null; fraction: number | null; done: boolean;
|
||||
/** The UI may stop it with `cancel_task(id)` (see [`crate::task`]). */
|
||||
cancellable: boolean } |
|
||||
/** Project indexing state changed (dumb mode on/off). */
|
||||
{ type: "indexState"; indexing: boolean; files: number; symbols: number } |
|
||||
/** Git status / branch information should be refreshed. */
|
||||
|
||||
@@ -39,9 +39,13 @@ export function checkForUpdates(): Promise<void> {
|
||||
|
||||
async function runCheck() {
|
||||
const title = t("app.update.checking");
|
||||
upsertTask({ id: CHECK_TASK_ID, title, message: null, fraction: null, done: false });
|
||||
// The check only reads: cancelling drops its answer (the request finishes on its own).
|
||||
let abandon = () => {};
|
||||
const abandoned = new Promise<"cancelled">((resolve) => (abandon = () => resolve("cancelled")));
|
||||
upsertTask({ id: CHECK_TASK_ID, title, message: null, fraction: null, done: false, cancel: () => abandon() });
|
||||
try {
|
||||
const r = await call(commands.appCheckUpdate());
|
||||
const r = await Promise.race([call(commands.appCheckUpdate()), abandoned]);
|
||||
if (r === "cancelled") return;
|
||||
if (!r.enabled) {
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
@@ -122,6 +126,8 @@ async function runInstall(version: string) {
|
||||
fraction,
|
||||
done: false,
|
||||
cancel: p.type === "installing" ? undefined : cancel,
|
||||
// Replacing the app happens in one step that must not be cut short.
|
||||
uncancellable: p.type === "installing" ? t("app.update.installUncancellable") : undefined,
|
||||
});
|
||||
};
|
||||
// The notification's action is spent: progress lives in the status bar from here.
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { downloadJdkActions } from "@/platform/jdk";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { removeTask, upsertTask } from "@/workbench/tasks";
|
||||
import { cancelTask, removeTask, upsertTask, useTasks } from "@/workbench/tasks";
|
||||
import { profilesList, type GradleRunOptions, type MavenRunOptions } from "./model";
|
||||
|
||||
export interface BuildSettings {
|
||||
@@ -231,7 +231,10 @@ export async function syncTool(tool: BuildTool): Promise<void> {
|
||||
|
||||
export function cancelSync(tool: BuildTool) {
|
||||
const s = useBuild.getState().syncing[tool];
|
||||
if (s) void commands.cancelRequest(s.request);
|
||||
if (!s) return;
|
||||
const taskId = `build-sync:${tool}`;
|
||||
if (useTasks.getState().tasks.some((t) => t.id === taskId && t.cancel)) cancelTask(taskId);
|
||||
else void commands.cancelRequest(s.request);
|
||||
}
|
||||
|
||||
export function resetBuild() {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { maybeEditorService, notifications, toolWindowService } from "@/workbench/contributions";
|
||||
import { removeTask, upsertTask } from "@/workbench/tasks";
|
||||
import { cancelTask, removeTask, upsertTask, useTasks } from "@/workbench/tasks";
|
||||
import { appendOutput, finishSummary, firstError, messageFiles, type BuildStatus, type ConsoleLine } from "./model";
|
||||
|
||||
export const BUILD_TASK_ID = "build:compile";
|
||||
@@ -248,7 +248,10 @@ function showFailure() {
|
||||
|
||||
export function stopBuild() {
|
||||
const { request } = useBuildRun.getState();
|
||||
if (request != null) backend.cancel(request);
|
||||
if (request == null) return;
|
||||
// Through the task, so its progress says "Cancelling…" until the build has stopped.
|
||||
if (useTasks.getState().tasks.some((t) => t.id === BUILD_TASK_ID && t.cancel)) cancelTask(BUILD_TASK_ID);
|
||||
else backend.cancel(request);
|
||||
}
|
||||
|
||||
/** Runs the last build again (the tool window's rerun button). */
|
||||
|
||||
@@ -70,6 +70,22 @@ describe("tools store", () => {
|
||||
expect(useTools.getState().tools.find((t) => t.name === "gopls")?.status).toBe("missing");
|
||||
});
|
||||
|
||||
it("installs one at a time; one cancelled while it waits never starts", async () => {
|
||||
backend.stepMs = 10;
|
||||
await refreshTools();
|
||||
const install = vi.spyOn(backend, "install");
|
||||
const first = installTool("gopls");
|
||||
const queued = installTool("vtsls");
|
||||
expect(useTasks.getState().tasks.find((t) => t.id === "tools:vtsls")).toMatchObject({ queued: true });
|
||||
cancelInstall("vtsls");
|
||||
expect(useTasks.getState().tasks.find((t) => t.id === "tools:vtsls")).toBeUndefined();
|
||||
expect(await queued).toBeNull();
|
||||
expect(useTools.getState().logs["vtsls"]?.error).toBe("Cancelled");
|
||||
expect((await first)?.status).toBe("installed");
|
||||
expect(install.mock.calls.map((c) => c[0])).toEqual(["gopls"]);
|
||||
expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("uninstalls", async () => {
|
||||
await refreshTools();
|
||||
expect(await uninstallTool("rust-analyzer")).toBe(true);
|
||||
|
||||
@@ -17,10 +17,12 @@ import {
|
||||
requiredJdk,
|
||||
TEMURIN,
|
||||
type DownloadJdkArgs,
|
||||
type JdkDownload,
|
||||
} from "@/platform/jdk";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { upsertTask } from "@/workbench/tasks";
|
||||
import { queueTask, upsertTask } from "@/workbench/tasks";
|
||||
import { DOWNLOAD_QUEUE } from "./store";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
const JAVA_SETTINGS = "languages.java";
|
||||
@@ -42,16 +44,19 @@ export function downloadJdkFor(args: DownloadJdkArgs): Promise<Sdk | null> {
|
||||
const title = `Downloading JDK ${args.version}`;
|
||||
const taskId = `jdk-download:${args.version}`;
|
||||
const key = `jdk-download-${args.version}`;
|
||||
const cancel = () => job.cancel();
|
||||
const job = downloadJdk(TEMURIN, args.version, null, (e) => {
|
||||
const message = e.type === "line" ? e.text : e.message;
|
||||
const fraction = e.type === "progress" ? e.fraction : null;
|
||||
upsertTask({ id: taskId, title, message, fraction, done: false, cancel });
|
||||
});
|
||||
upsertTask({ id: taskId, title, message: "Eclipse Temurin", fraction: null, done: false, cancel });
|
||||
const p = (async () => {
|
||||
try {
|
||||
const sdk = await job.promise;
|
||||
// Waits for the downloads ahead of it; cancelling it there means it never starts.
|
||||
const sdk = await queueTask(DOWNLOAD_QUEUE, { id: taskId, title }, () => {
|
||||
const cancel = () => job.cancel();
|
||||
upsertTask({ id: taskId, title, message: "Eclipse Temurin", fraction: null, done: false, cancel });
|
||||
const job: JdkDownload = downloadJdk(TEMURIN, args.version, null, (e) => {
|
||||
const message = e.type === "line" ? e.text : e.message;
|
||||
const fraction = e.type === "progress" ? e.fraction : null;
|
||||
upsertTask({ id: taskId, title, message, fraction, done: false, cancel });
|
||||
});
|
||||
return job.promise;
|
||||
});
|
||||
if (args.languageServer) await chooseForLanguageServer(sdk.path);
|
||||
notifications().notify({
|
||||
group: "tools",
|
||||
|
||||
@@ -4,11 +4,13 @@ import type { RequestId, ToolInfo } from "@/bindings";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { upsertTask } from "@/workbench/tasks";
|
||||
import { cancelTask, queueTask, upsertTask, useTasks } from "@/workbench/tasks";
|
||||
import { toolsBackend } from "./backend";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
export const SETTINGS_PAGE_ID = "languages.servers";
|
||||
/** Tool installs and JDK downloads take turns (one network download / package manager at a time). */
|
||||
export const DOWNLOAD_QUEUE = "downloads";
|
||||
const MAX_LOG_LINES = 400;
|
||||
|
||||
export interface InstallJob {
|
||||
@@ -100,24 +102,25 @@ export function installTool(name: string, opts: { notificationKey?: string } = {
|
||||
const taskId = `tools:${name}`;
|
||||
const key = opts.notificationKey ?? `tools-install-${name}`;
|
||||
useTools.setState((s) => ({ jobs: { ...s.jobs, [name]: { request, lines: [], fraction: null, message: null } } }));
|
||||
upsertTask({ id: taskId, title: tx`Installing ${display}`, message: null, fraction: null, done: false });
|
||||
const title = tx`Installing ${display}`;
|
||||
const cancel = () => toolsBackend().cancel(request);
|
||||
const task = (message: string | null, fraction: number | null) =>
|
||||
upsertTask({ id: taskId, title, message, fraction, done: false, cancel });
|
||||
|
||||
const p = (async () => {
|
||||
try {
|
||||
const info = await toolsBackend().install(name, request, (e) => {
|
||||
if (e.type === "line") {
|
||||
patchJob(name, (j) => ({ ...j, lines: [...j.lines, e.text].slice(-MAX_LOG_LINES), message: e.text }));
|
||||
upsertTask({ id: taskId, title: tx`Installing ${display}`, message: e.text, fraction: null, done: false });
|
||||
} else {
|
||||
patchJob(name, (j) => ({ ...j, fraction: e.fraction, message: e.message }));
|
||||
upsertTask({
|
||||
id: taskId,
|
||||
title: tx`Installing ${display}`,
|
||||
message: e.message,
|
||||
fraction: e.fraction,
|
||||
done: false,
|
||||
});
|
||||
}
|
||||
// Downloads and package managers run one at a time; the rest wait, cancellable.
|
||||
const info = await queueTask(DOWNLOAD_QUEUE, { id: taskId, title }, () => {
|
||||
task(null, null);
|
||||
return toolsBackend().install(name, request, (e) => {
|
||||
if (e.type === "line") {
|
||||
patchJob(name, (j) => ({ ...j, lines: [...j.lines, e.text].slice(-MAX_LOG_LINES), message: e.text }));
|
||||
task(e.text, null);
|
||||
} else {
|
||||
patchJob(name, (j) => ({ ...j, fraction: e.fraction, message: e.message }));
|
||||
task(e.message, e.fraction);
|
||||
}
|
||||
});
|
||||
});
|
||||
upsertTool(info);
|
||||
endJob(name, null);
|
||||
@@ -151,7 +154,7 @@ export function installTool(name: string, opts: { notificationKey?: string } = {
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
upsertTask({ id: taskId, title: tx`Installing ${display}`, message: null, fraction: 1, done: true });
|
||||
upsertTask({ id: taskId, title, message: null, fraction: 1, done: true });
|
||||
running.delete(name);
|
||||
}
|
||||
})();
|
||||
@@ -159,7 +162,10 @@ export function installTool(name: string, opts: { notificationKey?: string } = {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Cancels the install of `name`, queued or running (as the task's Cancel does). */
|
||||
export function cancelInstall(name: string) {
|
||||
const taskId = `tools:${name}`;
|
||||
if (useTasks.getState().tasks.some((t) => t.id === taskId)) return cancelTask(taskId);
|
||||
const job = useTools.getState().jobs[name];
|
||||
if (job) toolsBackend().cancel(job.request);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ import { Channel } from "@tauri-apps/api/core";
|
||||
import { create } from "zustand";
|
||||
import type { GitOperation, RemoteOp } from "@/bindings";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { call, commands, emitLocalIdeEvent, isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { call, commands, isCancelled, nextRequestId } from "@/platform/ipc";
|
||||
import { editorsFor } from "@/editor/api";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { cancelTask, upsertTaskSoon, useTasks } from "@/workbench/tasks";
|
||||
import { progressFraction } from "./model";
|
||||
import { currentRepo, refreshNow, repoName, useVcs } from "./store";
|
||||
|
||||
@@ -129,7 +130,10 @@ export function lastRemoteFailure(): string | null {
|
||||
}
|
||||
|
||||
export function cancelRemote() {
|
||||
if (activeRemote !== null) void commands.cancelRequest(activeRemote);
|
||||
if (activeRemote === null) return;
|
||||
const taskId = `vcs:${activeRemote}`;
|
||||
if (useTasks.getState().tasks.some((t) => t.id === taskId)) cancelTask(taskId);
|
||||
else void commands.cancelRequest(activeRemote);
|
||||
}
|
||||
|
||||
/** Every repository of the project (Fetch and Update Project act on all of them, as in IDEA). */
|
||||
@@ -154,11 +158,12 @@ export async function runRemote(
|
||||
const [progressText, doneTitle] = REMOTE_TITLE[op.op];
|
||||
const progressTitle = tx(progressText);
|
||||
const request = nextRequestId();
|
||||
const taskId = 900_000 + request;
|
||||
const taskId = `vcs:${request}`;
|
||||
activeRemote = request;
|
||||
useVcs.setState({ remoteBusy: progressTitle });
|
||||
const cancel = () => void commands.cancelRequest(request);
|
||||
const task = (message: string | null, fraction: number | null, done: boolean) =>
|
||||
emitLocalIdeEvent({ type: "task", id: taskId, title: progressTitle, message, fraction, done });
|
||||
upsertTaskSoon({ id: taskId, title: progressTitle, message, fraction, done, cancel });
|
||||
task(detail ?? null, null, false);
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
@@ -220,13 +225,16 @@ export async function runRemote(
|
||||
const cancelled = isCancelled(e);
|
||||
lastRemoteError = cancelled ? null : errorMessage(e);
|
||||
logConsole(cancelled ? "info" : "err", cancelled ? `${doneTitle} cancelled` : errorMessage(e));
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: cancelled ? "warning" : "error",
|
||||
key: "vcs.remote",
|
||||
title: cancelled ? `${doneTitle} cancelled` : `${doneTitle} failed`,
|
||||
body: cancelled ? undefined : errorMessage(e),
|
||||
});
|
||||
// The user asked for it: the progress balloon just goes away.
|
||||
if (cancelled) notifications().expire?.("vcs.remote");
|
||||
else
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: "error",
|
||||
key: "vcs.remote",
|
||||
title: `${doneTitle} failed`,
|
||||
body: errorMessage(e),
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
task(null, 1, true);
|
||||
@@ -320,12 +328,13 @@ export async function runGitOperation(op: GitOperation, opts: GitOperationOption
|
||||
}
|
||||
await saveAll();
|
||||
const request = nextRequestId();
|
||||
const taskId = 900_000 + request;
|
||||
const taskId = `vcs:${request}`;
|
||||
activeRemote = request;
|
||||
const progress = tx(opts.progress);
|
||||
useVcs.setState({ remoteBusy: progress });
|
||||
const cancel = () => void commands.cancelRequest(request);
|
||||
const task = (message: string | null, done: boolean) =>
|
||||
emitLocalIdeEvent({ type: "task", id: taskId, title: progress, message, fraction: done ? 1 : null, done });
|
||||
upsertTaskSoon({ id: taskId, title: progress, message, fraction: done ? 1 : null, done, cancel });
|
||||
task(null, false);
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
@@ -357,14 +366,14 @@ export async function runGitOperation(op: GitOperation, opts: GitOperationOption
|
||||
logConsole(cancelled ? "info" : "err", cancelled ? `${opts.failure}: cancelled` : message);
|
||||
notifications().expire?.("vcs.operation");
|
||||
await refreshNow();
|
||||
const handled = !cancelled && opts.onError ? await opts.onError(message) : false;
|
||||
const handled = cancelled || (opts.onError ? await opts.onError(message) : false);
|
||||
if (!handled)
|
||||
notifications().notify({
|
||||
group: "vcs",
|
||||
level: cancelled ? "warning" : "error",
|
||||
level: "error",
|
||||
key: "vcs.operation",
|
||||
title: cancelled ? `${opts.progress} cancelled` : opts.failure,
|
||||
body: cancelled ? undefined : message,
|
||||
title: opts.failure,
|
||||
body: message,
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
|
||||
@@ -34,7 +34,9 @@ export function isCancelled(e: unknown): boolean {
|
||||
return e instanceof IpcError && e.kind === "cancelled";
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
// Ids are shared by every window of the backend and outlive a reload there (a cancel that
|
||||
// arrives before its request began is remembered), so each page starts at a random point.
|
||||
let nextId = 1 + Math.floor(Math.random() * 0x7000_0000);
|
||||
/** Allocates an id for a cancellable request (see `commands.cancelRequest`). */
|
||||
export function nextRequestId(): RequestId {
|
||||
return nextId++ as RequestId;
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"app.update.installed": "OpenIDEA {version} is installed",
|
||||
"app.update.installedBody": "It is used after OpenIDEA restarts.",
|
||||
"app.update.installingMessage": "Installing…",
|
||||
"app.update.installUncancellable": "The update is being installed; this step can't be cancelled",
|
||||
"app.update.none": "OpenIDEA {version} is up to date",
|
||||
"app.update.progress": "{done} MB of {total} MB",
|
||||
"app.update.progressUnknown": "{done} MB",
|
||||
|
||||
@@ -4644,5 +4644,10 @@
|
||||
"Couldn't copy the task": "无法复制任务",
|
||||
"Copy to a New Task From Here": "从此处复制为新任务",
|
||||
"Run Again From Here": "从此处重新运行",
|
||||
"The messages after this one are replaced. Files the agent changed stay as they are.": "这条之后的消息会被替换。Agent 改动过的文件保持不变。"
|
||||
"The messages after this one are replaced. Files the agent changed stay as they are.": "这条之后的消息会被替换。Agent 改动过的文件保持不变。",
|
||||
"Cancelling…": "正在取消…",
|
||||
"Code insight needs the index; it can't be stopped": "代码洞察依赖索引,无法停止",
|
||||
"Waiting for other tasks": "正在等待其他任务",
|
||||
"This task can't be cancelled": "此任务无法取消",
|
||||
"Cancelled": "已取消"
|
||||
}
|
||||
@@ -66,6 +66,7 @@
|
||||
"app.update.installed": "OpenIDEA {version} 已安装",
|
||||
"app.update.installedBody": "重启 OpenIDEA 后生效。",
|
||||
"app.update.installingMessage": "正在安装…",
|
||||
"app.update.installUncancellable": "正在安装更新,此步骤无法取消",
|
||||
"app.update.none": "OpenIDEA {version} 已是最新版本",
|
||||
"app.update.progress": "{done} MB / {total} MB",
|
||||
"app.update.progressUnknown": "{done} MB",
|
||||
|
||||
@@ -90,6 +90,12 @@
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.taskCancelOff {
|
||||
display: inline-flex;
|
||||
}
|
||||
.taskCancelOff > button {
|
||||
pointer-events: none;
|
||||
}
|
||||
.taskPct {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { BarButton, IconButton, Popup, ProgressBar, showContextMenu, T } from "@/ui";
|
||||
import { BarButton, IconButton, Popup, ProgressBar, showContextMenu, T, tooltipAttrs } from "@/ui";
|
||||
import { useContributions, useStatusInfo, type StatusItemContribution } from "./contributions";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { isStatusItemVisible, statusBarMenu, useStatusWidgetOverrides } from "./statusBarWidgets";
|
||||
import { useTasks } from "./tasks";
|
||||
import { cancelTask, canCancel, useTasks, type BackgroundTask } from "./tasks";
|
||||
import styles from "./StatusBar.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -71,13 +71,29 @@ export function StatusInfo() {
|
||||
);
|
||||
}
|
||||
|
||||
/** A task's cancel button: working, "Cancelling…", or disabled with the reason it can't be stopped. */
|
||||
function TaskCancel({ task: t }: { task: BackgroundTask }) {
|
||||
if (t.done) return null;
|
||||
if (canCancel(t))
|
||||
return <IconButton icon="stop" size="small" title={tx`Cancel ${t.title}`} onClick={() => cancelTask(t.id)} />;
|
||||
const reason = t.cancelling ? tx("Cancelling…") : t.uncancellable;
|
||||
if (!reason) return null;
|
||||
// A disabled button gets no pointer events; the wrapper shows why.
|
||||
return (
|
||||
<span className={styles.taskCancelOff} {...tooltipAttrs(reason)}>
|
||||
<IconButton icon="stop" size="small" title={reason} disabled />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Built-in: background task progress; click lists all running tasks. */
|
||||
export function TaskStatus() {
|
||||
const tasks = useTasks((s) => s.tasks);
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const running = tasks.filter((t) => !t.done);
|
||||
const current = running[running.length - 1] ?? tasks[tasks.length - 1];
|
||||
// A task that is working wins over the queued ones waiting behind it.
|
||||
const current = running.findLast((t) => !t.queued) ?? running[running.length - 1] ?? tasks[tasks.length - 1];
|
||||
if (!current) return null;
|
||||
const more = running.length - 1;
|
||||
return (
|
||||
@@ -119,9 +135,7 @@ export function TaskStatus() {
|
||||
<T>{t.title}</T>
|
||||
</span>
|
||||
{t.fraction != null && <span className={styles.taskPct}>{Math.round(t.fraction * 100)}%</span>}
|
||||
{t.cancel && !t.done && (
|
||||
<IconButton icon="stop" size="small" title={tx`Cancel ${t.title}`} onClick={() => t.cancel?.()} />
|
||||
)}
|
||||
<TaskCancel task={t} />
|
||||
</div>
|
||||
<ProgressBar value={t.done ? 1 : t.fraction} aria-label={t.title} />
|
||||
{t.message && <div className={styles.taskMessage}>{t.message}</div>}
|
||||
|
||||
@@ -86,12 +86,29 @@ describe("background tasks", () => {
|
||||
message: null,
|
||||
fraction: null,
|
||||
done: false,
|
||||
cancellable: false,
|
||||
});
|
||||
emitLocalIdeEvent({
|
||||
type: "task",
|
||||
id: 7,
|
||||
title: "Building index",
|
||||
message: null,
|
||||
fraction: 0.5,
|
||||
done: false,
|
||||
cancellable: false,
|
||||
});
|
||||
emitLocalIdeEvent({ type: "task", id: 7, title: "Building index", message: null, fraction: 0.5, done: false });
|
||||
});
|
||||
expect(screen.queryByText("Building index")).toBeNull();
|
||||
act(() => {
|
||||
emitLocalIdeEvent({ type: "task", id: 6, title: "Publish Diagnostics", message: null, fraction: 1, done: true });
|
||||
emitLocalIdeEvent({
|
||||
type: "task",
|
||||
id: 6,
|
||||
title: "Publish Diagnostics",
|
||||
message: null,
|
||||
fraction: 1,
|
||||
done: true,
|
||||
cancellable: false,
|
||||
});
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
expect(screen.queryByText("Publish Diagnostics")).toBeNull();
|
||||
|
||||
@@ -8,7 +8,7 @@ import { notify } from "./notificationStore";
|
||||
import { NOTIFICATIONS_TOOL_WINDOW } from "./Notifications";
|
||||
import { activateToolWindow } from "./toolwindows/store";
|
||||
import { toggleTheme } from "./theme";
|
||||
import { useTasks } from "./tasks";
|
||||
import { cancelTask, canCancel, useTasks } from "./tasks";
|
||||
import { showStatusInfo } from "./contributions";
|
||||
|
||||
export async function openProjectDialog() {
|
||||
@@ -39,13 +39,13 @@ export async function openProjectDialog() {
|
||||
|
||||
/** Stop Background Processes (⇧⌘F2): the one running cancellable task, else a choice of them. */
|
||||
export function stopBackgroundProcesses() {
|
||||
const running = useTasks.getState().tasks.filter((t) => t.cancel && !t.done);
|
||||
const running = useTasks.getState().tasks.filter(canCancel);
|
||||
if (!running.length) return showStatusInfo("No background processes to stop");
|
||||
if (running.length === 1) return running[0]!.cancel!();
|
||||
if (running.length === 1) return cancelTask(running[0]!.id);
|
||||
showContextMenu({ x: window.innerWidth / 2 - 160, y: 96 }, [
|
||||
...running.map((t) => ({ id: String(t.id), label: t.title, icon: "stop", run: () => t.cancel!() })),
|
||||
...running.map((t) => ({ id: String(t.id), label: t.title, icon: "stop", run: () => cancelTask(t.id) })),
|
||||
{ kind: "separator" as const },
|
||||
{ id: "all", label: "Stop All", run: () => running.forEach((t) => t.cancel!()) },
|
||||
{ id: "all", label: "Stop All", run: () => running.forEach((t) => cancelTask(t.id)) },
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
const cancelTaskCommand = vi.fn(async (_id: number) => true);
|
||||
vi.mock("@/platform/ipc", async (orig) => {
|
||||
const actual = await orig<typeof import("@/platform/ipc")>();
|
||||
return { ...actual, commands: { ...actual.commands, cancelTask: cancelTaskCommand } };
|
||||
});
|
||||
|
||||
const { emitLocalIdeEvent, isCancelled } = await import("@/platform/ipc");
|
||||
const { cancelTask, installTaskEvents, queueTask, setIndexing, upsertTask, useTasks } = await import("./tasks");
|
||||
const { TaskStatus } = await import("./StatusBar");
|
||||
|
||||
const task = (id: string) => useTasks.getState().tasks.find((t) => t.id === id);
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((r) => (resolve = r));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
useTasks.setState({ tasks: [], indexing: null });
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("task queues", () => {
|
||||
it("runs one task at a time; the others wait, listed as queued", async () => {
|
||||
const first = deferred();
|
||||
const started: string[] = [];
|
||||
const a = queueTask("q1", { id: "a", title: "A" }, async () => {
|
||||
started.push("a");
|
||||
upsertTask({ id: "a", title: "A", message: null, fraction: null, done: false });
|
||||
await first.promise;
|
||||
return "a";
|
||||
});
|
||||
const b = queueTask("q1", { id: "b", title: "B" }, async () => {
|
||||
started.push("b");
|
||||
return "b";
|
||||
});
|
||||
expect(started).toEqual(["a"]);
|
||||
expect(task("b")).toMatchObject({ queued: true, message: "Waiting for other tasks" });
|
||||
first.resolve();
|
||||
await expect(a).resolves.toBe("a");
|
||||
await expect(b).resolves.toBe("b");
|
||||
expect(started).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("cancelling a queued task drops it at once: its work never starts and its caller sees a cancellation", async () => {
|
||||
const first = deferred();
|
||||
const work = vi.fn(async () => "never");
|
||||
const running = queueTask("q2", { id: "run", title: "Running" }, () => first.promise);
|
||||
const queued = queueTask("q2", { id: "wait", title: "Waiting" }, work);
|
||||
const next = queueTask("q2", { id: "next", title: "Next" }, async () => "next");
|
||||
|
||||
cancelTask("wait");
|
||||
await expect(queued).rejects.toSatisfy(isCancelled);
|
||||
expect(task("wait")).toBeUndefined();
|
||||
|
||||
first.resolve();
|
||||
await running;
|
||||
await expect(next).resolves.toBe("next");
|
||||
expect(work).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("the queue's Cancel is gone once the work starts (the work brings its own)", async () => {
|
||||
const first = deferred();
|
||||
const second = deferred();
|
||||
void queueTask("q3", { id: "x", title: "X" }, () => first.promise);
|
||||
const y = queueTask("q3", { id: "y", title: "Y" }, () => second.promise);
|
||||
first.resolve();
|
||||
await vi.waitFor(() => expect(task("y")?.queued).toBe(false));
|
||||
expect(task("y")?.cancel).toBeUndefined();
|
||||
second.resolve();
|
||||
await y;
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelling", () => {
|
||||
it("shows Cancelling… until the task ends, then removes it without the finished bar", () => {
|
||||
vi.useFakeTimers();
|
||||
const cancel = vi.fn();
|
||||
upsertTask({ id: "sync", title: "Syncing", message: "Resolving", fraction: 0.3, done: false, cancel });
|
||||
cancelTask("sync");
|
||||
cancelTask("sync");
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(task("sync")).toMatchObject({ cancelling: true, message: "Cancelling…" });
|
||||
upsertTask({ id: "sync", title: "Syncing", message: "Downloading", fraction: 0.4, done: false, cancel });
|
||||
expect(task("sync")?.message).toBe("Cancelling…");
|
||||
upsertTask({ id: "sync", title: "Syncing", message: null, fraction: 1, done: true });
|
||||
expect(task("sync")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("the popup's cancel buttons: working, Cancelling…, and disabled with the reason", () => {
|
||||
const cancel = vi.fn();
|
||||
upsertTask({ id: "dl", title: "Downloading JDK 25", message: null, fraction: null, done: false, cancel });
|
||||
upsertTask({
|
||||
id: "inst",
|
||||
title: "Installing update",
|
||||
message: null,
|
||||
fraction: null,
|
||||
done: false,
|
||||
uncancellable: "This step can't be cancelled",
|
||||
});
|
||||
render(<TaskStatus />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Background tasks/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel Downloading JDK 25" }));
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(screen.getByRole("button", { name: "Cancelling…" })).toBeDisabled();
|
||||
const off = screen.getByRole("button", { name: "This step can't be cancelled" });
|
||||
expect(off).toBeDisabled();
|
||||
expect(off.parentElement).toHaveAttribute("data-tooltip", "This step can't be cancelled");
|
||||
});
|
||||
|
||||
it("indexing says why it can't be stopped", () => {
|
||||
setIndexing(true, 10, 20);
|
||||
expect(task("indexing")).toMatchObject({ uncancellable: expect.any(String) });
|
||||
expect(task("indexing")?.cancel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("backend tasks: cancellable ones cancel through cancel_task, the others say they can't", () => {
|
||||
vi.useFakeTimers();
|
||||
const uninstall = installTaskEvents();
|
||||
const base = { type: "task" as const, message: null, fraction: null, done: false };
|
||||
act(() => {
|
||||
emitLocalIdeEvent({ ...base, id: 41, title: "Building workspace", cancellable: true });
|
||||
emitLocalIdeEvent({ ...base, id: 42, title: "Loading", cancellable: false });
|
||||
vi.advanceTimersByTime(600);
|
||||
});
|
||||
cancelTask(41);
|
||||
expect(cancelTaskCommand).toHaveBeenCalledWith(41);
|
||||
expect(useTasks.getState().tasks.find((t) => t.id === 42)).toMatchObject({
|
||||
cancel: undefined,
|
||||
uncancellable: "This task can't be cancelled",
|
||||
});
|
||||
uninstall();
|
||||
});
|
||||
});
|
||||
+160
-28
@@ -1,6 +1,6 @@
|
||||
/** Background task progress (from `task` / `indexState` backend events). */
|
||||
import { create } from "zustand";
|
||||
import { onIdeEvent } from "@/platform/ipc";
|
||||
import { commands, IpcError, onIdeEvent } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
export interface BackgroundTask {
|
||||
@@ -11,8 +11,14 @@ export interface BackgroundTask {
|
||||
fraction: number | null;
|
||||
done: boolean;
|
||||
startedAt: number;
|
||||
/** Stops the task (shown as a cancel button in the Background Tasks popup). */
|
||||
/** Stops the task (shown as a cancel button in the Background Tasks popup). Call it through `cancelTask`. */
|
||||
cancel?: () => void;
|
||||
/** Why the task can't be stopped (a disabled cancel button with this tooltip), when it has no `cancel`. */
|
||||
uncancellable?: string;
|
||||
/** Cancel was requested: shown as "Cancelling…" until the task ends, then removed at once. */
|
||||
cancelling?: boolean;
|
||||
/** Waiting for the tasks ahead of it in its queue (`queueTask`); cancelling drops it. */
|
||||
queued?: boolean;
|
||||
}
|
||||
|
||||
interface TaskState {
|
||||
@@ -27,22 +33,47 @@ const DONE_LINGER_MS = 600;
|
||||
const SHOW_DELAY_MS = 500;
|
||||
export const INDEX_TASK_ID = "indexing";
|
||||
|
||||
export function upsertTask(t: Omit<BackgroundTask, "startedAt">) {
|
||||
export type TaskUpdate = Omit<BackgroundTask, "startedAt" | "cancelling" | "queued">;
|
||||
|
||||
export function upsertTask(t: TaskUpdate) {
|
||||
let cancelled = false;
|
||||
useTasks.setState((s) => {
|
||||
const i = s.tasks.findIndex((x) => x.id === t.id);
|
||||
const tasks = [...s.tasks];
|
||||
if (i >= 0) tasks[i] = { ...tasks[i]!, ...t };
|
||||
else if (!t.done) tasks.push({ ...t, startedAt: Date.now() });
|
||||
if (i >= 0) {
|
||||
cancelled = !!tasks[i]!.cancelling;
|
||||
// Progress that arrives after Cancel keeps saying "Cancelling…".
|
||||
tasks[i] = { ...tasks[i]!, ...t, queued: false, message: cancelled ? tasks[i]!.message : t.message };
|
||||
} else if (!t.done) tasks.push({ ...t, startedAt: Date.now() });
|
||||
else return s;
|
||||
return { tasks };
|
||||
});
|
||||
if (t.done) setTimeout(() => removeTask(t.id), DONE_LINGER_MS);
|
||||
// A cancelled task leaves without the finished (full) progress bar.
|
||||
if (t.done) {
|
||||
if (cancelled) removeTask(t.id);
|
||||
else setTimeout(() => removeTask(t.id), DONE_LINGER_MS);
|
||||
}
|
||||
}
|
||||
|
||||
export function removeTask(id: BackgroundTask["id"]) {
|
||||
useTasks.setState((s) => ({ tasks: s.tasks.filter((t) => t.id !== id) }));
|
||||
}
|
||||
|
||||
/** Whether `t` offers a working Cancel now. */
|
||||
export function canCancel(t: BackgroundTask): boolean {
|
||||
return !!t.cancel && !t.done && !t.cancelling;
|
||||
}
|
||||
|
||||
/** Cancels task `id`: it shows "Cancelling…" until its producer ends it. */
|
||||
export function cancelTask(id: BackgroundTask["id"]) {
|
||||
const t = useTasks.getState().tasks.find((x) => x.id === id);
|
||||
if (!t || !canCancel(t)) return;
|
||||
useTasks.setState((s) => ({
|
||||
tasks: s.tasks.map((x) => (x.id === id ? { ...x, cancelling: true, message: tx("Cancelling…") } : x)),
|
||||
}));
|
||||
t.cancel!();
|
||||
}
|
||||
|
||||
export function setIndexing(indexing: boolean, files: number, symbols: number) {
|
||||
useTasks.setState({ indexing: indexing ? { files, symbols } : null });
|
||||
upsertTask({
|
||||
@@ -51,36 +82,137 @@ export function setIndexing(indexing: boolean, files: number, symbols: number) {
|
||||
message: indexing ? tx`${files.toLocaleString()} files · ${symbols.toLocaleString()} symbols` : null,
|
||||
fraction: null,
|
||||
done: !indexing,
|
||||
uncancellable: tx("Code insight needs the index; it can't be stopped"),
|
||||
});
|
||||
}
|
||||
|
||||
export function installTaskEvents(): () => void {
|
||||
const pending = new Map<BackgroundTask["id"], ReturnType<typeof setTimeout>>();
|
||||
const latest = new Map<BackgroundTask["id"], Omit<BackgroundTask, "startedAt">>();
|
||||
const a = onIdeEvent("task", (e) => {
|
||||
const t = { id: e.id, title: e.title, message: e.message, fraction: e.fraction, done: e.done };
|
||||
if (useTasks.getState().tasks.some((x) => x.id === t.id)) return upsertTask(t);
|
||||
if (t.done) {
|
||||
clearTimeout(pending.get(t.id));
|
||||
pending.delete(t.id);
|
||||
latest.delete(t.id);
|
||||
return;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queues: tasks that run one at a time, the rest waiting (listed, cancellable).
|
||||
|
||||
/** The error a task cancelled while queued rejects with (`isCancelled` is true for it). */
|
||||
export function queuedCancelled(): IpcError {
|
||||
return new IpcError({ kind: "cancelled", message: "Cancelled" });
|
||||
}
|
||||
|
||||
interface Waiter {
|
||||
id: BackgroundTask["id"];
|
||||
start: () => void;
|
||||
}
|
||||
|
||||
interface Queue {
|
||||
limit: number;
|
||||
running: number;
|
||||
waiting: Waiter[];
|
||||
}
|
||||
|
||||
const queues = new Map<string, Queue>();
|
||||
|
||||
function startNext(q: Queue) {
|
||||
while (q.running < q.limit && q.waiting.length) {
|
||||
const w = q.waiting.shift()!;
|
||||
// The queue's Cancel is spent; the work sets its own (or says why it has none).
|
||||
useTasks.setState((s) => ({
|
||||
tasks: s.tasks.map((t) => (t.id === w.id ? { ...t, queued: false, cancel: undefined } : t)),
|
||||
}));
|
||||
w.start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `work` once the tasks queued before it in `queue` are done (`limit` run at a time).
|
||||
* Until then task `task.id` is listed as queued, and its Cancel drops it: `work` never
|
||||
* starts and the promise rejects with a cancellation (`isCancelled`). Once started,
|
||||
* `work` reports its own progress with `upsertTask` under `task.id` and ends the task.
|
||||
*/
|
||||
export function queueTask<T>(
|
||||
queue: string,
|
||||
task: { id: BackgroundTask["id"]; title: string },
|
||||
work: () => Promise<T>,
|
||||
limit = 1,
|
||||
): Promise<T> {
|
||||
let q = queues.get(queue);
|
||||
if (!q) queues.set(queue, (q = { limit, running: 0, waiting: [] }));
|
||||
const slot = q;
|
||||
const run = async () => {
|
||||
slot.running++;
|
||||
try {
|
||||
return await work();
|
||||
} finally {
|
||||
slot.running--;
|
||||
startNext(slot);
|
||||
}
|
||||
latest.set(t.id, t);
|
||||
if (pending.has(t.id)) return;
|
||||
pending.set(
|
||||
t.id,
|
||||
setTimeout(() => {
|
||||
pending.delete(t.id);
|
||||
const last = latest.get(t.id);
|
||||
latest.delete(t.id);
|
||||
if (last) upsertTask(last);
|
||||
}, SHOW_DELAY_MS),
|
||||
);
|
||||
};
|
||||
if (slot.running < slot.limit && !slot.waiting.length) return run();
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const waiter: Waiter = { id: task.id, start: () => void run().then(resolve, reject) };
|
||||
slot.waiting.push(waiter);
|
||||
const cancel = () => {
|
||||
const i = slot.waiting.indexOf(waiter);
|
||||
if (i < 0) return;
|
||||
slot.waiting.splice(i, 1);
|
||||
removeTask(task.id);
|
||||
reject(queuedCancelled());
|
||||
};
|
||||
const queued: BackgroundTask = {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
message: tx("Waiting for other tasks"),
|
||||
fraction: null,
|
||||
done: false,
|
||||
startedAt: Date.now(),
|
||||
queued: true,
|
||||
cancel,
|
||||
};
|
||||
useTasks.setState((s) => ({ tasks: [...s.tasks.filter((t) => t.id !== task.id), queued] }));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tasks that often finish at once
|
||||
|
||||
const pending = new Map<BackgroundTask["id"], ReturnType<typeof setTimeout>>();
|
||||
const latest = new Map<BackgroundTask["id"], TaskUpdate>();
|
||||
|
||||
/** `upsertTask`, except that a task ending within `SHOW_DELAY_MS` of its start is never shown. */
|
||||
export function upsertTaskSoon(t: TaskUpdate) {
|
||||
if (useTasks.getState().tasks.some((x) => x.id === t.id)) return upsertTask(t);
|
||||
if (t.done) {
|
||||
clearTimeout(pending.get(t.id));
|
||||
pending.delete(t.id);
|
||||
latest.delete(t.id);
|
||||
return;
|
||||
}
|
||||
latest.set(t.id, t);
|
||||
if (pending.has(t.id)) return;
|
||||
pending.set(
|
||||
t.id,
|
||||
setTimeout(() => {
|
||||
pending.delete(t.id);
|
||||
const last = latest.get(t.id);
|
||||
latest.delete(t.id);
|
||||
if (last) upsertTask(last);
|
||||
}, SHOW_DELAY_MS),
|
||||
);
|
||||
}
|
||||
|
||||
export function installTaskEvents(): () => void {
|
||||
const notCancellable = tx("This task can't be cancelled");
|
||||
const a = onIdeEvent("task", (e) =>
|
||||
upsertTaskSoon({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
message: e.message,
|
||||
fraction: e.fraction,
|
||||
done: e.done,
|
||||
cancel: e.cancellable ? () => void commands.cancelTask(e.id).catch(() => {}) : undefined,
|
||||
uncancellable: e.cancellable ? undefined : notCancellable,
|
||||
}),
|
||||
);
|
||||
const b = onIdeEvent("indexState", (e) => setIndexing(e.indexing, e.files, e.symbols));
|
||||
return () => {
|
||||
pending.forEach(clearTimeout);
|
||||
pending.clear();
|
||||
latest.clear();
|
||||
a();
|
||||
b();
|
||||
};
|
||||
|
||||
Reference in new issue
Заблокувати користувача