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

..
Автор 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
48 змінених файлів з 59 додано та 1394 видалено

No files matched your search

-2
Переглянути файл
@@ -67,8 +67,6 @@ The task tab shows the conversation: your messages, the agent's replies, its rea
On your own messages you can **Edit** (the messages after it are replaced; files already changed stay as they are) or **Retract Message**. On replies you can **Copy Markdown**, **Quote Reply**, **Regenerate Response** and **Branch Conversation from Here**, which starts a new task from that point.
Right-click a link and choose **Copy Link Address** to copy its URL; **Copy** copies the selected text.
### Permissions
When an agent wants to run a command or call a tool that needs approval, the task shows the request and is listed as **Needs Input**. Choose one of the options the agent offers, such as **Allow Once**, **Allow for This Session**, **Always Allow**, **Decline**, **Always Decline** or **Decline and Stop**. Many agents also have a mode picker that approves edits or everything automatically; use it with care.
-2
Переглянути файл
@@ -107,8 +107,6 @@ To format on every save, enable **Reformat code** (and optionally **Optimize imp
Tab placement, the tab limit and what happens when it is reached, the preview tab, sorting, and which tab is activated after one closes are set in **Settings › Editor › General › Editor Tabs**.
With tabs placed left or right, the tab column is as wide as its longest name, up to a limit. Drag the column's inner edge (or focus it and press `←` / `→`) to change the limit; it applies to every editor group and is remembered.
## Split editors
| Action | macOS | Windows / Linux |
-2
Переглянути файл
@@ -67,8 +67,6 @@ OpenIDEA 本身不带模型或账号:它启动你已安装并登录的 agent
在你自己的消息上可以 **编辑**(其后的消息会被替换,已经改动的文件保持不变)或 **撤回消息**。在回复上可以 **复制 Markdown**、**引用回复**、**重新生成回复**,以及 **从此处创建分支对话**,从这一点开始一个新任务。
右键点击链接并选择 **复制链接地址** 可复制它的 URL;**复制** 复制选中的文字。
### 权限
agent 要运行命令或调用需要授权的工具时,任务中会显示请求,并归入 **等待输入**。从 agent 提供的选项中选择,例如 **允许一次**、**在此会话中允许**、**始终允许**、**拒绝**、**始终拒绝** 或 **拒绝并停止**。很多 agent 还有模式选择器,可以自动批准编辑甚至所有操作,请谨慎使用。
-2
Переглянути файл
@@ -107,8 +107,6 @@ description: "多光标与选区、行编辑、剪贴板历史、代码折叠、
标签页的位置、数量上限以及达到上限后的处理方式、预览标签页、排序方式,以及关闭标签页后激活哪一个,都在 **设置 › 编辑器 › 常规 › 编辑器标签页** 中设置。
标签页放在左侧或右侧时,标签页列的宽度随最长的名称变化,但不超过一个上限。拖动该列靠近编辑器的边缘(或聚焦后按 `←` / `→`)可以调整上限;调整对所有编辑器组生效,并会被记住。
## 拆分编辑器
| 操作 | macOS | Windows / Linux |
-31
Переглянути файл
@@ -198,37 +198,6 @@ test("copies reply Markdown with the keyboard and keeps code copying separate",
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("const a = 1;");
});
test("copies a reply link's address from its context menu and keeps Copy for the selected text", async ({
page,
context,
}) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
const url = "https://example.com/pull/42?tab=files#diff";
const reply = await openReply(page, `[**Review changes**](${url})\n\nOrdinary reply text`);
const link = reply.getByRole("link", { name: "Review changes" });
await link.locator("strong").click({ button: "right" });
await page.getByRole("menuitem", { name: "Copy Link Address" }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(url);
await link.evaluate((el) => {
const range = document.createRange();
range.selectNodeContents(el);
window.getSelection()!.removeAllRanges();
window.getSelection()!.addRange(range);
});
await link.click({ button: "right" });
await page
.getByRole("menuitem", { name: /^Copy\b/ })
.first()
.click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("Review changes");
const paragraph = reply.getByText("Ordinary reply text", { exact: true });
await paragraph.click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Copy Link Address" })).toHaveCount(0);
await page.keyboard.press("Escape");
});
test("keeps latest reply actions visible and quotes into the draft without shifting content", async ({ page }) => {
const reply = await openReply(page, REPLY);
const copy = page.getByRole("button", { name: "Copy Markdown", exact: true });
-35
Переглянути файл
@@ -285,38 +285,3 @@ test("vertical tabs leave a narrow editor group most of its width", async ({ wor
expect(contentBox!.width).toBeGreaterThanOrEqual(paneBox!.width * 0.6 - 1);
}
});
test("dragging the vertical tab column's edge resizes it and the width is kept", async ({ workbench: page }) => {
await openFromTree(page, "settings.gradle.kts");
const placeLeft = () =>
page.evaluate(async () => {
const projectModule = "/src/platform/project.ts";
const schemaModule = "/src/features/settings/model/schema.ts";
const [{ useProject }, { DEFAULT_SETTINGS }] = await Promise.all([
import(/* @vite-ignore */ projectModule),
import(/* @vite-ignore */ schemaModule),
]);
const base = useProject.getState().settings ?? DEFAULT_SETTINGS;
useProject.setState({
settings: { ...base, editor: { ...base.editor, tabs: { ...base.editor.tabs, placement: "left" } } },
});
});
await placeLeft();
const strip = page.getByRole("tablist", { name: "Editor tabs" });
const handle = page.getByRole("separator", { name: "Resize editor tabs" });
const before = (await strip.boundingBox())!.width;
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x - 60, box.y + box.height / 2, { steps: 5 });
await page.mouse.up();
await expect.poll(async () => (await strip.boundingBox())!.width).toBeLessThan(before - 40);
const narrowed = (await strip.boundingBox())!.width;
// The next session starts with the dragged width.
await page.reload();
await openFromTree(page, "settings.gradle.kts");
await placeLeft();
await expect.poll(async () => Math.round((await strip.boundingBox())!.width)).toBe(Math.round(narrowed));
});
-102
Переглянути файл
@@ -1,102 +0,0 @@
import { expect, stubDesktop, test } from "./fixtures";
test.use({ video: "on" });
test("offers file templates for the selected workspace member", async ({ workbench: page }) => {
await stubDesktop(page);
await page.evaluate(async () => {
const markers: Record<string, string> = {
"/work/api": "go.mod",
"/work/web": "pom.xml",
"/work/library": "pom.xml",
};
window.__ipc.handlers.fs_read_dir = ({ path }) => {
const name = markers[String(path)];
return name ? [{ name, path: `${path}/${name}`, kind: "file", size: 0, mtimeMs: 0 }] : [];
};
window.__ipc.handlers.run_configs = () => [
{
id: "workspace:web:tomcat",
name: "Web / Tomcat",
kind: "tomcat",
command: "catalina",
args: ["run"],
cwd: "/work/web",
module: "/work/web",
env: {},
detected: true,
isTest: false,
debugAdapter: null,
},
];
window.__ipc.handlers.project_roots = () => ({ folders: [] });
const module = "/src/platform/project.ts";
const { useProject } = await import(/* @vite-ignore */ module);
const current = useProject.getState().project;
useProject.setState({
project: {
...current,
name: "Workspace",
root: "/workspace",
workspace: { version: 1, name: "Workspace", projects: Object.keys(markers) },
},
});
});
const newMenu = async (path: string) => {
await page.locator(`[data-project-path="${path}"]`).click({ button: "right" });
await page.getByRole("menuitem", { name: "New", exact: true }).hover();
await expect(page.getByRole("menuitem", { name: "From Template…", exact: true })).toBeVisible();
};
await newMenu("/work/api");
await expect(page.getByRole("menuitem", { name: "Go File", exact: true })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("go-new-menu.png"), animations: "disabled" });
await expect(page.getByRole("menuitem", { name: "Go Module", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "JSON File", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Java Class", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "Tomcat Context Descriptor", exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await newMenu("/work/web");
await expect(page.getByRole("menuitem", { name: "Java Class", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Tomcat Context Descriptor", exact: true })).toBeVisible();
await expect(page.getByRole("menuitem", { name: "Go File", exact: true })).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: "TypeScript File", exact: true })).toBeVisible();
await page.screenshot({ path: test.info().outputPath("tomcat-new-menu.png"), animations: "disabled" });
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await page.locator('[data-project-path="/work/library"]').click();
await page.keyboard.press("Meta+n");
const popup = page.getByRole("dialog", { name: "New", exact: true });
await expect(popup.getByText("Java Class", { exact: true })).toBeVisible();
await expect(popup.getByText("Tomcat Context Descriptor", { exact: true })).toHaveCount(0);
await expect(popup.getByText("Go File", { exact: true })).toHaveCount(0);
});
test("uses the single project's type in the keyboard New popup", async ({ workbench: page }) => {
await stubDesktop(page);
await page.evaluate(async () => {
window.__ipc.handlers.fs_read_dir = ({ path }) =>
path === "/work/script" ? [{ name: "pyproject.toml", path: "/work/script/pyproject.toml", kind: "file" }] : [];
window.__ipc.handlers.run_configs = () => [];
window.__ipc.handlers.project_roots = () => ({ folders: [] });
const module = "/src/platform/project.ts";
const { useProject } = await import(/* @vite-ignore */ module);
useProject.setState({
project: { ...useProject.getState().project, name: "script", root: "/work/script" },
});
});
await page.locator('[data-project-path="/work/script"]').click();
await page.keyboard.press("Meta+n");
const popup = page.getByRole("dialog", { name: "New", exact: true });
await expect(popup.getByText("Python File", { exact: true })).toBeVisible();
await expect(popup.getByText("Go File", { exact: true })).toHaveCount(0);
await expect(popup.getByText("Tomcat Context Descriptor", { exact: true })).toHaveCount(0);
await popup.getByRole("textbox", { name: "Search for file type" }).fill("from template");
await popup.getByText("From Template…", { exact: true }).click();
await expect(
page.getByRole("dialog", { name: "New File from Template" }).getByText("Go File", { exact: true }),
).toBeVisible();
});
+4 -23
Переглянути файл
@@ -270,29 +270,13 @@
}
.tabsVertical > .tabs,
.tabsVertical > .tabRows {
/* Fits the labels up to the draggable limit (SideTabsResizer); the editor keeps at least 60%. */
width: max-content;
max-width: min(var(--side-tabs-limit, 260px), 40%);
/* A narrow editor group keeps most of its width for the editor; labels truncate. */
min-width: min(140px, 40%);
max-width: min(260px, 40%);
border-bottom: none;
border-right: 1px solid var(--border-editor-tabs, var(--border));
}
/* A zero-width handle on the column's inner border, with a wider hit area. */
.sideTabsResizer {
position: relative;
z-index: 1;
flex: none;
width: 0;
outline: none;
}
.sideTabsResizer::after {
content: "";
position: absolute;
inset: 0 -3px;
cursor: ew-resize;
}
.sideTabsResizer:focus-visible::after {
background: color-mix(in srgb, var(--focus-ring) 55%, transparent);
}
.tabsVertical .tabRows > .tabs {
min-height: 0;
border-bottom: none;
@@ -301,12 +285,9 @@
flex: 1 1 auto;
border-top: 1px solid var(--border-editor-tabs, var(--border));
}
.tabsRight > .sideTabsResizer {
order: 1;
}
.tabsRight > .tabs,
.tabsRight > .tabRows {
order: 2;
order: 1;
border-right: none;
border-left: 1px solid var(--border-editor-tabs, var(--border));
}
-4
Переглянути файл
@@ -64,7 +64,6 @@ import { TextEditorHost } from "./TextEditorHost";
import { FindBar } from "./FindBar";
import { EditorBanners, VirtualBanner } from "./Banner";
import { CodeBreadcrumbs } from "./StatusItems";
import { SideTabsResizer, useSideTabsStyle } from "./SideTabsResizer";
import styles from "./EditorArea.module.css";
import { tx } from "@/platform/i18n";
@@ -253,7 +252,6 @@ function PaneView({ paneId }: { paneId: string }) {
const pinnedCount = tabs.filter((t) => t.pinned).length;
// Settings › Editor Tabs › Tab placement Left / Right: a vertical, scrolling column.
const vertical = tabPrefs.placement === "left" || tabPrefs.placement === "right";
const sideTabsStyle = useSideTabsStyle();
const pinnedRow = tabPrefs.pinnedSeparateRow && pinnedCount > 0 && pinnedCount < tabs.length;
const dnd = useMemo(() => {
// A tab released outside the window detaches into a forced new window.
@@ -368,7 +366,6 @@ function PaneView({ paneId }: { paneId: string }) {
vertical && styles.tabsVertical,
tabPrefs.placement === "right" && styles.tabsRight,
)}
style={vertical ? sideTabsStyle : undefined}
data-pane-id={paneId}
data-focus-scope=""
aria-label={tx("Editor")}
@@ -384,7 +381,6 @@ function PaneView({ paneId }: { paneId: string }) {
) : (
strip(items, dnd.all, actions)
)}
{vertical && !hidden && <SideTabsResizer side={tabPrefs.placement === "left" ? "left" : "right"} />}
<div
className={styles.content}
role="tabpanel"
-106
Переглянути файл
@@ -1,106 +0,0 @@
/**
* Width limit of editor tabs placed left / right, as in IDEA: the column fits its
* labels up to this limit, and dragging its inner edge sets the limit for every
* editor group. Remembered across sessions.
*/
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import { create } from "zustand";
import { lockDragCursor } from "@/ui";
import { tx } from "@/platform/i18n";
import styles from "./EditorArea.module.css";
const KEY = "openidea.editor.sideTabsWidth";
const MIN_WIDTH = 80;
const MAX_WIDTH = 800;
const KEY_STEP = 16;
/** CSS custom property read by `.tabsVertical` in EditorArea.module.css. */
const LIMIT_VAR = "--side-tabs-limit";
function load(): number | null {
try {
const v: unknown = JSON.parse(localStorage.getItem(KEY) ?? "null");
return typeof v === "number" && Number.isFinite(v) ? clamp(v) : null;
} catch {
return null;
}
}
function clamp(px: number) {
return Math.round(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, px)));
}
/** `null`: the default limit from the stylesheet. */
const useSideTabsWidth = create<{ width: number | null }>(() => ({ width: load() }));
function setSideTabsWidth(px: number) {
const width = clamp(px);
useSideTabsWidth.setState({ width });
try {
localStorage.setItem(KEY, JSON.stringify(width));
} catch {
// Storage unavailable: the width lasts for this session.
}
}
/** Style for the editor pane that holds a vertical tab column. */
export function useSideTabsStyle(): CSSProperties | undefined {
const width = useSideTabsWidth((s) => s.width);
return width == null ? undefined : ({ [LIMIT_VAR]: `${width}px` } as CSSProperties);
}
/** The drag handle on the editor side of the tab column; render it right after the column. */
export function SideTabsResizer({ side }: { side: "left" | "right" }) {
const sign = side === "left" ? 1 : -1;
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
const handle = e.currentTarget;
const pane = handle.parentElement;
const start = handle.previousElementSibling?.getBoundingClientRect().width;
if (e.button !== 0 || !pane || start == null) return;
// No focus ring or text selection from a mouse drag.
e.preventDefault();
handle.setPointerCapture(e.pointerId);
const origin = e.clientX;
const previous = pane.style.getPropertyValue(LIMIT_VAR);
const releaseCursor = lockDragCursor("ew-resize");
let next: number | null = null;
// Only this pane follows the pointer; every group takes the new width on release.
const move = (ev: globalThis.PointerEvent) => {
if (!(ev.buttons & 1)) return end();
next = clamp(start + sign * (ev.clientX - origin));
pane.style.setProperty(LIMIT_VAR, `${next}px`);
};
const end = () => {
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", end);
handle.removeEventListener("pointercancel", end);
releaseCursor();
if (next != null) setSideTabsWidth(next);
else if (previous) pane.style.setProperty(LIMIT_VAR, previous);
else pane.style.removeProperty(LIMIT_VAR);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", end);
handle.addEventListener("pointercancel", end);
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const dir = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
const width = e.currentTarget.previousElementSibling?.getBoundingClientRect().width;
if (!dir || width == null) return;
e.preventDefault();
setSideTabsWidth(width + sign * dir * KEY_STEP);
};
return (
<div
role="separator"
aria-orientation="vertical"
aria-label={tx("Resize editor tabs")}
tabIndex={0}
className={styles.sideTabsResizer}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
/>
);
}
-9
Переглянути файл
@@ -98,15 +98,6 @@ describe("run config → launch config", () => {
});
});
it("cargo run under lldb-dap builds the same way and passes the environment as strings", () => {
const l = launchConfigFor(
rc({ kind: "cargo", command: "cargo", args: ["run"], env: { RUST_LOG: "debug" }, debugAdapter: "lldb-dap" }),
"/p",
)!;
expect(l.adapter).toBe("lldb-dap");
expect(extra(l)).toEqual({ cargo: { args: ["build"], filter: { kind: "bin" } }, env: ["RUST_LOG=debug"] });
});
it("cargo test builds test binaries without running", () => {
const l = launchConfigFor(
rc({ kind: "cargo", command: "cargo", args: ["test", "--workspace"], debugAdapter: "codelldb" }),
-10
Переглянути файл
@@ -157,16 +157,6 @@ describe("debug tool window", () => {
expect(screen.getByRole("tab", { name: "Memory" })).toBeTruthy();
});
it("shows the Memory tab of a JVM session while it starts, before its capabilities arrive", () => {
const starting = { ...session, adapter: "java-debug", status: "starting" as const, caps: null };
useDebug.setState({ sessions: { 1: starting }, active: 1 });
render(<DebugToolWindow />);
fireEvent.click(screen.getByRole("tab", { name: "Memory" }));
expect(screen.getByText("The Memory view is available once the program has started.")).toBeTruthy();
act(() => useDebug.setState({ sessions: { 1: { ...starting, adapter: "debugpy" } } }));
expect(screen.queryByRole("tab", { name: "Memory" })).toBeNull();
});
it("keeps the Memory tab selected when the session pauses", async () => {
const caps = { jvmMemory: true } as DebugSession["caps"];
useDebug.setState({ sessions: { 1: { ...session, caps, status: "running" } }, active: 1 });
+1 -6
Переглянути файл
@@ -67,12 +67,7 @@ function cargoLaunch(rc: RunConfig, cwd: string, adapter: string): LaunchConfig
return {
...base(rc, adapter, cwd),
args: programArgs,
extraJson: JSON.stringify({
cargo: filter ? { args, filter } : { args },
...(adapter === "codelldb" ? { sourceLanguages: ["rust"] } : {}),
// lldb-dap takes the environment as `KEY=VALUE` strings.
...(adapter === "lldb-dap" ? { env: Object.entries(rc.env).map(([k, v]) => `${k}=${v}`) } : {}),
}),
extraJson: JSON.stringify({ cargo: filter ? { args, filter } : { args }, sourceLanguages: ["rust"] }),
};
}
+2 -15
Переглянути файл
@@ -169,9 +169,7 @@ export function DebugToolWindow() {
? "variables"
: view.tab,
});
// A JVM session shows the Memory tab from the start, before the debugger has reported what it supports.
const memoryPending = !!active && !active.caps && JVM_ADAPTERS.has(rootOf(active).adapter);
const hasMemory = !!active && (sessionSupports("jvmMemory", active) || memoryPending);
const hasMemory = !!active && sessionSupports("jvmMemory", active);
const hasNetwork = !!active && sessionSupports("jsPage", active);
// The selected session may be one without the Memory or the Network tab.
const tab: Tab =
@@ -257,15 +255,7 @@ export function DebugToolWindow() {
{tab === "variables" ? (
<ThreadsAndVariables session={active} />
) : tab === "memory" ? (
memoryPending ? (
<div className={styles.emptyWrap}>
<EmptyState title="Memory">
<T>The Memory view is available once the program has started.</T>
</EmptyState>
</div>
) : (
<MemoryView key={active.id} session={active} />
)
<MemoryView key={active.id} session={active} />
) : tab === "network" ? (
<NetworkView key={active.id} session={active.id} />
) : (
@@ -276,9 +266,6 @@ export function DebugToolWindow() {
);
}
/** Debug adapters whose sessions have the Memory view (`ide-dap`'s `JVM_MEMORY_ADAPTERS`). */
const JVM_ADAPTERS = new Set(["java-debug", "kotlin-debug-adapter", "kotlin-lsp"]);
// --- threads & frames ----------------------------------------------------------
function ThreadsAndVariables({ session }: { session: DebugSession }) {
-9
Переглянути файл
@@ -207,12 +207,3 @@
.pendingMethod {
font-weight: 600;
}
.match {
border-radius: 2px;
color: var(--fg-search-match);
background: var(--bg-search-match);
}
.matchCurrent {
background: var(--bg-search-match-current);
}
+10 -116
Переглянути файл
@@ -3,7 +3,7 @@
* exchange (right): status line, timing, body (pretty-printed JSON / XML /
* HTML or raw), response headers and the request as sent.
*/
import { Fragment, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import clsx from "clsx";
import type { HttpExchange, HttpHistoryEntry } from "@/platform/ipc";
import {
@@ -18,9 +18,6 @@ import {
T,
useSelectionBoundary,
} from "@/ui";
import { ConsoleFindBar } from "@/ui/ConsoleFindBar";
import { DEFAULT_FIND_OPTIONS, findInLines, type ConsoleMatch, type FindOptions } from "@/ui/consoleFind";
import { formatBinding, hasPrimaryModifier, hostBinding } from "@/platform/keymap";
import { bodyKind, formatMs, formatSize, prettyBody } from "./format";
import { cancelRunning, clearHistory, refreshHistory, selectEntry, useHttpClient } from "./store";
import styles from "./HttpClient.module.css";
@@ -63,49 +60,6 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
const r = ex.response;
const kind = r?.body != null ? bodyKind(r.contentType, r.body) : "text";
const body = useMemo(() => (r?.body == null ? null : raw ? r.body : prettyBody(kind, r.body)), [r, raw, kind]);
const [findOpen, setFindOpen] = useState(false);
const [query, setQuery] = useState("");
const [findOptions, setFindOptions] = useState<FindOptions>(DEFAULT_FIND_OPTIONS);
const [active, setActive] = useState(0);
const findInput = useRef<HTMLInputElement>(null);
const searchable = findOpen && tab === "body" && body != null;
const lines = useMemo(() => (body ?? "").split("\n").map((text, id) => ({ id, text })), [body]);
const found = useMemo(
() => (searchable ? findInLines(lines, query, findOptions) : { matches: [], overflow: false, invalid: false }),
[searchable, lines, query, findOptions],
);
const activeIndex = found.matches.length ? Math.min(active, found.matches.length - 1) : -1;
useEffect(() => {
pane.current?.querySelector("[data-find-current]")?.scrollIntoView({ block: "nearest", inline: "nearest" });
}, [activeIndex, found]);
const openFind = () => {
setFindOpen(true);
// The bar mounts with this render; focus it (and select the query) once it has.
requestAnimationFrame(() => findInput.current?.select());
};
const closeFind = () => {
setFindOpen(false);
pane.current?.focus();
};
const goToMatch = (step: number) => {
const n = found.matches.length;
if (n) setActive((activeIndex + step + n) % n);
};
// ⌘F in the response, as in IDEA's response viewer: the find bar of the Run console.
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (
tab === "body" &&
body != null &&
e.key.toLowerCase() === "f" &&
hasPrimaryModifier(e) &&
!e.altKey &&
!e.shiftKey
) {
e.preventDefault();
e.stopPropagation();
openFind();
}
};
return (
<div className={styles.exchange}>
<div className={styles.statusLine}>
@@ -137,52 +91,18 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
{ id: "request", label: "Request" },
]}
trailing={
tab === "body" && body != null ? (
<>
<IconButton
icon="search"
title="Find…"
shortcut={formatBinding(hostBinding("Meta+F"))}
pressed={findOpen}
onClick={() => (findOpen ? closeFind() : openFind())}
/>
{kind !== "text" && (
<IconButton
icon="soft-wrap"
title={raw ? "Show Formatted" : "Show Raw"}
pressed={raw}
onClick={() => setRaw((v) => !v)}
/>
)}
</>
tab === "body" && kind !== "text" ? (
<IconButton
icon="soft-wrap"
title={raw ? "Show Formatted" : "Show Raw"}
pressed={raw}
onClick={() => setRaw((v) => !v)}
/>
) : undefined
}
/>
{searchable && (
<ConsoleFindBar
query={query}
onChange={(q) => {
setQuery(q);
setActive(0);
}}
options={findOptions}
onOptionsChange={(o) => {
setFindOptions(o);
setActive(0);
}}
count={found.matches.length}
active={activeIndex}
overflow={found.overflow}
invalid={found.invalid}
inputRef={findInput}
onNext={() => goToMatch(1)}
onPrevious={() => goToMatch(-1)}
onClose={closeFind}
label="Find in response"
/>
)}
{/* Focusable, so Select All and Copy work on the response like in a read-only editor. */}
<div ref={pane} className={styles.tabBody} tabIndex={0} onKeyDown={onKeyDown}>
<div ref={pane} className={styles.tabBody} tabIndex={0}>
{tab === "body" &&
(r == null ? (
<pre className={styles.pre}>{ex.entry.error}</pre>
@@ -192,7 +112,7 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
</EmptyState>
) : (
<pre className={styles.pre} aria-label={tx("Response body")}>
{found.matches.length ? highlighted(lines, found.matches, activeIndex) : body}
{body}
{r.truncated && (
<span className={styles.truncated}>{`\n… truncated (${formatSize(r.size)} received)`}</span>
)}
@@ -211,32 +131,6 @@ function ExchangeView({ ex }: { ex: HttpExchange }) {
);
}
/** The body with its find matches marked, the current one distinctly. */
function highlighted(lines: readonly { text: string }[], matches: readonly ConsoleMatch[], current: number): ReactNode {
const out: ReactNode[] = [];
let m = 0;
lines.forEach(({ text }, line) => {
if (line > 0) out.push("\n");
let at = 0;
for (; m < matches.length && matches[m]!.line === line; m++) {
const { start, end } = matches[m]!;
if (start > at) out.push(text.slice(at, start));
out.push(
<mark
key={m}
className={clsx(styles.match, m === current && styles.matchCurrent)}
data-find-current={m === current ? "" : undefined}
>
{text.slice(start, end)}
</mark>,
);
at = end;
}
if (at < text.length) out.push(text.slice(at));
});
return <Fragment>{out}</Fragment>;
}
function HeaderTable({ headers }: { headers: Array<[string, string]> }) {
return (
<table className={styles.headers}>
-29
Переглянути файл
@@ -1,29 +0,0 @@
import { Text } from "@codemirror/state";
import { describe, expect, it } from "vitest";
import { fileReferenceAt } from "./fileNavigation";
const doc = Text.of([
"POST http://x/api",
"",
"< ./body.json",
"> handler.js",
"> {% client.test('ok', () => {}); %}",
">> out/result.json",
"<> 2026-11-10T135253.200.json",
]);
const at = (line: number, col: number) => fileReferenceAt(doc, doc.line(line).from + col);
describe("fileReferenceAt", () => {
it("finds the file a body, handler, redirect or saved-response line names", () => {
expect(at(3, 4)).toMatchObject({ value: "./body.json", response: false });
expect(at(4, 3)).toMatchObject({ value: "handler.js", response: false });
expect(at(6, 5)).toMatchObject({ value: "out/result.json", response: false });
expect(at(7, 10)).toMatchObject({ value: "2026-11-10T135253.200.json", response: true });
});
it("ignores inline handler scripts, the marker itself and other lines", () => {
expect(at(5, 6)).toBeNull();
expect(at(7, 0)).toBeNull();
expect(at(1, 6)).toBeNull();
});
});
-44
Переглянути файл
@@ -1,44 +0,0 @@
/**
* Navigate › Declaration (⌘-click, ⌘B) on the file a `.http` line names: a request body
* (`< ./body.json`), a response handler script (`> handler.js`), a response redirect
* (`>> out.json`) and a saved response (`<> 2026-11-10T135253.200.json`). Saved responses
* resolve against `.idea/httpRequests/`, where IntelliJ IDEA writes them, then against the
* request file's folder.
*/
import type { Text } from "@codemirror/state";
import { docOf } from "@/editor/lsp/lsp";
import { registerNavigationProvider } from "@/editor/navigationProviders";
import { call, commands } from "@/platform/ipc";
import { HTTP_LANGUAGE } from "./editor";
/** The file named on the line at `offset`, as document offsets; `response` for a `<>` line. */
export function fileReferenceAt(
doc: Text,
offset: number,
): { value: string; from: number; to: number; response: boolean } | null {
const line = doc.lineAt(offset);
const m = /^(\s*(<>|>>!?|<|>)\s+)(\S(?:.*\S)?)\s*$/.exec(line.text);
// `> {% … %}` is an inline handler script, not a file.
if (!m || m[3]!.startsWith("{%")) return null;
const from = line.from + m[1]!.length;
const to = from + m[3]!.length;
return offset >= from && offset <= to ? { value: m[3]!, from, to, response: m[2] === "<>" } : null;
}
export function registerFileNavigation(): () => void {
return registerNavigationProvider(async (view, offset) => {
const doc = docOf(view);
if (!doc || doc.virtual || doc.language !== HTTP_LANGUAGE) return null;
const at = fileReferenceAt(view.state.doc, offset);
if (!at) return null;
const candidates = at.response ? [`/.idea/httpRequests/${at.value}`, at.value] : [at.value];
for (const value of candidates) {
const path = await call(commands.fsResolveReference(doc.path, value)).catch(() => null);
if (path) {
const start = { line: 0, col: 0 };
return { from: at.from, to: at.to, targets: [{ path, range: { start, end: start } }] };
}
}
return { from: at.from, to: at.to, targets: [] };
});
}
-2
Переглянути файл
@@ -17,7 +17,6 @@ import {
registerServiceView,
showServiceView,
} from "@/workbench/contributions";
import { registerFileNavigation } from "./fileNavigation";
import { registerVariableNavigation } from "./variableNavigation";
import { HTTP_LANGUAGE, httpEditorExtension, refreshRequests, requestAtCaret } from "./editor";
import { HttpClientHeaderActions, HttpClientView } from "./HttpClientView";
@@ -135,7 +134,6 @@ export function activate(): () => void {
const disposers = [
registerSpringEndpointProvider(),
registerVariableNavigation(),
registerFileNavigation(),
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) resetHttpClient();
}),
-67
Переглянути файл
@@ -1,67 +0,0 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { HttpExchange } from "@/platform/ipc";
vi.mock("./store", async (importOriginal) => ({
...(await importOriginal<typeof import("./store")>()),
refreshHistory: vi.fn(async () => {}),
}));
const { useHttpClient } = await import("./store");
const { HttpClientView } = await import("./HttpClientView");
const exchange: HttpExchange = {
entry: {
id: "e1",
timeMs: 0,
name: null,
method: "GET",
url: "http://x/api",
status: 200,
totalMs: 5,
file: null,
error: null,
},
request: { name: null, method: "GET", url: "http://x/api", headers: [], body: null },
response: {
status: 200,
statusText: "OK",
version: "HTTP/1.1",
headers: [["content-type", "text/plain"]],
contentType: "text/plain",
body: "alpha beta\nbeta gamma\nBeta",
size: 26,
truncated: false,
ttfbMs: 1,
totalMs: 5,
},
};
describe("HTTP response find", () => {
afterEach(() => useHttpClient.setState({ history: [], exchanges: {}, selected: null }));
it("opens a find bar with ⌘F in the response and marks the matches", () => {
Element.prototype.scrollIntoView ??= () => {};
useHttpClient.setState({ history: [exchange.entry], exchanges: { e1: exchange }, selected: "e1" });
render(<HttpClientView />);
const body = screen.getByLabelText("Response body");
fireEvent.keyDown(body, { key: "f", metaKey: true, ctrlKey: true });
const input = screen.getByLabelText("Find in response", { selector: "input" });
act(() => {
fireEvent.change(input, { target: { value: "beta" } });
});
const marks = body.querySelectorAll("mark");
expect([...marks].map((m) => m.textContent)).toEqual(["beta", "beta", "Beta"]);
expect(screen.getByText("1/3")).toBeTruthy();
expect(body.textContent).toBe("alpha beta\nbeta gamma\nBeta");
fireEvent.keyDown(input, { key: "Enter" });
expect(screen.getByText("2/3")).toBeTruthy();
expect(
marks[1]!.hasAttribute("data-find-current") ||
body.querySelectorAll("mark")[1]!.hasAttribute("data-find-current"),
).toBe(true);
fireEvent.keyDown(input, { key: "Escape" });
expect(screen.queryByLabelText("Find in response", { selector: "input" })).toBeNull();
expect(body.querySelectorAll("mark")).toHaveLength(0);
});
});
+2 -4
Переглянути файл
@@ -66,7 +66,7 @@ import {
} from "@/platform/projectPresentation";
import { relativePath } from "@/platform/project";
import { folderKind } from "./folderKind";
import { PROJECT_TYPES } from "@/platform/projectType";
import { PROJECT_TYPES } from "./projectType";
import { useHomeDir } from "./home";
import { keyLike, memberNodes, nodeKeyFor, visibleInfo, type ProjectNode } from "./model";
import {
@@ -286,9 +286,7 @@ const Row = memo(function Row({
const root = useProjectTree((s) => s.root);
const detected = useDetectedRoots();
const build = useProjectPresentation();
const projectType = useProjectTree((s) =>
s.members && node.root && !node.icon ? s.projectTypes.get(node.path) : undefined,
);
const projectType = useProjectTree((s) => (node.root && !node.icon ? s.projectTypes.get(node.path) : undefined));
const file = useJvmPresentation(node.dir || node.placeholder ? "" : node.path, node.entry?.mtimeMs);
const expanded = useProjectTree((s) => node.dir && s.expanded.has(node.key));
const holdsOpen = useProjectTree((s) => node.dir && holdsOpenFile(node.path, s.openFiles));
+2 -8
Переглянути файл
@@ -3,7 +3,7 @@ import { executeAction, getAction } from "@/platform/actions";
import { getContext } from "@/platform/context";
import { canRevealInFileManager, fileManagerName } from "@/platform/fileManager";
import { formatBinding, hostBinding, isMacHost, shortcutLabel } from "@/platform/keymap";
import { projectFolder, relativePath, useProject } from "@/platform/project";
import { relativePath, useProject } from "@/platform/project";
import { currentDetectedRoots, currentRoots } from "@/platform/projectRoots";
import type { MenuItem } from "@/ui";
import { menuGroupItems, toolWindowService, type MenuGroupContext } from "@/workbench/contributions";
@@ -31,7 +31,6 @@ import {
vcsArg,
} from "./operations";
import { isSameOrInside } from "./paths";
import { useProjectTree } from "./store";
import { tx } from "@/platform/i18n";
/** Keys handled by the project view itself (the default keymap has no entries for them). */
@@ -106,12 +105,7 @@ function showProjectView() {
/** File kinds contributed to the `new` menu group (templates, HTTP Request, Scratch File…). */
function contributedNewItems(parent: string | null): MenuItem[] {
const owner = parent ? projectFolder(useProject.getState().project, parent) : null;
const projectType =
parent && owner && isSameOrInside(parent, owner.root)
? useProjectTree.getState().projectTypes.get(owner.root)
: undefined;
const items = menuGroupItems("new", parent ? { paths: [parent], dirs: [true], projectType } : { paths: [] });
const items = menuGroupItems("new", parent ? { paths: [parent], dirs: [true] } : { paths: [] });
return items.length ? [{ kind: "separator" }, ...items] : [];
}
+1 -1
Переглянути файл
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { hasIcon } from "@/ui/icons";
import { detectProjectType, isProjectMarker, PROJECT_TYPES } from "@/platform/projectType";
import { detectProjectType, isProjectMarker, PROJECT_TYPES } from "./projectType";
describe("project types", () => {
it.each([
Переглянути файл
File renamed without changes.
+4 -3
Переглянути файл
@@ -45,10 +45,11 @@ describe("automatic project types", () => {
expect([...useProjectTree.getState().projectTypes.values()]).toEqual(Array(9).fill("go"));
expect(backend.fsReadDir).toHaveBeenCalledTimes(9);
});
it("detects the type for New menu templates in a single project", async () => {
it("leaves a single project root alone", async () => {
initProjectTree("/app");
await waitFor(() => expect(useProjectTree.getState().projectTypes.get("/app")).toBe("node"));
expect(backend.fsReadDir).toHaveBeenCalledExactlyOnceWith("/app");
await new Promise((resolve) => setTimeout(resolve, 20));
expect(backend.fsReadDir).not.toHaveBeenCalled();
expect(useProjectTree.getState().projectTypes.size).toBe(0);
});
it("refreshes only affected markers and clears a removed project type", async () => {
initProjectTree("/ws", "Workspace", ["/a", "/b"]);
+4 -5
Переглянути файл
@@ -1,6 +1,6 @@
/** Bounded, read-only detection for project roots, including collapsed workspace members. */
/** Bounded, read-only detection for workspace members, collapsed ones included; a single project root keeps its own icon. */
import { call, commands, isTauri, onIdeEvent } from "@/platform/ipc";
import { detectProjectType, isProjectMarker } from "@/platform/projectType";
import { detectProjectType, isProjectMarker } from "./projectType";
import { useProjectTree } from "./store";
async function detect(root: string) {
@@ -16,9 +16,8 @@ export function initProjectTypes(): () => void {
let reset: (() => void) | undefined;
const load = () => {
reset?.();
const { root, members } = useProjectTree.getState();
if (!root || !isTauri()) return;
const roots = members ?? [root];
const { root, members: roots } = useProjectTree.getState();
if (!root || !roots || !isTauri()) return;
const pending = new Set(roots);
const versions = new Map<string, number>();
let active = 0;
+1 -1
Переглянути файл
@@ -36,7 +36,7 @@ import {
type TreeOptions,
} from "./model";
import { projectFs } from "./fs";
import { PROJECT_TYPES, type ProjectType } from "@/platform/projectType";
import { PROJECT_TYPES, type ProjectType } from "./projectType";
import {
projectViewRoots,
useProjectViewRoots,
+2 -17
Переглянути файл
@@ -6,15 +6,13 @@ import { registerEditorContribution } from "@/editor/api";
import { executeAction, executeActionOrThrow, getAction, registerActions } from "@/platform/actions";
import { getContext, setContext, useContextKeys } from "@/platform/context";
import { call, commands, onIdeEvent, type RunConfig } from "@/platform/ipc";
import { projectFolder, useProject } from "@/platform/project";
import { isSameOrInside } from "@/platform/paths";
import { useProject } from "@/platform/project";
import { agentRunSettings, onWorkflowSettingsChange } from "@/platform/workflowSettings";
import { showContextMenu, lazyComponent, lazyWhen, type MenuItem } from "@/ui";
import {
menuGroupItems,
useDebugRunSessions,
notifications,
provideRunConfigurationLookup,
provideRunOutput,
registerMenuGroup,
registerOverlay,
@@ -46,7 +44,6 @@ import { invalidateGoModules } from "./goModules";
import { invalidatePythonPackages } from "./pythonPackages";
import { RunWidget } from "./RunWidget";
import { runConfigsRunAnything } from "./runAnything";
import { runDirectory } from "./navigate";
import {
cleanupRemovedProject,
registerRunServiceActions,
@@ -351,7 +348,7 @@ function useRunningCount(): number | null {
}
const CONFIG_FILES =
/(?:^|\/)(?:\.openidea\/run(?:\.local)?\.toml|package\.json|deno\.jsonc?|Cargo\.toml|build\.gradle(?:\.kts)?|pom\.xml|go\.(?:mod|work)|pyproject\.toml|setup\.cfg|pytest\.ini|manage\.py|Makefile|justfile|CMakeLists\.txt|CMakeCache\.txt|index-[^/]+\.json)$/;
/(?:^|\/)(?:\.openidea\/run\.toml|package\.json|deno\.jsonc?|Cargo\.toml|build\.gradle(?:\.kts)?|pom\.xml|go\.(?:mod|work)|pyproject\.toml|setup\.cfg|pytest\.ini|manage\.py|Makefile|justfile|CMakeLists\.txt|CMakeCache\.txt|index-[^/]+\.json)$/;
let reloadTimer: ReturnType<typeof setTimeout> | undefined;
function scheduleReload() {
@@ -768,18 +765,6 @@ export function activate(): () => void {
disposers.push(() => setBeforeLaunchRunner(null));
provideRunOutput({ subscribe: subscribeOutput });
disposers.push(() => provideRunOutput(undefined));
provideRunConfigurationLookup((kind, directory) => {
const { project } = useProject.getState();
if (!project?.trusted) return false;
const owner = projectFolder(project, directory);
if (!owner || !isSameOrInside(directory, owner.root)) return false;
return useRun.getState().configs.some((config) => {
if (config.kind !== kind) return false;
const path = runDirectory(config.module ?? config.cwd);
return isSameOrInside(path, owner.root) && projectFolder(project, path)?.root === owner.root;
});
});
disposers.push(() => provideRunConfigurationLookup(undefined));
if (hasProject()) void loadConfigs();
// Reload when run.toml or build files change on disk (cheap; detection is fast).
+1 -1
Переглянути файл
@@ -1132,7 +1132,7 @@ function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget)
}
if (/\.zig$/.test(input.path)) {
// `--test-filter` matches a substring of the test's full name.
return zigFile(input, env, s, { name: target.name, filter: target.name });
return zigFile(input, env, s, { name: target.name, filter: target.name.replace(/\\(.)/g, "$1") });
}
const rust = /\.rs$/.test(input.path);
if (rust && !(s.inProject && cargoOwner(rel, input.configs).length)) {
+2 -2
Переглянути файл
@@ -131,8 +131,8 @@ function showTargetMenu(view: EditorView, target: RunTarget, path: string, at: {
showContextMenu(at, targetMenuItems(target, path, view.state.doc.toString()));
}
/** Results of frameworks that root their test tree at the file (jest/vitest, pytest, Zig's module) also match by file. */
const fileRootedResults = (path: string) => /\.(py|[cm]?[jt]sx?|zig)$/.test(path);
/** Results of frameworks that root their test tree at the file (jest/vitest, pytest) also match by file. */
const fileRootedResults = (path: string) => /\.(py|[cm]?[jt]sx?)$/.test(path);
/** Last test result of a gutter target (IDEA: green / red run icons after a test run). */
export function gutterTestState(target: RunTarget, path: string): TestState | null {
+1 -2
Переглянути файл
@@ -361,8 +361,7 @@ export function zigTargets(content: string): RunTarget[] {
if (/^pub\s+fn\s+main\s*\(/.test(line)) out.push({ line: i, kind: "main", name: "main", path: [] });
const test = /^\s*test\s+(?:"((?:[^"\\]|\\.)*)"|([A-Za-z_]\w*))\s*\{/.exec(line);
if (test) {
// Unescape for display and for `--test-filter`: the runner reports the raw name.
const name = test[1]?.replace(/\\(.)/g, "$1") ?? test[2]!;
const name = test[1] ?? test[2]!;
out.push({ line: i, kind: "test", name, path: [name] });
}
});
+2 -32
Переглянути файл
@@ -8,23 +8,12 @@ import { phpunitParser, withTeamcity } from "./phpunit";
import { pytestParser } from "./pytest";
import { rubyParser } from "./ruby";
import { unittestParser } from "./unittest";
import { zigParser } from "./zig";
import type { TestEvent, TestOutputParser } from "./types";
export type { TestEvent, TestOutputParser, TestStatus } from "./types";
export { parseDuration } from "./types";
export { TestResults, nodeOutput, nodeStatus, type TestNode } from "./results";
export {
cargoParser,
goParser,
jsParser,
jvmParser,
phpunitParser,
pytestParser,
rubyParser,
unittestParser,
zigParser,
};
export { cargoParser, goParser, jsParser, jvmParser, phpunitParser, pytestParser, rubyParser, unittestParser };
/** Runs several parsers side by side (unknown tool: e.g. `make test`). */
export function combinedParser(parsers: TestOutputParser[]): TestOutputParser {
@@ -34,18 +23,7 @@ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser {
};
}
export type Framework =
| "cargo"
| "js"
| "pytest"
| "unittest"
| "phpunit"
| "go"
| "jvm"
| "ruby"
| "dotnet"
| "zig"
| "auto";
export type Framework = "cargo" | "js" | "pytest" | "unittest" | "phpunit" | "go" | "jvm" | "ruby" | "dotnet" | "auto";
export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args">): Framework {
const cmd = [config.command, ...config.args].join(" ");
@@ -76,8 +54,6 @@ export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args"
case "rake":
case "rspec":
return "ruby";
case "zig":
return "zig";
}
if (/(^|[/\\])dotnet(?:\.exe)?$/i.test(config.command) && config.args[0] === "test") return "dotnet";
if (/\bcargo\b/.test(cmd)) return "cargo";
@@ -122,10 +98,6 @@ export function withTestReporting<T extends Pick<RunConfig, "kind" | "command" |
};
}
if (frameworkFor(config) === "phpunit") return { ...config, args: withTeamcity(config.args) };
if (frameworkFor(config) === "zig" && config.args[0] === "build") {
// `zig build test` prints nothing for a passing (or cached) run; the summary reports the counts.
return config.args.includes("--summary") ? config : { ...config, args: [...config.args, "--summary", "all"] };
}
if (frameworkFor(config) === "unittest") {
const chosen = config.args.some((a) => /^(-v|--verbosity)(=?\d)?$/.test(a));
return chosen ? config : { ...config, args: [...config.args, "-v", "2"] };
@@ -159,8 +131,6 @@ export function createTestParser(config: Pick<RunConfig, "kind" | "command" | "a
return jvmParser();
case "ruby":
return rubyParser();
case "zig":
return zigParser();
default:
return combinedParser([
cargoParser(),
-17
Переглянути файл
@@ -180,23 +180,6 @@ describe("rerunFailedConfig", () => {
).toEqual(["test", "-Dtest=com.a.FooTest#bar+baz,com.a.BarTest"]);
});
it("zig: --test-filter per failed test for `zig test`, null for build-based runs", async () => {
expect(
(await rerunFailedConfig(cfg("zig", "zig", ["test", "src/calc.zig"]), [
["calc", "adds"],
["calc", "subs"],
]))!.args,
).toEqual(["test", "src/calc.zig", "--test-filter", "adds", "--test-filter", "subs"]);
// Existing filters are replaced, not stacked.
expect(
(await rerunFailedConfig(cfg("zig", "zig", ["test", "src/calc.zig", "--test-filter", "adds"]), [
["calc", "subs"],
]))!.args,
).toEqual(["test", "src/calc.zig", "--test-filter", "subs"]);
// `zig build` forwards no args to the test binary, so a build-based run can't narrow.
expect(await rerunFailedConfig(cfg("zig", "zig", ["build", "test"]), [["tally", "subs"]])).toBeNull();
});
it("returns null when nothing failed or the framework is unknown", async () => {
expect(await rerunFailedConfig(cfg("cargo", "cargo", ["test"]), [])).toBeNull();
expect(await rerunFailedConfig(cfg("make", "make", ["test"]), [["x"]])).toBeNull();
-15
Переглянути файл
@@ -249,21 +249,6 @@ function builtinTestsConfig(config: RunConfig, paths: string[][], name: string):
case "go":
args = goArgs(config.args, paths, config.params?.scope === "file" ? config.params.target : undefined);
break;
case "zig": {
// Only a standalone `zig test <file>` can filter: `zig build` forwards no args to the test binary.
const file = config.args[0] === "test" ? config.args[1] : undefined;
const names = unique(paths.map((p) => p[p.length - 1]!).filter(Boolean));
args =
file && names.length
? [
config.args[0]!,
file,
...dropFlag(config.args.slice(2), ["--test-filter"]),
...names.flatMap((n) => ["--test-filter", n]),
]
: null;
break;
}
case "jvm":
args = jvmArgs(config, paths);
break;
-13
Переглянути файл
@@ -58,19 +58,6 @@ describe("test states for the run gutter", () => {
expect(testState({ path: ["QTests", "test_a"], file: "/work/app/città/tests.py" })).toBe("passed");
});
it("matches Zig results rooted at the module stem of the file", () => {
run([
{ type: "result", path: ["tally", "adds"], status: "passed" },
{ type: "result", path: ["tally", "subs"], status: "failed" },
{ type: "result", path: ["other", "adds"], status: "failed" },
]);
expect(testState({ path: ["adds"], file: "src/tally.zig" })).toBe("passed");
expect(testState({ path: ["subs"], file: "/work/app/src/tally.zig" })).toBe("failed");
// Same-named tests of another module don't bleed over.
expect(testState({ path: ["adds"], file: "src/other.zig" })).toBe("failed");
expect(testState({ path: ["adds"], file: "src/tally/main.zig" })).toBeNull();
});
it("newer runs win and listeners hear about changes", () => {
const listener = vi.fn();
const off = onTestStatesChanged(listener);
-4
Переглянути файл
@@ -69,8 +69,6 @@ function segmentMatches(result: string, target: string): boolean {
const cleanPath = (p: string) => p.replace(/\\/g, "/").replace(/^\.\//, "");
const stemOf = (p: string) => p.replace(/.*\//, "").replace(/\.[^.]+$/, "");
function sameFile(resultRoot: string, relPath: string): boolean {
const b = cleanPath(relPath);
// unittest / Django results are rooted at the dotted module (`polls.tests`).
@@ -82,8 +80,6 @@ function sameFile(resultRoot: string, relPath: string): boolean {
const module = resultRoot.replace(/\./g, "/");
return [`${module}.py`, `${module}/__init__.py`].some((f) => b === f || b.endsWith(`/${f}`));
}
// Zig results are rooted at the module stem (`tally` for `src/tally.zig`).
if (/^[\p{L}_][\p{L}\p{N}_]*$/u.test(resultRoot) && stemOf(b) === resultRoot) return true;
const a = cleanPath(resultRoot);
return a === b || b.endsWith(`/${a}`) || a.endsWith(`/${b}`);
}
-174
Переглянути файл
@@ -1,174 +0,0 @@
import { describe, expect, it } from "vitest";
import { createTestParser, frameworkFor, parseAll, withTestReporting } from "./index";
import { TestResults, type TestOutputParser } from "./index";
import { zigParser } from "./zig";
function results(parser: TestOutputParser, text: string) {
const r = new TestResults();
r.apply(parseAll(parser, text));
return r;
}
function leaf(r: TestResults, ...path: string[]) {
return r.get(path.join("\u0000"));
}
const FAILING_RUN = `1/3 calc.test.adds two numbers...OK
2/3 calc.test.fails on purpose...expected 7, found 5
FAIL (TestExpectedEqual)
/Users/me/.zvm/0.16.0/lib/std/testing.zig:118:17: 0x10262da43 in expectEqualInner__anon_36302 (test)
return error.TestExpectedEqual;
^
/Users/me/.zvm/0.16.0/lib/std/testing.zig:83:5: 0x10262dae3 in expectEqual (test)
return expectEqualInner(T, expected, actual);
^
/work/app/src/calc.zig:12:5: 0x10262db23 in test.fails on purpose (test)
try std.testing.expectEqual(@as(u32, 7), add(2, 3));
^
3/3 calc.test.printing helper...boom
OK
2 passed; 0 skipped; 1 failed.
error: the following test command failed with exit code 1:
/Users/me/.cache/zig/o/91f9863b4e219205507fbe58005bc1c5/test --seed=0xebfccadb
`;
describe("zig test", () => {
it("builds the module's tests with failure output and the error's location", () => {
const r = results(zigParser(), FAILING_RUN);
expect(r.roots.map((n) => n.name)).toEqual(["calc"]);
expect(leaf(r, "calc", "adds two numbers")?.own).toBe("passed");
expect(leaf(r, "calc", "printing helper")?.own).toBe("passed");
expect(leaf(r, "calc", "printing helper")?.output).toEqual(["boom"]);
const failed = leaf(r, "calc", "fails on purpose")!;
expect(failed.own).toBe("failed");
expect(failed.output[0]).toBe("expected 7, found 5");
expect(failed.output).toContain("try std.testing.expectEqual(@as(u32, 7), add(2, 3));");
expect(failed.location).toEqual({
path: "/work/app/src/calc.zig",
line: 12,
col: 5,
resolve: "path",
});
expect(r.summary).toEqual({ passed: 2, skipped: 0, failed: 1 });
expect(r.counts()).toMatchObject({ passed: 2, failed: 1 });
});
it("parses a quiet passing run and a filtered single test", () => {
const r = results(
zigParser(),
`1/3 tally.test.total adds the prices...OK
2/3 tally.test.total of nothing is zero...OK
3/3 tally.test.format pads the cents...OK
All 3 tests passed.
`,
);
expect(leaf(r, "tally", "total adds the prices")?.own).toBe("passed");
expect(r.counts()).toMatchObject({ passed: 3, failed: 0 });
const filtered = results(
zigParser(),
`1/1 calc.test.adds two numbers...OK
All 1 tests passed.
`,
);
expect(filtered.counts()).toMatchObject({ passed: 1 });
// A filter matching nothing leaves no nodes but reports the counts.
expect(results(zigParser(), "All 0 tests passed.\n").counts()).toMatchObject({ passed: 0 });
});
it("reports skips, and status printed after output without a newline", () => {
const r = results(
zigParser(),
`1/2 calc.test.will skip...SKIP
2/2 calc.test.greets...helloOK
1 passed; 1 skipped; 0 failed.
`,
);
expect(leaf(r, "calc", "will skip")?.own).toBe("skipped");
expect(leaf(r, "calc", "greets")?.own).toBe("passed");
expect(leaf(r, "calc", "greets")?.output).toEqual(["hello"]);
expect(r.summary).toEqual({ passed: 1, skipped: 1, failed: 0 });
});
it("parses zig build test failure blocks (the build runner's captured output)", () => {
const r = results(
zigParser(),
`test
+- run test 2 pass, 1 fail (3 total)
error: 'tally.test.total of nothing is zero' failed:
expected 1, found 0
/Users/me/.zvm/0.16.0/lib/std/testing.zig:94:17: 0x102d48893 in expectEqualInner__func_1083 (test)
return error.TestExpectedEqual;
^
/Users/me/.zvm/0.16.0/lib/std/testing.zig:58:5: 0x102d48937 in expectEqual (test)
return expectEqualInner(T, expected, actual);
^
/work/app/src/tally.zig:22:5: 0x102d48977 in test.total of nothing is zero (test)
try std.testing.expectEqual(@as(u32, 1), total(&.{}));
^
failed command: ./.zig-cache/o/b84ba4b8338e114a2e03f2758f6ca155/test --cache-dir=./.zig-cache --seed=0x7689fac0 --listen=-
Build Summary: 1/3 steps succeeded (1 failed); 2/3 tests passed (1 failed)
test transitive failure
+- run test 2 pass, 1 fail (3 total)
+- compile test debug native success 790ms MaxRSS:249M
`,
);
// The build runner reports passing tests only as counts; failures get nodes.
const failed = leaf(r, "tally", "total of nothing is zero")!;
expect(failed.own).toBe("failed");
expect(failed.output[0]).toBe("expected 1, found 0");
expect(failed.output).toContain("try std.testing.expectEqual(@as(u32, 1), total(&.{}));");
expect(failed.location).toEqual({ path: "/work/app/src/tally.zig", line: 22, col: 5, resolve: "path" });
expect(r.counts()).toMatchObject({ passed: 2, failed: 1 });
});
it("parses a passing zig build test --summary all (counts only, no nodes)", () => {
const r = results(
zigParser(),
`Build Summary: 3/3 steps succeeded; 3/3 tests passed
test success
+- run test 3 pass (3 total) 2ms MaxRSS:2M
+- compile test Debug native cached 36ms MaxRSS:32M
`,
);
expect(r.roots).toEqual([]);
expect(r.counts()).toMatchObject({ passed: 3, failed: 0 });
});
it("a failure block left open at the end of the output still gets its location", () => {
const r = results(
zigParser(),
`error: 'calc.test.breaks' failed:
/work/app/src/calc.zig:4:5: 0x102 in test.breaks (test)
`,
);
const failed = leaf(r, "calc", "breaks")!;
expect(failed.own).toBe("failed");
expect(failed.location).toEqual({ path: "/work/app/src/calc.zig", line: 4, col: 5, resolve: "path" });
});
});
describe("zig framework selection", () => {
const test = (args: string[]) => ({ kind: "zig", command: "zig", args }) as const;
it("selects the zig parser for zig test configurations", () => {
expect(frameworkFor(test(["build", "test"]))).toBe("zig");
expect(frameworkFor(test(["test", "src/calc.zig", "--test-filter", "adds"]))).toBe("zig");
const events = createTestParser(test(["test", "calc.zig"])).feed("1/1 calc.test.adds two numbers...OK");
expect(events).toEqual([
{ type: "start", path: ["calc", "adds two numbers"] },
{ type: "result", path: ["calc", "adds two numbers"], status: "passed" },
]);
});
it("adds --summary all to zig build test runs so passing runs report counts", () => {
expect(withTestReporting(test(["build", "test"]))).toMatchObject({ args: ["build", "test", "--summary", "all"] });
expect(withTestReporting(test(["build", "test", "--summary", "none"]))).toMatchObject({
args: ["build", "test", "--summary", "none"],
});
// A standalone `zig test` always prints per-test lines.
expect(withTestReporting(test(["test", "calc.zig"]))).toMatchObject({ args: ["test", "calc.zig"] });
});
});
-149
Переглянути файл
@@ -1,149 +0,0 @@
/** `zig test` (the runner's progress lines) and `zig build test` (failure blocks from the build runner). */
import type { TestEvent, TestOutputParser } from "./types";
// `1/3 tally.test.total adds the prices...OK` — the status lands here when the test printed nothing.
const PROGRESS_RE = /^(\d+)\/(\d+)\s+(.+?)\.\.\.(.*)$/;
// `…/src/tally.zig:22:5: 0x… in test.total of nothing is zero (test)` — error-return trace frames.
const FRAME_RE = /^\s*(.+\.zig):(\d+):(\d+): 0x[0-9a-fA-F]+ in /;
const SUMMARY_RE = /^(\d+) passed(?:; (\d+) skipped)?(?:; (\d+) failed)?\.?$/;
const BUILD_SUMMARY_RE = /^Build Summary: .*?(\d+)\/\d+ tests passed(?: \((\d+) failed\))?/;
const STEP_RE = /^\s*\+- run [^()]*?(\d+) pass(?:, (\d+) skip)?(?:, (\d+) fail)? \(\d+ total\)/;
const FAILED_RE = /^error: '(.+)' failed:$/;
// Frames inside the toolchain's standard library (`<zig>/lib/std/…`; stage3 builds `/lib/zig/std/…`).
const STD_LIB_RE = /\/lib\/(?:zig\/)?std\//;
interface Frame {
path: string;
line: number;
col: number;
}
interface Awaiting {
path: string[];
frames: Frame[];
}
/** `tally.test.total adds the prices` → module `tally`, name `total adds the prices` (the last `.test.` wins). */
function splitName(full: string): { stem: string | null; name: string } {
const at = full.lastIndexOf(".test.");
return at < 0 ? { stem: null, name: full } : { stem: full.slice(0, at), name: full.slice(at + 6) };
}
/**
* The runner writes the status right after whatever the test printed, on the same line
* (`…boomOK`) or, when the print ended in a newline, alone on the next one.
*/
function statusOf(text: string): { status: "passed" | "skipped" | "failed"; output: string } | null {
const trimmed = text.replace(/\s+$/, "");
if (trimmed.endsWith("OK")) return { status: "passed", output: trimmed.slice(0, -2) };
if (trimmed.endsWith("SKIP")) return { status: "skipped", output: trimmed.slice(0, -4) };
const failed = /^(.*)FAIL(?: \([^)]*\))?$/.exec(trimmed);
return failed ? { status: "failed", output: failed[1]! } : null;
}
export function zigParser(): TestOutputParser {
let pending: Awaiting | null = null;
let block: Awaiting | null = null;
let trace: Awaiting | null = null;
const pathOf = (stem: string | null, name: string) => (stem ? [stem, name] : [name]);
const location = (events: TestEvent[], path: string[], frames: Frame[]) => {
// The error originates in the first non-library frame; the outermost is the fallback.
const frame = frames.find((f) => !STD_LIB_RE.test(f.path)) ?? frames[frames.length - 1];
if (frame)
events.push({
type: "location",
path,
location: { path: frame.path, line: frame.line, col: frame.col, resolve: "path" },
});
};
/** A failed test's error-return trace: indented source lines and frames, up to the next unindented line. */
const traced = (events: TestEvent[], text: Awaiting, line: string): boolean => {
const m = FRAME_RE.exec(line);
if (m) {
text.frames.push({ path: m[1]!, line: Number(m[2]), col: Number(m[3]) });
return true;
}
if (line === "" || /^\s/.test(line)) {
if (line.trim()) events.push({ type: "output", path: text.path, line: line.trim() });
return true;
}
location(events, text.path, text.frames);
return false;
};
return {
feed(line) {
const events: TestEvent[] = [];
let m: RegExpExecArray | null;
// Inside a `zig build` failure block: indented (or blank) lines are the test's captured output.
if (block) {
if (traced(events, block, line)) return events;
block = null;
}
// After a `zig test` FAIL: the error-return trace follows on the next lines.
if (trace) {
if (traced(events, trace, line)) return events;
trace = null;
}
if (pending && (m = /^(OK|SKIP|FAIL(?: \([^)]*\))?)$/.exec(line))) {
const status = m[1] === "OK" ? "passed" : m[1] === "SKIP" ? "skipped" : "failed";
events.push({ type: "result", path: pending.path, status });
// A failure's trace follows on the next lines; the summary must not become its output.
trace = status === "failed" ? pending : null;
pending = null;
return events;
}
if (pending && (m = FRAME_RE.exec(line))) {
pending.frames.push({ path: m[1]!, line: Number(m[2]), col: Number(m[3]) });
return events;
}
if ((m = PROGRESS_RE.exec(line))) {
const { stem, name } = splitName(m[3]!);
pending = { path: pathOf(stem, name), frames: [] };
events.push({ type: "start", path: pending.path });
const status = statusOf(m[4]!);
if (status) {
if (status.output) events.push({ type: "output", path: pending.path, line: status.output });
events.push({ type: "result", path: pending.path, status: status.status });
trace = status.status === "failed" ? pending : null;
pending = null;
} else if (m[4]) {
events.push({ type: "output", path: pending.path, line: m[4] });
}
return events;
}
if ((m = FAILED_RE.exec(line))) {
const { stem, name } = splitName(m[1]!);
block = { path: pathOf(stem, name), frames: [] };
events.push({ type: "result", path: block.path, status: "failed" });
return events;
}
if (pending) {
// Anything else before the status is the running test's output (prints, panic text).
events.push({ type: "output", path: pending.path, line });
return events;
}
if ((m = SUMMARY_RE.exec(line))) {
events.push({ type: "summary", passed: Number(m[1]), skipped: Number(m[2] ?? 0), failed: Number(m[3] ?? 0) });
} else if ((m = BUILD_SUMMARY_RE.exec(line))) {
events.push({ type: "summary", passed: Number(m[1]), failed: Number(m[2] ?? 0) });
} else if ((m = STEP_RE.exec(line))) {
events.push({ type: "summary", passed: Number(m[1]), skipped: Number(m[2] ?? 0), failed: Number(m[3] ?? 0) });
}
return events;
},
end() {
const events: TestEvent[] = [];
if (trace) {
location(events, trace.path, trace.frames);
trace = null;
}
if (block) {
location(events, block.path, block.frames);
block = null;
}
return events;
},
};
}
+4 -28
Переглянути файл
@@ -11,7 +11,6 @@ import {
registerMenuGroup,
registerOverlay,
registerSettingsPage,
hasRunConfiguration,
type MenuGroupContext,
} from "@/workbench/contributions";
import { insertLiveTemplate, surroundTarget, surroundWith, templateEditorExtension } from "./editor";
@@ -25,7 +24,6 @@ import { tagAtCaret, updateTag } from "./emmet/editor";
import { loadEmmet } from "./emmet/model";
import { promptAbbreviation, UpdateTagDialogHost } from "./emmet/UpdateTagDialog";
import { tx } from "@/platform/i18n";
import type { ProjectType } from "@/platform/projectType";
const EDITOR_WHEN = "textEditorActive && !editorReadOnly && (editorFocus || !toolWindowFocus)";
const MARKUP_PATH = /\.(html?|xhtml|shtml|vue|svelte|xml)$/i;
@@ -94,20 +92,6 @@ function newFileAction(t: FileTemplate): Action {
};
}
// Generic templates and user or plugin templates have no project type restriction; nor do the web
// ones (JavaScript, TypeScript), which IntelliJ IDEA Ultimate offers in every project.
const templateProjectTypes: Partial<Record<string, readonly ProjectType[]>> = {
rust: ["cargo"],
python: ["python"],
jupyter: ["python"],
go: ["go"],
"go-mod": ["go"],
php: ["php"],
"php-class": ["php"],
java: ["maven", "gradle"],
kotlin: ["maven", "gradle"],
};
/** New popup / New › entries: the enabled templates ("File" is the project view's own entry), then From Template…. */
function registerNewMenuGroups(): () => void {
const arg = (ctx: MenuGroupContext) => (ctx.paths[0] ? { directory: ctx.paths[0] } : undefined);
@@ -116,24 +100,16 @@ function registerNewMenuGroups(): () => void {
id: "templates.new",
menus: ["new"],
order: 10,
items: (_menu, ctx) => {
const directory = ctx.paths[0];
const tomcat = directory !== undefined && hasRunConfiguration("tomcat", directory);
return fileTemplates()
.filter((t) => {
if (t.enabled === false || t.id === "file") return false;
if (t.id === "tomcat-context") return tomcat;
const types = templateProjectTypes[t.id];
return !types || !ctx.projectType || types.includes(ctx.projectType);
})
items: (_menu, ctx) =>
fileTemplates()
.filter((t) => t.enabled !== false && t.id !== "file")
.map((t) => ({
kind: "action",
action: `templates.new.${t.id}`,
arg: arg(ctx),
label: t.name,
icon: t.icon,
}));
},
})),
}),
registerMenuGroup({
id: "templates.newFromTemplate",
-106
Переглянути файл
@@ -1,106 +0,0 @@
import { beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { MergeOptions } from "@/bindings";
const mocks = vi.hoisted(() => ({
merges: [] as Array<{ repo: string | null; branches: string[]; options: MergeOptions }>,
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
const ok = (data: unknown) => Promise.resolve({ status: "ok" as const, data });
const head = (repo: string | null) => (repo === "/p/b" ? "dev" : "main");
const local = (repo: string | null) =>
repo === "/p/b" ? ["dev", "feature-b", "shared"] : ["main", "feature-a", "shared"];
return {
...real,
commands: {
...real.commands,
vcsBranchInfo: (repo: string | null) =>
ok({ head: head(repo), headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null }),
vcsBranches: (repo: string | null) =>
ok(local(repo).map((name) => ({ name, remote: false, current: name === head(repo) }))),
vcsMergeWith: (repo: string | null, branches: string[], options: MergeOptions) => {
mocks.merges.push({ repo, branches, options });
return ok(null);
},
},
};
});
import { VcsDialogHost } from "../dialogs";
import { showMergeDialog } from "../gitMenuDialogs";
import { useVcs } from "../store";
beforeEach(() => {
mocks.merges = [];
useVcs.setState({
root: "/p",
roots: ["/p/a", "/p/b"],
repo: "/p/a",
branch: { head: "main", headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null },
available: false,
});
});
const pick = async (combo: string, option: string) => {
fireEvent.click(screen.getByRole("button", { name: combo }));
fireEvent.click(await screen.findByRole("option", { name: option }));
};
it("offers no repository choice when the project has one repository", async () => {
useVcs.setState({ roots: ["/p"], repo: null });
render(<VcsDialogHost />);
await act(() => showMergeDialog());
expect(await screen.findByText("Merge into main")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Repository" })).toBeNull();
await pick("Branch to merge", "feature-a");
fireEvent.click(screen.getByRole("button", { name: "Merge" }));
await waitFor(() => expect(mocks.merges).toHaveLength(1));
expect(mocks.merges[0]).toMatchObject({ repo: null, branches: ["feature-a"] });
});
it("merges into the repository picked in the dialog, offering that repository's branches", async () => {
render(<VcsDialogHost />);
await act(() => showMergeDialog());
expect(await screen.findByText("Merge into main")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Repository" })).toHaveTextContent("a");
await pick("Repository", "b");
expect(await screen.findByText("Merge into dev")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Branch to merge" }));
expect(await screen.findByRole("option", { name: "feature-b" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "feature-a" })).toBeNull();
// The current branch of the picked repository is not offered.
expect(screen.queryByRole("option", { name: "dev" })).toBeNull();
fireEvent.click(screen.getByRole("option", { name: "feature-b" }));
fireEvent.click(screen.getByRole("button", { name: "Merge" }));
await waitFor(() => expect(mocks.merges).toHaveLength(1));
expect(mocks.merges[0]).toMatchObject({ repo: "/p/b", branches: ["feature-b"] });
});
it("clears the chosen branch when another repository is picked", async () => {
render(<VcsDialogHost />);
await act(() => showMergeDialog());
expect(await screen.findByText("Merge into main")).toBeInTheDocument();
// A branch name both repositories have still has to be chosen again for the new one.
await pick("Branch to merge", "shared");
expect(screen.getByRole("button", { name: "Merge" })).toBeEnabled();
await pick("Repository", "b");
expect(await screen.findByText("Merge into dev")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Merge" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Branch to merge" })).not.toHaveTextContent("shared");
// Picking the repository that is already selected keeps the choice.
await pick("Branch to merge", "shared");
await pick("Repository", "b");
expect(screen.getByRole("button", { name: "Merge" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(mocks.merges).toEqual([]);
});
-110
Переглянути файл
@@ -1,110 +0,0 @@
import { beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { RebaseOptions } from "@/bindings";
const mocks = vi.hoisted(() => ({
rebases: [] as Array<{ repo: string | null; upstream: string; options: RebaseOptions }>,
}));
vi.mock("@/platform/ipc", async (importOriginal) => {
const real = await importOriginal<typeof import("@/platform/ipc")>();
const ok = (data: unknown) => Promise.resolve({ status: "ok" as const, data });
const head = (repo: string | null) => (repo === "/p/b" ? "dev" : "main");
const local = (repo: string | null) =>
repo === "/p/b" ? ["dev", "feature-b", "shared"] : ["main", "feature-a", "shared"];
return {
...real,
commands: {
...real.commands,
vcsBranchInfo: (repo: string | null) =>
ok({ head: head(repo), headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null }),
vcsBranches: (repo: string | null) =>
ok(local(repo).map((name) => ({ name, remote: false, current: name === head(repo) }))),
vcsRebaseWith: (repo: string | null, upstream: string, options: RebaseOptions) => {
mocks.rebases.push({ repo, upstream, options });
return ok(null);
},
},
};
});
import { VcsDialogHost } from "../dialogs";
import { showRebaseDialog } from "../gitMenuDialogs";
import { useVcs } from "../store";
beforeEach(() => {
mocks.rebases = [];
useVcs.setState({
root: "/p",
roots: ["/p/a", "/p/b"],
repo: "/p/a",
branch: { head: "main", headCommit: "c1", upstream: null, ahead: 0, behind: 0, operation: null },
available: false,
});
});
const pick = async (combo: string, option: string) => {
fireEvent.click(screen.getByRole("button", { name: combo }));
fireEvent.click(await screen.findByRole("option", { name: option }));
};
it("offers no repository choice when the project has one repository", async () => {
useVcs.setState({ roots: ["/p"], repo: null });
render(<VcsDialogHost />);
await act(() => showRebaseDialog());
expect(await screen.findByText("Rebase main onto:")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Repository" })).toBeNull();
await pick("Upstream", "feature-a");
fireEvent.click(screen.getByRole("button", { name: "Rebase" }));
await waitFor(() => expect(mocks.rebases).toHaveLength(1));
expect(mocks.rebases[0]).toMatchObject({ repo: null, upstream: "feature-a" });
});
it("rebases the repository picked in the dialog, offering that repository's branches", async () => {
render(<VcsDialogHost />);
await act(() => showRebaseDialog());
expect(await screen.findByText("Rebase main onto:")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Repository" })).toHaveTextContent("a");
await pick("Repository", "b");
expect(await screen.findByText("Rebase dev onto:")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Upstream" }));
expect(await screen.findByRole("option", { name: "feature-b" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "feature-a" })).toBeNull();
// The current branch of the picked repository is not offered.
expect(screen.queryByRole("option", { name: "dev" })).toBeNull();
fireEvent.click(screen.getByRole("option", { name: "feature-b" }));
fireEvent.click(screen.getByRole("button", { name: "Rebase" }));
await waitFor(() => expect(mocks.rebases).toHaveLength(1));
expect(mocks.rebases[0]).toMatchObject({ repo: "/p/b", upstream: "feature-b" });
});
it("clears the chosen branches when another repository is picked", async () => {
render(<VcsDialogHost />);
await act(() => showRebaseDialog());
expect(await screen.findByText("Rebase main onto:")).toBeInTheDocument();
// Branch names both repositories have still have to be chosen again for the new one.
await pick("Upstream", "shared");
fireEvent.click(screen.getByRole("checkbox", { name: "--onto" }));
await pick("New base", "shared");
expect(screen.getByRole("button", { name: "Rebase" })).toBeEnabled();
await pick("Repository", "b");
expect(await screen.findByText("Rebase commits of dev not on:")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Rebase" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Upstream" })).not.toHaveTextContent("shared");
expect(screen.getByRole("button", { name: "New base" })).not.toHaveTextContent("shared");
// Picking the repository that is already selected keeps the choice.
await pick("Upstream", "shared");
await pick("New base", "shared");
await pick("Repository", "b");
expect(screen.getByRole("button", { name: "Rebase" })).toBeEnabled();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(mocks.rebases).toEqual([]);
});
+4 -19
Переглянути файл
@@ -284,8 +284,8 @@ export function showMergeDialog() {
}
function MergeDialog({ onClose }: { onClose: () => void }) {
const [repo, setRepo] = useState(currentRepo);
const head = useBranchInfo(repo)?.head ?? null;
const [repo] = useState(currentRepo);
const [head] = useState(() => useVcs.getState().branch?.head ?? null);
const branches = useBranches(repo);
const [branch, setBranch] = useState<string | null>(null);
const [flags, setFlags] = useState<Set<MergeFlag>>(() => new Set());
@@ -296,12 +296,6 @@ function MergeDialog({ onClose }: { onClose: () => void }) {
[branches],
);
const chooseRepo = (root: string) => {
if (root === repo) return;
setRepo(root);
setBranch(null);
};
const run = async () => {
if (!branch) return;
onClose();
@@ -327,7 +321,6 @@ function MergeDialog({ onClose }: { onClose: () => void }) {
]}
>
<div className={styles.form}>
<RepoPicker repo={repo} onChange={chooseRepo} />
<div className={styles.fieldLabel}>
<T>Branch to merge:</T>
</div>
@@ -363,8 +356,8 @@ export function showRebaseDialog() {
}
function RebaseDialog({ onClose }: { onClose: () => void }) {
const [repo, setRepo] = useState(currentRepo);
const head = useBranchInfo(repo)?.head ?? null;
const [repo] = useState(currentRepo);
const [head] = useState(() => useVcs.getState().branch?.head ?? null);
const branches = useBranches(repo);
const [upstream, setUpstream] = useState<string | null>(null);
const [useOnto, setUseOnto] = useState(false);
@@ -377,13 +370,6 @@ function RebaseDialog({ onClose }: { onClose: () => void }) {
);
const ready = !!upstream && (!useOnto || !!onto);
const chooseRepo = (root: string) => {
if (root === repo) return;
setRepo(root);
setUpstream(null);
setOnto(null);
};
const run = async () => {
if (!upstream) return;
onClose();
@@ -417,7 +403,6 @@ function RebaseDialog({ onClose }: { onClose: () => void }) {
]}
>
<div className={styles.form}>
<RepoPicker repo={repo} onChange={chooseRepo} />
<div className={styles.fieldLabel}>
{useOnto ? tx`Rebase commits of ${head ?? "HEAD"} not on:` : `Rebase ${head ?? "HEAD"} onto:`}
</div>
-2
Переглянути файл
@@ -13,7 +13,6 @@
"Rename Group": "Rename Group",
"Remove from Group": "Remove from Group",
"Remove “{name}”? Its tasks move back to their status groups.": "Remove “{name}”? Its tasks move back to their status groups.",
"Copy Link Address": "Copy Link Address",
"Pin Agent Tasks": "Pin Agent Tasks",
"Unpin Agent Tasks": "Unpin Agent Tasks",
"New Agent Task Group": "New Agent Task Group",
@@ -182,7 +181,6 @@
"This debugger cannot list instances.": "This debugger cannot list instances.",
"The debugger cannot list instances: {detail}": "The debugger cannot list instances: {detail}",
"Pause the program to see instances.": "Pause the program to see instances.",
"The Memory view is available once the program has started.": "The Memory view is available once the program has started.",
"The object has been garbage collected.": "The object has been garbage collected.",
"The class is not loaded in the JVM.": "The class is not loaded in the JVM.",
"Could not tell which JVM this session debugs.": "Could not tell which JVM this session debugs.",
-4
Переглянути файл
@@ -8,7 +8,6 @@
"Rename Group": "重命名组",
"Remove from Group": "从组中移除",
"Remove “{name}”? Its tasks move back to their status groups.": "移除“{name}”?其中的任务将回到各自的状态分组。",
"Copy Link Address": "复制链接地址",
"Pin Agent Tasks": "固定 Agent 任务",
"Unpin Agent Tasks": "取消固定 Agent 任务",
"New Agent Task Group": "新建 Agent 任务组",
@@ -159,7 +158,6 @@
"This debugger cannot list instances.": "此调试器无法列出实例。",
"The debugger cannot list instances: {detail}": "调试器无法列出实例:{detail}",
"Pause the program to see instances.": "暂停程序以查看实例。",
"The Memory view is available once the program has started.": "程序启动后即可使用内存视图。",
"The object has been garbage collected.": "该对象已被垃圾回收。",
"The class is not loaded in the JVM.": "JVM 中未加载该类。",
"Could not tell which JVM this session debugs.": "无法确定此会话调试的是哪个 JVM。",
@@ -1193,7 +1191,6 @@
"Editor color scheme": "编辑器配色方案",
"Editor resynchronised": "编辑器已重新同步",
"Editor tabs": "编辑器标签页",
"Resize editor tabs": "调整编辑器标签页宽度",
"Editor › Font": "编辑器 › 字体",
"Editor › General": "编辑器 › 常规",
"Effect": "效果",
@@ -1333,7 +1330,6 @@
"Find in Folder…": "在文件夹中查找…",
"Find in Terminal": "在终端中查找",
"Find in console": "在控制台中查找",
"Find in response": "在响应中查找",
"Find in file": "在文件中查找",
"Find in terminal": "在终端中查找",
"Find results": "查找结果",
+9 -6
Переглянути файл
@@ -25,8 +25,6 @@ export interface ConsoleFindBarProps {
onNext: () => void;
onPrevious: () => void;
onClose: () => void;
/** What is searched, for the field's placeholder and label. */
label?: string;
}
export function ConsoleFindBar({
@@ -42,7 +40,6 @@ export function ConsoleFindBar({
onNext,
onPrevious,
onClose,
label = "Find in console",
}: ConsoleFindBarProps) {
const none = !!query && !invalid && count === 0;
const counter = invalid
@@ -84,14 +81,20 @@ export function ConsoleFindBar({
return (
<div className={styles.container}>
<div className={styles.find} role="search" aria-label={tx(label)} data-console-find="" onKeyDown={onKeyDown}>
<div
className={styles.find}
role="search"
aria-label={tx("Find in console")}
data-console-find=""
onKeyDown={onKeyDown}
>
<SearchField
ref={inputRef}
className={styles.findField}
value={query}
onChange={onChange}
placeholder={label}
aria-label={label}
placeholder="Find in console"
aria-label="Find in console"
invalid={invalid || none}
toggles={[
{
-37
Переглянути файл
@@ -35,43 +35,6 @@ describe("copyMenuFor", () => {
expect(items![0]).toMatchObject({ label: "Copy", disabled: false });
});
it.each([
["link", "none"],
["nested", "link"],
["text", "elsewhere"],
])("offers Copy Link Address on a %s target with %s selected", async (target, selection) => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
const url = "https://example.com/pull/42?tab=files#diff";
mount(
`<div class="${selectableText}"><a id="link" href="${url}"><strong id="nested">Review changes</strong></a><p id="elsewhere">Other text</p></div>`,
);
if (selection !== "none") select(document.getElementById(selection)!);
const node = target === "text" ? document.getElementById("nested")!.firstChild : document.getElementById(target);
const items = copyMenuFor(node)!;
expect(items.map((i) => ("label" in i ? i.label : null))).toEqual(["Copy", "Copy Link Address"]);
const link = items[1]!;
if ("run" in link) link.run?.();
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith(url);
});
it("keeps Copy on a link copying the selected text", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true });
mount(`<div class="${selectableText}"><a id="link" href="https://example.com">Review changes</a></div>`);
select(document.getElementById("link")!);
const copy = copyMenuFor(document.getElementById("link"))![0]!;
if ("run" in copy) copy.run?.();
await Promise.resolve();
expect(writeText).toHaveBeenCalledWith("Review changes");
});
it("offers no Copy Link Address on an anchor without a destination or with an in-page one", () => {
mount(`<div class="${selectableText}"><a id="anchor">Section</a><a id="local" href="#top">Top</a></div>`);
for (const id of ["anchor", "local"]) expect(copyMenuFor(document.getElementById(id))).toHaveLength(1);
});
it("offers a disabled Copy in a selectable pane with nothing selected", () => {
mount(`<div class="${selectableText}"><p id="p">Hello</p></div>`);
expect(copyMenuFor(document.getElementById("p"))![0]).toMatchObject({ label: "Copy", disabled: true });
+3 -10
Переглянути файл
@@ -24,19 +24,14 @@ function selectionCovers(sel: Selection | null, target: Node): boolean {
return false;
}
/**
* The Copy menu for a right-click on `target`, or null when it is not on readable text. On a link
* it also offers Copy Link Address, as browsers do; Copy keeps copying the selected text.
*/
/** The Copy menu for a right-click on `target`, or null when it is not on readable text. */
export function copyMenuFor(target: EventTarget | null): MenuItem[] | null {
const el = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
if (!el || el.closest(EDITABLE)) return null;
const sel = window.getSelection();
const text = selectionCovers(sel, el) ? sel!.toString() : "";
const href = el.closest("a[href]")?.getAttribute("href") ?? "";
const link = href.startsWith("#") ? "" : href;
if (!text && !link && !el.closest(`.${selectableText}`)) return null;
const items: MenuItem[] = [
if (!text && !el.closest(`.${selectableText}`)) return null;
return [
{
id: "copy",
label: "Copy",
@@ -46,8 +41,6 @@ export function copyMenuFor(target: EventTarget | null): MenuItem[] | null {
run: () => void copyText(text),
},
];
if (link) items.push({ id: "copy-link", label: "Copy Link Address", run: () => void copyText(link) });
return items;
}
/** Writes `text` to the clipboard; falls back to copying the live selection. */
-8
Переглянути файл
@@ -19,7 +19,6 @@ import {
} from "react";
import type { CommitSelection, FileMatches, SearchQuery, RunConfig, RunId } from "@/bindings";
import { notifyEach } from "@/platform/listeners";
import type { ProjectType } from "@/platform/projectType";
import type { MenuItem } from "@/ui/Menu";
import type { DragData } from "@/ui/pointerDrag";
import type { TreeReorder } from "@/ui/Tree";
@@ -771,7 +770,6 @@ const services: {
notifications?: NotificationService;
embeddedTerminal?: EmbeddedTerminalFactory;
runOutput?: RunOutputService;
hasRunConfiguration?: (kind: string, directory: string) => boolean;
} = {};
function need<T>(s: T | undefined, name: string): T {
@@ -784,10 +782,6 @@ export const provideToolWindowService = (s: ToolWindowService) => void (services
export const provideNotificationService = (s: NotificationService) => void (services.notifications = s);
export const provideEmbeddedTerminal = (f: EmbeddedTerminalFactory | undefined) => void (services.embeddedTerminal = f);
export const provideRunOutput = (s: RunOutputService | undefined) => void (services.runOutput = s);
export const provideRunConfigurationLookup = (lookup: typeof services.hasRunConfiguration) =>
void (services.hasRunConfiguration = lookup);
export const hasRunConfiguration = (kind: string, directory: string): boolean =>
services.hasRunConfiguration?.(kind, directory) ?? false;
export const editorService = (): EditorService => need(services.editor, "editor");
export const toolWindowService = (): ToolWindowService => need(services.toolWindows, "toolWindows");
@@ -1232,8 +1226,6 @@ export interface MenuGroupContext {
paths: string[];
/** Per path: whether it is a directory (project view). */
dirs?: boolean[];
/** New menu: the detected type of the project owning the target directory. */
projectType?: ProjectType;
/** The document text, when the menu is for an open document. */
text?: string;
/** Editor menu: 0-based caret line; gutter menu: the clicked line. */