Make the command center stable and workspace-aware (#2749)
* feat(app): expand command center actions Keep command center navigation stable by revealing only clipped rows without smooth centering. Prioritize a concise default list while making workspace, Git, pane, and creation actions searchable through the existing contribution and action-policy paths. * test(app): search for query-only command action Add project is intentionally absent from the unfiltered command center. Exercise the overlay flow through search before selecting it.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import {
|
||||
expectCommandCenterToRemainAtTheTop,
|
||||
expectPrimaryCommandCenterActions,
|
||||
expectTightTwoLineResultSpacing,
|
||||
expectVisibleKeyboardNavigationNotToScroll,
|
||||
expectWorkspaceResultsToBeLimitedUntilSearch,
|
||||
observeCommandCenterScroll,
|
||||
openCommandCenterWithKeyboard,
|
||||
} from "./helpers/command-center-scroll";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
|
||||
test.use({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/145.0 Safari/537.36",
|
||||
});
|
||||
|
||||
test("opening the command center keeps its first result fully visible", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-scroll-",
|
||||
title: "Command Center Scroll 00",
|
||||
});
|
||||
|
||||
try {
|
||||
for (let index = 1; index <= 8; index += 1) {
|
||||
const created = await seeded.client.createWorkspace({
|
||||
source: {
|
||||
kind: "worktree",
|
||||
projectId: seeded.projectId,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: `command-center-scroll-${index}`,
|
||||
},
|
||||
title: `Command Center Scroll ${String(index).padStart(2, "0")}`,
|
||||
});
|
||||
if (!created.workspace) {
|
||||
throw new Error(created.error ?? `Failed to seed workspace ${index}`);
|
||||
}
|
||||
}
|
||||
|
||||
await gotoAppShell(page);
|
||||
await observeCommandCenterScroll(page);
|
||||
const panel = await openCommandCenterWithKeyboard(page);
|
||||
|
||||
await expectCommandCenterToRemainAtTheTop(page);
|
||||
await expectPrimaryCommandCenterActions(panel);
|
||||
await expectVisibleKeyboardNavigationNotToScroll(page, panel);
|
||||
await expectTightTwoLineResultSpacing(panel, "Command Center Scroll 00");
|
||||
await expectWorkspaceResultsToBeLimitedUntilSearch(panel);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { test } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { openCommandCenter } from "./helpers/command-center";
|
||||
import {
|
||||
createTerminalFromCommandCenter,
|
||||
expectDefaultWorkspaceActions,
|
||||
expectWorkspaceActionsAvailableThroughSearch,
|
||||
makeWorkspacePrimaryGitActionCommit,
|
||||
openWorkspaceFromCommandCenter,
|
||||
} from "./helpers/command-center-workspace-actions";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
|
||||
test("workspace actions use the Git policy and dispatch through the active workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const title = "Command Center Workspace Actions";
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "command-center-workspace-actions-",
|
||||
title,
|
||||
});
|
||||
|
||||
try {
|
||||
await makeWorkspacePrimaryGitActionCommit(seeded);
|
||||
await gotoAppShell(page);
|
||||
await openWorkspaceFromCommandCenter(page, seeded, title);
|
||||
|
||||
const panel = await openCommandCenter(page);
|
||||
await expectDefaultWorkspaceActions(panel);
|
||||
await expectWorkspaceActionsAvailableThroughSearch(panel);
|
||||
await createTerminalFromCommandCenter(page, panel);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
interface CommandCenterScrollSample {
|
||||
scrollTop: number;
|
||||
firstResultTop: number;
|
||||
viewportTop: number;
|
||||
}
|
||||
|
||||
interface CommandCenterScrollObservation {
|
||||
done: boolean;
|
||||
samples: CommandCenterScrollSample[];
|
||||
}
|
||||
|
||||
type ScrollOracleWindow = Window & {
|
||||
__commandCenterScrollObservation?: CommandCenterScrollObservation;
|
||||
};
|
||||
|
||||
export async function observeCommandCenterScroll(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const oracleWindow = window as ScrollOracleWindow;
|
||||
oracleWindow.__commandCenterScrollObservation = { done: false, samples: [] };
|
||||
|
||||
const discoverPanel = new MutationObserver(() => {
|
||||
const panel = document.querySelector<HTMLElement>('[data-testid="command-center-panel"]');
|
||||
const firstResult = panel?.querySelector<HTMLElement>('[role="button"]');
|
||||
if (!panel || !firstResult) return;
|
||||
|
||||
let viewport = firstResult.parentElement;
|
||||
while (viewport && viewport !== panel && viewport.scrollHeight <= viewport.clientHeight) {
|
||||
viewport = viewport.parentElement;
|
||||
}
|
||||
if (!viewport || viewport === panel) return;
|
||||
|
||||
discoverPanel.disconnect();
|
||||
let frame = 0;
|
||||
const sample = () => {
|
||||
const observation = oracleWindow.__commandCenterScrollObservation;
|
||||
if (!observation) return;
|
||||
const firstResultBounds = firstResult.getBoundingClientRect();
|
||||
const viewportBounds = viewport.getBoundingClientRect();
|
||||
observation.samples.push({
|
||||
scrollTop: viewport.scrollTop,
|
||||
firstResultTop: firstResultBounds.top,
|
||||
viewportTop: viewportBounds.top,
|
||||
});
|
||||
frame += 1;
|
||||
if (frame < 60) {
|
||||
requestAnimationFrame(sample);
|
||||
return;
|
||||
}
|
||||
observation.done = true;
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
|
||||
discoverPanel.observe(document.body, { childList: true, subtree: true });
|
||||
});
|
||||
}
|
||||
|
||||
export async function openCommandCenterWithKeyboard(page: Page): Promise<Locator> {
|
||||
await expect(page.getByTestId("sidebar-command-center-search")).toBeVisible({ timeout: 30_000 });
|
||||
await page.keyboard.press("Meta+K");
|
||||
const panel = page.getByTestId("command-center-panel");
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
|
||||
export async function expectCommandCenterToRemainAtTheTop(page: Page): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => (window as ScrollOracleWindow).__commandCenterScrollObservation?.done === true,
|
||||
);
|
||||
const samples = await page.evaluate(
|
||||
() => (window as ScrollOracleWindow).__commandCenterScrollObservation?.samples ?? [],
|
||||
);
|
||||
|
||||
expect(samples.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
Math.min(...samples.map((sample) => sample.firstResultTop - sample.viewportTop)),
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
expect(Math.max(...samples.map((sample) => sample.scrollTop))).toBe(0);
|
||||
}
|
||||
|
||||
export async function expectPrimaryCommandCenterActions(panel: Locator): Promise<void> {
|
||||
const expectedActions = [
|
||||
{ title: "New workspace", shortcut: "⌘N" },
|
||||
{ title: "History" },
|
||||
{ title: "Schedules" },
|
||||
{ title: "Settings", shortcut: "⌘," },
|
||||
{ title: "Keyboard shortcuts", shortcut: "?" },
|
||||
] as const;
|
||||
|
||||
for (const action of expectedActions) {
|
||||
const row = panel.getByRole("button").filter({ hasText: action.title });
|
||||
await expect(row).toBeVisible();
|
||||
if ("shortcut" in action) await expect(row).toContainText(action.shortcut);
|
||||
}
|
||||
|
||||
await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toHaveCount(0);
|
||||
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toHaveCount(0);
|
||||
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill("add project");
|
||||
await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toBeVisible();
|
||||
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toHaveCount(0);
|
||||
await input.fill("home");
|
||||
await expect(panel.getByRole("button").filter({ hasText: "Home" })).toBeVisible();
|
||||
await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toHaveCount(0);
|
||||
await input.fill("");
|
||||
}
|
||||
|
||||
export async function expectVisibleKeyboardNavigationNotToScroll(
|
||||
page: Page,
|
||||
panel: Locator,
|
||||
): Promise<void> {
|
||||
for (let index = 0; index < 4; index += 1) await page.keyboard.press("ArrowDown");
|
||||
|
||||
const scrollSamples = await panel
|
||||
.getByRole("button")
|
||||
.first()
|
||||
.evaluate(async (firstResult) => {
|
||||
let viewport = firstResult.parentElement;
|
||||
while (viewport && viewport.scrollHeight <= viewport.clientHeight) {
|
||||
viewport = viewport.parentElement;
|
||||
}
|
||||
if (!viewport) throw new Error("Could not find the command center scroll viewport");
|
||||
|
||||
const samples: number[] = [];
|
||||
for (let frame = 0; frame < 20; frame += 1) {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
samples.push(viewport.scrollTop);
|
||||
}
|
||||
return samples;
|
||||
});
|
||||
|
||||
expect(Math.max(...scrollSamples)).toBe(0);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceResultsToBeLimitedUntilSearch(panel: Locator): Promise<void> {
|
||||
const workspaces = panel.locator(
|
||||
'[data-testid^="command-center-workspace-"]:not([data-testid="command-center-workspace-subtitle"])',
|
||||
);
|
||||
const distinctWorkspaceCount = async () =>
|
||||
new Set(
|
||||
await workspaces.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-testid")),
|
||||
),
|
||||
).size;
|
||||
|
||||
await expect.poll(distinctWorkspaceCount).toBe(5);
|
||||
|
||||
await panel.getByTestId("command-center-input").fill("Command Center Scroll");
|
||||
await expect.poll(distinctWorkspaceCount).toBe(9);
|
||||
}
|
||||
|
||||
export async function expectTightTwoLineResultSpacing(
|
||||
panel: Locator,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
const row = panel.getByText(title, { exact: true }).locator("..");
|
||||
const titleTop = await row
|
||||
.getByText(title, { exact: true })
|
||||
.evaluate((element) => element.getBoundingClientRect().top);
|
||||
const subtitleTop = await row
|
||||
.getByTestId("command-center-workspace-subtitle")
|
||||
.evaluate((element) => element.getBoundingClientRect().top);
|
||||
expect(subtitleTop - titleTop).toBeLessThanOrEqual(18);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import { expectAppRoute } from "./route-assertions";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
import { getServerId } from "./server-id";
|
||||
import { expectTerminalTabOpen } from "./workspace-tabs";
|
||||
|
||||
function action(panel: Locator, title: string): Locator {
|
||||
const escapedTitle = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return panel.getByRole("button", { name: new RegExp(`^${escapedTitle}(?:\\s|$)`) });
|
||||
}
|
||||
|
||||
export async function makeWorkspacePrimaryGitActionCommit(seeded: SeededWorkspace): Promise<void> {
|
||||
writeFileSync(path.join(seeded.repoPath, "command-center-dirty.txt"), "dirty\n");
|
||||
const refreshed = await seeded.client.checkoutRefresh(seeded.repoPath);
|
||||
if (!refreshed.success) {
|
||||
throw new Error(`Failed to refresh checkout: ${JSON.stringify(refreshed.error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function openWorkspaceFromCommandCenter(
|
||||
page: Page,
|
||||
seeded: SeededWorkspace,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
const panel = await openCommandCenter(page);
|
||||
await panel.getByTestId("command-center-input").fill(title);
|
||||
await page.keyboard.press("Enter");
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), seeded.workspaceId), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectDefaultWorkspaceActions(panel: Locator): Promise<void> {
|
||||
await expect(panel.getByText("Workspace actions", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(action(panel, "New agent")).toBeVisible();
|
||||
await expect(action(panel, "Commit")).toBeVisible();
|
||||
await expect(action(panel, "Push")).toHaveCount(0);
|
||||
await expect(action(panel, "New terminal")).toHaveCount(0);
|
||||
await expect(action(panel, "Split pane right")).toHaveCount(0);
|
||||
await expect(action(panel, "Home")).toHaveCount(0);
|
||||
await expect(action(panel, "Add project")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceActionsAvailableThroughSearch(panel: Locator): Promise<void> {
|
||||
const input = panel.getByTestId("command-center-input");
|
||||
await input.fill("push");
|
||||
await expect(action(panel, "Push")).toBeVisible();
|
||||
|
||||
await input.fill("home");
|
||||
await expect(action(panel, "Home")).toBeVisible();
|
||||
|
||||
await input.fill("git");
|
||||
await expect.poll(() => panel.getByRole("button").count()).toBeGreaterThan(1);
|
||||
}
|
||||
|
||||
export async function createTerminalFromCommandCenter(page: Page, panel: Locator): Promise<void> {
|
||||
await panel.getByTestId("command-center-input").fill("terminal");
|
||||
await page.keyboard.press("Enter");
|
||||
await expectTerminalTabOpen(page);
|
||||
}
|
||||
@@ -156,6 +156,7 @@ test.describe("provider settings overlay stack", () => {
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+K");
|
||||
await expect(commandCenter).toBeVisible({ timeout: 10_000 });
|
||||
await commandCenter.getByTestId("command-center-input").fill("add project");
|
||||
await commandCenter.getByText("Add project", { exact: true }).click();
|
||||
const addProject = page.getByTestId("add-project-flow");
|
||||
await expect(addProject).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -21,8 +21,10 @@ import { GestureDetector, GestureHandlerRootView } from "react-native-gesture-ha
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { CommandCenter, CommandCenterRootActions } from "@/command-center/command-center";
|
||||
import { CommandCenter } from "@/command-center/command-center";
|
||||
import { CommandCenterRootActions } from "@/command-center/root-registration";
|
||||
import { CommandCenterProvider } from "@/command-center/provider";
|
||||
import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration";
|
||||
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
|
||||
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
|
||||
import { DownloadToast } from "@/components/download-toast";
|
||||
@@ -555,6 +557,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
<UpdateCalloutSource />
|
||||
<WorktreeSetupCalloutSource />
|
||||
<CommandCenterRootActions />
|
||||
<CommandCenterWorkspaceActions />
|
||||
<WorkspacePinShortcutHandler />
|
||||
<CommandCenter />
|
||||
<AddProjectFlowHost />
|
||||
|
||||
@@ -5,13 +5,15 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type ListRenderItemInfo,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type PressableStateCallbackType,
|
||||
} from "react-native";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, ChevronRight, Folder, Home, Plus, Settings } from "lucide-react-native";
|
||||
import { Check, ChevronRight, Folder } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
BottomSheetBackdrop,
|
||||
@@ -25,11 +27,9 @@ import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useProjects } from "@/hooks/use-projects";
|
||||
import {
|
||||
OverlayLayerProvider,
|
||||
@@ -39,22 +39,16 @@ import {
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions";
|
||||
import { useCommandCenterActions, useCommandCenterContributions } from "./provider";
|
||||
import { useCommandCenterContributions } from "./provider";
|
||||
import {
|
||||
buildContributionSections,
|
||||
joinSubtitleParts,
|
||||
@@ -79,113 +73,9 @@ const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreg
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedPlus = withUnistyles(Plus, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const ThemedSettings = withUnistyles(Settings, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||
|
||||
function PlusIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedPlus size={size} strokeWidth={2.4} />;
|
||||
}
|
||||
|
||||
function SettingsIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedSettings size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function HomeIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedHome size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function resolveActionShortcutKeys(
|
||||
actionId: string | undefined,
|
||||
overrides: Record<string, string>,
|
||||
): ShortcutKey[][] | undefined {
|
||||
if (!actionId) return undefined;
|
||||
const platform = {
|
||||
isMac: getShortcutOs() === "mac",
|
||||
isDesktop: getIsElectronRuntime(),
|
||||
};
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (!bindingId) return undefined;
|
||||
const override = overrides[bindingId];
|
||||
if (override) return chordStringToShortcutKeys(override);
|
||||
const defaultKeys = getDefaultKeysForAction(actionId, platform);
|
||||
return defaultKeys ? [defaultKeys] : undefined;
|
||||
}
|
||||
|
||||
export function CommandCenterRootActions() {
|
||||
const { t } = useTranslation();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const openAddProject = useOpenAddProject();
|
||||
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
|
||||
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
|
||||
const actions = useMemo<CommandCenterContribution[]>(
|
||||
() => [
|
||||
{
|
||||
id: "new-agent",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 0,
|
||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
openAddProject();
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("shell.commandCenter.addProject"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: PlusIcon,
|
||||
shortcutKeys: resolveActionShortcutKeys("new-agent", overrides),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "home",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 1,
|
||||
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
router.push(homeRoute);
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("shell.commandCenter.home"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: HomeIcon,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 2,
|
||||
keywords: ["settings", "preferences", "config", "configuration"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
router.push(settingsRoute);
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("sidebar.actions.settings"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
},
|
||||
],
|
||||
[homeRoute, openAddProject, overrides, settingsRoute, t],
|
||||
);
|
||||
|
||||
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
|
||||
return null;
|
||||
}
|
||||
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
|
||||
|
||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||
@@ -205,6 +95,10 @@ function matchesQuery(searchText: string, query: string): boolean {
|
||||
return !normalized || searchText.includes(normalized);
|
||||
}
|
||||
|
||||
function limitDefaultCategoryResults<Result>(results: Result[], query: string): Result[] {
|
||||
return query.trim() ? results : results.slice(0, DEFAULT_CATEGORY_RESULT_LIMIT);
|
||||
}
|
||||
|
||||
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
|
||||
const { t } = useTranslation();
|
||||
const { agents } = useAggregatedAgents();
|
||||
@@ -249,10 +143,12 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
|
||||
const workspaceTitleByKey = new Map(
|
||||
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
|
||||
);
|
||||
const workspaces = allWorkspaces.filter((workspace) =>
|
||||
matchesQuery(workspace.searchText, query),
|
||||
const workspaces = limitDefaultCategoryResults(
|
||||
allWorkspaces.filter((workspace) => matchesQuery(workspace.searchText, query)),
|
||||
query,
|
||||
);
|
||||
const agentResults = agents
|
||||
const agentResults = limitDefaultCategoryResults(
|
||||
agents
|
||||
.map<CommandCenterAgentResult>((agent) => {
|
||||
const title = agent.title || t("shell.commandCenter.newAgent");
|
||||
const workspaceTitle = agent.workspaceId
|
||||
@@ -278,7 +174,9 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
|
||||
};
|
||||
})
|
||||
.filter((agent) => matchesQuery(agent.searchText, query))
|
||||
.sort((left, right) => sortAgents(left.agent, right.agent));
|
||||
.sort((left, right) => sortAgents(left.agent, right.agent)),
|
||||
query,
|
||||
);
|
||||
return [
|
||||
{
|
||||
id: "workspaces",
|
||||
@@ -570,19 +468,54 @@ export function CommandCenter() {
|
||||
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
||||
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
||||
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(null);
|
||||
const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 });
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: state.open,
|
||||
isEnabled: showBottomSheet,
|
||||
onClose: state.close,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const revealActiveResult = useCallback(() => {
|
||||
if (!state.open || !state.activeId) return;
|
||||
const index = state.rowIndexByResultId.get(state.activeId);
|
||||
if (index === undefined) return;
|
||||
const { offset, visibleLength } = scrollMetricsRef.current;
|
||||
if (visibleLength <= 0) return;
|
||||
const rowTop = state.offsets[index];
|
||||
const rowBottom = rowTop + state.rows[index].height;
|
||||
let nextOffset: number | null = null;
|
||||
if (rowTop < offset) nextOffset = rowTop;
|
||||
if (rowBottom > offset + visibleLength) nextOffset = rowBottom - visibleLength;
|
||||
if (nextOffset === null) return;
|
||||
const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current;
|
||||
ref?.scrollToIndex({ index, animated: true, viewPosition: 0.5 });
|
||||
}, [showBottomSheet, state.activeId, state.open, state.rowIndexByResultId]);
|
||||
const boundedOffset = Math.max(0, nextOffset);
|
||||
scrollMetricsRef.current.offset = boundedOffset;
|
||||
ref?.scrollToOffset({ offset: boundedOffset, animated: false });
|
||||
}, [
|
||||
showBottomSheet,
|
||||
state.activeId,
|
||||
state.offsets,
|
||||
state.open,
|
||||
state.rowIndexByResultId,
|
||||
state.rows,
|
||||
]);
|
||||
useEffect(() => {
|
||||
if (!state.open) {
|
||||
scrollMetricsRef.current = { offset: 0, visibleLength: 0 };
|
||||
return;
|
||||
}
|
||||
revealActiveResult();
|
||||
}, [revealActiveResult, state.open]);
|
||||
const handleListLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
scrollMetricsRef.current.visibleLength = event.nativeEvent.layout.height;
|
||||
revealActiveResult();
|
||||
},
|
||||
[revealActiveResult],
|
||||
);
|
||||
const handleListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
scrollMetricsRef.current.offset = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!showBottomSheet || !state.open) return;
|
||||
const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
|
||||
@@ -626,6 +559,9 @@ export function CommandCenter() {
|
||||
initialNumToRender: 12,
|
||||
maxToRenderPerBatch: 10,
|
||||
windowSize: 5,
|
||||
onLayout: handleListLayout,
|
||||
onScroll: handleListScroll,
|
||||
scrollEventThrottle: 16,
|
||||
};
|
||||
const keyPress = useCallback(
|
||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
|
||||
@@ -786,10 +722,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: 20,
|
||||
lineHeight: 18,
|
||||
flexShrink: 1,
|
||||
},
|
||||
subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 18 },
|
||||
subtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 16 },
|
||||
iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" },
|
||||
rowShortcut: { flexShrink: 0 },
|
||||
breadcrumb: {
|
||||
|
||||
@@ -72,6 +72,16 @@ describe("Command Center result projection", () => {
|
||||
expect(projection.offsets).toEqual([0, 32, 68, 117]);
|
||||
});
|
||||
|
||||
it("shows query-only actions only when their group matches a search", () => {
|
||||
const contributions = [
|
||||
contribution({ id: "settings", group: "actions", groupRank: 0 }),
|
||||
contribution({ id: "home", group: "actions", groupRank: 0, visibility: "query" }),
|
||||
];
|
||||
|
||||
expect(sectionResultIds(buildContributionSections(contributions, ""))).toEqual(["settings"]);
|
||||
expect(sectionResultIds(buildContributionSections(contributions, "home"))).toEqual(["home"]);
|
||||
});
|
||||
|
||||
it("preserves active selection by id and falls back to the first result", () => {
|
||||
const first = workspace("workspace:first");
|
||||
const second = workspace("workspace:second");
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useMemo } from "react";
|
||||
import { router, type Href } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
CalendarClock,
|
||||
FolderPlus,
|
||||
History,
|
||||
Home,
|
||||
Keyboard,
|
||||
Plus,
|
||||
Settings,
|
||||
} from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
buildOpenProjectRoute,
|
||||
buildSchedulesRoute,
|
||||
buildSessionsRoute,
|
||||
buildSettingsRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions";
|
||||
import { useCommandCenterActions } from "./provider";
|
||||
|
||||
const ThemedPlus = withUnistyles(Plus, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
const ThemedFolderPlus = withUnistyles(FolderPlus, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedHistory = withUnistyles(History, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedCalendarClock = withUnistyles(CalendarClock, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedKeyboard = withUnistyles(Keyboard, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedSettings = withUnistyles(Settings, (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
}));
|
||||
const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted }));
|
||||
|
||||
function PlusIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedPlus size={size} strokeWidth={2.4} />;
|
||||
}
|
||||
|
||||
function AddProjectIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedFolderPlus size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function SettingsIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedSettings size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function HistoryIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedHistory size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function SchedulesIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedCalendarClock size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function KeyboardIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedKeyboard size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
function HomeIcon({ size }: CommandCenterIconProps) {
|
||||
return <ThemedHome size={size} strokeWidth={2.2} />;
|
||||
}
|
||||
|
||||
export function CommandCenterRootActions() {
|
||||
const { t } = useTranslation();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const openAddProject = useOpenAddProject();
|
||||
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
|
||||
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
|
||||
const sessionsRoute = useMemo<Href>(() => buildSessionsRoute(), []);
|
||||
const schedulesRoute = useMemo<Href>(() => buildSchedulesRoute(), []);
|
||||
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
|
||||
const shortcutPlatform = useMemo(
|
||||
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
|
||||
[],
|
||||
);
|
||||
const actions = useMemo<CommandCenterContribution[]>(
|
||||
() => [
|
||||
{
|
||||
id: "add-project",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 0,
|
||||
keywords: ["open", "project", "folder", "workspace", "repo"],
|
||||
visibility: "query",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
openAddProject();
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("shell.commandCenter.addProject"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: AddProjectIcon,
|
||||
// The help id remains "new-agent" because user overrides are keyed by its binding id.
|
||||
shortcutKeys:
|
||||
resolveShortcutKeysForAction("new-agent", overrides, shortcutPlatform) ?? undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "new-workspace",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 1,
|
||||
keywords: ["new", "workspace", "worktree", "branch"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
keyboardActionDispatcher.dispatch({ id: "workspace.new", scope: "sidebar" });
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("sidebar.actions.newWorkspace"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: PlusIcon,
|
||||
shortcutKeys:
|
||||
resolveShortcutKeysForAction("new-workspace", overrides, shortcutPlatform) ?? undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "home",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 2,
|
||||
keywords: ["home", "start", "import", "session", "pair", "device", "providers"],
|
||||
visibility: "query",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
router.push(homeRoute);
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("shell.commandCenter.home"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: HomeIcon,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "history",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 3,
|
||||
keywords: ["history", "sessions", "recent"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
router.push(sessionsRoute);
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("sidebar.sections.sessions"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: HistoryIcon,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "schedules",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 4,
|
||||
keywords: ["schedules", "scheduled", "automation", "recurring"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
router.push(schedulesRoute);
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("sidebar.sections.schedules"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: SchedulesIcon,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 5,
|
||||
keywords: ["settings", "preferences", "config", "configuration"],
|
||||
visibility: "always",
|
||||
run: () => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
router.push(settingsRoute);
|
||||
},
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("sidebar.actions.settings"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: SettingsIcon,
|
||||
shortcutKeys:
|
||||
resolveShortcutKeysForAction("toggle-settings", overrides, shortcutPlatform) ??
|
||||
undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "keyboard-shortcuts",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
rank: 6,
|
||||
keywords: ["keyboard", "shortcuts", "keys", "hotkeys"],
|
||||
visibility: "always",
|
||||
run: () => setShortcutsDialogOpen(true),
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: t("sidebar.help.shortcuts"),
|
||||
sectionTitle: t("shell.commandCenter.actions"),
|
||||
icon: KeyboardIcon,
|
||||
shortcutKeys:
|
||||
resolveShortcutKeysForAction("show-shortcuts", overrides, shortcutPlatform) ??
|
||||
undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
homeRoute,
|
||||
openAddProject,
|
||||
overrides,
|
||||
schedulesRoute,
|
||||
sessionsRoute,
|
||||
setShortcutsDialogOpen,
|
||||
settingsRoute,
|
||||
shortcutPlatform,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GitAction, GitActions } from "@/git/policy";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import {
|
||||
buildWorkspaceCommandCenterContributions,
|
||||
type WorkspaceCommandCenterSource,
|
||||
} from "./workspace-contributions";
|
||||
|
||||
function gitAction(id: GitAction["id"], label: string): GitAction {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
pendingLabel: `${label} pending`,
|
||||
successLabel: `${label} complete`,
|
||||
disabled: false,
|
||||
status: "idle",
|
||||
startsGroup: false,
|
||||
handler: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function source(gitActions: GitActions): {
|
||||
value: WorkspaceCommandCenterSource;
|
||||
runGitActions: GitAction[];
|
||||
dispatched: KeyboardActionDefinition[];
|
||||
} {
|
||||
const runGitActions: GitAction[] = [];
|
||||
const dispatched: KeyboardActionDefinition[] = [];
|
||||
return {
|
||||
value: {
|
||||
gitActions,
|
||||
labels: {
|
||||
section: "Workspace actions",
|
||||
newAgent: "New agent",
|
||||
newTerminal: "New terminal",
|
||||
newBrowser: "New browser",
|
||||
splitRight: "Split pane right",
|
||||
splitDown: "Split pane down",
|
||||
},
|
||||
icons: {},
|
||||
shortcuts: {},
|
||||
capabilities: { canSplitPanes: true, canOpenBrowserTabs: true },
|
||||
dispatch: (action) => dispatched.push(action),
|
||||
runGitAction: (action) => runGitActions.push(action),
|
||||
},
|
||||
runGitActions,
|
||||
dispatched,
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace command center contributions", () => {
|
||||
it("makes only the policy-selected primary Git action default-visible and runs it", () => {
|
||||
const primary = gitAction("commit", "Commit");
|
||||
const fixture = source({
|
||||
primary,
|
||||
secondary: [gitAction("push", "Push")],
|
||||
menu: [],
|
||||
});
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
const gitContributions = contributions.filter((item) => item.id.startsWith("git:"));
|
||||
const defaultGitContributions = gitContributions.filter((item) => item.visibility === "always");
|
||||
|
||||
expect(defaultGitContributions.map((item) => item.id)).toEqual(["git:commit"]);
|
||||
defaultGitContributions[0].run();
|
||||
expect(fixture.runGitActions).toEqual([primary]);
|
||||
});
|
||||
|
||||
it("does not duplicate a primary action retained in the secondary policy list", () => {
|
||||
const primary = gitAction("pull", "Pull");
|
||||
const fixture = source({
|
||||
primary,
|
||||
secondary: [primary, gitAction("push", "Push")],
|
||||
menu: [],
|
||||
});
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
expect(contributions.filter((item) => item.id === "git:pull")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("orders New agent before Git and keeps terminal, browser, and splits search-only", () => {
|
||||
const fixture = source({
|
||||
primary: gitAction("commit", "Commit"),
|
||||
secondary: [],
|
||||
menu: [],
|
||||
});
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
expect(contributions.map(({ id, rank, visibility }) => ({ id, rank, visibility }))).toEqual([
|
||||
{ id: "tab:new-agent", rank: 0, visibility: "always" },
|
||||
{ id: "git:commit", rank: 1, visibility: "always" },
|
||||
{ id: "tab:new-terminal", rank: 2, visibility: "query" },
|
||||
{ id: "tab:new-browser", rank: 3, visibility: "query" },
|
||||
{ id: "pane:split-right", rank: 4, visibility: "query" },
|
||||
{ id: "pane:split-down", rank: 5, visibility: "query" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits browser and split actions when their existing capabilities are unavailable", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
fixture.value.capabilities = { canSplitPanes: false, canOpenBrowserTabs: false };
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
expect(contributions.map((item) => item.id)).toEqual(["tab:new-agent", "tab:new-terminal"]);
|
||||
});
|
||||
|
||||
it("dispatches every tab and pane command to the workspace scope", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
for (const contribution of contributions) contribution.run();
|
||||
|
||||
expect(fixture.dispatched).toEqual([
|
||||
{ id: "workspace.tab.new", scope: "workspace" },
|
||||
{ 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", () => {
|
||||
const fixture = source({ primary: null, secondary: [], menu: [] });
|
||||
|
||||
const contributions = buildWorkspaceCommandCenterContributions(fixture.value);
|
||||
|
||||
expect(contributions.map((item) => item.id)).toEqual([
|
||||
"tab:new-agent",
|
||||
"tab:new-terminal",
|
||||
"tab:new-browser",
|
||||
"pane:split-right",
|
||||
"pane:split-down",
|
||||
]);
|
||||
expect(contributions.some((item) => item.id.startsWith("git:"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { GitAction, GitActions } from "@/git/policy";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import type { CommandCenterContribution, CommandCenterIcon } from "./contributions";
|
||||
|
||||
export interface WorkspaceCommandCenterLabels {
|
||||
section: string;
|
||||
newAgent: string;
|
||||
newTerminal: string;
|
||||
newBrowser: string;
|
||||
splitRight: string;
|
||||
splitDown: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandCenterIcons {
|
||||
newAgent?: CommandCenterIcon;
|
||||
newTerminal?: CommandCenterIcon;
|
||||
newBrowser?: CommandCenterIcon;
|
||||
splitRight?: CommandCenterIcon;
|
||||
splitDown?: CommandCenterIcon;
|
||||
git?(action: GitAction): CommandCenterIcon | undefined;
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandCenterShortcuts {
|
||||
newAgent?: ShortcutKey[][];
|
||||
newTerminal?: ShortcutKey[][];
|
||||
splitRight?: ShortcutKey[][];
|
||||
splitDown?: ShortcutKey[][];
|
||||
archiveWorkspace?: ShortcutKey[][];
|
||||
}
|
||||
|
||||
export interface WorkspaceCommandCenterSource {
|
||||
gitActions: GitActions;
|
||||
labels: WorkspaceCommandCenterLabels;
|
||||
icons: WorkspaceCommandCenterIcons;
|
||||
shortcuts: WorkspaceCommandCenterShortcuts;
|
||||
capabilities: {
|
||||
canSplitPanes: boolean;
|
||||
canOpenBrowserTabs: boolean;
|
||||
};
|
||||
dispatch(action: KeyboardActionDefinition): void;
|
||||
runGitAction(action: GitAction): void;
|
||||
}
|
||||
|
||||
function buildGitContribution(
|
||||
source: WorkspaceCommandCenterSource,
|
||||
action: GitAction,
|
||||
rank: number,
|
||||
visibility: "always" | "query",
|
||||
): CommandCenterContribution {
|
||||
return {
|
||||
id: `git:${action.id}`,
|
||||
group: "workspace",
|
||||
groupRank: -1,
|
||||
rank,
|
||||
keywords: [action.id, "git"],
|
||||
visibility,
|
||||
run: () => source.runGitAction(action),
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: action.label,
|
||||
sectionTitle: source.labels.section,
|
||||
icon: source.icons.git?.(action),
|
||||
shortcutKeys:
|
||||
action.id === "archive-workspace" ? source.shortcuts.archiveWorkspace : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkspaceAction(input: {
|
||||
source: WorkspaceCommandCenterSource;
|
||||
id: string;
|
||||
rank: number;
|
||||
title: string;
|
||||
keywords: readonly string[];
|
||||
icon?: CommandCenterIcon;
|
||||
shortcutKeys?: ShortcutKey[][];
|
||||
action: KeyboardActionDefinition;
|
||||
visibility: "always" | "query";
|
||||
}): CommandCenterContribution {
|
||||
return {
|
||||
id: input.id,
|
||||
group: "workspace",
|
||||
groupRank: -1,
|
||||
rank: input.rank,
|
||||
keywords: input.keywords,
|
||||
visibility: input.visibility,
|
||||
run: () => input.source.dispatch(input.action),
|
||||
presentation: {
|
||||
kind: "action",
|
||||
title: input.title,
|
||||
sectionTitle: input.source.labels.section,
|
||||
icon: input.icon,
|
||||
shortcutKeys: input.shortcutKeys,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWorkspaceCommandCenterContributions(
|
||||
source: WorkspaceCommandCenterSource,
|
||||
): CommandCenterContribution[] {
|
||||
const contributions: CommandCenterContribution[] = [
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "tab:new-agent",
|
||||
rank: 0,
|
||||
title: source.labels.newAgent,
|
||||
keywords: ["tab", "new", "agent", "chat"],
|
||||
icon: source.icons.newAgent,
|
||||
shortcutKeys: source.shortcuts.newAgent,
|
||||
action: { id: "workspace.tab.new", scope: "workspace" },
|
||||
visibility: "always",
|
||||
}),
|
||||
];
|
||||
const primary = source.gitActions.primary;
|
||||
if (primary) contributions.push(buildGitContribution(source, primary, 1, "always"));
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "tab:new-terminal",
|
||||
rank: 2,
|
||||
title: source.labels.newTerminal,
|
||||
keywords: ["terminal", "shell", "console"],
|
||||
icon: source.icons.newTerminal,
|
||||
shortcutKeys: source.shortcuts.newTerminal,
|
||||
action: { id: "workspace.terminal.new", scope: "workspace" },
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
if (source.capabilities.canOpenBrowserTabs) {
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "tab:new-browser",
|
||||
rank: 3,
|
||||
title: source.labels.newBrowser,
|
||||
keywords: ["browser", "web", "preview"],
|
||||
icon: source.icons.newBrowser,
|
||||
action: { id: "workspace.browser.new", scope: "workspace" },
|
||||
visibility: "query",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (source.capabilities.canSplitPanes) {
|
||||
contributions.push(
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
id: "pane:split-right",
|
||||
rank: 4,
|
||||
title: source.labels.splitRight,
|
||||
keywords: ["split", "pane", "vertical"],
|
||||
icon: source.icons.splitRight,
|
||||
shortcutKeys: source.shortcuts.splitRight,
|
||||
action: { id: "workspace.pane.split.right", scope: "workspace" },
|
||||
visibility: "query",
|
||||
}),
|
||||
buildWorkspaceAction({
|
||||
source,
|
||||
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()) {
|
||||
if (action.id === primary?.id) continue;
|
||||
contributions.push(buildGitContribution(source, action, 10 + index, "query"));
|
||||
}
|
||||
return contributions;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Columns2, Globe, Rows2, SquarePen, SquareTerminal } from "lucide-react-native";
|
||||
import { getIsElectron } from "@/constants/platform";
|
||||
import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||
import { useGitActionRunner, useGitActions } from "@/git/use-actions";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { useWorkspaceDirectory } from "@/stores/session-store-hooks";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getCommandCenterIcon } from "./icon";
|
||||
import type { CommandCenterIcon } from "./contributions";
|
||||
import { useCommandCenterActions } from "./provider";
|
||||
import {
|
||||
buildWorkspaceCommandCenterContributions,
|
||||
type WorkspaceCommandCenterShortcuts,
|
||||
} from "./workspace-contributions";
|
||||
|
||||
const WORKSPACE_COMMAND_CENTER_ICONS = {
|
||||
newAgent: getCommandCenterIcon(SquarePen),
|
||||
newTerminal: getCommandCenterIcon(SquareTerminal),
|
||||
newBrowser: getCommandCenterIcon(Globe),
|
||||
splitRight: getCommandCenterIcon(Columns2),
|
||||
splitDown: getCommandCenterIcon(Rows2),
|
||||
};
|
||||
|
||||
function staticIcon(element: ReactElement | undefined): CommandCenterIcon | undefined {
|
||||
if (!element) return undefined;
|
||||
function StaticIcon() {
|
||||
return element;
|
||||
}
|
||||
return StaticIcon;
|
||||
}
|
||||
|
||||
function resolveWorkspaceShortcuts(
|
||||
overrides: Readonly<Record<string, string>>,
|
||||
): WorkspaceCommandCenterShortcuts {
|
||||
const platform = { isMac: getShortcutOs() === "mac", isDesktop: getIsElectron() };
|
||||
return {
|
||||
newAgent: resolveShortcutKeysForAction("workspace-tab-new", overrides, platform) ?? undefined,
|
||||
newTerminal:
|
||||
resolveShortcutKeysForAction("workspace-terminal-new", overrides, platform) ?? undefined,
|
||||
splitRight:
|
||||
resolveShortcutKeysForAction("workspace-pane-split-right", overrides, platform) ?? undefined,
|
||||
splitDown:
|
||||
resolveShortcutKeysForAction("workspace-pane-split-down", overrides, platform) ?? undefined,
|
||||
archiveWorkspace:
|
||||
resolveShortcutKeysForAction("archive-workspace", overrides, platform) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkspaceCommandCenterActions(): void {
|
||||
const { t } = useTranslation();
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const workspaceId = selection?.workspaceId ?? null;
|
||||
const cwd = useWorkspaceDirectory(serverId, workspaceId);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const { gitActions } = useGitActions({
|
||||
serverId: serverId ?? "",
|
||||
cwd: cwd ?? "",
|
||||
icons: GIT_ACTION_ICONS,
|
||||
});
|
||||
const runGitAction = useGitActionRunner();
|
||||
|
||||
const actions = useMemo(
|
||||
() =>
|
||||
buildWorkspaceCommandCenterContributions({
|
||||
gitActions,
|
||||
labels: {
|
||||
section: t("workspace.header.actions.workspaceActions"),
|
||||
newAgent: t("workspace.tabs.actions.newAgent"),
|
||||
newTerminal: t("workspace.tabs.actions.newTerminal"),
|
||||
newBrowser: t("workspace.tabs.actions.newBrowser"),
|
||||
splitRight: t("workspace.tabs.actions.splitRight"),
|
||||
splitDown: t("workspace.tabs.actions.splitDown"),
|
||||
},
|
||||
icons: {
|
||||
...WORKSPACE_COMMAND_CENTER_ICONS,
|
||||
git: (action) => staticIcon(action.icon),
|
||||
},
|
||||
shortcuts: resolveWorkspaceShortcuts(overrides),
|
||||
capabilities: {
|
||||
canSplitPanes: supportsDesktopPaneSplits() && !isCompact,
|
||||
canOpenBrowserTabs: getIsElectron(),
|
||||
},
|
||||
dispatch: (action) => {
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
keyboardActionDispatcher.dispatch(action);
|
||||
},
|
||||
runGitAction,
|
||||
}),
|
||||
[gitActions, isCompact, overrides, runGitAction, t],
|
||||
);
|
||||
|
||||
useCommandCenterActions({
|
||||
sourceId: "workspace",
|
||||
enabled: Boolean(serverId && cwd),
|
||||
actions,
|
||||
});
|
||||
}
|
||||
|
||||
export function CommandCenterWorkspaceActions() {
|
||||
useWorkspaceCommandCenterActions();
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
Archive,
|
||||
ArrowDownUp,
|
||||
Download,
|
||||
GitCommitHorizontal,
|
||||
GitMerge,
|
||||
RefreshCcw,
|
||||
Upload,
|
||||
} from "lucide-react-native";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||
const ThemedDownload = withUnistyles(Download);
|
||||
const ThemedUpload = withUnistyles(Upload);
|
||||
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
|
||||
const ThemedGitMerge = withUnistyles(GitMerge);
|
||||
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
export const GIT_ACTION_ICONS = {
|
||||
commit: <ThemedGitCommitHorizontal size={16} uniProps={mutedColorMapping} />,
|
||||
pull: <ThemedDownload size={16} uniProps={mutedColorMapping} />,
|
||||
push: <ThemedUpload size={16} uniProps={mutedColorMapping} />,
|
||||
pullAndPush: <ThemedArrowDownUp size={16} uniProps={mutedColorMapping} />,
|
||||
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
|
||||
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />,
|
||||
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { ChevronDown, Info, MoreVertical } from "lucide-react-native";
|
||||
import { ChevronDown, MoreVertical } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -15,8 +15,8 @@ import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import type { GitAction, GitActions } from "@/git/policy";
|
||||
import { useGitActionRunner } from "@/git/use-actions";
|
||||
|
||||
interface GitActionsSplitButtonProps {
|
||||
gitActions: GitActions;
|
||||
@@ -76,7 +76,7 @@ function GitActionMenuItem({
|
||||
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const runGitAction = useGitActionRunner();
|
||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||
|
||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||
@@ -85,26 +85,12 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
return action.label;
|
||||
}, []);
|
||||
|
||||
const handleActionSelect = useCallback(
|
||||
(action: GitAction) => {
|
||||
if (action.unavailableMessage) {
|
||||
toast.show(action.unavailableMessage, {
|
||||
durationMs: 3200,
|
||||
icon: <Info size={16} color={theme.colors.foreground} />,
|
||||
});
|
||||
return;
|
||||
}
|
||||
action.handler();
|
||||
},
|
||||
[theme.colors.foreground, toast],
|
||||
);
|
||||
|
||||
const handlePrimaryPress = useCallback(() => {
|
||||
if (!gitActions.primary) {
|
||||
return;
|
||||
}
|
||||
handleActionSelect(gitActions.primary);
|
||||
}, [gitActions.primary, handleActionSelect]);
|
||||
runGitAction(gitActions.primary);
|
||||
}, [gitActions.primary, runGitAction]);
|
||||
|
||||
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
|
||||
|
||||
@@ -172,7 +158,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
<GitActionMenuItem
|
||||
key={action.id}
|
||||
action={action}
|
||||
onSelect={handleActionSelect}
|
||||
onSelect={runGitAction}
|
||||
archiveShortcutKeys={archiveShortcutKeys}
|
||||
needsSeparator={action.startsGroup}
|
||||
showSeparator={index > 0}
|
||||
@@ -205,7 +191,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
||||
<GitActionMenuItem
|
||||
key={action.id}
|
||||
action={action}
|
||||
onSelect={handleActionSelect}
|
||||
onSelect={runGitAction}
|
||||
closeOnSelect={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -30,22 +30,15 @@ import { BORDER_WIDTH, ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import {
|
||||
AlignJustify,
|
||||
Archive,
|
||||
ArrowDownUp,
|
||||
ChevronDown,
|
||||
Columns2,
|
||||
Download,
|
||||
FolderTree,
|
||||
GitCommitHorizontal,
|
||||
GitMerge,
|
||||
List,
|
||||
ListChevronsDownUp,
|
||||
ListChevronsUpDown,
|
||||
Maximize2,
|
||||
Pilcrow,
|
||||
RefreshCcw,
|
||||
RotateCw,
|
||||
Upload,
|
||||
WrapText,
|
||||
} from "lucide-react-native";
|
||||
import { type ParsedDiffFile, type DiffLine, type HighlightToken } from "@/git/use-diff-query";
|
||||
@@ -90,6 +83,7 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import { BranchSwitcher } from "@/components/branch-switcher";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
|
||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
|
||||
@@ -1330,13 +1324,6 @@ const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
|
||||
const ThemedFolderTree = withUnistyles(FolderTree);
|
||||
const ThemedList = withUnistyles(List);
|
||||
const ThemedMaximize2 = withUnistyles(Maximize2);
|
||||
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||
const ThemedDownload = withUnistyles(Download);
|
||||
const ThemedUpload = withUnistyles(Upload);
|
||||
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
|
||||
const ThemedGitMerge = withUnistyles(GitMerge);
|
||||
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const DIFF_OPTIONS_WHITESPACE_ICON = (
|
||||
<ThemedPilcrow size={14} uniProps={foregroundMutedIconColorMapping} />
|
||||
@@ -2823,22 +2810,10 @@ export function GitDiffPane({
|
||||
() => computeBaseRefLabel(baseRef, t("workspace.git.diff.base")),
|
||||
[baseRef, t],
|
||||
);
|
||||
const gitActionsIcons = useMemo(
|
||||
() => ({
|
||||
commit: <ThemedGitCommitHorizontal size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
pull: <ThemedDownload size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
push: <ThemedUpload size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
pullAndPush: <ThemedArrowDownUp size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
merge: <ThemedGitMerge size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
archive: <ThemedArchive size={16} uniProps={foregroundMutedIconColorMapping} />,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const { gitActions, branchLabel } = useGitActions({
|
||||
serverId,
|
||||
cwd,
|
||||
icons: gitActionsIcons,
|
||||
icons: GIT_ACTION_ICONS,
|
||||
});
|
||||
const committedDiffDescription = useMemo(
|
||||
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
|
||||
import { Info } from "lucide-react-native";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -31,6 +33,26 @@ import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||
|
||||
const forgeMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const ThemedInfo = withUnistyles(Info, (theme) => ({ color: theme.colors.foreground }));
|
||||
|
||||
export function useGitActionRunner(): (action: GitAction) => void {
|
||||
const toast = useToast();
|
||||
|
||||
return useCallback(
|
||||
(action: GitAction) => {
|
||||
if (action.disabled) return;
|
||||
if (action.unavailableMessage) {
|
||||
toast.show(action.unavailableMessage, {
|
||||
durationMs: 3200,
|
||||
icon: <ThemedInfo size={16} />,
|
||||
});
|
||||
return;
|
||||
}
|
||||
action.handler();
|
||||
},
|
||||
[toast],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The leading icon for every change-request action (create/view/merge) is the
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
Archive,
|
||||
ArrowDownUp,
|
||||
Download,
|
||||
GitCommitHorizontal,
|
||||
GitMerge,
|
||||
RefreshCcw,
|
||||
Upload,
|
||||
} from "lucide-react-native";
|
||||
import { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||
import { useGitActions } from "@/git/use-actions";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||
|
||||
interface WorkspaceActionsProps {
|
||||
serverId: string;
|
||||
@@ -18,33 +8,11 @@ interface WorkspaceActionsProps {
|
||||
hideLabels?: boolean;
|
||||
}
|
||||
|
||||
const ThemedGitCommitHorizontal = withUnistyles(GitCommitHorizontal);
|
||||
const ThemedDownload = withUnistyles(Download);
|
||||
const ThemedUpload = withUnistyles(Upload);
|
||||
const ThemedArrowDownUp = withUnistyles(ArrowDownUp);
|
||||
const ThemedGitMerge = withUnistyles(GitMerge);
|
||||
const ThemedRefreshCcw = withUnistyles(RefreshCcw);
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
|
||||
const ICONS = {
|
||||
commit: <ThemedGitCommitHorizontal size={16} uniProps={mutedColorMapping} />,
|
||||
pull: <ThemedDownload size={16} uniProps={mutedColorMapping} />,
|
||||
push: <ThemedUpload size={16} uniProps={mutedColorMapping} />,
|
||||
pullAndPush: <ThemedArrowDownUp size={16} uniProps={mutedColorMapping} />,
|
||||
merge: <ThemedGitMerge size={16} uniProps={mutedColorMapping} />,
|
||||
mergeFromBase: <ThemedRefreshCcw size={16} uniProps={mutedColorMapping} />,
|
||||
archive: <ThemedArchive size={16} uniProps={mutedColorMapping} />,
|
||||
};
|
||||
|
||||
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
|
||||
const { gitActions } = useGitActions({
|
||||
serverId,
|
||||
cwd,
|
||||
icons: ICONS,
|
||||
icons: GIT_ACTION_ICONS,
|
||||
});
|
||||
|
||||
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo } from "react";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
@@ -12,16 +11,9 @@ export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
|
||||
return useMemo(() => {
|
||||
const platform = { isMac, isDesktop: isDesktopApp };
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (!bindingId) return null;
|
||||
|
||||
const override = overrides[bindingId];
|
||||
if (override) {
|
||||
return chordStringToShortcutKeys(override);
|
||||
}
|
||||
|
||||
const defaultKeys = getDefaultKeysForAction(actionId, platform);
|
||||
return defaultKeys ? [defaultKeys] : null;
|
||||
return resolveShortcutKeysForAction(actionId, overrides, {
|
||||
isMac,
|
||||
isDesktop: isDesktopApp,
|
||||
});
|
||||
}, [actionId, overrides, isMac, isDesktopApp]);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export type KeyboardActionId =
|
||||
| "workspace.pane.close"
|
||||
| "workspace.focus.toggle"
|
||||
| "workspace.terminal.new"
|
||||
| "workspace.browser.new"
|
||||
| "sidebar.toggle.right"
|
||||
| "workspace.new"
|
||||
| "workspace.project.pick"
|
||||
@@ -61,6 +62,7 @@ export type KeyboardActionDefinition =
|
||||
| { id: "workspace.pane.close"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.focus.toggle"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.browser.new"; scope: KeyboardActionScope }
|
||||
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.new"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.project.pick"; scope: KeyboardActionScope }
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
MessageInputKeyboardActionKind,
|
||||
} from "@/keyboard/actions";
|
||||
import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string";
|
||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
|
||||
export type { KeyCombo } from "@/keyboard/shortcut-string";
|
||||
|
||||
@@ -1406,6 +1407,19 @@ export function getDefaultKeysForAction(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveShortcutKeysForAction(
|
||||
actionId: string,
|
||||
overrides: Readonly<Record<string, string>>,
|
||||
platform: { isMac: boolean; isDesktop: boolean },
|
||||
): ShortcutKey[][] | null {
|
||||
const bindingId = getBindingIdForAction(actionId, platform);
|
||||
if (!bindingId) return null;
|
||||
const override = overrides[bindingId];
|
||||
if (override) return chordStringToShortcutKeys(override);
|
||||
const defaultKeys = getDefaultKeysForAction(actionId, platform);
|
||||
return defaultKeys ? [defaultKeys] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `KeyboardEvent.key` whose hold reveals the sidebar workspace-jump number
|
||||
* badges. It must match the modifier of the active `workspace.navigate.index`
|
||||
|
||||
@@ -3048,6 +3048,9 @@ function WorkspaceScreenContent({
|
||||
case "workspace.terminal.new":
|
||||
handleCreateTerminal();
|
||||
return true;
|
||||
case "workspace.browser.new":
|
||||
handleCreateBrowserTab();
|
||||
return true;
|
||||
case "workspace.tab.close-current":
|
||||
if (activeTabId) {
|
||||
void handleCloseTabById(activeTabId);
|
||||
@@ -3080,6 +3083,7 @@ function WorkspaceScreenContent({
|
||||
activeTabId,
|
||||
handleCloseTabById,
|
||||
handleCreateDraftTab,
|
||||
handleCreateBrowserTab,
|
||||
handleCreateTerminal,
|
||||
navigateToTabId,
|
||||
tabs,
|
||||
@@ -3194,6 +3198,7 @@ function WorkspaceScreenContent({
|
||||
"workspace.tab.navigate-index",
|
||||
"workspace.tab.navigate-relative",
|
||||
"workspace.terminal.new",
|
||||
"workspace.browser.new",
|
||||
] as const,
|
||||
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
||||
priority: 100,
|
||||
|
||||
Reference in New Issue
Block a user