mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
Open the tab chooser beside the pointer (#3735)
* feat(app): open the tab chooser beside the pointer Keep keyboard-created and empty-pane New tabs as persistent launcher surfaces, while giving pointer users an anchored chooser at the pane-local plus button. Both presentations resolve the same launch catalogue so future ordering and customization cannot drift. * test(app): follow the anchored tab chooser The browser helpers still searched inside the pane for persistent-launcher controls after clicking the portalled menu trigger. Exercise the menu through its own roles and stable catalogue IDs.
This commit is contained in:
@@ -137,6 +137,18 @@ test.describe("Tab creation", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking + opens its menu without creating a New tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
const countBefore = await countTabsOfKind(page, "new_tab");
|
||||
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-menu").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
await expect.poll(() => countTabsOfKind(page, "new_tab")).toBe(countBefore);
|
||||
});
|
||||
|
||||
test("opening two New tabs creates two independent tab identities", async ({ page }) => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
@@ -218,7 +230,11 @@ test.describe("Tab creation", () => {
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
await page.getByRole("button", { name: EMPTY_PROMPT_PROFILE.name }).click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu")
|
||||
.filter({ visible: true })
|
||||
.getByRole("menuitem", { name: EMPTY_PROMPT_PROFILE.name })
|
||||
.click();
|
||||
|
||||
await expectTerminalOutputContains(page, "prompt-args: 0");
|
||||
} finally {
|
||||
@@ -230,10 +246,10 @@ test.describe("Tab creation", () => {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
|
||||
const panel = page.getByTestId("workspace-new-tab-panel").filter({ visible: true });
|
||||
await expect(panel.getByText("Terminal profiles", { exact: true })).toBeVisible();
|
||||
const menu = page.getByTestId("workspace-new-tab-menu").filter({ visible: true });
|
||||
await expect(menu.getByText("Terminal profiles", { exact: true })).toBeVisible();
|
||||
|
||||
const editProfiles = panel.getByTestId("workspace-new-tab-edit-terminal-profiles");
|
||||
const editProfiles = menu.getByTestId("workspace-new-tab-menu-edit-terminal-profiles");
|
||||
await expect(editProfiles).toHaveAccessibleName("Edit profiles");
|
||||
|
||||
await editProfiles.click();
|
||||
@@ -363,7 +379,7 @@ test.describe("Tab transitions (no flash)", () => {
|
||||
}) => {
|
||||
const isolatedWorkspace = await withWorkspace({ prefix: "launcher-no-flash-" });
|
||||
await isolatedWorkspace.navigateTo();
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
await pressNewTabShortcut(page);
|
||||
await expect(
|
||||
page.getByTestId("workspace-new-tab-panel").filter({ visible: true }),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -218,7 +218,11 @@ test.describe("Side panel", () => {
|
||||
await capture(page, testInfo, "08-changes-in-side-panel");
|
||||
|
||||
await mainPane(page).getByTestId("workspace-new-tab-button").click();
|
||||
await page.getByTestId("workspace-new-tab-changes").filter({ visible: true }).first().click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu-changes")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(mainPane(page).getByTestId("workspace-tab-working_diff")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
|
||||
@@ -60,7 +60,11 @@ async function createTerminalViaMenu(page: Page): Promise<void> {
|
||||
// main pane, so an unscoped testid locator matches both; scope to the
|
||||
// visible one.
|
||||
await page.getByTestId("workspace-new-tab-button").filter({ visible: true }).click();
|
||||
await page.getByTestId("workspace-new-tab-terminal").filter({ visible: true }).first().click();
|
||||
await page
|
||||
.getByTestId("workspace-new-tab-menu-terminal")
|
||||
.filter({ visible: true })
|
||||
.first()
|
||||
.click();
|
||||
}
|
||||
|
||||
async function listTerminalIds(harness: TerminalE2EHarness): Promise<string[]> {
|
||||
|
||||
@@ -96,17 +96,20 @@ export async function assertNewTabMenuTriggerVisible(page: Page): Promise<void>
|
||||
|
||||
// ─── Tab creation actions ─────────────────────────────────────────────────
|
||||
|
||||
/** Create a New tab and choose Agent to create a draft/chat tab. */
|
||||
/** Choose Agent from the pane-local `+` menu. */
|
||||
export async function clickNewChat(page: Page): Promise<void> {
|
||||
await createAgentTabFromMenu(page);
|
||||
}
|
||||
|
||||
/** Create a New tab and choose Terminal. */
|
||||
/** Choose Terminal from the pane-local `+` menu. */
|
||||
export async function clickNewTerminal(page: Page): Promise<void> {
|
||||
const trigger = page.getByTestId("workspace-new-tab-button").filter({ visible: true }).first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
const item = page.getByTestId("workspace-new-tab-terminal").filter({ visible: true }).first();
|
||||
const item = page
|
||||
.getByTestId("workspace-new-tab-menu-terminal")
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
await expect(item).toBeVisible({ timeout: 10_000 });
|
||||
await item.click();
|
||||
}
|
||||
|
||||
@@ -88,16 +88,16 @@ export async function ensureSidePanel(page: Page): Promise<Locator> {
|
||||
*/
|
||||
async function openSidePanelView(
|
||||
page: Page,
|
||||
view: { tabTestId: string; launcherTestId: string; contentTestId: string; timeout?: number },
|
||||
view: { tabTestId: string; launchItemId: string; contentTestId: string; timeout?: number },
|
||||
): Promise<void> {
|
||||
const panel = await ensureSidePanel(page);
|
||||
const tab = panel.getByTestId(view.tabTestId);
|
||||
if ((await tab.count()) === 0) {
|
||||
const launcher = panel.getByTestId("workspace-new-tab-panel");
|
||||
if ((await launcher.count()) === 0) {
|
||||
await panel.getByTestId("workspace-new-tab-button").click();
|
||||
}
|
||||
await panel.getByTestId(view.launcherTestId).click();
|
||||
await panel.getByTestId("workspace-new-tab-button").click();
|
||||
await page
|
||||
.getByTestId(`workspace-new-tab-menu-${view.launchItemId}`)
|
||||
.filter({ visible: true })
|
||||
.click();
|
||||
}
|
||||
await selectWorkspaceTab(tab);
|
||||
await expect(visibleTestId(page, view.contentTestId).first()).toBeVisible({
|
||||
@@ -108,7 +108,7 @@ async function openSidePanelView(
|
||||
export async function openChangesPanel(page: Page): Promise<void> {
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-working_diff",
|
||||
launcherTestId: "workspace-new-tab-changes",
|
||||
launchItemId: "changes",
|
||||
contentTestId: "working-diff-panel",
|
||||
});
|
||||
}
|
||||
@@ -116,7 +116,7 @@ export async function openChangesPanel(page: Page): Promise<void> {
|
||||
export async function openFilesPanel(page: Page): Promise<void> {
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-files",
|
||||
launcherTestId: "workspace-new-tab-files",
|
||||
launchItemId: "files",
|
||||
contentTestId: "file-explorer-tree-scroll",
|
||||
});
|
||||
}
|
||||
@@ -130,7 +130,7 @@ export async function openPullRequestPanel(page: Page): Promise<void> {
|
||||
}
|
||||
await openSidePanelView(page, {
|
||||
tabTestId: "workspace-tab-pull_request",
|
||||
launcherTestId: "workspace-new-tab-pull-request",
|
||||
launchItemId: "pull-request",
|
||||
contentTestId: "pr-pane",
|
||||
timeout: 15_000,
|
||||
});
|
||||
@@ -145,12 +145,12 @@ export async function waitForWorkspaceTabsVisible(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a New tab from `+` and pick Agent in its launcher. */
|
||||
/** Open the pane-local `+` menu and pick Agent. */
|
||||
export async function createAgentTabFromMenu(page: Page): Promise<void> {
|
||||
const trigger = visibleTestId(page, "workspace-new-tab-button").first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
const item = visibleTestId(page, "workspace-new-tab-agent").first();
|
||||
const item = visibleTestId(page, "workspace-new-tab-menu-agent").first();
|
||||
await expect(item).toBeVisible({ timeout: 10_000 });
|
||||
await item.click();
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export type LauncherAction = "agent" | "terminal" | "changes" | "files" | "browser" | "pullRequest";
|
||||
|
||||
export function getLauncherActionOrder(isSidePanel: boolean): LauncherAction[] {
|
||||
if (isSidePanel) {
|
||||
return ["changes", "files", "terminal", "agent", "browser", "pullRequest"];
|
||||
}
|
||||
return ["agent", "terminal", "changes", "files", "browser", "pullRequest"];
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getLauncherActionOrder } from "@/panels/new-tab-action-order";
|
||||
|
||||
describe("getLauncherActionOrder", () => {
|
||||
it("leads with the work companion tools in the Side panel", () => {
|
||||
expect(getLauncherActionOrder(true)).toEqual([
|
||||
"changes",
|
||||
"files",
|
||||
"terminal",
|
||||
"agent",
|
||||
"browser",
|
||||
"pullRequest",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leads with creating work in an ordinary pane", () => {
|
||||
expect(getLauncherActionOrder(false)).toEqual([
|
||||
"agent",
|
||||
"terminal",
|
||||
"changes",
|
||||
"files",
|
||||
"browser",
|
||||
"pullRequest",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +1,21 @@
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, type ReactElement } from "react";
|
||||
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FileDiff,
|
||||
FolderTree,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
Pencil,
|
||||
Plus,
|
||||
SquarePen,
|
||||
SquareTerminal,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import { useRouter, type Href } from "expo-router";
|
||||
import { Pencil, Plus, type LucideIcon } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { TerminalProfileIcon } from "@/components/terminal-profile-icon";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { usePaneContext, usePaneFocus } from "@/panels/pane-context";
|
||||
import { getLauncherActionOrder, type LauncherAction } from "@/panels/new-tab-action-order";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
import { resolvePluginIcon } from "@/plugins/icons";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import type { InstalledPlugin } from "@/plugins/types";
|
||||
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
|
||||
import type { PluginWorkspacePanelContribution } from "@getpaseo/plugin";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
getTerminalProfileIcon,
|
||||
resolveTerminalProfiles,
|
||||
} from "@getpaseo/protocol/terminal-profiles";
|
||||
|
||||
export interface NewTabLauncher {
|
||||
showChanges: boolean;
|
||||
showPullRequest: boolean;
|
||||
showBrowser: boolean;
|
||||
terminalDisabled: boolean;
|
||||
activate: (tabId: string, selection: NewTabSelection) => void;
|
||||
}
|
||||
|
||||
const NewTabLauncherContext = createContext<NewTabLauncher | null>(null);
|
||||
|
||||
export function NewTabLauncherProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: NewTabLauncher;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <NewTabLauncherContext.Provider value={value}>{children}</NewTabLauncherContext.Provider>;
|
||||
}
|
||||
useWorkspaceTabLaunchCatalog,
|
||||
type WorkspaceTabLaunchItem,
|
||||
} from "@/workspace-tabs/launcher";
|
||||
|
||||
const ThemedPlus = withUnistyles(Plus);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
@@ -80,29 +29,6 @@ const LAUNCHER_MAX_WIDTH = 380;
|
||||
const EDIT_PROFILES_HIT_SIZE = ICON_SIZE.xs + SPACING[2];
|
||||
const ROW_DATA_SET = { newTabLauncherRow: "true" };
|
||||
const ROW_SELECTOR = '[data-new-tab-launcher-row="true"]';
|
||||
const CHANGES_SELECTION: NewTabSelection = {
|
||||
kind: "target",
|
||||
target: { kind: "working_diff" },
|
||||
};
|
||||
const FILES_SELECTION: NewTabSelection = { kind: "target", target: { kind: "files" } };
|
||||
const PULL_REQUEST_SELECTION: NewTabSelection = {
|
||||
kind: "target",
|
||||
target: { kind: "pull_request" },
|
||||
};
|
||||
const AGENT_SELECTION: NewTabSelection = { kind: "agent" };
|
||||
const TERMINAL_SELECTION: NewTabSelection = { kind: "terminal" };
|
||||
const BROWSER_SELECTION: NewTabSelection = { kind: "browser" };
|
||||
|
||||
interface LauncherActionDefinition {
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
selection: NewTabSelection;
|
||||
shortcut?: string;
|
||||
testID: string;
|
||||
}
|
||||
|
||||
function LauncherIcon({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
|
||||
return <Icon size={LAUNCHER_ICON_SIZE} color={color} />;
|
||||
}
|
||||
@@ -147,109 +73,35 @@ function EditProfilesButton({ label, onPress }: { label: string; onPress: () =>
|
||||
);
|
||||
}
|
||||
|
||||
function LauncherRow({
|
||||
label,
|
||||
Icon,
|
||||
terminalIconKey,
|
||||
disabled,
|
||||
selection,
|
||||
shortcut,
|
||||
testID,
|
||||
}: {
|
||||
label: string;
|
||||
Icon?: LucideIcon;
|
||||
terminalIconKey?: string;
|
||||
disabled?: boolean;
|
||||
selection: NewTabSelection;
|
||||
shortcut?: string;
|
||||
testID: string;
|
||||
}) {
|
||||
function LauncherRow({ item }: { item: WorkspaceTabLaunchItem }) {
|
||||
const { tabId } = usePaneContext();
|
||||
const launcher = useContext(NewTabLauncherContext);
|
||||
invariant(launcher, "NewTabLauncherProvider is required");
|
||||
const handlePress = useCallback(() => {
|
||||
launcher.activate(tabId, selection);
|
||||
}, [launcher, selection, tabId]);
|
||||
item.launch({ kind: "replace", tabId });
|
||||
}, [item, tabId]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={label}
|
||||
accessibilityLabel={item.label}
|
||||
dataSet={ROW_DATA_SET}
|
||||
disabled={disabled}
|
||||
disabled={item.disabled}
|
||||
onPress={handlePress}
|
||||
style={rowStyle}
|
||||
tabIndex={-1}
|
||||
testID={testID}
|
||||
testID={`workspace-new-tab-${item.id}`}
|
||||
>
|
||||
{Icon ? <ThemedLauncherIcon Icon={Icon} uniProps={mutedColorMapping} /> : null}
|
||||
{terminalIconKey ? (
|
||||
<TerminalProfileIcon iconKey={terminalIconKey} size={LAUNCHER_ICON_SIZE} />
|
||||
{item.Icon ? <ThemedLauncherIcon Icon={item.Icon} uniProps={mutedColorMapping} /> : null}
|
||||
{item.terminalIconKey ? (
|
||||
<TerminalProfileIcon iconKey={item.terminalIconKey} size={LAUNCHER_ICON_SIZE} />
|
||||
) : null}
|
||||
<Text numberOfLines={1} style={styles.rowLabel}>
|
||||
{label}
|
||||
{item.label}
|
||||
</Text>
|
||||
{shortcut ? <LauncherShortcut actionId={shortcut} /> : null}
|
||||
{item.shortcutActionId ? <LauncherShortcut actionId={item.shortcutActionId} /> : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalProfileLauncherRow({
|
||||
profile,
|
||||
disabled,
|
||||
}: {
|
||||
profile: TerminalProfile;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const selection = useMemo<NewTabSelection>(() => ({ kind: "terminal", profile }), [profile]);
|
||||
return (
|
||||
<LauncherRow
|
||||
label={profile.name}
|
||||
terminalIconKey={getTerminalProfileIcon(profile)}
|
||||
disabled={disabled}
|
||||
selection={selection}
|
||||
testID={`workspace-new-tab-terminal-profile-${profile.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginLauncherRow({
|
||||
pluginId,
|
||||
panel,
|
||||
}: {
|
||||
pluginId: string;
|
||||
panel: PluginWorkspacePanelContribution;
|
||||
}) {
|
||||
const selection = useMemo<NewTabSelection>(
|
||||
() => ({
|
||||
kind: "target",
|
||||
target: { kind: "plugin", pluginId, panelId: panel.id, context: "workspace" },
|
||||
}),
|
||||
[panel.id, pluginId],
|
||||
);
|
||||
return (
|
||||
<LauncherRow
|
||||
label={panel.title}
|
||||
Icon={resolvePluginIcon(panel.icon)}
|
||||
selection={selection}
|
||||
testID={`workspace-new-tab-plugin-${pluginId}-${panel.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function collectWorkspacePluginPanels(plugins: InstalledPlugin[], serverId: string) {
|
||||
const result: Array<{ pluginId: string; panel: PluginWorkspacePanelContribution }> = [];
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.serverId !== serverId) continue;
|
||||
for (const panel of plugin.workspacePanels) {
|
||||
if (panel.context === "workspace") {
|
||||
result.push({ pluginId: plugin.id, panel });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function useNewTabDescriptor() {
|
||||
const { t } = useTranslation();
|
||||
const label = t("workspace.tabs.actions.newTab");
|
||||
@@ -264,73 +116,17 @@ function useNewTabDescriptor() {
|
||||
}
|
||||
|
||||
const NewTabPanel = memo(function NewTabPanel(): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { isSidePanel, serverId, tabId } = usePaneContext();
|
||||
const { isInteractive, focusPane } = usePaneFocus();
|
||||
const launcher = useContext(NewTabLauncherContext);
|
||||
invariant(launcher, "NewTabLauncherProvider is required");
|
||||
const { config } = useDaemonConfig(serverId);
|
||||
const plugins = useInstalledPlugins();
|
||||
const containerRef = useRef<View | null>(null);
|
||||
const profiles = useMemo(
|
||||
() => resolveTerminalProfiles(config?.terminalProfiles),
|
||||
[config?.terminalProfiles],
|
||||
const groups = useWorkspaceTabLaunchCatalog({
|
||||
serverId,
|
||||
purpose: isSidePanel ? "supporting" : "primary",
|
||||
});
|
||||
const itemsById = useMemo(
|
||||
() => new Map(groups.flatMap((group) => group.items).map((item) => [item.id, item])),
|
||||
[groups],
|
||||
);
|
||||
const pluginPanels = useMemo(
|
||||
() => collectWorkspacePluginPanels(plugins, serverId),
|
||||
[plugins, serverId],
|
||||
);
|
||||
const editTerminalProfiles = useCallback(() => {
|
||||
router.push(buildSettingsHostSectionRoute(serverId, "terminals") as Href);
|
||||
}, [router, serverId]);
|
||||
const launcherActions: Record<LauncherAction, LauncherActionDefinition> = {
|
||||
agent: {
|
||||
label: t("workspace.tabs.fallback.agent"),
|
||||
Icon: SquarePen,
|
||||
selection: AGENT_SELECTION,
|
||||
shortcut: "workspace-tab-target-agent",
|
||||
testID: "workspace-new-tab-agent",
|
||||
},
|
||||
terminal: {
|
||||
label: t("workspace.tabs.fallback.terminal"),
|
||||
Icon: SquareTerminal,
|
||||
disabled: launcher.terminalDisabled,
|
||||
selection: TERMINAL_SELECTION,
|
||||
shortcut: "workspace-terminal-new",
|
||||
testID: "workspace-new-tab-terminal",
|
||||
},
|
||||
changes: {
|
||||
label: t("workspace.tabs.actions.changes"),
|
||||
Icon: FileDiff,
|
||||
hidden: !launcher.showChanges,
|
||||
selection: CHANGES_SELECTION,
|
||||
shortcut: "workspace-tab-target-changes",
|
||||
testID: "workspace-new-tab-changes",
|
||||
},
|
||||
files: {
|
||||
label: t("workspace.tabs.actions.files"),
|
||||
Icon: FolderTree,
|
||||
selection: FILES_SELECTION,
|
||||
shortcut: "workspace-tab-target-files",
|
||||
testID: "workspace-new-tab-files",
|
||||
},
|
||||
browser: {
|
||||
label: t("workspace.tabs.fallback.browser"),
|
||||
Icon: Globe,
|
||||
hidden: !launcher.showBrowser,
|
||||
selection: BROWSER_SELECTION,
|
||||
shortcut: "workspace-tab-target-browser",
|
||||
testID: "workspace-new-tab-browser",
|
||||
},
|
||||
pullRequest: {
|
||||
label: t("workspace.tabs.actions.pullRequest"),
|
||||
Icon: GitPullRequest,
|
||||
hidden: !launcher.showPullRequest,
|
||||
selection: PULL_REQUEST_SELECTION,
|
||||
testID: "workspace-new-tab-pull-request",
|
||||
},
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWeb || !isInteractive) return;
|
||||
@@ -383,39 +179,31 @@ const NewTabPanel = memo(function NewTabPanel(): ReactElement {
|
||||
};
|
||||
}, [focusPane, isInteractive]);
|
||||
|
||||
const activate = useCallback(
|
||||
(selection: NewTabSelection) => launcher.activate(tabId, selection),
|
||||
[launcher, tabId],
|
||||
);
|
||||
const handleKeyboardAction = useCallback(
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
if (action.id === "workspace.agent.new" || action.id === "workspace.tab.target.agent") {
|
||||
activate({ kind: "agent" });
|
||||
itemsById.get("agent")?.launch({ kind: "replace", tabId });
|
||||
return true;
|
||||
}
|
||||
if (action.id === "workspace.terminal.new") {
|
||||
activate({ kind: "terminal" });
|
||||
itemsById.get("terminal")?.launch({ kind: "replace", tabId });
|
||||
return true;
|
||||
}
|
||||
if (action.id === "workspace.browser.new" || action.id === "workspace.tab.target.browser") {
|
||||
if (launcher.showBrowser) {
|
||||
activate({ kind: "browser" });
|
||||
}
|
||||
itemsById.get("browser")?.launch({ kind: "replace", tabId });
|
||||
return true;
|
||||
}
|
||||
if (action.id === "workspace.tab.target.changes") {
|
||||
if (launcher.showChanges) {
|
||||
activate({ kind: "target", target: { kind: "working_diff" } });
|
||||
}
|
||||
itemsById.get("changes")?.launch({ kind: "replace", tabId });
|
||||
return true;
|
||||
}
|
||||
if (action.id === "workspace.tab.target.files") {
|
||||
activate({ kind: "target", target: { kind: "files" } });
|
||||
itemsById.get("files")?.launch({ kind: "replace", tabId });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[activate, launcher.showBrowser, launcher.showChanges],
|
||||
[itemsById, tabId],
|
||||
);
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `new-tab:${tabId}`,
|
||||
@@ -437,40 +225,24 @@ const NewTabPanel = memo(function NewTabPanel(): ReactElement {
|
||||
<View ref={containerRef} style={styles.container} testID="workspace-new-tab-panel">
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.rail}>
|
||||
<View style={styles.group}>
|
||||
{getLauncherActionOrder(isSidePanel).map((actionId) => {
|
||||
const action = launcherActions[actionId];
|
||||
if (action.hidden) return null;
|
||||
return <LauncherRow key={actionId} {...action} />;
|
||||
})}
|
||||
{pluginPanels.map(({ pluginId, panel }) => (
|
||||
<PluginLauncherRow
|
||||
key={`${pluginId}:${panel.id}`}
|
||||
pluginId={pluginId}
|
||||
panel={panel}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
{profiles.length > 0 ? (
|
||||
<View style={styles.group}>
|
||||
<View style={styles.groupHeader}>
|
||||
<Text style={styles.groupLabel}>
|
||||
{t("workspace.tabs.actions.terminalProfilesMenu")}
|
||||
</Text>
|
||||
<EditProfilesButton
|
||||
label={t("workspace.tabs.actions.editTerminalProfiles")}
|
||||
onPress={editTerminalProfiles}
|
||||
/>
|
||||
</View>
|
||||
{profiles.map((profile) => (
|
||||
<TerminalProfileLauncherRow
|
||||
key={profile.id}
|
||||
disabled={launcher.terminalDisabled}
|
||||
profile={profile}
|
||||
/>
|
||||
{groups.map((group) => (
|
||||
<View key={group.id} style={styles.group}>
|
||||
{group.label ? (
|
||||
<View style={styles.groupHeader}>
|
||||
<Text style={styles.groupLabel}>{group.label}</Text>
|
||||
{group.accessory ? (
|
||||
<EditProfilesButton
|
||||
label={group.accessory.label}
|
||||
onPress={group.accessory.run}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
{group.items.map((item) => (
|
||||
<LauncherRow key={item.id} item={item} />
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { DropdownMenu, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
@@ -79,6 +80,8 @@ import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { buildWorkspaceKeyboardHandlerId } from "@/keyboard/handler-id";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { WorkspaceNewTabMenuContent } from "@/screens/workspace/workspace-new-tab-menu";
|
||||
import { SIDE_PANEL_PANE_ID } from "@/stores/workspace-layout-actions";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const DEFAULT_INLINE_ADD_BUTTON_RESERVED_WIDTH = 36;
|
||||
@@ -217,36 +220,48 @@ function TabLabelMeasurement({
|
||||
}
|
||||
|
||||
interface WorkspaceNewTabButtonProps {
|
||||
serverId: string;
|
||||
paneId?: string;
|
||||
shortcutKeys: ShortcutKey[][] | null;
|
||||
onPress: () => void;
|
||||
onLayout: (event: LayoutChangeEvent) => void;
|
||||
}
|
||||
|
||||
function WorkspaceNewTabButton({ shortcutKeys, onPress, onLayout }: WorkspaceNewTabButtonProps) {
|
||||
function WorkspaceNewTabButton({
|
||||
serverId,
|
||||
paneId,
|
||||
shortcutKeys,
|
||||
onLayout,
|
||||
}: WorkspaceNewTabButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const tooltipText = t("workspace.tabs.actions.newTab");
|
||||
|
||||
return (
|
||||
<View style={styles.inlineAddButton} onLayout={onLayout}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="triggerRef">
|
||||
<Pressable
|
||||
testID="workspace-new-tab-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={tooltipText}
|
||||
onPress={onPress}
|
||||
style={inlineAddActionButtonStyle}
|
||||
>
|
||||
<ThemedPlus size={14} uniProps={extraMutedColorMapping} />
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
|
||||
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="triggerRef">
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-new-tab-button"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={tooltipText}
|
||||
style={inlineAddActionButtonStyle}
|
||||
>
|
||||
<ThemedPlus size={14} uniProps={extraMutedColorMapping} />
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>{tooltipText}</Text>
|
||||
{shortcutKeys ? <Shortcut chord={shortcutKeys} /> : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<WorkspaceNewTabMenuContent
|
||||
serverId={serverId}
|
||||
purpose={paneId === SIDE_PANEL_PANE_ID ? "supporting" : "primary"}
|
||||
paneId={paneId}
|
||||
/>
|
||||
</DropdownMenu>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1347,8 +1362,9 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
/>
|
||||
{!layout.requiresHorizontalScrollFallback ? (
|
||||
<WorkspaceNewTabButton
|
||||
serverId={normalizedServerId}
|
||||
paneId={paneId}
|
||||
shortcutKeys={newTabKeys}
|
||||
onPress={createNewTab}
|
||||
onLayout={handleInlineAddButtonLayout}
|
||||
/>
|
||||
) : null}
|
||||
@@ -1361,8 +1377,9 @@ function ResolvedWorkspaceDesktopTabsRow({
|
||||
</View>
|
||||
{layout.requiresHorizontalScrollFallback ? (
|
||||
<WorkspaceNewTabButton
|
||||
serverId={normalizedServerId}
|
||||
paneId={paneId}
|
||||
shortcutKeys={newTabKeys}
|
||||
onPress={createNewTab}
|
||||
onLayout={handleInlineAddButtonLayout}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import { View } from "react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { TerminalProfileIcon } from "@/components/terminal-profile-icon";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import {
|
||||
useWorkspaceTabLaunchCatalog,
|
||||
type WorkspaceTabLaunchItem,
|
||||
type WorkspaceTabLaunchPurpose,
|
||||
} from "@/workspace-tabs/launcher";
|
||||
import type { LucideIcon } from "lucide-react-native";
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
function LaunchItemIconGlyph({ Icon, color = "" }: { Icon: LucideIcon; color?: string }) {
|
||||
return <Icon size={14} color={color} />;
|
||||
}
|
||||
|
||||
const ThemedLaunchItemIconGlyph = withUnistyles(LaunchItemIconGlyph);
|
||||
|
||||
function LaunchItemIcon({ item }: { item: WorkspaceTabLaunchItem }): ReactElement | null {
|
||||
if (item.Icon) {
|
||||
return <ThemedLaunchItemIconGlyph Icon={item.Icon} uniProps={mutedColorMapping} />;
|
||||
}
|
||||
if (item.terminalIconKey) {
|
||||
return (
|
||||
<View>
|
||||
<TerminalProfileIcon iconKey={item.terminalIconKey} size={14} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function WorkspaceNewTabMenuItem({
|
||||
item,
|
||||
paneId,
|
||||
}: {
|
||||
item: WorkspaceTabLaunchItem;
|
||||
paneId?: string;
|
||||
}) {
|
||||
const leading = useMemo(() => <LaunchItemIcon item={item} />, [item]);
|
||||
const trailing = useMemo(
|
||||
() =>
|
||||
item.shortcutActionId ? <LaunchItemShortcut actionId={item.shortcutActionId} /> : undefined,
|
||||
[item.shortcutActionId],
|
||||
);
|
||||
const handleSelect = useCallback(() => item.launch({ kind: "open", paneId }), [item, paneId]);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
testID={`workspace-new-tab-menu-${item.id}`}
|
||||
leading={leading}
|
||||
trailing={trailing}
|
||||
disabled={item.disabled}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function LaunchItemShortcut({ actionId }: { actionId: string }) {
|
||||
const keys = useShortcutKeys(actionId);
|
||||
return keys ? <Shortcut chord={keys} /> : null;
|
||||
}
|
||||
|
||||
export function WorkspaceNewTabMenuContent({
|
||||
serverId,
|
||||
purpose,
|
||||
paneId,
|
||||
}: {
|
||||
serverId: string;
|
||||
purpose: WorkspaceTabLaunchPurpose;
|
||||
paneId?: string;
|
||||
}) {
|
||||
const groups = useWorkspaceTabLaunchCatalog({ serverId, purpose });
|
||||
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
offset={4}
|
||||
minWidth={200}
|
||||
testID="workspace-new-tab-menu"
|
||||
>
|
||||
{groups.map((group, index) => (
|
||||
<View key={group.id}>
|
||||
{index > 0 ? <DropdownMenuSeparator /> : null}
|
||||
{group.label ? <DropdownMenuLabel>{group.label}</DropdownMenuLabel> : null}
|
||||
{group.items.map((item) => (
|
||||
<WorkspaceNewTabMenuItem key={item.id} item={item} paneId={paneId} />
|
||||
))}
|
||||
{group.accessory ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
testID={`workspace-new-tab-menu-${group.accessory.id}`}
|
||||
onSelect={group.accessory.run}
|
||||
>
|
||||
{group.accessory.label}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
);
|
||||
}
|
||||
@@ -156,8 +156,12 @@ import {
|
||||
} from "@/screens/workspace/workspace-pane-content";
|
||||
import { useMountedTabSet } from "@/screens/workspace/use-mounted-tab-set";
|
||||
import { WorkspaceFocusProvider } from "@/workspace/focus";
|
||||
import { NewTabLauncherProvider, type NewTabLauncher } from "@/panels/new-tab-panel";
|
||||
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
|
||||
import {
|
||||
NewTabLauncherProvider,
|
||||
type NewTabLauncher,
|
||||
type WorkspaceTabLaunchDestination,
|
||||
} from "@/workspace-tabs/launcher";
|
||||
import type { TerminalTabDestination } from "@/screens/workspace/terminals/use-workspace-terminals";
|
||||
import {
|
||||
buildBulkCloseConfirmationMessage,
|
||||
@@ -2314,17 +2318,24 @@ function WorkspaceScreenContent({
|
||||
[createWorkspaceTab, persistenceKey],
|
||||
);
|
||||
|
||||
const activateNewTab = useCallback(
|
||||
(tabId: string, selection: NewTabSelection) => {
|
||||
const launchWorkspaceTab = useCallback(
|
||||
(selection: NewTabSelection, destination: WorkspaceTabLaunchDestination) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
const openTarget = (target: WorkspaceTab["target"]) => {
|
||||
if (destination.kind === "replace") {
|
||||
replaceWorkspaceTabTarget(persistenceKey, destination.tabId, target);
|
||||
} else {
|
||||
createWorkspaceTab(persistenceKey, target, paneLocalPlacement(destination.paneId));
|
||||
}
|
||||
};
|
||||
if (selection.kind === "target") {
|
||||
replaceWorkspaceTabTarget(persistenceKey, tabId, selection.target);
|
||||
openTarget(selection.target);
|
||||
return;
|
||||
}
|
||||
if (selection.kind === "agent") {
|
||||
replaceWorkspaceTabTarget(persistenceKey, tabId, {
|
||||
openTarget({
|
||||
kind: "draft",
|
||||
draftId: generateDraftId(),
|
||||
});
|
||||
@@ -2333,14 +2344,14 @@ function WorkspaceScreenContent({
|
||||
if (selection.kind === "terminal") {
|
||||
createTerminal({
|
||||
profile: selection.profile,
|
||||
destination: { kind: "replace", tabId },
|
||||
destination,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { browserId } = createWorkspaceBrowser();
|
||||
replaceWorkspaceTabTarget(persistenceKey, tabId, { kind: "browser", browserId });
|
||||
openTarget({ kind: "browser", browserId });
|
||||
},
|
||||
[createTerminal, persistenceKey, replaceWorkspaceTabTarget],
|
||||
[createTerminal, createWorkspaceTab, persistenceKey, replaceWorkspaceTabTarget],
|
||||
);
|
||||
|
||||
const handleOpenUrlInBrowserTab = useCallback(
|
||||
@@ -3679,9 +3690,15 @@ function WorkspaceScreenContent({
|
||||
showPullRequest: hasPullRequest,
|
||||
showBrowser: showCreateBrowserTab,
|
||||
terminalDisabled: createTerminalDisabled,
|
||||
activate: activateNewTab,
|
||||
launch: launchWorkspaceTab,
|
||||
}),
|
||||
[activateNewTab, createTerminalDisabled, hasPullRequest, isGitCheckout, showCreateBrowserTab],
|
||||
[
|
||||
createTerminalDisabled,
|
||||
hasPullRequest,
|
||||
isGitCheckout,
|
||||
launchWorkspaceTab,
|
||||
showCreateBrowserTab,
|
||||
],
|
||||
);
|
||||
const focusedPaneIdOrUndefined = useMemo(() => focusedPaneId ?? undefined, [focusedPaneId]);
|
||||
const desktopFocusModeEnabled = useMemo(
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
||||
import { useRouter, type Href } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
FileDiff,
|
||||
FolderTree,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
SquarePen,
|
||||
SquareTerminal,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
import { resolvePluginIcon } from "@/plugins/icons";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
|
||||
import type { NewTabSelection } from "@/workspace-tabs/new-tab";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
getTerminalProfileIcon,
|
||||
resolveTerminalProfiles,
|
||||
} from "@getpaseo/protocol/terminal-profiles";
|
||||
import { getBuiltInLaunchOrder, type BuiltInLaunchItemId } from "./internal/catalog";
|
||||
|
||||
export type WorkspaceTabLaunchPurpose = "primary" | "supporting";
|
||||
|
||||
export type WorkspaceTabLaunchDestination =
|
||||
| { kind: "open"; paneId?: string }
|
||||
| { kind: "replace"; tabId: string };
|
||||
|
||||
export interface NewTabLauncher {
|
||||
showChanges: boolean;
|
||||
showPullRequest: boolean;
|
||||
showBrowser: boolean;
|
||||
terminalDisabled: boolean;
|
||||
launch: (selection: NewTabSelection, destination: WorkspaceTabLaunchDestination) => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabLaunchItem {
|
||||
id: string;
|
||||
label: string;
|
||||
Icon?: LucideIcon;
|
||||
terminalIconKey?: string;
|
||||
shortcutActionId?: string;
|
||||
disabled: boolean;
|
||||
launch: (destination: WorkspaceTabLaunchDestination) => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabLaunchGroup {
|
||||
id: "tabs" | "terminal-profiles";
|
||||
label: string | null;
|
||||
items: readonly WorkspaceTabLaunchItem[];
|
||||
accessory?: { id: string; label: string; run: () => void };
|
||||
}
|
||||
|
||||
const NewTabLauncherContext = createContext<NewTabLauncher | null>(null);
|
||||
|
||||
export function NewTabLauncherProvider({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: NewTabLauncher;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <NewTabLauncherContext.Provider value={value}>{children}</NewTabLauncherContext.Provider>;
|
||||
}
|
||||
|
||||
const BUILT_IN_SELECTIONS: Record<BuiltInLaunchItemId, NewTabSelection> = {
|
||||
agent: { kind: "agent" },
|
||||
terminal: { kind: "terminal" },
|
||||
changes: { kind: "target", target: { kind: "working_diff" } },
|
||||
files: { kind: "target", target: { kind: "files" } },
|
||||
browser: { kind: "browser" },
|
||||
pullRequest: { kind: "target", target: { kind: "pull_request" } },
|
||||
};
|
||||
|
||||
export function useWorkspaceTabLaunchCatalog(input: {
|
||||
serverId: string;
|
||||
purpose: WorkspaceTabLaunchPurpose;
|
||||
}): readonly WorkspaceTabLaunchGroup[] {
|
||||
const { serverId, purpose } = input;
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const launcher = useContext(NewTabLauncherContext);
|
||||
invariant(launcher, "NewTabLauncherProvider is required");
|
||||
const { config } = useDaemonConfig(serverId);
|
||||
const plugins = useInstalledPlugins();
|
||||
|
||||
const launchSelection = useCallback(
|
||||
(selection: NewTabSelection) => (destination: WorkspaceTabLaunchDestination) => {
|
||||
launcher.launch(selection, destination);
|
||||
},
|
||||
[launcher],
|
||||
);
|
||||
const editTerminalProfiles = useCallback(() => {
|
||||
router.push(buildSettingsHostSectionRoute(serverId, "terminals") as Href);
|
||||
}, [router, serverId]);
|
||||
|
||||
return useMemo(() => {
|
||||
const builtIns: Record<BuiltInLaunchItemId, WorkspaceTabLaunchItem & { hidden?: boolean }> = {
|
||||
agent: {
|
||||
id: "agent",
|
||||
label: t("workspace.tabs.fallback.agent"),
|
||||
Icon: SquarePen,
|
||||
shortcutActionId: "workspace-tab-target-agent",
|
||||
disabled: false,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.agent),
|
||||
},
|
||||
terminal: {
|
||||
id: "terminal",
|
||||
label: t("workspace.tabs.fallback.terminal"),
|
||||
Icon: SquareTerminal,
|
||||
shortcutActionId: "workspace-terminal-new",
|
||||
disabled: launcher.terminalDisabled,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.terminal),
|
||||
},
|
||||
changes: {
|
||||
id: "changes",
|
||||
label: t("workspace.tabs.actions.changes"),
|
||||
Icon: FileDiff,
|
||||
shortcutActionId: "workspace-tab-target-changes",
|
||||
disabled: false,
|
||||
hidden: !launcher.showChanges,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.changes),
|
||||
},
|
||||
files: {
|
||||
id: "files",
|
||||
label: t("workspace.tabs.actions.files"),
|
||||
Icon: FolderTree,
|
||||
shortcutActionId: "workspace-tab-target-files",
|
||||
disabled: false,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.files),
|
||||
},
|
||||
browser: {
|
||||
id: "browser",
|
||||
label: t("workspace.tabs.fallback.browser"),
|
||||
Icon: Globe,
|
||||
shortcutActionId: "workspace-tab-target-browser",
|
||||
disabled: false,
|
||||
hidden: !launcher.showBrowser,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.browser),
|
||||
},
|
||||
pullRequest: {
|
||||
id: "pull-request",
|
||||
label: t("workspace.tabs.actions.pullRequest"),
|
||||
Icon: GitPullRequest,
|
||||
disabled: false,
|
||||
hidden: !launcher.showPullRequest,
|
||||
launch: launchSelection(BUILT_IN_SELECTIONS.pullRequest),
|
||||
},
|
||||
};
|
||||
const tabItems = getBuiltInLaunchOrder(purpose).flatMap((id) => {
|
||||
const item = builtIns[id];
|
||||
return item.hidden ? [] : [item];
|
||||
});
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.serverId !== serverId) continue;
|
||||
for (const panel of plugin.workspacePanels) {
|
||||
if (panel.context !== "workspace") continue;
|
||||
const selection: NewTabSelection = {
|
||||
kind: "target",
|
||||
target: { kind: "plugin", pluginId: plugin.id, panelId: panel.id, context: "workspace" },
|
||||
};
|
||||
tabItems.push({
|
||||
id: `plugin:${plugin.id}:${panel.id}`,
|
||||
label: panel.title,
|
||||
Icon: resolvePluginIcon(panel.icon),
|
||||
disabled: false,
|
||||
launch: launchSelection(selection),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const profiles = resolveTerminalProfiles(config?.terminalProfiles);
|
||||
const groups: WorkspaceTabLaunchGroup[] = [{ id: "tabs", label: null, items: tabItems }];
|
||||
if (profiles.length > 0) {
|
||||
groups.push({
|
||||
id: "terminal-profiles",
|
||||
label: t("workspace.tabs.actions.terminalProfilesMenu"),
|
||||
items: profiles.map((profile: TerminalProfile) => ({
|
||||
id: `terminal-profile:${profile.id}`,
|
||||
label: profile.name,
|
||||
terminalIconKey: getTerminalProfileIcon(profile),
|
||||
disabled: launcher.terminalDisabled,
|
||||
launch: launchSelection({ kind: "terminal", profile }),
|
||||
})),
|
||||
accessory: {
|
||||
id: "edit-terminal-profiles",
|
||||
label: t("workspace.tabs.actions.editTerminalProfiles"),
|
||||
run: editTerminalProfiles,
|
||||
},
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}, [
|
||||
config?.terminalProfiles,
|
||||
editTerminalProfiles,
|
||||
launchSelection,
|
||||
launcher,
|
||||
plugins,
|
||||
purpose,
|
||||
serverId,
|
||||
t,
|
||||
]);
|
||||
}
|
||||
|
||||
export { getBuiltInLaunchOrder } from "./internal/catalog";
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getBuiltInLaunchOrder } from "./catalog";
|
||||
|
||||
describe("getBuiltInLaunchOrder", () => {
|
||||
it("leads with creating work in a primary pane", () => {
|
||||
expect(getBuiltInLaunchOrder("primary")).toEqual([
|
||||
"agent",
|
||||
"terminal",
|
||||
"changes",
|
||||
"files",
|
||||
"browser",
|
||||
"pullRequest",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leads with companion tools in a supporting pane", () => {
|
||||
expect(getBuiltInLaunchOrder("supporting")).toEqual([
|
||||
"changes",
|
||||
"files",
|
||||
"terminal",
|
||||
"agent",
|
||||
"browser",
|
||||
"pullRequest",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
export const PRIMARY_LAUNCH_ORDER = [
|
||||
"agent",
|
||||
"terminal",
|
||||
"changes",
|
||||
"files",
|
||||
"browser",
|
||||
"pullRequest",
|
||||
] as const;
|
||||
|
||||
export const SUPPORTING_LAUNCH_ORDER = [
|
||||
"changes",
|
||||
"files",
|
||||
"terminal",
|
||||
"agent",
|
||||
"browser",
|
||||
"pullRequest",
|
||||
] as const;
|
||||
|
||||
export type BuiltInLaunchItemId = (typeof PRIMARY_LAUNCH_ORDER)[number];
|
||||
|
||||
export function getBuiltInLaunchOrder(purpose: "primary" | "supporting") {
|
||||
return purpose === "supporting" ? SUPPORTING_LAUNCH_ORDER : PRIMARY_LAUNCH_ORDER;
|
||||
}
|
||||
Reference in New Issue
Block a user