11.4 KB346 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 type {DiffId, DiffSignalSummary, DiffSummary, Hash, PageVisibility, Result, ValidatedRepoInfo} from '../types';
9import type {UICodeReviewProvider} from './UICodeReviewProvider';
10
11import {startTransition} from 'react';
12import {atom} from 'jotai';
13import {clearTrackedCache} from 'shared/LRU';
14import {debounce} from 'shared/debounce';
15import {firstLine, nullthrows} from 'shared/utils';
16import serverAPI from '../ClientToServerAPI';
17import {commitMessageTemplate} from '../CommitInfoView/CommitInfoState';
18import {
19 applyEditedFields,
20 commitMessageFieldsSchema,
21 commitMessageFieldsToString,
22 emptyCommitMessageFields,
23 parseCommitMessageFields,
24} from '../CommitInfoView/CommitMessageFields';
25import {Internal} from '../Internal';
26import {getTracker} from '../analytics/globalTracker';
27import {atomFamilyWeak, atomWithOnChange, writeAtom} from '../jotaiUtils';
28import {messageSyncingEnabledState} from '../messageSyncing';
29import {dagWithPreviews} from '../previews';
30import {commitByHash, repositoryInfo} from '../serverAPIState';
31import {registerCleanup, registerDisposable} from '../utils';
32import {GithubUICodeReviewProvider} from './github/github';
33import {GroveUICodeReviewProvider} from './grove/grove';
34
35export const codeReviewProvider = atom<UICodeReviewProvider | null>(get => {
36 const repoInfo = get(repositoryInfo);
37 return repoInfoToCodeReviewProvider(repoInfo);
38});
39
40function repoInfoToCodeReviewProvider(repoInfo?: ValidatedRepoInfo): UICodeReviewProvider | null {
41 if (repoInfo == null) {
42 return null;
43 }
44 if (repoInfo.codeReviewSystem.type === 'github') {
45 return new GithubUICodeReviewProvider(
46 repoInfo.codeReviewSystem,
47 repoInfo.preferredSubmitCommand ?? 'pr',
48 );
49 }
50 if (repoInfo.codeReviewSystem.type === 'grove') {
51 return new GroveUICodeReviewProvider(repoInfo.codeReviewSystem);
52 }
53 if (
54 repoInfo.codeReviewSystem.type === 'phabricator' &&
55 Internal.PhabricatorUICodeReviewProvider != null
56 ) {
57 return new Internal.PhabricatorUICodeReviewProvider(repoInfo.codeReviewSystem);
58 }
59 return null;
60}
61
62export const diffSummary = atomFamilyWeak((diffId: DiffId | undefined) =>
63 atom<Result<DiffSummary | undefined>>(get => {
64 if (diffId == null) {
65 return {value: undefined};
66 }
67 const all = get(allDiffSummaries);
68 if (all == null) {
69 return {value: undefined};
70 }
71 if (all.error) {
72 return {error: all.error};
73 }
74 return {value: all.value?.get(diffId)};
75 }),
76);
77
78/**
79 * Resolve a commit's diffId: use the template-parsed value if available,
80 * otherwise fall back to the commitHash→diffId reverse index
81 * (populated from GroveDiffSummary.commitHash).
82 */
83export const diffIdForCommit = atomFamilyWeak((hash: Hash) =>
84 atom<DiffId | undefined>(get => {
85 const commit = get(commitByHash(hash));
86 if (commit?.diffId != null) {
87 return commit.diffId;
88 }
89 return get(diffIdsByCommitHash).get(hash);
90 }),
91);
92
93export const branchingDiffInfos = atomFamilyWeak((branchName: string) =>
94 atom<Result<DiffSummary | undefined>>(get => {
95 const all = get(allDiffSummaries);
96 if (all == null) {
97 return {value: undefined};
98 }
99 if (all.error) {
100 return {error: all.error};
101 }
102 const idMap = get(diffIdsByBranchName);
103 const idForBranchName = idMap.get(branchName);
104 if (idForBranchName) {
105 return {value: all.value?.get(idForBranchName)};
106 }
107 return {value: undefined};
108 }),
109);
110
111export const allDiffSummaries = atom<Result<Map<DiffId, DiffSummary> | null>>({value: null});
112export const diffIdsByBranchName = atom<Map<string, DiffId>>(new Map());
113export const diffIdsByCommitHash = atom<Map<Hash, DiffId>>(new Map());
114
115registerDisposable(
116 allDiffSummaries,
117 serverAPI.onMessageOfType('fetchedDiffSummaries', event => {
118 // Defer off the current task so ongoing user interactions (e.g. drag) aren't blocked.
119 setTimeout(() => {
120 startTransition(() => {
121 writeAtom(diffIdsByBranchName, existing => {
122 if (event.summaries.error) {
123 return existing;
124 }
125
126 const map = new Map<string, DiffId>(existing);
127 for (const [diffId, summary] of event.summaries.value.entries()) {
128 if (summary.branchName) {
129 map.set(summary.branchName, diffId);
130 }
131 }
132 return map;
133 });
134
135 writeAtom(diffIdsByCommitHash, existing => {
136 if (event.summaries.error) {
137 return existing;
138 }
139
140 const map = new Map<Hash, DiffId>(existing);
141 for (const [diffId, summary] of event.summaries.value.entries()) {
142 if (summary.type === 'grove' && summary.commitHash) {
143 map.set(summary.commitHash, diffId);
144 }
145 }
146 return map;
147 });
148
149 writeAtom(allDiffSummaries, existing => {
150 if (existing.error) {
151 // TODO: if we only fetch one diff, but had an error on the overall fetch... should we still somehow show that error...?
152 // Right now, this will reset all other diffs to "loading" instead of error
153 // Probably, if all diffs fail to fetch, so will individual diffs.
154 return event.summaries;
155 }
156
157 if (event.summaries.error || existing.value == null) {
158 return event.summaries;
159 }
160
161 // merge old values with newly fetched ones
162 return {
163 value: new Map([
164 ...nullthrows(existing.value).entries(),
165 ...event.summaries.value.entries(),
166 ]),
167 };
168 });
169 });
170 }, 0);
171 }),
172 import.meta.hot,
173);
174
175registerCleanup(
176 allDiffSummaries,
177 serverAPI.onSetup(() => {
178 serverAPI.postMessage({
179 type: 'fetchDiffSummaries',
180 });
181 getTracker()?.track('DiffFetchSource', {extras: {source: 'webview_startup'}});
182 }),
183 import.meta.hot,
184);
185
186export type CanopySignalInfo = {signal: DiffSignalSummary; runId?: number; commitId?: string};
187
188/** Canopy CI/CD build signals keyed by commit message title. */
189export const canopySignalsByTitle = atom<Map<string, CanopySignalInfo>>(new Map());
190
191registerDisposable(
192 canopySignalsByTitle,
193 serverAPI.onMessageOfType('fetchedCanopySignals', event => {
194 setTimeout(() => {
195 startTransition(() => {
196 writeAtom(canopySignalsByTitle, _existing => {
197 // Replace the entire map with fresh data from the server.
198 // The server returns runs newest-first, keeping only the most recent per commit message,
199 // so we always want to use the latest signal (e.g. running → passed).
200 const next = new Map<string, CanopySignalInfo>();
201 for (const run of event.runs) {
202 if (!next.has(run.commitMessage)) {
203 next.set(run.commitMessage, {signal: run.signal, runId: run.runId, commitId: run.commitId});
204 }
205 }
206 return next;
207 });
208 });
209 }, 0);
210 }),
211 import.meta.hot,
212);
213
214let canopySignalsFetchRequested = false;
215/** Request Canopy signals from the server. Only sends the request once. */
216export function ensureCanopySignalsFetched(): void {
217 if (canopySignalsFetchRequested) {
218 return;
219 }
220 canopySignalsFetchRequested = true;
221 serverAPI.postMessage({type: 'fetchCanopySignals'});
222}
223
224/** Look up Canopy CI signal for a specific commit by matching its title to Canopy run messages. */
225export const canopySignalForCommit = atomFamilyWeak((hash: Hash) =>
226 atom<CanopySignalInfo | undefined>(get => {
227 const dag = get(dagWithPreviews);
228 const commit = dag.get(hash);
229 if (!commit) {
230 return undefined;
231 }
232 const title = commit.title;
233 return get(canopySignalsByTitle).get(title);
234 }),
235);
236
237/**
238 * Latest commit message (title,description) for a hash.
239 * There's multiple competing values, in order of priority:
240 * (1) the optimistic commit's message
241 * (2) the latest commit message on the server (phabricator/github)
242 * (3) the local commit's message
243 *
244 * Remote messages preferred above local messages, so you see remote changes accounted for.
245 * Optimistic changes preferred above remote changes, since we should always
246 * async update the remote message to match the optimistic state anyway, but the UI will
247 * be smoother if we use the optimistic one before the remote has gotten the update propagated.
248 * This is only necessary if the optimistic message is different than the local message.
249 */
250export const latestCommitMessage = atomFamilyWeak((hash: Hash | 'head') =>
251 atom((get): [title: string, description: string] => {
252 if (hash === 'head') {
253 const template = get(commitMessageTemplate);
254 if (template) {
255 const schema = get(commitMessageFieldsSchema);
256 const result = applyEditedFields(emptyCommitMessageFields(schema), template);
257 const templateString = commitMessageFieldsToString(
258 schema,
259 result,
260 /* allowEmptyTitle */ true,
261 );
262 const title = firstLine(templateString);
263 const description = templateString.slice(title.length);
264 return [title, description];
265 }
266 return ['', ''];
267 }
268 const commit = get(commitByHash(hash));
269 const preview = get(dagWithPreviews).get(hash);
270
271 if (
272 preview != null &&
273 (preview.title !== commit?.title || preview.description !== commit?.description)
274 ) {
275 return [preview.title, preview.description];
276 }
277
278 if (!commit) {
279 return ['', ''];
280 }
281
282 const syncEnabled = get(messageSyncingEnabledState);
283
284 let remoteTitle = commit.title;
285 let remoteDescription = commit.description;
286 if (syncEnabled && commit.diffId) {
287 // use the diff's commit message instead of the local one, if available
288 const summary = get(diffSummary(commit.diffId));
289 if (summary?.value) {
290 remoteTitle = summary.value.title;
291 remoteDescription = summary.value.commitMessage;
292 }
293 }
294
295 return [remoteTitle, remoteDescription];
296 }),
297);
298
299export const latestCommitMessageTitle = atomFamilyWeak((hashOrHead: Hash | 'head') =>
300 atom(get => {
301 const [title] = get(latestCommitMessage(hashOrHead));
302 return title;
303 }),
304);
305
306export const latestCommitMessageFields = atomFamilyWeak((hashOrHead: Hash | 'head') =>
307 atom(get => {
308 const [title, description] = get(latestCommitMessage(hashOrHead));
309 const schema = get(commitMessageFieldsSchema);
310 return parseCommitMessageFields(schema, title, description);
311 }),
312);
313
314export const pageVisibility = atomWithOnChange(
315 atom<PageVisibility>(document.hasFocus() ? 'focused' : document.visibilityState),
316 debounce(state => {
317 serverAPI.postMessage({
318 type: 'pageVisibility',
319 state,
320 });
321 }, 50),
322);
323
324const handleVisibilityChange = () => {
325 const newValue = document.hasFocus() ? 'focused' : document.visibilityState;
326 writeAtom(pageVisibility, oldValue => {
327 if (oldValue !== newValue && newValue === 'hidden') {
328 clearTrackedCache();
329 }
330 return newValue;
331 });
332};
333
334window.addEventListener('focus', handleVisibilityChange);
335window.addEventListener('blur', handleVisibilityChange);
336document.addEventListener('visibilitychange', handleVisibilityChange);
337registerCleanup(
338 pageVisibility,
339 () => {
340 document.removeEventListener('visibilitychange', handleVisibilityChange);
341 window.removeEventListener('focus', handleVisibilityChange);
342 window.removeEventListener('blur', handleVisibilityChange);
343 },
344 import.meta.hot,
345);
346