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

...
Автор SHA1 Повідомлення Дата
l0ng-ai 564f08b062 test(i18n): allow "Python {short}" to read the same in every locale
The conda Python version picker labels its entries "Python 3.12", which every locale writes like English.
2026-11-13 19:11:53 +08:00
Mr0Bread 401d7a4105 fix(database): Tab goes from a result tab to the grid's first cell (#3130)
Tab on a database result tab stepped through every toolbar control (7 to 11 stops) before reaching the grid, and the grid then had no active cell. Now:
- a new WAI-ARIA Toolbar (src/ui/Toolbar.tsx) makes the result, plan, Output and pager toolbars one Tab stop with roving focus on arrows/Home/End;
- Tabs gets an optional focusContent prop, called on plain Tab, so Tab from a result tab focuses the grid (or plan tree / Output log);
- the grid selects its first cell when focused from the keyboard, Tab / Shift+Tab move between cells row by row and leave the grid past either end, and Tab in the cell editor commits and moves on.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 19:05:47 +08:00
Mr0Bread 46bd1f6fb8 fix(ui): accent the selected tab only in the part that has focus (#3124)
The editor, tool windows and database results painted their selected tab with the same accent, so it wasn't clear which part had focus. A focus-scope tracker now marks the editor pane, each tool window and the results view that contain the focused element; outside the focused scope the selected tab uses the existing --bg-tab-selected-inactive / --border-tab-selected-inactive tokens. Scopes are opt-in, so tab strips in dialogs and popups stay accented, and focus moving into a popup, menu or dialog keeps the current marks. IntelliJ theme import maps the inactive underlined tab colours to the same tokens.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 19:05:26 +08:00
Mr0Bread 85f755591b feat(database): Expand All in the Database tool window header (#3140)
The Database tool window header had Collapse All but no Expand All. Add Expand All (db.expandAll) next to it, also reachable from Find Action. It opens every node whose children are already loaded and never connects to a data source or runs a query, so unconnected sources and tables without loaded columns stay closed.

Co-authored-by: Mr0Bread <[email protected]>
2026-11-13 19:05:14 +08:00
Shohruh Ismoilovтаshohruh-genius 06b7513210 fix(vcs): keep Jump to Source current in focused diffs (#3137)
Pressing Cmd+Down or F4 in a focused revision diff did nothing when the working file was found after focus entered the pane: the keyboard dispatcher kept the navigator object registered before that lookup, while the diff replaced it. The diff now keeps the registered navigator and refreshes its handlers in place, so Jump to Source opens the working file at the mapped caret.

Co-authored-by: shohruh-genius <[email protected]>
2026-11-13 19:04:56 +08:00
l0ng-ai 09aa03bb86 fix(debugger): keep the frames | variables split, put the views after the toolbar, badge live sessions (#3144)
The divider between frames and variables is now remembered across debug starts; the Debug tool window's toolbar leads with the session actions, followed by the Threads & Variables / Console / Memory tabs; and the Debug stripe button shows how many debug sessions are live, as Run does.

Refs #3112
2026-11-13 18:58:27 +08:00
l0ng-ai 317988cfa7 fix(python): refresh the interpreter page after recreating an environment, pick a conda env's Python version (#3143)
Creating an environment at the path the project interpreter already pointed to left the Python Interpreter page saying it does not exist: the package list and the validity check reloaded only when the path changed. Both now reload after an environment is created. A new conda environment now asks for a Python version instead of a base interpreter chosen among existing environments, which read as copying one; the note says it starts with only Python.

Refs #3109
2026-11-13 18:58:22 +08:00
l0ng-ai 7db2e98269 fix(project-view): offer Git and Find Usages in workspace members outside the workspace folder (#3142)
The Project view's context menu counted a node as part of the project only inside the window's root, which in a workspace is the workspace folder. Members outside it lost the Git submenu and Find Usages, although their repositories are discovered and their changes listed. Those entries now accept nodes inside the root or any workspace member; Mark Directory as stays limited to the root, whose project structure it edits, and Scratches stay outside.

Fixes #3111
2026-11-13 18:55:42 +08:00
l0ng-ai fdc8bc2d4c fix(editor): leave reference counts to Code Vision's usages (#3139)
csharp-ls adds a "3 Reference(s)" lens above each declaration, next to Code Vision's own "3 usages". It duplicated the count, ignored the Code Vision settings, and was a second set of lens rows replaced after every edit. Language-server lenses that are only a reference count are now left out; Settings › Editor › Inlay Hints › Code Vision › Usages governs the count.

Refs #3107
2026-11-13 18:41:34 +08:00
l0ng-ai 497d4766d7 fix(vcs): lay out a merge commit's file side by side, each pane scrolling (#3138)
Opening a file of a merge commit from the Git log stacked its three panes in a column the editor could not scroll: only the first parent's text showed, without the changes, and neither the wheel nor the arrow keys moved it. The panes now sit side by side under a label row, each scrolls and they stay in step.

Fixes #3108
2026-11-13 18:38:56 +08:00
42 змінених файлів з 906 додано та 91 видалено

No files matched your search

+3
Переглянути файл
@@ -204,6 +204,8 @@ argument; themes that ship no `.icls` keep the built-in syntax colours.
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
@@ -268,6 +270,7 @@ by hand:
| `--link` | `textLink.foreground` |
| `--bg-input`, `--bg-field` | `input.background` |
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
+3
Переглянути файл
@@ -177,6 +177,8 @@ pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor`(Islands), `EditorTabs.underlineColor` |
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
@@ -239,6 +241,7 @@ VS Code 主题(扩展中的 `themes/*.json`)包含 `type`(`dark` / `light`
| `--link` | `textLink.foreground` |
| `--bg-input`, `--bg-field` | `input.background` |
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
+87
Переглянути файл
@@ -174,6 +174,8 @@ for (const [theme, label] of [
const surface = target.locator("..");
const other = tab(page, "Result 3");
const otherSurface = other.locator("..");
// The results hold focus, as they do at the end when the menu is opened from a result tab.
await tab(page, "Result 2").focus();
await page.mouse.move(0, 0);
const idle = await surface.evaluate((element) => getComputedStyle(element).backgroundColor);
const selectedColor = await tab(page, "Result 2")
@@ -227,6 +229,43 @@ for (const [theme, label] of [
});
}
test("accents the selected tabs only in the part holding focus, as IDEA does", async ({ workbench: page }) => {
await openFromTree(page, "README.md");
await seedResults(page);
const token = (name: string) =>
page.evaluate((name) => {
const probe = document.body.appendChild(document.createElement("div"));
probe.style.background = `var(${name})`;
const color = getComputedStyle(probe).backgroundColor;
probe.remove();
return color;
}, name);
const accent = await token("--bg-tab-selected");
const inactive = await token("--bg-tab-selected-inactive");
expect(inactive).not.toBe(accent);
const surfaces = [
page.getByRole("tablist", { name: "Editor tabs" }).getByRole("tab", { name: "README.md" }),
page.getByRole("tab", { name: "Database", exact: true }),
tab(page, "Result 2"),
].map((tab) => tab.locator(".."));
const colors = () =>
Promise.all(surfaces.map((surface) => surface.evaluate((element) => getComputedStyle(element).backgroundColor)));
await page.getByRole("gridcell", { name: "beta rows", exact: true }).click();
await expect.poll(colors).toEqual([inactive, accent, accent]);
// A menu opened from a result tab keeps the results active.
await tab(page, "Result 2").click({ button: "right" });
await expect(menu(page)).toBeVisible();
await expect.poll(colors).toEqual([inactive, accent, accent]);
await page.keyboard.press("Escape");
await expect(menu(page)).toHaveCount(0);
await page.locator(".cm-content").first().click();
await expect.poll(colors).toEqual([accent, inactive, inactive]);
await page.getByRole("tree", { name: "Project", exact: true }).click({ position: { x: 10, y: 10 } });
await expect.poll(colors).toEqual([inactive, inactive, inactive]);
});
test("renames the clicked inactive result without changing its rows and navigates through Output with wraparound", async ({
workbench: page,
}) => {
@@ -512,3 +551,51 @@ test("console runs replace their unpinned results; pinned results stay until Clo
await slot.getByRole("button", { name: "Close Tab", exact: true }).click();
await expect(strip).toHaveText(["Output", result(2)]);
});
test("Tab goes from a result tab to its first cell, and the result toolbar is one stop back", async ({
workbench: page,
}) => {
await openFromTree(page, "README.md");
await seedResults(page);
const grid = page.getByRole("grid");
const cell = page.getByRole("gridcell", { name: "beta rows", exact: true });
await expect(cell).toHaveAttribute("aria-selected", "false");
await tab(page, "Result 2").focus();
await page.keyboard.press("Tab");
await expect(grid).toBeFocused();
await expect(cell).toHaveAttribute("aria-selected", "true");
const toolbar = page.getByRole("toolbar", { name: "Result actions", exact: true });
await page.keyboard.press("Shift+Tab");
await expect(toolbar.getByRole("button", { name: "Table", exact: true })).toBeFocused();
await page.keyboard.press("ArrowRight");
await expect(toolbar.getByRole("button", { name: "Chart", exact: true })).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(tab(page, "Result 2")).toBeFocused();
// The toolbar is entered at the control used last.
await page.keyboard.press("Tab");
await expect(grid).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(toolbar.getByRole("button", { name: "Chart", exact: true })).toBeFocused();
// Past the last cell, Tab leaves the grid instead of landing on its header's filter button.
await page.keyboard.press("Tab");
await expect(grid).toBeFocused();
await page.keyboard.press("Tab");
await expect(grid).not.toBeFocused();
expect(await page.evaluate(() => !!document.activeElement?.closest("[role=grid]"))).toBe(false);
// The keyboard path to the header's local filter is the Local Filter action.
await grid.focus();
await page.evaluate(async () => {
const actionsModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionsModule);
await executeAction("db.localFilter");
});
const filter = page.getByRole("dialog", { name: "Local filter of payload", exact: true });
await expect(filter.getByRole("textbox", { name: "Search values", exact: true })).toBeFocused();
await page.keyboard.press("Escape");
await expect(filter).toBeHidden();
await expect(grid).toBeFocused();
});
+39
Переглянути файл
@@ -0,0 +1,39 @@
import { expect, stubDesktop, test } from "./fixtures";
test.use({ video: "on" });
test("opens the working file with Command+Down after a revision diff loads its source", async ({ workbench: page }) => {
await stubDesktop(page);
await page.evaluate(async () => {
const path = "/dev/acme-api/README.md";
const probe = new Promise((resolve) => {
window.__ipc.handlers.complete_probe = () => resolve({ size: 48, binary: false, tooLarge: false });
});
window.__ipc.handlers.fs_probe = ({ path: candidate }) => (candidate === path ? probe : null);
window.__ipc.handlers.fs_read_text = () => "# Acme API\n\nWorking copy\n";
const keymap = "/src/platform/keymap.ts";
(await import(/* @vite-ignore */ keymap)).applyKeymapSettings("intellij-macos", {});
const actions = "/src/platform/actions.ts";
await (
await import(/* @vite-ignore */ actions)
).executeAction("diff.compareTexts", {
path,
left: { text: "# Acme API\n\nPrevious commit\n", label: "Parent" },
right: { text: "# Acme API\n\nNew commit\n", label: "Commit" },
});
});
const pane = page.locator(".cm-merge-b .cm-content");
await pane.click();
await expect(pane).toBeFocused();
const jump = page.getByRole("button", { name: "Jump to Source", exact: true });
await expect(jump).toHaveCount(0);
await page.evaluate(() => window.__ipc.handlers.complete_probe!({}));
await expect(jump).toBeVisible();
await expect(pane).toBeFocused();
await page.keyboard.press("Meta+ArrowDown");
await page.screenshot({ path: test.info().outputPath("jump-to-source.png"), animations: "disabled" });
await expect(page.getByRole("tab", { name: "README.md", exact: true, selected: true })).toBeVisible();
await expect(pane).toBeHidden();
});
+3
Переглянути файл
@@ -199,6 +199,8 @@ ship no `.icls` at all keep the built-in syntax colours.
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
| `--bg-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBackground` |
| `--border-tab-selected-inactive` | `EditorTabs.inactiveUnderlinedTabBorderColor`, `.inactiveUnderlineColor` |
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
@@ -262,6 +264,7 @@ from `type`, then map:
| `--link` | `textLink.foreground` |
| `--bg-input`, `--bg-field` | `input.background` |
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
| `--bg-tab-selected-inactive`, `--border-tab-selected-inactive` | `tab.unfocusedActiveBackground`, `tab.unfocusedActiveBorderTop` |
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
+1
Переглянути файл
@@ -367,6 +367,7 @@ function PaneView({ paneId }: { paneId: string }) {
tabPrefs.placement === "right" && styles.tabsRight,
)}
data-pane-id={paneId}
data-focus-scope=""
aria-label={tx("Editor")}
onFocus={onFocus}
onBlur={onBlur}
+24 -2
Переглянути файл
@@ -46,7 +46,7 @@ it("keeps mapped lenses across edits instead of clearing them (#2710)", async ()
data: [
{
range: { start: { line: 1, col: 0 }, end: { line: 1, col: 3 } },
command: { title: "1 reference", command: "csharp.showReferences", arguments: [] },
command: { title: "Run test", command: "csharp.runTest", arguments: [] },
},
],
});
@@ -67,5 +67,27 @@ it("keeps mapped lenses across edits instead of clearing them (#2710)", async ()
const after = view.state.facet(codeLenses);
expect(after).toHaveLength(1);
expect(after[0]!.pos).toBe(posBefore + 1);
expect(after[0]!.title).toBe("1 reference");
expect(after[0]!.title).toBe("Run test");
});
it("leaves reference counts to Code Vision's usages", async () => {
vi.useFakeTimers();
const lens = (line: number, title: string) => ({
range: { start: { line, col: 0 }, end: { line, col: 1 } },
command: { title, command: "csharp.showReferences", arguments: [] },
});
ipc.lspCodeLens.mockResolvedValue({
status: "ok",
data: [lens(0, "3 Reference(s)"), lens(1, "1 reference"), lens(2, "12 references"), lens(3, "2 implementations")],
});
const view = new EditorView({
state: EditorState.create({
doc: "a\nb\nc\nd",
extensions: serverCodeLenses({ doc: 1, path: "/p/a.cs", language: "csharp", uiMode: null, readOnly: false }),
}),
parent: document.body,
});
views.push(view);
await vi.advanceTimersByTimeAsync(400);
expect(view.state.facet(codeLenses).map((l) => l.title)).toEqual(["2 implementations"]);
});
+7 -1
Переглянути файл
@@ -6,6 +6,12 @@ import { call, commands } from "@/platform/ipc";
import { onNavigationProvidersChanged } from "@/editor/navigationProviders";
import { posToOffset } from "./positions";
import { onLspRefresh, onServerSettled } from "./availability";
/**
* A server's reference count ("3 references", csharp-ls's "3 Reference(s)"): Code Vision's
* Usages shows the same count above the declaration, under its own setting.
*/
const REFERENCE_COUNT = /^\s*\d+\s+references?(\(s\))?\s*$/i;
const set = StateEffect.define<CodeLens[]>();
const field = StateField.define<CodeLens[]>({
create: () => [],
@@ -62,7 +68,7 @@ export function serverCodeLenses(ctx: EditorDocContext): Extension {
view.dispatch({
effects: set.of(
result.flatMap((l) =>
l.command
l.command && !REFERENCE_COUNT.test(l.command.title)
? [
{
pos: posToOffset(text, l.range.start),
+26
Переглянути файл
@@ -107,3 +107,29 @@ describe("routine grouping", () => {
expect(tree.queryByText("functions")).toBeNull();
});
});
describe("Expand All", () => {
it("opens every loaded node and leaves unloaded ones closed", async () => {
useDatabase.setState({
sources: [
{ id: "pg", name: "PostgreSQL", driver: "postgres", options: { singleDatabase: true } },
{ id: "lite", name: "SQLite", driver: "sqlite" },
],
tables: { [tablesKey("pg", "public")]: { status: "ok", value: [{ name: "users", kind: "table" }] } },
treeExpanded: new Set(),
});
render(<DatabaseView />);
await act(() => executeAction("db.expandAll"));
expect([...useDatabase.getState().treeExpanded].sort()).toEqual([
"pg",
"pg/public",
"pg/public/routines",
"pg/public/tables",
]);
const tree = within(screen.getByRole("tree", { name: "Data sources" }));
expect(tree.getByText("refresh_data")).toBeInTheDocument();
// Its columns aren't loaded, and neither is the SQLite source: Expand All loads nothing.
expect(tree.getByText("users").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
expect(tree.getByText("SQLite").closest('[role="treeitem"]')).toHaveAttribute("aria-expanded", "false");
});
});
+37 -11
Переглянути файл
@@ -58,7 +58,7 @@ import {
useDatabase,
type Loaded,
} from "./store";
import { DB_VIEW_FOCUS, openTableData } from "./treeActions";
import { DB_VIEW_FOCUS, databaseTree, openTableData } from "./treeActions";
import { newMenu, nodeMenu } from "./treeMenu";
import styles from "./Database.module.css";
import { tx } from "@/platform/i18n";
@@ -366,6 +366,34 @@ export function DatabaseView() {
else if (n.kind === "table") await loadDetails(n.id, n.schema, n.table.name);
};
const expandable = (n: Node) =>
n.kind === "source" ||
n.kind === "database" ||
n.kind === "schema" ||
n.kind === "folder" ||
n.kind === "table" ||
n.kind === "group";
// Expand All opens the nodes already loaded: it connects to nothing and runs no query.
useEffect(() => {
databaseTree.expandAll = () => {
const next = new Set(useDatabase.getState().treeExpanded);
const walk = (nodes: readonly Node[]) => {
for (const n of nodes) {
const kids = expandable(n) ? children(n) : undefined;
if (!kids) continue;
next.add(n.key);
walk(kids);
}
};
walk(roots);
useDatabase.setState({ treeExpanded: next });
};
return () => {
databaseTree.expandAll = null;
};
});
const label = (n: Node): string => {
switch (n.kind) {
case "source":
@@ -509,14 +537,7 @@ export function DatabaseView() {
getKey={(n) => n.key}
getLabel={label}
getChildren={children}
isExpandable={(n) =>
n.kind === "source" ||
n.kind === "database" ||
n.kind === "schema" ||
n.kind === "folder" ||
n.kind === "table" ||
n.kind === "group"
}
isExpandable={expandable}
loadChildren={load}
getIcon={(n) => icon(n)}
getDetail={detail}
@@ -662,7 +683,12 @@ function DatabaseToolbar() {
);
}
/** Header controls (shown on hover, like IDEA's): Collapse All. */
/** Header controls (shown on hover, like IDEA's): Expand All, Collapse All. */
export function DatabaseHeaderActions() {
return <IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />;
return (
<>
<IconButton icon="expand-all" action="db.expandAll" size="small" iconSize={16} />
<IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />
</>
);
}
+59 -3
Переглянути файл
@@ -12,6 +12,7 @@ import {
useRef,
useState,
type ClipboardEvent,
type FocusEvent,
type KeyboardEvent,
type MouseEvent,
} from "react";
@@ -392,6 +393,7 @@ export function Grid(props: GridProps) {
canToggle: () => canEdit() && !!bools[sel!.c1] && props.rowState?.(sel!.r1) !== "deleted",
canSetNull: () => canEdit() && nullOk(norm(sel!)),
canSetDefault: () => canEdit() && defaultable(norm(sel!)),
canFilter: () => !!props.onFilter && !!sel && !editing,
toggle: () => {
if (actions.canToggle()) toggleBoolean(sel!.r1, sel!.c1, booleanLiterals(props.driver));
},
@@ -401,6 +403,12 @@ export function Grid(props: GridProps) {
setDefault: () => {
if (actions.canSetDefault()) setDefaults(norm(sel!));
},
filter: () => {
if (!actions.canFilter()) return;
scrollColumnIntoView(sel!.c1);
const anchor = scrollRef.current?.querySelectorAll<HTMLElement>("[data-column-filter]")[sel!.c1];
if (anchor) props.onFilter!(sel!.c1, anchor);
},
};
Object.assign((cellActions.current ??= {} as CellActions), actions);
});
@@ -430,6 +438,14 @@ export function Grid(props: GridProps) {
startEdit(sel.r1, sel.c1, e.key === "Enter" && e.shiftKey);
return;
}
if (e.key === "Tab") {
// IDEA's tables: Tab moves between cells, leaving the grid after the last one.
const next = mod || e.altKey ? null : nextCell(sel.r1, sel.c1, e.shiftKey ? -1 : 1);
if (!next) return;
e.preventDefault();
moveTo(next[0], next[1], false);
return;
}
const moves: Record<string, [number, number]> = {
ArrowUp: [-1, 0],
ArrowDown: [1, 0],
@@ -439,13 +455,32 @@ export function Grid(props: GridProps) {
const m = moves[e.key];
if (!m) return;
e.preventDefault();
const r = Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0]));
const c = Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1]));
setSel(e.shiftKey ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
moveTo(
Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0])),
Math.max(0, Math.min(columns.length - 1, sel.c1 + m[1])),
e.shiftKey,
);
};
/** The cell Tab (`step` 1) or Shift+Tab (-1) goes to, row by row; null past either end. */
const nextCell = (r: number, c: number, step: 1 | -1): [number, number] | null => {
const i = Math.min(r, rowCount - 1) * columns.length + c + step;
return i < 0 || i >= rowCount * columns.length ? null : [Math.floor(i / columns.length), i % columns.length];
};
const moveTo = (r: number, c: number, extend: boolean) => {
setSel(extend && sel ? { ...sel, r1: r, c1: c } : { r0: r, c0: c, r1: r, c1: c });
virtualizer.scrollToIndex(r, { align: "auto" });
scrollColumnIntoView(c);
};
/** Focus from a click lands on the clicked cell; from the keyboard, on the first cell. */
const pointerFocus = useRef(false);
const onFocus = (e: FocusEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget || pointerFocus.current || sel || editing) return;
if (rowCount && columns.length) moveTo(0, 0, false);
};
const onCellMouseDown = (e: MouseEvent, r: number, c: number) => {
if (e.button !== 0) {
if (!selected(r, c)) setSel({ r0: r, c0: c, r1: r, c1: c });
@@ -512,6 +547,9 @@ export function Grid(props: GridProps) {
{ kind: "action", action: "db.setCellDefault", arg: cellActions.current },
);
}
if (props.onFilter) {
items.push({ kind: "separator" }, { kind: "action", action: "db.localFilter", arg: cellActions.current });
}
const extra = rowMenu?.(selectedRows()) ?? [];
if (extra.length) items.push({ kind: "separator" }, ...extra);
showContextMenu(e, items);
@@ -546,6 +584,11 @@ export function Grid(props: GridProps) {
aria-label={props["aria-label"] ?? "Results"}
aria-rowcount={rowCount}
onKeyDown={onKeyDown}
onFocus={onFocus}
onMouseDownCapture={() => {
pointerFocus.current = true;
setTimeout(() => (pointerFocus.current = false));
}}
onPaste={onPaste}
onContextMenu={onContextMenu}
>
@@ -615,6 +658,10 @@ export function Grid(props: GridProps) {
{props.onFilter && (
<button
type="button"
data-column-filter=""
// Not a Tab stop, like IDEA's table header: Tab past the last cell leaves the grid.
// The keyboard opens it with the Local Filter action.
tabIndex={-1}
className={clsx(styles.headerFilter, props.filtered?.(ci) && styles.headerFilterOn)}
aria-label={`Filter ${c.name}`}
title={tx("Local filter")}
@@ -709,6 +756,13 @@ export function Grid(props: GridProps) {
return;
}
if (e.key === "Enter") commit();
if (e.key === "Tab") {
// Like IDEA's table editor: Tab commits and selects the next cell.
e.preventDefault();
const next = nextCell(editing.row, editing.col, e.shiftKey ? -1 : 1);
commit();
if (next) moveTo(next[0], next[1], false);
}
if (e.key === "Escape") {
skipBlurCommit.current = true;
setEditing(null);
@@ -726,6 +780,7 @@ export function Grid(props: GridProps) {
<div
key={c}
role="gridcell"
aria-selected={selected(r, c)}
className={clsx(
styles.cell,
numeric[c] && styles.cellNumber,
@@ -751,6 +806,7 @@ export function Grid(props: GridProps) {
onDoubleClick={(e) => e.stopPropagation()}
>
<Checkbox
tabIndex={-1}
className={styles.cellCheckbox}
aria-label={tx`Toggle ${columns[c]!.name}`}
checked={val === lit.t}
+25 -14
Переглянути файл
@@ -21,6 +21,7 @@ import {
showContextMenu,
T,
tooltipAttrs,
Toolbar,
} from "@/ui";
import { notifications } from "@/workbench/contributions";
import { ToolWindowContentTabs } from "@/workbench/toolwindows/ToolWindows";
@@ -80,10 +81,10 @@ function Output() {
}, [lines.length]);
return (
<div className={styles.view}>
<div className={styles.toolbar}>
<Toolbar className={styles.toolbar} aria-label="Output actions">
<span className={styles.spacer} />
<IconButton icon="trash" title="Clear All" disabled={!lines.length} onClick={clearOutput} />
</div>
</Toolbar>
<div
ref={ref}
className={styles.output}
@@ -148,12 +149,7 @@ function Pager({ tab }: { tab: ResultTab }) {
const total = w.total === null ? `${w.last + 1}+` : String(w.total);
const sizes = PAGE_SIZES.includes(pageSize) ? PAGE_SIZES : [...PAGE_SIZES, pageSize].sort((a, b) => a - b);
return (
<div
className={styles.resultPager}
role="toolbar"
aria-label={tx("Result pages")}
title={outcomeSummary(tab.outcome)}
>
<Toolbar className={styles.resultPager} aria-label="Result pages" title={outcomeSummary(tab.outcome)}>
<IconButton
icon="page-first"
title="First Page"
@@ -198,7 +194,7 @@ function Pager({ tab }: { tab: ResultTab }) {
onChange={(v) => go(0, Number(v))}
disabled={!canPage}
/>
</div>
</Toolbar>
);
}
@@ -433,7 +429,7 @@ function Result({ tab }: { tab: ResultTab }) {
}
return (
<div className={styles.view} ref={viewRef} data-db-table-editor="">
<div className={styles.toolbar}>
<Toolbar className={styles.toolbar} aria-label="Result actions">
<IconButton icon="database" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
<IconButton icon="db-chart" title="Chart" aria-pressed={view === "chart"} onClick={() => setView("chart")} />
<span className={styles.toolbarSeparator} />
@@ -487,7 +483,7 @@ function Result({ tab }: { tab: ResultTab }) {
onClick={(e) => showContextMenu(e, exportMenu(data(), queryTable(text, { driver }), extractor))}
/>
{view === "table" && <PanelToggle panel={panel} onChange={setPanel} />}
</div>
</Toolbar>
{view === "chart" ? (
<div className={styles.fill}>
<ChartView columns={columns} rows={r.rows} />
@@ -595,7 +591,7 @@ function PlanResult({ tab }: { tab: ResultTab }) {
const selectedKey = selected < 0 ? statement.key : String(selected);
return (
<div className={styles.view}>
<div className={styles.toolbar}>
<Toolbar className={styles.toolbar} aria-label="Result actions">
<IconButton icon="refresh" title="Explain Again" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
<PinButton tab={tab} />
{tab.busy && <Spinner size={12} />}
@@ -603,7 +599,7 @@ function PlanResult({ tab }: { tab: ResultTab }) {
<span className={styles.resultSource} title={tab.outcome.statement.text}>
{targetLabel(tab.sourceId)}
</span>
</div>
</Toolbar>
<div className={clsx(styles.fill, styles.planView)}>
<div className={styles.planTree}>
<div className={styles.planHeader}>
@@ -677,6 +673,13 @@ export function ResultsView() {
const active = useDatabase((s) => s.activeResult);
const hasOutput = useDatabase((s) => s.output.length > 0);
const root = useRef<HTMLDivElement>(null);
const panel = useRef<HTMLDivElement>(null);
/** Tab from a result tab: its table, plan tree or output log (IDEA focuses the tab's content). */
const focusContent = () => {
const target = panel.current?.querySelector<HTMLElement>('[role="grid"], [role="tree"], [role="log"]');
target?.focus();
return !!target && document.activeElement === target;
};
useEffect(() => {
const syncFocus = () => setContext("dbResultsFocus", !!root.current?.contains(document.activeElement));
document.addEventListener("focusin", syncFocus);
@@ -704,6 +707,7 @@ export function ResultsView() {
<div
ref={root}
className={styles.view}
data-focus-scope=""
onKeyDown={(e) => {
if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return;
const tab = (e.target as Element).closest<HTMLElement>('[role="tab"]');
@@ -724,6 +728,7 @@ export function ResultsView() {
aria-label="Results"
activeId={tab ? String(tab.id) : String(OUTPUT_TAB)}
panelIdPrefix="db-results"
focusContent={focusContent}
onSelect={(id) => useDatabase.setState({ activeResult: Number(id) })}
onClose={(id) => closeResult(Number(id))}
onTogglePin={(id) => {
@@ -744,7 +749,13 @@ export function ResultsView() {
]}
/>
</ToolWindowContentTabs>
<div className={styles.fill}>
<div
ref={panel}
className={styles.fill}
role="tabpanel"
id={`db-results-panel-${tab ? tab.id : OUTPUT_TAB}`}
aria-labelledby={`db-results-tab-${tab ? tab.id : OUTPUT_TAB}`}
>
{!tab ? <Output /> : tab.plan ? <PlanResult key={tab.id} tab={tab} /> : <Result key={tab.id} tab={tab} />}
</div>
</div>
+11
Переглянути файл
@@ -5,9 +5,12 @@ export interface CellActions {
canToggle(): boolean;
canSetNull(): boolean;
canSetDefault(): boolean;
canFilter(): boolean;
toggle(): void;
setNull(): void;
setDefault(): void;
/** Opens the local filter of the selected cell's column. */
filter(): void;
}
const grids = new WeakMap<Element, CellActions>();
@@ -59,6 +62,14 @@ export function installCellActions(): () => void {
enabled: (c) => handle(c)?.canSetDefault() ?? false,
run: (c) => handle(c)?.setDefault(),
},
{
id: "db.localFilter",
title: "Local Filter",
category: "Database",
keyWhen: "dbCellGridFocus",
enabled: (c) => handle(c)?.canFilter() ?? false,
run: (c) => handle(c)?.filter(),
},
]);
return () => {
dispose();
+70
Переглянути файл
@@ -1,4 +1,5 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DbDriver, DbQueryResult, DbTableDetails } from "@/platform/ipc";
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
@@ -432,6 +433,75 @@ describe("result grid keyboard Submit / Revert (#2332)", () => {
});
});
describe("Tab in the results (IDEA: Tab enters the table and moves between its cells)", () => {
const selectedCells = () =>
screen
.getAllByRole("gridcell")
.filter((cell) => cell.getAttribute("aria-selected") === "true")
.map((cell) => cell.textContent);
const rows = [
["1", "Ann"],
["2", "Bob"],
];
it("goes from the result tab to the first cell, cell by cell, and back through the toolbar's one stop", async () => {
const user = userEvent.setup();
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
await act(async () => render(<ResultsView />));
await screen.findByRole("button", { name: "Add Row" });
act(() => screen.getByRole("tab", { name: "Result 1" }).focus());
await user.tab();
expect(screen.getByRole("grid")).toHaveFocus();
expect(selectedCells()).toEqual(["1"]);
await user.tab();
expect(selectedCells()).toEqual(["Ann"]);
await user.tab();
expect(selectedCells()).toEqual(["2"]);
await user.tab({ shift: true });
await user.tab({ shift: true });
expect(selectedCells()).toEqual(["1"]);
await user.tab({ shift: true });
expect(screen.getByRole("toolbar", { name: "Result actions" })).toContainElement(
document.activeElement as HTMLElement,
);
await user.tab({ shift: true });
expect(screen.getByRole("tab", { name: "Result 1" })).toHaveFocus();
});
it("leaves the grid after the last cell", async () => {
const user = userEvent.setup();
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
await act(async () => render(<ResultsView />));
fireEvent.mouseDown(screen.getByRole("gridcell", { name: "Bob" }), { button: 0 });
await user.tab();
expect(screen.getByRole("grid")).not.toHaveFocus();
expect(selectedCells()).toEqual(["Bob"]);
});
it("commits the cell editor with Tab and selects the next cell", async () => {
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
await act(async () => render(<ResultsView />));
fireEvent.doubleClick(await screen.findByRole("gridcell", { name: "Ann" }));
const input = screen.getByRole("textbox", { name: "Edit name" });
fireEvent.change(input, { target: { value: "Cara" } });
fireEvent.keyDown(input, { key: "Tab" });
expect(screen.getByRole("gridcell", { name: "Cara" })).toBeTruthy();
expect(selectedCells()).toEqual(["2"]);
expect(screen.getByRole("grid")).toHaveFocus();
});
it("goes from the Output tab to the log, past its toolbar", async () => {
const user = userEvent.setup();
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], rows);
useDatabase.setState({ output: [{ id: 1, time: 0, kind: "statement", text: "SELECT * FROM users" }] });
await act(async () => render(<ResultsView />));
fireEvent.click(screen.getByRole("tab", { name: "Output" }));
act(() => screen.getByRole("tab", { name: "Output" }).focus());
await user.tab();
expect(screen.getByRole("log", { name: "Database output" })).toHaveFocus();
});
});
it("omits a reset inserted cell from Submit instead of writing SQL NULL", async () => {
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
+10
Переглянути файл
@@ -44,6 +44,10 @@ import { tx } from "@/platform/i18n";
/** Context key: the Database tree has focus. */
export const DB_VIEW_FOCUS = "databaseViewFocus";
/** Set while the Database view is mounted: Expand All walks its tree's loaded nodes. */
export const databaseTree: { expandAll: (() => void) | null } = { expandAll: null };
const OPEN = "projectOpen";
const isNode = (v: unknown): v is DbNode => !!v && typeof v === "object" && "kind" in v && "key" in v;
@@ -481,6 +485,12 @@ export function registerDatabaseTreeActions(): () => void {
title: "Separate Procedures and Functions",
run: toggleSeparateProceduresAndFunctions,
}),
tree({
id: "db.expandAll",
title: "Expand All",
icon: "expand-all",
run: () => databaseTree.expandAll?.(),
}),
tree({
id: "db.collapseAll",
title: "Collapse All",
+6
Переглянути файл
@@ -48,6 +48,11 @@ import { forgetMemory } from "./memory/memoryState";
const useAnyOverlay = () =>
useOverlays((s) => !!(s.popover || s.evaluate || s.breakpoints || s.chooser || s.attach || s.smartStep));
/** The Debug stripe button's badge, like Run's: how many debug sessions (child sessions aside) are live. */
function useLiveSessionCount(): number | null {
return useDebug((s) => Object.values(s.sessions).filter((x) => x.parent === null && isLive(x)).length || null);
}
export function activate(): () => void {
useDebugRunSessions.setState({
Console: lazyComponent(() => import("./ui/DebugConsole").then((m) => m.DebugServiceConsole)),
@@ -71,6 +76,7 @@ export function activate(): () => void {
order: 20,
component: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugToolWindow)),
headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)),
useBadge: useLiveSessionCount,
}),
registerEditorKind(heapEditorKind),
installHeapContext(),
+30 -25
Переглянути файл
@@ -176,30 +176,7 @@ export function DebugToolWindow() {
return (
<div className={styles.root}>
<div className={styles.toolbar} role="toolbar" aria-label={tx("Debugger actions")}>
<div role="tablist" aria-label={tx("Debug views")} className={styles.tabs}>
{(
[
["variables", "Threads & Variables"],
["console", "Console"],
...(hasMemory ? [["memory", "Memory"] as const] : []),
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
id={`debug-tab-${id}`}
aria-selected={tab === id}
aria-controls={`debug-panel-${id}`}
className={clsx(styles.tab, tab === id && styles.tabActive)}
onClick={() => setView({ session: active.startedAt, status: active.status, tab: id })}
>
{tx(label)}
</button>
))}
</div>
{/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group). */}
<span className={styles.sep} aria-hidden />
{/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group), then the views. */}
<ToolButton action="debug.rerun" icon="rerun" />
<ToolButton action="debug.stop" icon="stop" tone="danger" />
<span className={styles.sep} aria-hidden />
@@ -233,6 +210,29 @@ export function DebugToolWindow() {
onClick={() => setMuted(!muted)}
/>
<MoreActions />
<span className={styles.sep} aria-hidden />
<div role="tablist" aria-label={tx("Debug views")} className={styles.tabs}>
{(
[
["variables", "Threads & Variables"],
["console", "Console"],
...(hasMemory ? [["memory", "Memory"] as const] : []),
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
id={`debug-tab-${id}`}
aria-selected={tab === id}
aria-controls={`debug-panel-${id}`}
className={clsx(styles.tab, tab === id && styles.tabActive)}
onClick={() => setView({ session: active.startedAt, status: active.status, tab: id })}
>
{tx(label)}
</button>
))}
</div>
</div>
<div role="tabpanel" id={`debug-panel-${tab}`} aria-labelledby={`debug-tab-${tab}`} className={styles.panel}>
{tab === "variables" ? (
@@ -251,7 +251,12 @@ export function DebugToolWindow() {
function ThreadsAndVariables({ session }: { session: DebugSession }) {
return (
<SplitGroup orientation="horizontal" id="debug-threads-variables" className={styles.split}>
<SplitGroup
orientation="horizontal"
id="debug-threads-variables"
storageKey="openidea.debug.threadsVariables"
className={styles.split}
>
<SplitPanel id="frames" defaultSize="34%" minSize="160px">
<FramesPane session={session} />
</SplitPanel>
+6
Переглянути файл
@@ -55,6 +55,12 @@ export const UI_KEYS: Record<string, string[]> = {
"--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"],
// Islands paints the selected tab's border with `underlinedBorderColor`; the classic tabs underline with `underlineColor`.
"--border-tab-selected": ["EditorTabs.underlinedBorderColor", "EditorTabs.underlineColor"],
// The selected tab of a part without focus (src/ui/focusScope.ts).
"--bg-tab-selected-inactive": ["EditorTabs.inactiveUnderlinedTabBackground"],
"--border-tab-selected-inactive": [
"EditorTabs.inactiveUnderlinedTabBorderColor",
"EditorTabs.inactiveUnderlineColor",
],
"--bg-tab-hover": ["EditorTabs.hoverBackground"],
"--border-editor-tabs": ["EditorTabs.borderColor"],
"--bg-toolbar-hover": ["ActionButton.hoverBackground"],
+26
Переглянути файл
@@ -301,6 +301,32 @@ it("offers workspace removal for every member root", () => {
);
});
it("offers Git and Find Usages in workspace members outside the workspace folder", () => {
useProject.setState({
project: {
root: "/work/workspaces/demo",
name: "demo",
settings: {},
trusted: true,
isGit: false,
workspace: { version: 1, name: "demo", projects: ["/work/repos/a", "/work/repos/b"] },
},
});
setContext("vcsAvailable", true);
try {
for (const target of [node("/work/repos/a", true, true), node("/work/repos/a/src", true)])
expect(sub(contextMenuItems([target]), "Git")).toContain("Commit…");
expect(sub(contextMenuItems([node("/work/repos/a/x.py")]), "Git")).toContain("Annotate with Git Blame");
expect(labels(contextMenuItems([node("/work/repos/a/x.py")]))).toContain("Find Usages");
// Project structure is the workspace folder's: its roots can't name a folder outside it.
expect(labels(contextMenuItems([node("/work/repos/a/src", true)]))).not.toContain("Mark Directory as");
// Scratches stay outside.
expect(sub(contextMenuItems([node("/config/scratches/s.py")]), "Git")).toBeUndefined();
} finally {
clearContext("vcsAvailable");
}
});
it("does not offer removal on the attached primary", () => {
useProject.setState({
project: {
+5 -2
Переглянути файл
@@ -151,6 +151,9 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
const project = useProject.getState().project;
// Scratches and other contributed roots live outside the project: no VCS, roots or LSP usages.
const inProject = !!project && nodes.every((n) => isSameOrInside(n.path, project.root));
// Workspace members may live outside the workspace folder; they have VCS and usages too.
const memberRoots = project ? [project.root, ...(project.workspace?.projects ?? [])] : [];
const inWorkspace = nodes.every((n) => memberRoots.some((root) => isSameOrInside(n.path, root)));
const items: MenuItem[] = [];
const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i));
@@ -228,7 +231,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
shortcut: keyLabel("project.open"),
run: () => files.forEach((f) => void openFile(f.path)),
},
inProject &&
inWorkspace &&
singleFile && {
id: "findUsages",
label: "Find Usages",
@@ -338,7 +341,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
);
// Git › (`vcsAvailable` also covers repositories nested below a non-Git project root)
if (inProject && (project?.isGit || getContext().vcsAvailable === true)) {
if (inWorkspace && (project?.isGit || getContext().vcsAvailable === true)) {
const arg = vcsArg(nodes);
const dirs = nodes.some((n) => n.dir);
push(
+29 -16
Переглянути файл
@@ -43,13 +43,23 @@ export function AddInterpreterDialog({
const [error, setError] = useState<string | null>(null);
const ids = { base: useId(), location: useId(), existing: useId() };
const baseValue = base ?? bases[0]?.path ?? null;
const toOption = (s: { path: string; name: string }): SelectOption => ({
value: s.path,
label: s.name,
detail: tildify(s.path),
});
const baseOptions = bases.map(toOption);
// conda installs a fresh Python of the base's version: offer versions, not environments to copy.
const versionOptions = (() => {
const seen = new Map<string, SelectOption>();
for (const s of bases) {
const short = s.version?.split(".").slice(0, 2).join(".");
if (short && !seen.has(short)) seen.set(short, { value: s.path, label: tx`Python ${short}` });
}
return [...seen.values()].sort((a, b) => b.label.localeCompare(a.label, undefined, { numeric: true }));
})();
const baseOptions = kind === "conda" ? versionOptions : bases.map(toOption);
const baseValue =
base ?? (kind === "conda" ? (versionOptions[0]?.value as string | undefined) : bases[0]?.path) ?? null;
const existingOptions = [...browsed, ...list.filter((s) => !browsed.some((b) => b.path === s.path))].map((s) => ({
value: s.path,
label: `${s.name} — ${tildify(s.path)}`,
@@ -108,7 +118,9 @@ export function AddInterpreterDialog({
const canOk = !busy && (mode === "new" ? !!baseValue && !!location.trim() : !!existing?.trim());
const note = {
venv: tx("Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter."),
conda: tx("Runs “conda create -p <location>”; the new interpreter becomes the project interpreter."),
conda: tx(
"Runs “conda create -p <location> python=<version>”: a new environment with only Python in it; the new interpreter becomes the project interpreter.",
),
uv: tx("Runs “uv venv <location> --python <base>”; the new interpreter becomes the project interpreter."),
poetry: tx(
"Runs “poetry env use <base>” in the project; poetry keeps the environment under its own cache directory.",
@@ -143,6 +155,7 @@ export function AddInterpreterDialog({
aria-current={kind === k.tool || undefined}
onClick={() => {
setKind(k.tool);
setBase(null);
setError(null);
}}
>
@@ -199,9 +212,7 @@ export function AddInterpreterDialog({
}}
/>
</div>
<label htmlFor={ids.base}>
<T>Base interpreter:</T>
</label>
<label htmlFor={ids.base}>{kind === "conda" ? <T>Python version:</T> : <T>Base interpreter:</T>}</label>
<div className={styles.pathRow}>
<Select
className={styles.pathInput}
@@ -209,17 +220,19 @@ export function AddInterpreterDialog({
options={baseOptions}
onChange={setBase}
placeholder={loading ? "Detecting…" : "No Python found"}
aria-label="Base interpreter"
/>
<IconButton
icon="folder-open"
size="small"
title="Browse…"
onClick={async () => {
const p = await choosePath({ directory: false, title: "Select Base Interpreter" });
if (p) setBase(p);
}}
aria-label={kind === "conda" ? "Python version" : "Base interpreter"}
/>
{kind !== "conda" && (
<IconButton
icon="folder-open"
size="small"
title="Browse…"
onClick={async () => {
const p = await choosePath({ directory: false, title: "Select Base Interpreter" });
if (p) setBase(p);
}}
/>
)}
</div>
<div className={styles.note}>{note}</div>
</>
+6 -2
Переглянути файл
@@ -68,6 +68,8 @@ export function PythonInterpreterPage() {
const [activate, setActivate] = useSetting<boolean>("languages.python.activateVirtualenv");
const root = useProject((s) => s.project?.root ?? null);
const [auto, setAuto] = useState<string | null>(null);
// Bumped when an environment is created: it may reuse the configured path, whose state changed.
const [created, setCreated] = useState(0);
// Detected interpreters plus the ones added to (or hidden from) the IDE-wide list.
const { detected, rows, hidden } = usePythonInterpreters({ withProjects: false });
const extras = useMemo(
@@ -91,7 +93,7 @@ export function PythonInterpreterPage() {
return () => {
live = false;
};
}, [root]);
}, [root, created]);
// The interpreter runs resolve to: the configured one while it exists, else the fallback.
const [resolved, setResolved] = useState<{ interpreter: string; path: string | null } | null>(null);
@@ -104,7 +106,7 @@ export function PythonInterpreterPage() {
return () => {
live = false;
};
}, [root, interpreter]);
}, [root, interpreter, created]);
// A configured interpreter whose executable is gone (PyCharm's "Invalid"): runs fall back.
const invalid =
!!interpreter && resolved?.interpreter === interpreter && !sameInterpreter(resolved.path, interpreter, root);
@@ -362,6 +364,8 @@ export function PythonInterpreterPage() {
onClose={() => setAdding(false)}
onAdded={(path) => {
setInterpreter(path);
setCreated((n) => n + 1);
reload();
setAdding(false);
}}
/>
+3 -2
Переглянути файл
@@ -1107,7 +1107,8 @@ export function DiffEditor({
viewer: viewerOf,
});
useEffect(() => {
nav.current = {
// Keep the registered navigator's identity while refreshing its handlers.
Object.assign(nav.current, {
next: () => step(1),
prev: () => step(-1),
jumpToSource,
@@ -1115,7 +1116,7 @@ export function DiffEditor({
file,
showSettings,
viewer: viewerOf,
};
});
});
useEffect(
+48
Переглянути файл
@@ -444,6 +444,54 @@
display: flex;
flex-direction: column;
}
/* Go to a merge commit's file: parent 1 | merge result | parent 2, each pane scrolling itself. */
.mergeDiff {
flex: 1;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.mergeDiffLabels {
flex: none;
height: var(--h-row);
display: grid;
grid-template-columns: 1fr 1fr 1fr;
border-bottom: 1px solid var(--border);
color: var(--fg-secondary);
font-size: var(--fs-small);
}
.mergeDiffLabels > span {
display: flex;
align-items: center;
min-width: 0;
padding: 0 12px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.mergeDiffLabels > span + span {
border-left: 1px solid var(--border);
}
.mergeDiffPanes {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
}
.mergeDiffPane {
min-width: 0;
min-height: 0;
display: flex;
overflow: hidden;
}
.mergeDiffPane + .mergeDiffPane {
border-left: 1px solid var(--border);
}
.mergeDiffPane > :global(.cm-editor) {
flex: 1;
min-width: 0;
}
.subjectLine {
color: var(--fg-strong);
font-weight: 600;
+11 -8
Переглянути файл
@@ -51,10 +51,7 @@ export function GitMergeDiff({ input }: { input: EditorInput }) {
}
for (const [i, text] of texts.entries()) {
const pane = document.createElement("div");
pane.style.cssText = "flex:1;min-width:0;display:flex;flex-direction:column";
const label = document.createElement("div");
label.textContent = `${sides[i]!.rev.slice(0, 8)} · ${sides[i]!.path}`;
pane.append(label);
pane.className = styles.mergeDiffPane!;
host.current.append(pane);
const view = new EditorView({
parent: pane,
@@ -86,10 +83,16 @@ export function GitMergeDiff({ input }: { input: EditorInput }) {
element?.replaceChildren();
};
}, [data]);
const labels = [data.parents[0]!, { rev: data.revision, path: data.path }, data.parents[1]!];
return (
<>
<div role={error ? "alert" : undefined}>{error}</div>
<div className={styles.revisionSource} style={{ display: "flex" }} ref={host} />
</>
<div className={styles.mergeDiff}>
{error && <div role="alert">{error}</div>}
<div className={styles.mergeDiffLabels}>
{labels.map((side, i) => (
<span key={i}>{`${side.rev.slice(0, 8)} · ${side.path}`}</span>
))}
</div>
<div className={styles.mergeDiffPanes} ref={host} />
</div>
);
}
+35
Переглянути файл
@@ -560,6 +560,41 @@ describe("Jump to Source of a revision diff", () => {
expect(await jumpFrom(0, 2)).toEqual(["/p/a.ts", { selection: { line: 5, col: 1 }, focus: true }]);
});
it.each([{ key: "ArrowDown", metaKey: true }, { key: "F4" }])(
"jumps with $key when the working copy is found after the pane gains focus",
async (stroke) => {
setUp();
let finishProbe!: (result: unknown) => void;
probe.mockReturnValue(new Promise((resolve) => (finishProbe = resolve)));
const keymap = useKeymap.getState();
applyKeymapSettings("intellij-macos", {});
const unregister = registerCompareActions();
const uninstall = installKeyboardDispatcher(window);
const mounted = render(<DiffEditor input={input} />);
try {
const view = await pane(1);
view.dispatch({ selection: { anchor: view.state.doc.line(3).from + 1 } });
view.focus();
expect(view.contentDOM).toBe(document.activeElement);
expect(screen.queryByRole("button", { name: "Jump to Source" })).toBeNull();
finishProbe({ status: "ok", data: { size: working.length, binary: false, tooLarge: false } });
await screen.findByRole("button", { name: "Jump to Source" });
expect(view.contentDOM).toBe(document.activeElement);
fireEvent.keyDown(view.contentDOM, { ...stroke, code: stroke.key });
await waitFor(() =>
expect(openFile).toHaveBeenCalledExactlyOnceWith("/p/a.ts", { selection: { line: 3, col: 1 }, focus: true }),
);
} finally {
mounted.unmount();
setContext("diffEditorFocus", false);
uninstall();
unregister();
useKeymap.setState(keymap);
}
},
);
it("jumps on ⌘↓ in the IntelliJ macOS keymap only while a diff pane has focus", async () => {
setUp();
const keymap = useKeymap.getState();
+1
Переглянути файл
@@ -264,6 +264,7 @@ describe("catalogs", () => {
"my-agent",
"Java",
"Python",
"Python {short}",
"C/C++",
"C#",
"Go",
+1
Переглянути файл
@@ -404,6 +404,7 @@ export const intellijMacos: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.expandAll": [],
"db.collapseAll": [],
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
"terminal.new": ["Meta+T"],
+1
Переглянути файл
@@ -402,6 +402,7 @@ export const intellijWindows: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.expandAll": [],
"db.collapseAll": [],
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
"terminal.new": ["Ctrl+Shift+T"],
+1
Переглянути файл
@@ -392,6 +392,7 @@ export const vscodeMacos: Record<string, string[]> = {
"db.runSqlScript": [],
"db.sqlScript": [],
"db.goToReferencedTable": [],
"db.expandAll": [],
"db.collapseAll": [],
// Find in Files popup (findPopupOpen): after the editor's keys, so these win while it is open.
"find.popup.nextResult": ["Meta+G"],
+1
Переглянути файл
@@ -1011,6 +1011,7 @@
"Decompiled .class file: sources are not available.": "Decompiled .class file: sources are not available.",
"Set DEFAULT": "Set DEFAULT",
"Toggle Boolean Cell": "Toggle Boolean Cell",
"Local Filter": "Local Filter",
"Toggle {name}": "Toggle {name}",
"true": "true",
"false": "false",
+6 -1
Переглянути файл
@@ -5217,7 +5217,9 @@
"{count} modified action": "已修改 {count} 个操作",
"{count} modified actions": "已修改 {count} 个操作",
"Runs “<base> -m venv <location>”; the new interpreter becomes the project interpreter.": "运行“<base> -m venv <location>”;新的解释器将成为项目解释器。",
"Runs “conda create -p <location>”; the new interpreter becomes the project interpreter.": "运行“conda create -p <location>”;新的解释器将成为项目解释器。",
"Runs “conda create -p <location> python=<version>”: a new environment with only Python in it; the new interpreter becomes the project interpreter.": "运行“conda create -p <location> python=<version>”:新建一个只包含 Python 的环境;新的解释器将成为项目解释器。",
"Python version": "Python 版本",
"Python {short}": "Python {short}",
"Runs “poetry env use <base>” in the project; poetry keeps the environment under its own cache directory.": "在项目中运行“poetry env use <base>”;Poetry 会将环境保存在其自己的缓存目录下。",
"Runs “uv venv <location> --python <base>”; the new interpreter becomes the project interpreter.": "运行“uv venv <location> --python <base>”;新的解释器将成为项目解释器。",
"{vendor} {version} is unpacked into a folder of its own in {location}, where JDK detection finds it.": "{vendor} {version} 将解压到 {location} 下它自己的文件夹中,JDK 检测会在那里找到它。",
@@ -6842,6 +6844,8 @@
"Bad regex": "正则表达式无效",
"Filter rows": "筛选行",
"Result pages": "结果分页",
"Result actions": "结果操作",
"Output actions": "输出操作",
"First Page": "第一页",
"Count rows": "统计行数",
"Last Page": "最后一页",
@@ -7372,6 +7376,7 @@
"Sources not found": "未找到源代码",
"Cannot load library sources": "无法加载库源代码",
"Toggle Boolean Cell": "切换布尔单元格",
"Local Filter": "本地筛选",
"Toggle {name}": "切换 {name}",
"true": "真",
"false": "假",
+13 -1
Переглянути файл
@@ -12,10 +12,21 @@ export interface CheckboxProps {
/** Tri-state display. */
indeterminate?: boolean;
className?: string;
/** `-1` keeps it out of the Tab order, e.g. inside a grid that moves between cells itself. */
tabIndex?: number;
"aria-label"?: string;
}
export function Checkbox({ checked, onChange, label, disabled, indeterminate, className, ...aria }: CheckboxProps) {
export function Checkbox({
checked,
onChange,
label,
disabled,
indeterminate,
className,
tabIndex,
...aria
}: CheckboxProps) {
const id = useId();
const tx = useGivenTx();
return (
@@ -27,6 +38,7 @@ export function Checkbox({ checked, onChange, label, disabled, indeterminate, cl
className={styles.native}
checked={checked}
disabled={disabled}
tabIndex={tabIndex}
aria-checked={indeterminate ? "mixed" : checked}
aria-label={tx(aria["aria-label"])}
onChange={(e) => onChange(e.target.checked)}
+10 -3
Переглянути файл
@@ -1,9 +1,16 @@
.strip {
--tab-selected-bg: var(--bg-tab-selected);
--tab-selected-border: var(--border-tab-selected);
flex: none;
display: flex;
align-items: stretch;
min-width: 0;
}
/* IDEA: only the part holding focus accents its selected tab (src/ui/focusScope.ts). */
:where([data-focus-scope]:not([data-focus-active])) .strip {
--tab-selected-bg: var(--bg-tab-selected-inactive);
--tab-selected-border: var(--border-tab-selected-inactive);
}
/* Islands tabs: the selected tab is a rounded pill (tab-selected-bg / -border); no underline, no separators. */
.editor {
height: var(--h-header);
@@ -47,15 +54,15 @@
}
.selected,
.selected:hover {
background: var(--bg-tab-selected);
box-shadow: inset 0 0 0 1px var(--border-tab-selected);
background: var(--tab-selected-bg);
box-shadow: inset 0 0 0 1px var(--tab-selected-border);
}
.tinted,
.tinted:hover {
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), transparent);
}
.tinted.selected {
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), var(--bg-tab-selected));
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), var(--tab-selected-bg));
}
.tabButton {
display: flex;
+9
Переглянути файл
@@ -132,6 +132,11 @@ export interface TabsProps {
"aria-label"?: string;
/** Tabpanel id prefix for `aria-controls`. */
panelIdPrefix?: string;
/**
* Tab from a tab moves focus into the selected tab's content (IDEA's table, list or tree)
* instead of the next control; returns false when there is nothing to focus.
*/
focusContent?: () => boolean;
/** Enables drag & drop reordering (and moving between strips of the same type). */
dnd?: TabsDnd;
/** Tabs that don't fit: `scroll` the strip (default), `squeeze` them, or `wrap` into rows. */
@@ -156,6 +161,7 @@ export function Tabs({
trailing,
className,
panelIdPrefix,
focusContent,
dnd,
layout = "scroll",
closePosition = "right",
@@ -318,6 +324,9 @@ export function Tabs({
if (onClose && item.closable !== false && !(item.pinned && onTogglePin)) onClose(item.id);
else return;
break;
case "Tab":
if (e.shiftKey || !focusContent?.()) return;
break;
default:
return;
}
+72
Переглянути файл
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Toolbar } from "./Toolbar";
function Bar({ busy = false, extra = false }: { busy?: boolean; extra?: boolean }) {
return (
<>
<button>Before</button>
<Toolbar aria-label="Actions">
<button>Run</button>
<button disabled={busy}>Stop</button>
{extra && <button>Pin</button>}
<button tabIndex={-1}>Hidden</button>
<input aria-label="Filter" />
</Toolbar>
<button>After</button>
</>
);
}
const tabStops = () => [...document.querySelectorAll('[role="toolbar"] [tabindex="0"]')].map((e) => e.textContent);
describe("Toolbar", () => {
it("is one Tab stop that remembers the control used last", async () => {
const user = userEvent.setup();
render(<Bar />);
screen.getByRole("button", { name: "Before" }).focus();
await user.tab();
expect(screen.getByRole("button", { name: "Run" })).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("button", { name: "Stop" })).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: "After" })).toHaveFocus();
await user.tab({ shift: true });
expect(screen.getByRole("button", { name: "Stop" })).toHaveFocus();
});
it("moves with the arrows, Home and End, wrapping, past controls that opt out", async () => {
const user = userEvent.setup();
render(<Bar />);
act(() => screen.getByRole("button", { name: "Run" }).focus());
await user.keyboard("{ArrowLeft}");
expect(screen.getByRole("textbox", { name: "Filter" })).toHaveFocus();
// A text field keeps its caret keys.
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("textbox", { name: "Filter" })).toHaveFocus();
act(() => screen.getByRole("button", { name: "Stop" }).focus());
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("textbox", { name: "Filter" })).toHaveFocus();
act(() => screen.getByRole("button", { name: "Stop" }).focus());
await user.keyboard("{End}");
expect(screen.getByRole("textbox", { name: "Filter" })).toHaveFocus();
act(() => screen.getByRole("button", { name: "Stop" }).focus());
await user.keyboard("{Home}");
expect(screen.getByRole("button", { name: "Run" })).toHaveFocus();
expect(screen.getByRole("button", { name: "Hidden" })).toHaveAttribute("tabindex", "-1");
});
it("keeps one stop when controls are added or the remembered one is disabled", async () => {
const { rerender } = render(<Bar />);
act(() => screen.getByRole("button", { name: "Stop" }).focus());
expect(tabStops()).toEqual(["Stop"]);
rerender(<Bar extra />);
await waitFor(() => expect(screen.getByRole("button", { name: "Pin" })).toHaveAttribute("tabindex", "-1"));
expect(tabStops()).toEqual(["Stop"]);
rerender(<Bar extra busy />);
await waitFor(() => expect(tabStops()).toEqual(["Run"]));
fireEvent.keyDown(screen.getByRole("button", { name: "Run" }), { key: "ArrowRight" });
expect(screen.getByRole("button", { name: "Pin" })).toHaveFocus();
});
});
+88
Переглянути файл
@@ -0,0 +1,88 @@
import { forwardRef, useImperativeHandle, useLayoutEffect, useRef, type HTMLAttributes } from "react";
import { useGivenTx } from "@/platform/i18n";
export interface ToolbarProps extends HTMLAttributes<HTMLDivElement> {
"aria-label": string;
orientation?: "horizontal" | "vertical";
}
const ITEMS = "button, input, select, textarea, a[href], [tabindex]";
/** Controls whose tabindex a toolbar wrote, as opposed to an owner's own `-1` (kept across remounts). */
const managed = new WeakSet<Element>();
/**
* A toolbar that is one Tab stop (WAI-ARIA toolbar): the arrow keys, Home and End move
* between its controls, and Tab enters at the control used last. IDEA's toolbar buttons
* aren't Tab stops at all; this keeps them reachable from the keyboard.
*
* Roving works on the DOM, so the controls need nothing: a control that sets its own
* `tabIndex={-1}` stays out, and controls added, removed, enabled or disabled later are
* picked up.
*/
export const Toolbar = forwardRef<HTMLDivElement, ToolbarProps>(function Toolbar(
{ orientation = "horizontal", "aria-label": ariaLabel, children, ...rest },
ref,
) {
const tx = useGivenTx();
const root = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => root.current!);
useLayoutEffect(() => {
const el = root.current!;
let current: Element | null = null;
const candidates = () =>
[...el.querySelectorAll<HTMLElement>(ITEMS)].filter(
(item) => managed.has(item) || item.getAttribute("tabindex") !== "-1",
);
const items = () => candidates().filter((item) => !(item as HTMLButtonElement).disabled);
const sync = () => {
const all = candidates();
const enabled = all.filter((item) => !(item as HTMLButtonElement).disabled);
if (!current || !enabled.includes(current as HTMLElement)) current = enabled[0] ?? null;
for (const item of all) {
const index = item === current ? "0" : "-1";
if (item.getAttribute("tabindex") !== index) item.setAttribute("tabindex", index);
managed.add(item);
}
};
const onFocusIn = (e: FocusEvent) => {
if (!(e.target instanceof Element) || !items().includes(e.target as HTMLElement)) return;
current = e.target;
sync();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
const target = e.target as HTMLElement;
const all = items();
const index = all.indexOf(target);
if (index < 0) return;
const vertical = orientation === "vertical";
// Text fields keep their caret keys.
const text = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement;
let next: number;
if (e.key === (vertical ? "ArrowDown" : "ArrowRight") && !text) next = index + 1;
else if (e.key === (vertical ? "ArrowUp" : "ArrowLeft") && !text) next = index - 1;
else if (e.key === "Home" && !text) next = 0;
else if (e.key === "End" && !text) next = all.length - 1;
else return;
e.preventDefault();
all[(next + all.length) % all.length]!.focus();
};
sync();
const observer = new MutationObserver(sync);
observer.observe(el, { childList: true, subtree: true, attributes: true, attributeFilter: ["disabled"] });
el.addEventListener("focusin", onFocusIn);
el.addEventListener("keydown", onKeyDown);
return () => {
observer.disconnect();
el.removeEventListener("focusin", onFocusIn);
el.removeEventListener("keydown", onKeyDown);
};
}, [orientation]);
return (
<div ref={root} role="toolbar" aria-orientation={orientation} aria-label={tx(ariaLabel)} {...rest}>
{children}
</div>
);
});
+56
Переглянути файл
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it } from "vitest";
import { FOCUS_ACTIVE_ATTR, installFocusScopeTracking } from "./focusScope";
describe("focus scopes", () => {
let dispose = () => {};
afterEach(() => {
dispose();
document.body.innerHTML = "";
});
function setup() {
document.body.innerHTML = `
<section id="editor" data-focus-scope><button id="editorTab">a</button></section>
<section id="services" data-focus-scope>
<button id="tree">tree</button>
<div id="results" data-focus-scope><button id="grid">grid</button></div>
</section>
<div data-popup><button id="menuItem">menu</button></div>`;
dispose = installFocusScopeTracking();
const el = (id: string) => document.getElementById(id)!;
const active = () =>
["editor", "services", "results"].filter((id) => el(id).hasAttribute(FOCUS_ACTIVE_ATTR)).join(",");
return { el, active };
}
it("marks the focused scope with its enclosing scopes and moves the mark with focus", () => {
const { el, active } = setup();
el("editorTab").focus();
expect(active()).toBe("editor");
el("grid").focus();
expect(active()).toBe("services,results");
el("tree").focus();
expect(active()).toBe("services");
});
it("keeps the marks while focus is in a popup or nowhere", () => {
const { el, active } = setup();
el("grid").focus();
el("menuItem").focus();
expect(active()).toBe("services,results");
el("menuItem").blur();
expect(document.activeElement).toBe(document.body);
expect(active()).toBe("services,results");
});
it("marks the scope already holding focus on install and clears the marks on dispose", () => {
document.body.innerHTML = `<section id="editor" data-focus-scope><button id="tab">a</button></section>`;
document.getElementById("tab")!.focus();
dispose = installFocusScopeTracking();
const editor = document.getElementById("editor")!;
expect(editor.hasAttribute(FOCUS_ACTIVE_ATTR)).toBe(true);
dispose();
dispose = () => {};
expect(editor.hasAttribute(FOCUS_ACTIVE_ATTR)).toBe(false);
});
});
+33
Переглянути файл
@@ -0,0 +1,33 @@
/**
* Focus scopes: containers marked `data-focus-scope` that show whether they hold focus, as
* IDEA's tab strips do (the selected tab is accented only in the part that has focus).
*
* The scopes containing the last element focused inside any scope get `data-focus-active`.
* Focus moving outside every scope (popups, menus, dialogs, the body) keeps the marks, so a
* context menu opened from a tab leaves its strip active.
*/
const FOCUS_SCOPE_ATTR = "data-focus-scope";
export const FOCUS_ACTIVE_ATTR = "data-focus-active";
/** Marks the scopes holding focus. Returns an uninstaller that clears the marks. */
export function installFocusScopeTracking(): () => void {
let active: Element[] = [];
const mark = (target: Element | null) => {
const chain: Element[] = [];
for (let el = target?.closest(`[${FOCUS_SCOPE_ATTR}]`); el; el = el.parentElement?.closest(`[${FOCUS_SCOPE_ATTR}]`))
chain.push(el);
if (!chain.length) return;
for (const el of active) if (!chain.includes(el)) el.removeAttribute(FOCUS_ACTIVE_ATTR);
for (const el of chain) el.setAttribute(FOCUS_ACTIVE_ATTR, "");
active = chain;
};
const onFocusIn = (e: FocusEvent) => mark(e.target instanceof Element ? e.target : null);
document.addEventListener("focusin", onFocusIn);
mark(document.activeElement);
return () => {
document.removeEventListener("focusin", onFocusIn);
for (const el of active) el.removeAttribute(FOCUS_ACTIVE_ATTR);
active = [];
};
}
+1
Переглянути файл
@@ -8,6 +8,7 @@ export * from "./Input";
export * from "./Checkbox";
export * from "./Select";
export * from "./Tabs";
export * from "./Toolbar";
export {
FILES_DRAG,
REMOTE_FILES_DRAG,
+2
Переглянути файл
@@ -17,6 +17,7 @@ import { installBrowserGuards } from "./browserGuards";
import { installDistractionFree } from "./distractionFree";
import { installFileDrop } from "./fileDrop";
import { installFocusTracking } from "./focus";
import { installFocusScopeTracking } from "@/ui/focusScope";
import { clearProjectNotifications, installNotifyEvents, notificationServiceImpl } from "./notificationStore";
import {
NOTIFICATIONS_TOOL_WINDOW,
@@ -74,6 +75,7 @@ export function activateWorkbench(): () => void {
installToolWindowLayoutSync(),
installDistractionFree(),
installFocusTracking(setFocusedToolWindow),
installFocusScopeTracking(),
installTaskEvents(),
installNotifyEvents(),
installBrowserGuards(),
+1
Переглянути файл
@@ -871,6 +871,7 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo
ref={ref}
className={styles.window}
data-toolwindow={tw.id}
data-focus-scope=""
aria-label={titleOf(tw)}
hidden={hidden}
tabIndex={-1}