Add keyboard shortcut to create a new workspace
Cmd+N (Ctrl+N off mac) navigates to the new-workspace route through a global handler keyed on the active host. The sidebar header row surfaces the shortcut hint on hover, replacing the redundant "+" button that duplicated the action in the Workspaces section header.
This commit is contained in:
@@ -63,6 +63,7 @@ import { loadDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { RosettaCalloutSource } from "@/desktop/updates/rosetta-callout-source";
|
||||
import { UpdateCalloutSource } from "@/desktop/updates/update-callout-source";
|
||||
import { useActiveWorktreeNewAction } from "@/hooks/use-active-worktree-new-action";
|
||||
import { useGlobalNewWorkspaceAction } from "@/hooks/use-global-new-workspace-action";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useCompactWebViewportZoomLock } from "@/hooks/use-compact-web-viewport-zoom-lock";
|
||||
@@ -461,6 +462,7 @@ function AppContainer({
|
||||
});
|
||||
|
||||
useActiveWorktreeNewAction();
|
||||
useGlobalNewWorkspaceAction();
|
||||
|
||||
const content = (
|
||||
<View style={layoutStyles.surfaceFill}>
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
buildSettingsRoute,
|
||||
mapPathnameToServer,
|
||||
} from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton";
|
||||
import { SidebarCalloutSlot } from "./sidebar-callout-slot";
|
||||
import { SidebarWorkspaceList } from "./sidebar-workspace-list";
|
||||
@@ -112,6 +113,7 @@ interface SidebarSharedProps {
|
||||
active: boolean;
|
||||
onPress: () => void;
|
||||
}) => ReactElement;
|
||||
newWorkspaceKeys: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
interface SidebarLabels {
|
||||
@@ -285,6 +287,8 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
[pathname],
|
||||
);
|
||||
|
||||
const newWorkspaceKeys = useShortcutKeys("new-workspace");
|
||||
|
||||
const labels = useMemo(
|
||||
(): SidebarLabels => ({
|
||||
addProject: t("sidebar.actions.addProject"),
|
||||
@@ -320,6 +324,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
labels,
|
||||
newWorkspaceKeys,
|
||||
};
|
||||
|
||||
if (isCompactLayout) {
|
||||
@@ -598,6 +603,7 @@ function MobileSidebar({
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
newWorkspaceKeys,
|
||||
handleNewWorkspaceNavigate,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
@@ -809,6 +815,7 @@ function MobileSidebar({
|
||||
onPress={handleNewWorkspace}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
shortcutKeys={newWorkspaceKeys}
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={Clock}
|
||||
@@ -819,10 +826,7 @@ function MobileSidebar({
|
||||
variant="compact"
|
||||
/>
|
||||
</View>
|
||||
<WorkspacesSectionHeader
|
||||
serverId={activeServerId}
|
||||
onNewWorkspacePress={handleNewWorkspace}
|
||||
/>
|
||||
<WorkspacesSectionHeader serverId={activeServerId} />
|
||||
<Pressable
|
||||
style={styles.mobileCloseButton}
|
||||
onPress={closeSidebar}
|
||||
@@ -904,6 +908,7 @@ function DesktopSidebar({
|
||||
handleRefresh,
|
||||
handleHostSelect,
|
||||
renderHostOption,
|
||||
newWorkspaceKeys,
|
||||
handleNewWorkspaceNavigate,
|
||||
handleOpenProject,
|
||||
handleHome,
|
||||
@@ -990,6 +995,7 @@ function DesktopSidebar({
|
||||
onPress={handleNewWorkspaceNavigate}
|
||||
testID="sidebar-global-new-workspace"
|
||||
variant="compact"
|
||||
shortcutKeys={newWorkspaceKeys}
|
||||
/>
|
||||
<SidebarHeaderRow
|
||||
icon={Clock}
|
||||
@@ -1001,10 +1007,7 @@ function DesktopSidebar({
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<WorkspacesSectionHeader
|
||||
serverId={activeServerId}
|
||||
onNewWorkspacePress={handleNewWorkspaceNavigate}
|
||||
/>
|
||||
<WorkspacesSectionHeader serverId={activeServerId} />
|
||||
|
||||
{isInitialLoad ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
@@ -1050,13 +1053,7 @@ function DesktopSidebar({
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspacesSectionHeader({
|
||||
serverId,
|
||||
onNewWorkspacePress,
|
||||
}: {
|
||||
serverId: string | null;
|
||||
onNewWorkspacePress: () => void;
|
||||
}) {
|
||||
function WorkspacesSectionHeader({ serverId }: { serverId: string | null }) {
|
||||
const { theme } = useUnistyles();
|
||||
const setCommandCenterOpen = useKeyboardShortcutsStore((state) => state.setCommandCenterOpen);
|
||||
const commandCenterKeys = useShortcutKeys("toggle-command-center");
|
||||
@@ -1073,29 +1070,6 @@ function WorkspacesSectionHeader({
|
||||
<View style={styles.workspacesSectionHeader}>
|
||||
<Text style={styles.workspacesSectionTitle}>Workspaces</Text>
|
||||
<View style={styles.workspacesSectionActions}>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New workspace"
|
||||
testID="sidebar-new-workspace"
|
||||
style={searchButtonStyle}
|
||||
onPress={onNewWorkspacePress}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<Plus
|
||||
size={14}
|
||||
color={
|
||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<HeaderIconTooltipContent label="New workspace" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
|
||||
@@ -5,6 +5,8 @@ import type { LucideIcon } from "lucide-react-native";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -26,6 +28,7 @@ interface SidebarHeaderRowProps {
|
||||
* sit in a header group whose wrapper owns the single divider.
|
||||
*/
|
||||
variant?: SidebarHeaderRowVariant;
|
||||
shortcutKeys?: ShortcutKey[][] | null;
|
||||
}
|
||||
|
||||
export function SidebarHeaderRow({
|
||||
@@ -37,6 +40,7 @@ export function SidebarHeaderRow({
|
||||
nativeID,
|
||||
accessibilityLabel,
|
||||
variant = "header",
|
||||
shortcutKeys = null,
|
||||
}: SidebarHeaderRowProps) {
|
||||
const ThemedIcon = useMemo(() => withUnistyles(Icon), [Icon]);
|
||||
|
||||
@@ -63,10 +67,13 @@ export function SidebarHeaderRow({
|
||||
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
<SidebarHeaderRowLabel label={label} isHighlighted={isHighlighted} />
|
||||
{shortcutKeys && Boolean(state.hovered) ? (
|
||||
<Shortcut chord={shortcutKeys} style={styles.shortcut} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
},
|
||||
[ThemedIcon, isActive, label],
|
||||
[ThemedIcon, isActive, label, shortcutKeys],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -139,4 +146,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
labelHighlighted: {
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
shortcut: {
|
||||
marginLeft: "auto",
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback } from "react";
|
||||
import { router } from "expo-router";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { buildHostNewWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
const WORKSPACE_NEW_ACTIONS: readonly KeyboardActionId[] = ["workspace.new"];
|
||||
|
||||
export function useGlobalNewWorkspaceAction() {
|
||||
const selection = useActiveWorkspaceSelection();
|
||||
const serverId = selection?.serverId ?? null;
|
||||
|
||||
const handle = useCallback(() => {
|
||||
if (!serverId) {
|
||||
return false;
|
||||
}
|
||||
router.navigate(buildHostNewWorkspaceRoute(serverId) as never);
|
||||
return true;
|
||||
}, [serverId]);
|
||||
|
||||
useKeyboardActionHandler({
|
||||
handlerId: "workspace-new-global",
|
||||
actions: WORKSPACE_NEW_ACTIONS,
|
||||
enabled: serverId !== null,
|
||||
priority: 0,
|
||||
handle,
|
||||
});
|
||||
}
|
||||
@@ -299,6 +299,7 @@ describe("translation resources", () => {
|
||||
expect(en.settings.shortcuts.dialogTitle).toBe("Shortcuts");
|
||||
expect(en.settings.shortcuts.sections.tabsPanes).toBe("Tabs & Panes");
|
||||
expect(en.settings.shortcuts.help.toggleCommandCenter).toBe("Toggle command center");
|
||||
expect(en.settings.shortcuts.help.newWorkspace).toBe("New workspace");
|
||||
expect(en.settings.shortcuts.helpNotes.showKeyboardShortcuts).toBe(
|
||||
"Available when focus is not in a text field or terminal.",
|
||||
);
|
||||
|
||||
@@ -1528,6 +1528,7 @@ export const ar: TranslationResources = {
|
||||
},
|
||||
help: {
|
||||
openProject: "مشروع مفتوح",
|
||||
newWorkspace: "مساحة عمل جديدة",
|
||||
newWorktree: "شجرة عمل جديدة",
|
||||
archiveWorktree: "أرشفة شجرة العمل",
|
||||
newTab: "علامة تبويب جديدة",
|
||||
|
||||
@@ -1533,6 +1533,7 @@ export const en = {
|
||||
},
|
||||
help: {
|
||||
openProject: "Open project",
|
||||
newWorkspace: "New workspace",
|
||||
newWorktree: "New worktree",
|
||||
archiveWorktree: "Archive worktree",
|
||||
newTab: "New tab",
|
||||
|
||||
@@ -1565,6 +1565,7 @@ export const es: TranslationResources = {
|
||||
},
|
||||
help: {
|
||||
openProject: "Abrir proyecto",
|
||||
newWorkspace: "Nuevo espacio de trabajo",
|
||||
newWorktree: "Nuevo árbol de trabajo",
|
||||
archiveWorktree: "Árbol de trabajo de archivo",
|
||||
newTab: "Nueva pestaña",
|
||||
|
||||
@@ -1569,6 +1569,7 @@ export const fr: TranslationResources = {
|
||||
},
|
||||
help: {
|
||||
openProject: "Projet ouvert",
|
||||
newWorkspace: "Nouvel espace de travail",
|
||||
newWorktree: "Nouvel arbre de travail",
|
||||
archiveWorktree: "Arbre de travail d'archivage",
|
||||
newTab: "Nouvel onglet",
|
||||
|
||||
@@ -1557,6 +1557,7 @@ export const ru: TranslationResources = {
|
||||
},
|
||||
help: {
|
||||
openProject: "Открыть проект",
|
||||
newWorkspace: "Новое рабочее пространство",
|
||||
newWorktree: "Новое рабочее дерево",
|
||||
archiveWorktree: "Архив рабочего дерева",
|
||||
newTab: "Новая вкладка",
|
||||
|
||||
@@ -1509,6 +1509,7 @@ export const zhCN: TranslationResources = {
|
||||
},
|
||||
help: {
|
||||
openProject: "打开项目",
|
||||
newWorkspace: "新建 workspace",
|
||||
newWorktree: "新建 worktree",
|
||||
archiveWorktree: "归档 worktree",
|
||||
newTab: "新建标签",
|
||||
|
||||
@@ -41,6 +41,7 @@ export type KeyboardActionId =
|
||||
| "command-center.toggle"
|
||||
| "shortcuts.dialog.toggle"
|
||||
| "workspace.terminal.new"
|
||||
| "workspace.new"
|
||||
| "worktree.new"
|
||||
| "worktree.archive"
|
||||
| "view.toggle.focus"
|
||||
|
||||
@@ -26,6 +26,7 @@ export type KeyboardActionId =
|
||||
| "workspace.pane.close"
|
||||
| "workspace.terminal.new"
|
||||
| "sidebar.toggle.right"
|
||||
| "workspace.new"
|
||||
| "worktree.new"
|
||||
| "worktree.archive";
|
||||
|
||||
@@ -55,6 +56,7 @@ export type KeyboardActionDefinition =
|
||||
| { id: "workspace.pane.close"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.terminal.new"; scope: KeyboardActionScope }
|
||||
| { id: "sidebar.toggle.right"; scope: KeyboardActionScope }
|
||||
| { id: "workspace.new"; scope: KeyboardActionScope }
|
||||
| { id: "worktree.new"; scope: KeyboardActionScope }
|
||||
| { id: "worktree.archive"; scope: KeyboardActionScope };
|
||||
|
||||
|
||||
@@ -128,6 +128,18 @@ describe("keyboard-shortcuts", () => {
|
||||
context: { isMac: true },
|
||||
action: "agent.new",
|
||||
},
|
||||
{
|
||||
name: "matches Cmd+N to create new workspace on mac",
|
||||
event: { key: "n", code: "KeyN", metaKey: true },
|
||||
context: { isMac: true, commandCenterOpen: false },
|
||||
action: "workspace.new",
|
||||
},
|
||||
{
|
||||
name: "matches Ctrl+N to create new workspace on non-mac",
|
||||
event: { key: "n", code: "KeyN", ctrlKey: true },
|
||||
context: { isMac: false, commandCenterOpen: false, focusScope: "other" },
|
||||
action: "workspace.new",
|
||||
},
|
||||
{
|
||||
name: "matches question-mark shortcut to toggle the shortcuts dialog",
|
||||
event: { key: "?", code: "Slash", shiftKey: true },
|
||||
@@ -564,6 +576,7 @@ describe("keyboard-shortcut help sections", () => {
|
||||
context: { isMac: true, isDesktop: true },
|
||||
expectedKeys: {
|
||||
"new-agent": ["mod", "shift", "O"],
|
||||
"new-workspace": ["mod", "N"],
|
||||
"workspace-tab-new": ["mod", "T"],
|
||||
"workspace-jump-index": ["mod", "1-9"],
|
||||
"workspace-tab-jump-index": ["mod", "alt", "1-9"],
|
||||
|
||||
@@ -120,6 +120,7 @@ const SHORTCUT_HELP_SECTION_LABEL_KEYS: Record<ShortcutSectionId, string> = {
|
||||
|
||||
const SHORTCUT_HELP_LABEL_KEYS: Record<string, string> = {
|
||||
"new-agent": "settings.shortcuts.help.openProject",
|
||||
"new-workspace": "settings.shortcuts.help.newWorkspace",
|
||||
"new-worktree": "settings.shortcuts.help.newWorktree",
|
||||
"archive-worktree": "settings.shortcuts.help.archiveWorktree",
|
||||
"workspace-tab-new": "settings.shortcuts.help.newTab",
|
||||
@@ -190,6 +191,32 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
},
|
||||
},
|
||||
|
||||
// --- New workspace ---
|
||||
{
|
||||
id: "workspace-new-cmd-n-mac",
|
||||
action: "workspace.new",
|
||||
combo: "Cmd+N",
|
||||
when: { mac: true, commandCenter: false },
|
||||
help: {
|
||||
id: "new-workspace",
|
||||
section: "projects",
|
||||
label: "New workspace",
|
||||
keys: ["mod", "N"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "workspace-new-ctrl-n-non-mac",
|
||||
action: "workspace.new",
|
||||
combo: "Ctrl+N",
|
||||
when: { mac: false, commandCenter: false, terminal: false },
|
||||
help: {
|
||||
id: "new-workspace",
|
||||
section: "projects",
|
||||
label: "New workspace",
|
||||
keys: ["mod", "N"],
|
||||
},
|
||||
},
|
||||
|
||||
// --- New worktree ---
|
||||
{
|
||||
id: "worktree-new-cmd-o-mac",
|
||||
|
||||
@@ -29,6 +29,7 @@ describe("routeKeyboardShortcut — dispatch passthroughs", () => {
|
||||
it.each([
|
||||
["agent.interrupt", { id: "agent.interrupt", scope: "global" }],
|
||||
["workspace.tab.new", { id: "workspace.tab.new", scope: "workspace" }],
|
||||
["workspace.new", { id: "workspace.new", scope: "sidebar" }],
|
||||
["worktree.archive", { id: "worktree.archive", scope: "sidebar" }],
|
||||
["worktree.new", { id: "worktree.new", scope: "sidebar" }],
|
||||
["workspace.terminal.new", { id: "workspace.terminal.new", scope: "workspace" }],
|
||||
|
||||
@@ -45,6 +45,7 @@ const NONE: ShortcutAction = { kind: "none" };
|
||||
const PASSTHROUGH_DISPATCH: Record<string, KeyboardActionDefinition> = {
|
||||
"agent.interrupt": { id: "agent.interrupt", scope: "global" },
|
||||
"workspace.tab.new": { id: "workspace.tab.new", scope: "workspace" },
|
||||
"workspace.new": { id: "workspace.new", scope: "sidebar" },
|
||||
"worktree.archive": { id: "worktree.archive", scope: "sidebar" },
|
||||
"worktree.new": { id: "worktree.new", scope: "sidebar" },
|
||||
"workspace.terminal.new": { id: "workspace.terminal.new", scope: "workspace" },
|
||||
|
||||
Reference in New Issue
Block a user