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 page.keyboard.press("ControlOrMeta+K");
|
||||||
await expect(commandCenter).toBeVisible({ timeout: 10_000 });
|
await expect(commandCenter).toBeVisible({ timeout: 10_000 });
|
||||||
|
await commandCenter.getByTestId("command-center-input").fill("add project");
|
||||||
await commandCenter.getByText("Add project", { exact: true }).click();
|
await commandCenter.getByText("Add project", { exact: true }).click();
|
||||||
const addProject = page.getByTestId("add-project-flow");
|
const addProject = page.getByTestId("add-project-flow");
|
||||||
await expect(addProject).toBeVisible({ timeout: 10_000 });
|
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 { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
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 { CommandCenterProvider } from "@/command-center/provider";
|
||||||
|
import { CommandCenterWorkspaceActions } from "@/command-center/workspace-registration";
|
||||||
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
|
import { AddProjectFlowHost } from "@/components/add-project-flow-host";
|
||||||
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
|
import { WorktreeSetupCalloutSource } from "@/components/worktree-setup-callout-source";
|
||||||
import { DownloadToast } from "@/components/download-toast";
|
import { DownloadToast } from "@/components/download-toast";
|
||||||
@@ -555,6 +557,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
<UpdateCalloutSource />
|
<UpdateCalloutSource />
|
||||||
<WorktreeSetupCalloutSource />
|
<WorktreeSetupCalloutSource />
|
||||||
<CommandCenterRootActions />
|
<CommandCenterRootActions />
|
||||||
|
<CommandCenterWorkspaceActions />
|
||||||
<WorkspacePinShortcutHandler />
|
<WorkspacePinShortcutHandler />
|
||||||
<CommandCenter />
|
<CommandCenter />
|
||||||
<AddProjectFlowHost />
|
<AddProjectFlowHost />
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
View,
|
View,
|
||||||
|
type LayoutChangeEvent,
|
||||||
type ListRenderItemInfo,
|
type ListRenderItemInfo,
|
||||||
|
type NativeScrollEvent,
|
||||||
|
type NativeSyntheticEvent,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { router, type Href } from "expo-router";
|
|
||||||
import { useTranslation } from "react-i18next";
|
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 { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
BottomSheetBackdrop,
|
BottomSheetBackdrop,
|
||||||
@@ -25,11 +27,9 @@ import {
|
|||||||
IsolatedBottomSheetModal,
|
IsolatedBottomSheetModal,
|
||||||
useIsolatedBottomSheetVisibility,
|
useIsolatedBottomSheetVisibility,
|
||||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
} 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 { isNative, isWeb } from "@/constants/platform";
|
||||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
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 { useProjects } from "@/hooks/use-projects";
|
||||||
import {
|
import {
|
||||||
OverlayLayerProvider,
|
OverlayLayerProvider,
|
||||||
@@ -39,22 +39,16 @@ import {
|
|||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-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 { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import {
|
import {
|
||||||
clearCommandCenterFocusRestoreElement,
|
clearCommandCenterFocusRestoreElement,
|
||||||
takeCommandCenterFocusRestoreElement,
|
takeCommandCenterFocusRestoreElement,
|
||||||
} from "@/utils/command-center-focus-restore";
|
} from "@/utils/command-center-focus-restore";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
|
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { useCommandCenterContributions } from "./provider";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
|
||||||
import type { CommandCenterContribution, CommandCenterIconProps } from "./contributions";
|
|
||||||
import { useCommandCenterActions, useCommandCenterContributions } from "./provider";
|
|
||||||
import {
|
import {
|
||||||
buildContributionSections,
|
buildContributionSections,
|
||||||
joinSubtitleParts,
|
joinSubtitleParts,
|
||||||
@@ -79,113 +73,9 @@ const ThemedCheck = withUnistyles(Check, (theme) => ({ color: theme.colors.foreg
|
|||||||
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
|
const ThemedChevronRight = withUnistyles(ChevronRight, (theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
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 COMMAND_CENTER_SNAP_POINTS = ["60%", "90%"];
|
||||||
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
const KEYBOARD_SHOULD_PERSIST_TAPS = "always" as const;
|
||||||
|
const DEFAULT_CATEGORY_RESULT_LIMIT = 5;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||||
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
const leftNeedsInput = (left.pendingPermissionCount ?? 0) > 0 ? 1 : 0;
|
||||||
@@ -205,6 +95,10 @@ function matchesQuery(searchText: string, query: string): boolean {
|
|||||||
return !normalized || searchText.includes(normalized);
|
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[] {
|
function useBuiltInSections(open: boolean, query: string): CommandCenterResultSection[] {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { agents } = useAggregatedAgents();
|
const { agents } = useAggregatedAgents();
|
||||||
@@ -249,36 +143,40 @@ function useBuiltInSections(open: boolean, query: string): CommandCenterResultSe
|
|||||||
const workspaceTitleByKey = new Map(
|
const workspaceTitleByKey = new Map(
|
||||||
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
|
allWorkspaces.map((workspace) => [workspace.id.slice("workspace:".length), workspace.title]),
|
||||||
);
|
);
|
||||||
const workspaces = allWorkspaces.filter((workspace) =>
|
const workspaces = limitDefaultCategoryResults(
|
||||||
matchesQuery(workspace.searchText, query),
|
allWorkspaces.filter((workspace) => matchesQuery(workspace.searchText, query)),
|
||||||
|
query,
|
||||||
|
);
|
||||||
|
const agentResults = limitDefaultCategoryResults(
|
||||||
|
agents
|
||||||
|
.map<CommandCenterAgentResult>((agent) => {
|
||||||
|
const title = agent.title || t("shell.commandCenter.newAgent");
|
||||||
|
const workspaceTitle = agent.workspaceId
|
||||||
|
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
||||||
|
: undefined;
|
||||||
|
const location = workspaceTitle ?? shortenPath(agent.cwd);
|
||||||
|
const subtitle = joinSubtitleParts([
|
||||||
|
showHost ? agent.serverLabel : null,
|
||||||
|
location,
|
||||||
|
formatTimeAgo(agent.lastActivityAt),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
kind: "agent",
|
||||||
|
id: `agent:${agent.serverId}:${agent.id}`,
|
||||||
|
agent,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(),
|
||||||
|
run: () => {
|
||||||
|
clearCommandCenterFocusRestoreElement();
|
||||||
|
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((agent) => matchesQuery(agent.searchText, query))
|
||||||
|
.sort((left, right) => sortAgents(left.agent, right.agent)),
|
||||||
|
query,
|
||||||
);
|
);
|
||||||
const agentResults = agents
|
|
||||||
.map<CommandCenterAgentResult>((agent) => {
|
|
||||||
const title = agent.title || t("shell.commandCenter.newAgent");
|
|
||||||
const workspaceTitle = agent.workspaceId
|
|
||||||
? workspaceTitleByKey.get(`${agent.serverId}:${agent.workspaceId}`)
|
|
||||||
: undefined;
|
|
||||||
const location = workspaceTitle ?? shortenPath(agent.cwd);
|
|
||||||
const subtitle = joinSubtitleParts([
|
|
||||||
showHost ? agent.serverLabel : null,
|
|
||||||
location,
|
|
||||||
formatTimeAgo(agent.lastActivityAt),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
kind: "agent",
|
|
||||||
id: `agent:${agent.serverId}:${agent.id}`,
|
|
||||||
agent,
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
searchText: `${title} ${subtitle} ${agent.cwd}`.toLowerCase(),
|
|
||||||
run: () => {
|
|
||||||
clearCommandCenterFocusRestoreElement();
|
|
||||||
navigateToAgent({ serverId: agent.serverId, agentId: agent.id });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((agent) => matchesQuery(agent.searchText, query))
|
|
||||||
.sort((left, right) => sortAgents(left.agent, right.agent));
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: "workspaces",
|
id: "workspaces",
|
||||||
@@ -570,19 +468,54 @@ export function CommandCenter() {
|
|||||||
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
const listRef = useRef<FlatList<CommandCenterListRow>>(null);
|
||||||
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
const bottomSheetListRef = useRef<BottomSheetFlatListMethods>(null);
|
||||||
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(null);
|
const bottomSheetInputRef = useRef<React.ElementRef<typeof BottomSheetTextInput>>(null);
|
||||||
|
const scrollMetricsRef = useRef({ offset: 0, visibleLength: 0 });
|
||||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||||
visible: state.open,
|
visible: state.open,
|
||||||
isEnabled: showBottomSheet,
|
isEnabled: showBottomSheet,
|
||||||
onClose: state.close,
|
onClose: state.close,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const revealActiveResult = useCallback(() => {
|
||||||
if (!state.open || !state.activeId) return;
|
if (!state.open || !state.activeId) return;
|
||||||
const index = state.rowIndexByResultId.get(state.activeId);
|
const index = state.rowIndexByResultId.get(state.activeId);
|
||||||
if (index === undefined) return;
|
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;
|
const ref = showBottomSheet ? bottomSheetListRef.current : listRef.current;
|
||||||
ref?.scrollToIndex({ index, animated: true, viewPosition: 0.5 });
|
const boundedOffset = Math.max(0, nextOffset);
|
||||||
}, [showBottomSheet, state.activeId, state.open, state.rowIndexByResultId]);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!showBottomSheet || !state.open) return;
|
if (!showBottomSheet || !state.open) return;
|
||||||
const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
|
const timer = setTimeout(() => bottomSheetInputRef.current?.focus(), 300);
|
||||||
@@ -626,6 +559,9 @@ export function CommandCenter() {
|
|||||||
initialNumToRender: 12,
|
initialNumToRender: 12,
|
||||||
maxToRenderPerBatch: 10,
|
maxToRenderPerBatch: 10,
|
||||||
windowSize: 5,
|
windowSize: 5,
|
||||||
|
onLayout: handleListLayout,
|
||||||
|
onScroll: handleListScroll,
|
||||||
|
scrollEventThrottle: 16,
|
||||||
};
|
};
|
||||||
const keyPress = useCallback(
|
const keyPress = useCallback(
|
||||||
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
|
({ nativeEvent: { key } }: { nativeEvent: { key: string } }) => state.key(key),
|
||||||
@@ -786,10 +722,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
title: {
|
title: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
lineHeight: 20,
|
lineHeight: 18,
|
||||||
flexShrink: 1,
|
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" },
|
iconSlot: { width: 16, height: 20, alignItems: "center", justifyContent: "center" },
|
||||||
rowShortcut: { flexShrink: 0 },
|
rowShortcut: { flexShrink: 0 },
|
||||||
breadcrumb: {
|
breadcrumb: {
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ describe("Command Center result projection", () => {
|
|||||||
expect(projection.offsets).toEqual([0, 32, 68, 117]);
|
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", () => {
|
it("preserves active selection by id and falls back to the first result", () => {
|
||||||
const first = workspace("workspace:first");
|
const first = workspace("workspace:first");
|
||||||
const second = workspace("workspace:second");
|
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 { useCallback, useMemo } from "react";
|
||||||
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
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 { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -15,8 +15,8 @@ import { Shortcut } from "@/components/ui/shortcut";
|
|||||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { useToast } from "@/contexts/toast-context";
|
|
||||||
import type { GitAction, GitActions } from "@/git/policy";
|
import type { GitAction, GitActions } from "@/git/policy";
|
||||||
|
import { useGitActionRunner } from "@/git/use-actions";
|
||||||
|
|
||||||
interface GitActionsSplitButtonProps {
|
interface GitActionsSplitButtonProps {
|
||||||
gitActions: GitActions;
|
gitActions: GitActions;
|
||||||
@@ -76,7 +76,7 @@ function GitActionMenuItem({
|
|||||||
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
|
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toast = useToast();
|
const runGitAction = useGitActionRunner();
|
||||||
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
||||||
|
|
||||||
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
||||||
@@ -85,26 +85,12 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
|||||||
return action.label;
|
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(() => {
|
const handlePrimaryPress = useCallback(() => {
|
||||||
if (!gitActions.primary) {
|
if (!gitActions.primary) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleActionSelect(gitActions.primary);
|
runGitAction(gitActions.primary);
|
||||||
}, [gitActions.primary, handleActionSelect]);
|
}, [gitActions.primary, runGitAction]);
|
||||||
|
|
||||||
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
|
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
|
||||||
|
|
||||||
@@ -172,7 +158,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
|||||||
<GitActionMenuItem
|
<GitActionMenuItem
|
||||||
key={action.id}
|
key={action.id}
|
||||||
action={action}
|
action={action}
|
||||||
onSelect={handleActionSelect}
|
onSelect={runGitAction}
|
||||||
archiveShortcutKeys={archiveShortcutKeys}
|
archiveShortcutKeys={archiveShortcutKeys}
|
||||||
needsSeparator={action.startsGroup}
|
needsSeparator={action.startsGroup}
|
||||||
showSeparator={index > 0}
|
showSeparator={index > 0}
|
||||||
@@ -205,7 +191,7 @@ export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSpli
|
|||||||
<GitActionMenuItem
|
<GitActionMenuItem
|
||||||
key={action.id}
|
key={action.id}
|
||||||
action={action}
|
action={action}
|
||||||
onSelect={handleActionSelect}
|
onSelect={runGitAction}
|
||||||
closeOnSelect={false}
|
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 { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||||
import {
|
import {
|
||||||
AlignJustify,
|
AlignJustify,
|
||||||
Archive,
|
|
||||||
ArrowDownUp,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Columns2,
|
Columns2,
|
||||||
Download,
|
|
||||||
FolderTree,
|
FolderTree,
|
||||||
GitCommitHorizontal,
|
|
||||||
GitMerge,
|
|
||||||
List,
|
List,
|
||||||
ListChevronsDownUp,
|
ListChevronsDownUp,
|
||||||
ListChevronsUpDown,
|
ListChevronsUpDown,
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Pilcrow,
|
Pilcrow,
|
||||||
RefreshCcw,
|
|
||||||
RotateCw,
|
RotateCw,
|
||||||
Upload,
|
|
||||||
WrapText,
|
WrapText,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { type ParsedDiffFile, type DiffLine, type HighlightToken } from "@/git/use-diff-query";
|
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 { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||||
import { BranchSwitcher } from "@/components/branch-switcher";
|
import { BranchSwitcher } from "@/components/branch-switcher";
|
||||||
import { useGitActions } from "@/git/use-actions";
|
import { useGitActions } from "@/git/use-actions";
|
||||||
|
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||||
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
|
import { buildForgeSignInCommand, getForgePresentation, type Forge } from "@/git/forge";
|
||||||
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
|
||||||
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
|
import type { ForgeAuthState } from "@getpaseo/protocol/messages";
|
||||||
@@ -1330,13 +1324,6 @@ const ThemedListChevronsUpDown = withUnistyles(ListChevronsUpDown);
|
|||||||
const ThemedFolderTree = withUnistyles(FolderTree);
|
const ThemedFolderTree = withUnistyles(FolderTree);
|
||||||
const ThemedList = withUnistyles(List);
|
const ThemedList = withUnistyles(List);
|
||||||
const ThemedMaximize2 = withUnistyles(Maximize2);
|
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 ThemedChevronDown = withUnistyles(ChevronDown);
|
||||||
const DIFF_OPTIONS_WHITESPACE_ICON = (
|
const DIFF_OPTIONS_WHITESPACE_ICON = (
|
||||||
<ThemedPilcrow size={14} uniProps={foregroundMutedIconColorMapping} />
|
<ThemedPilcrow size={14} uniProps={foregroundMutedIconColorMapping} />
|
||||||
@@ -2823,22 +2810,10 @@ export function GitDiffPane({
|
|||||||
() => computeBaseRefLabel(baseRef, t("workspace.git.diff.base")),
|
() => computeBaseRefLabel(baseRef, t("workspace.git.diff.base")),
|
||||||
[baseRef, t],
|
[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({
|
const { gitActions, branchLabel } = useGitActions({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
icons: gitActionsIcons,
|
icons: GIT_ACTION_ICONS,
|
||||||
});
|
});
|
||||||
const committedDiffDescription = useMemo(
|
const committedDiffDescription = useMemo(
|
||||||
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
|
() => computeCommittedDiffDescription(branchLabel, baseRefLabel),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
|
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 AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
@@ -31,6 +33,26 @@ import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
|||||||
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
export type { GitActionId, GitAction, GitActions } from "@/git/policy";
|
||||||
|
|
||||||
const forgeMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
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
|
* 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 { GitActionsSplitButton } from "@/git/actions-split-button";
|
||||||
import { useGitActions } from "@/git/use-actions";
|
import { useGitActions } from "@/git/use-actions";
|
||||||
import type { Theme } from "@/styles/theme";
|
import { GIT_ACTION_ICONS } from "@/git/action-icons";
|
||||||
|
|
||||||
interface WorkspaceActionsProps {
|
interface WorkspaceActionsProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -18,33 +8,11 @@ interface WorkspaceActionsProps {
|
|||||||
hideLabels?: boolean;
|
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) {
|
export function WorkspaceActions({ serverId, cwd, hideLabels }: WorkspaceActionsProps) {
|
||||||
const { gitActions } = useGitActions({
|
const { gitActions } = useGitActions({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
icons: ICONS,
|
icons: GIT_ACTION_ICONS,
|
||||||
});
|
});
|
||||||
|
|
||||||
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
|
return <GitActionsSplitButton gitActions={gitActions} hideLabels={hideLabels} />;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
|
||||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import { getIsElectronRuntime } from "@/constants/layout";
|
import { getIsElectronRuntime } from "@/constants/layout";
|
||||||
@@ -12,16 +11,9 @@ export function useShortcutKeys(actionId: string): ShortcutKey[][] | null {
|
|||||||
const isDesktopApp = getIsElectronRuntime();
|
const isDesktopApp = getIsElectronRuntime();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const platform = { isMac, isDesktop: isDesktopApp };
|
return resolveShortcutKeysForAction(actionId, overrides, {
|
||||||
const bindingId = getBindingIdForAction(actionId, platform);
|
isMac,
|
||||||
if (!bindingId) return null;
|
isDesktop: isDesktopApp,
|
||||||
|
});
|
||||||
const override = overrides[bindingId];
|
|
||||||
if (override) {
|
|
||||||
return chordStringToShortcutKeys(override);
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultKeys = getDefaultKeysForAction(actionId, platform);
|
|
||||||
return defaultKeys ? [defaultKeys] : null;
|
|
||||||
}, [actionId, overrides, isMac, isDesktopApp]);
|
}, [actionId, overrides, isMac, isDesktopApp]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type KeyboardActionId =
|
|||||||
| "workspace.pane.close"
|
| "workspace.pane.close"
|
||||||
| "workspace.focus.toggle"
|
| "workspace.focus.toggle"
|
||||||
| "workspace.terminal.new"
|
| "workspace.terminal.new"
|
||||||
|
| "workspace.browser.new"
|
||||||
| "sidebar.toggle.right"
|
| "sidebar.toggle.right"
|
||||||
| "workspace.new"
|
| "workspace.new"
|
||||||
| "workspace.project.pick"
|
| "workspace.project.pick"
|
||||||
@@ -61,6 +62,7 @@ export type KeyboardActionDefinition =
|
|||||||
| { id: "workspace.pane.close"; scope: KeyboardActionScope }
|
| { id: "workspace.pane.close"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.focus.toggle"; scope: KeyboardActionScope }
|
| { id: "workspace.focus.toggle"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
|
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
|
||||||
|
| { id: "workspace.browser.new"; scope: KeyboardActionScope }
|
||||||
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
|
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.new"; scope: KeyboardActionScope }
|
| { id: "workspace.new"; scope: KeyboardActionScope }
|
||||||
| { id: "workspace.project.pick"; scope: KeyboardActionScope }
|
| { id: "workspace.project.pick"; scope: KeyboardActionScope }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
MessageInputKeyboardActionKind,
|
MessageInputKeyboardActionKind,
|
||||||
} from "@/keyboard/actions";
|
} from "@/keyboard/actions";
|
||||||
import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string";
|
import { type KeyCombo, parseChordString } from "@/keyboard/shortcut-string";
|
||||||
|
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||||
|
|
||||||
export type { KeyCombo } from "@/keyboard/shortcut-string";
|
export type { KeyCombo } from "@/keyboard/shortcut-string";
|
||||||
|
|
||||||
@@ -1406,6 +1407,19 @@ export function getDefaultKeysForAction(
|
|||||||
return null;
|
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
|
* The `KeyboardEvent.key` whose hold reveals the sidebar workspace-jump number
|
||||||
* badges. It must match the modifier of the active `workspace.navigate.index`
|
* badges. It must match the modifier of the active `workspace.navigate.index`
|
||||||
|
|||||||
@@ -3048,6 +3048,9 @@ function WorkspaceScreenContent({
|
|||||||
case "workspace.terminal.new":
|
case "workspace.terminal.new":
|
||||||
handleCreateTerminal();
|
handleCreateTerminal();
|
||||||
return true;
|
return true;
|
||||||
|
case "workspace.browser.new":
|
||||||
|
handleCreateBrowserTab();
|
||||||
|
return true;
|
||||||
case "workspace.tab.close-current":
|
case "workspace.tab.close-current":
|
||||||
if (activeTabId) {
|
if (activeTabId) {
|
||||||
void handleCloseTabById(activeTabId);
|
void handleCloseTabById(activeTabId);
|
||||||
@@ -3080,6 +3083,7 @@ function WorkspaceScreenContent({
|
|||||||
activeTabId,
|
activeTabId,
|
||||||
handleCloseTabById,
|
handleCloseTabById,
|
||||||
handleCreateDraftTab,
|
handleCreateDraftTab,
|
||||||
|
handleCreateBrowserTab,
|
||||||
handleCreateTerminal,
|
handleCreateTerminal,
|
||||||
navigateToTabId,
|
navigateToTabId,
|
||||||
tabs,
|
tabs,
|
||||||
@@ -3194,6 +3198,7 @@ function WorkspaceScreenContent({
|
|||||||
"workspace.tab.navigate-index",
|
"workspace.tab.navigate-index",
|
||||||
"workspace.tab.navigate-relative",
|
"workspace.tab.navigate-relative",
|
||||||
"workspace.terminal.new",
|
"workspace.terminal.new",
|
||||||
|
"workspace.browser.new",
|
||||||
] as const,
|
] as const,
|
||||||
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
enabled: Boolean(isRouteFocused && normalizedServerId && normalizedWorkspaceId),
|
||||||
priority: 100,
|
priority: 100,
|
||||||
|
|||||||
Reference in New Issue
Block a user