Порівняти коміти

..
Автор SHA1 Повідомлення Дата
l0ng-ai 7c9ff885dd fix(debugger): let a Python program read input while it is debugged
debugpy launched the program with console "internalConsole", which gives it no
standard input: input() raised EOFError right after printing its prompt. The
program now runs on the pseudo-terminal the debugger already provides for
runInTerminal, so what is typed in the debug console reaches it, as in a Run.
Its output reaches the console from that terminal, so debugpy no longer
captures it a second time (redirectOutput).
2026-11-14 21:33:53 +08:00
l0ng-ai 4e90d9fd5c fix: keep a JAR application's derived Windows java.exe off saved configurations; fix three Windows-only tests (#3368)
Saving a JAR Application configuration on Windows stored the project JDK's full ...\bin\java.exe instead of the generated java, so a later Project JDK change no longer reached it: undoing a derived command only tried the file name. The name without .exe is tried first now.

Windows-only test failures on main: the plugin fuel test's 3_500 budget did not cover activation on Windows (6_000 still proves the per-report refill, which patient needs about 9_000 without); the class-folder test built JDT's memento from a native path; the download test passed its missing remote path with backslashes.
2026-11-14 21:26:01 +08:00
pravdomil ffc2f75d3b fix(markdown): restore the Preview only scroll position when going Back after clicking a link to another Markdown file (#3363)
In Preview only, navigation history recorded the hidden editor's caret, and the preview's caret-follow effect then scrolled to that line over the offset the preview had restored. History now records the line the preview shows, the current entry is refreshed before opening another file, and the preview keeps its restored offset when the caret is already on the line it shows.

Co-authored-by: pravdomil <[email protected]>
2026-11-14 21:25:18 +08:00
Akbar Husain 19587af8b1 fix(vcs): Rollback acts on the checked changes when the selection has none (#3330)
Toolbar Rollback in the Commit window resolved its targets from the tree selection only, so with changes checked but an unversioned file selected it reported "No changes to roll back". The selection is used while it holds rollbackable changes; otherwise the checked changes are.

Co-authored-by: akbar1214 <[email protected]>
2026-11-14 21:25:12 +08:00
akshathayadav378-boopтаakshathayadav378-boop b2c22ccec6 fix(ui): keep a file drag from selecting the text it passes over (#3358)
Dragging a file from the Project view selected the editor text the pointer passed over (#3334). WebKit keeps contenteditable text selectable whatever its user-select, and starts a selection there during a mouse drag even when the press selected nothing. beginDrag() now clears the selection and marks every pointer drag (startPointerDrag, the Project view's own file drag, tool window stripe buttons): no selection, and inputs, textareas and contenteditable out of hit testing (drop targets are their containers).

Co-authored-by: akshathayadav378-boop <[email protected]>
2026-11-14 21:24:20 +08:00
LunarFellerтаLunarFeller f795aa3232 fix(agents): ignore task replies from previous projects (#3356)
Starting or forking a task in project A and switching to B before the reply arrived let A's late reply insert its task into B and replace B's New Agent Task page. startTask and forkTask now check the connection generation they started under before handling the reply or error, and again after Home closes, covering reconnects and A -> B -> A.

Co-authored-by: LunarFeller <[email protected]>
2026-11-14 21:13:23 +08:00
Shohruh Ismoilovтаshohruh-genius 9239b99f3a test(settings): follow the grouped Language Servers page (#3360)
The Language Servers page now groups tools by language (#3272). The search, settings and settingsPaneLayout specs still looked for the old single tool list, and the details pane directly under the body, so they failed on main.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-14 21:10:05 +08:00
24 змінених файлів з 944 додано та 54 видалено

No files matched your search

+5 -3
Переглянути файл
@@ -821,8 +821,10 @@ fn a_reporting_task_outlives_its_background_budget() {
#[test]
fn background_tasks_scale_and_renew_cpu_fuel_budget() {
// Room for activation (about 3_100 on macOS, more on Windows) but not for `patient`'s
// whole run (about 9_000) without the refill on each report.
let f = fixture(Limits {
fuel: 3_500,
fuel: 6_000,
timeout: Duration::from_millis(500),
background: Duration::from_millis(5_000),
..Limits::default()
@@ -831,14 +833,14 @@ fn background_tasks_scale_and_renew_cpu_fuel_budget() {
if f.host.set_capability("misbehave", "process:git", true).is_err() {
return;
}
// Warm up activation in its own call so `patient` starts with a fresh 3_500-fuel budget.
// Warm up activation in its own call so `patient` starts with a fresh budget.
assert_eq!(f.run("misbehave", "echo", Some("warm")).unwrap().as_deref(), Some("warm"));
let out = f.run("misbehave", "patient", None).expect("background task succeeds").expect("output");
assert_eq!(out, "still here");
let err = f.run("misbehave", "spin", None).unwrap_err();
assert!(err.contains("exceeded its CPU budget (3500 fuel per call)"), "{err}");
assert!(err.contains("exceeded its CPU budget (6000 fuel per call)"), "{err}");
}
#[test]
+2 -1
Переглянути файл
@@ -482,7 +482,8 @@ mod tests {
std::fs::create_dir_all(output.join("test001")).unwrap();
// As jdtls writes it: the class's source file name in the path, the library's
// classpath attributes after the folder.
let escaped = output.to_string_lossy().replace('/', "\\/");
// JDT writes the folder as a portable path, `/` on Windows too, each `/` escaped.
let escaped = output.to_string_lossy().replace('\\', "/").replace('/', "\\/");
let handle = format!("=demo/{escaped}=/ai.openidea.kotlin=/true=/<test001(Db.class");
let uri = format!("jdt://contents/main-0/test001/Db.kt?{}", utf8_percent_encode(&handle, PATH));
assert_eq!(folder_class(&uri), Some(("test001".into(), "Db".into())));
+1 -1
Переглянути файл
@@ -403,7 +403,7 @@ async fn download_into_copies_only_the_selected_items() {
assert!(std::fs::read_dir(&elsewhere).unwrap().next().is_none(), "nothing written through the links");
}
let missing = [server.join("gone").to_str().unwrap().to_owned()];
let missing = [server.join("gone").to_str().unwrap().replace('\\', "/")];
let report = service
.download_into(&FsConnection::Local, &missing, &project, &CancellationToken::new(), &mut |_, _, _| {})
.await
+11 -4
Переглянути файл
@@ -1830,10 +1830,17 @@ pub fn without_derived_in(config: &RunConfig, root: &Path, fallback: Option<&Pat
if path.is_absolute()
&& let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned())
{
let mut probe = out.clone();
probe.command = name.clone();
if applied(&probe).command == config.command {
out.command = name;
// Generated configurations name the bare program (`java`), also on Windows where the
// derived path ends in `java.exe`.
let bare =
name.len().checked_sub(4).filter(|&at| name[at..].eq_ignore_ascii_case(".exe")).map(|at| &name[..at]);
for candidate in bare.into_iter().chain([name.as_str()]) {
let mut probe = out.clone();
probe.command = candidate.to_owned();
if applied(&probe).command == config.command {
out.command = candidate.to_owned();
break;
}
}
}
out
+212
Переглянути файл
@@ -0,0 +1,212 @@
import type { Page } from "@playwright/test";
import type { AgentTask } from "../src/bindings";
import { expect, test } from "./fixtures";
const task: AgentTask = {
id: "source-a",
title: "Source task in A",
provider: "codex",
status: "ready",
isolation: "local",
model: null,
cwd: "/dev/acme-api",
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: 0,
updatedMs: 0,
config: [],
commands: [],
usage: null,
queue: [],
capabilities: { fork: true },
};
async function prepare(page: Page, kind: "create" | "fork", failure: boolean, holdClose = false, closeFailure = false) {
await page.evaluate(
async ({ task, kind, failure, holdClose, closeFailure }) => {
const storePath = "/src/features/agents/store.ts";
const ipcPath = "/src/platform/ipc.ts";
const projectPath = "/src/platform/project.ts";
const trustPath = "/src/platform/projectTrust.ts";
const contributionsPath = "/src/workbench/contributions.ts";
const store = await import(storePath);
const { commands } = await import(ipcPath);
const project = await import(projectPath);
const initial = project.useProject.getState().project;
(await import(trustPath)).provideProjectTrustPrompt(async () => true);
commands.openProject = async (root: string) => ({
status: "ok",
data: {
...initial,
root,
name: root === task.cwd ? "acme-api" : "project-b",
trusted: true,
},
});
commands.closeProject = async () => {};
const provider = {
id: "codex",
name: "Codex",
command: "codex",
program: "codex",
args: [],
defaultProgram: null,
defaultArgs: [],
loginHint: null,
commandOverridden: false,
commandEditable: true,
commandReadOnlyReason: null,
installed: true,
env: {},
commands: [],
source: { kind: "custom" },
config: [],
thoughtLevels: {},
};
commands.agentProviders = async () => ({ status: "ok", data: [provider] });
commands.agentProbe = async () => ({ status: "ok", data: null });
commands.agentEntries = async () => ({ status: "ok", data: [] });
commands.agentChanges = async () => ({ status: "ok", data: [] });
const complete = () => {
document.documentElement.dataset.taskRequest = "pending";
return new Promise((resolve) =>
document.addEventListener(
"test:task-complete",
() => {
resolve(
failure
? { status: "error", error: { kind: "internal", message: "Local operation failed" } }
: { status: "ok", data: { ...task, id: "completed-a", title: "Task from project A" } },
);
setTimeout(() => {
document.documentElement.dataset.taskRequest = "settled";
}, 0);
},
{ once: true },
),
);
};
commands.agentCreate = complete;
commands.agentFork = complete;
if (holdClose) {
const editors = (await import(contributionsPath)).editorService();
const close = editors.closeInput;
editors.closeInput = async (key: string) => {
const closed = await close(key);
if (key !== "agent:new") return closed;
document.documentElement.dataset.homeClosing = "pending";
await new Promise((resolve, reject) =>
document.addEventListener(
"test:close-complete",
() => {
if (closeFailure) reject(new Error("Local close failed"));
else resolve(null);
setTimeout(() => {
document.documentElement.dataset.homeClosing = "settled";
}, 0);
},
{ once: true },
),
);
return closed;
};
}
store.useAgents.setState({
loaded: true,
tasks: new Map([[task.id, task]]),
providers: [provider],
composers: new Map(),
history: [],
entries: new Map([[task.id, [{ type: "user", text: "Existing prompt in A", attachments: [] }]]]),
draft: { provider: "codex", config: {}, isolation: "local", base: null },
sessionLocation: "editor",
});
if (kind === "create") store.openHome("Submitted task in A");
else store.openTask(task.id);
},
{ task, kind, failure, holdClose, closeFailure },
);
if (kind === "create") await page.getByRole("button", { name: "Start", exact: true }).click();
else {
await page.getByText("Existing prompt in A", { exact: true }).hover();
await page.getByRole("button", { name: "Fork", exact: true }).click();
}
await expect(page.locator("html")).toHaveAttribute("data-task-request", "pending");
}
async function changeProject(page: Page, change: "A to B" | "A to B to A" | "reopen A") {
await page.evaluate(
async ({ change, task }) => {
const storePath = "/src/features/agents/store.ts";
const projectPath = "/src/platform/project.ts";
const store = await import(storePath);
const project = await import(projectPath);
if (change === "reopen A") await project.closeProject();
else await project.openProject("/dev/project-b");
if (change !== "A to B") await project.openProject(task.cwd);
const root = project.useProject.getState().project.root;
store.useAgents.setState({
loaded: true,
tasks: new Map([
["current-project", { ...task, id: "current-project", title: "Current project's task", cwd: root }],
]),
entries: new Map(),
history: ["Current project history"],
});
store.openHome("Current project draft");
},
{ change, task },
);
await expect(page.getByRole("textbox", { name: "Message", exact: true })).toHaveText("Current project draft");
}
async function expectCurrentProject(page: Page) {
await expect(page.getByRole("textbox", { name: "Message", exact: true })).toHaveText("Current project draft");
await expect(page.getByRole("tab", { name: "New Agent Task" })).toBeVisible();
await expect(page.getByRole("tab", { name: "Task from project A" })).toHaveCount(0);
await expect(page.getByText(/Local (operation|close) failed/)).toHaveCount(0);
expect(
await page.evaluate(async () => {
const storePath = "/src/features/agents/store.ts";
const store = await import(storePath);
return { tasks: [...store.useAgents.getState().tasks.keys()], history: store.useAgents.getState().history };
}),
).toEqual({ tasks: ["current-project"], history: ["Current project history"] });
}
for (const kind of ["create", "fork"] as const) {
for (const failure of [false, true]) {
for (const change of ["A to B", "A to B to A", "reopen A"] as const) {
test(`${kind}: old ${failure ? "failure" : "success"} after ${change}`, async ({ workbench: page }) => {
await prepare(page, kind, failure);
await changeProject(page, change);
await page.evaluate(() => document.dispatchEvent(new Event("test:task-complete")));
await expect(page.locator("html")).toHaveAttribute("data-task-request", "settled");
await expectCurrentProject(page);
});
}
test(`${kind}: ${failure ? "failure" : "success"} in the current project`, async ({ workbench: page }) => {
await prepare(page, kind, failure);
await page.evaluate(() => document.dispatchEvent(new Event("test:task-complete")));
await expect(page.locator("html")).toHaveAttribute("data-task-request", "settled");
if (failure) await expect(page.getByText(/Local operation failed/).first()).toBeVisible();
else await expect(page.getByRole("tab", { name: "Task from project A" })).toBeVisible();
});
}
}
for (const closeFailure of [false, true]) {
test(`create: project changes while Home closes (${closeFailure ? "failure" : "success"})`, async ({
workbench: page,
}) => {
await prepare(page, "create", false, true, closeFailure);
await page.evaluate(() => document.dispatchEvent(new Event("test:task-complete")));
await expect(page.locator("html")).toHaveAttribute("data-home-closing", "pending");
await changeProject(page, "A to B to A");
await page.evaluate(() => document.dispatchEvent(new Event("test:close-complete")));
await expect(page.locator("html")).toHaveAttribute("data-home-closing", "settled");
await expectCurrentProject(page);
});
}
+3 -1
Переглянути файл
@@ -20,7 +20,9 @@ test("Search Everywhere finds and runs an action", async ({ workbench: page }) =
await expect(option).toBeVisible();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog", { name: "Settings" })).toBeVisible();
await expect(page.getByRole("list", { name: /language servers/i })).toBeVisible();
await expect(
page.getByRole("dialog", { name: "Settings" }).getByRole("list", { name: "Languages", exact: true }),
).toBeVisible();
});
const ROOT = "/dev/acme-api";
+8 -8
Переглянути файл
@@ -86,20 +86,20 @@ test("Language Servers page installs a tool with progress (in-memory installer)"
// The page tree is virtualized; search brings the page into view.
await dialog.getByRole("textbox", { name: "Search settings" }).fill("Language Servers");
await dialog.getByRole("treeitem", { name: "Language Servers", exact: true }).click();
const list = dialog.getByRole("list", { name: /language servers/i });
await expect(list).toBeVisible();
await list.locator('[data-tool="gopls"]').click();
const languages = dialog.getByRole("list", { name: "Languages", exact: true });
await languages.getByRole("button", { name: /^Go\b/ }).click();
const gopls = dialog.getByLabel("Go tools").locator('[data-tool="gopls"]');
await gopls.click();
await expect(gopls).toContainText("Missing");
const details = dialog.getByLabel("gopls details");
await expect(details).toContainText("Missing");
await details.getByRole("button", { name: "Install gopls" }).click();
await expect(details.getByRole("progressbar", { name: "Installing gopls" })).toBeVisible();
await expect(details).toContainText("Installed");
await expect(details).toContainText("1.0.0");
await expect(gopls).toContainText("Installed");
await expect(gopls).toContainText("1.0.0");
await expect(details.getByRole("button", { name: "Uninstall" })).toBeVisible();
// Tools without an installer explain how to get them.
await list.locator('[data-tool="solargraph"]').click();
await languages.getByRole("button", { name: /^Ruby\b/ }).click();
await expect(dialog.getByLabel("solargraph details")).toContainText("gem install solargraph");
});
+1 -1
Переглянути файл
@@ -3,7 +3,7 @@ import { expect, test } from "./fixtures";
for (const [id, selector] of [
["build.deployment", "[class*='layout'] > [class*='note']"],
["editor.inspections", "[class*='split'] > [class*='details']"],
["languages.servers", "[class*='body'] > [class*='details']"],
["languages.servers", "[class*='body'] > [class*='language'][aria-label]"],
["plugins", "[class*='body'] > [class*='details']"],
["agents.list", "section[aria-label='Codex']"],
]) {
+4
Переглянути файл
@@ -682,6 +682,10 @@ export function setShownLine(path: string, line: number | null) {
}, true);
}
export function shownLine(path: string): number | null {
return useShownLines.getState()[path] ?? null;
}
export function useShownLine(path: string | null): number | null {
return useShownLines((s) => (path ? (s[path] ?? null) : null));
}
+19
Переглянути файл
@@ -13,6 +13,7 @@ import { evaluateWhen, getContext, setContext } from "@/platform/context";
import { installKeyboardDispatcher } from "@/platform/keymap";
import { TEXT_WHEN } from "./actions";
import { EDITOR_WHEN } from "@/editor/lsp/actions";
import { setShownLine } from "@/editor/api";
let ctrl: EditorController | null = null;
@@ -33,6 +34,24 @@ describe("EditorController", () => {
expect(useProjectViewLead.getState().lead).toBeNull();
});
it("goes back to the line a stand-in view shows (Markdown Preview only), not the hidden caret", async () => {
const text = Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n");
ctrl = new EditorController(createMemoryBackend({ "/p/a.md": text, "/p/b.md": "b\n" }));
ctrl.start();
await ctrl.openFile("/p/a.md", { selection: { line: 45, col: 0 }, focus: false });
// The preview was scrolled after the file opened; then a link opens another file.
setShownLine("/p/a.md", 20);
try {
await ctrl.openFile("/p/b.md", { focus: false });
await ctrl.back();
const slot = ctrl.activeSlot!;
expect(slot.model.path).toBe("/p/a.md");
expect(slot.caret().line).toBe(20);
} finally {
setShownLine("/p/a.md", null);
}
});
it("autosaves on blur, but not while focus is in a popup over the editor", async () => {
const backend = createMemoryBackend({ "/p/a.c": "int main() {\n \n}\n" });
const save = vi.spyOn(backend, "save");
+7 -1
Переглянути файл
@@ -18,6 +18,7 @@ import {
_provideNavigationHistory,
_provideOpenEditorPaths,
_setActiveEditor,
shownLine,
type NavigationLocation,
} from "@/editor/api";
import {
@@ -972,7 +973,10 @@ export class EditorController implements SlotHost {
private recordNav(slot: EditorSlot, navigation = false) {
if (this.navSuppress > 0 || slot.disposed) return;
const c = slot.caret();
this.nav.update({ path: slot.model.path, line: c.line, col: c.col }, navigation);
// A view standing in for the editor (Markdown Preview only) is where the reader is.
const shown = shownLine(slot.model.path);
const loc = shown === null || shown === c.line ? { line: c.line, col: c.col } : { line: shown, col: 0 };
this.nav.update({ path: slot.model.path, ...loc }, navigation);
}
async navigateTo(loc: NavigationLocation | null) {
@@ -1055,6 +1059,8 @@ export class EditorController implements SlotHost {
}
async openFile(path: string, o: OpenOptions = {}): Promise<void> {
// The place left behind, as it is now (a preview may have scrolled since it was recorded).
if (this.navSuppress === 0 && this.activeSlot) this.recordNav(this.activeSlot);
return this.openFileRequest(path, o, this.openingGeneration(o));
}
+36 -24
Переглянути файл
@@ -814,25 +814,31 @@ export async function startTask(
: (draft.config[o.id] ?? defaults[o.id] ?? null);
return value !== null && o.values.some((v) => v.value === value) ? [{ id: o.id, value }] : [];
});
const task = await call(
commands.agentCreate({
projectRoot,
prompt: toPrompt(composer),
provider: draft.provider,
config,
isolation,
base: isolation === "worktree" ? draft.base : null,
}),
);
// Its events can arrive before this reply: keep the newer state they carried.
useAgents.setState((s) => ({
tasks: s.tasks.has(task.id) ? s.tasks : new Map(s.tasks).set(task.id, task),
entries: new Map(s.entries).set(task.id, s.entries.get(task.id) ?? []),
}));
updateDraft({ projectRoot });
remember(composer.text.trim());
clearSubmittedComposer(HOME, composer, started);
if ((await editorService().closeInput?.(HOME_KEY)) !== false) openTask(task.id);
try {
const task = await call(
commands.agentCreate({
projectRoot,
prompt: toPrompt(composer),
provider: draft.provider,
config,
isolation,
base: isolation === "worktree" ? draft.base : null,
}),
);
if (generation !== started) return;
// Its events can arrive before this reply: keep the newer state they carried.
useAgents.setState((s) => ({
tasks: s.tasks.has(task.id) ? s.tasks : new Map(s.tasks).set(task.id, task),
entries: new Map(s.entries).set(task.id, s.entries.get(task.id) ?? []),
}));
updateDraft({ projectRoot });
remember(composer.text.trim());
clearSubmittedComposer(HOME, composer, started);
const closed = await editorService().closeInput?.(HOME_KEY);
if (generation === started && closed !== false) openTask(task.id);
} catch (e) {
if (generation === started) throw e;
}
}
/** Sessions on this machine that no task holds yet (a terminal, another editor: #312). */
@@ -955,11 +961,17 @@ export function quoteInComposer(task: string, text: string) {
/** Copies the task's conversation into a new task (up to its first `turns` turns) and opens it. */
export async function forkTask(task: string, turns: number | null): Promise<void> {
const copy = await call(commands.agentFork(task, turns));
useAgents.setState((s) => ({
tasks: s.tasks.has(copy.id) ? s.tasks : new Map(s.tasks).set(copy.id, copy),
}));
openTask(copy.id);
const started = generation;
try {
const copy = await call(commands.agentFork(task, turns));
if (generation !== started) return;
useAgents.setState((s) => ({
tasks: s.tasks.has(copy.id) ? s.tasks : new Map(s.tasks).set(copy.id, copy),
}));
openTask(copy.id);
} catch (e) {
if (generation === started) throw e;
}
}
/** Puts a message back in the task's composer, above anything typed meanwhile. */
+399
Переглянути файл
@@ -0,0 +1,399 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentTask, ProjectInfo } from "@/bindings";
import { useProject } from "@/platform/project";
import { HOME_KEY, SESSION_KIND, TOOL_WINDOW, sessionKey } from "./model";
import {
HOME,
composerDraft,
connect,
disconnect,
forkTask,
startTask,
updateComposer,
updateDraft,
useAgents,
} from "./store";
const backend = vi.hoisted(() => ({
create: vi.fn(),
fork: vi.fn(),
subscribe: vi.fn(),
open: vi.fn(),
close: vi.fn(),
show: vi.fn(),
closeAvailable: true,
tasks: [] as AgentTask[],
}));
vi.mock("@tauri-apps/api/core", async (original) => ({
...(await original<typeof import("@tauri-apps/api/core")>()),
Channel: class {
onmessage = () => {};
},
}));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
isTauri: () => true,
commands: {
agentCreate: backend.create,
agentFork: backend.fork,
agentSubscribe: backend.subscribe,
agentProviders: async () => ({ status: "ok", data: [] }),
},
}));
vi.mock("@/workbench/contributions", async (original) => ({
...(await original<typeof import("@/workbench/contributions")>()),
editorService: () => ({
openInput: backend.open,
closeInput: backend.closeAvailable ? backend.close : undefined,
openTabs: () => [],
}),
toolWindowService: () => ({ show: backend.show }),
}));
const ok = <T>(data: T) => ({ status: "ok" as const, data });
function deferred() {
let resolve!: (value: unknown) => void;
let reject!: (reason: Error) => void;
const promise = new Promise((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
const task = (id: string, cwd = "/project-a"): AgentTask => ({
id,
cwd,
title: id,
provider: "codex",
status: "ready",
isolation: "local",
model: null,
branch: null,
base: null,
baseCommit: null,
error: null,
createdMs: 0,
updatedMs: 0,
config: [],
commands: [],
usage: null,
queue: [],
});
const setProject = (root: string | null) =>
useProject.setState({
project: root === null ? null : ({ root, trusted: true } as ProjectInfo),
});
beforeEach(() => {
disconnect();
vi.resetAllMocks();
backend.closeAvailable = true;
backend.close.mockResolvedValue(true);
backend.tasks = [task("source")];
backend.subscribe.mockImplementation(async () => ok(backend.tasks));
setProject("/project-a");
useProject.setState({ settings: null });
useAgents.setState({
loaded: true,
tasks: new Map([["source", task("source")]]),
entries: new Map(),
providers: [],
draft: { provider: "codex", config: {}, isolation: "local", base: "main" },
history: [],
composers: new Map(),
lastComposer: HOME,
focus: null,
sessionLocation: "editor",
docked: null,
lastTask: null,
homeOpen: true,
});
updateComposer(HOME, { text: "submitted in A" });
});
afterEach(() => disconnect());
const transitions = [
"disconnect",
"reconnect",
"replace connection",
"trust change",
"A to B",
"A to B to A",
"A to B to C",
"close project",
] as const;
async function transition(change: (typeof transitions)[number]) {
if (change !== "replace connection") disconnect();
if (change.startsWith("A to B")) setProject("/project-b");
if (change === "trust change")
useProject.setState({ project: { root: "/project-a", trusted: false } as ProjectInfo });
if (change === "close project") setProject(null);
backend.tasks = [task("current", useProject.getState().project?.root ?? "")];
if (change !== "disconnect" && change !== "close project") await connect();
if (change === "A to B to A" || change === "A to B to C") {
disconnect();
setProject(change === "A to B to A" ? "/project-a" : "/project-c");
backend.tasks = [task("current", useProject.getState().project!.root)];
await connect();
}
updateDraft({ projectRoot: "/current/member", provider: "current-agent", config: { model: "current-model" } });
updateComposer(HOME, {
text: "current project draft",
files: [{ path: "/current/next.ts", rel: "next.ts", attached: true }],
images: [{ mimeType: "image/png", data: "next", name: "next.png" }],
});
useAgents.setState({ history: ["current history"], docked: "current", homeOpen: true, lastTask: "current" });
}
const paths = [
{ name: "create local task", create: true, run: () => startTask("local"), mock: backend.create, turns: null },
{ name: "create worktree task", create: true, run: () => startTask("worktree"), mock: backend.create, turns: null },
{ name: "fork entire task", create: false, run: () => forkTask("source", null), mock: backend.fork, turns: null },
{ name: "fork selected turns", create: false, run: () => forkTask("source", 2), mock: backend.fork, turns: 2 },
];
describe.each(paths)("$name project ownership", (path) => {
function begin() {
const response = deferred();
path.mock.mockReturnValueOnce(response.promise);
return { ...response, pending: path.run() };
}
it.each(["editor", "toolWindow"] as const)("opens a successful task in the current %s", async (location) => {
useAgents.setState({ sessionLocation: location });
const response = begin();
expect(path.mock).toHaveBeenCalledTimes(1);
if (path.create)
expect(backend.create).toHaveBeenCalledWith(
expect.objectContaining({
prompt: { text: "submitted in A", files: [], images: [] },
}),
);
else expect(backend.fork).toHaveBeenCalledWith("source", path.turns);
response.resolve(ok(task("completed")));
await response.pending;
expect(useAgents.getState().tasks.get("completed")).toEqual(task("completed"));
if (location === "editor") {
expect(backend.open).toHaveBeenCalledWith(
{ kind: SESSION_KIND, key: sessionKey("completed"), data: { task: "completed", title: "completed" } },
{ focus: true },
);
} else {
expect(useAgents.getState().docked).toBe("completed");
expect(backend.show).toHaveBeenCalledWith(TOOL_WINDOW, true);
expect(backend.open).not.toHaveBeenCalled();
}
expect(composerDraft(HOME).text).toBe(path.create ? "" : "submitted in A");
expect(useAgents.getState().history).toEqual(path.create ? ["submitted in A"] : []);
});
it.each(transitions)("ignores a successful reply after %s", async (change) => {
const response = begin();
await transition(change);
const current = useAgents.getState();
response.resolve(ok(task("old-task")));
await response.pending;
expect(useAgents.getState()).toBe(current);
expect(backend.open).not.toHaveBeenCalled();
expect(backend.close).not.toHaveBeenCalled();
expect(backend.show).not.toHaveBeenCalled();
});
it("leaves a current task with a colliding id and its conversation untouched", async () => {
const response = begin();
await transition("A to B");
const currentTask = { ...task("completed", "/project-b"), title: "B's task" };
useAgents.setState({
tasks: new Map([[currentTask.id, currentTask]]),
entries: new Map([[currentTask.id, [{ type: "agent", text: "B's conversation" }]]]),
});
const current = useAgents.getState();
response.resolve(ok(task("completed")));
await response.pending;
expect(useAgents.getState()).toBe(current);
expect(backend.open).not.toHaveBeenCalled();
});
it.each([false, true])("preserves state on command failure (switched project=%s)", async (switched) => {
const response = begin();
if (switched) await transition("A to B");
const current = useAgents.getState();
const failure = switched
? expect(response.pending).resolves.toBeUndefined()
: expect(response.pending).rejects.toThrow("operation failed");
response.resolve({ status: "error", error: { kind: "internal", message: "operation failed" } });
await failure;
expect(useAgents.getState()).toBe(current);
expect(backend.open).not.toHaveBeenCalled();
expect(backend.close).not.toHaveBeenCalled();
});
it.each([false, true])("preserves state on transport failure (switched project=%s)", async (switched) => {
const response = begin();
if (switched) await transition("A to B");
const current = useAgents.getState();
const failure = switched
? expect(response.pending).resolves.toBeUndefined()
: expect(response.pending).rejects.toThrow("connection lost");
response.reject(new Error("connection lost"));
await failure;
expect(useAgents.getState()).toBe(current);
expect(backend.open).not.toHaveBeenCalled();
expect(backend.close).not.toHaveBeenCalled();
});
it("keeps task and conversation events that arrived before the same-project reply", async () => {
const response = begin();
const newer = { ...task("completed"), title: "Newer title", status: "running" as const };
useAgents.setState({
tasks: new Map([[newer.id, newer]]),
entries: new Map([[newer.id, [{ type: "agent", text: "new entry" }]]]),
});
response.resolve(ok(task("completed")));
await response.pending;
expect(useAgents.getState().tasks.get(newer.id)).toBe(newer);
expect(useAgents.getState().entries.get(newer.id)).toEqual([{ type: "agent", text: "new entry" }]);
});
it.each(["old first", "new first"])(
"accepts only the new project's request when acknowledgements arrive %s",
async (order) => {
const old = begin();
await transition("A to B");
const current = begin();
const finishOld = async () => {
old.resolve(ok(task("old-task")));
await old.pending;
};
const finishCurrent = async () => {
current.resolve(ok(task("new-task", "/project-b")));
await current.pending;
};
if (order === "old first") {
await finishOld();
await finishCurrent();
} else {
await finishCurrent();
await finishOld();
}
expect([...useAgents.getState().tasks.keys()]).toEqual(["current", "new-task"]);
expect(useAgents.getState().lastTask).toBe("new-task");
expect(backend.open).toHaveBeenCalledTimes(1);
expect(backend.open).toHaveBeenCalledWith(expect.objectContaining({ key: sessionKey("new-task") }), {
focus: true,
});
expect(useAgents.getState().history).toEqual(
path.create ? ["current project draft", "current history"] : ["current history"],
);
},
);
it.each(["first then second", "second then first"])(
"keeps both legitimate same-project requests (%s)",
async (order) => {
const first = begin();
const second = begin();
const finishFirst = async () => {
first.resolve(ok(task("first")));
await first.pending;
};
const finishSecond = async () => {
second.resolve(ok(task("second")));
await second.pending;
};
if (order === "first then second") {
await finishFirst();
await finishSecond();
} else {
await finishSecond();
await finishFirst();
}
expect(useAgents.getState().tasks.has("first")).toBe(true);
expect(useAgents.getState().tasks.has("second")).toBe(true);
expect(backend.open).toHaveBeenCalledTimes(2);
},
);
});
describe.each(paths.filter((path) => path.create))("$name after closing Home", (path) => {
async function waitingForClose() {
const closing = deferred();
backend.close.mockReturnValueOnce(closing.promise);
backend.create.mockResolvedValueOnce(ok(task("completed")));
const pending = path.run();
await vi.waitFor(() => expect(backend.close).toHaveBeenCalledWith(HOME_KEY));
return { ...closing, pending };
}
it.each(transitions)("does not navigate after %s while the page is closing", async (change) => {
const closing = await waitingForClose();
await transition(change);
const current = useAgents.getState();
closing.resolve(true);
await closing.pending;
expect(useAgents.getState()).toBe(current);
expect(backend.open).not.toHaveBeenCalled();
expect(backend.show).not.toHaveBeenCalled();
expect(backend.close).toHaveBeenCalledTimes(1);
});
it.each(transitions)("does not surface an old close failure after %s", async (change) => {
const closing = await waitingForClose();
await transition(change);
const current = useAgents.getState();
const done = expect(closing.pending).resolves.toBeUndefined();
closing.reject(new Error("old close failed"));
await done;
expect(useAgents.getState()).toBe(current);
expect(backend.open).not.toHaveBeenCalled();
expect(backend.show).not.toHaveBeenCalled();
});
it("still navigates after closing when only settings or another composer changed", async () => {
const closing = await waitingForClose();
updateDraft({ config: { model: "different" } });
updateComposer("other", { text: "another conversation" });
closing.resolve(true);
await closing.pending;
expect(backend.open).toHaveBeenCalledWith(expect.objectContaining({ key: sessionKey("completed") }), {
focus: true,
});
expect(composerDraft("other").text).toBe("another conversation");
});
it("respects a cancelled close", async () => {
const closing = await waitingForClose();
closing.resolve(false);
await closing.pending;
expect(backend.open).not.toHaveBeenCalled();
expect(useAgents.getState().tasks.has("completed")).toBe(true);
});
it("does not navigate when closing fails", async () => {
const closing = await waitingForClose();
const failure = expect(closing.pending).rejects.toThrow("could not close");
closing.reject(new Error("could not close"));
await failure;
expect(backend.open).not.toHaveBeenCalled();
expect(useAgents.getState().tasks.has("completed")).toBe(true);
});
it("opens the task when the editor service has no close operation", async () => {
backend.closeAvailable = false;
backend.create.mockResolvedValueOnce(ok(task("completed")));
await path.run();
expect(backend.open).toHaveBeenCalledWith(expect.objectContaining({ key: sessionKey("completed") }), {
focus: true,
});
});
it("keeps the workspace member selected for the request", async () => {
const root = "/workspace/member-a";
const result = task("completed", root);
backend.create.mockResolvedValueOnce(ok(result));
await startTask(path.name === "create worktree task" ? "worktree" : "local", root);
expect(backend.create).toHaveBeenCalledWith(expect.objectContaining({ projectRoot: root }));
expect(useAgents.getState().tasks.get(result.id)).toEqual(result);
expect(backend.open).toHaveBeenCalledWith(expect.objectContaining({ key: sessionKey(result.id) }), { focus: true });
});
});
+1 -1
Переглянути файл
@@ -113,7 +113,7 @@ describe("run config → launch config", () => {
)!;
expect(script.program).toBe("/p/manage.py");
expect(script.args).toEqual(["runserver"]);
expect(extra(script)).toMatchObject({ python: "/p/.venv/bin/python", django: true, console: "internalConsole" });
expect(extra(script)).toMatchObject({ python: "/p/.venv/bin/python", django: true, console: "integratedTerminal" });
const mod = launchConfigFor(
rc({ kind: "python", command: "python3", args: ["-m", "app", "-v"], debugAdapter: "debugpy" }),
+3 -2
Переглянути файл
@@ -95,10 +95,11 @@ function debugpyLaunch(rc: RunConfig, cwd: string): LaunchConfig {
const dbg = workflowSettings().debugger;
const extra: Record<string, unknown> = {
python: pythonFor(rc, cwd),
console: "internalConsole",
// On a terminal the program reads what is typed in the debug console (`input()`); its
// output reaches the console from there, so debugpy doesn't capture it a second time.
console: "integratedTerminal",
justMyCode: dbg.skipLibraryCode,
subProcess: dbg.python.attachSubprocess,
redirectOutput: true,
};
if (dbg.python.gevent) extra.gevent = true;
const name = (rc.command.split("/").pop() ?? rc.command).replace(/\.exe$/, "");
+4
Переглянути файл
@@ -39,6 +39,7 @@ import {
HighlightedText,
Icon,
Popup,
beginDrag,
dragSession,
registerDropTarget,
Tree,
@@ -641,6 +642,7 @@ export function ProjectView() {
// editor opens only files, so folders carry the trailing slash `findDirs`
// uses and the editor leaves them out.
let files: DragSession | null = null;
let endDrag: (() => void) | null = null;
const move = (ev: PointerEvent) => {
// A press with no button held is a reordered macOS tap, not a drag (see pointerDrag.ts).
if (!(ev.buttons & 1)) {
@@ -651,6 +653,7 @@ export function ProjectView() {
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return;
active = true;
dragged.current = true;
endDrag = beginDrag();
const m = selectModel(useProjectTree.getState());
const keys = before.has(node.key) ? [...before] : [node.key];
if (before.has(node.key)) setSelection(before, lead);
@@ -697,6 +700,7 @@ export function ProjectView() {
window.removeEventListener("pointercancel", cleanup);
window.removeEventListener("keydown", key, true);
files?.cancel();
endDrag?.();
setDropTarget(null);
setDrag(null);
};
+148
Переглянути файл
@@ -0,0 +1,148 @@
/**
* The `vcs.rollback` action's targets: the change tree's selection while the Commit
* window has focus, the checked changes when the selection holds nothing
* rollbackable (unversioned files, stale paths), and the fall-throughs otherwise.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { getAction } from "@/platform/actions";
import type { FileStatus } from "@/bindings";
const mocks = vi.hoisted(() => ({ notify: vi.fn(), showDialog: vi.fn() }));
vi.mock("@/platform/context", () => ({ getContext: () => ({ activeToolWindow: toolWindow }) }));
vi.mock("@/editor/api", async (orig) => {
const actual = await orig<typeof import("@/editor/api")>();
return { ...actual, activeEditor: () => undefined };
});
vi.mock("@/workbench/contributions", async (orig) => {
const actual = await orig<typeof import("@/workbench/contributions")>();
return { ...actual, maybeEditorService: () => undefined, notifications: () => ({ notify: mocks.notify }) };
});
vi.mock("../gutter", async (orig) => {
const actual = await orig<typeof import("../gutter")>();
return { ...actual, rollbackSelectedLines: () => false };
});
vi.mock("../dialogs", () => ({ openDialog: vi.fn(), confirmDialog: vi.fn() }));
vi.mock("../changeOps", async (orig) => {
const actual = await orig<typeof import("../changeOps")>();
return { ...actual, showRollbackDialog: mocks.showDialog };
});
const { registerVcsActions } = await import("../actions");
const { useVcs } = await import("../store");
const { useCommit } = await import("../commitStore");
let toolWindow: string | null = null;
const fs = (rel: string, unstaged: FileStatus["unstaged"]): FileStatus => ({
path: `/r/${rel}`,
relPath: rel,
staged: null,
unstaged,
renamedFrom: null,
});
/** Two modified files and two unversioned ones, as in the recorded session. */
const status = [
fs("src/ui/icons.tsx", "modified"),
fs("src/main.tsx", "modified"),
fs("src/ui/fileTypeIcons.ts", "untracked"),
fs("perf/startup-improvement-plan.md", "untracked"),
];
afterEach(() => {
vi.clearAllMocks();
toolWindow = null;
useVcs.setState({ status: [], root: null, roots: [] });
useCommit.setState({ selection: [], checks: new Map() });
});
describe("vcs.rollback targets", () => {
it("rolls back the checked changes when the selection holds unversioned files", async () => {
const unregister = registerVcsActions();
try {
useVcs.setState({ status, root: "/r", roots: ["/r"] });
// The tree selection is the unversioned files; the modified ones are checked.
useCommit.setState({ selection: ["/r/src/ui/fileTypeIcons.ts", "/r/perf/startup-improvement-plan.md"] });
toolWindow = "commit";
await getAction("vcs.rollback")!.run({ arg: undefined, ctx: { vcsAvailable: true } });
expect(mocks.showDialog).toHaveBeenCalledWith([status[0], status[1]]);
} finally {
unregister();
}
});
it("uses the tree selection while it holds rollbackable changes", async () => {
const unregister = registerVcsActions();
try {
useVcs.setState({ status, root: "/r", roots: ["/r"] });
useCommit.setState({ selection: ["/r/src/main.tsx"] });
toolWindow = "commit";
await getAction("vcs.rollback")!.run({ arg: undefined, ctx: { vcsAvailable: true } });
expect(mocks.showDialog).toHaveBeenCalledWith([status[1]]);
} finally {
unregister();
}
});
it("offers every change when nothing is selected or checked", async () => {
const unregister = registerVcsActions();
try {
useVcs.setState({ status, root: "/r", roots: ["/r"] });
toolWindow = "commit";
await getAction("vcs.rollback")!.run({ arg: undefined, ctx: { vcsAvailable: true } });
expect(mocks.showDialog).toHaveBeenCalledWith([status[0], status[1]]);
} finally {
unregister();
}
});
it("falls through to every change when the checked set is empty too", async () => {
const unregister = registerVcsActions();
try {
useVcs.setState({ status, root: "/r", roots: ["/r"] });
// The unversioned file is selected; the modified ones are deliberately unchecked.
useCommit.setState({
selection: ["/r/src/ui/fileTypeIcons.ts"],
checks: new Map([
["/r/src/ui/icons.tsx", "none"],
["/r/src/main.tsx", "none"],
]),
});
toolWindow = "commit";
await getAction("vcs.rollback")!.run({ arg: undefined, ctx: { vcsAvailable: true } });
expect(mocks.showDialog).toHaveBeenCalledWith([status[0], status[1]]);
} finally {
unregister();
}
});
it("still rolls back the explicit paths of a context menu", async () => {
const unregister = registerVcsActions();
try {
useVcs.setState({ status, root: "/r", roots: ["/r"] });
useCommit.setState({ selection: ["/r/src/ui/fileTypeIcons.ts"] });
toolWindow = "commit";
await getAction("vcs.rollback")!.run({ arg: { path: "/r/src/main.tsx" }, ctx: { vcsAvailable: true } });
expect(mocks.showDialog).toHaveBeenCalledWith([status[1]]);
} finally {
unregister();
}
});
it("explains a nested repository entry chosen explicitly", async () => {
const unregister = registerVcsActions();
try {
const status = [fs("app.txt", "modified"), fs("libs/core", "modified"), fs("libs/core/core.txt", "modified")];
useVcs.setState({ status, root: "/r", roots: ["/r", "/r/libs/core"] });
toolWindow = "commit";
await getAction("vcs.rollback")!.run({ arg: { path: "/r/libs/core" }, ctx: { vcsAvailable: true } });
expect(mocks.showDialog).not.toHaveBeenCalled();
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ title: "Submodule changes cannot be rolled back here" }),
);
} finally {
unregister();
}
});
});
+14 -2
Переглянути файл
@@ -25,6 +25,7 @@ import {
showUnstashDialog,
} from "./changeOps";
import { setAmend, useCommit } from "./commitStore";
import { currentPlan } from "./commitSelection";
import { gotoConflict, resolveAtCaret, showConflictsDialog } from "./conflictsUi";
import { focusedDiffNavigator } from "./diffNavigator";
import { showDiffWindow } from "./DiffWindow";
@@ -358,9 +359,20 @@ export function registerVcsActions(): () => void {
// IDEA: in the editor, with the caret on changed lines, ⌥⌘Z is Rollback Lines.
const ed = activeEditor();
if (!explicit?.length && ed?.view.hasFocus && rollbackSelectedLines(ed.view)) return;
// The Commit window's subject is its tree selection; when the selection holds
// nothing rollbackable (unversioned files, stale paths), the checked changes are
// instead — as IDEA's toolbar Rollback acts on the checked changes there.
const focused = commitWindowFocused();
const sel = useCommit.getState().selection;
const paths = explicit?.length ? explicit : commitWindowFocused() && sel.length ? sel : undefined;
if (paths) {
const fromSel = focused && sel.length ? rollbackCandidates(sel) : null;
const paths = explicit?.length
? explicit
: fromSel?.length
? sel
: focused
? currentPlan().included.map((f) => f.path)
: undefined;
if (paths?.length) {
const candidates = rollbackCandidates(paths);
const submodule = paths.find((p) => isNestedRepoEntry(p));
if (!candidates.length && submodule)
+6 -2
Переглянути файл
@@ -233,8 +233,12 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
}
}
}
root.scrollTop = previewOffsetForLine(blocksOf(root), caretLine, root.scrollHeight);
scrolledTo.current = root.scrollTop;
const blocks = blocksOf(root);
// Back / Forward return to the line on screen: keep the restored offset within it.
if (previewLineAtOffset(blocks, root.scrollTop) !== caretLine) {
root.scrollTop = previewOffsetForLine(blocks, caretLine, root.scrollHeight);
scrolledTo.current = root.scrollTop;
}
setShownLine(path, caretLine);
}, [caretSeq, caretLine, sync, path, find]);
+1
Переглянути файл
@@ -13,6 +13,7 @@ export {
FILES_DRAG,
REMOTE_FILES_DRAG,
dragRequiresAlt,
beginDrag,
dragSession,
dropZoneAt,
registerDropTarget,
+7
Переглянути файл
@@ -17,3 +17,10 @@
cursor: grabbing !important;
user-select: none !important;
}
/* WebKit keeps editable text selectable whatever its user-select, so the pointer passing over
the editor or a text field during a drag selected text there (#3334). Take them out of hit
testing; drop targets are their containers. */
.dragging :is(input, textarea, [contenteditable]) {
pointer-events: none !important;
}
+32 -1
Переглянути файл
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { dropZoneAt, isOutsideWindow, startPointerDrag } from "./pointerDrag";
import { beginDrag, dropZoneAt, isOutsideWindow, startPointerDrag } from "./pointerDrag";
const fire = (type: string, buttons: number, x = 0, y = 0) =>
window.dispatchEvent(new MouseEvent(type, { bubbles: true, buttons, clientX: x, clientY: y }));
@@ -47,6 +47,23 @@ describe("startPointerDrag with a reordered macOS tap", () => {
fire("pointerup", 0, 60, 0);
expect(onEnd).toHaveBeenCalledWith(true, expect.objectContaining({ x: 60 }));
});
it("clears a text selection that began before the drag threshold (#3334)", () => {
const text = document.body.appendChild(document.createElement("p"));
text.textContent = "dragging a file must not select this";
const range = document.createRange();
range.selectNodeContents(text);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
const { onStart } = start();
fire("pointermove", 1, 40, 0);
fire("pointerup", 0, 40, 0);
expect(onStart).toHaveBeenCalledOnce();
expect(window.getSelection()?.rangeCount).toBe(0);
});
});
const viewport = { left: 0, top: 0, right: 1280, bottom: 800 };
@@ -76,3 +93,17 @@ describe("isOutsideWindow", () => {
expect(isOutsideWindow(viewport, 640, 800)).toBe(true);
});
});
describe("beginDrag", () => {
it("clears the selection and marks the drag until it ends (#3334)", () => {
const text = document.body.appendChild(document.createElement("p"));
text.textContent = "selected before the drag";
window.getSelection()!.selectAllChildren(text);
const end = beginDrag();
expect(window.getSelection()?.rangeCount).toBe(0);
expect(document.documentElement.classList.length).toBe(1);
end();
expect(document.documentElement.classList.length).toBe(0);
text.remove();
});
});
+16 -2
Переглянути файл
@@ -115,6 +115,19 @@ export function dragSession(data: DragData): DragSession {
/** Movement before a press becomes a drag, px. */
const DRAG_THRESHOLD = 4;
/**
* Marks a pointer drag as running until the returned function is called: no text
* selection, and editable text out of hit testing. WebKit starts a selection in
* editable text the pointer passes over even when the press selected nothing
* (#3334), so every pointer-driven drag, built on `startPointerDrag` or not, uses this.
*/
export function beginDrag(): () => void {
window.getSelection()?.removeAllRanges();
const html = document.documentElement;
html.classList.add(styles.dragging!);
return () => html.classList.remove(styles.dragging!);
}
/** Settings › Appearance › "Drag-and-drop with Alt pressed only" for tabs and stripe buttons. */
let requiresAlt = () => false;
export function setDragRequiresAlt(fn: () => boolean) {
@@ -150,6 +163,7 @@ export function startPointerDrag(
if (e.button !== 0 || (!opts.always && requiresAlt() && !e.altKey)) return;
const start = { x: e.clientX, y: e.clientY };
let ghost: HTMLElement | null = null;
let endDrag: (() => void) | null = null;
const session = dragSession(data);
const onMove = (ev: PointerEvent) => {
if (!ghost) {
@@ -172,7 +186,7 @@ export function startPointerDrag(
height: `${r.height}px`,
});
document.body.appendChild(ghost);
document.documentElement.classList.add(styles.dragging!);
endDrag = beginDrag();
opts.onStart?.();
}
// Late moves after release may still precede the pointerup; ignore, don't cancel the drop.
@@ -204,7 +218,7 @@ export function startPointerDrag(
window.removeEventListener("pointercancel", onCancel);
window.removeEventListener("keydown", onKey, true);
ghost?.remove();
document.documentElement.classList.remove(styles.dragging!);
endDrag?.();
};
const onCancel = () => {
session.cancel();
+4
Переглянути файл
@@ -18,6 +18,7 @@ import { call, commands } from "@/platform/ipc";
import { shortcutLabel } from "@/platform/keymap";
import { useProject } from "@/platform/project";
import {
beginDrag,
dragRequiresAlt,
Icon,
IconButton,
@@ -366,6 +367,7 @@ function startStripeDrag(e: PointerEvent<HTMLButtonElement>, id: string) {
const button = e.currentTarget;
const start = { x: e.clientX, y: e.clientY };
let ghost: HTMLElement | null = null;
let endDrag: (() => void) | null = null;
const place = (x: number, y: number) => {
if (ghost) ghost.style.transform = `translate(${x - start.x}px, ${y - start.y}px)`;
};
@@ -385,6 +387,7 @@ function startStripeDrag(e: PointerEvent<HTMLButtonElement>, id: string) {
height: `${r.height}px`,
});
document.body.appendChild(ghost);
endDrag = beginDrag();
useStripeDrag.setState({ id, drop: null });
}
if (!(ev.buttons & 1)) return;
@@ -400,6 +403,7 @@ function startStripeDrag(e: PointerEvent<HTMLButtonElement>, id: string) {
window.removeEventListener("keydown", onKey, true);
if (!ghost) return;
ghost.remove();
endDrag?.();
const { drop } = useStripeDrag.getState();
useStripeDrag.setState({ id: null, drop: null });
dragEnded = true;