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