дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-13 13:15:42 +02:00
Порівняти коміти
10
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
564f08b062 | ||
|
|
401d7a4105 | ||
|
|
46bd1f6fb8 | ||
|
|
85f755591b | ||
|
|
06b7513210 | ||
|
|
09aa03bb86 | ||
|
|
317988cfa7 | ||
|
|
7db2e98269 | ||
|
|
fdc8bc2d4c | ||
|
|
497d4766d7 |
No files matched your search
@@ -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` |
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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` |
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: {} });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -264,6 +264,7 @@ describe("catalogs", () => {
|
||||
"my-agent",
|
||||
"Java",
|
||||
"Python",
|
||||
"Python {short}",
|
||||
"C/C++",
|
||||
"C#",
|
||||
"Go",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "假",
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 = [];
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Заблокувати користувача