mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 20:13:56 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b3519c7f8 |
No files matched your search
@@ -77,8 +77,6 @@ pub enum AutoUpload {
|
|||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
pub struct DeploymentSettings {
|
pub struct DeploymentSettings {
|
||||||
pub servers: Vec<DeploymentServer>,
|
pub servers: Vec<DeploymentServer>,
|
||||||
/// Server groups: Upload to… on a group uploads to each of its servers.
|
|
||||||
pub groups: Vec<DeploymentGroup>,
|
|
||||||
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
|
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
|
||||||
pub default_server: String,
|
pub default_server: String,
|
||||||
pub auto_upload: AutoUpload,
|
pub auto_upload: AutoUpload,
|
||||||
@@ -94,7 +92,6 @@ impl Default for DeploymentSettings {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
servers: Vec::new(),
|
servers: Vec::new(),
|
||||||
groups: Vec::new(),
|
|
||||||
default_server: String::new(),
|
default_server: String::new(),
|
||||||
auto_upload: AutoUpload::Never,
|
auto_upload: AutoUpload::Never,
|
||||||
delete_remote: false,
|
delete_remote: false,
|
||||||
@@ -106,16 +103,6 @@ impl Default for DeploymentSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Named set of deployment servers that files are uploaded to together.
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
|
||||||
#[serde(default, rename_all = "camelCase")]
|
|
||||||
pub struct DeploymentGroup {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
/// [`DeploymentServer::id`]s, in upload order.
|
|
||||||
pub servers: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum DeploymentType {
|
pub enum DeploymentType {
|
||||||
|
|||||||
@@ -204,8 +204,6 @@ argument; themes that ship no `.icls` keep the built-in syntax colours.
|
|||||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
|
| `--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` |
|
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||||
@@ -270,7 +268,6 @@ by hand:
|
|||||||
| `--link` | `textLink.foreground` |
|
| `--link` | `textLink.foreground` |
|
||||||
| `--bg-input`, `--bg-field` | `input.background` |
|
| `--bg-input`, `--bg-field` | `input.background` |
|
||||||
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
|
| `--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` |
|
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
|
||||||
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
|
| `--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` |
|
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
|
||||||
|
|||||||
@@ -177,8 +177,6 @@ pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out
|
|||||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor`(Islands), `EditorTabs.underlineColor` |
|
| `--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` |
|
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||||
@@ -241,7 +239,6 @@ VS Code 主题(扩展中的 `themes/*.json`)包含 `type`(`dark` / `light`
|
|||||||
| `--link` | `textLink.foreground` |
|
| `--link` | `textLink.foreground` |
|
||||||
| `--bg-input`, `--bg-field` | `input.background` |
|
| `--bg-input`, `--bg-field` | `input.background` |
|
||||||
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
|
| `--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` |
|
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
|
||||||
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
|
| `--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` |
|
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
|
||||||
|
|||||||
@@ -174,8 +174,6 @@ for (const [theme, label] of [
|
|||||||
const surface = target.locator("..");
|
const surface = target.locator("..");
|
||||||
const other = tab(page, "Result 3");
|
const other = tab(page, "Result 3");
|
||||||
const otherSurface = other.locator("..");
|
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);
|
await page.mouse.move(0, 0);
|
||||||
const idle = await surface.evaluate((element) => getComputedStyle(element).backgroundColor);
|
const idle = await surface.evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||||
const selectedColor = await tab(page, "Result 2")
|
const selectedColor = await tab(page, "Result 2")
|
||||||
@@ -229,43 +227,6 @@ 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 ({
|
test("renames the clicked inactive result without changing its rows and navigates through Output with wraparound", async ({
|
||||||
workbench: page,
|
workbench: page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -551,51 +512,3 @@ test("console runs replace their unpinned results; pinned results stay until Clo
|
|||||||
await slot.getByRole("button", { name: "Close Tab", exact: true }).click();
|
await slot.getByRole("button", { name: "Close Tab", exact: true }).click();
|
||||||
await expect(strip).toHaveText(["Output", result(2)]);
|
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();
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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,8 +199,6 @@ ship no `.icls` at all keep the built-in syntax colours.
|
|||||||
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
| `--bg-button`, `--border-button` | `Button.startBackground`, `Button.startBorderColor` |
|
||||||
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
| `--bg-tab-selected` | `EditorTabs.underlinedTabBackground` |
|
||||||
| `--border-tab-selected` | `EditorTabs.underlinedBorderColor` (Islands), `EditorTabs.underlineColor` |
|
| `--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` |
|
| `--fg-completion-match` | `CompletionPopup.matchForeground` |
|
||||||
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
| `--bg-toolbar-hover`, `--bg-toolbar-pressed` | `ActionButton.hoverBackground`, `ActionButton.pressedBackground` |
|
||||||
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
| `--bg-stripe-hover`, `--bg-stripe-selected` | `ToolWindow.Button.hoverBackground`, `.selectedBackground` |
|
||||||
@@ -264,7 +262,6 @@ from `type`, then map:
|
|||||||
| `--link` | `textLink.foreground` |
|
| `--link` | `textLink.foreground` |
|
||||||
| `--bg-input`, `--bg-field` | `input.background` |
|
| `--bg-input`, `--bg-field` | `input.background` |
|
||||||
| `--bg-tab-selected`, `--border-tab-selected` | `tab.activeBackground`, `tab.activeBorderTop` |
|
| `--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` |
|
| `--scrollbar-thumb`, `--scrollbar-thumb-hover` | `scrollbarSlider.background`, `scrollbarSlider.hoverBackground` |
|
||||||
| `--danger`, `--warning`, `--info` | `errorForeground`, `editorWarning.foreground`, `editorInfo.foreground` |
|
| `--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` |
|
| `--vcs-modified`, `--vcs-added`, `--vcs-deleted`, `--vcs-untracked`, `--vcs-ignored`, `--vcs-conflict` | `gitDecoration.modifiedResourceForeground`, `.addedResourceForeground`, `.deletedResourceForeground`, `.untrackedResourceForeground`, `.ignoredResourceForeground`, `.conflictingResourceForeground` |
|
||||||
|
|||||||
@@ -4090,14 +4090,6 @@ export type DeploymentCoverage = {
|
|||||||
skipped: TransferError[],
|
skipped: TransferError[],
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Named set of deployment servers that files are uploaded to together. */
|
|
||||||
export type DeploymentGroup = {
|
|
||||||
id?: string,
|
|
||||||
name?: string,
|
|
||||||
/** [`DeploymentServer::id`]s, in upload order. */
|
|
||||||
servers?: string[],
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Local files to compare with a deployment server (Sync with Deployed). */
|
/** Local files to compare with a deployment server (Sync with Deployed). */
|
||||||
export type DeploymentLaunch = {
|
export type DeploymentLaunch = {
|
||||||
/** Deployment server id. */
|
/** Deployment server id. */
|
||||||
@@ -4141,8 +4133,6 @@ export type DeploymentServer = {
|
|||||||
/** Build, Execution, Deployment › Deployment (and its Options page). */
|
/** Build, Execution, Deployment › Deployment (and its Options page). */
|
||||||
export type DeploymentSettings = {
|
export type DeploymentSettings = {
|
||||||
servers?: DeploymentServer[],
|
servers?: DeploymentServer[],
|
||||||
/** Server groups: Upload to… on a group uploads to each of its servers. */
|
|
||||||
groups?: DeploymentGroup[],
|
|
||||||
/** Server used by Upload / Download / Sync and automatic upload; empty = none. */
|
/** Server used by Upload / Download / Sync and automatic upload; empty = none. */
|
||||||
defaultServer?: string,
|
defaultServer?: string,
|
||||||
autoUpload?: AutoUpload,
|
autoUpload?: AutoUpload,
|
||||||
|
|||||||
@@ -367,7 +367,6 @@ function PaneView({ paneId }: { paneId: string }) {
|
|||||||
tabPrefs.placement === "right" && styles.tabsRight,
|
tabPrefs.placement === "right" && styles.tabsRight,
|
||||||
)}
|
)}
|
||||||
data-pane-id={paneId}
|
data-pane-id={paneId}
|
||||||
data-focus-scope=""
|
|
||||||
aria-label={tx("Editor")}
|
aria-label={tx("Editor")}
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
|
|||||||
@@ -107,29 +107,3 @@ describe("routine grouping", () => {
|
|||||||
expect(tree.queryByText("functions")).toBeNull();
|
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,
|
useDatabase,
|
||||||
type Loaded,
|
type Loaded,
|
||||||
} from "./store";
|
} from "./store";
|
||||||
import { DB_VIEW_FOCUS, databaseTree, openTableData } from "./treeActions";
|
import { DB_VIEW_FOCUS, openTableData } from "./treeActions";
|
||||||
import { newMenu, nodeMenu } from "./treeMenu";
|
import { newMenu, nodeMenu } from "./treeMenu";
|
||||||
import styles from "./Database.module.css";
|
import styles from "./Database.module.css";
|
||||||
import { tx } from "@/platform/i18n";
|
import { tx } from "@/platform/i18n";
|
||||||
@@ -366,34 +366,6 @@ export function DatabaseView() {
|
|||||||
else if (n.kind === "table") await loadDetails(n.id, n.schema, n.table.name);
|
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 => {
|
const label = (n: Node): string => {
|
||||||
switch (n.kind) {
|
switch (n.kind) {
|
||||||
case "source":
|
case "source":
|
||||||
@@ -537,7 +509,14 @@ export function DatabaseView() {
|
|||||||
getKey={(n) => n.key}
|
getKey={(n) => n.key}
|
||||||
getLabel={label}
|
getLabel={label}
|
||||||
getChildren={children}
|
getChildren={children}
|
||||||
isExpandable={expandable}
|
isExpandable={(n) =>
|
||||||
|
n.kind === "source" ||
|
||||||
|
n.kind === "database" ||
|
||||||
|
n.kind === "schema" ||
|
||||||
|
n.kind === "folder" ||
|
||||||
|
n.kind === "table" ||
|
||||||
|
n.kind === "group"
|
||||||
|
}
|
||||||
loadChildren={load}
|
loadChildren={load}
|
||||||
getIcon={(n) => icon(n)}
|
getIcon={(n) => icon(n)}
|
||||||
getDetail={detail}
|
getDetail={detail}
|
||||||
@@ -683,12 +662,7 @@ function DatabaseToolbar() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Header controls (shown on hover, like IDEA's): Expand All, Collapse All. */
|
/** Header controls (shown on hover, like IDEA's): Collapse All. */
|
||||||
export function DatabaseHeaderActions() {
|
export function DatabaseHeaderActions() {
|
||||||
return (
|
return <IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />;
|
||||||
<>
|
|
||||||
<IconButton icon="expand-all" action="db.expandAll" size="small" iconSize={16} />
|
|
||||||
<IconButton icon="collapse-all" action="db.collapseAll" size="small" iconSize={16} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type ClipboardEvent,
|
type ClipboardEvent,
|
||||||
type FocusEvent,
|
|
||||||
type KeyboardEvent,
|
type KeyboardEvent,
|
||||||
type MouseEvent,
|
type MouseEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -393,7 +392,6 @@ export function Grid(props: GridProps) {
|
|||||||
canToggle: () => canEdit() && !!bools[sel!.c1] && props.rowState?.(sel!.r1) !== "deleted",
|
canToggle: () => canEdit() && !!bools[sel!.c1] && props.rowState?.(sel!.r1) !== "deleted",
|
||||||
canSetNull: () => canEdit() && nullOk(norm(sel!)),
|
canSetNull: () => canEdit() && nullOk(norm(sel!)),
|
||||||
canSetDefault: () => canEdit() && defaultable(norm(sel!)),
|
canSetDefault: () => canEdit() && defaultable(norm(sel!)),
|
||||||
canFilter: () => !!props.onFilter && !!sel && !editing,
|
|
||||||
toggle: () => {
|
toggle: () => {
|
||||||
if (actions.canToggle()) toggleBoolean(sel!.r1, sel!.c1, booleanLiterals(props.driver));
|
if (actions.canToggle()) toggleBoolean(sel!.r1, sel!.c1, booleanLiterals(props.driver));
|
||||||
},
|
},
|
||||||
@@ -403,12 +401,6 @@ export function Grid(props: GridProps) {
|
|||||||
setDefault: () => {
|
setDefault: () => {
|
||||||
if (actions.canSetDefault()) setDefaults(norm(sel!));
|
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);
|
Object.assign((cellActions.current ??= {} as CellActions), actions);
|
||||||
});
|
});
|
||||||
@@ -438,14 +430,6 @@ export function Grid(props: GridProps) {
|
|||||||
startEdit(sel.r1, sel.c1, e.key === "Enter" && e.shiftKey);
|
startEdit(sel.r1, sel.c1, e.key === "Enter" && e.shiftKey);
|
||||||
return;
|
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]> = {
|
const moves: Record<string, [number, number]> = {
|
||||||
ArrowUp: [-1, 0],
|
ArrowUp: [-1, 0],
|
||||||
ArrowDown: [1, 0],
|
ArrowDown: [1, 0],
|
||||||
@@ -455,32 +439,13 @@ export function Grid(props: GridProps) {
|
|||||||
const m = moves[e.key];
|
const m = moves[e.key];
|
||||||
if (!m) return;
|
if (!m) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
moveTo(
|
const r = Math.max(0, Math.min(rowCount - 1, sel.r1 + m[0]));
|
||||||
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]));
|
||||||
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 });
|
||||||
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" });
|
virtualizer.scrollToIndex(r, { align: "auto" });
|
||||||
scrollColumnIntoView(c);
|
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) => {
|
const onCellMouseDown = (e: MouseEvent, r: number, c: number) => {
|
||||||
if (e.button !== 0) {
|
if (e.button !== 0) {
|
||||||
if (!selected(r, c)) setSel({ r0: r, c0: c, r1: r, c1: c });
|
if (!selected(r, c)) setSel({ r0: r, c0: c, r1: r, c1: c });
|
||||||
@@ -547,9 +512,6 @@ export function Grid(props: GridProps) {
|
|||||||
{ kind: "action", action: "db.setCellDefault", arg: cellActions.current },
|
{ 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()) ?? [];
|
const extra = rowMenu?.(selectedRows()) ?? [];
|
||||||
if (extra.length) items.push({ kind: "separator" }, ...extra);
|
if (extra.length) items.push({ kind: "separator" }, ...extra);
|
||||||
showContextMenu(e, items);
|
showContextMenu(e, items);
|
||||||
@@ -584,11 +546,6 @@ export function Grid(props: GridProps) {
|
|||||||
aria-label={props["aria-label"] ?? "Results"}
|
aria-label={props["aria-label"] ?? "Results"}
|
||||||
aria-rowcount={rowCount}
|
aria-rowcount={rowCount}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onFocus={onFocus}
|
|
||||||
onMouseDownCapture={() => {
|
|
||||||
pointerFocus.current = true;
|
|
||||||
setTimeout(() => (pointerFocus.current = false));
|
|
||||||
}}
|
|
||||||
onPaste={onPaste}
|
onPaste={onPaste}
|
||||||
onContextMenu={onContextMenu}
|
onContextMenu={onContextMenu}
|
||||||
>
|
>
|
||||||
@@ -658,10 +615,6 @@ export function Grid(props: GridProps) {
|
|||||||
{props.onFilter && (
|
{props.onFilter && (
|
||||||
<button
|
<button
|
||||||
type="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)}
|
className={clsx(styles.headerFilter, props.filtered?.(ci) && styles.headerFilterOn)}
|
||||||
aria-label={`Filter ${c.name}`}
|
aria-label={`Filter ${c.name}`}
|
||||||
title={tx("Local filter")}
|
title={tx("Local filter")}
|
||||||
@@ -756,13 +709,6 @@ export function Grid(props: GridProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "Enter") commit();
|
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") {
|
if (e.key === "Escape") {
|
||||||
skipBlurCommit.current = true;
|
skipBlurCommit.current = true;
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -780,7 +726,6 @@ export function Grid(props: GridProps) {
|
|||||||
<div
|
<div
|
||||||
key={c}
|
key={c}
|
||||||
role="gridcell"
|
role="gridcell"
|
||||||
aria-selected={selected(r, c)}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
styles.cell,
|
styles.cell,
|
||||||
numeric[c] && styles.cellNumber,
|
numeric[c] && styles.cellNumber,
|
||||||
@@ -806,7 +751,6 @@ export function Grid(props: GridProps) {
|
|||||||
onDoubleClick={(e) => e.stopPropagation()}
|
onDoubleClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
tabIndex={-1}
|
|
||||||
className={styles.cellCheckbox}
|
className={styles.cellCheckbox}
|
||||||
aria-label={tx`Toggle ${columns[c]!.name}`}
|
aria-label={tx`Toggle ${columns[c]!.name}`}
|
||||||
checked={val === lit.t}
|
checked={val === lit.t}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
showContextMenu,
|
showContextMenu,
|
||||||
T,
|
T,
|
||||||
tooltipAttrs,
|
tooltipAttrs,
|
||||||
Toolbar,
|
|
||||||
} from "@/ui";
|
} from "@/ui";
|
||||||
import { notifications } from "@/workbench/contributions";
|
import { notifications } from "@/workbench/contributions";
|
||||||
import { ToolWindowContentTabs } from "@/workbench/toolwindows/ToolWindows";
|
import { ToolWindowContentTabs } from "@/workbench/toolwindows/ToolWindows";
|
||||||
@@ -81,10 +80,10 @@ function Output() {
|
|||||||
}, [lines.length]);
|
}, [lines.length]);
|
||||||
return (
|
return (
|
||||||
<div className={styles.view}>
|
<div className={styles.view}>
|
||||||
<Toolbar className={styles.toolbar} aria-label="Output actions">
|
<div className={styles.toolbar}>
|
||||||
<span className={styles.spacer} />
|
<span className={styles.spacer} />
|
||||||
<IconButton icon="trash" title="Clear All" disabled={!lines.length} onClick={clearOutput} />
|
<IconButton icon="trash" title="Clear All" disabled={!lines.length} onClick={clearOutput} />
|
||||||
</Toolbar>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={styles.output}
|
className={styles.output}
|
||||||
@@ -149,7 +148,12 @@ function Pager({ tab }: { tab: ResultTab }) {
|
|||||||
const total = w.total === null ? `${w.last + 1}+` : String(w.total);
|
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);
|
const sizes = PAGE_SIZES.includes(pageSize) ? PAGE_SIZES : [...PAGE_SIZES, pageSize].sort((a, b) => a - b);
|
||||||
return (
|
return (
|
||||||
<Toolbar className={styles.resultPager} aria-label="Result pages" title={outcomeSummary(tab.outcome)}>
|
<div
|
||||||
|
className={styles.resultPager}
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={tx("Result pages")}
|
||||||
|
title={outcomeSummary(tab.outcome)}
|
||||||
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="page-first"
|
icon="page-first"
|
||||||
title="First Page"
|
title="First Page"
|
||||||
@@ -194,7 +198,7 @@ function Pager({ tab }: { tab: ResultTab }) {
|
|||||||
onChange={(v) => go(0, Number(v))}
|
onChange={(v) => go(0, Number(v))}
|
||||||
disabled={!canPage}
|
disabled={!canPage}
|
||||||
/>
|
/>
|
||||||
</Toolbar>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +433,7 @@ function Result({ tab }: { tab: ResultTab }) {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className={styles.view} ref={viewRef} data-db-table-editor="">
|
<div className={styles.view} ref={viewRef} data-db-table-editor="">
|
||||||
<Toolbar className={styles.toolbar} aria-label="Result actions">
|
<div className={styles.toolbar}>
|
||||||
<IconButton icon="database" title="Table" aria-pressed={view === "table"} onClick={() => setView("table")} />
|
<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")} />
|
<IconButton icon="db-chart" title="Chart" aria-pressed={view === "chart"} onClick={() => setView("chart")} />
|
||||||
<span className={styles.toolbarSeparator} />
|
<span className={styles.toolbarSeparator} />
|
||||||
@@ -483,7 +487,7 @@ function Result({ tab }: { tab: ResultTab }) {
|
|||||||
onClick={(e) => showContextMenu(e, exportMenu(data(), queryTable(text, { driver }), extractor))}
|
onClick={(e) => showContextMenu(e, exportMenu(data(), queryTable(text, { driver }), extractor))}
|
||||||
/>
|
/>
|
||||||
{view === "table" && <PanelToggle panel={panel} onChange={setPanel} />}
|
{view === "table" && <PanelToggle panel={panel} onChange={setPanel} />}
|
||||||
</Toolbar>
|
</div>
|
||||||
{view === "chart" ? (
|
{view === "chart" ? (
|
||||||
<div className={styles.fill}>
|
<div className={styles.fill}>
|
||||||
<ChartView columns={columns} rows={r.rows} />
|
<ChartView columns={columns} rows={r.rows} />
|
||||||
@@ -591,7 +595,7 @@ function PlanResult({ tab }: { tab: ResultTab }) {
|
|||||||
const selectedKey = selected < 0 ? statement.key : String(selected);
|
const selectedKey = selected < 0 ? statement.key : String(selected);
|
||||||
return (
|
return (
|
||||||
<div className={styles.view}>
|
<div className={styles.view}>
|
||||||
<Toolbar className={styles.toolbar} aria-label="Result actions">
|
<div className={styles.toolbar}>
|
||||||
<IconButton icon="refresh" title="Explain Again" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
|
<IconButton icon="refresh" title="Explain Again" disabled={tab.busy} onClick={() => void rerunResult(tab.id)} />
|
||||||
<PinButton tab={tab} />
|
<PinButton tab={tab} />
|
||||||
{tab.busy && <Spinner size={12} />}
|
{tab.busy && <Spinner size={12} />}
|
||||||
@@ -599,7 +603,7 @@ function PlanResult({ tab }: { tab: ResultTab }) {
|
|||||||
<span className={styles.resultSource} title={tab.outcome.statement.text}>
|
<span className={styles.resultSource} title={tab.outcome.statement.text}>
|
||||||
{targetLabel(tab.sourceId)}
|
{targetLabel(tab.sourceId)}
|
||||||
</span>
|
</span>
|
||||||
</Toolbar>
|
</div>
|
||||||
<div className={clsx(styles.fill, styles.planView)}>
|
<div className={clsx(styles.fill, styles.planView)}>
|
||||||
<div className={styles.planTree}>
|
<div className={styles.planTree}>
|
||||||
<div className={styles.planHeader}>
|
<div className={styles.planHeader}>
|
||||||
@@ -673,13 +677,6 @@ export function ResultsView() {
|
|||||||
const active = useDatabase((s) => s.activeResult);
|
const active = useDatabase((s) => s.activeResult);
|
||||||
const hasOutput = useDatabase((s) => s.output.length > 0);
|
const hasOutput = useDatabase((s) => s.output.length > 0);
|
||||||
const root = useRef<HTMLDivElement>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const syncFocus = () => setContext("dbResultsFocus", !!root.current?.contains(document.activeElement));
|
const syncFocus = () => setContext("dbResultsFocus", !!root.current?.contains(document.activeElement));
|
||||||
document.addEventListener("focusin", syncFocus);
|
document.addEventListener("focusin", syncFocus);
|
||||||
@@ -707,7 +704,6 @@ export function ResultsView() {
|
|||||||
<div
|
<div
|
||||||
ref={root}
|
ref={root}
|
||||||
className={styles.view}
|
className={styles.view}
|
||||||
data-focus-scope=""
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return;
|
if (e.key !== "ContextMenu" && !(e.shiftKey && e.key === "F10")) return;
|
||||||
const tab = (e.target as Element).closest<HTMLElement>('[role="tab"]');
|
const tab = (e.target as Element).closest<HTMLElement>('[role="tab"]');
|
||||||
@@ -728,7 +724,6 @@ export function ResultsView() {
|
|||||||
aria-label="Results"
|
aria-label="Results"
|
||||||
activeId={tab ? String(tab.id) : String(OUTPUT_TAB)}
|
activeId={tab ? String(tab.id) : String(OUTPUT_TAB)}
|
||||||
panelIdPrefix="db-results"
|
panelIdPrefix="db-results"
|
||||||
focusContent={focusContent}
|
|
||||||
onSelect={(id) => useDatabase.setState({ activeResult: Number(id) })}
|
onSelect={(id) => useDatabase.setState({ activeResult: Number(id) })}
|
||||||
onClose={(id) => closeResult(Number(id))}
|
onClose={(id) => closeResult(Number(id))}
|
||||||
onTogglePin={(id) => {
|
onTogglePin={(id) => {
|
||||||
@@ -749,13 +744,7 @@ export function ResultsView() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</ToolWindowContentTabs>
|
</ToolWindowContentTabs>
|
||||||
<div
|
<div className={styles.fill}>
|
||||||
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} />}
|
{!tab ? <Output /> : tab.plan ? <PlanResult key={tab.id} tab={tab} /> : <Result key={tab.id} tab={tab} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,12 +5,9 @@ export interface CellActions {
|
|||||||
canToggle(): boolean;
|
canToggle(): boolean;
|
||||||
canSetNull(): boolean;
|
canSetNull(): boolean;
|
||||||
canSetDefault(): boolean;
|
canSetDefault(): boolean;
|
||||||
canFilter(): boolean;
|
|
||||||
toggle(): void;
|
toggle(): void;
|
||||||
setNull(): void;
|
setNull(): void;
|
||||||
setDefault(): void;
|
setDefault(): void;
|
||||||
/** Opens the local filter of the selected cell's column. */
|
|
||||||
filter(): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const grids = new WeakMap<Element, CellActions>();
|
const grids = new WeakMap<Element, CellActions>();
|
||||||
@@ -62,14 +59,6 @@ export function installCellActions(): () => void {
|
|||||||
enabled: (c) => handle(c)?.canSetDefault() ?? false,
|
enabled: (c) => handle(c)?.canSetDefault() ?? false,
|
||||||
run: (c) => handle(c)?.setDefault(),
|
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 () => {
|
return () => {
|
||||||
dispose();
|
dispose();
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { DbDriver, DbQueryResult, DbTableDetails } from "@/platform/ipc";
|
import type { DbDriver, DbQueryResult, DbTableDetails } from "@/platform/ipc";
|
||||||
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
|
import { installKeyboardDispatcher, useKeymap } from "@/platform/keymap";
|
||||||
@@ -433,75 +432,6 @@ 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 () => {
|
it("omits a reset inserted cell from Submit instead of writing SQL NULL", async () => {
|
||||||
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
show("SELECT * FROM users", [{ name: "id" }, { name: "name" }], [["1", "Ann"]]);
|
||||||
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
|
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
|
||||||
|
|||||||
@@ -44,10 +44,6 @@ import { tx } from "@/platform/i18n";
|
|||||||
|
|
||||||
/** Context key: the Database tree has focus. */
|
/** Context key: the Database tree has focus. */
|
||||||
export const DB_VIEW_FOCUS = "databaseViewFocus";
|
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 OPEN = "projectOpen";
|
||||||
|
|
||||||
const isNode = (v: unknown): v is DbNode => !!v && typeof v === "object" && "kind" in v && "key" in v;
|
const isNode = (v: unknown): v is DbNode => !!v && typeof v === "object" && "kind" in v && "key" in v;
|
||||||
@@ -485,12 +481,6 @@ export function registerDatabaseTreeActions(): () => void {
|
|||||||
title: "Separate Procedures and Functions",
|
title: "Separate Procedures and Functions",
|
||||||
run: toggleSeparateProceduresAndFunctions,
|
run: toggleSeparateProceduresAndFunctions,
|
||||||
}),
|
}),
|
||||||
tree({
|
|
||||||
id: "db.expandAll",
|
|
||||||
title: "Expand All",
|
|
||||||
icon: "expand-all",
|
|
||||||
run: () => databaseTree.expandAll?.(),
|
|
||||||
}),
|
|
||||||
tree({
|
tree({
|
||||||
id: "db.collapseAll",
|
id: "db.collapseAll",
|
||||||
title: "Collapse All",
|
title: "Collapse All",
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ describe("variables the adapter computes after the stop", () => {
|
|||||||
expect(await screen.findByText("Collecting data…")).toBeTruthy();
|
expect(await screen.findByText("Collecting data…")).toBeTruthy();
|
||||||
adapterData.library = "com.example.library.Library@1";
|
adapterData.library = "com.example.library.Library@1";
|
||||||
act(() => handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] }));
|
act(() => handleDebugEvent(1, { kind: "invalidated", areas: ["variables"] }));
|
||||||
const value = await screen.findByText("{Library@1}");
|
const value = await screen.findByText("com.example.library.Library@1");
|
||||||
expect(screen.queryByText("Collecting data…")).toBeNull();
|
expect(screen.queryByText("Collecting data…")).toBeNull();
|
||||||
expect(value.closest("[role=treeitem]")?.querySelector("[class*=changed]")).toBeNull();
|
expect(value.closest("[role=treeitem]")?.querySelector("[class*=changed]")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -348,7 +348,7 @@ describe("variables the adapter computes after the stop", () => {
|
|||||||
await screen.findByText("Collecting data…");
|
await screen.findByText("Collecting data…");
|
||||||
adapterData.library = "Library@2";
|
adapterData.library = "Library@2";
|
||||||
await act(() => setVariableValue({ container: 1, name: "count", evaluateName: "count" }, "4", session));
|
await act(() => setVariableValue({ container: 1, name: "count", evaluateName: "count" }, "4", session));
|
||||||
expect(await screen.findByText("{Library@2}")).toBeTruthy();
|
expect(await screen.findByText("Library@2")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does nothing for an invalidation while the program runs", async () => {
|
it("does nothing for an invalidation while the program runs", async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||||
import { selectionExpression } from "../editor/valueTooltip";
|
import { selectionExpression } from "../editor/valueTooltip";
|
||||||
import { classifyValue, identifiersIn, inlineValue, shownValue, sortVariables } from "../values";
|
import { classifyValue, identifiersIn, inlineValue, sortVariables } from "../values";
|
||||||
import { inlineValuesFor } from "../editor/execution";
|
import { inlineValuesFor } from "../editor/execution";
|
||||||
import { frameLabel, groupFrames, isLibraryFrame } from "../frames";
|
import { frameLabel, groupFrames, isLibraryFrame } from "../frames";
|
||||||
import type { StackFrame } from "@/platform/ipc";
|
import type { StackFrame } from "@/platform/ipc";
|
||||||
@@ -150,25 +150,3 @@ describe("selectionExpression", () => {
|
|||||||
expect(selectionExpression(multi)).toBeNull();
|
expect(selectionExpression(multi)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("shown values", () => {
|
|
||||||
it.each([
|
|
||||||
["String[0]@9", "String[]", { label: "{String[0]@9}", text: "[]" }],
|
|
||||||
["int[3]@12", "int[]", { label: "{int[3]@12}", text: "" }],
|
|
||||||
["HashMap@10 size=1", "HashMap", { label: "{HashMap@10}", text: "size = 1" }],
|
|
||||||
["ArrayList@15 size=0", "java.util.ArrayList", { label: "{ArrayList@15}", text: "size = 0" }],
|
|
||||||
["Db@21", "Db", { label: "{Db@21}", text: "" }],
|
|
||||||
["com.example.Library@1", "Library", { label: "{Library@1}", text: "" }],
|
|
||||||
['HashMap$Node@31 "a":"b"', "HashMap$Node", { label: null, text: '"a" -> "b"', entry: true }],
|
|
||||||
['HashMap$Node@32 "a:b":"c"', "HashMap$Node", { label: null, text: '"a:b" -> "c"', entry: true }],
|
|
||||||
['TreeMap$Entry@33 1:"x"', "TreeMap$Entry", { label: null, text: '1 -> "x"', entry: true }],
|
|
||||||
['"hello"', "String", { label: null, text: '"hello"' }],
|
|
||||||
["42", "int", { label: "{int}", text: "42" }],
|
|
||||||
// Other debuggers' values keep their text and type.
|
|
||||||
["Foo@1", "Bar", { label: "{Bar}", text: "Foo@1" }],
|
|
||||||
["{'a': 1}", "dict", { label: "{dict}", text: "{'a': 1}" }],
|
|
||||||
["0x1000", null, { label: null, text: "0x1000" }],
|
|
||||||
])("%s {%s}", (value, type, shown) => {
|
|
||||||
expect(shownValue(value, type)).toEqual(shown);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
|
|||||||
import { activeSession, useExecutionPoint } from "../state";
|
import { activeSession, useExecutionPoint } from "../state";
|
||||||
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
|
import { onWorkflowSettingsChange, workflowSettings } from "@/platform/workflowSettings";
|
||||||
import { tx } from "@/platform/i18n";
|
import { tx } from "@/platform/i18n";
|
||||||
import { identifiersIn, inlineValue, shownValue } from "../values";
|
import { identifiersIn, inlineValue } from "../values";
|
||||||
import { valueHint } from "./valueTooltip";
|
import { valueHint } from "./valueTooltip";
|
||||||
import styles from "./editor.module.css";
|
import styles from "./editor.module.css";
|
||||||
|
|
||||||
@@ -113,13 +113,11 @@ export function inlineValuesFor(
|
|||||||
evaluateName: id,
|
evaluateName: id,
|
||||||
})
|
})
|
||||||
: raw;
|
: raw;
|
||||||
const shown = shownValue(variable.value, variable.typeName);
|
|
||||||
const value = shown.text || shown.label || variable.value;
|
|
||||||
parts.push({
|
parts.push({
|
||||||
name: id,
|
name: id,
|
||||||
text: `${id}: ${inlineValue(value, 40)}`,
|
text: `${id}: ${inlineValue(variable.value, 40)}`,
|
||||||
variable,
|
variable,
|
||||||
truncated: value.length > 40,
|
truncated: variable.value.length > 40,
|
||||||
});
|
});
|
||||||
if (parts.length >= MAX_PER_LINE) break;
|
if (parts.length >= MAX_PER_LINE) break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { showHint } from "@/editor/lsp/hint";
|
|||||||
import { workflowSettings } from "@/platform/workflowSettings";
|
import { workflowSettings } from "@/platform/workflowSettings";
|
||||||
import { navigationModifierHeld } from "@/platform/navigationModifier";
|
import { navigationModifierHeld } from "@/platform/navigationModifier";
|
||||||
import { activeSession, evaluate } from "../state";
|
import { activeSession, evaluate } from "../state";
|
||||||
import { inlineValue, shownValue } from "../values";
|
import { inlineValue } from "../values";
|
||||||
import { tx } from "@/platform/i18n";
|
import { tx } from "@/platform/i18n";
|
||||||
import { iconMarkup, selectableText } from "@/ui";
|
import { iconMarkup, selectableText } from "@/ui";
|
||||||
import { mountValueTree } from "../ui/ValueHintTree";
|
import { mountValueTree } from "../ui/ValueHintTree";
|
||||||
@@ -70,19 +70,13 @@ export function valueHint(
|
|||||||
const name = document.createElement("span");
|
const name = document.createElement("span");
|
||||||
name.className = styles.valueName!;
|
name.className = styles.valueName!;
|
||||||
name.textContent = `${expression} = `;
|
name.textContent = `${expression} = `;
|
||||||
const shown = shownValue(value.value, value.typeName);
|
dom.append(name, max === null ? value.value : inlineValue(value.value, max));
|
||||||
// A JVM object's `{Type@id}` leads its value; other types follow it.
|
if (value.typeName) {
|
||||||
const leading = shown.label?.includes("@") ?? false;
|
const type = document.createElement("span");
|
||||||
const type = (text: string) => {
|
type.className = styles.valueType!;
|
||||||
const span = document.createElement("span");
|
type.textContent = ` {${value.typeName}}`;
|
||||||
span.className = styles.valueType!;
|
dom.append(type);
|
||||||
span.textContent = text;
|
}
|
||||||
return span;
|
|
||||||
};
|
|
||||||
dom.append(name);
|
|
||||||
if (leading) dom.append(type(`${shown.label} `));
|
|
||||||
dom.append(max === null ? shown.text : inlineValue(shown.text, max));
|
|
||||||
if (shown.label && !leading) dom.append(type(` ${shown.label}`));
|
|
||||||
return { dom, destroy: () => tree?.unmount() };
|
return { dom, destroy: () => tree?.unmount() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,11 +48,6 @@ import { forgetMemory } from "./memory/memoryState";
|
|||||||
const useAnyOverlay = () =>
|
const useAnyOverlay = () =>
|
||||||
useOverlays((s) => !!(s.popover || s.evaluate || s.breakpoints || s.chooser || s.attach || s.smartStep));
|
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 {
|
export function activate(): () => void {
|
||||||
useDebugRunSessions.setState({
|
useDebugRunSessions.setState({
|
||||||
Console: lazyComponent(() => import("./ui/DebugConsole").then((m) => m.DebugServiceConsole)),
|
Console: lazyComponent(() => import("./ui/DebugConsole").then((m) => m.DebugServiceConsole)),
|
||||||
@@ -76,7 +71,6 @@ export function activate(): () => void {
|
|||||||
order: 20,
|
order: 20,
|
||||||
component: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugToolWindow)),
|
component: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugToolWindow)),
|
||||||
headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)),
|
headerContent: lazyComponent(() => import("./ui/DebugToolWindow").then((m) => m.DebugTabs)),
|
||||||
useBadge: useLiveSessionCount,
|
|
||||||
}),
|
}),
|
||||||
registerEditorKind(heapEditorKind),
|
registerEditorKind(heapEditorKind),
|
||||||
installHeapContext(),
|
installHeapContext(),
|
||||||
|
|||||||
@@ -176,7 +176,30 @@ export function DebugToolWindow() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<div className={styles.toolbar} role="toolbar" aria-label={tx("Debugger actions")}>
|
<div className={styles.toolbar} role="toolbar" aria-label={tx("Debugger actions")}>
|
||||||
{/* IDEA's XDebugger.ToolWindow.TopToolbar3 (+ its "More" group), then the views. */}
|
<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 />
|
||||||
<ToolButton action="debug.rerun" icon="rerun" />
|
<ToolButton action="debug.rerun" icon="rerun" />
|
||||||
<ToolButton action="debug.stop" icon="stop" tone="danger" />
|
<ToolButton action="debug.stop" icon="stop" tone="danger" />
|
||||||
<span className={styles.sep} aria-hidden />
|
<span className={styles.sep} aria-hidden />
|
||||||
@@ -210,29 +233,6 @@ export function DebugToolWindow() {
|
|||||||
onClick={() => setMuted(!muted)}
|
onClick={() => setMuted(!muted)}
|
||||||
/>
|
/>
|
||||||
<MoreActions />
|
<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>
|
||||||
<div role="tabpanel" id={`debug-panel-${tab}`} aria-labelledby={`debug-tab-${tab}`} className={styles.panel}>
|
<div role="tabpanel" id={`debug-panel-${tab}`} aria-labelledby={`debug-tab-${tab}`} className={styles.panel}>
|
||||||
{tab === "variables" ? (
|
{tab === "variables" ? (
|
||||||
@@ -251,12 +251,7 @@ export function DebugToolWindow() {
|
|||||||
|
|
||||||
function ThreadsAndVariables({ session }: { session: DebugSession }) {
|
function ThreadsAndVariables({ session }: { session: DebugSession }) {
|
||||||
return (
|
return (
|
||||||
<SplitGroup
|
<SplitGroup orientation="horizontal" id="debug-threads-variables" className={styles.split}>
|
||||||
orientation="horizontal"
|
|
||||||
id="debug-threads-variables"
|
|
||||||
storageKey="openidea.debug.threadsVariables"
|
|
||||||
className={styles.split}
|
|
||||||
>
|
|
||||||
<SplitPanel id="frames" defaultSize="34%" minSize="160px">
|
<SplitPanel id="frames" defaultSize="34%" minSize="160px">
|
||||||
<FramesPane session={session} />
|
<FramesPane session={session} />
|
||||||
</SplitPanel>
|
</SplitPanel>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { addFieldWatchpoint } from "../breakpoints/special";
|
|||||||
import { methodAt } from "../breakpoints/symbols";
|
import { methodAt } from "../breakpoints/symbols";
|
||||||
import { activeSession, addWatch, removeWatch, revealFrame, sessionSupports, setVariableValue } from "../state";
|
import { activeSession, addWatch, removeWatch, revealFrame, sessionSupports, setVariableValue } from "../state";
|
||||||
import { blurDebugView, focusDebugView } from "./focusedView";
|
import { blurDebugView, focusDebugView } from "./focusedView";
|
||||||
import { classifyValue, oneLine, shownValue, sortVariables } from "../values";
|
import { classifyValue, oneLine, sortVariables } from "../values";
|
||||||
import { valueText } from "../valueText";
|
import { valueText } from "../valueText";
|
||||||
import { workflowSettings } from "@/platform/workflowSettings";
|
import { workflowSettings } from "@/platform/workflowSettings";
|
||||||
import styles from "./VariablesView.module.css";
|
import styles from "./VariablesView.module.css";
|
||||||
@@ -486,25 +486,20 @@ function VarRow({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
const kind = node.error ? null : classifyValue(node.value, node.typeName);
|
const kind = node.error ? null : classifyValue(node.value, node.typeName);
|
||||||
const shown = shownValue(node.value, node.typeName);
|
|
||||||
return (
|
return (
|
||||||
<span className={styles.row}>
|
<span className={styles.row}>
|
||||||
<Icon name={nodeIcon(node)} size={16} className={styles.nodeIcon} />
|
<Icon name={nodeIcon(node)} size={16} className={styles.nodeIcon} />
|
||||||
{!shown.entry && (
|
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
|
||||||
<>
|
{node.name}
|
||||||
<span className={clsx(styles.name, node.kind === "var" && styles.varName, changed && styles.changed)}>
|
</span>
|
||||||
{node.name}
|
<span className={styles.eq}>=</span>
|
||||||
</span>
|
|
||||||
<span className={styles.eq}>=</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{node.error ? (
|
{node.error ? (
|
||||||
<span className={styles.error}>{oneLine(node.error)}</span>
|
<span className={styles.error}>{oneLine(node.error)}</span>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{shown.label && <span className={styles.type}>{shown.label}</span>}
|
{node.typeName && <span className={styles.type}>{`{${node.typeName}}`}</span>}
|
||||||
<span ref={valueRef} className={clsx(styles.value, kind && styles[kind])} title={node.value}>
|
<span ref={valueRef} className={clsx(styles.value, kind && styles[kind])} title={node.value}>
|
||||||
{oneLine(shown.text)}
|
{oneLine(node.value)}
|
||||||
</span>
|
</span>
|
||||||
{overflow && (
|
{overflow && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -69,35 +69,3 @@ export function sortVariables<T extends { name: string }>(vars: readonly T[]): T
|
|||||||
if (vars.every((v) => INDEX.test(v.name))) return [...vars];
|
if (vars.every((v) => INDEX.test(v.name))) return [...vars];
|
||||||
return [...vars].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
return [...vars].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How a variable's type and value read in the Variables view: the braced label before the value, then the value. */
|
|
||||||
export interface ShownValue {
|
|
||||||
label: string | null;
|
|
||||||
text: string;
|
|
||||||
/** A map entry: the row shows `key -> value` alone, without its index. */
|
|
||||||
entry?: true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `HashMap@10 size=1`, `String[0]@9`: a JVM object as the Java debugger writes it (type, then the object id).
|
|
||||||
const JVM_OBJECT = /^([\w$.]+(?:\[\d*\])*)@(\d+)(?:\s+(.*))?$/s;
|
|
||||||
// A map entry's key, at the start of `"a":"b"` or `1:"b"`.
|
|
||||||
const ENTRY_KEY = /^("(?:[^"\\]|\\.)*"|[^:"]+?):(.*)$/s;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* JVM objects read as `{HashMap@10} size = 1` and a map entry as `"a" -> "b"`; strings show without
|
|
||||||
* a type. Values in other forms keep the adapter's text under `{type}`.
|
|
||||||
*/
|
|
||||||
export function shownValue(value: string, typeName: string | null | undefined): ShownValue {
|
|
||||||
const plain = { label: typeName ? `{${typeName}}` : null, text: value };
|
|
||||||
if (!typeName) return plain;
|
|
||||||
if (typeName === "String" && /^".*"$/s.test(value)) return { label: null, text: value };
|
|
||||||
const object = JVM_OBJECT.exec(value);
|
|
||||||
if (!object) return plain;
|
|
||||||
const [, head, id, rest = ""] = object;
|
|
||||||
const simple = (t: string) => t.replace(/\[\d*\]/g, "[]").replace(/^.*\./, "");
|
|
||||||
if (simple(head!) !== simple(typeName)) return plain;
|
|
||||||
const entry = /(?:^|\$)\w*(?:Entry|Node)$/.test(typeName) ? ENTRY_KEY.exec(rest) : null;
|
|
||||||
if (entry) return { label: null, text: `${entry[1]!.trim()} -> ${entry[2]!.trim()}`, entry: true };
|
|
||||||
const text = rest.replace(/\b(size|length)=(\d+)/g, "$1 = $2") || (/\[0\]/.test(head!) ? "[]" : "");
|
|
||||||
return { label: `{${head!.replace(/^.*\./, "")}@${id}}`, text };
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import type { SyncEntry } from "@/platform/ipc";
|
|||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
actions: [] as { id: string; run: (context: { arg: string[] }) => unknown }[],
|
actions: [] as { id: string; run: (context: { arg: string[] }) => unknown }[],
|
||||||
servers: [] as DeploymentServerValid[],
|
servers: [] as DeploymentServerValid[],
|
||||||
groups: [] as { id: string; name: string; servers: string[] }[],
|
|
||||||
coverage: [] as { server: string; paths: string[]; skipped: { path: string; message: string }[] }[],
|
coverage: [] as { server: string; paths: string[]; skipped: { path: string; message: string }[] }[],
|
||||||
choose: vi.fn(),
|
choose: vi.fn(),
|
||||||
notify: vi.fn(),
|
notify: vi.fn(),
|
||||||
@@ -38,7 +37,7 @@ vi.mock("@/platform/project", () => ({
|
|||||||
useProject: { getState: () => ({ project: { root: "/project" } }) },
|
useProject: { getState: () => ({ project: { root: "/project" } }) },
|
||||||
}));
|
}));
|
||||||
vi.mock("@/platform/workflowSettings", () => ({
|
vi.mock("@/platform/workflowSettings", () => ({
|
||||||
workflowSettings: () => ({ deployment: { servers: mocks.servers, groups: mocks.groups, defaultServer: "one" } }),
|
workflowSettings: () => ({ deployment: { servers: mocks.servers, defaultServer: "one" } }),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/workbench/contributions", () => ({
|
vi.mock("@/workbench/contributions", () => ({
|
||||||
notifications: () => ({ notify: mocks.notify }),
|
notifications: () => ({ notify: mocks.notify }),
|
||||||
@@ -73,7 +72,6 @@ async function run(id: string, paths = ["/project/a", "/project/b"]) {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.servers = ["one", "two"].map((id) => ({ id, name: id, kind: "local" }) as DeploymentServerValid);
|
mocks.servers = ["one", "two"].map((id) => ({ id, name: id, kind: "local" }) as DeploymentServerValid);
|
||||||
mocks.groups = [];
|
|
||||||
mocks.coverage = [
|
mocks.coverage = [
|
||||||
{
|
{
|
||||||
server: "one",
|
server: "one",
|
||||||
@@ -93,34 +91,6 @@ beforeEach(() => {
|
|||||||
useDeployment.setState({ sync: null });
|
useDeployment.setState({ sync: null });
|
||||||
registerDeploymentActions();
|
registerDeploymentActions();
|
||||||
});
|
});
|
||||||
describe("server groups", () => {
|
|
||||||
it("offers a group with a covering server and uploads to each of its servers, past a failure", async () => {
|
|
||||||
mocks.groups = [
|
|
||||||
{ id: "g", name: "Production", servers: ["one", "two"] },
|
|
||||||
{ id: "none", name: "Elsewhere", servers: ["two"] },
|
|
||||||
];
|
|
||||||
mocks.coverage[1]!.paths = [];
|
|
||||||
mocks.choose.mockResolvedValue("Production (group)");
|
|
||||||
mocks.upload.mockRejectedValueOnce(new Error("connection refused"));
|
|
||||||
await run("deployment.uploadTo");
|
|
||||||
expect(mocks.choose).toHaveBeenCalledWith({ title: "Upload to", items: ["one", "Production (group)"] });
|
|
||||||
await vi.waitFor(() =>
|
|
||||||
expect(mocks.notify).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ title: "No selected paths can be deployed to two" }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(mocks.upload).toHaveBeenCalledTimes(1);
|
|
||||||
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ title: "Cannot upload to one" }));
|
|
||||||
});
|
|
||||||
it("uploads to every covered server of the group in order", async () => {
|
|
||||||
mocks.groups = [{ id: "g", name: "Production", servers: ["two", "one"] }];
|
|
||||||
mocks.coverage[1]!.paths = ["/project/b"];
|
|
||||||
mocks.choose.mockResolvedValue("Production (group)");
|
|
||||||
await run("deployment.uploadTo");
|
|
||||||
await vi.waitFor(() => expect(mocks.upload).toHaveBeenCalledTimes(2));
|
|
||||||
expect(mocks.upload.mock.calls.map((c) => c[1])).toEqual(["two", "one"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
describe("deployment eligibility", () => {
|
describe("deployment eligibility", () => {
|
||||||
it("keeps upstream's server-specific explanation when only one server is configured", async () => {
|
it("keeps upstream's server-specific explanation when only one server is configured", async () => {
|
||||||
mocks.servers = [mocks.servers[0]!];
|
mocks.servers = [mocks.servers[0]!];
|
||||||
|
|||||||
@@ -89,13 +89,13 @@ function notifyNoCoverage(server?: DeploymentServerValid, skipped?: TransferRepo
|
|||||||
: "No deployment server covers the selected paths",
|
: "No deployment server covers the selected paths",
|
||||||
body: skipped?.length ? <ReportBody report={{ files: 0, skipped: skipped.length, errors: skipped }} /> : undefined,
|
body: skipped?.length ? <ReportBody report={{ files: 0, skipped: skipped.length, errors: skipped }} /> : undefined,
|
||||||
group: GROUP,
|
group: GROUP,
|
||||||
key: server ? `deployment.noCoverage.${server.id}` : "deployment.noCoverage",
|
key: "deployment.noCoverage",
|
||||||
actions: [{ label: "Configure…", run: () => void executeAction("deployment.configure") }],
|
actions: [{ label: "Configure…", run: () => void executeAction("deployment.configure") }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The servers covering `paths`, or null (after saying why) when none does. */
|
/** Keep the chooser visible even when only one server covers the selection. */
|
||||||
async function coveringServers(paths: string[]): Promise<DeploymentServerValid[] | null> {
|
async function pickServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
||||||
const configured = deployment().servers;
|
const configured = deployment().servers;
|
||||||
if (!configured.length) {
|
if (!configured.length) {
|
||||||
notifyNoServers();
|
notifyNoServers();
|
||||||
@@ -119,36 +119,11 @@ async function coveringServers(paths: string[]): Promise<DeploymentServerValid[]
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return servers;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Keep the chooser visible even when only one server covers the selection. */
|
|
||||||
async function pickServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
|
||||||
const servers = await coveringServers(paths);
|
|
||||||
if (!servers) return null;
|
|
||||||
const labels = servers.map(serverLabel);
|
const labels = servers.map(serverLabel);
|
||||||
const picked = await chooseItem({ title, items: labels });
|
const picked = await chooseItem({ title, items: labels });
|
||||||
return picked === null ? null : (servers[labels.indexOf(picked)] ?? null);
|
return picked === null ? null : (servers[labels.indexOf(picked)] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Upload to…: a server covering the selection, or a group with such a server (all its servers). */
|
|
||||||
async function pickUploadTargets(paths: string[]): Promise<DeploymentServerValid[] | null> {
|
|
||||||
const servers = await coveringServers(paths);
|
|
||||||
if (!servers) return null;
|
|
||||||
const { servers: configured, groups } = deployment();
|
|
||||||
const choices = new Map<string, DeploymentServerValid[]>();
|
|
||||||
for (const server of servers) if (!choices.has(serverLabel(server))) choices.set(serverLabel(server), [server]);
|
|
||||||
for (const group of groups) {
|
|
||||||
const members = group.servers.flatMap((id) => configured.find((s) => s.id === id) ?? []);
|
|
||||||
if (!members.some((m) => servers.includes(m))) continue;
|
|
||||||
const name = group.name.trim() || group.id;
|
|
||||||
const label = tx`${name} (group)`;
|
|
||||||
if (!choices.has(label)) choices.set(label, members);
|
|
||||||
}
|
|
||||||
const picked = await chooseItem({ title: "Upload to", items: [...choices.keys()] });
|
|
||||||
return picked === null ? null : (choices.get(picked) ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function defaultServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
async function defaultServer(title: string, paths: string[]): Promise<DeploymentServerValid | null> {
|
||||||
return resolveServer(deployment(), null) ?? pickServer(title, paths);
|
return resolveServer(deployment(), null) ?? pickServer(title, paths);
|
||||||
}
|
}
|
||||||
@@ -185,8 +160,7 @@ export function showReport(
|
|||||||
title: s.title.replace("{n}", String(s.params.n)).replace("{server}", s.params.server),
|
title: s.title.replace("{n}", String(s.params.n)).replace("{server}", s.params.server),
|
||||||
body: report.errors.length || report.skipped ? <ReportBody report={report} /> : undefined,
|
body: report.errors.length || report.skipped ? <ReportBody report={report} /> : undefined,
|
||||||
group: GROUP,
|
group: GROUP,
|
||||||
// One per server, so each server of a group reports on its own.
|
key: `deployment.${verb}`,
|
||||||
key: `deployment.${verb}.${server.id}`,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +171,7 @@ function showFailure(title: string, server: DeploymentServerValid, e: unknown) {
|
|||||||
title: title.replace("{server}", serverLabel(server)),
|
title: title.replace("{server}", serverLabel(server)),
|
||||||
body: errorText(e),
|
body: errorText(e),
|
||||||
group: GROUP,
|
group: GROUP,
|
||||||
key: `deployment.failure.${server.id}`,
|
key: "deployment.failure",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,10 +451,7 @@ export function registerDeploymentActions(): () => void {
|
|||||||
category: CATEGORY,
|
category: CATEGORY,
|
||||||
when,
|
when,
|
||||||
keywords: ["deploy", "sftp", "upload", "remote"],
|
keywords: ["deploy", "sftp", "upload", "remote"],
|
||||||
run: withTargets(async (paths) => {
|
run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to", paths), paths)),
|
||||||
// A group uploads to each server in turn; each reports on its own, and a failure doesn't stop the rest.
|
|
||||||
for (const server of (await pickUploadTargets(paths)) ?? []) await transfer("upload", server, paths);
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "deployment.downloadRemoteInto",
|
id: "deployment.downloadRemoteInto",
|
||||||
|
|||||||
@@ -55,12 +55,6 @@ export const UI_KEYS: Record<string, string[]> = {
|
|||||||
"--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"],
|
"--bg-tab-selected": ["EditorTabs.underlinedTabBackground", "EditorTabs.selectedBackground"],
|
||||||
// Islands paints the selected tab's border with `underlinedBorderColor`; the classic tabs underline with `underlineColor`.
|
// Islands paints the selected tab's border with `underlinedBorderColor`; the classic tabs underline with `underlineColor`.
|
||||||
"--border-tab-selected": ["EditorTabs.underlinedBorderColor", "EditorTabs.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"],
|
"--bg-tab-hover": ["EditorTabs.hoverBackground"],
|
||||||
"--border-editor-tabs": ["EditorTabs.borderColor"],
|
"--border-editor-tabs": ["EditorTabs.borderColor"],
|
||||||
"--bg-toolbar-hover": ["ActionButton.hoverBackground"],
|
"--bg-toolbar-hover": ["ActionButton.hoverBackground"],
|
||||||
|
|||||||
@@ -301,32 +301,6 @@ 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", () => {
|
it("does not offer removal on the attached primary", () => {
|
||||||
useProject.setState({
|
useProject.setState({
|
||||||
project: {
|
project: {
|
||||||
|
|||||||
@@ -151,9 +151,6 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
|||||||
const project = useProject.getState().project;
|
const project = useProject.getState().project;
|
||||||
// Scratches and other contributed roots live outside the project: no VCS, roots or LSP usages.
|
// 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));
|
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 items: MenuItem[] = [];
|
||||||
const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i));
|
const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i));
|
||||||
|
|
||||||
@@ -231,7 +228,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
|||||||
shortcut: keyLabel("project.open"),
|
shortcut: keyLabel("project.open"),
|
||||||
run: () => files.forEach((f) => void openFile(f.path)),
|
run: () => files.forEach((f) => void openFile(f.path)),
|
||||||
},
|
},
|
||||||
inWorkspace &&
|
inProject &&
|
||||||
singleFile && {
|
singleFile && {
|
||||||
id: "findUsages",
|
id: "findUsages",
|
||||||
label: "Find Usages",
|
label: "Find Usages",
|
||||||
@@ -341,7 +338,7 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Git › (`vcsAvailable` also covers repositories nested below a non-Git project root)
|
// Git › (`vcsAvailable` also covers repositories nested below a non-Git project root)
|
||||||
if (inWorkspace && (project?.isGit || getContext().vcsAvailable === true)) {
|
if (inProject && (project?.isGit || getContext().vcsAvailable === true)) {
|
||||||
const arg = vcsArg(nodes);
|
const arg = vcsArg(nodes);
|
||||||
const dirs = nodes.some((n) => n.dir);
|
const dirs = nodes.some((n) => n.dir);
|
||||||
push(
|
push(
|
||||||
|
|||||||
@@ -194,13 +194,3 @@
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
color: var(--fg-subtle);
|
color: var(--fg-subtle);
|
||||||
}
|
}
|
||||||
.groupMembers {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
.groupTitle {
|
|
||||||
color: var(--fg-subtle);
|
|
||||||
padding-bottom: 2px;
|
|
||||||
}
|
|
||||||
@@ -13,24 +13,10 @@ import {
|
|||||||
deploymentConnectable,
|
deploymentConnectable,
|
||||||
deploymentConnection,
|
deploymentConnection,
|
||||||
deploymentSecretKey,
|
deploymentSecretKey,
|
||||||
type DeploymentGroupValid,
|
|
||||||
type DeploymentServerValid,
|
type DeploymentServerValid,
|
||||||
type FtpConnectionValid,
|
type FtpConnectionValid,
|
||||||
} from "@/platform/remoteSettings";
|
} from "@/platform/remoteSettings";
|
||||||
import {
|
import { Badge, Button, Checkbox, Dialog, Icon, IconButton, Input, Select, Spinner, T, Tabs } from "@/ui";
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Dialog,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
showContextMenu,
|
|
||||||
Spinner,
|
|
||||||
T,
|
|
||||||
Tabs,
|
|
||||||
} from "@/ui";
|
|
||||||
import { GlobListEditor, Row } from "../components/Form";
|
import { GlobListEditor, Row } from "../components/Form";
|
||||||
import { SshConfigSelect, useSshConfigs } from "../components/SshConfigSelect";
|
import { SshConfigSelect, useSshConfigs } from "../components/SshConfigSelect";
|
||||||
import { useSetting } from "../components/context";
|
import { useSetting } from "../components/context";
|
||||||
@@ -39,13 +25,10 @@ import {
|
|||||||
fixDefaultServer,
|
fixDefaultServer,
|
||||||
folderWithin,
|
folderWithin,
|
||||||
formatExcludes,
|
formatExcludes,
|
||||||
newGroup,
|
|
||||||
newServer,
|
newServer,
|
||||||
parseExcludes,
|
parseExcludes,
|
||||||
pruneGroups,
|
|
||||||
remoteParent,
|
remoteParent,
|
||||||
removeMapping,
|
removeMapping,
|
||||||
toggleGroupServer,
|
|
||||||
updateMapping,
|
updateMapping,
|
||||||
} from "./deploymentModel";
|
} from "./deploymentModel";
|
||||||
import { choosePath } from "../languages/api";
|
import { choosePath } from "../languages/api";
|
||||||
@@ -628,118 +611,44 @@ function ExcludedPathsTab({
|
|||||||
|
|
||||||
type ServerTab = "connection" | "mappings" | "excluded";
|
type ServerTab = "connection" | "mappings" | "excluded";
|
||||||
|
|
||||||
function GroupPanel({
|
|
||||||
group,
|
|
||||||
servers,
|
|
||||||
update,
|
|
||||||
}: {
|
|
||||||
group: DeploymentGroupValid;
|
|
||||||
servers: readonly DeploymentServerValid[];
|
|
||||||
update: (patch: Partial<DeploymentGroupValid>) => void;
|
|
||||||
}) {
|
|
||||||
const tx = useTx();
|
|
||||||
return (
|
|
||||||
<div className={styles.panel}>
|
|
||||||
<TextField label="Name" value={group.name} onChange={(name) => update({ name })} />
|
|
||||||
<div className={styles.groupMembers} role="group" aria-label={tx("Servers in group")}>
|
|
||||||
<div className={styles.groupTitle}>
|
|
||||||
<T>Servers in group</T>
|
|
||||||
</div>
|
|
||||||
{servers.length === 0 && (
|
|
||||||
<div className={styles.note}>
|
|
||||||
<T>No servers. Click + to add one.</T>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{servers.map((s) => (
|
|
||||||
<Checkbox
|
|
||||||
key={s.id}
|
|
||||||
checked={group.servers.includes(s.id)}
|
|
||||||
onChange={() => update(toggleGroupServer(group, s.id, servers))}
|
|
||||||
label={<span>{s.name || s.id}</span>}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className={styles.note}>
|
|
||||||
<T>Upload to… on this group uploads the files to each of its servers in turn.</T>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type Selection = { kind: "server" | "group"; id: string } | null;
|
|
||||||
|
|
||||||
function DeploymentServersEditor() {
|
function DeploymentServersEditor() {
|
||||||
const tx = useTx();
|
const tx = useTx();
|
||||||
const [servers, setServers] = useSetting<DeploymentServerValid[]>("deployment.servers");
|
const [servers, setServers] = useSetting<DeploymentServerValid[]>("deployment.servers");
|
||||||
const [groups, setGroups] = useSetting<DeploymentGroupValid[]>("deployment.groups");
|
|
||||||
const [defaultServer, setDefaultServer] = useSetting<string>("deployment.defaultServer");
|
const [defaultServer, setDefaultServer] = useSetting<string>("deployment.defaultServer");
|
||||||
const list = servers ?? [];
|
const list = servers ?? [];
|
||||||
const groupList = groups ?? [];
|
const [selected, setSelected] = useState<string | null>(list[0]?.id ?? null);
|
||||||
const [selected, setSelected] = useState<Selection>(list[0] ? { kind: "server", id: list[0].id } : null);
|
|
||||||
const [tab, setTab] = useState<ServerTab>("connection");
|
const [tab, setTab] = useState<ServerTab>("connection");
|
||||||
const current = selected?.kind === "server" ? (list.find((s) => s.id === selected.id) ?? null) : null;
|
const current = list.find((s) => s.id === selected) ?? null;
|
||||||
const currentGroup = selected?.kind === "group" ? (groupList.find((g) => g.id === selected.id) ?? null) : null;
|
|
||||||
const effectiveDefault = fixDefaultServer(list, defaultServer ?? "");
|
const effectiveDefault = fixDefaultServer(list, defaultServer ?? "");
|
||||||
|
|
||||||
const commit = (next: DeploymentServerValid[]) => {
|
const commit = (next: DeploymentServerValid[]) => {
|
||||||
setServers(next);
|
setServers(next);
|
||||||
const d = fixDefaultServer(next, defaultServer ?? "");
|
const d = fixDefaultServer(next, defaultServer ?? "");
|
||||||
if (d !== (defaultServer ?? "")) setDefaultServer(d);
|
if (d !== (defaultServer ?? "")) setDefaultServer(d);
|
||||||
const pruned = pruneGroups(groupList, next);
|
|
||||||
if (pruned.some((g, i) => g !== groupList[i])) setGroups(pruned);
|
|
||||||
};
|
};
|
||||||
const update = (patch: Partial<DeploymentServerValid>) => {
|
const update = (patch: Partial<DeploymentServerValid>) => {
|
||||||
if (current) commit(list.map((s) => (s.id === current.id ? { ...s, ...patch } : s)));
|
if (current) commit(list.map((s) => (s.id === current.id ? { ...s, ...patch } : s)));
|
||||||
};
|
};
|
||||||
const updateGroup = (patch: Partial<DeploymentGroupValid>) => {
|
|
||||||
if (currentGroup) setGroups(groupList.map((g) => (g.id === currentGroup.id ? { ...g, ...patch } : g)));
|
|
||||||
};
|
|
||||||
const add = (base?: DeploymentServerValid) => {
|
const add = (base?: DeploymentServerValid) => {
|
||||||
const server = newServer(list, base);
|
const server = newServer(list, base);
|
||||||
commit([...list, server]);
|
commit([...list, server]);
|
||||||
setSelected({ kind: "server", id: server.id });
|
setSelected(server.id);
|
||||||
setTab("connection");
|
setTab("connection");
|
||||||
};
|
};
|
||||||
const addGroup = () => {
|
|
||||||
const group = newGroup(groupList);
|
|
||||||
setGroups([...groupList, group]);
|
|
||||||
setSelected({ kind: "group", id: group.id });
|
|
||||||
};
|
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
if (currentGroup) {
|
|
||||||
const index = groupList.indexOf(currentGroup);
|
|
||||||
const rest = groupList.filter((g) => g.id !== currentGroup.id);
|
|
||||||
setGroups(rest);
|
|
||||||
const next = rest[Math.min(index, rest.length - 1)] ?? null;
|
|
||||||
setSelected(next ? { kind: "group", id: next.id } : list[0] ? { kind: "server", id: list[0].id } : null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
const index = list.indexOf(current);
|
const index = list.indexOf(current);
|
||||||
const rest = list.filter((s) => s.id !== current.id);
|
const rest = list.filter((s) => s.id !== current.id);
|
||||||
commit(rest);
|
commit(rest);
|
||||||
const next = rest[Math.min(index, rest.length - 1)] ?? null;
|
setSelected(rest[Math.min(index, rest.length - 1)]?.id ?? null);
|
||||||
setSelected(next ? { kind: "server", id: next.id } : null);
|
|
||||||
};
|
};
|
||||||
const isSelected = (kind: "server" | "group", id: string) => selected?.kind === kind && selected.id === id;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.layout}>
|
<div className={styles.layout}>
|
||||||
<div className={workflow.tableBox}>
|
<div className={workflow.tableBox}>
|
||||||
<div className={workflow.toolbar} role="toolbar" aria-label={tx("Deployment servers actions")}>
|
<div className={workflow.toolbar} role="toolbar" aria-label={tx("Deployment servers actions")}>
|
||||||
<IconButton
|
<IconButton icon="plus" title="Add" size="small" onClick={() => add()} />
|
||||||
icon="plus"
|
<IconButton icon="minus" title="Remove" size="small" disabled={!current} onClick={remove} />
|
||||||
title="Add"
|
|
||||||
size="small"
|
|
||||||
onClick={(e) => {
|
|
||||||
const r = e.currentTarget.getBoundingClientRect();
|
|
||||||
showContextMenu({ x: r.left, y: r.bottom }, [
|
|
||||||
{ label: "Server", icon: "web", run: () => add() },
|
|
||||||
{ label: "Server Group", icon: "folder", run: addGroup },
|
|
||||||
]);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<IconButton icon="minus" title="Remove" size="small" disabled={!current && !currentGroup} onClick={remove} />
|
|
||||||
<IconButton
|
<IconButton
|
||||||
icon="copy"
|
icon="copy"
|
||||||
title="Copy"
|
title="Copy"
|
||||||
@@ -756,31 +665,18 @@ function DeploymentServersEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.serverList} role="listbox" aria-label={tx("Deployment servers")}>
|
<div className={styles.serverList} role="listbox" aria-label={tx("Deployment servers")}>
|
||||||
{list.length === 0 && groupList.length === 0 && (
|
{list.length === 0 && (
|
||||||
<div className={styles.empty}>
|
<div className={styles.empty}>
|
||||||
<T>No servers. Click + to add one.</T>
|
<T>No servers. Click + to add one.</T>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{groupList.map((g) => (
|
|
||||||
<div
|
|
||||||
key={g.id}
|
|
||||||
role="option"
|
|
||||||
aria-selected={isSelected("group", g.id)}
|
|
||||||
className={clsx(styles.serverRow, isSelected("group", g.id) && styles.serverSelected)}
|
|
||||||
onMouseDown={() => setSelected({ kind: "group", id: g.id })}
|
|
||||||
>
|
|
||||||
<Icon name="folder" />
|
|
||||||
<span className={styles.serverName}>{g.name || g.id}</span>
|
|
||||||
<Badge>{g.servers.length}</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{list.map((s) => (
|
{list.map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.id}
|
key={s.id}
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={isSelected("server", s.id)}
|
aria-selected={s.id === selected}
|
||||||
className={clsx(styles.serverRow, isSelected("server", s.id) && styles.serverSelected)}
|
className={clsx(styles.serverRow, s.id === selected && styles.serverSelected)}
|
||||||
onMouseDown={() => setSelected({ kind: "server", id: s.id })}
|
onMouseDown={() => setSelected(s.id)}
|
||||||
>
|
>
|
||||||
<Icon name="web" />
|
<Icon name="web" />
|
||||||
<span className={clsx(styles.serverName, s.id === effectiveDefault && styles.default)}>
|
<span className={clsx(styles.serverName, s.id === effectiveDefault && styles.default)}>
|
||||||
@@ -791,11 +687,7 @@ function DeploymentServersEditor() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{currentGroup ? (
|
{current ? (
|
||||||
<div className={styles.detail}>
|
|
||||||
<GroupPanel key={currentGroup.id} group={currentGroup} servers={list} update={updateGroup} />
|
|
||||||
</div>
|
|
||||||
) : current ? (
|
|
||||||
<div className={styles.detail}>
|
<div className={styles.detail}>
|
||||||
<Tabs
|
<Tabs
|
||||||
variant="compact"
|
variant="compact"
|
||||||
@@ -888,7 +780,6 @@ export const DEPLOYMENT_PAGES: PageSpec[] = [
|
|||||||
"Passive mode",
|
"Passive mode",
|
||||||
"Test Connection",
|
"Test Connection",
|
||||||
"Use as Default",
|
"Use as Default",
|
||||||
"Server Group",
|
|
||||||
"Local path",
|
"Local path",
|
||||||
"Deployment path",
|
"Deployment path",
|
||||||
"Web path",
|
"Web path",
|
||||||
|
|||||||
@@ -5,40 +5,14 @@ import {
|
|||||||
fixDefaultServer,
|
fixDefaultServer,
|
||||||
folderWithin,
|
folderWithin,
|
||||||
formatExcludes,
|
formatExcludes,
|
||||||
newGroup,
|
|
||||||
newServer,
|
newServer,
|
||||||
nextServerName,
|
nextServerName,
|
||||||
parseExcludes,
|
parseExcludes,
|
||||||
pruneGroups,
|
|
||||||
remoteParent,
|
remoteParent,
|
||||||
removeMapping,
|
removeMapping,
|
||||||
toggleGroupServer,
|
|
||||||
updateMapping,
|
updateMapping,
|
||||||
} from "./deploymentModel";
|
} from "./deploymentModel";
|
||||||
|
|
||||||
describe("server groups", () => {
|
|
||||||
const servers = ["a", "b", "c"].map((id) => ({ ...DEFAULT_DEPLOYMENT_SERVER, id, name: id }));
|
|
||||||
it("names a new group after the first free number", () => {
|
|
||||||
const g = newGroup([{ id: "x", name: "Group 2", servers: [] }]);
|
|
||||||
expect(g.name).toBe("Group 3");
|
|
||||||
expect(g.servers).toEqual([]);
|
|
||||||
expect(g.id).toMatch(/^group-/);
|
|
||||||
});
|
|
||||||
it("toggles members, keeping the server list's order", () => {
|
|
||||||
let g = { id: "g", name: "G", servers: [] as string[] };
|
|
||||||
g = toggleGroupServer(g, "c", servers);
|
|
||||||
g = toggleGroupServer(g, "a", servers);
|
|
||||||
expect(g.servers).toEqual(["a", "c"]);
|
|
||||||
expect(toggleGroupServer(g, "a", servers).servers).toEqual(["c"]);
|
|
||||||
});
|
|
||||||
it("drops removed servers from groups and keeps untouched groups as they are", () => {
|
|
||||||
const keep = { id: "k", name: "K", servers: ["a"] };
|
|
||||||
const pruned = pruneGroups([keep, { id: "g", name: "G", servers: ["a", "b"] }], servers.slice(0, 1));
|
|
||||||
expect(pruned[0]).toBe(keep);
|
|
||||||
expect(pruned[1]!.servers).toEqual(["a"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("exclude names", () => {
|
describe("exclude names", () => {
|
||||||
it("parses a ;-separated list", () => {
|
it("parses a ;-separated list", () => {
|
||||||
expect(parseExcludes(".git; .idea;;*.pyc ;")).toEqual([".git", ".idea", "*.pyc"]);
|
expect(parseExcludes(".git; .idea;;*.pyc ;")).toEqual([".git", ".idea", "*.pyc"]);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_DEPLOYMENT_SERVER,
|
DEFAULT_DEPLOYMENT_SERVER,
|
||||||
newRemoteId,
|
newRemoteId,
|
||||||
type DeploymentGroupValid,
|
|
||||||
type DeploymentMappingValid,
|
type DeploymentMappingValid,
|
||||||
type DeploymentServerValid,
|
type DeploymentServerValid,
|
||||||
} from "@/platform/remoteSettings";
|
} from "@/platform/remoteSettings";
|
||||||
@@ -68,37 +67,6 @@ export function newServer(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A new, empty group named "Group <n>" (the first free number). */
|
|
||||||
export function newGroup(groups: readonly DeploymentGroupValid[]): DeploymentGroupValid {
|
|
||||||
const names = new Set(groups.map((g) => g.name));
|
|
||||||
let n = groups.length + 1;
|
|
||||||
while (names.has(`Group ${n}`)) n++;
|
|
||||||
return { id: newRemoteId("group"), name: `Group ${n}`, servers: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Adds or removes `server` in `group`, keeping the members in the server list's order. */
|
|
||||||
export function toggleGroupServer(
|
|
||||||
group: DeploymentGroupValid,
|
|
||||||
server: string,
|
|
||||||
servers: readonly DeploymentServerValid[],
|
|
||||||
): DeploymentGroupValid {
|
|
||||||
const members = new Set(group.servers);
|
|
||||||
if (members.has(server)) members.delete(server);
|
|
||||||
else members.add(server);
|
|
||||||
return { ...group, servers: servers.map((s) => s.id).filter((id) => members.has(id)) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `groups` without members that are no longer in `servers`. */
|
|
||||||
export function pruneGroups(
|
|
||||||
groups: readonly DeploymentGroupValid[],
|
|
||||||
servers: readonly DeploymentServerValid[],
|
|
||||||
): DeploymentGroupValid[] {
|
|
||||||
const ids = new Set(servers.map((s) => s.id));
|
|
||||||
return groups.map((g) =>
|
|
||||||
g.servers.every((id) => ids.has(id)) ? g : { ...g, servers: g.servers.filter((id) => ids.has(id)) },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The default server after `servers` changed: kept when still present, else the first one (or none). */
|
/** The default server after `servers` changed: kept when still present, else the first one (or none). */
|
||||||
export function fixDefaultServer(servers: readonly DeploymentServerValid[], defaultServer: string): string {
|
export function fixDefaultServer(servers: readonly DeploymentServerValid[], defaultServer: string): string {
|
||||||
if (servers.some((s) => s.id === defaultServer)) return defaultServer;
|
if (servers.some((s) => s.id === defaultServer)) return defaultServer;
|
||||||
|
|||||||
@@ -1107,8 +1107,7 @@ export function DiffEditor({
|
|||||||
viewer: viewerOf,
|
viewer: viewerOf,
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Keep the registered navigator's identity while refreshing its handlers.
|
nav.current = {
|
||||||
Object.assign(nav.current, {
|
|
||||||
next: () => step(1),
|
next: () => step(1),
|
||||||
prev: () => step(-1),
|
prev: () => step(-1),
|
||||||
jumpToSource,
|
jumpToSource,
|
||||||
@@ -1116,7 +1115,7 @@ export function DiffEditor({
|
|||||||
file,
|
file,
|
||||||
showSettings,
|
showSettings,
|
||||||
viewer: viewerOf,
|
viewer: viewerOf,
|
||||||
});
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
|
|||||||
@@ -560,41 +560,6 @@ describe("Jump to Source of a revision diff", () => {
|
|||||||
expect(await jumpFrom(0, 2)).toEqual(["/p/a.ts", { selection: { line: 5, col: 1 }, focus: true }]);
|
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 () => {
|
it("jumps on ⌘↓ in the IntelliJ macOS keymap only while a diff pane has focus", async () => {
|
||||||
setUp();
|
setUp();
|
||||||
const keymap = useKeymap.getState();
|
const keymap = useKeymap.getState();
|
||||||
|
|||||||
@@ -264,7 +264,6 @@ describe("catalogs", () => {
|
|||||||
"my-agent",
|
"my-agent",
|
||||||
"Java",
|
"Java",
|
||||||
"Python",
|
"Python",
|
||||||
"Python {short}",
|
|
||||||
"C/C++",
|
"C/C++",
|
||||||
"C#",
|
"C#",
|
||||||
"Go",
|
"Go",
|
||||||
|
|||||||
@@ -404,7 +404,6 @@ export const intellijMacos: Record<string, string[]> = {
|
|||||||
"db.runSqlScript": [],
|
"db.runSqlScript": [],
|
||||||
"db.sqlScript": [],
|
"db.sqlScript": [],
|
||||||
"db.goToReferencedTable": [],
|
"db.goToReferencedTable": [],
|
||||||
"db.expandAll": [],
|
|
||||||
"db.collapseAll": [],
|
"db.collapseAll": [],
|
||||||
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
|
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
|
||||||
"terminal.new": ["Meta+T"],
|
"terminal.new": ["Meta+T"],
|
||||||
|
|||||||
@@ -402,7 +402,6 @@ export const intellijWindows: Record<string, string[]> = {
|
|||||||
"db.runSqlScript": [],
|
"db.runSqlScript": [],
|
||||||
"db.sqlScript": [],
|
"db.sqlScript": [],
|
||||||
"db.goToReferencedTable": [],
|
"db.goToReferencedTable": [],
|
||||||
"db.expandAll": [],
|
|
||||||
"db.collapseAll": [],
|
"db.collapseAll": [],
|
||||||
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
|
// Terminal (keyWhen: terminalFocus); after VCS Update / Go to Test so it wins there.
|
||||||
"terminal.new": ["Ctrl+Shift+T"],
|
"terminal.new": ["Ctrl+Shift+T"],
|
||||||
|
|||||||
@@ -392,7 +392,6 @@ export const vscodeMacos: Record<string, string[]> = {
|
|||||||
"db.runSqlScript": [],
|
"db.runSqlScript": [],
|
||||||
"db.sqlScript": [],
|
"db.sqlScript": [],
|
||||||
"db.goToReferencedTable": [],
|
"db.goToReferencedTable": [],
|
||||||
"db.expandAll": [],
|
|
||||||
"db.collapseAll": [],
|
"db.collapseAll": [],
|
||||||
// Find in Files popup (findPopupOpen): after the editor's keys, so these win while it is open.
|
// Find in Files popup (findPopupOpen): after the editor's keys, so these win while it is open.
|
||||||
"find.popup.nextResult": ["Meta+G"],
|
"find.popup.nextResult": ["Meta+G"],
|
||||||
|
|||||||
@@ -1011,7 +1011,6 @@
|
|||||||
"Decompiled .class file: sources are not available.": "Decompiled .class file: sources are not available.",
|
"Decompiled .class file: sources are not available.": "Decompiled .class file: sources are not available.",
|
||||||
"Set DEFAULT": "Set DEFAULT",
|
"Set DEFAULT": "Set DEFAULT",
|
||||||
"Toggle Boolean Cell": "Toggle Boolean Cell",
|
"Toggle Boolean Cell": "Toggle Boolean Cell",
|
||||||
"Local Filter": "Local Filter",
|
|
||||||
"Toggle {name}": "Toggle {name}",
|
"Toggle {name}": "Toggle {name}",
|
||||||
"true": "true",
|
"true": "true",
|
||||||
"false": "false",
|
"false": "false",
|
||||||
@@ -1506,9 +1505,5 @@
|
|||||||
"Port": "Port",
|
"Port": "Port",
|
||||||
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "The fixed port on 127.0.0.1, used while Use a fixed port is on.",
|
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "The fixed port on 127.0.0.1, used while Use a fixed port is on.",
|
||||||
"Keep the token": "Keep the token",
|
"Keep the token": "Keep the token",
|
||||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.",
|
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks."
|
||||||
"{name} (group)": "{name} (group)",
|
|
||||||
"Servers in group": "Servers in group",
|
|
||||||
"Upload to… on this group uploads the files to each of its servers in turn.": "Upload to… on this group uploads the files to each of its servers in turn.",
|
|
||||||
"Server Group": "Server Group"
|
|
||||||
}
|
}
|
||||||
@@ -6844,8 +6844,6 @@
|
|||||||
"Bad regex": "正则表达式无效",
|
"Bad regex": "正则表达式无效",
|
||||||
"Filter rows": "筛选行",
|
"Filter rows": "筛选行",
|
||||||
"Result pages": "结果分页",
|
"Result pages": "结果分页",
|
||||||
"Result actions": "结果操作",
|
|
||||||
"Output actions": "输出操作",
|
|
||||||
"First Page": "第一页",
|
"First Page": "第一页",
|
||||||
"Count rows": "统计行数",
|
"Count rows": "统计行数",
|
||||||
"Last Page": "最后一页",
|
"Last Page": "最后一页",
|
||||||
@@ -7376,7 +7374,6 @@
|
|||||||
"Sources not found": "未找到源代码",
|
"Sources not found": "未找到源代码",
|
||||||
"Cannot load library sources": "无法加载库源代码",
|
"Cannot load library sources": "无法加载库源代码",
|
||||||
"Toggle Boolean Cell": "切换布尔单元格",
|
"Toggle Boolean Cell": "切换布尔单元格",
|
||||||
"Local Filter": "本地筛选",
|
|
||||||
"Toggle {name}": "切换 {name}",
|
"Toggle {name}": "切换 {name}",
|
||||||
"true": "真",
|
"true": "真",
|
||||||
"false": "假",
|
"false": "假",
|
||||||
@@ -7907,9 +7904,5 @@
|
|||||||
"Listen on the same port in every run, so HTTP clients and port forwards keep working after OpenIDEA restarts. When the port is in use, this run takes a free one and Status says so. Off: a free port in every run.": "每次运行都监听同一个端口,这样 OpenIDEA 重启后 HTTP 客户端和端口转发仍然有效。端口被占用时,本次运行改用一个空闲端口,并在状态中说明。关闭时:每次运行使用一个空闲端口。",
|
"Listen on the same port in every run, so HTTP clients and port forwards keep working after OpenIDEA restarts. When the port is in use, this run takes a free one and Status says so. Off: a free port in every run.": "每次运行都监听同一个端口,这样 OpenIDEA 重启后 HTTP 客户端和端口转发仍然有效。端口被占用时,本次运行改用一个空闲端口,并在状态中说明。关闭时:每次运行使用一个空闲端口。",
|
||||||
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "127.0.0.1 上的固定端口,在开启“使用固定端口”时使用。",
|
"The fixed port on 127.0.0.1, used while Use a fixed port is on.": "127.0.0.1 上的固定端口,在开启“使用固定端口”时使用。",
|
||||||
"Keep the token": "保留令牌",
|
"Keep the token": "保留令牌",
|
||||||
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "每次运行都使用同一个访问令牌,只为你的账户保存。关闭时:每次运行生成新令牌。任何拿到令牌的人都能在 OpenIDEA 运行时使用这些工具,令牌泄露时请重新生成。",
|
"Keep the same bearer token in every run, stored for your account only. Off: a new token in every run. Anyone with the token can use the tools while OpenIDEA runs, so regenerate it if it leaks.": "每次运行都使用同一个访问令牌,只为你的账户保存。关闭时:每次运行生成新令牌。任何拿到令牌的人都能在 OpenIDEA 运行时使用这些工具,令牌泄露时请重新生成。"
|
||||||
"{name} (group)": "{name}(分组)",
|
|
||||||
"Servers in group": "组内服务器",
|
|
||||||
"Upload to… on this group uploads the files to each of its servers in turn.": "对此分组执行“上传到…”时,会依次上传到组内的每台服务器。",
|
|
||||||
"Server Group": "服务器分组"
|
|
||||||
}
|
}
|
||||||
@@ -43,17 +43,8 @@ export interface DeploymentServerValid {
|
|||||||
excludedPaths: string[];
|
excludedPaths: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Servers that Upload to… on the group uploads to, one after another. */
|
|
||||||
export interface DeploymentGroupValid {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
/** Server ids, in upload order. */
|
|
||||||
servers: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeploymentSettingsValid {
|
export interface DeploymentSettingsValid {
|
||||||
servers: DeploymentServerValid[];
|
servers: DeploymentServerValid[];
|
||||||
groups: DeploymentGroupValid[];
|
|
||||||
defaultServer: string;
|
defaultServer: string;
|
||||||
autoUpload: AutoUploadValid;
|
autoUpload: AutoUploadValid;
|
||||||
deleteRemote: boolean;
|
deleteRemote: boolean;
|
||||||
@@ -115,7 +106,6 @@ export const DEFAULT_DEPLOYMENT_SERVER: DeploymentServerValid = {
|
|||||||
|
|
||||||
export const DEFAULT_DEPLOYMENT: DeploymentSettingsValid = {
|
export const DEFAULT_DEPLOYMENT: DeploymentSettingsValid = {
|
||||||
servers: [],
|
servers: [],
|
||||||
groups: [],
|
|
||||||
defaultServer: "",
|
defaultServer: "",
|
||||||
autoUpload: "never",
|
autoUpload: "never",
|
||||||
deleteRemote: false,
|
deleteRemote: false,
|
||||||
@@ -216,14 +206,8 @@ export function validateDeployment(raw: unknown): DeploymentSettingsValid {
|
|||||||
mappings: validateMappings(s.mappings),
|
mappings: validateMappings(s.mappings),
|
||||||
excludedPaths: strings(s.excludedPaths, []),
|
excludedPaths: strings(s.excludedPaths, []),
|
||||||
}));
|
}));
|
||||||
const groups = withIds(o.groups, "group", (g) => ({
|
|
||||||
id: text(g.id, ""),
|
|
||||||
name: text(g.name, ""),
|
|
||||||
servers: strings(g.servers, []),
|
|
||||||
}));
|
|
||||||
return {
|
return {
|
||||||
servers,
|
servers,
|
||||||
groups,
|
|
||||||
defaultServer: text(o.defaultServer, d.defaultServer),
|
defaultServer: text(o.defaultServer, d.defaultServer),
|
||||||
autoUpload: oneOf(o.autoUpload, ["never", "onExplicitSave", "always"] as const, d.autoUpload),
|
autoUpload: oneOf(o.autoUpload, ["never", "onExplicitSave", "always"] as const, d.autoUpload),
|
||||||
deleteRemote: bool(o.deleteRemote, d.deleteRemote),
|
deleteRemote: bool(o.deleteRemote, d.deleteRemote),
|
||||||
|
|||||||
+1
-13
@@ -12,21 +12,10 @@ export interface CheckboxProps {
|
|||||||
/** Tri-state display. */
|
/** Tri-state display. */
|
||||||
indeterminate?: boolean;
|
indeterminate?: boolean;
|
||||||
className?: string;
|
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;
|
"aria-label"?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Checkbox({
|
export function Checkbox({ checked, onChange, label, disabled, indeterminate, className, ...aria }: CheckboxProps) {
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
label,
|
|
||||||
disabled,
|
|
||||||
indeterminate,
|
|
||||||
className,
|
|
||||||
tabIndex,
|
|
||||||
...aria
|
|
||||||
}: CheckboxProps) {
|
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const tx = useGivenTx();
|
const tx = useGivenTx();
|
||||||
return (
|
return (
|
||||||
@@ -38,7 +27,6 @@ export function Checkbox({
|
|||||||
className={styles.native}
|
className={styles.native}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
tabIndex={tabIndex}
|
|
||||||
aria-checked={indeterminate ? "mixed" : checked}
|
aria-checked={indeterminate ? "mixed" : checked}
|
||||||
aria-label={tx(aria["aria-label"])}
|
aria-label={tx(aria["aria-label"])}
|
||||||
onChange={(e) => onChange(e.target.checked)}
|
onChange={(e) => onChange(e.target.checked)}
|
||||||
|
|||||||
+3
-10
@@ -1,16 +1,9 @@
|
|||||||
.strip {
|
.strip {
|
||||||
--tab-selected-bg: var(--bg-tab-selected);
|
|
||||||
--tab-selected-border: var(--border-tab-selected);
|
|
||||||
flex: none;
|
flex: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
min-width: 0;
|
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. */
|
/* Islands tabs: the selected tab is a rounded pill (tab-selected-bg / -border); no underline, no separators. */
|
||||||
.editor {
|
.editor {
|
||||||
height: var(--h-header);
|
height: var(--h-header);
|
||||||
@@ -54,15 +47,15 @@
|
|||||||
}
|
}
|
||||||
.selected,
|
.selected,
|
||||||
.selected:hover {
|
.selected:hover {
|
||||||
background: var(--tab-selected-bg);
|
background: var(--bg-tab-selected);
|
||||||
box-shadow: inset 0 0 0 1px var(--tab-selected-border);
|
box-shadow: inset 0 0 0 1px var(--border-tab-selected);
|
||||||
}
|
}
|
||||||
.tinted,
|
.tinted,
|
||||||
.tinted:hover {
|
.tinted:hover {
|
||||||
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), transparent);
|
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), transparent);
|
||||||
}
|
}
|
||||||
.tinted.selected {
|
.tinted.selected {
|
||||||
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), var(--tab-selected-bg));
|
background: color-mix(in srgb, var(--tab-tint) var(--tint-strength, 22%), var(--bg-tab-selected));
|
||||||
}
|
}
|
||||||
.tabButton {
|
.tabButton {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -132,11 +132,6 @@ export interface TabsProps {
|
|||||||
"aria-label"?: string;
|
"aria-label"?: string;
|
||||||
/** Tabpanel id prefix for `aria-controls`. */
|
/** Tabpanel id prefix for `aria-controls`. */
|
||||||
panelIdPrefix?: string;
|
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). */
|
/** Enables drag & drop reordering (and moving between strips of the same type). */
|
||||||
dnd?: TabsDnd;
|
dnd?: TabsDnd;
|
||||||
/** Tabs that don't fit: `scroll` the strip (default), `squeeze` them, or `wrap` into rows. */
|
/** Tabs that don't fit: `scroll` the strip (default), `squeeze` them, or `wrap` into rows. */
|
||||||
@@ -161,7 +156,6 @@ export function Tabs({
|
|||||||
trailing,
|
trailing,
|
||||||
className,
|
className,
|
||||||
panelIdPrefix,
|
panelIdPrefix,
|
||||||
focusContent,
|
|
||||||
dnd,
|
dnd,
|
||||||
layout = "scroll",
|
layout = "scroll",
|
||||||
closePosition = "right",
|
closePosition = "right",
|
||||||
@@ -324,9 +318,6 @@ export function Tabs({
|
|||||||
if (onClose && item.closable !== false && !(item.pinned && onTogglePin)) onClose(item.id);
|
if (onClose && item.closable !== false && !(item.pinned && onTogglePin)) onClose(item.id);
|
||||||
else return;
|
else return;
|
||||||
break;
|
break;
|
||||||
case "Tab":
|
|
||||||
if (e.shiftKey || !focusContent?.()) return;
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,7 +8,6 @@ export * from "./Input";
|
|||||||
export * from "./Checkbox";
|
export * from "./Checkbox";
|
||||||
export * from "./Select";
|
export * from "./Select";
|
||||||
export * from "./Tabs";
|
export * from "./Tabs";
|
||||||
export * from "./Toolbar";
|
|
||||||
export {
|
export {
|
||||||
FILES_DRAG,
|
FILES_DRAG,
|
||||||
REMOTE_FILES_DRAG,
|
REMOTE_FILES_DRAG,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { installBrowserGuards } from "./browserGuards";
|
|||||||
import { installDistractionFree } from "./distractionFree";
|
import { installDistractionFree } from "./distractionFree";
|
||||||
import { installFileDrop } from "./fileDrop";
|
import { installFileDrop } from "./fileDrop";
|
||||||
import { installFocusTracking } from "./focus";
|
import { installFocusTracking } from "./focus";
|
||||||
import { installFocusScopeTracking } from "@/ui/focusScope";
|
|
||||||
import { clearProjectNotifications, installNotifyEvents, notificationServiceImpl } from "./notificationStore";
|
import { clearProjectNotifications, installNotifyEvents, notificationServiceImpl } from "./notificationStore";
|
||||||
import {
|
import {
|
||||||
NOTIFICATIONS_TOOL_WINDOW,
|
NOTIFICATIONS_TOOL_WINDOW,
|
||||||
@@ -75,7 +74,6 @@ export function activateWorkbench(): () => void {
|
|||||||
installToolWindowLayoutSync(),
|
installToolWindowLayoutSync(),
|
||||||
installDistractionFree(),
|
installDistractionFree(),
|
||||||
installFocusTracking(setFocusedToolWindow),
|
installFocusTracking(setFocusedToolWindow),
|
||||||
installFocusScopeTracking(),
|
|
||||||
installTaskEvents(),
|
installTaskEvents(),
|
||||||
installNotifyEvents(),
|
installNotifyEvents(),
|
||||||
installBrowserGuards(),
|
installBrowserGuards(),
|
||||||
|
|||||||
@@ -871,7 +871,6 @@ function ToolWindowView({ tw, hidden }: { tw: ToolWindowContribution; hidden: bo
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={styles.window}
|
className={styles.window}
|
||||||
data-toolwindow={tw.id}
|
data-toolwindow={tw.id}
|
||||||
data-focus-scope=""
|
|
||||||
aria-label={titleOf(tw)}
|
aria-label={titleOf(tw)}
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
|||||||
Reference in new issue
Block a user