| 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 {startTransition} from 'react'; |
| 9 | import type {MessageBusStatus} from './MessageBus'; |
| 10 | import type { |
| 11 | AbsolutePath, |
| 12 | ApplicationInfo, |
| 13 | ChangedFile, |
| 14 | CommitInfo, |
| 15 | MergeConflicts, |
| 16 | RepoInfo, |
| 17 | SmartlogCommits, |
| 18 | SubmodulesByRoot, |
| 19 | SubscriptionKind, |
| 20 | SubscriptionResultsData, |
| 21 | UncommittedChanges, |
| 22 | ValidatedRepoInfo, |
| 23 | } from './types'; |
| 24 | |
| 25 | import {Set as ImSet} from 'immutable'; |
| 26 | import {DEFAULT_DAYS_OF_COMMITS_TO_LOAD} from 'isl-server/src/constants'; |
| 27 | import type {Atom} from 'jotai'; |
| 28 | import {atom} from 'jotai'; |
| 29 | import {reuseEqualObjects} from 'shared/deepEqualExt'; |
| 30 | import {randomId} from 'shared/utils'; |
| 31 | import { |
| 32 | type BookmarksData, |
| 33 | bookmarksDataStorage, |
| 34 | recommendedBookmarksAtom, |
| 35 | recommendedBookmarksAvailableAtom, |
| 36 | REMOTE_MASTER_BOOKMARK, |
| 37 | } from './BookmarksData'; |
| 38 | import serverAPI from './ClientToServerAPI'; |
| 39 | import {hiddenMasterFeatureAvailableAtom, shouldHideMasterAtom} from './HiddenMasterData'; |
| 40 | import type {InternalTypes} from './InternalTypes'; |
| 41 | import {latestSuccessorsMapAtom, successionTracker} from './SuccessionTracker'; |
| 42 | import {Dag, DagCommitInfo} from './dag/dag'; |
| 43 | import {readInterestingAtoms, serializeAtomsState} from './debug/getInterestingAtoms'; |
| 44 | import {atomFamilyWeak, configBackedAtom, readAtom, writeAtom} from './jotaiUtils'; |
| 45 | import platform from './platform'; |
| 46 | import {atomResetOnCwdChange, repositoryData} from './repositoryData'; |
| 47 | import {registerCleanup, registerDisposable} from './utils'; |
| 48 | |
| 49 | export {repositoryData}; |
| 50 | |
| 51 | registerDisposable( |
| 52 | repositoryData, |
| 53 | serverAPI.onMessageOfType('repoInfo', event => { |
| 54 | writeAtom(repositoryData, {info: event.info, cwd: event.cwd}); |
| 55 | }), |
| 56 | import.meta.hot, |
| 57 | ); |
| 58 | registerCleanup( |
| 59 | repositoryData, |
| 60 | serverAPI.onSetup(() => |
| 61 | serverAPI.postMessage({ |
| 62 | type: 'requestRepoInfo', |
| 63 | }), |
| 64 | ), |
| 65 | import.meta.hot, |
| 66 | ); |
| 67 | |
| 68 | export const repositoryInfoOrError = atom( |
| 69 | get => { |
| 70 | const data = get(repositoryData); |
| 71 | return data?.info; |
| 72 | }, |
| 73 | ( |
| 74 | get, |
| 75 | set, |
| 76 | update: RepoInfo | undefined | ((_prev: RepoInfo | undefined) => RepoInfo | undefined), |
| 77 | ) => { |
| 78 | const value = typeof update === 'function' ? update(get(repositoryData)?.info) : update; |
| 79 | set(repositoryData, last => ({ |
| 80 | ...last, |
| 81 | info: value, |
| 82 | })); |
| 83 | }, |
| 84 | ); |
| 85 | |
| 86 | /** ValidatedRepoInfo, or undefined on error. */ |
| 87 | export const repositoryInfo = atom( |
| 88 | get => { |
| 89 | const info = get(repositoryInfoOrError); |
| 90 | if (info?.type === 'success') { |
| 91 | return info; |
| 92 | } |
| 93 | return undefined; |
| 94 | }, |
| 95 | ( |
| 96 | get, |
| 97 | set, |
| 98 | update: |
| 99 | | ValidatedRepoInfo |
| 100 | | undefined |
| 101 | | ((_prev: ValidatedRepoInfo | undefined) => ValidatedRepoInfo | undefined), |
| 102 | ) => { |
| 103 | const value = typeof update === 'function' ? update(get(repositoryInfo)) : update; |
| 104 | set(repositoryData, last => ({ |
| 105 | ...last, |
| 106 | info: value, |
| 107 | })); |
| 108 | }, |
| 109 | ); |
| 110 | |
| 111 | /** Main command name, like 'sl'. */ |
| 112 | export const mainCommandName = atom(get => { |
| 113 | const info = get(repositoryInfo); |
| 114 | return info?.command ?? 'sl'; |
| 115 | }); |
| 116 | |
| 117 | /** List of repo roots. Useful when cwd is in nested submodules. */ |
| 118 | export const repoRoots = atom(get => { |
| 119 | const info = get(repositoryInfo); |
| 120 | return info?.repoRoots; |
| 121 | }); |
| 122 | |
| 123 | export const applicationinfo = atom<ApplicationInfo | undefined>(undefined); |
| 124 | registerDisposable( |
| 125 | applicationinfo, |
| 126 | serverAPI.onMessageOfType('applicationInfo', event => { |
| 127 | writeAtom(applicationinfo, event.info); |
| 128 | }), |
| 129 | import.meta.hot, |
| 130 | ); |
| 131 | registerCleanup( |
| 132 | applicationinfo, |
| 133 | serverAPI.onSetup(() => |
| 134 | serverAPI.postMessage({ |
| 135 | type: 'requestApplicationInfo', |
| 136 | }), |
| 137 | ), |
| 138 | import.meta.hot, |
| 139 | ); |
| 140 | |
| 141 | export const watchmanStatus = atom< |
| 142 | 'initializing' | 'reconnecting' | 'healthy' | 'ended' | 'errored' | 'unavailable' | undefined |
| 143 | >(undefined); |
| 144 | registerDisposable( |
| 145 | watchmanStatus, |
| 146 | serverAPI.onMessageOfType('watchmanStatus', event => { |
| 147 | writeAtom(watchmanStatus, event.status); |
| 148 | }), |
| 149 | import.meta.hot, |
| 150 | ); |
| 151 | |
| 152 | export const reconnectingStatus = atom<MessageBusStatus>({type: 'initializing'}); |
| 153 | registerDisposable( |
| 154 | reconnectingStatus, |
| 155 | platform.messageBus.onChangeStatus(status => { |
| 156 | writeAtom(reconnectingStatus, status); |
| 157 | }), |
| 158 | import.meta.hot, |
| 159 | ); |
| 160 | |
| 161 | export async function forceFetchCommit(revset: string): Promise<CommitInfo> { |
| 162 | serverAPI.postMessage({ |
| 163 | type: 'fetchLatestCommit', |
| 164 | revset, |
| 165 | }); |
| 166 | const response = await serverAPI.nextMessageMatching( |
| 167 | 'fetchedLatestCommit', |
| 168 | message => message.revset === revset, |
| 169 | ); |
| 170 | if (response.info.error) { |
| 171 | throw response.info.error; |
| 172 | } |
| 173 | return response.info.value; |
| 174 | } |
| 175 | |
| 176 | export const mostRecentSubscriptionIds: Record<SubscriptionKind, string> = { |
| 177 | smartlogCommits: '', |
| 178 | uncommittedChanges: '', |
| 179 | mergeConflicts: '', |
| 180 | submodules: '', |
| 181 | subscribedFullRepoBranches: '', |
| 182 | }; |
| 183 | |
| 184 | /** |
| 185 | * Send a subscribeFoo message to the server on initialization, |
| 186 | * and send an unsubscribe message on dispose. |
| 187 | * Extract subscription response messages via a unique subscriptionID per effect call. |
| 188 | */ |
| 189 | function subscriptionEffect<K extends SubscriptionKind>( |
| 190 | kind: K, |
| 191 | onData: (data: SubscriptionResultsData[K]) => unknown, |
| 192 | ): () => void { |
| 193 | const subscriptionID = randomId(); |
| 194 | mostRecentSubscriptionIds[kind] = subscriptionID; |
| 195 | const disposable = serverAPI.onMessageOfType('subscriptionResult', event => { |
| 196 | if (event.subscriptionID !== subscriptionID || event.kind !== kind) { |
| 197 | return; |
| 198 | } |
| 199 | // Defer off the current task so ongoing user interactions (e.g. drag) aren't blocked. |
| 200 | // startTransition alone isn't enough since the message handler runs synchronously |
| 201 | // on the same task as the drag mousemove handler. |
| 202 | setTimeout(() => { |
| 203 | startTransition(() => { |
| 204 | onData(event.data as SubscriptionResultsData[K]); |
| 205 | }); |
| 206 | }, 0); |
| 207 | }); |
| 208 | |
| 209 | const disposeSubscription = serverAPI.onSetup(() => { |
| 210 | serverAPI.postMessage({ |
| 211 | type: 'subscribe', |
| 212 | kind, |
| 213 | subscriptionID, |
| 214 | }); |
| 215 | |
| 216 | return () => |
| 217 | serverAPI.postMessage({ |
| 218 | type: 'unsubscribe', |
| 219 | kind, |
| 220 | subscriptionID, |
| 221 | }); |
| 222 | }); |
| 223 | |
| 224 | return () => { |
| 225 | disposable.dispose(); |
| 226 | disposeSubscription(); |
| 227 | }; |
| 228 | } |
| 229 | |
| 230 | export const latestUncommittedChangesData = atom<{ |
| 231 | fetchStartTimestamp: number; |
| 232 | fetchCompletedTimestamp: number; |
| 233 | files: UncommittedChanges; |
| 234 | error?: Error; |
| 235 | }>({fetchStartTimestamp: 0, fetchCompletedTimestamp: 0, files: []}); |
| 236 | // This is used by a test. Tests do not go through babel to rewrite source |
| 237 | // to insert debugLabel. |
| 238 | latestUncommittedChangesData.debugLabel = 'latestUncommittedChangesData'; |
| 239 | |
| 240 | registerCleanup( |
| 241 | latestUncommittedChangesData, |
| 242 | subscriptionEffect('uncommittedChanges', data => { |
| 243 | writeAtom(latestUncommittedChangesData, last => ({ |
| 244 | ...data, |
| 245 | files: |
| 246 | data.files.value ?? |
| 247 | // leave existing files in place if there was no error |
| 248 | (last.error == null ? [] : last.files) ?? |
| 249 | [], |
| 250 | error: data.files.error, |
| 251 | })); |
| 252 | }), |
| 253 | import.meta.hot, |
| 254 | ); |
| 255 | |
| 256 | /** |
| 257 | * Latest fetched uncommitted file changes from the server, without any previews. |
| 258 | * Prefer using `uncommittedChangesWithPreviews`, since it includes optimistic state |
| 259 | * and previews. |
| 260 | */ |
| 261 | export const latestUncommittedChanges = atom<Array<ChangedFile>>( |
| 262 | get => get(latestUncommittedChangesData).files, |
| 263 | ); |
| 264 | |
| 265 | export const uncommittedChangesFetchError = atom(get => { |
| 266 | return get(latestUncommittedChangesData).error; |
| 267 | }); |
| 268 | |
| 269 | export const mergeConflicts = atom<MergeConflicts | undefined>(undefined); |
| 270 | registerCleanup( |
| 271 | mergeConflicts, |
| 272 | subscriptionEffect('mergeConflicts', data => { |
| 273 | writeAtom(mergeConflicts, data); |
| 274 | }), |
| 275 | ); |
| 276 | |
| 277 | export const inMergeConflicts = atom(get => get(mergeConflicts) != undefined); |
| 278 | |
| 279 | export const latestCommitsData = atom<{ |
| 280 | fetchStartTimestamp: number; |
| 281 | fetchCompletedTimestamp: number; |
| 282 | commits: SmartlogCommits; |
| 283 | error?: Error; |
| 284 | }>({fetchStartTimestamp: 0, fetchCompletedTimestamp: 0, commits: []}); |
| 285 | |
| 286 | registerCleanup( |
| 287 | latestCommitsData, |
| 288 | subscriptionEffect('smartlogCommits', data => { |
| 289 | const previousDag = readAtom(latestDag); |
| 290 | writeAtom(latestCommitsData, last => { |
| 291 | let commits = last.commits; |
| 292 | const newCommits = data.commits.value; |
| 293 | if (newCommits != null) { |
| 294 | // leave existing commits in place if there was no error |
| 295 | commits = reuseEqualObjects(commits, newCommits, c => c.hash); |
| 296 | } |
| 297 | return { |
| 298 | ...data, |
| 299 | commits, |
| 300 | error: data.commits.error, |
| 301 | }; |
| 302 | }); |
| 303 | if (data.commits.value) { |
| 304 | successionTracker.findNewSuccessionsFromCommits(previousDag, data.commits.value); |
| 305 | } |
| 306 | }), |
| 307 | ); |
| 308 | |
| 309 | export const latestUncommittedChangesTimestamp = atom(get => { |
| 310 | return get(latestUncommittedChangesData).fetchCompletedTimestamp; |
| 311 | }); |
| 312 | |
| 313 | /** |
| 314 | * Lookup a commit by hash, *WITHOUT PREVIEWS*. |
| 315 | * Generally, you'd want to look up WITH previews, which you can use dagWithPreviews for. |
| 316 | */ |
| 317 | export const commitByHash = atomFamilyWeak((hash: string) => atom(get => get(latestDag).get(hash))); |
| 318 | |
| 319 | export const latestCommits = atom(get => { |
| 320 | return get(latestCommitsData).commits; |
| 321 | }); |
| 322 | |
| 323 | /** The dag also includes a mutationDag to answer successor queries. */ |
| 324 | export const latestDag = atom(get => { |
| 325 | const commits = get(latestCommits); |
| 326 | const successorMap = get(latestSuccessorsMapAtom); |
| 327 | const bookmarksData = get(bookmarksDataStorage); |
| 328 | const recommendedBookmarksAvailable = get(recommendedBookmarksAvailableAtom); |
| 329 | const enableRecommended = bookmarksData.useRecommendedBookmark && recommendedBookmarksAvailable; |
| 330 | const recommendedBookmarks = get(recommendedBookmarksAtom); |
| 331 | const shouldHideMaster = get(shouldHideMasterAtom); |
| 332 | const hiddenMasterFeatureAvailable = get(hiddenMasterFeatureAvailableAtom); |
| 333 | const commitDag = undefined; // will be populated from `commits` |
| 334 | |
| 335 | const dag = Dag.fromDag(commitDag, successorMap) |
| 336 | .add( |
| 337 | commits.map(c => { |
| 338 | return DagCommitInfo.fromCommitInfo( |
| 339 | filterBookmarks( |
| 340 | bookmarksData, |
| 341 | c, |
| 342 | Boolean(enableRecommended), |
| 343 | recommendedBookmarks, |
| 344 | shouldHideMaster, |
| 345 | hiddenMasterFeatureAvailable, |
| 346 | ), |
| 347 | ); |
| 348 | }), |
| 349 | ) |
| 350 | .maybeForceConnectPublic(); |
| 351 | return dag; |
| 352 | }); |
| 353 | |
| 354 | function filterBookmarks( |
| 355 | bookmarksData: BookmarksData, |
| 356 | commit: CommitInfo, |
| 357 | enableRecommended: boolean, |
| 358 | recommendedBookmarks: Set<string>, |
| 359 | shouldHideMaster: boolean, |
| 360 | hiddenMasterFeatureAvailable: boolean, |
| 361 | ): CommitInfo { |
| 362 | if (commit.phase !== 'public') { |
| 363 | return commit; |
| 364 | } |
| 365 | |
| 366 | const hiddenBookmarks = new Set(bookmarksData.hiddenRemoteBookmarks); |
| 367 | |
| 368 | const bookmarkFilter = (b: string) => { |
| 369 | // When hidden master feature is available, handle remote/master visibility separately |
| 370 | if (b === REMOTE_MASTER_BOOKMARK && hiddenMasterFeatureAvailable) { |
| 371 | const visibility = bookmarksData.masterBookmarkVisibility; |
| 372 | if (visibility === 'show') { |
| 373 | return true; |
| 374 | } |
| 375 | if (visibility === 'hide') { |
| 376 | return false; |
| 377 | } |
| 378 | // visibility === 'auto' or undefined - use sitevar config |
| 379 | return !shouldHideMaster; |
| 380 | } |
| 381 | |
| 382 | // For all other bookmarks (and remote/master when feature is not available), hide if in hidden list |
| 383 | if (hiddenBookmarks.has(b)) { |
| 384 | return false; |
| 385 | } |
| 386 | |
| 387 | // If recommended bookmarks are enabled, hide all bookmarks except the recommended ones and master |
| 388 | return !enableRecommended || recommendedBookmarks.has(b) || b === REMOTE_MASTER_BOOKMARK; |
| 389 | }; |
| 390 | |
| 391 | return { |
| 392 | ...commit, |
| 393 | remoteBookmarks: commit.remoteBookmarks.filter(bookmarkFilter), |
| 394 | bookmarks: commit.bookmarks.filter(bookmarkFilter), |
| 395 | stableCommitMetadata: commit.stableCommitMetadata?.filter(b => !hiddenBookmarks.has(b.value)), |
| 396 | }; |
| 397 | } |
| 398 | |
| 399 | export const commitFetchError = atom(get => { |
| 400 | return get(latestCommitsData).error; |
| 401 | }); |
| 402 | |
| 403 | export const authorString = configBackedAtom<string | null>( |
| 404 | 'ui.username', |
| 405 | null, |
| 406 | true /* read-only */, |
| 407 | true /* use raw value */, |
| 408 | ); |
| 409 | |
| 410 | export const isFetchingCommits = atom(false); |
| 411 | registerDisposable( |
| 412 | isFetchingCommits, |
| 413 | serverAPI.onMessageOfType('subscriptionResult', () => { |
| 414 | writeAtom(isFetchingCommits, false); // new commits OR error means the fetch is not running anymore |
| 415 | }), |
| 416 | import.meta.hot, |
| 417 | ); |
| 418 | registerDisposable( |
| 419 | isFetchingCommits, |
| 420 | serverAPI.onMessageOfType('beganFetchingSmartlogCommitsEvent', () => { |
| 421 | writeAtom(isFetchingCommits, true); |
| 422 | }), |
| 423 | import.meta.hot, |
| 424 | ); |
| 425 | |
| 426 | export const isFetchingAdditionalCommits = atom(false); |
| 427 | registerDisposable( |
| 428 | isFetchingAdditionalCommits, |
| 429 | serverAPI.onMessageOfType('subscriptionResult', e => { |
| 430 | if (e.kind === 'smartlogCommits') { |
| 431 | writeAtom(isFetchingAdditionalCommits, false); |
| 432 | } |
| 433 | }), |
| 434 | import.meta.hot, |
| 435 | ); |
| 436 | registerDisposable( |
| 437 | isFetchingAdditionalCommits, |
| 438 | serverAPI.onMessageOfType('subscriptionResult', e => { |
| 439 | if (e.kind === 'smartlogCommits') { |
| 440 | writeAtom(isFetchingAdditionalCommits, false); |
| 441 | } |
| 442 | }), |
| 443 | import.meta.hot, |
| 444 | ); |
| 445 | registerDisposable( |
| 446 | isFetchingAdditionalCommits, |
| 447 | serverAPI.onMessageOfType('beganLoadingMoreCommits', () => { |
| 448 | writeAtom(isFetchingAdditionalCommits, true); |
| 449 | }), |
| 450 | import.meta.hot, |
| 451 | ); |
| 452 | |
| 453 | export const isFetchingUncommittedChanges = atom(false); |
| 454 | registerDisposable( |
| 455 | isFetchingUncommittedChanges, |
| 456 | serverAPI.onMessageOfType('subscriptionResult', e => { |
| 457 | if (e.kind === 'uncommittedChanges') { |
| 458 | writeAtom(isFetchingUncommittedChanges, false); // new files OR error means the fetch is not running anymore |
| 459 | } |
| 460 | }), |
| 461 | import.meta.hot, |
| 462 | ); |
| 463 | registerDisposable( |
| 464 | isFetchingUncommittedChanges, |
| 465 | serverAPI.onMessageOfType('beganFetchingUncommittedChangesEvent', () => { |
| 466 | writeAtom(isFetchingUncommittedChanges, true); |
| 467 | }), |
| 468 | import.meta.hot, |
| 469 | ); |
| 470 | |
| 471 | export const commitsShownRange = atomResetOnCwdChange<number | undefined>( |
| 472 | DEFAULT_DAYS_OF_COMMITS_TO_LOAD, |
| 473 | ); |
| 474 | registerDisposable( |
| 475 | applicationinfo, |
| 476 | serverAPI.onMessageOfType('commitsShownRange', event => { |
| 477 | writeAtom(commitsShownRange, event.rangeInDays); |
| 478 | }), |
| 479 | import.meta.hot, |
| 480 | ); |
| 481 | |
| 482 | /** |
| 483 | * Latest head commit from original data from the server, without any previews. |
| 484 | * Prefer using `dagWithPreviews.resolve('.')`, since it includes optimistic state |
| 485 | * and previews. |
| 486 | */ |
| 487 | export const latestHeadCommit = atom(get => { |
| 488 | const commits = get(latestCommits); |
| 489 | return commits.find(commit => commit.isDot); |
| 490 | }); |
| 491 | |
| 492 | /** |
| 493 | * No longer in the "loading" state: |
| 494 | * - Either the list of commits has successfully loaded |
| 495 | * - or there was an error during the fetch |
| 496 | */ |
| 497 | export const haveCommitsLoadedYet = atom(get => { |
| 498 | const data = get(latestCommitsData); |
| 499 | return data.commits.length > 0 || data.error != null; |
| 500 | }); |
| 501 | |
| 502 | export const haveRemotePath = atom(get => { |
| 503 | const info = get(repositoryInfo); |
| 504 | // codeReviewSystem.type is 'unknown' or other values if paths.default is present. |
| 505 | return info?.type === 'success' && info.codeReviewSystem.type !== 'none'; |
| 506 | }); |
| 507 | |
| 508 | registerDisposable( |
| 509 | serverAPI, |
| 510 | serverAPI.onMessageOfType('getUiState', () => { |
| 511 | const state = readInterestingAtoms(); |
| 512 | window.clientToServerAPI?.postMessage({ |
| 513 | type: 'gotUiState', |
| 514 | state: JSON.stringify(serializeAtomsState(state), undefined, 2), |
| 515 | }); |
| 516 | }), |
| 517 | import.meta.hot, |
| 518 | ); |
| 519 | |
| 520 | export const submodulesByRoot = atom<SubmodulesByRoot>(new Map()); |
| 521 | |
| 522 | registerCleanup( |
| 523 | submodulesByRoot, |
| 524 | subscriptionEffect('submodules', fetchedSubmoduleMap => { |
| 525 | writeAtom(submodulesByRoot, _prev_data => { |
| 526 | // TODO: In the future we may add more granular client-server API |
| 527 | // to update submodules. For now we just replace the whole map when the active repo updates. |
| 528 | return fetchedSubmoduleMap; |
| 529 | }); |
| 530 | }), |
| 531 | import.meta.hot, |
| 532 | ); |
| 533 | |
| 534 | export const submodulePathsByRoot = atomFamilyWeak<AbsolutePath, Atom<ImSet<string> | undefined>>( |
| 535 | (root: AbsolutePath) => |
| 536 | atom(get => { |
| 537 | const paths = get(submodulesByRoot) |
| 538 | .get(root) |
| 539 | ?.value?.map(m => m.path); |
| 540 | return paths ? ImSet(paths) : undefined; |
| 541 | }), |
| 542 | ); |
| 543 | |
| 544 | export const subscribedFullRepoBranches = atom<Array<InternalTypes['FullRepoBranch']>>([]); |
| 545 | |
| 546 | registerCleanup( |
| 547 | subscribedFullRepoBranches, |
| 548 | subscriptionEffect('subscribedFullRepoBranches', data => { |
| 549 | writeAtom(subscribedFullRepoBranches, _ => data); |
| 550 | }), |
| 551 | ); |
| 552 | |