| 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 | |
| 8 | import type {DiffId, DiffSignalSummary, DiffSummary, Hash, PageVisibility, Result, ValidatedRepoInfo} from '../types'; |
| 9 | import type {UICodeReviewProvider} from './UICodeReviewProvider'; |
| 10 | |
| 11 | import {startTransition} from 'react'; |
| 12 | import {atom} from 'jotai'; |
| 13 | import {clearTrackedCache} from 'shared/LRU'; |
| 14 | import {debounce} from 'shared/debounce'; |
| 15 | import {firstLine, nullthrows} from 'shared/utils'; |
| 16 | import serverAPI from '../ClientToServerAPI'; |
| 17 | import {commitMessageTemplate} from '../CommitInfoView/CommitInfoState'; |
| 18 | import { |
| 19 | applyEditedFields, |
| 20 | commitMessageFieldsSchema, |
| 21 | commitMessageFieldsToString, |
| 22 | emptyCommitMessageFields, |
| 23 | parseCommitMessageFields, |
| 24 | } from '../CommitInfoView/CommitMessageFields'; |
| 25 | import {Internal} from '../Internal'; |
| 26 | import {getTracker} from '../analytics/globalTracker'; |
| 27 | import {atomFamilyWeak, atomWithOnChange, writeAtom} from '../jotaiUtils'; |
| 28 | import {messageSyncingEnabledState} from '../messageSyncing'; |
| 29 | import {dagWithPreviews} from '../previews'; |
| 30 | import {commitByHash, repositoryInfo} from '../serverAPIState'; |
| 31 | import {registerCleanup, registerDisposable} from '../utils'; |
| 32 | import {GithubUICodeReviewProvider} from './github/github'; |
| 33 | import {GroveUICodeReviewProvider} from './grove/grove'; |
| 34 | |
| 35 | export const codeReviewProvider = atom<UICodeReviewProvider | null>(get => { |
| 36 | const repoInfo = get(repositoryInfo); |
| 37 | return repoInfoToCodeReviewProvider(repoInfo); |
| 38 | }); |
| 39 | |
| 40 | function 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 | |
| 62 | export 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 | */ |
| 83 | export 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 | |
| 93 | export 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 | |
| 111 | export const allDiffSummaries = atom<Result<Map<DiffId, DiffSummary> | null>>({value: null}); |
| 112 | export const diffIdsByBranchName = atom<Map<string, DiffId>>(new Map()); |
| 113 | export const diffIdsByCommitHash = atom<Map<Hash, DiffId>>(new Map()); |
| 114 | |
| 115 | registerDisposable( |
| 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 | |
| 175 | registerCleanup( |
| 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 | |
| 186 | export type CanopySignalInfo = {signal: DiffSignalSummary; runId?: number; commitId?: string}; |
| 187 | |
| 188 | /** Canopy CI/CD build signals keyed by commit message title. */ |
| 189 | export const canopySignalsByTitle = atom<Map<string, CanopySignalInfo>>(new Map()); |
| 190 | |
| 191 | registerDisposable( |
| 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 | |
| 214 | let canopySignalsFetchRequested = false; |
| 215 | /** Request Canopy signals from the server. Only sends the request once. */ |
| 216 | export 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. */ |
| 225 | export 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 | */ |
| 250 | export 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 | |
| 299 | export const latestCommitMessageTitle = atomFamilyWeak((hashOrHead: Hash | 'head') => |
| 300 | atom(get => { |
| 301 | const [title] = get(latestCommitMessage(hashOrHead)); |
| 302 | return title; |
| 303 | }), |
| 304 | ); |
| 305 | |
| 306 | export 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 | |
| 314 | export 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 | |
| 324 | const 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 | |
| 334 | window.addEventListener('focus', handleVisibilityChange); |
| 335 | window.addEventListener('blur', handleVisibilityChange); |
| 336 | document.addEventListener('visibilitychange', handleVisibilityChange); |
| 337 | registerCleanup( |
| 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 | |