addons/isl/src/smartActions/SmartActionsDropdown.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import * as stylex from '@stylexjs/stylex';
b69ab319import {Button, buttonStyles} from 'isl-components/Button';
b69ab3110import {ButtonDropdown, styles} from 'isl-components/ButtonDropdown';
b69ab3111import {Row} from 'isl-components/Flex';
b69ab3112import {Icon} from 'isl-components/Icon';
b69ab3113import {Tooltip} from 'isl-components/Tooltip';
b69ab3114import {getZoomLevel} from 'isl-components/zoom';
b69ab3115import {atom, useAtomValue} from 'jotai';
b69ab3116import {loadable} from 'jotai/utils';
b69ab3117import {useEffect, useMemo, useRef, useState} from 'react';
b69ab3118import {useContextMenu} from 'shared/ContextMenu';
b69ab3119import {tracker} from '../analytics';
b69ab3120import serverAPI from '../ClientToServerAPI';
b69ab3121import {bulkFetchFeatureFlags, useFeatureFlagSync} from '../featureFlags';
b69ab3122import {t} from '../i18n';
b69ab3123import {Internal} from '../Internal';
b69ab3124import platform from '../platform';
b69ab3125import {optimisticMergeConflicts} from '../previews';
b69ab3126import {repositoryInfo} from '../serverAPIState';
b69ab3127import type {CommitInfo, PlatformSpecificClientToServerMessages} from '../types';
b69ab3128import {bumpSmartAction, useSortedActions} from './smartActionsOrdering';
b69ab3129import type {ActionContext, ActionMenuItem, SmartActionConfig} from './types';
b69ab3130
b69ab3131const smartActionsConfig = [
b69ab3132 // Internal actions
b69ab3133 ...(Internal.smartActions?.smartActionsConfig ?? []),
b69ab3134 // Public actions
b69ab3135 // TODO: Add public actions here
b69ab3136] satisfies SmartActionConfig[];
b69ab3137
b69ab3138const smartActionFeatureFlagsAtom = atom<Promise<Record<string, boolean>>>(async () => {
b69ab3139 const flags: Record<string, boolean> = {};
b69ab3140
b69ab3141 const flagNames: string[] = [];
b69ab3142 for (const config of smartActionsConfig) {
b69ab3143 if (config.featureFlag && Internal.featureFlags?.[config.featureFlag]) {
b69ab3144 flagNames.push(Internal.featureFlags[config.featureFlag]);
b69ab3145 }
b69ab3146 }
b69ab3147
b69ab3148 if (flagNames.length === 0) {
b69ab3149 return flags;
b69ab3150 }
b69ab3151
b69ab3152 const results = await bulkFetchFeatureFlags(flagNames);
b69ab3153
b69ab3154 // Map back from flag names to flag keys
b69ab3155 for (const config of smartActionsConfig) {
b69ab3156 if (config.featureFlag && Internal.featureFlags?.[config.featureFlag]) {
b69ab3157 const flagName = Internal.featureFlags[config.featureFlag];
b69ab3158 flags[config.featureFlag as string] = results[flagName] ?? false;
b69ab3159 }
b69ab3160 }
b69ab3161
b69ab3162 return flags;
b69ab3163});
b69ab3164
b69ab3165const loadableFeatureFlagsAtom = loadable(smartActionFeatureFlagsAtom);
b69ab3166
b69ab3167export function SmartActionsDropdown({commit}: {commit?: CommitInfo}) {
b69ab3168 const smartActionsMenuEnabled = useFeatureFlagSync(Internal.featureFlags?.SmartActionsMenu);
b69ab3169 const repo = useAtomValue(repositoryInfo);
b69ab3170 const conflicts = useAtomValue(optimisticMergeConflicts);
b69ab3171 const featureFlagsLoadable = useAtomValue(loadableFeatureFlagsAtom);
b69ab3172 const dropdownButtonRef = useRef<HTMLButtonElement>(null);
b69ab3173
b69ab3174 const context: ActionContext = useMemo(
b69ab3175 () => ({
b69ab3176 commit,
b69ab3177 repoPath: repo?.repoRoot,
b69ab3178 conflicts,
b69ab3179 }),
b69ab3180 [commit, repo?.repoRoot, conflicts],
b69ab3181 );
b69ab3182
b69ab3183 const availableActionItems = useMemo(() => {
b69ab3184 const featureFlagResults =
b69ab3185 featureFlagsLoadable.state === 'hasData' ? featureFlagsLoadable.data : {};
b69ab3186 const items: ActionMenuItem[] = [];
b69ab3187
b69ab3188 if (featureFlagsLoadable.state === 'hasData') {
b69ab3189 for (const config of smartActionsConfig) {
b69ab3190 if (
b69ab3191 shouldShowSmartAction(
b69ab3192 config,
b69ab3193 context,
b69ab3194 config.featureFlag ? featureFlagResults[config.featureFlag as string] : true,
b69ab3195 )
b69ab3196 ) {
b69ab3197 items.push({
b69ab3198 id: config.id,
b69ab3199 label: config.label,
b69ab31100 config,
b69ab31101 });
b69ab31102 }
b69ab31103 }
b69ab31104 }
b69ab31105
b69ab31106 return items;
b69ab31107 }, [featureFlagsLoadable, context]);
b69ab31108
b69ab31109 const sortedActionItems = useSortedActions(availableActionItems);
b69ab31110
b69ab31111 const [selectedAction, setSelectedAction] = useState<ActionMenuItem | undefined>(undefined);
b69ab31112
b69ab31113 useEffect(() => {
b69ab31114 if (
b69ab31115 !selectedAction || // No action selected
b69ab31116 !sortedActionItems.find(item => item.id === selectedAction.id) // Selected action is no longer available
b69ab31117 ) {
b69ab31118 setSelectedAction(sortedActionItems[0]);
b69ab31119 }
b69ab31120 }, [selectedAction, sortedActionItems]);
b69ab31121
b69ab31122 const contextMenu = useContextMenu(() =>
b69ab31123 sortedActionItems.map(actionItem => ({
b69ab31124 label: (
b69ab31125 // Mark the current action as selected
b69ab31126 <Row>
b69ab31127 <Icon icon={actionItem.id === selectedAction?.id ? 'check' : 'blank'} />
b69ab31128 {actionItem.label}
b69ab31129 </Row>
b69ab31130 ),
b69ab31131 onClick: () => {
b69ab31132 setSelectedAction(actionItem);
b69ab31133 // Run the action immediately on click instead of requiring a second click
b69ab31134 runSmartAction(actionItem.config, context);
b69ab31135 bumpSmartAction(actionItem.id);
b69ab31136 },
b69ab31137 tooltip: actionItem.config.description ? t(actionItem.config.description) : undefined,
b69ab31138 })),
b69ab31139 );
b69ab31140
b69ab31141 if (featureFlagsLoadable.state !== 'hasData') {
b69ab31142 return null;
b69ab31143 }
b69ab31144
b69ab31145 if (
b69ab31146 !smartActionsMenuEnabled ||
b69ab31147 !Internal.smartActions?.showSmartActions ||
b69ab31148 sortedActionItems.length === 0 ||
b69ab31149 !selectedAction
b69ab31150 ) {
b69ab31151 return null;
b69ab31152 }
b69ab31153
b69ab31154 let buttonComponent;
b69ab31155
b69ab31156 const description = selectedAction.config.description
b69ab31157 ? t(selectedAction.config.description)
b69ab31158 : undefined;
b69ab31159 const tooltip =
b69ab31160 description != null && Internal.smartActions?.renderModifierContextTooltip != null
b69ab31161 ? Internal.smartActions.renderModifierContextTooltip(description)
b69ab31162 : description;
b69ab31163
b69ab31164 if (sortedActionItems.length === 1) {
b69ab31165 const singleAction = sortedActionItems[0];
b69ab31166 buttonComponent = (
b69ab31167 <SmartActionWithContext config={singleAction.config} context={context} tooltip={tooltip}>
b69ab31168 <Button
b69ab31169 kind="icon"
b69ab31170 onClick={e => {
b69ab31171 if (e.altKey) {
b69ab31172 return;
b69ab31173 }
b69ab31174 e.stopPropagation();
b69ab31175 runSmartAction(singleAction.config, context);
b69ab31176 bumpSmartAction(singleAction.id);
b69ab31177 }}>
b69ab31178 <Icon icon="lightbulb-sparkle" />
b69ab31179 {singleAction.label}
b69ab31180 </Button>
b69ab31181 </SmartActionWithContext>
b69ab31182 );
b69ab31183 } else {
b69ab31184 buttonComponent = (
b69ab31185 <SmartActionWithContext config={selectedAction.config} context={context} tooltip={tooltip}>
b69ab31186 <ButtonDropdown
b69ab31187 kind="icon"
b69ab31188 options={[]}
b69ab31189 selected={selectedAction}
b69ab31190 icon={<Icon icon="lightbulb-sparkle" />}
b69ab31191 onClick={(action, e) => {
b69ab31192 if (e.altKey) {
b69ab31193 return;
b69ab31194 }
b69ab31195 e.stopPropagation();
b69ab31196 runSmartAction(action.config, context);
b69ab31197 // Update the cache with the most recent action
b69ab31198 bumpSmartAction(action.id);
b69ab31199 }}
b69ab31200 onChangeSelected={() => {}}
b69ab31201 customSelectComponent={
b69ab31202 <Button
b69ab31203 {...stylex.props(styles.select, buttonStyles.icon, styles.iconSelect)}
b69ab31204 onClick={e => {
b69ab31205 if (dropdownButtonRef.current) {
b69ab31206 const rect = dropdownButtonRef.current.getBoundingClientRect();
b69ab31207 const zoom = getZoomLevel();
b69ab31208 const xOffset = 4 * zoom;
b69ab31209 const centerX = rect.left + rect.width / 2 - xOffset;
b69ab31210 // Position arrow at the top or bottom edge of button depending on which half of screen we're in
b69ab31211 const isTopHalf =
b69ab31212 (rect.top + rect.height / 2) / zoom <= window.innerHeight / zoom / 2;
b69ab31213 const yOffset = 5 * zoom;
b69ab31214 const edgeY = isTopHalf ? rect.bottom - yOffset : rect.top + yOffset;
b69ab31215 Object.defineProperty(e, 'clientX', {value: centerX, configurable: true});
b69ab31216 Object.defineProperty(e, 'clientY', {value: edgeY, configurable: true});
b69ab31217 }
b69ab31218 contextMenu(e);
b69ab31219 e.stopPropagation();
b69ab31220 }}
b69ab31221 ref={dropdownButtonRef}
b69ab31222 />
b69ab31223 }
b69ab31224 />
b69ab31225 </SmartActionWithContext>
b69ab31226 );
b69ab31227 }
b69ab31228
b69ab31229 return buttonComponent;
b69ab31230}
b69ab31231
b69ab31232function SmartActionWithContext({
b69ab31233 config,
b69ab31234 context,
b69ab31235 tooltip,
b69ab31236 children,
b69ab31237}: {
b69ab31238 config: SmartActionConfig;
b69ab31239 context: ActionContext;
b69ab31240 tooltip?: React.ReactNode;
b69ab31241 children: React.ReactNode;
b69ab31242}) {
b69ab31243 const ContextInput = Internal.smartActions?.ContextInput;
b69ab31244
b69ab31245 if (!ContextInput) {
b69ab31246 if (tooltip) {
b69ab31247 return <Tooltip title={tooltip}>{children}</Tooltip>;
b69ab31248 }
b69ab31249 return <>{children}</>;
b69ab31250 }
b69ab31251
b69ab31252 return (
b69ab31253 <Tooltip
b69ab31254 trigger="click"
b69ab31255 component={dismiss => (
b69ab31256 <ContextInput
b69ab31257 onSubmit={(userContext: string) => {
b69ab31258 runSmartAction(config, {...context, userContext});
b69ab31259 bumpSmartAction(config.id);
b69ab31260 dismiss();
b69ab31261 }}
b69ab31262 />
b69ab31263 )}
b69ab31264 title={tooltip}
b69ab31265 group="smart-action-context-input">
b69ab31266 {children}
b69ab31267 </Tooltip>
b69ab31268 );
b69ab31269}
b69ab31270
b69ab31271function shouldShowSmartAction(
b69ab31272 config: SmartActionConfig,
b69ab31273 context: ActionContext,
b69ab31274 passesFeatureFlag: boolean,
b69ab31275): boolean {
b69ab31276 if (!passesFeatureFlag) {
b69ab31277 return false;
b69ab31278 }
b69ab31279
b69ab31280 if (config.platformRestriction && !config.platformRestriction?.includes(platform.platformName)) {
b69ab31281 return false;
b69ab31282 }
b69ab31283
b69ab31284 return config.shouldShow?.(context) ?? true;
b69ab31285}
b69ab31286
b69ab31287function runSmartAction(config: SmartActionConfig, context: ActionContext): void {
b69ab31288 tracker.track('SmartActionClicked', {
b69ab31289 extras: {action: config.trackEventName, withUserContext: context.userContext != null},
b69ab31290 });
b69ab31291 if (config.getMessagePayload) {
b69ab31292 const payload = config.getMessagePayload(context);
b69ab31293 serverAPI.postMessage({
b69ab31294 ...payload,
b69ab31295 } as PlatformSpecificClientToServerMessages);
b69ab31296 }
b69ab31297}