18.4 KB545 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 {Hash, RepoPath} from 'shared/types/common';
9import type {ExportFile, ImportCommit} from 'shared/types/stack';
10import type {RepoRelativePath} from './types';
11
12import Immutable from 'immutable';
13import {atom, useAtom, useAtomValue} from 'jotai';
14import {RateLimiter} from 'shared/RateLimiter';
15import {SelfUpdate} from 'shared/immutableExt';
16import clientToServerAPI from './ClientToServerAPI';
17import {t} from './i18n';
18import {dagWithPreviews, uncommittedChangesWithPreviews} from './previews';
19import {atomResetOnCwdChange} from './repositoryData';
20import {latestUncommittedChangesTimestamp} from './serverAPIState';
21import {ChunkSelectState} from './stackEdit/chunkSelectState';
22import {assert} from './utils';
23
24type SingleFileSelection =
25 | false /* not selected */
26 | true /* selected, default */
27 | ChunkSelectState /* maybe partially selected */;
28
29type PartialSelectionProps = {
30 /** Explicitly set selection. */
31 fileMap: Immutable.Map<RepoRelativePath, SingleFileSelection>;
32 /** For files not in fileMap, whether they are selected or not. */
33 selectByDefault: boolean;
34 expanded: Immutable.Set<RepoRelativePath>;
35};
36const PartialSelectionRecord = Immutable.Record<PartialSelectionProps>({
37 fileMap: Immutable.Map(),
38 selectByDefault: true,
39 expanded: Immutable.Set(),
40});
41type PartialSelectionRecord = Immutable.RecordOf<PartialSelectionProps>;
42
43/**
44 * Selection of partial changes made by a commit.
45 *
46 * Intended to be useful for both concrete commits and the `wdir()` virtual commit.
47 * This class does not handle the differences between `wdir()` and concrete commits,
48 * like how to load the file content, and how to get the list of changed files.
49 * Those differences are handled at a higher level.
50 */
51export class PartialSelection extends SelfUpdate<PartialSelectionRecord> {
52 constructor(record: PartialSelectionRecord) {
53 super(record);
54 }
55
56 set<K extends keyof PartialSelectionProps>(
57 key: K,
58 value: PartialSelectionProps[K],
59 ): PartialSelection {
60 return new PartialSelection(this.inner.set(key, value));
61 }
62
63 /** Empty selection. */
64 static empty(props: {selectByDefault?: boolean}): PartialSelection {
65 return new PartialSelection(PartialSelectionRecord(props));
66 }
67
68 /** Explicitly select a file. */
69 select(path: RepoRelativePath): PartialSelection {
70 return new PartialSelection(this.inner.setIn(['fileMap', path], true));
71 }
72
73 /** Explicitly deselect a file. */
74 deselect(path: RepoRelativePath): PartialSelection {
75 return new PartialSelection(this.inner.setIn(['fileMap', path], false)).toggleExpand(
76 path,
77 false,
78 );
79 }
80
81 /** Reset to the "default" state. Useful for commit/amend. */
82 clear(): PartialSelection {
83 return this.set('fileMap', Immutable.Map()).set('expanded', Immutable.Set());
84 }
85
86 /** Toggle expansion of a file. */
87 toggleExpand(path: RepoRelativePath, select?: boolean): PartialSelection {
88 const expanded = this.inner.expanded;
89 const newExpanded =
90 (select ?? !expanded.has(path)) ? expanded.add(path) : expanded.remove(path);
91 return this.set('expanded', newExpanded);
92 }
93
94 /** Test if a file was expanded. */
95 isExpanded(path: RepoRelativePath): boolean {
96 return this.inner.expanded.has(path);
97 }
98
99 /** Drop "chunk selection" states. */
100 discardPartialSelections() {
101 const newFileMap = this.inner.fileMap.filter(
102 fileSelection => !(fileSelection instanceof ChunkSelectState),
103 );
104 return new PartialSelection(this.inner.merge({fileMap: newFileMap, expanded: Immutable.Set()}));
105 }
106
107 /** Start chunk selection for the given file. */
108 startChunkSelect(
109 path: RepoRelativePath,
110 a: string,
111 b: string,
112 selected: boolean | string,
113 normalize = false,
114 ): PartialSelection {
115 const chunkState = ChunkSelectState.fromText(a, b, selected, normalize);
116 return new PartialSelection(this.inner.setIn(['fileMap', path], chunkState));
117 }
118
119 /** Edit chunk selection for a file. */
120 editChunkSelect(
121 path: RepoRelativePath,
122 newValue: ((chunkState: ChunkSelectState) => ChunkSelectState) | ChunkSelectState,
123 ): PartialSelection {
124 const chunkState = this.inner.fileMap.get(path);
125 assert(
126 chunkState instanceof ChunkSelectState,
127 'PartialSelection.editChunkSelect() called without startChunkEdit',
128 );
129 const newChunkState = typeof newValue === 'function' ? newValue(chunkState) : newValue;
130 return new PartialSelection(this.inner.setIn(['fileMap', path], newChunkState));
131 }
132
133 getSelection(path: RepoRelativePath): SingleFileSelection {
134 const record = this.inner;
135 return record.fileMap.get(path) ?? record.selectByDefault;
136 }
137
138 /**
139 * Return true if a file is selected, false if deselected,
140 * or a string with the edited content.
141 * Even if the file is being chunk edited, this function might
142 * still return true or false.
143 */
144 getSimplifiedSelection(path: RepoRelativePath): boolean | string {
145 const selected = this.getSelection(path);
146 if (selected === true || selected === false) {
147 return selected;
148 }
149 const chunkState: ChunkSelectState = selected;
150 const text = chunkState.getSelectedText();
151 if (text === chunkState.a) {
152 return false;
153 }
154 if (text === chunkState.b) {
155 return true;
156 }
157 return text;
158 }
159
160 isFullyOrPartiallySelected(path: RepoRelativePath): boolean {
161 return this.getSimplifiedSelection(path) !== false;
162 }
163
164 isPartiallySelected(path: RepoRelativePath): boolean {
165 return typeof this.getSimplifiedSelection(path) !== 'boolean';
166 }
167
168 isFullySelected(path: RepoRelativePath): boolean {
169 return this.getSimplifiedSelection(path) === true;
170 }
171
172 isDeselected(path: RepoRelativePath): boolean {
173 return this.getSimplifiedSelection(path) === false;
174 }
175
176 isEverythingSelected(getAllPaths: () => Array<RepoRelativePath>): boolean {
177 const record = this.inner;
178 const paths = record.selectByDefault ? record.fileMap.keySeq() : getAllPaths();
179 return paths.every(p => this.getSimplifiedSelection(p) === true);
180 }
181
182 isNothingSelected(getAllPaths: () => Array<RepoRelativePath>): boolean {
183 const record = this.inner;
184 const paths = record.selectByDefault ? getAllPaths() : record.fileMap.keySeq();
185 return paths.every(p => this.getSimplifiedSelection(p) === false);
186 }
187
188 /**
189 * Produce a `ImportStack['files']` useful for the `debugimportstack` command
190 * to create commits.
191 *
192 * `allPaths` provides extra file paths to be considered. This is useful
193 * when we only track "deselected files".
194 */
195 calculateImportStackFiles(
196 allPaths: Array<RepoRelativePath>,
197 inverse = false,
198 ): ImportCommit['files'] {
199 const files: ImportCommit['files'] = {};
200 // Process files in the fileMap. Note: this map might only contain the "deselected"
201 // files, depending on selectByDefault.
202 const fileMap = this.inner.fileMap;
203 fileMap.forEach((fileSelection, path) => {
204 if (fileSelection instanceof ChunkSelectState) {
205 const text = inverse ? fileSelection.getInverseText() : fileSelection.getSelectedText();
206 if (inverse || text !== fileSelection.a) {
207 // The file is edited. Use the changed content.
208 files[path] = {data: text, copyFrom: '.', flags: '.'};
209 }
210 } else if (fileSelection === true) {
211 // '.' can be used for both inverse = true and false.
212 // - For inverse = true, '.' is used with the 'write' debugimportstack command.
213 // The 'write' command treats '.' as "working parent" to "revert" changes.
214 // - For inverse = false, '.' is used with the 'commit' or 'amend' debugimportstack
215 // commands. They treat '.' as "working copy" to "commit/amend" changes.
216 files[path] = '.';
217 }
218 });
219 // Process files outside the fileMap.
220 allPaths.forEach(path => {
221 if (!fileMap.has(path) && this.getSimplifiedSelection(path) !== false) {
222 files[path] = '.';
223 }
224 });
225 return files;
226 }
227
228 /** If any file is partially selected. */
229 hasChunkSelection(): boolean {
230 return this.inner.fileMap
231 .keySeq()
232 .some(p => typeof this.getSimplifiedSelection(p) !== 'boolean');
233 }
234
235 /** Get all paths with chunk selections (regardless of partial or not). */
236 chunkSelectionPaths(): Array<RepoRelativePath> {
237 return this.inner.fileMap
238 .filter((v, _path) => v instanceof ChunkSelectState)
239 .keySeq()
240 .toArray();
241 }
242}
243
244/** Default: select all files. */
245const defaultUncommittedPartialSelection = PartialSelection.empty({
246 selectByDefault: true,
247});
248
249/** PartialSelection for `wdir()`. See `UseUncommittedSelection` for the public API. */
250export const uncommittedSelection = atomResetOnCwdChange<PartialSelection>(
251 defaultUncommittedPartialSelection,
252);
253
254const wdirRev = 'wdir()';
255
256/** PartialSelection for `wdir()` that handles loading file contents. */
257export class UseUncommittedSelection {
258 // Persist across `UseUncommittedSelection` life cycles.
259 // Not an atom so updating the cache does not trigger re-render.
260 static fileContentCache: {
261 wdirHash: Hash;
262 files: Map<RepoPath, ExportFile | null>;
263 parentFiles: Map<RepoPath, ExportFile | null>;
264 asyncLoadingLock: RateLimiter;
265 epoch: number;
266 } = {
267 wdirHash: '',
268 files: new Map(),
269 parentFiles: new Map(),
270 asyncLoadingLock: new RateLimiter(1),
271 epoch: 0,
272 };
273
274 constructor(
275 public selection: PartialSelection,
276 private setSelection: (_v: PartialSelection) => void,
277 wdirHash: Hash,
278 private getPaths: () => Array<RepoRelativePath>,
279 epoch: number,
280 ) {
281 const cache = UseUncommittedSelection.fileContentCache;
282 if (wdirHash !== cache.wdirHash || epoch !== cache.epoch) {
283 // Invalidate existing cache when `.` or epoch changes.
284 cache.files.clear();
285 cache.parentFiles.clear();
286 cache.wdirHash = wdirHash;
287 cache.epoch = epoch;
288 }
289 }
290
291 /** Explicitly select a file. */
292 select(...paths: Array<RepoRelativePath>) {
293 let newSelection = this.selection;
294 for (const path of paths) {
295 newSelection = newSelection.select(path);
296 }
297 this.setSelection(newSelection);
298 }
299
300 selectAll() {
301 const newSelection = defaultUncommittedPartialSelection;
302 this.setSelection(newSelection);
303 }
304
305 /** Explicitly deselect a file. Also drops the related file content cache. */
306 deselect(...paths: Array<RepoRelativePath>) {
307 let newSelection = this.selection;
308 const cache = UseUncommittedSelection.fileContentCache;
309 for (const path of paths) {
310 cache.files.delete(path);
311 newSelection = newSelection.deselect(path);
312 }
313 this.setSelection(newSelection);
314 }
315
316 deselectAll() {
317 let newSelection = this.selection;
318 this.getPaths().forEach(path => (newSelection = newSelection.deselect(path)));
319 this.setSelection(newSelection);
320 }
321
322 /** Toggle a file expansion. */
323 toggleExpand(path: RepoRelativePath, select?: boolean) {
324 this.setSelection(this.selection.toggleExpand(path, select));
325 }
326
327 /** Test if a path is marked as expanded. */
328 isExpanded(path: RepoRelativePath): boolean {
329 return this.selection.isExpanded(path);
330 }
331
332 /** Drop "chunk selection" states. Useful to clear states after an wdir-changing operation. */
333 discardPartialSelections() {
334 return this.setSelection(this.selection.discardPartialSelections());
335 }
336
337 /** Restore to the default selection (select all). */
338 clear() {
339 const newSelection = this.selection.clear();
340 this.setSelection(newSelection);
341 }
342
343 /**
344 * Get the chunk select state for the given path.
345 * The file content will be loaded on demand.
346 *
347 * `epoch` is used to invalidate existing caches.
348 */
349 getChunkSelect(path: RepoRelativePath): ChunkSelectState | Promise<ChunkSelectState> {
350 const fileSelection = this.selection.inner.fileMap.get(path);
351 const cache = UseUncommittedSelection.fileContentCache;
352
353 let maybeStaleResult = undefined;
354 if (fileSelection instanceof ChunkSelectState) {
355 maybeStaleResult = fileSelection;
356 if (cache.files.has(path)) {
357 // Up to date.
358 return maybeStaleResult;
359 } else {
360 // Cache invalidated by constructor.
361 // Trigger a new fetch below.
362 // Still return `maybeStaleResult` to avoid flakiness.
363 }
364 }
365
366 const maybeReadFromCache = (): ChunkSelectState | null => {
367 const file = cache.files.get(path);
368 if (file === undefined) {
369 return null;
370 }
371 const parentPath = file?.copyFrom ?? path;
372 const parentFile = cache.parentFiles.get(parentPath);
373 if (parentFile?.dataBase85 || file?.dataBase85) {
374 throw new Error(t('Cannot edit non-utf8 file'));
375 }
376 const a = parentFile?.data ?? '';
377 const b = file?.data ?? '';
378 const existing = this.getSelection(path);
379 let selected: string | boolean;
380 if (existing instanceof ChunkSelectState) {
381 if (existing.a === a && existing.b === b) {
382 return existing;
383 }
384 selected = existing.getSelectedText();
385 } else {
386 selected = existing;
387 }
388 const newSelection = this.selection.startChunkSelect(path, a, b, selected, true);
389 this.setSelection(newSelection);
390 const newSelected = newSelection.getSelection(path);
391 assert(
392 newSelected instanceof ChunkSelectState,
393 'startChunkSelect() should provide ChunkSelectState',
394 );
395 return newSelected;
396 };
397
398 const promise = cache.asyncLoadingLock.enqueueRun(async () => {
399 const chunkState = maybeReadFromCache();
400 if (chunkState !== null) {
401 return chunkState;
402 }
403
404 // Not found in cache. Need to (re)load the file via the server.
405
406 const revs = wdirRev;
407 // Setup event listener before sending the request.
408 const iter = clientToServerAPI.iterateMessageOfType('exportedStack');
409 // Explicitly ask for the file via assumeTracked. Note this also provides contents
410 // of other tracked files.
411 clientToServerAPI.postMessage({type: 'exportStack', revs, assumeTracked: [path]});
412 for await (const event of iter) {
413 if (event.revs !== revs) {
414 // Ignore unrelated response.
415 continue;
416 }
417 if (event.error) {
418 throw new Error(event.error);
419 }
420 if (event.stack.some(c => !c.requested && c.node !== cache.wdirHash)) {
421 // The wdirHash has changed. Fail the load.
422 // The exported stack usually has a non-requested commit that is the parent of
423 // the requested "wdir()", which is the "." commit that should match `wdirHash`.
424 // Note: for an empty repo there is no such non-requested commit exported so
425 // we skip the check in that case.
426 throw new Error(t('Working copy has changed'));
427 }
428
429 // Update cache.
430 event.stack.forEach(commit => {
431 if (commit.requested) {
432 mergeObjectToMap(commit.files, cache.files);
433 } else {
434 mergeObjectToMap(commit.relevantFiles, cache.parentFiles);
435 }
436 });
437
438 // Try read from cache again.
439 const chunkState = maybeReadFromCache();
440 if (chunkState === null) {
441 if (event.assumeTracked.includes(path)) {
442 // We explicitly requested the file, but the server does not provide
443 // it somehow.
444 break;
445 } else {
446 // It's possible that there are multiple export requests.
447 // This one does not provide the file we want, continue checking other responses.
448 continue;
449 }
450 } else {
451 return chunkState;
452 }
453 }
454
455 // Handles the `break` above. Tells tsc that we don't return undefined.
456 throw new Error(t('Unable to get file content unexpectedly'));
457 });
458
459 return maybeStaleResult ?? promise;
460 }
461
462 /** Edit chunk selection for a file. */
463 editChunkSelect(
464 path: RepoRelativePath,
465 newValue: ((chunkState: ChunkSelectState) => ChunkSelectState) | ChunkSelectState,
466 ) {
467 const newSelection = this.selection.editChunkSelect(path, newValue);
468 this.setSelection(newSelection);
469 }
470
471 // ---------- Read-only methods below ----------
472
473 /**
474 * Return true if a file is selected (default), false if deselected,
475 * or a string with the edited content.
476 */
477 getSelection(path: RepoRelativePath): SingleFileSelection {
478 return this.selection.getSelection(path);
479 }
480
481 isFullyOrPartiallySelected(path: RepoRelativePath): boolean {
482 return this.selection.isFullyOrPartiallySelected(path);
483 }
484
485 isPartiallySelected(path: RepoRelativePath): boolean {
486 return this.selection.isPartiallySelected(path);
487 }
488
489 isFullySelected(path: RepoRelativePath): boolean {
490 return this.selection.isFullySelected(path);
491 }
492
493 isDeselected(path: RepoRelativePath): boolean {
494 return this.selection.isDeselected(path);
495 }
496
497 isEverythingSelected(): boolean {
498 return this.selection.isEverythingSelected(this.getPaths);
499 }
500
501 isNothingSelected(): boolean {
502 return this.selection.isNothingSelected(this.getPaths);
503 }
504
505 hasChunkSelection(): boolean {
506 return this.selection.hasChunkSelection();
507 }
508}
509
510export const isFullyOrPartiallySelected = atom(get => {
511 const sel = get(uncommittedSelection);
512 const uncommittedChanges = get(uncommittedChangesWithPreviews);
513 const epoch = get(latestUncommittedChangesTimestamp);
514 const dag = get(dagWithPreviews);
515 const wdirHash = dag.resolve('.')?.hash ?? '';
516 const getPaths = () => uncommittedChanges.map(c => c.path);
517 const emptyFunction = () => {};
518 // NOTE: Usually UseUncommittedSelection is only used in React, but this is
519 // a private shortcut path to get the logic outside React.
520 const selection = new UseUncommittedSelection(sel, emptyFunction, wdirHash, getPaths, epoch);
521 return selection.isFullyOrPartiallySelected.bind(selection);
522});
523
524/** react hook to get the uncommitted selection state. */
525export function useUncommittedSelection() {
526 const [selection, setSelection] = useAtom(uncommittedSelection);
527 const uncommittedChanges = useAtomValue(uncommittedChangesWithPreviews);
528 const epoch = useAtomValue(latestUncommittedChangesTimestamp);
529 const dag = useAtomValue(dagWithPreviews);
530 const wdirHash = dag.resolve('.')?.hash ?? '';
531 const getPaths = () => uncommittedChanges.map(c => c.path);
532
533 return new UseUncommittedSelection(selection, setSelection, wdirHash, getPaths, epoch);
534}
535
536function mergeObjectToMap<V>(obj: {[path: string]: V} | undefined, map: Map<string, V>) {
537 if (obj === undefined) {
538 return;
539 }
540 for (const k in obj) {
541 const v = obj[k];
542 map.set(k, v);
543 }
544}
545