* 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.
262 lines
8.4 KiB
TypeScript
262 lines
8.4 KiB
TypeScript
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import { useCallback, useMemo } from "react";
|
|
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { ChevronDown, MoreVertical } from "lucide-react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Shortcut } from "@/components/ui/shortcut";
|
|
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
|
import type { GitAction, GitActions } from "@/git/policy";
|
|
import { useGitActionRunner } from "@/git/use-actions";
|
|
|
|
interface GitActionsSplitButtonProps {
|
|
gitActions: GitActions;
|
|
hideLabels?: boolean;
|
|
}
|
|
|
|
interface GitActionMenuItemProps {
|
|
action: GitAction;
|
|
onSelect: (action: GitAction) => void;
|
|
archiveShortcutKeys?: ShortcutKey[][] | null;
|
|
needsSeparator?: boolean;
|
|
showSeparator?: boolean;
|
|
closeOnSelect?: boolean;
|
|
}
|
|
|
|
function GitActionMenuItem({
|
|
action,
|
|
onSelect,
|
|
archiveShortcutKeys,
|
|
needsSeparator,
|
|
showSeparator,
|
|
closeOnSelect,
|
|
}: GitActionMenuItemProps) {
|
|
const handleSelect = useCallback(() => onSelect(action), [onSelect, action]);
|
|
const trailing = useMemo(
|
|
() =>
|
|
action.id === "archive-workspace" && archiveShortcutKeys ? (
|
|
<Shortcut chord={archiveShortcutKeys} />
|
|
) : undefined,
|
|
[action.id, archiveShortcutKeys],
|
|
);
|
|
return (
|
|
<View>
|
|
{needsSeparator && showSeparator ? <DropdownMenuSeparator /> : null}
|
|
<DropdownMenuItem
|
|
testID={
|
|
action.id === "archive-workspace"
|
|
? "workspace-archive-action"
|
|
: `changes-menu-${action.id}`
|
|
}
|
|
leading={action.icon}
|
|
trailing={trailing}
|
|
disabled={action.disabled}
|
|
muted={Boolean(action.unavailableMessage)}
|
|
status={action.status}
|
|
pendingLabel={action.pendingLabel}
|
|
successLabel={action.successLabel}
|
|
closeOnSelect={closeOnSelect}
|
|
onSelect={handleSelect}
|
|
>
|
|
{action.label}
|
|
</DropdownMenuItem>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function GitActionsSplitButton({ gitActions, hideLabels }: GitActionsSplitButtonProps) {
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const runGitAction = useGitActionRunner();
|
|
const archiveShortcutKeys = useShortcutKeys("archive-workspace");
|
|
|
|
const getActionDisplayLabel = useCallback((action: GitAction): string => {
|
|
if (action.status === "pending") return action.pendingLabel;
|
|
if (action.status === "success") return action.successLabel;
|
|
return action.label;
|
|
}, []);
|
|
|
|
const handlePrimaryPress = useCallback(() => {
|
|
if (!gitActions.primary) {
|
|
return;
|
|
}
|
|
runGitAction(gitActions.primary);
|
|
}, [gitActions.primary, runGitAction]);
|
|
|
|
const overflowMenuButtonStyle = useMemo(() => [styles.iconButton, styles.overflowMenuButton], []);
|
|
|
|
const primaryDisabled = gitActions.primary?.disabled;
|
|
const primaryPressableStyle = useCallback(
|
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
styles.splitButtonPrimary,
|
|
(Boolean(hovered) || pressed) &&
|
|
inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }),
|
|
primaryDisabled && styles.splitButtonPrimaryDisabled,
|
|
],
|
|
[primaryDisabled, theme.colors.surface2],
|
|
);
|
|
|
|
const caretTriggerStyle = useCallback(
|
|
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
|
|
styles.splitButtonCaret,
|
|
(hovered || pressed || open) &&
|
|
inlineUnistylesStyle({ backgroundColor: theme.colors.surface2 }),
|
|
],
|
|
[theme.colors.surface2],
|
|
);
|
|
|
|
return (
|
|
<View style={styles.row}>
|
|
{gitActions.primary ? (
|
|
<View style={styles.splitButton}>
|
|
<Pressable
|
|
testID="changes-primary-cta"
|
|
style={primaryPressableStyle}
|
|
onPress={handlePrimaryPress}
|
|
disabled={gitActions.primary.disabled}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={gitActions.primary.label}
|
|
>
|
|
{gitActions.primary.status === "pending" ? (
|
|
<LoadingSpinner
|
|
size="small"
|
|
color={theme.colors.foreground}
|
|
style={styles.splitButtonSpinnerOnly}
|
|
/>
|
|
) : (
|
|
<View style={styles.splitButtonContent}>
|
|
{gitActions.primary.icon}
|
|
{!hideLabels && (
|
|
<Text style={styles.splitButtonText}>
|
|
{getActionDisplayLabel(gitActions.primary)}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
{gitActions.secondary.length > 0 ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
testID="changes-primary-cta-caret"
|
|
style={caretTriggerStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("workspace.git.actions.moreOptions")}
|
|
>
|
|
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" testID="changes-primary-cta-menu">
|
|
{gitActions.secondary.map((action, index) => (
|
|
<GitActionMenuItem
|
|
key={action.id}
|
|
action={action}
|
|
onSelect={runGitAction}
|
|
archiveShortcutKeys={archiveShortcutKeys}
|
|
needsSeparator={action.startsGroup}
|
|
showSeparator={index > 0}
|
|
closeOnSelect={
|
|
action.status === "idle" &&
|
|
action.id === "pr" &&
|
|
action.label === action.pendingLabel &&
|
|
action.label === action.successLabel
|
|
}
|
|
/>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
</View>
|
|
) : null}
|
|
{gitActions.menu.length > 0 ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
testID="changes-overflow-menu"
|
|
hitSlop={8}
|
|
style={overflowMenuButtonStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("workspace.git.actions.moreActions")}
|
|
>
|
|
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
|
|
{gitActions.menu.map((action) => (
|
|
<GitActionMenuItem
|
|
key={action.id}
|
|
action={action}
|
|
onSelect={runGitAction}
|
|
closeOnSelect={false}
|
|
/>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
row: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
flexShrink: 0,
|
|
},
|
|
splitButton: {
|
|
flexDirection: "row",
|
|
alignItems: "stretch",
|
|
borderRadius: theme.borderRadius.md,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.borderAccent,
|
|
overflow: "hidden",
|
|
},
|
|
splitButtonPrimary: {
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[1],
|
|
justifyContent: "center",
|
|
position: "relative",
|
|
},
|
|
splitButtonPrimaryDisabled: {
|
|
opacity: 0.6,
|
|
},
|
|
splitButtonText: {
|
|
fontSize: theme.fontSize.sm,
|
|
lineHeight: theme.fontSize.sm * 1.5,
|
|
color: theme.colors.foreground,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
splitButtonContent: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
splitButtonSpinnerOnly: {
|
|
transform: [{ scale: 0.8 }],
|
|
},
|
|
splitButtonCaret: {
|
|
width: 28,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
borderLeftWidth: theme.borderWidth[1],
|
|
borderLeftColor: theme.colors.borderAccent,
|
|
},
|
|
iconButton: {
|
|
width: 32,
|
|
height: 32,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
borderRadius: theme.borderRadius.md,
|
|
},
|
|
overflowMenuButton: {
|
|
marginRight: -theme.spacing[2],
|
|
},
|
|
}));
|