9.3 KB298 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import * as stylex from '@stylexjs/stylex';
9import {Button, buttonStyles} from 'isl-components/Button';
10import {ButtonDropdown, styles} from 'isl-components/ButtonDropdown';
11import {Row} from 'isl-components/Flex';
12import {Icon} from 'isl-components/Icon';
13import {Tooltip} from 'isl-components/Tooltip';
14import {getZoomLevel} from 'isl-components/zoom';
15import {atom, useAtomValue} from 'jotai';
16import {loadable} from 'jotai/utils';
17import {useEffect, useMemo, useRef, useState} from 'react';
18import {useContextMenu} from 'shared/ContextMenu';
19import {tracker} from '../analytics';
20import serverAPI from '../ClientToServerAPI';
21import {bulkFetchFeatureFlags, useFeatureFlagSync} from '../featureFlags';
22import {t} from '../i18n';
23import {Internal} from '../Internal';
24import platform from '../platform';
25import {optimisticMergeConflicts} from '../previews';
26import {repositoryInfo} from '../serverAPIState';
27import type {CommitInfo, PlatformSpecificClientToServerMessages} from '../types';
28import {bumpSmartAction, useSortedActions} from './smartActionsOrdering';
29import type {ActionContext, ActionMenuItem, SmartActionConfig} from './types';
30
31const smartActionsConfig = [
32 // Internal actions
33 ...(Internal.smartActions?.smartActionsConfig ?? []),
34 // Public actions
35 // TODO: Add public actions here
36] satisfies SmartActionConfig[];
37
38const smartActionFeatureFlagsAtom = atom<Promise<Record<string, boolean>>>(async () => {
39 const flags: Record<string, boolean> = {};
40
41 const flagNames: string[] = [];
42 for (const config of smartActionsConfig) {
43 if (config.featureFlag && Internal.featureFlags?.[config.featureFlag]) {
44 flagNames.push(Internal.featureFlags[config.featureFlag]);
45 }
46 }
47
48 if (flagNames.length === 0) {
49 return flags;
50 }
51
52 const results = await bulkFetchFeatureFlags(flagNames);
53
54 // Map back from flag names to flag keys
55 for (const config of smartActionsConfig) {
56 if (config.featureFlag && Internal.featureFlags?.[config.featureFlag]) {
57 const flagName = Internal.featureFlags[config.featureFlag];
58 flags[config.featureFlag as string] = results[flagName] ?? false;
59 }
60 }
61
62 return flags;
63});
64
65const loadableFeatureFlagsAtom = loadable(smartActionFeatureFlagsAtom);
66
67export function SmartActionsDropdown({commit}: {commit?: CommitInfo}) {
68 const smartActionsMenuEnabled = useFeatureFlagSync(Internal.featureFlags?.SmartActionsMenu);
69 const repo = useAtomValue(repositoryInfo);
70 const conflicts = useAtomValue(optimisticMergeConflicts);
71 const featureFlagsLoadable = useAtomValue(loadableFeatureFlagsAtom);
72 const dropdownButtonRef = useRef<HTMLButtonElement>(null);
73
74 const context: ActionContext = useMemo(
75 () => ({
76 commit,
77 repoPath: repo?.repoRoot,
78 conflicts,
79 }),
80 [commit, repo?.repoRoot, conflicts],
81 );
82
83 const availableActionItems = useMemo(() => {
84 const featureFlagResults =
85 featureFlagsLoadable.state === 'hasData' ? featureFlagsLoadable.data : {};
86 const items: ActionMenuItem[] = [];
87
88 if (featureFlagsLoadable.state === 'hasData') {
89 for (const config of smartActionsConfig) {
90 if (
91 shouldShowSmartAction(
92 config,
93 context,
94 config.featureFlag ? featureFlagResults[config.featureFlag as string] : true,
95 )
96 ) {
97 items.push({
98 id: config.id,
99 label: config.label,
100 config,
101 });
102 }
103 }
104 }
105
106 return items;
107 }, [featureFlagsLoadable, context]);
108
109 const sortedActionItems = useSortedActions(availableActionItems);
110
111 const [selectedAction, setSelectedAction] = useState<ActionMenuItem | undefined>(undefined);
112
113 useEffect(() => {
114 if (
115 !selectedAction || // No action selected
116 !sortedActionItems.find(item => item.id === selectedAction.id) // Selected action is no longer available
117 ) {
118 setSelectedAction(sortedActionItems[0]);
119 }
120 }, [selectedAction, sortedActionItems]);
121
122 const contextMenu = useContextMenu(() =>
123 sortedActionItems.map(actionItem => ({
124 label: (
125 // Mark the current action as selected
126 <Row>
127 <Icon icon={actionItem.id === selectedAction?.id ? 'check' : 'blank'} />
128 {actionItem.label}
129 </Row>
130 ),
131 onClick: () => {
132 setSelectedAction(actionItem);
133 // Run the action immediately on click instead of requiring a second click
134 runSmartAction(actionItem.config, context);
135 bumpSmartAction(actionItem.id);
136 },
137 tooltip: actionItem.config.description ? t(actionItem.config.description) : undefined,
138 })),
139 );
140
141 if (featureFlagsLoadable.state !== 'hasData') {
142 return null;
143 }
144
145 if (
146 !smartActionsMenuEnabled ||
147 !Internal.smartActions?.showSmartActions ||
148 sortedActionItems.length === 0 ||
149 !selectedAction
150 ) {
151 return null;
152 }
153
154 let buttonComponent;
155
156 const description = selectedAction.config.description
157 ? t(selectedAction.config.description)
158 : undefined;
159 const tooltip =
160 description != null && Internal.smartActions?.renderModifierContextTooltip != null
161 ? Internal.smartActions.renderModifierContextTooltip(description)
162 : description;
163
164 if (sortedActionItems.length === 1) {
165 const singleAction = sortedActionItems[0];
166 buttonComponent = (
167 <SmartActionWithContext config={singleAction.config} context={context} tooltip={tooltip}>
168 <Button
169 kind="icon"
170 onClick={e => {
171 if (e.altKey) {
172 return;
173 }
174 e.stopPropagation();
175 runSmartAction(singleAction.config, context);
176 bumpSmartAction(singleAction.id);
177 }}>
178 <Icon icon="lightbulb-sparkle" />
179 {singleAction.label}
180 </Button>
181 </SmartActionWithContext>
182 );
183 } else {
184 buttonComponent = (
185 <SmartActionWithContext config={selectedAction.config} context={context} tooltip={tooltip}>
186 <ButtonDropdown
187 kind="icon"
188 options={[]}
189 selected={selectedAction}
190 icon={<Icon icon="lightbulb-sparkle" />}
191 onClick={(action, e) => {
192 if (e.altKey) {
193 return;
194 }
195 e.stopPropagation();
196 runSmartAction(action.config, context);
197 // Update the cache with the most recent action
198 bumpSmartAction(action.id);
199 }}
200 onChangeSelected={() => {}}
201 customSelectComponent={
202 <Button
203 {...stylex.props(styles.select, buttonStyles.icon, styles.iconSelect)}
204 onClick={e => {
205 if (dropdownButtonRef.current) {
206 const rect = dropdownButtonRef.current.getBoundingClientRect();
207 const zoom = getZoomLevel();
208 const xOffset = 4 * zoom;
209 const centerX = rect.left + rect.width / 2 - xOffset;
210 // Position arrow at the top or bottom edge of button depending on which half of screen we're in
211 const isTopHalf =
212 (rect.top + rect.height / 2) / zoom <= window.innerHeight / zoom / 2;
213 const yOffset = 5 * zoom;
214 const edgeY = isTopHalf ? rect.bottom - yOffset : rect.top + yOffset;
215 Object.defineProperty(e, 'clientX', {value: centerX, configurable: true});
216 Object.defineProperty(e, 'clientY', {value: edgeY, configurable: true});
217 }
218 contextMenu(e);
219 e.stopPropagation();
220 }}
221 ref={dropdownButtonRef}
222 />
223 }
224 />
225 </SmartActionWithContext>
226 );
227 }
228
229 return buttonComponent;
230}
231
232function SmartActionWithContext({
233 config,
234 context,
235 tooltip,
236 children,
237}: {
238 config: SmartActionConfig;
239 context: ActionContext;
240 tooltip?: React.ReactNode;
241 children: React.ReactNode;
242}) {
243 const ContextInput = Internal.smartActions?.ContextInput;
244
245 if (!ContextInput) {
246 if (tooltip) {
247 return <Tooltip title={tooltip}>{children}</Tooltip>;
248 }
249 return <>{children}</>;
250 }
251
252 return (
253 <Tooltip
254 trigger="click"
255 component={dismiss => (
256 <ContextInput
257 onSubmit={(userContext: string) => {
258 runSmartAction(config, {...context, userContext});
259 bumpSmartAction(config.id);
260 dismiss();
261 }}
262 />
263 )}
264 title={tooltip}
265 group="smart-action-context-input">
266 {children}
267 </Tooltip>
268 );
269}
270
271function shouldShowSmartAction(
272 config: SmartActionConfig,
273 context: ActionContext,
274 passesFeatureFlag: boolean,
275): boolean {
276 if (!passesFeatureFlag) {
277 return false;
278 }
279
280 if (config.platformRestriction && !config.platformRestriction?.includes(platform.platformName)) {
281 return false;
282 }
283
284 return config.shouldShow?.(context) ?? true;
285}
286
287function runSmartAction(config: SmartActionConfig, context: ActionContext): void {
288 tracker.track('SmartActionClicked', {
289 extras: {action: config.trackEventName, withUserContext: context.userContext != null},
290 });
291 if (config.getMessagePayload) {
292 const payload = config.getMessagePayload(context);
293 serverAPI.postMessage({
294 ...payload,
295 } as PlatformSpecificClientToServerMessages);
296 }
297}
298