дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-15 06:05:39 +02:00
Порівняти коміти
7
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
7c9ff885dd | ||
|
|
4e90d9fd5c | ||
|
|
ffc2f75d3b | ||
|
|
19587af8b1 | ||
|
|
b2c22ccec6 | ||
|
|
f795aa3232 | ||
|
|
9239b99f3a |
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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())));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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']"],
|
||||
]) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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$/, "");
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
FILES_DRAG,
|
||||
REMOTE_FILES_DRAG,
|
||||
dragRequiresAlt,
|
||||
beginDrag,
|
||||
dragSession,
|
||||
dropZoneAt,
|
||||
registerDropTarget,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Заблокувати користувача