15.8 KB552 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 {startTransition} from 'react';
9import type {MessageBusStatus} from './MessageBus';
10import 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
25import {Set as ImSet} from 'immutable';
26import {DEFAULT_DAYS_OF_COMMITS_TO_LOAD} from 'isl-server/src/constants';
27import type {Atom} from 'jotai';
28import {atom} from 'jotai';
29import {reuseEqualObjects} from 'shared/deepEqualExt';
30import {randomId} from 'shared/utils';
31import {
32 type BookmarksData,
33 bookmarksDataStorage,
34 recommendedBookmarksAtom,
35 recommendedBookmarksAvailableAtom,
36 REMOTE_MASTER_BOOKMARK,
37} from './BookmarksData';
38import serverAPI from './ClientToServerAPI';
39import {hiddenMasterFeatureAvailableAtom, shouldHideMasterAtom} from './HiddenMasterData';
40import type {InternalTypes} from './InternalTypes';
41import {latestSuccessorsMapAtom, successionTracker} from './SuccessionTracker';
42import {Dag, DagCommitInfo} from './dag/dag';
43import {readInterestingAtoms, serializeAtomsState} from './debug/getInterestingAtoms';
44import {atomFamilyWeak, configBackedAtom, readAtom, writeAtom} from './jotaiUtils';
45import platform from './platform';
46import {atomResetOnCwdChange, repositoryData} from './repositoryData';
47import {registerCleanup, registerDisposable} from './utils';
48
49export {repositoryData};
50
51registerDisposable(
52 repositoryData,
53 serverAPI.onMessageOfType('repoInfo', event => {
54 writeAtom(repositoryData, {info: event.info, cwd: event.cwd});
55 }),
56 import.meta.hot,
57);
58registerCleanup(
59 repositoryData,
60 serverAPI.onSetup(() =>
61 serverAPI.postMessage({
62 type: 'requestRepoInfo',
63 }),
64 ),
65 import.meta.hot,
66);
67
68export 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. */
87export 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'. */
112export 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. */
118export const repoRoots = atom(get => {
119 const info = get(repositoryInfo);
120 return info?.repoRoots;
121});
122
123export const applicationinfo = atom<ApplicationInfo | undefined>(undefined);
124registerDisposable(
125 applicationinfo,
126 serverAPI.onMessageOfType('applicationInfo', event => {
127 writeAtom(applicationinfo, event.info);
128 }),
129 import.meta.hot,
130);
131registerCleanup(
132 applicationinfo,
133 serverAPI.onSetup(() =>
134 serverAPI.postMessage({
135 type: 'requestApplicationInfo',
136 }),
137 ),
138 import.meta.hot,
139);
140
141export const watchmanStatus = atom<
142 'initializing' | 'reconnecting' | 'healthy' | 'ended' | 'errored' | 'unavailable' | undefined
143>(undefined);
144registerDisposable(
145 watchmanStatus,
146 serverAPI.onMessageOfType('watchmanStatus', event => {
147 writeAtom(watchmanStatus, event.status);
148 }),
149 import.meta.hot,
150);
151
152export const reconnectingStatus = atom<MessageBusStatus>({type: 'initializing'});
153registerDisposable(
154 reconnectingStatus,
155 platform.messageBus.onChangeStatus(status => {
156 writeAtom(reconnectingStatus, status);
157 }),
158 import.meta.hot,
159);
160
161export 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
176export 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 */
189function 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
230export 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.
238latestUncommittedChangesData.debugLabel = 'latestUncommittedChangesData';
239
240registerCleanup(
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 */
261export const latestUncommittedChanges = atom<Array<ChangedFile>>(
262 get => get(latestUncommittedChangesData).files,
263);
264
265export const uncommittedChangesFetchError = atom(get => {
266 return get(latestUncommittedChangesData).error;
267});
268
269export const mergeConflicts = atom<MergeConflicts | undefined>(undefined);
270registerCleanup(
271 mergeConflicts,
272 subscriptionEffect('mergeConflicts', data => {
273 writeAtom(mergeConflicts, data);
274 }),
275);
276
277export const inMergeConflicts = atom(get => get(mergeConflicts) != undefined);
278
279export const latestCommitsData = atom<{
280 fetchStartTimestamp: number;
281 fetchCompletedTimestamp: number;
282 commits: SmartlogCommits;
283 error?: Error;
284}>({fetchStartTimestamp: 0, fetchCompletedTimestamp: 0, commits: []});
285
286registerCleanup(
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
309export 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 */
317export const commitByHash = atomFamilyWeak((hash: string) => atom(get => get(latestDag).get(hash)));
318
319export const latestCommits = atom(get => {
320 return get(latestCommitsData).commits;
321});
322
323/** The dag also includes a mutationDag to answer successor queries. */
324export 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
354function 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
399export const commitFetchError = atom(get => {
400 return get(latestCommitsData).error;
401});
402
403export const authorString = configBackedAtom<string | null>(
404 'ui.username',
405 null,
406 true /* read-only */,
407 true /* use raw value */,
408);
409
410export const isFetchingCommits = atom(false);
411registerDisposable(
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);
418registerDisposable(
419 isFetchingCommits,
420 serverAPI.onMessageOfType('beganFetchingSmartlogCommitsEvent', () => {
421 writeAtom(isFetchingCommits, true);
422 }),
423 import.meta.hot,
424);
425
426export const isFetchingAdditionalCommits = atom(false);
427registerDisposable(
428 isFetchingAdditionalCommits,
429 serverAPI.onMessageOfType('subscriptionResult', e => {
430 if (e.kind === 'smartlogCommits') {
431 writeAtom(isFetchingAdditionalCommits, false);
432 }
433 }),
434 import.meta.hot,
435);
436registerDisposable(
437 isFetchingAdditionalCommits,
438 serverAPI.onMessageOfType('subscriptionResult', e => {
439 if (e.kind === 'smartlogCommits') {
440 writeAtom(isFetchingAdditionalCommits, false);
441 }
442 }),
443 import.meta.hot,
444);
445registerDisposable(
446 isFetchingAdditionalCommits,
447 serverAPI.onMessageOfType('beganLoadingMoreCommits', () => {
448 writeAtom(isFetchingAdditionalCommits, true);
449 }),
450 import.meta.hot,
451);
452
453export const isFetchingUncommittedChanges = atom(false);
454registerDisposable(
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);
463registerDisposable(
464 isFetchingUncommittedChanges,
465 serverAPI.onMessageOfType('beganFetchingUncommittedChangesEvent', () => {
466 writeAtom(isFetchingUncommittedChanges, true);
467 }),
468 import.meta.hot,
469);
470
471export const commitsShownRange = atomResetOnCwdChange<number | undefined>(
472 DEFAULT_DAYS_OF_COMMITS_TO_LOAD,
473);
474registerDisposable(
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 */
487export 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 */
497export const haveCommitsLoadedYet = atom(get => {
498 const data = get(latestCommitsData);
499 return data.commits.length > 0 || data.error != null;
500});
501
502export 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
508registerDisposable(
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
520export const submodulesByRoot = atom<SubmodulesByRoot>(new Map());
521
522registerCleanup(
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
534export 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
544export const subscribedFullRepoBranches = atom<Array<InternalTypes['FullRepoBranch']>>([]);
545
546registerCleanup(
547 subscribedFullRepoBranches,
548 subscriptionEffect('subscribedFullRepoBranches', data => {
549 writeAtom(subscribedFullRepoBranches, _ => data);
550 }),
551);
552