Expand workspace panel actions and keyboard navigation (#3685)
CI / changes (push) Successful in 6m6s
Docker / setup (push) Successful in 14s
Docker / publish (push) Skipped
Docker / build (push) Failing after 10m1s
CI / relay-tests (push) Canceled after 0s
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / server-tests (ubuntu-latest) (push) Canceled after 0s
CI / server-tests (windows-latest) (push) Canceled after 0s
CI / desktop-tests (ubuntu-latest) (push) Canceled after 0s
CI / desktop-tests (windows-latest) (push) Canceled after 0s
CI / app-tests (push) Canceled after 0s
CI / sdk-tests (push) Canceled after 0s
CI / playwright (shard 1/4) (push) Canceled after 0s
CI / playwright (shard 2/4) (push) Canceled after 0s
CI / playwright (shard 3/4) (push) Canceled after 0s
CI / playwright (shard 4/4) (push) Canceled after 0s
CI / format (push) Canceled after 0s
CI / cli-tests (shard 1/3) (push) Canceled after 0s
CI / cli-tests (shard 2/3) (push) Canceled after 0s
CI / cli-tests (shard 3/3) (push) Canceled after 0s
CI / changes (push) Successful in 6m6s
Docker / setup (push) Successful in 14s
Docker / publish (push) Skipped
Docker / build (push) Failing after 10m1s
CI / relay-tests (push) Canceled after 0s
CI / lint (push) Canceled after 0s
CI / typecheck (push) Canceled after 0s
CI / server-tests (ubuntu-latest) (push) Canceled after 0s
CI / server-tests (windows-latest) (push) Canceled after 0s
CI / desktop-tests (ubuntu-latest) (push) Canceled after 0s
CI / desktop-tests (windows-latest) (push) Canceled after 0s
CI / app-tests (push) Canceled after 0s
CI / sdk-tests (push) Canceled after 0s
CI / playwright (shard 1/4) (push) Canceled after 0s
CI / playwright (shard 2/4) (push) Canceled after 0s
CI / playwright (shard 3/4) (push) Canceled after 0s
CI / playwright (shard 4/4) (push) Canceled after 0s
CI / format (push) Canceled after 0s
CI / cli-tests (shard 1/3) (push) Canceled after 0s
CI / cli-tests (shard 2/3) (push) Canceled after 0s
CI / cli-tests (shard 3/3) (push) Canceled after 0s
* fix(app): keep file navigation in place Retargeting a File tab allocated a new tab identity, causing the tab track to unmount and remount during navigation. Preserve the identity for File-to-File changes while retaining new instances for cross-kind replacements. * feat(app): expand workspace panel actions Keep the Command Center aligned with workspace tab and pane primitives. Add explicit panel placement, focused open behavior, keyboard navigation for empty panes, and a Changes pill that toggles the visible Side panel without closing its tab.
This commit is contained in:
+3
-1
@@ -85,7 +85,9 @@ supporting tabs land in the focused pane instead. Mobile ignores it. Explicit ac
|
|||||||
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
|
— the header toggle, a pane's `+` menu, the empty-state launcher — are never subject
|
||||||
to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are
|
to it. The Changes keyboard shortcut and the diff-stat pill above an agent composer are
|
||||||
supporting opens: they follow the preference and focus an existing Changes tab wherever
|
supporting opens: they follow the preference and focus an existing Changes tab wherever
|
||||||
the user left it.
|
the user left it. When Changes is already visible in the Side panel, the diff-stat pill
|
||||||
|
hides the panel without closing the tab. A different visible Side panel tab switches to
|
||||||
|
Changes instead.
|
||||||
|
|
||||||
## The name
|
## The name
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { rm, writeFile } from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
import { openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ async function seedChangedAgent(repoPrefix: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test("composer diff stat opens Changes in the preferred Side panel", async ({ page }) => {
|
test("composer diff stat toggles Changes in the preferred Side panel", async ({ page }) => {
|
||||||
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
const workspace = await seedChangedAgent("composer-diff-stat-side-");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -58,6 +59,26 @@ test("composer diff stat opens Changes in the preferred Side panel", async ({ pa
|
|||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
await test.step("switching from another Side panel view reveals Changes", async () => {
|
||||||
|
await openFilesPanel(page);
|
||||||
|
await expect(sidePanel.getByTestId("file-explorer-tree-scroll")).toBeVisible();
|
||||||
|
|
||||||
|
await pill.click();
|
||||||
|
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("clicking visible Changes hides the panel without closing its tab", async () => {
|
||||||
|
await pill.click();
|
||||||
|
await expect(sidePanel).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("workspace-tab-working_diff")).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("clicking again restores Changes", async () => {
|
||||||
|
await pill.click();
|
||||||
|
await expect(sidePanel.getByTestId("workspace-tab-working_diff")).toBeVisible();
|
||||||
|
await expect(sidePanel.getByTestId("working-diff-panel")).toBeVisible();
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { expect, type Locator, type Page } from "@playwright/test";
|
||||||
|
import { test } from "../support/fixtures";
|
||||||
|
import { runWorkspaceActionFromCommandCenter } from "../support/helpers/command-center-workspace-actions";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
|
import { waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
function visibleEmptyPane(page: Page): Locator {
|
||||||
|
return page.getByTestId("workspace-pane-empty-state").filter({ visible: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectLauncherSelection(launcher: Locator, name: string): Promise<void> {
|
||||||
|
await expect(launcher.getByRole("button", { name })).toBeFocused();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSidePanelWithKeyboard(page: Page): Promise<Locator> {
|
||||||
|
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||||
|
await page.keyboard.press(`${modifier}+E`);
|
||||||
|
const launcher = page
|
||||||
|
.getByTestId("workspace-side-panel")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.getByTestId("workspace-pane-empty-state");
|
||||||
|
await expect(launcher).toBeVisible({ timeout: 30_000 });
|
||||||
|
return launcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("Empty pane keyboard launcher", () => {
|
||||||
|
test("Cmd+E focuses the launcher and Enter opens the selected view", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-side-panel-keyboard-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
|
||||||
|
const launcher = await openSidePanelWithKeyboard(page);
|
||||||
|
await expectLauncherSelection(launcher, "Changes");
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await expectLauncherSelection(launcher, "Files");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.getByTestId("workspace-side-panel")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.getByTestId("workspace-tab-files"),
|
||||||
|
).toBeVisible({ timeout: 30_000 });
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a new split pane focuses its launcher and wraps arrow navigation", async ({ page }) => {
|
||||||
|
const workspace = await seedWorkspace({ repoPrefix: "empty-pane-split-keyboard-" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gotoWorkspace(page, workspace.workspaceId);
|
||||||
|
await waitForWorkspaceTabsVisible(page);
|
||||||
|
await runWorkspaceActionFromCommandCenter(page, "Split pane right");
|
||||||
|
|
||||||
|
const launcher = visibleEmptyPane(page);
|
||||||
|
await expect(launcher).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expectLauncherSelection(launcher, "Changes");
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowUp");
|
||||||
|
await expectLauncherSelection(launcher, "Terminal");
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await expectLauncherSelection(launcher, "Changes");
|
||||||
|
await page.keyboard.press("ArrowDown");
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
|
||||||
|
await expect(page.getByTestId("workspace-tab-files").filter({ visible: true })).toBeVisible({
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -39,6 +39,12 @@ async function assertHeldFileTransition(
|
|||||||
finalText: string,
|
finalText: string,
|
||||||
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
|
gate: Awaited<ReturnType<typeof delayFileReadResponse>>,
|
||||||
) {
|
) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const originalChip = document.querySelector('[data-testid="workspace-tab-file_src/a.ts"]');
|
||||||
|
(
|
||||||
|
window as typeof window & { __fileTransitionOriginalChip?: Element | null }
|
||||||
|
).__fileTransitionOriginalChip = originalChip;
|
||||||
|
});
|
||||||
await page.evaluate((tabTestId) => {
|
await page.evaluate((tabTestId) => {
|
||||||
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
|
const timeline: Array<{ chip: boolean; loading: boolean; unavailable: boolean }> = [];
|
||||||
const sample = () => {
|
const sample = () => {
|
||||||
@@ -69,6 +75,19 @@ async function assertHeldFileTransition(
|
|||||||
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
|
await expect(page.getByTestId("file-preview-unsupported")).toHaveCount(0);
|
||||||
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
|
await expect(page.getByText("export const sourceA = true;")).toHaveCount(0);
|
||||||
|
|
||||||
|
const chipIdentity = await page.evaluate((tabTestId) => {
|
||||||
|
const windowWithOriginal = window as typeof window & {
|
||||||
|
__fileTransitionOriginalChip?: Element | null;
|
||||||
|
};
|
||||||
|
const originalChip = windowWithOriginal.__fileTransitionOriginalChip ?? null;
|
||||||
|
const currentChip = document.querySelector(`[data-testid="${tabTestId}"]`);
|
||||||
|
return {
|
||||||
|
originalStillConnected: originalChip?.isConnected ?? false,
|
||||||
|
sameNode: originalChip === currentChip,
|
||||||
|
};
|
||||||
|
}, `workspace-tab-file_${targetPath}`);
|
||||||
|
expect(chipIdentity).toEqual({ originalStillConnected: true, sameNode: true });
|
||||||
|
|
||||||
const heldTimeline = await page.evaluate(
|
const heldTimeline = await page.evaluate(
|
||||||
() =>
|
() =>
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -36,10 +36,41 @@ function source(gitActions: GitActions): {
|
|||||||
newBrowser: "New browser",
|
newBrowser: "New browser",
|
||||||
splitRight: "Split pane right",
|
splitRight: "Split pane right",
|
||||||
splitDown: "Split pane down",
|
splitDown: "Split pane down",
|
||||||
|
changes: "Changes",
|
||||||
|
files: "Files",
|
||||||
|
pullRequest: "Pull request",
|
||||||
|
openPanel: (name, placement) => `Open ${name} ${placement}`,
|
||||||
|
previousTab: "Previous tab",
|
||||||
|
nextTab: "Next tab",
|
||||||
|
closeCurrentTab: "Close current tab",
|
||||||
|
renameTab: "Rename",
|
||||||
|
reloadAgent: "Reload agent",
|
||||||
|
copyResumeCommand: "Copy resume command",
|
||||||
|
copyAgentId: "Copy agent id",
|
||||||
|
copyTerminalId: "Copy terminal id",
|
||||||
|
copyFilePath: "Copy file path",
|
||||||
|
closeTabsLeft: "Close tabs left",
|
||||||
|
closeTabsRight: "Close tabs right",
|
||||||
|
closeOtherTabs: "Close other tabs",
|
||||||
|
focusPaneLeft: "Focus pane left",
|
||||||
|
focusPaneRight: "Focus pane right",
|
||||||
|
focusPaneUp: "Focus pane up",
|
||||||
|
focusPaneDown: "Focus pane down",
|
||||||
|
moveTabLeft: "Move tab left",
|
||||||
|
moveTabRight: "Move tab right",
|
||||||
|
moveTabUp: "Move tab up",
|
||||||
|
moveTabDown: "Move tab down",
|
||||||
|
closePane: "Close pane",
|
||||||
|
togglePaneMaximization: "Toggle pane maximization",
|
||||||
|
toggleFocusMode: "Toggle focus mode",
|
||||||
|
toggleSidePanel: "Toggle side panel",
|
||||||
},
|
},
|
||||||
icons: {},
|
icons: {},
|
||||||
shortcuts: {},
|
shortcuts: {},
|
||||||
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true },
|
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true, isGit: false },
|
||||||
|
activeTabKind: null,
|
||||||
|
activeTabIndex: -1,
|
||||||
|
activeTabCount: 0,
|
||||||
dispatch: (action) => dispatched.push(action),
|
dispatch: (action) => dispatched.push(action),
|
||||||
runGitAction: (action) => runGitActions.push(action),
|
runGitAction: (action) => runGitActions.push(action),
|
||||||
},
|
},
|
||||||
@@ -88,23 +119,43 @@ describe("workspace command center contributions", () => {
|
|||||||
|
|
||||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||||
|
|
||||||
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([
|
expect(
|
||||||
{ id: "tab:new-agent", rank: 0, visibility: "always" },
|
contributions
|
||||||
{ id: "git:commit", rank: 1, visibility: "always" },
|
.filter((item) =>
|
||||||
{ id: "tab:new-terminal", rank: 2, visibility: "query" },
|
[
|
||||||
{ id: "tab:new-browser", rank: 3, visibility: "query" },
|
"tab:new-agent",
|
||||||
{ id: "pane:split-right", rank: 4, visibility: "query" },
|
"git:commit",
|
||||||
{ id: "pane:split-down", rank: 5, visibility: "query" },
|
"tab:new-terminal",
|
||||||
|
"tab:new-browser",
|
||||||
|
"pane:split-right",
|
||||||
|
"pane:split-down",
|
||||||
|
].includes(item.id),
|
||||||
|
)
|
||||||
|
.map(({ id, visibility }) => ({ id, visibility })),
|
||||||
|
).toEqual([
|
||||||
|
{ id: "tab:new-agent", visibility: "always" },
|
||||||
|
{ id: "git:commit", visibility: "always" },
|
||||||
|
{ id: "tab:new-terminal", visibility: "query" },
|
||||||
|
{ id: "tab:new-browser", visibility: "query" },
|
||||||
|
{ id: "pane:split-right", visibility: "query" },
|
||||||
|
{ id: "pane:split-down", visibility: "query" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("omits browser and split actions when their existing capabilities are unavailable", () => {
|
it("omits browser and split actions when their existing capabilities are unavailable", () => {
|
||||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||||
fixture.value.capabilities = { canSplitPanes: false, canOpenBrowserTabs: false };
|
fixture.value.capabilities = {
|
||||||
|
canSplitPanes: false,
|
||||||
|
canOpenBrowserTabs: false,
|
||||||
|
isGit: false,
|
||||||
|
};
|
||||||
|
|
||||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||||
|
|
||||||
expect(contributions.map((item) => item.id)).toEqual(["tab:new-agent", "tab:new-terminal"]);
|
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
|
||||||
|
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
|
||||||
|
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(false);
|
||||||
|
expect(contributions.some((item) => item.id.startsWith("pane:"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("dispatches every tab and pane command to the workspace scope", () => {
|
it("dispatches every tab and pane command to the workspace scope", () => {
|
||||||
@@ -113,13 +164,8 @@ describe("workspace command center contributions", () => {
|
|||||||
|
|
||||||
for (const contribution of contributions) contribution.run();
|
for (const contribution of contributions) contribution.run();
|
||||||
|
|
||||||
expect(fixture.dispatched).toEqual([
|
expect(fixture.dispatched.length).toBeGreaterThan(5);
|
||||||
{ id: "workspace.agent.new", scope: "workspace" },
|
expect(fixture.dispatched.every((action) => action.scope === "workspace")).toBe(true);
|
||||||
{ id: "workspace.terminal.new", scope: "workspace" },
|
|
||||||
{ id: "workspace.browser.new", scope: "workspace" },
|
|
||||||
{ id: "workspace.pane.split.right", scope: "workspace" },
|
|
||||||
{ id: "workspace.pane.split.down", scope: "workspace" },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps workspace creation commands available outside Git", () => {
|
it("keeps workspace creation commands available outside Git", () => {
|
||||||
@@ -127,13 +173,11 @@ describe("workspace command center contributions", () => {
|
|||||||
|
|
||||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||||
|
|
||||||
expect(contributions.map((item) => item.id)).toEqual([
|
expect(contributions.some((item) => item.id === "tab:new-agent")).toBe(true);
|
||||||
"tab:new-agent",
|
expect(contributions.some((item) => item.id === "tab:new-terminal")).toBe(true);
|
||||||
"tab:new-terminal",
|
expect(contributions.some((item) => item.id === "tab:new-browser")).toBe(true);
|
||||||
"tab:new-browser",
|
expect(contributions.some((item) => item.id === "pane:split-right")).toBe(true);
|
||||||
"pane:split-right",
|
expect(contributions.some((item) => item.id === "pane:split-down")).toBe(true);
|
||||||
"pane:split-down",
|
|
||||||
]);
|
|
||||||
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
|
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { GitAction, GitActions } from "@/git/policy";
|
import type { GitAction, GitActions } from "@/git/policy";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import type {
|
||||||
|
WorkspacePanelPlacement,
|
||||||
|
WorkspacePanelTarget,
|
||||||
|
} from "@/keyboard/keyboard-action-dispatcher";
|
||||||
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
||||||
|
|
||||||
@@ -10,6 +15,34 @@ export interface WorkspaceCommandCenterLabels {
|
|||||||
newBrowser: string;
|
newBrowser: string;
|
||||||
splitRight: string;
|
splitRight: string;
|
||||||
splitDown: string;
|
splitDown: string;
|
||||||
|
changes: string;
|
||||||
|
files: string;
|
||||||
|
pullRequest: string;
|
||||||
|
openPanel(name: string, placement: WorkspacePanelPlacement): string;
|
||||||
|
previousTab: string;
|
||||||
|
nextTab: string;
|
||||||
|
closeCurrentTab: string;
|
||||||
|
renameTab: string;
|
||||||
|
reloadAgent: string;
|
||||||
|
copyResumeCommand: string;
|
||||||
|
copyAgentId: string;
|
||||||
|
copyTerminalId: string;
|
||||||
|
copyFilePath: string;
|
||||||
|
closeTabsLeft: string;
|
||||||
|
closeTabsRight: string;
|
||||||
|
closeOtherTabs: string;
|
||||||
|
focusPaneLeft: string;
|
||||||
|
focusPaneRight: string;
|
||||||
|
focusPaneUp: string;
|
||||||
|
focusPaneDown: string;
|
||||||
|
moveTabLeft: string;
|
||||||
|
moveTabRight: string;
|
||||||
|
moveTabUp: string;
|
||||||
|
moveTabDown: string;
|
||||||
|
closePane: string;
|
||||||
|
togglePaneMaximization: string;
|
||||||
|
toggleFocusMode: string;
|
||||||
|
toggleSidePanel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceCommandCenterIcons {
|
export interface WorkspaceCommandCenterIcons {
|
||||||
@@ -18,6 +51,20 @@ export interface WorkspaceCommandCenterIcons {
|
|||||||
newBrowser?: CommandCenterIcon;
|
newBrowser?: CommandCenterIcon;
|
||||||
splitRight?: CommandCenterIcon;
|
splitRight?: CommandCenterIcon;
|
||||||
splitDown?: CommandCenterIcon;
|
splitDown?: CommandCenterIcon;
|
||||||
|
changes?: CommandCenterIcon;
|
||||||
|
files?: CommandCenterIcon;
|
||||||
|
pullRequest?: CommandCenterIcon;
|
||||||
|
previousTab?: CommandCenterIcon;
|
||||||
|
nextTab?: CommandCenterIcon;
|
||||||
|
close?: CommandCenterIcon;
|
||||||
|
rename?: CommandCenterIcon;
|
||||||
|
reload?: CommandCenterIcon;
|
||||||
|
copy?: CommandCenterIcon;
|
||||||
|
focusPane?: CommandCenterIcon;
|
||||||
|
moveTab?: CommandCenterIcon;
|
||||||
|
maximize?: CommandCenterIcon;
|
||||||
|
focusMode?: CommandCenterIcon;
|
||||||
|
sidePanel?: CommandCenterIcon;
|
||||||
git?(action: GitAction): CommandCenterIcon | undefined;
|
git?(action: GitAction): CommandCenterIcon | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +74,13 @@ export interface WorkspaceCommandCenterShortcuts {
|
|||||||
splitRight?: ShortcutKey[][];
|
splitRight?: ShortcutKey[][];
|
||||||
splitDown?: ShortcutKey[][];
|
splitDown?: ShortcutKey[][];
|
||||||
archiveWorkspace?: ShortcutKey[][];
|
archiveWorkspace?: ShortcutKey[][];
|
||||||
|
previousTab?: ShortcutKey[][];
|
||||||
|
nextTab?: ShortcutKey[][];
|
||||||
|
closeCurrentTab?: ShortcutKey[][];
|
||||||
|
closePane?: ShortcutKey[][];
|
||||||
|
togglePaneMaximization?: ShortcutKey[][];
|
||||||
|
toggleFocusMode?: ShortcutKey[][];
|
||||||
|
toggleSidePanel?: ShortcutKey[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceCommandCenterSource {
|
export interface WorkspaceCommandCenterSource {
|
||||||
@@ -37,7 +91,11 @@ export interface WorkspaceCommandCenterSource {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
canSplitPanes: boolean;
|
canSplitPanes: boolean;
|
||||||
canOpenBrowserTabs: boolean;
|
canOpenBrowserTabs: boolean;
|
||||||
|
isGit: boolean;
|
||||||
};
|
};
|
||||||
|
activeTabKind: WorkspaceTabTarget["kind"] | null;
|
||||||
|
activeTabIndex: number;
|
||||||
|
activeTabCount: number;
|
||||||
dispatch(action: KeyboardActionDefinition): void;
|
dispatch(action: KeyboardActionDefinition): void;
|
||||||
runGitAction(action: GitAction): void;
|
runGitAction(action: GitAction): void;
|
||||||
}
|
}
|
||||||
@@ -96,7 +154,322 @@ function buildWorkspaceAction(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWorkspaceCommandCenterContributions(
|
type QueryActionInput = Omit<Parameters<typeof buildWorkspaceAction>[0], "source" | "visibility">;
|
||||||
|
|
||||||
|
function buildQueryAction(
|
||||||
|
source: WorkspaceCommandCenterSource,
|
||||||
|
input: QueryActionInput,
|
||||||
|
): CommandCenterContribution {
|
||||||
|
return buildWorkspaceAction({ ...input, source, visibility: "query" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPanelContributions(
|
||||||
|
source: WorkspaceCommandCenterSource,
|
||||||
|
): CommandCenterContribution[] {
|
||||||
|
const contributions: CommandCenterContribution[] = [];
|
||||||
|
const panelTargets: Array<{
|
||||||
|
target: WorkspacePanelTarget;
|
||||||
|
name: string;
|
||||||
|
icon?: CommandCenterIcon;
|
||||||
|
}> = [
|
||||||
|
{ target: "changes", name: source.labels.changes, icon: source.icons.changes },
|
||||||
|
{ target: "files", name: source.labels.files, icon: source.icons.files },
|
||||||
|
{
|
||||||
|
target: "pull-request",
|
||||||
|
name: source.labels.pullRequest,
|
||||||
|
icon: source.icons.pullRequest,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const [index, panel] of panelTargets.entries()) {
|
||||||
|
if (panel.target !== "files" && !source.capabilities.isGit) continue;
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: `tab:open:${panel.target}`,
|
||||||
|
rank: 4 + index,
|
||||||
|
title: source.labels.openPanel(panel.name, "supporting"),
|
||||||
|
keywords: ["open", panel.target, "tab", "supporting"],
|
||||||
|
icon: panel.icon,
|
||||||
|
action: {
|
||||||
|
id: "workspace.tab.open",
|
||||||
|
scope: "workspace",
|
||||||
|
target: panel.target,
|
||||||
|
placement: "supporting",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!source.capabilities.canSplitPanes) continue;
|
||||||
|
for (const [placementIndex, placement] of (["side-panel", "focused-pane"] as const).entries()) {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: `tab:open:${panel.target}:${placement}`,
|
||||||
|
rank: 10 + index * 2 + placementIndex,
|
||||||
|
title: source.labels.openPanel(panel.name, placement),
|
||||||
|
keywords: ["open", panel.target, "tab", placement, "pane"],
|
||||||
|
icon: panel.icon,
|
||||||
|
action: {
|
||||||
|
id: "workspace.tab.open",
|
||||||
|
scope: "workspace",
|
||||||
|
target: panel.target,
|
||||||
|
placement,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contributions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildActiveTabContributions(
|
||||||
|
source: WorkspaceCommandCenterSource,
|
||||||
|
): CommandCenterContribution[] {
|
||||||
|
if (source.activeTabCount === 0) return [];
|
||||||
|
const contributions: CommandCenterContribution[] = [
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:previous",
|
||||||
|
rank: 30,
|
||||||
|
title: source.labels.previousTab,
|
||||||
|
keywords: ["tab", "previous", "switch", "navigate"],
|
||||||
|
icon: source.icons.previousTab,
|
||||||
|
shortcutKeys: source.shortcuts.previousTab,
|
||||||
|
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: -1 },
|
||||||
|
}),
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:next",
|
||||||
|
rank: 31,
|
||||||
|
title: source.labels.nextTab,
|
||||||
|
keywords: ["tab", "next", "switch", "navigate"],
|
||||||
|
icon: source.icons.nextTab,
|
||||||
|
shortcutKeys: source.shortcuts.nextTab,
|
||||||
|
action: { id: "workspace.tab.navigate-relative", scope: "workspace", delta: 1 },
|
||||||
|
}),
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:close-current",
|
||||||
|
rank: 32,
|
||||||
|
title: source.labels.closeCurrentTab,
|
||||||
|
keywords: ["tab", "close", "current"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
shortcutKeys: source.shortcuts.closeCurrentTab,
|
||||||
|
action: { id: "workspace.tab.close-current", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
if (source.activeTabKind === "agent" || source.activeTabKind === "terminal") {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:rename-current",
|
||||||
|
rank: 33,
|
||||||
|
title: source.labels.renameTab,
|
||||||
|
keywords: ["tab", "rename", "current"],
|
||||||
|
icon: source.icons.rename,
|
||||||
|
action: { id: "workspace.tab.rename-current", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:copy-id",
|
||||||
|
rank: 34,
|
||||||
|
title:
|
||||||
|
source.activeTabKind === "agent"
|
||||||
|
? source.labels.copyAgentId
|
||||||
|
: source.labels.copyTerminalId,
|
||||||
|
keywords: ["tab", "copy", "id"],
|
||||||
|
icon: source.icons.copy,
|
||||||
|
action: { id: "workspace.tab.copy-id", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabKind === "agent") {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:reload-current",
|
||||||
|
rank: 35,
|
||||||
|
title: source.labels.reloadAgent,
|
||||||
|
keywords: ["tab", "agent", "reload"],
|
||||||
|
icon: source.icons.reload,
|
||||||
|
action: { id: "workspace.tab.reload-current", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:copy-resume-command",
|
||||||
|
rank: 36,
|
||||||
|
title: source.labels.copyResumeCommand,
|
||||||
|
keywords: ["tab", "agent", "copy", "resume", "command"],
|
||||||
|
icon: source.icons.copy,
|
||||||
|
action: { id: "workspace.tab.copy-resume-command", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabKind === "file") {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:copy-file-path",
|
||||||
|
rank: 37,
|
||||||
|
title: source.labels.copyFilePath,
|
||||||
|
keywords: ["tab", "file", "copy", "path"],
|
||||||
|
icon: source.icons.copy,
|
||||||
|
action: { id: "workspace.tab.copy-file-path", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabIndex > 0) {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:close-left",
|
||||||
|
rank: 38,
|
||||||
|
title: source.labels.closeTabsLeft,
|
||||||
|
keywords: ["tab", "close", "left"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
action: { id: "workspace.tab.close-left", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabIndex >= 0 && source.activeTabIndex < source.activeTabCount - 1) {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:close-right",
|
||||||
|
rank: 39,
|
||||||
|
title: source.labels.closeTabsRight,
|
||||||
|
keywords: ["tab", "close", "right"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
action: { id: "workspace.tab.close-right", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (source.activeTabCount > 1) {
|
||||||
|
contributions.push(
|
||||||
|
buildQueryAction(source, {
|
||||||
|
id: "tab:close-others",
|
||||||
|
rank: 40,
|
||||||
|
title: source.labels.closeOtherTabs,
|
||||||
|
keywords: ["tab", "close", "others"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
action: { id: "workspace.tab.close-others", scope: "workspace" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return contributions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPaneContributions(source: WorkspaceCommandCenterSource): CommandCenterContribution[] {
|
||||||
|
const paneActions: Array<{
|
||||||
|
id: string;
|
||||||
|
rank: number;
|
||||||
|
title: string;
|
||||||
|
keywords: string[];
|
||||||
|
action: KeyboardActionDefinition;
|
||||||
|
icon?: CommandCenterIcon;
|
||||||
|
shortcutKeys?: ShortcutKey[][];
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
id: "pane:split-right",
|
||||||
|
rank: 50,
|
||||||
|
title: source.labels.splitRight,
|
||||||
|
keywords: ["split", "pane", "vertical"],
|
||||||
|
icon: source.icons.splitRight,
|
||||||
|
shortcutKeys: source.shortcuts.splitRight,
|
||||||
|
action: { id: "workspace.pane.split.right", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:split-down",
|
||||||
|
rank: 51,
|
||||||
|
title: source.labels.splitDown,
|
||||||
|
keywords: ["split", "pane", "horizontal"],
|
||||||
|
icon: source.icons.splitDown,
|
||||||
|
shortcutKeys: source.shortcuts.splitDown,
|
||||||
|
action: { id: "workspace.pane.split.down", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-left",
|
||||||
|
rank: 52,
|
||||||
|
title: source.labels.focusPaneLeft,
|
||||||
|
keywords: ["pane", "focus", "left"],
|
||||||
|
icon: source.icons.focusPane,
|
||||||
|
action: { id: "workspace.pane.focus.left", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-right",
|
||||||
|
rank: 53,
|
||||||
|
title: source.labels.focusPaneRight,
|
||||||
|
keywords: ["pane", "focus", "right"],
|
||||||
|
icon: source.icons.focusPane,
|
||||||
|
action: { id: "workspace.pane.focus.right", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-up",
|
||||||
|
rank: 54,
|
||||||
|
title: source.labels.focusPaneUp,
|
||||||
|
keywords: ["pane", "focus", "up"],
|
||||||
|
icon: source.icons.focusPane,
|
||||||
|
action: { id: "workspace.pane.focus.up", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-down",
|
||||||
|
rank: 55,
|
||||||
|
title: source.labels.focusPaneDown,
|
||||||
|
keywords: ["pane", "focus", "down"],
|
||||||
|
icon: source.icons.focusPane,
|
||||||
|
action: { id: "workspace.pane.focus.down", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:move-tab-left",
|
||||||
|
rank: 56,
|
||||||
|
title: source.labels.moveTabLeft,
|
||||||
|
keywords: ["pane", "tab", "move", "left"],
|
||||||
|
icon: source.icons.moveTab,
|
||||||
|
action: { id: "workspace.pane.move-tab.left", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:move-tab-right",
|
||||||
|
rank: 57,
|
||||||
|
title: source.labels.moveTabRight,
|
||||||
|
keywords: ["pane", "tab", "move", "right"],
|
||||||
|
icon: source.icons.moveTab,
|
||||||
|
action: { id: "workspace.pane.move-tab.right", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:move-tab-up",
|
||||||
|
rank: 58,
|
||||||
|
title: source.labels.moveTabUp,
|
||||||
|
keywords: ["pane", "tab", "move", "up"],
|
||||||
|
icon: source.icons.moveTab,
|
||||||
|
action: { id: "workspace.pane.move-tab.up", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:move-tab-down",
|
||||||
|
rank: 59,
|
||||||
|
title: source.labels.moveTabDown,
|
||||||
|
keywords: ["pane", "tab", "move", "down"],
|
||||||
|
icon: source.icons.moveTab,
|
||||||
|
action: { id: "workspace.pane.move-tab.down", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:close",
|
||||||
|
rank: 60,
|
||||||
|
title: source.labels.closePane,
|
||||||
|
keywords: ["pane", "close"],
|
||||||
|
icon: source.icons.close,
|
||||||
|
shortcutKeys: source.shortcuts.closePane,
|
||||||
|
action: { id: "workspace.pane.close", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:maximize-toggle",
|
||||||
|
rank: 61,
|
||||||
|
title: source.labels.togglePaneMaximization,
|
||||||
|
keywords: ["pane", "maximize", "restore", "toggle"],
|
||||||
|
icon: source.icons.maximize,
|
||||||
|
shortcutKeys: source.shortcuts.togglePaneMaximization,
|
||||||
|
action: { id: "workspace.explorer.maximize.toggle", scope: "workspace" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pane:focus-mode-toggle",
|
||||||
|
rank: 62,
|
||||||
|
title: source.labels.toggleFocusMode,
|
||||||
|
keywords: ["pane", "focus", "mode", "toggle"],
|
||||||
|
icon: source.icons.focusMode,
|
||||||
|
shortcutKeys: source.shortcuts.toggleFocusMode,
|
||||||
|
action: { id: "workspace.focus.toggle", scope: "workspace" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return paneActions.map((action) => buildQueryAction(source, action));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCreationContributions(
|
||||||
source: WorkspaceCommandCenterSource,
|
source: WorkspaceCommandCenterSource,
|
||||||
): CommandCenterContribution[] {
|
): CommandCenterContribution[] {
|
||||||
const contributions: CommandCenterContribution[] = [
|
const contributions: CommandCenterContribution[] = [
|
||||||
@@ -115,8 +488,7 @@ export function buildWorkspaceCommandCenterContributions(
|
|||||||
const primary = source.gitActions.primary;
|
const primary = source.gitActions.primary;
|
||||||
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
|
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
|
||||||
contributions.push(
|
contributions.push(
|
||||||
buildWorkspaceAction({
|
buildQueryAction(source, {
|
||||||
source,
|
|
||||||
id: "tab:new-terminal",
|
id: "tab:new-terminal",
|
||||||
rank: 2,
|
rank: 2,
|
||||||
title: source.labels.newTerminal,
|
title: source.labels.newTerminal,
|
||||||
@@ -124,52 +496,45 @@ export function buildWorkspaceCommandCenterContributions(
|
|||||||
icon: source.icons.newTerminal,
|
icon: source.icons.newTerminal,
|
||||||
shortcutKeys: source.shortcuts.newTerminal,
|
shortcutKeys: source.shortcuts.newTerminal,
|
||||||
action: { id: "workspace.terminal.new", scope: "workspace" },
|
action: { id: "workspace.terminal.new", scope: "workspace" },
|
||||||
visibility: "query",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (source.capabilities.canOpenBrowserTabs) {
|
if (source.capabilities.canOpenBrowserTabs) {
|
||||||
contributions.push(
|
contributions.push(
|
||||||
buildWorkspaceAction({
|
buildQueryAction(source, {
|
||||||
source,
|
|
||||||
id: "tab:new-browser",
|
id: "tab:new-browser",
|
||||||
rank: 3,
|
rank: 3,
|
||||||
title: source.labels.newBrowser,
|
title: source.labels.newBrowser,
|
||||||
keywords: ["browser", "web", "preview"],
|
keywords: ["browser", "web", "preview"],
|
||||||
icon: source.icons.newBrowser,
|
icon: source.icons.newBrowser,
|
||||||
action: { id: "workspace.browser.new", scope: "workspace" },
|
action: { id: "workspace.browser.new", scope: "workspace" },
|
||||||
visibility: "query",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (source.capabilities.canSplitPanes) {
|
return contributions;
|
||||||
contributions.push(
|
}
|
||||||
buildWorkspaceAction({
|
|
||||||
source,
|
export function buildWorkspaceCommandCenterContributions(
|
||||||
id: "pane:split-right",
|
source: WorkspaceCommandCenterSource,
|
||||||
rank: 4,
|
): CommandCenterContribution[] {
|
||||||
title: source.labels.splitRight,
|
const contributions = [
|
||||||
keywords: ["split", "pane", "vertical"],
|
...buildCreationContributions(source),
|
||||||
icon: source.icons.splitRight,
|
...buildPanelContributions(source),
|
||||||
shortcutKeys: source.shortcuts.splitRight,
|
...buildActiveTabContributions(source),
|
||||||
action: { id: "workspace.pane.split.right", scope: "workspace" },
|
...(source.capabilities.canSplitPanes ? buildPaneContributions(source) : []),
|
||||||
visibility: "query",
|
buildQueryAction(source, {
|
||||||
|
id: "side-panel:toggle",
|
||||||
|
rank: 70,
|
||||||
|
title: source.labels.toggleSidePanel,
|
||||||
|
keywords: ["side", "panel", "toggle", "show", "hide"],
|
||||||
|
icon: source.icons.sidePanel,
|
||||||
|
shortcutKeys: source.shortcuts.toggleSidePanel,
|
||||||
|
action: { id: "sidebar.toggle.right", scope: "workspace" },
|
||||||
}),
|
}),
|
||||||
buildWorkspaceAction({
|
];
|
||||||
source,
|
const primary = source.gitActions.primary;
|
||||||
id: "pane:split-down",
|
|
||||||
rank: 5,
|
|
||||||
title: source.labels.splitDown,
|
|
||||||
keywords: ["split", "pane", "horizontal"],
|
|
||||||
icon: source.icons.splitDown,
|
|
||||||
shortcutKeys: source.shortcuts.splitDown,
|
|
||||||
action: { id: "workspace.pane.split.down", scope: "workspace" },
|
|
||||||
visibility: "query",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const [index, action] of source.gitActions.secondary.entries()) {
|
for (const [index, action] of source.gitActions.secondary.entries()) {
|
||||||
if (action.id === primary?.id) continue;
|
if (action.id === primary?.id) continue;
|
||||||
contributions.push(buildGitContribution(source, action, 10 + index, "query"));
|
contributions.push(buildGitContribution(source, action, 100 + index, "query"));
|
||||||
}
|
}
|
||||||
return contributions;
|
return contributions;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import { useMemo, type ReactElement } from "react";
|
import { useMemo, type ReactElement } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Columns2, Globe, Rows2, SquarePen, SquareTerminal } from "lucide-react-native";
|
import {
|
||||||
|
ArrowDownToLine,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Columns2,
|
||||||
|
Copy,
|
||||||
|
Files,
|
||||||
|
Focus,
|
||||||
|
GitCompareArrows,
|
||||||
|
GitPullRequest,
|
||||||
|
Globe,
|
||||||
|
Maximize2,
|
||||||
|
Move,
|
||||||
|
PanelRight,
|
||||||
|
Pencil,
|
||||||
|
RotateCw,
|
||||||
|
Rows2,
|
||||||
|
SquarePen,
|
||||||
|
SquareTerminal,
|
||||||
|
X,
|
||||||
|
} from "lucide-react-native";
|
||||||
import { getIsElectron } from "@/constants/platform";
|
import { getIsElectron } from "@/constants/platform";
|
||||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||||
@@ -13,6 +33,12 @@ import {
|
|||||||
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context";
|
||||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||||
|
import {
|
||||||
|
collectAllTabs,
|
||||||
|
findPaneById,
|
||||||
|
useWorkspaceLayoutStore,
|
||||||
|
} from "@/stores/workspace-layout-store";
|
||||||
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import { getCommandCenterIcon } from "./icon";
|
import { getCommandCenterIcon } from "./icon";
|
||||||
@@ -29,8 +55,28 @@ const WORKSPACE_COMMAND_CENTER_ICONS = {
|
|||||||
newBrowser: getCommandCenterIcon(Globe),
|
newBrowser: getCommandCenterIcon(Globe),
|
||||||
splitRight: getCommandCenterIcon(Columns2),
|
splitRight: getCommandCenterIcon(Columns2),
|
||||||
splitDown: getCommandCenterIcon(Rows2),
|
splitDown: getCommandCenterIcon(Rows2),
|
||||||
|
changes: getCommandCenterIcon(GitCompareArrows),
|
||||||
|
files: getCommandCenterIcon(Files),
|
||||||
|
pullRequest: getCommandCenterIcon(GitPullRequest),
|
||||||
|
previousTab: getCommandCenterIcon(ArrowLeft),
|
||||||
|
nextTab: getCommandCenterIcon(ArrowRight),
|
||||||
|
close: getCommandCenterIcon(X),
|
||||||
|
rename: getCommandCenterIcon(Pencil),
|
||||||
|
reload: getCommandCenterIcon(RotateCw),
|
||||||
|
copy: getCommandCenterIcon(Copy),
|
||||||
|
focusPane: getCommandCenterIcon(Focus),
|
||||||
|
moveTab: getCommandCenterIcon(Move),
|
||||||
|
maximize: getCommandCenterIcon(Maximize2),
|
||||||
|
focusMode: getCommandCenterIcon(ArrowDownToLine),
|
||||||
|
sidePanel: getCommandCenterIcon(PanelRight),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OPEN_PANEL_LABEL_KEYS = {
|
||||||
|
supporting: "shell.commandCenter.open",
|
||||||
|
"side-panel": "shell.commandCenter.openInSidePanel",
|
||||||
|
"focused-pane": "shell.commandCenter.openInFocusedPane",
|
||||||
|
} as const;
|
||||||
|
|
||||||
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
|
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
|
||||||
if (!element) return undefined;
|
if (!element) return undefined;
|
||||||
function StaticIcon() {
|
function StaticIcon() {
|
||||||
@@ -51,6 +97,17 @@ function resolveWorkspaceShortcuts(overrides: ShortcutOverrides): WorkspaceComma
|
|||||||
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
|
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
|
||||||
archiveWorkspace:
|
archiveWorkspace:
|
||||||
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
|
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
|
||||||
|
previousTab:
|
||||||
|
resolveShortcutKeysForAction("workspace-tab-prev", overrides, platform) ?? undefined,
|
||||||
|
nextTab: resolveShortcutKeysForAction("workspace-tab-next", overrides, platform) ?? undefined,
|
||||||
|
closeCurrentTab:
|
||||||
|
resolveShortcutKeysForAction("workspace-tab-close-current", overrides, platform) ?? undefined,
|
||||||
|
closePane:
|
||||||
|
resolveShortcutKeysForAction("workspace-pane-close", overrides, platform) ?? undefined,
|
||||||
|
togglePaneMaximization:
|
||||||
|
resolveShortcutKeysForAction("workspace-explorer-maximize", overrides, platform) ?? undefined,
|
||||||
|
toggleSidePanel:
|
||||||
|
resolveShortcutKeysForAction("toggle-right-sidebar", overrides, platform) ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,10 +117,22 @@ export function useWorkspaceCommandCenterActions(): void {
|
|||||||
const selection = useActiveWorkspaceSelection();
|
const selection = useActiveWorkspaceSelection();
|
||||||
const serverId = selection?.serverId ?? null;
|
const serverId = selection?.serverId ?? null;
|
||||||
const workspaceId = selection?.workspaceId ?? null;
|
const workspaceId = selection?.workspaceId ?? null;
|
||||||
|
const workspaceKey =
|
||||||
|
serverId && workspaceId ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId }) : null;
|
||||||
|
const layout = useWorkspaceLayoutStore((state) =>
|
||||||
|
workspaceKey ? (state.layoutByWorkspace[workspaceKey] ?? null) : null,
|
||||||
|
);
|
||||||
|
const focusedPane = layout ? findPaneById(layout.root, layout.focusedPaneId) : null;
|
||||||
|
const focusedTabs = layout
|
||||||
|
? collectAllTabs(layout.root).filter((tab) => focusedPane?.tabIds.includes(tab.tabId))
|
||||||
|
: [];
|
||||||
|
const activeTabIndex = focusedTabs.findIndex((tab) => tab.tabId === focusedPane?.focusedTabId);
|
||||||
|
const activeTabKind =
|
||||||
|
activeTabIndex >= 0 ? (focusedTabs[activeTabIndex]?.target.kind ?? null) : null;
|
||||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const { overrides } = useKeyboardShortcutOverrides();
|
const { overrides } = useKeyboardShortcutOverrides();
|
||||||
const { gitActions } = useGitActions({
|
const { gitActions, isGit } = useGitActions({
|
||||||
serverId: serverId ?? "",
|
serverId: serverId ?? "",
|
||||||
cwd: cwd ?? "",
|
cwd: cwd ?? "",
|
||||||
icons: GIT_ACTION_ICONS,
|
icons: GIT_ACTION_ICONS,
|
||||||
@@ -81,6 +150,34 @@ export function useWorkspaceCommandCenterActions(): void {
|
|||||||
newBrowser: t("workspace.tabs.actions.newBrowser"),
|
newBrowser: t("workspace.tabs.actions.newBrowser"),
|
||||||
splitRight: t("workspace.tabs.actions.splitRight"),
|
splitRight: t("workspace.tabs.actions.splitRight"),
|
||||||
splitDown: t("workspace.tabs.actions.splitDown"),
|
splitDown: t("workspace.tabs.actions.splitDown"),
|
||||||
|
changes: t("workspace.tabs.actions.changes"),
|
||||||
|
files: t("workspace.tabs.actions.files"),
|
||||||
|
pullRequest: t("workspace.tabs.actions.pullRequest"),
|
||||||
|
openPanel: (name, placement) => t(OPEN_PANEL_LABEL_KEYS[placement], { name }),
|
||||||
|
previousTab: t("settings.shortcuts.help.previousTab"),
|
||||||
|
nextTab: t("settings.shortcuts.help.nextTab"),
|
||||||
|
closeCurrentTab: t("settings.shortcuts.help.closeCurrentTab"),
|
||||||
|
renameTab: t("workspace.tabs.menu.rename"),
|
||||||
|
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
|
||||||
|
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
|
||||||
|
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
|
||||||
|
copyTerminalId: t("workspace.tabs.menu.copyTerminalId"),
|
||||||
|
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
|
||||||
|
closeTabsLeft: t("workspace.tabs.menu.closeLeft"),
|
||||||
|
closeTabsRight: t("workspace.tabs.menu.closeRight"),
|
||||||
|
closeOtherTabs: t("workspace.tabs.menu.closeOthers"),
|
||||||
|
focusPaneLeft: t("settings.shortcuts.help.focusPaneLeft"),
|
||||||
|
focusPaneRight: t("settings.shortcuts.help.focusPaneRight"),
|
||||||
|
focusPaneUp: t("settings.shortcuts.help.focusPaneUp"),
|
||||||
|
focusPaneDown: t("settings.shortcuts.help.focusPaneDown"),
|
||||||
|
moveTabLeft: t("settings.shortcuts.help.moveTabLeft"),
|
||||||
|
moveTabRight: t("settings.shortcuts.help.moveTabRight"),
|
||||||
|
moveTabUp: t("settings.shortcuts.help.moveTabUp"),
|
||||||
|
moveTabDown: t("settings.shortcuts.help.moveTabDown"),
|
||||||
|
closePane: t("settings.shortcuts.help.closePane"),
|
||||||
|
togglePaneMaximization: t("settings.shortcuts.help.toggleExplorerPaneMaximization"),
|
||||||
|
toggleFocusMode: t("settings.shortcuts.help.toggleFocusMode"),
|
||||||
|
toggleSidePanel: t("workspace.tabs.sidePanel.toggle"),
|
||||||
},
|
},
|
||||||
icons: {
|
icons: {
|
||||||
...WORKSPACE_COMMAND_CENTER_ICONS,
|
...WORKSPACE_COMMAND_CENTER_ICONS,
|
||||||
@@ -90,14 +187,29 @@ export function useWorkspaceCommandCenterActions(): void {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
|
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
|
||||||
canOpenBrowserTabs: getIsElectron(),
|
canOpenBrowserTabs: getIsElectron(),
|
||||||
|
isGit,
|
||||||
},
|
},
|
||||||
|
activeTabKind,
|
||||||
|
activeTabIndex,
|
||||||
|
activeTabCount: focusedTabs.length,
|
||||||
dispatch: (action) => {
|
dispatch: (action) => {
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
keyboardActionDispatcher.dispatch(action);
|
keyboardActionDispatcher.dispatch(action);
|
||||||
},
|
},
|
||||||
runGitAction,
|
runGitAction,
|
||||||
}),
|
}),
|
||||||
[gitActions, isCompact, keyboardActionDispatcher, overrides, runGitAction, t],
|
[
|
||||||
|
activeTabIndex,
|
||||||
|
activeTabKind,
|
||||||
|
focusedTabs.length,
|
||||||
|
gitActions,
|
||||||
|
isCompact,
|
||||||
|
isGit,
|
||||||
|
keyboardActionDispatcher,
|
||||||
|
overrides,
|
||||||
|
runGitAction,
|
||||||
|
t,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useCommandCenterActions({
|
useCommandCenterActions({
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ interface SplitContainerProps {
|
|||||||
renderPaneEmptyState?: (input: {
|
renderPaneEmptyState?: (input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
paneIsolated: boolean;
|
paneIsolated: boolean;
|
||||||
|
isFocused: boolean;
|
||||||
onClosePane: () => void;
|
onClosePane: () => void;
|
||||||
}) => ReactNode;
|
}) => ReactNode;
|
||||||
focusModeEnabled?: boolean;
|
focusModeEnabled?: boolean;
|
||||||
@@ -1289,6 +1290,7 @@ function SplitPaneView({
|
|||||||
: (renderPaneEmptyState?.({
|
: (renderPaneEmptyState?.({
|
||||||
paneId,
|
paneId,
|
||||||
paneIsolated,
|
paneIsolated,
|
||||||
|
isFocused: isFocused && isWorkspaceFocused,
|
||||||
onClosePane: handleClosePane,
|
onClosePane: handleClosePane,
|
||||||
}) ?? null)}
|
}) ?? null)}
|
||||||
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
<SplitDropZone paneId={pane.id} active={showDropZones} preview={dropPreview} />
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ar: TranslationResources = {
|
|||||||
workspaces: "مساحات العمل",
|
workspaces: "مساحات العمل",
|
||||||
agents: "الوكلاء",
|
agents: "الوكلاء",
|
||||||
newAgent: "وكيل جديد",
|
newAgent: "وكيل جديد",
|
||||||
|
open: "فتح {{name}}",
|
||||||
|
openInSidePanel: "فتح {{name}} في اللوحة الجانبية",
|
||||||
|
openInFocusedPane: "فتح {{name}} في الجزء النشط",
|
||||||
addProject: "إضافة مشروع",
|
addProject: "إضافة مشروع",
|
||||||
home: "بيت",
|
home: "بيت",
|
||||||
groupByProject: "تجميع حسب المشروع",
|
groupByProject: "تجميع حسب المشروع",
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ export const en = {
|
|||||||
workspaces: "Workspaces",
|
workspaces: "Workspaces",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
newAgent: "New agent",
|
newAgent: "New agent",
|
||||||
|
open: "Open {{name}}",
|
||||||
|
openInSidePanel: "Open {{name}} in Side panel",
|
||||||
|
openInFocusedPane: "Open {{name}} in focused pane",
|
||||||
addProject: "Add project",
|
addProject: "Add project",
|
||||||
home: "Home",
|
home: "Home",
|
||||||
groupByProject: "Group by project",
|
groupByProject: "Group by project",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const es: TranslationResources = {
|
|||||||
workspaces: "Espacios de trabajo",
|
workspaces: "Espacios de trabajo",
|
||||||
agents: "Agentes",
|
agents: "Agentes",
|
||||||
newAgent: "Nuevo agente",
|
newAgent: "Nuevo agente",
|
||||||
|
open: "Abrir {{name}}",
|
||||||
|
openInSidePanel: "Abrir {{name}} en el panel lateral",
|
||||||
|
openInFocusedPane: "Abrir {{name}} en el panel enfocado",
|
||||||
addProject: "Agregar proyecto",
|
addProject: "Agregar proyecto",
|
||||||
home: "Hogar",
|
home: "Hogar",
|
||||||
groupByProject: "Agrupar por proyecto",
|
groupByProject: "Agrupar por proyecto",
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export const fr: TranslationResources = {
|
|||||||
workspaces: "Espaces de travail",
|
workspaces: "Espaces de travail",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
newAgent: "Nouvel agent",
|
newAgent: "Nouvel agent",
|
||||||
|
open: "Ouvrir {{name}}",
|
||||||
|
openInSidePanel: "Ouvrir {{name}} dans le panneau latéral",
|
||||||
|
openInFocusedPane: "Ouvrir {{name}} dans le volet actif",
|
||||||
addProject: "Ajouter un projet",
|
addProject: "Ajouter un projet",
|
||||||
home: "Maison",
|
home: "Maison",
|
||||||
groupByProject: "Grouper par projet",
|
groupByProject: "Grouper par projet",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ja: TranslationResources = {
|
|||||||
workspaces: "ワークスペース",
|
workspaces: "ワークスペース",
|
||||||
agents: "エージェント",
|
agents: "エージェント",
|
||||||
newAgent: "新しいエージェント",
|
newAgent: "新しいエージェント",
|
||||||
|
open: "{{name}}を開く",
|
||||||
|
openInSidePanel: "{{name}}をサイドパネルで開く",
|
||||||
|
openInFocusedPane: "{{name}}をフォーカス中のペインで開く",
|
||||||
addProject: "プロジェクトを追加",
|
addProject: "プロジェクトを追加",
|
||||||
home: "ホーム",
|
home: "ホーム",
|
||||||
groupByProject: "プロジェクトでグループ化",
|
groupByProject: "プロジェクトでグループ化",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ko: TranslationResources = {
|
|||||||
workspaces: "워크스페이스",
|
workspaces: "워크스페이스",
|
||||||
agents: "에이전트",
|
agents: "에이전트",
|
||||||
newAgent: "새 에이전트",
|
newAgent: "새 에이전트",
|
||||||
|
open: "{{name}} 열기",
|
||||||
|
openInSidePanel: "사이드 패널에서 {{name}} 열기",
|
||||||
|
openInFocusedPane: "포커스된 창에서 {{name}} 열기",
|
||||||
addProject: "프로젝트 추가",
|
addProject: "프로젝트 추가",
|
||||||
home: "홈",
|
home: "홈",
|
||||||
groupByProject: "프로젝트별 그룹화",
|
groupByProject: "프로젝트별 그룹화",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ptBR: TranslationResources = {
|
|||||||
workspaces: "Espaços de trabalho",
|
workspaces: "Espaços de trabalho",
|
||||||
agents: "Agentes",
|
agents: "Agentes",
|
||||||
newAgent: "Novo agente",
|
newAgent: "Novo agente",
|
||||||
|
open: "Abrir {{name}}",
|
||||||
|
openInSidePanel: "Abrir {{name}} no painel lateral",
|
||||||
|
openInFocusedPane: "Abrir {{name}} no painel em foco",
|
||||||
addProject: "Adicionar projeto",
|
addProject: "Adicionar projeto",
|
||||||
home: "Início",
|
home: "Início",
|
||||||
groupByProject: "Agrupar por projeto",
|
groupByProject: "Agrupar por projeto",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const ru: TranslationResources = {
|
|||||||
workspaces: "Рабочие пространства",
|
workspaces: "Рабочие пространства",
|
||||||
agents: "Агенты",
|
agents: "Агенты",
|
||||||
newAgent: "Новый агент",
|
newAgent: "Новый агент",
|
||||||
|
open: "Открыть {{name}}",
|
||||||
|
openInSidePanel: "Открыть {{name}} в боковой панели",
|
||||||
|
openInFocusedPane: "Открыть {{name}} в активной области",
|
||||||
addProject: "Добавить проект",
|
addProject: "Добавить проект",
|
||||||
home: "Главная",
|
home: "Главная",
|
||||||
groupByProject: "Группировать по проекту",
|
groupByProject: "Группировать по проекту",
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export const zhCN: TranslationResources = {
|
|||||||
workspaces: "工作区",
|
workspaces: "工作区",
|
||||||
agents: "Agents",
|
agents: "Agents",
|
||||||
newAgent: "新建 Agent",
|
newAgent: "新建 Agent",
|
||||||
|
open: "打开{{name}}",
|
||||||
|
openInSidePanel: "在侧边面板中打开{{name}}",
|
||||||
|
openInFocusedPane: "在当前窗格中打开{{name}}",
|
||||||
addProject: "添加 project",
|
addProject: "添加 project",
|
||||||
home: "首页",
|
home: "首页",
|
||||||
groupByProject: "按项目分组",
|
groupByProject: "按项目分组",
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
|
export type KeyboardActionScope = "global" | "message-input" | "sidebar" | "workspace";
|
||||||
|
|
||||||
|
export type WorkspacePanelTarget = "changes" | "files" | "pull-request";
|
||||||
|
export type WorkspacePanelPlacement = "supporting" | "side-panel" | "focused-pane";
|
||||||
|
|
||||||
export type KeyboardActionId =
|
export type KeyboardActionId =
|
||||||
| "agent.interrupt"
|
| "agent.interrupt"
|
||||||
| "message-input.focus"
|
| "message-input.focus"
|
||||||
@@ -19,6 +22,15 @@ export type KeyboardActionId =
|
|||||||
| "workspace.tab.close-current"
|
| "workspace.tab.close-current"
|
||||||
| "workspace.tab.navigate-index"
|
| "workspace.tab.navigate-index"
|
||||||
| "workspace.tab.navigate-relative"
|
| "workspace.tab.navigate-relative"
|
||||||
|
| "workspace.tab.open"
|
||||||
|
| "workspace.tab.rename-current"
|
||||||
|
| "workspace.tab.reload-current"
|
||||||
|
| "workspace.tab.copy-resume-command"
|
||||||
|
| "workspace.tab.copy-id"
|
||||||
|
| "workspace.tab.copy-file-path"
|
||||||
|
| "workspace.tab.close-left"
|
||||||
|
| "workspace.tab.close-right"
|
||||||
|
| "workspace.tab.close-others"
|
||||||
| "workspace.pane.split.right"
|
| "workspace.pane.split.right"
|
||||||
| "workspace.pane.split.down"
|
| "workspace.pane.split.down"
|
||||||
| "workspace.pane.focus.left"
|
| "workspace.pane.focus.left"
|
||||||
@@ -61,6 +73,20 @@ export type KeyboardActionDefinition =
|
|||||||
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
|
| { id: "workspace.tab.close-current"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
|
| { id: "workspace.tab.navigate-index"; scope: KeyboardActionScope; index: number }
|
||||||
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }
|
| { id: "workspace.tab.navigate-relative"; scope: KeyboardActionScope; delta: 1 | -1 }
|
||||||
|
| {
|
||||||
|
id: "workspace.tab.open";
|
||||||
|
scope: KeyboardActionScope;
|
||||||
|
target: WorkspacePanelTarget;
|
||||||
|
placement: WorkspacePanelPlacement;
|
||||||
|
}
|
||||||
|
| { id: "workspace.tab.rename-current"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.reload-current"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.copy-resume-command"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.copy-id"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.copy-file-path"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.close-left"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.close-right"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.tab.close-others"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.pane.split.right"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.split.right"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.pane.split.down"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.split.down"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.pane.focus.left"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.focus.left"; scope: KeyboardActionScope }
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { SubagentsTrack } from "@/subagents/track";
|
|||||||
import type { TodoEntry } from "@/types/stream";
|
import type { TodoEntry } from "@/types/stream";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||||
import { openSupportingTab } from "@/workspace-tabs/side-panel";
|
import { openSupportingTab, toggleSupportingTab } from "@/workspace-tabs/side-panel";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above
|
* The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above
|
||||||
@@ -96,7 +96,7 @@ export const AgentTracks = memo(function AgentTracks({
|
|||||||
if (!workspaceKey) {
|
if (!workspaceKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openSupportingTab({
|
toggleSupportingTab({
|
||||||
isCompact,
|
isCompact,
|
||||||
workspaceKey,
|
workspaceKey,
|
||||||
target: { kind: "working_diff" },
|
target: { kind: "working_diff" },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, type ReactElement, type ReactNode } from "react";
|
import { memo, useCallback, useEffect, useRef, type ReactElement, type ReactNode } from "react";
|
||||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
@@ -31,6 +32,7 @@ const TERMINAL_ICON = <ThemedSquareTerminal size={16} uniProps={mutedColorMappin
|
|||||||
const FILES_TARGET: WorkspaceTabTarget = { kind: "files" };
|
const FILES_TARGET: WorkspaceTabTarget = { kind: "files" };
|
||||||
const CHANGES_TARGET: WorkspaceTabTarget = { kind: "working_diff" };
|
const CHANGES_TARGET: WorkspaceTabTarget = { kind: "working_diff" };
|
||||||
const PULL_REQUEST_TARGET: WorkspaceTabTarget = { kind: "pull_request" };
|
const PULL_REQUEST_TARGET: WorkspaceTabTarget = { kind: "pull_request" };
|
||||||
|
const LAUNCHER_ROW_DATA_SET = { emptyPaneLauncherRow: "true" };
|
||||||
|
|
||||||
/** The launcher's own binding, or nothing when the action has no shortcut. */
|
/** The launcher's own binding, or nothing when the action has no shortcut. */
|
||||||
function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null {
|
function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | null {
|
||||||
@@ -38,8 +40,19 @@ function LauncherShortcut({ actionId }: { actionId: string }): ReactElement | nu
|
|||||||
return keys ? <Shortcut chord={keys} /> : null;
|
return keys ? <Shortcut chord={keys} /> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLauncherRowStyle({ pressed, hovered }: PressableStateCallbackType) {
|
const LAUNCHER_ROW_SELECTOR = '[data-empty-pane-launcher-row="true"]';
|
||||||
return [styles.row, hovered && styles.rowHovered, pressed && styles.pressed];
|
|
||||||
|
function getLauncherRowStyle({
|
||||||
|
pressed,
|
||||||
|
hovered = false,
|
||||||
|
focused = false,
|
||||||
|
}: PressableStateCallbackType & { hovered?: boolean; focused?: boolean }) {
|
||||||
|
return [
|
||||||
|
styles.row,
|
||||||
|
(hovered || focused) && styles.rowHovered,
|
||||||
|
focused && styles.rowFocused,
|
||||||
|
pressed && styles.pressed,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,6 +75,8 @@ function LauncherRow({
|
|||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
|
dataSet={LAUNCHER_ROW_DATA_SET}
|
||||||
|
tabIndex={-1}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={getLauncherRowStyle}
|
style={getLauncherRowStyle}
|
||||||
>
|
>
|
||||||
@@ -75,6 +90,8 @@ function LauncherRow({
|
|||||||
|
|
||||||
export interface WorkspacePaneEmptyStateProps {
|
export interface WorkspacePaneEmptyStateProps {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
|
/** Logical pane focus. On desktop, the launcher turns this into DOM focus. */
|
||||||
|
isFocused: boolean;
|
||||||
/** Changes needs a Git workspace; the pull request needs one that has been detected. */
|
/** Changes needs a Git workspace; the pull request needs one that has been detected. */
|
||||||
showChanges: boolean;
|
showChanges: boolean;
|
||||||
showPullRequest: boolean;
|
showPullRequest: boolean;
|
||||||
@@ -91,6 +108,7 @@ export interface WorkspacePaneEmptyStateProps {
|
|||||||
|
|
||||||
export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
||||||
paneId,
|
paneId,
|
||||||
|
isFocused,
|
||||||
showChanges,
|
showChanges,
|
||||||
showPullRequest,
|
showPullRequest,
|
||||||
onOpenView,
|
onOpenView,
|
||||||
@@ -99,6 +117,42 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
|||||||
onClosePane,
|
onClosePane,
|
||||||
}: WorkspacePaneEmptyStateProps): ReactElement {
|
}: WorkspacePaneEmptyStateProps): ReactElement {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const containerRef = useRef<View | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWeb || !isFocused) return;
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!(container instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const rows = Array.from(container.querySelectorAll<HTMLElement>(LAUNCHER_ROW_SELECTOR));
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
const currentIndex = rows.findIndex((row) => row === document.activeElement);
|
||||||
|
let nextIndex: number | null = null;
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % rows.length;
|
||||||
|
}
|
||||||
|
if (event.key === "ArrowUp") {
|
||||||
|
nextIndex =
|
||||||
|
currentIndex < 0 ? rows.length - 1 : (currentIndex - 1 + rows.length) % rows.length;
|
||||||
|
}
|
||||||
|
if (nextIndex !== null) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
rows[nextIndex]?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter" && currentIndex >= 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
rows[currentIndex]?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("keydown", handleKeyDown);
|
||||||
|
container.querySelector<HTMLElement>(LAUNCHER_ROW_SELECTOR)?.focus({ preventScroll: true });
|
||||||
|
return () => container.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [isFocused]);
|
||||||
|
|
||||||
const openFiles = useCallback(
|
const openFiles = useCallback(
|
||||||
() => onOpenView({ paneId, target: FILES_TARGET }),
|
() => onOpenView({ paneId, target: FILES_TARGET }),
|
||||||
@@ -119,7 +173,7 @@ export const WorkspacePaneEmptyState = memo(function WorkspacePaneEmptyState({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container} testID="workspace-pane-empty-state">
|
<View ref={containerRef} style={styles.container} testID="workspace-pane-empty-state">
|
||||||
<View style={styles.rail}>
|
<View style={styles.rail}>
|
||||||
<View style={styles.stack}>
|
<View style={styles.stack}>
|
||||||
{showChanges ? (
|
{showChanges ? (
|
||||||
@@ -196,11 +250,18 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
minHeight: 44,
|
minHeight: 44,
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: "transparent",
|
||||||
|
outlineWidth: 0,
|
||||||
|
outlineColor: "transparent",
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
rowHovered: {
|
rowHovered: {
|
||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface3,
|
||||||
},
|
},
|
||||||
|
rowFocused: {
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
|
},
|
||||||
pressed: {
|
pressed: {
|
||||||
opacity: 0.85,
|
opacity: 0.85,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar
|
|||||||
import {
|
import {
|
||||||
isSidePanelOpen,
|
isSidePanelOpen,
|
||||||
openSupportingTab,
|
openSupportingTab,
|
||||||
|
openTabInSidePanel,
|
||||||
toggleSidePanel,
|
toggleSidePanel,
|
||||||
useIsSidePanelOpen,
|
useIsSidePanelOpen,
|
||||||
} from "@/workspace-tabs/side-panel";
|
} from "@/workspace-tabs/side-panel";
|
||||||
@@ -76,7 +77,10 @@ import {
|
|||||||
import { useSettings } from "@/hooks/use-settings";
|
import { useSettings } from "@/hooks/use-settings";
|
||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type {
|
||||||
|
KeyboardActionDefinition,
|
||||||
|
WorkspacePanelTarget,
|
||||||
|
} from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import {
|
import {
|
||||||
buildDeterministicWorkspaceTabId,
|
buildDeterministicWorkspaceTabId,
|
||||||
@@ -1423,6 +1427,17 @@ function useWorkspaceTerminalTabActions({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveCommandCenterPanelTarget(target: WorkspacePanelTarget): WorkspaceTabTarget {
|
||||||
|
switch (target) {
|
||||||
|
case "changes":
|
||||||
|
return { kind: "working_diff" };
|
||||||
|
case "files":
|
||||||
|
return { kind: "files" };
|
||||||
|
case "pull-request":
|
||||||
|
return { kind: "pull_request" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function WorkspaceScreenContent({
|
function WorkspaceScreenContent({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -2842,6 +2857,109 @@ function WorkspaceScreenContent({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleWorkspacePanelOpenAction = useCallback(
|
||||||
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
|
if (action.id !== "workspace.tab.open") return false;
|
||||||
|
if (!persistenceKey) return true;
|
||||||
|
|
||||||
|
const target = resolveCommandCenterPanelTarget(action.target);
|
||||||
|
if (action.placement === "supporting") {
|
||||||
|
openSupportingTab({
|
||||||
|
isCompact: isMobile,
|
||||||
|
workspaceKey: persistenceKey,
|
||||||
|
target,
|
||||||
|
openInSidePanelByDefault,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action.placement === "side-panel") {
|
||||||
|
openTabInSidePanel({
|
||||||
|
isCompact: isMobile,
|
||||||
|
workspaceKey: persistenceKey,
|
||||||
|
checkout: activeExplorerCheckout,
|
||||||
|
target,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const focusedPaneId = focusedPaneTabState.pane?.id;
|
||||||
|
openWorkspaceTabFocused(
|
||||||
|
persistenceKey,
|
||||||
|
target,
|
||||||
|
focusedPaneId ? { mode: "pane", paneId: focusedPaneId } : undefined,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
activeExplorerCheckout,
|
||||||
|
focusedPaneTabState.pane?.id,
|
||||||
|
isMobile,
|
||||||
|
openInSidePanelByDefault,
|
||||||
|
openWorkspaceTabFocused,
|
||||||
|
persistenceKey,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleWorkspaceCurrentTabMetadataAction = useCallback(
|
||||||
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
|
const descriptor = activeTab?.descriptor;
|
||||||
|
switch (action.id) {
|
||||||
|
case "workspace.tab.rename-current":
|
||||||
|
if (descriptor) handleRenameTab(descriptor);
|
||||||
|
return true;
|
||||||
|
case "workspace.tab.reload-current":
|
||||||
|
if (descriptor?.target.kind === "agent")
|
||||||
|
void handleReloadAgent(descriptor.target.agentId);
|
||||||
|
return true;
|
||||||
|
case "workspace.tab.copy-resume-command":
|
||||||
|
if (descriptor?.target.kind === "agent") {
|
||||||
|
void handleCopyResumeCommand(descriptor.target.agentId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
case "workspace.tab.copy-id":
|
||||||
|
if (descriptor?.target.kind === "agent")
|
||||||
|
void handleCopyAgentId(descriptor.target.agentId);
|
||||||
|
if (descriptor?.target.kind === "terminal") {
|
||||||
|
void handleCopyTerminalId(descriptor.target.terminalId);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
case "workspace.tab.copy-file-path":
|
||||||
|
if (descriptor?.target.kind === "file") void handleCopyFilePath(descriptor.target.path);
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
activeTab,
|
||||||
|
handleCopyAgentId,
|
||||||
|
handleCopyFilePath,
|
||||||
|
handleCopyResumeCommand,
|
||||||
|
handleCopyTerminalId,
|
||||||
|
handleReloadAgent,
|
||||||
|
handleRenameTab,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleWorkspaceCurrentTabCloseAction = useCallback(
|
||||||
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
|
if (!activeTabId) return true;
|
||||||
|
if (action.id === "workspace.tab.close-left") {
|
||||||
|
void handleCloseTabsToLeft(activeTabId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action.id === "workspace.tab.close-right") {
|
||||||
|
void handleCloseTabsToRight(activeTabId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action.id === "workspace.tab.close-others") {
|
||||||
|
void handleCloseOtherTabs(activeTabId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[activeTabId, handleCloseOtherTabs, handleCloseTabsToLeft, handleCloseTabsToRight],
|
||||||
|
);
|
||||||
|
|
||||||
const handleWorkspaceTabAction = useCallback(
|
const handleWorkspaceTabAction = useCallback(
|
||||||
(action: KeyboardActionDefinition): boolean => {
|
(action: KeyboardActionDefinition): boolean => {
|
||||||
switch (action.id) {
|
switch (action.id) {
|
||||||
@@ -2999,6 +3117,10 @@ function WorkspaceScreenContent({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const workspaceKeyboardActionsEnabled = Boolean(
|
||||||
|
isRouteFocused && normalizedServerId && normalizedWorkspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: buildWorkspaceKeyboardHandlerId({
|
handlerId: buildWorkspaceKeyboardHandlerId({
|
||||||
name: "workspace-tab-actions",
|
name: "workspace-tab-actions",
|
||||||
@@ -3013,12 +3135,61 @@ function WorkspaceScreenContent({
|
|||||||
"workspace.terminal.new",
|
"workspace.terminal.new",
|
||||||
"workspace.browser.new",
|
"workspace.browser.new",
|
||||||
] as const,
|
] as const,
|
||||||
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
handle: handleWorkspaceTabAction,
|
handle: handleWorkspaceTabAction,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useKeyboardActionHandler({
|
||||||
|
handlerId: buildWorkspaceKeyboardHandlerId({
|
||||||
|
name: "workspace-panel-open-actions",
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
}),
|
||||||
|
actions: ["workspace.tab.open"] as const,
|
||||||
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
|
priority: 100,
|
||||||
|
isActive: () => true,
|
||||||
|
handle: handleWorkspacePanelOpenAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
useKeyboardActionHandler({
|
||||||
|
handlerId: buildWorkspaceKeyboardHandlerId({
|
||||||
|
name: "workspace-current-tab-metadata-actions",
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
}),
|
||||||
|
actions: [
|
||||||
|
"workspace.tab.rename-current",
|
||||||
|
"workspace.tab.reload-current",
|
||||||
|
"workspace.tab.copy-resume-command",
|
||||||
|
"workspace.tab.copy-id",
|
||||||
|
"workspace.tab.copy-file-path",
|
||||||
|
] as const,
|
||||||
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
|
priority: 100,
|
||||||
|
isActive: () => true,
|
||||||
|
handle: handleWorkspaceCurrentTabMetadataAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
useKeyboardActionHandler({
|
||||||
|
handlerId: buildWorkspaceKeyboardHandlerId({
|
||||||
|
name: "workspace-current-tab-close-actions",
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
}),
|
||||||
|
actions: [
|
||||||
|
"workspace.tab.close-left",
|
||||||
|
"workspace.tab.close-right",
|
||||||
|
"workspace.tab.close-others",
|
||||||
|
] as const,
|
||||||
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
|
priority: 100,
|
||||||
|
isActive: () => true,
|
||||||
|
handle: handleWorkspaceCurrentTabCloseAction,
|
||||||
|
});
|
||||||
|
|
||||||
useKeyboardActionHandler({
|
useKeyboardActionHandler({
|
||||||
handlerId: buildWorkspaceKeyboardHandlerId({
|
handlerId: buildWorkspaceKeyboardHandlerId({
|
||||||
name: "workspace-pane-actions",
|
name: "workspace-pane-actions",
|
||||||
@@ -3039,7 +3210,7 @@ function WorkspaceScreenContent({
|
|||||||
"workspace.pane.close",
|
"workspace.pane.close",
|
||||||
"workspace.focus.toggle",
|
"workspace.focus.toggle",
|
||||||
] as const,
|
] as const,
|
||||||
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
handle: handleWorkspacePaneAction,
|
handle: handleWorkspacePaneAction,
|
||||||
@@ -3052,7 +3223,7 @@ function WorkspaceScreenContent({
|
|||||||
workspaceId: normalizedWorkspaceId,
|
workspaceId: normalizedWorkspaceId,
|
||||||
}),
|
}),
|
||||||
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
|
actions: ["sidebar.toggle.right", "sidebar.toggle.both"] as const,
|
||||||
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
enabled: workspaceKeyboardActionsEnabled,
|
||||||
priority: 100,
|
priority: 100,
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
handle: handleWorkspaceSidebarAction,
|
handle: handleWorkspaceSidebarAction,
|
||||||
@@ -3290,6 +3461,7 @@ function WorkspaceScreenContent({
|
|||||||
function renderSplitPaneEmptyState(input: {
|
function renderSplitPaneEmptyState(input: {
|
||||||
paneId: string;
|
paneId: string;
|
||||||
paneIsolated: boolean;
|
paneIsolated: boolean;
|
||||||
|
isFocused: boolean;
|
||||||
onClosePane: () => void;
|
onClosePane: () => void;
|
||||||
}) {
|
}) {
|
||||||
// Same predicate the close action reads, so the button is offered exactly when
|
// Same predicate the close action reads, so the button is offered exactly when
|
||||||
@@ -3301,6 +3473,7 @@ function WorkspaceScreenContent({
|
|||||||
return (
|
return (
|
||||||
<WorkspacePaneEmptyState
|
<WorkspacePaneEmptyState
|
||||||
paneId={input.paneId}
|
paneId={input.paneId}
|
||||||
|
isFocused={input.isFocused}
|
||||||
showChanges={isGitCheckout}
|
showChanges={isGitCheckout}
|
||||||
showPullRequest={hasPullRequest}
|
showPullRequest={hasPullRequest}
|
||||||
onOpenView={handleOpenPaneView}
|
onOpenView={handleOpenPaneView}
|
||||||
|
|||||||
@@ -1582,7 +1582,12 @@ export function replaceTabTargetInLayout(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
let tabId = input.createTabId();
|
let tabId = input.createTabId();
|
||||||
if (currentTab?.target.kind === "draft") {
|
// File navigation is a retarget of one File tab, not a new panel instance. Keeping its
|
||||||
|
// identity preserves the mounted tab track and the File tab's local presentation state.
|
||||||
|
if (
|
||||||
|
currentTab?.target.kind === "draft" ||
|
||||||
|
(currentTab?.target.kind === "file" && input.target.kind === "file")
|
||||||
|
) {
|
||||||
tabId = input.tabId;
|
tabId = input.tabId;
|
||||||
} else if (input.target.kind === "draft") {
|
} else if (input.target.kind === "draft") {
|
||||||
tabId = buildDeterministicWorkspaceTabId(input.target);
|
tabId = buildDeterministicWorkspaceTabId(input.target);
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ describe("workspace-layout-store actions", () => {
|
|||||||
const fileState: FileState = { treeVisible: true, treeWidth: 240 };
|
const fileState: FileState = { treeVisible: true, treeWidth: 240 };
|
||||||
store.setTabState(workspaceKey, file, fileState);
|
store.setTabState(workspaceKey, file, fileState);
|
||||||
const sameKind = store.replaceTab(workspaceKey, file, { kind: "file", path: "/repo/b.ts" });
|
const sameKind = store.replaceTab(workspaceKey, file, { kind: "file", path: "/repo/b.ts" });
|
||||||
|
expect(sameKind).toBe(file);
|
||||||
expect(
|
expect(
|
||||||
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find(
|
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find(
|
||||||
(tab) => tab.tabId === sameKind,
|
(tab) => tab.tabId === sameKind,
|
||||||
@@ -554,11 +555,9 @@ describe("workspace-layout-store actions", () => {
|
|||||||
{ kind: "file", path: "/repo/b.ts" },
|
{ kind: "file", path: "/repo/b.ts" },
|
||||||
{ ...defaultFileState, treeVisible: true, treeWidth: 300 },
|
{ ...defaultFileState, treeVisible: true, treeWidth: 300 },
|
||||||
);
|
);
|
||||||
expect(replacement).not.toBe(file);
|
expect(replacement).toBe(file);
|
||||||
const replacedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
const replacedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
||||||
expect(replacedLayout.parentTabIdByTabId?.[file]).toBeUndefined();
|
expect(replacedLayout.parentTabIdByTabId?.[file]).toBe(parent);
|
||||||
expect(replacedLayout.parentTabIdByTabId?.[replacement as string]).toBe(parent);
|
|
||||||
expect(Object.entries(replacedLayout.parentTabIdByTabId ?? {}).flat()).not.toContain(file);
|
|
||||||
|
|
||||||
store.closeTab(workspaceKey, replacement as string);
|
store.closeTab(workspaceKey, replacement as string);
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
openSupportingTab,
|
openSupportingTab,
|
||||||
showSidePanel,
|
showSidePanel,
|
||||||
toggleSidePanel,
|
toggleSidePanel,
|
||||||
|
toggleSupportingTab,
|
||||||
} from "@/workspace-tabs/side-panel";
|
} from "@/workspace-tabs/side-panel";
|
||||||
|
|
||||||
const WORKSPACE_KEY = "server-1:ws-main";
|
const WORKSPACE_KEY = "server-1:ws-main";
|
||||||
@@ -205,6 +206,27 @@ describe("non-compact with splits", () => {
|
|||||||
expect(isSidePanelOpen(wide)).toBe(false);
|
expect(isSidePanelOpen(wide)).toBe(false);
|
||||||
expect(tabKinds()).toEqual(["files"]);
|
expect(tabKinds()).toEqual(["files"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("toggles the visible supporting target without closing its tab", () => {
|
||||||
|
const changes = {
|
||||||
|
...wide,
|
||||||
|
target: { kind: "working_diff" } as const,
|
||||||
|
openInSidePanelByDefault: true,
|
||||||
|
};
|
||||||
|
const tabId = toggleSupportingTab(changes);
|
||||||
|
openSidePanelView({ ...wide, view: "files" });
|
||||||
|
|
||||||
|
toggleSupportingTab(changes);
|
||||||
|
expect(isSidePanelOpen(wide)).toBe(true);
|
||||||
|
expect(focusedPaneTabKinds()).toContain("working_diff");
|
||||||
|
|
||||||
|
toggleSupportingTab(changes);
|
||||||
|
expect(isSidePanelOpen(wide)).toBe(false);
|
||||||
|
expect(tabKinds()).toContain("working_diff");
|
||||||
|
|
||||||
|
expect(toggleSupportingTab(changes)).toBe(tabId);
|
||||||
|
expect(isSidePanelOpen(wide)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("non-compact without splits", () => {
|
describe("non-compact without splits", () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type WorkspaceTabPlacement,
|
type WorkspaceTabPlacement,
|
||||||
} from "@/stores/workspace-layout-store";
|
} from "@/stores/workspace-layout-store";
|
||||||
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
|
||||||
|
import { workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The side panel is the companion surface beside the user's work — Changes, Files,
|
* The side panel is the companion surface beside the user's work — Changes, Files,
|
||||||
@@ -62,6 +63,20 @@ function canUseSidePanelPane(input: SidePanelQuery): boolean {
|
|||||||
return !input.isCompact && (input.supportsPaneSplits ?? supportsDesktopPaneSplits());
|
return !input.isCompact && (input.supportsPaneSplits ?? supportsDesktopPaneSplits());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTargetVisibleInSidePanel(
|
||||||
|
state: LayoutState,
|
||||||
|
workspaceKey: string,
|
||||||
|
target: WorkspaceTabTarget,
|
||||||
|
): boolean {
|
||||||
|
if (!selectIsSidePanelVisible(state, workspaceKey)) return false;
|
||||||
|
const layout = state.layoutByWorkspace[workspaceKey];
|
||||||
|
const paneId = selectSidePanelPaneId(state, workspaceKey);
|
||||||
|
const focusedTabId = layout && paneId ? findPaneById(layout.root, paneId)?.focusedTabId : null;
|
||||||
|
if (!layout || !focusedTabId) return false;
|
||||||
|
const focusedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === focusedTabId);
|
||||||
|
return Boolean(focusedTab && workspaceTabTargetsEqual(focusedTab.target, target));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Side panel tabs (non-compact, no splits) ──────────────────────────────
|
// ─── Side panel tabs (non-compact, no splits) ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,6 +107,11 @@ export interface OpenSupportingTabInput extends SidePanelQuery {
|
|||||||
background?: boolean;
|
background?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToggleSupportingTabInput extends SidePanelQuery {
|
||||||
|
target: WorkspaceTabTarget;
|
||||||
|
openInSidePanelByDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a tab the user did not place themselves — an agent's file link, a detected
|
* Opens a tab the user did not place themselves — an agent's file link, a detected
|
||||||
* pull request, workspace setup progress. A supporting tab that is already open is
|
* pull request, workspace setup progress. A supporting tab that is already open is
|
||||||
@@ -123,6 +143,52 @@ export function openSupportingTab(input: OpenSupportingTabInput): string | null
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Toggle a supporting target when it is the visible Side panel tab; otherwise reveal it. */
|
||||||
|
export function toggleSupportingTab(input: ToggleSupportingTabInput): string | null {
|
||||||
|
if (!input.workspaceKey) return null;
|
||||||
|
const store = useWorkspaceLayoutStore.getState();
|
||||||
|
if (
|
||||||
|
input.openInSidePanelByDefault &&
|
||||||
|
canUseSidePanelPane(input) &&
|
||||||
|
isTargetVisibleInSidePanel(store, input.workspaceKey, input.target)
|
||||||
|
) {
|
||||||
|
store.hideSidePanel(input.workspaceKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return openSupportingTab(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Explicitly place a companion tab in the Side panel, moving an existing tab there. */
|
||||||
|
export function openTabInSidePanel(
|
||||||
|
input: SidePanelInput & { target: WorkspaceTabTarget },
|
||||||
|
): string | null {
|
||||||
|
if (!input.workspaceKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.isCompact) {
|
||||||
|
const view = Object.entries(SIDE_PANEL_VIEW_TARGETS).find(
|
||||||
|
([, target]) => target.kind === input.target.kind,
|
||||||
|
)?.[0] as ExplorerTab | undefined;
|
||||||
|
if (!view || !input.checkout) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const panel = usePanelStore.getState();
|
||||||
|
panel.setExplorerTabForCheckout({ ...input.checkout, tab: view });
|
||||||
|
panel.openCompactFileExplorer(input.checkout);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useWorkspaceLayoutStore.getState();
|
||||||
|
const paneId = canUseSidePanelPane(input) ? store.showSidePanel(input.workspaceKey) : undefined;
|
||||||
|
return store.openTab({
|
||||||
|
workspaceKey: input.workspaceKey,
|
||||||
|
target: input.target,
|
||||||
|
intent: "reveal",
|
||||||
|
placement: paneId ? { mode: "pane", paneId } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Undefined means "no opinion, use the focused pane". Revealing is part of resolving
|
* Undefined means "no opinion, use the focused pane". Revealing is part of resolving
|
||||||
* the side panel: a focused tab in a hidden pane is nothing the user can see, so a
|
* the side panel: a focused tab in a hidden pane is nothing the user can see, so a
|
||||||
|
|||||||
Reference in New Issue
Block a user