addons/isl/src/CommitInfoView/CommitInfoState.tsxblame
View source
b69ab311/**
b69ab312 * Copyright (c) Meta Platforms, Inc. and affiliates.
b69ab313 *
b69ab314 * This source code is licensed under the MIT license found in the
b69ab315 * LICENSE file in the root directory of this source tree.
b69ab316 */
b69ab317
b69ab318import type {Hash} from '../types';
b69ab319import type {CommitMessageFields} from './types';
b69ab3110
b69ab3111import {atom} from 'jotai';
b69ab3112import {firstLine} from 'shared/utils';
b69ab3113import serverAPI from '../ClientToServerAPI';
b69ab3114import {successionTracker} from '../SuccessionTracker';
b69ab3115import {tracker} from '../analytics';
b69ab3116import {latestCommitMessageFields} from '../codeReview/CodeReviewInfo';
b69ab3117import {islDrawerState} from '../drawerState';
b69ab3118import {atomFamilyWeak, localStorageBackedAtomFamily, readAtom, writeAtom} from '../jotaiUtils';
b69ab3119import {AmendMessageOperation} from '../operations/AmendMessageOperation';
b69ab3120import {AmendOperation, PartialAmendOperation} from '../operations/AmendOperation';
b69ab3121import {CommitOperation, PartialCommitOperation} from '../operations/CommitOperation';
b69ab3122import {onOperationExited, queuedOperations, queuedOperationsErrorAtom} from '../operationsState';
b69ab3123import {dagWithPreviews} from '../previews';
b69ab3124import {selectedCommitInfos, selectedCommits} from '../selection';
b69ab3125import {latestHeadCommit} from '../serverAPIState';
b69ab3126import {registerCleanup, registerDisposable} from '../utils';
b69ab3127import {
b69ab3128 allFieldsBeingEdited,
b69ab3129 anyEditsMade,
b69ab3130 applyEditedFields,
b69ab3131 commitMessageFieldsSchema,
b69ab3132 mergeCommitMessageFields,
b69ab3133 mergeOnlyEmptyMessageFields,
b69ab3134 parseCommitMessageFields,
b69ab3135} from './CommitMessageFields';
b69ab3136
b69ab3137export type EditedMessage = Partial<CommitMessageFields>;
b69ab3138
b69ab3139export type CommitInfoMode = 'commit' | 'amend';
b69ab3140
b69ab3141export const commitMessageTemplate = atom<EditedMessage | undefined>(undefined);
b69ab3142registerDisposable(
b69ab3143 commitMessageTemplate,
b69ab3144 serverAPI.onMessageOfType('fetchedCommitMessageTemplate', event => {
b69ab3145 const title = firstLine(event.template);
b69ab3146 const description = event.template.slice(title.length + 1);
b69ab3147 const schema = readAtom(commitMessageFieldsSchema);
b69ab3148 const fields = parseCommitMessageFields(schema, title, description);
b69ab3149 writeAtom(commitMessageTemplate, fields);
b69ab3150 }),
b69ab3151 import.meta.hot,
b69ab3152);
b69ab3153registerCleanup(
b69ab3154 commitMessageTemplate,
b69ab3155 serverAPI.onSetup(() =>
b69ab3156 serverAPI.postMessage({
b69ab3157 type: 'fetchCommitMessageTemplate',
b69ab3158 }),
b69ab3159 ),
b69ab3160 import.meta.hot,
b69ab3161);
b69ab3162
b69ab3163/** Typed update messages when submitting a commit or set of commits.
b69ab3164 * Unlike editedCommitMessages, you can't provide an update message when committing the first time,
b69ab3165 * so we don't need to track this state for 'head'.
b69ab3166 */
b69ab3167export const diffUpdateMessagesState = atomFamilyWeak((_hash: Hash) => atom<string>(''));
b69ab3168
b69ab3169export const getDefaultEditedCommitMessage = (): EditedMessage => ({});
b69ab3170
b69ab3171/**
b69ab3172 * Map of hash -> latest edited commit message, representing any changes made to the commit's message fields.
b69ab3173 * Only fields that are edited are entered here. Fields that are not edited are not in the object.
b69ab3174 *
b69ab3175 * `{}` corresponds to the original commit message.
b69ab3176 * `{Title: 'hello'}` means the title was changed to "hello", but all other fields are unchanged.
b69ab3177 *
b69ab3178 * When you begin editing a field, that field must be initialized in the EditedMessage with the latest value.
b69ab3179 * This also stores the state of new commit messages being written, keyed by "head" instead of a commit hash.
b69ab3180 * Note: this state should be cleared when amending / committing / meta-editing.
b69ab3181 */
b69ab3182export const editedCommitMessages = localStorageBackedAtomFamily<Hash | 'head', EditedMessage>(
b69ab3183 'isl.edited-commit-messages:',
b69ab3184 () => getDefaultEditedCommitMessage(),
b69ab3185);
b69ab3186
b69ab3187function updateEditedCommitMessagesFromSuccessions() {
b69ab3188 return successionTracker.onSuccessions(successions => {
b69ab3189 for (const [oldHash, newHash] of successions) {
b69ab3190 const existing = readAtom(editedCommitMessages(oldHash));
b69ab3191 writeAtom(
b69ab3192 editedCommitMessages(newHash),
b69ab3193 Object.keys(existing).length === 0
b69ab3194 ? // If the edited message is empty, write undefined to remove from persisted storage
b69ab3195 undefined
b69ab3196 : existing,
b69ab3197 );
b69ab3198
b69ab3199 const existingUpdateMessage = readAtom(diffUpdateMessagesState(oldHash));
b69ab31100 if (existingUpdateMessage && existingUpdateMessage !== '') {
b69ab31101 // TODO: this doesn't work if you have multiple commits selected...
b69ab31102 writeAtom(diffUpdateMessagesState(newHash), existingUpdateMessage);
b69ab31103 }
b69ab31104 }
b69ab31105 });
b69ab31106}
b69ab31107let editedCommitMessageSuccessionDisposable = updateEditedCommitMessagesFromSuccessions();
b69ab31108export const __TEST__ = {
b69ab31109 renewEditedCommitMessageSuccessionSubscription() {
b69ab31110 editedCommitMessageSuccessionDisposable();
b69ab31111 editedCommitMessageSuccessionDisposable = updateEditedCommitMessagesFromSuccessions();
b69ab31112 },
b69ab31113};
b69ab31114registerCleanup(successionTracker, updateEditedCommitMessagesFromSuccessions, import.meta.hot);
b69ab31115
b69ab31116// Handle updateDraftCommitMessage messages
b69ab31117registerDisposable(
b69ab31118 serverAPI,
b69ab31119 serverAPI.onMessageOfType('updateDraftCommitMessage', event => {
b69ab31120 const title = event.title;
b69ab31121 const description = event.description;
b69ab31122 const mode = event.mode ?? 'commit'; // Default to 'commit' if not specified
b69ab31123 const hash = event.hash ?? readAtom(latestHeadCommit)?.hash ?? 'head';
b69ab31124
b69ab31125 writeAtom(islDrawerState, val => ({...val, right: {...val.right, collapsed: false}}));
b69ab31126 const schema = readAtom(commitMessageFieldsSchema);
b69ab31127 const fields = parseCommitMessageFields(schema, title, description);
b69ab31128
b69ab31129 if (mode === 'commit') {
b69ab31130 writeAtom(editedCommitMessages('head'), fields);
b69ab31131 } else {
b69ab31132 const currentMessage = readAtom(editedCommitMessages(hash));
b69ab31133 // For amend mode: non-empty fields replace existing values, empty fields preserve current values
b69ab31134 // By passing fields first, mergeOnlyEmptyMessageFields will prefer the new fields when non-empty
b69ab31135 writeAtom(
b69ab31136 editedCommitMessages(hash),
b69ab31137 mergeOnlyEmptyMessageFields(schema, fields, currentMessage as CommitMessageFields),
b69ab31138 );
b69ab31139 }
b69ab31140
b69ab31141 writeAtom(selectedCommits, new Set([hash]));
b69ab31142 writeAtom(rawCommitMode, mode);
b69ab31143 }),
b69ab31144);
b69ab31145
b69ab31146registerDisposable(
b69ab31147 serverAPI,
b69ab31148 onOperationExited((progress, exitedOperation) => {
b69ab31149 if (progress.exitCode === 0) {
b69ab31150 return;
b69ab31151 }
b69ab31152
b69ab31153 const queuedError = readAtom(queuedOperationsErrorAtom);
b69ab31154 const queued = readAtom(queuedOperations);
b69ab31155 const affectedOperations =
b69ab31156 queuedError?.operationThatErrored?.id !== exitedOperation.id
b69ab31157 ? [exitedOperation, ...queued]
b69ab31158 : [exitedOperation, ...queuedError.operations];
b69ab31159
b69ab31160 for (const operation of affectedOperations) {
b69ab31161 const isCommit =
b69ab31162 operation instanceof CommitOperation || operation instanceof PartialCommitOperation;
b69ab31163 const isAmend =
b69ab31164 operation instanceof AmendOperation || operation instanceof PartialAmendOperation;
b69ab31165 const isMetaedit = operation instanceof AmendMessageOperation;
b69ab31166 if (!(isCommit || isAmend || isMetaedit)) {
b69ab31167 continue;
b69ab31168 }
b69ab31169
b69ab31170 // Operation involving commit message failed, let's restore your edited commit message so you might save it or try again
b69ab31171 const message = operation.message;
b69ab31172 if (message == null) {
b69ab31173 continue;
b69ab31174 }
b69ab31175
b69ab31176 const headOrHash = isCommit
b69ab31177 ? 'head'
b69ab31178 : isMetaedit
b69ab31179 ? operation.getCommitHash()
b69ab31180 : readAtom(latestHeadCommit)?.hash;
b69ab31181
b69ab31182 if (!headOrHash) {
b69ab31183 continue;
b69ab31184 }
b69ab31185
b69ab31186 const [title] = message.split(/\n+/, 1);
b69ab31187 const description = message.slice(title.length);
b69ab31188
b69ab31189 tracker.track('RecoverCommitMessageFromOperationError');
b69ab31190
b69ab31191 const schema = readAtom(commitMessageFieldsSchema);
b69ab31192 const fields = parseCommitMessageFields(schema, title, description);
b69ab31193 const currentMessage = readAtom(editedCommitMessages(headOrHash));
b69ab31194 writeAtom(
b69ab31195 editedCommitMessages(headOrHash),
b69ab31196 mergeCommitMessageFields(schema, currentMessage as CommitMessageFields, fields),
b69ab31197 );
b69ab31198 writeAtom(commitMode, isCommit ? 'commit' : 'amend');
b69ab31199 if (!isCommit) {
b69ab31200 writeAtom(selectedCommits, new Set([headOrHash]));
b69ab31201 }
b69ab31202 }
b69ab31203 }),
b69ab31204 import.meta.hot,
b69ab31205);
b69ab31206
b69ab31207export const latestCommitMessageFieldsWithEdits = atomFamilyWeak((hashOrHead: Hash | 'head') => {
b69ab31208 return atom(get => {
b69ab31209 const edited = get(editedCommitMessages(hashOrHead));
b69ab31210 const latest = get(latestCommitMessageFields(hashOrHead));
b69ab31211 return applyEditedFields(latest, edited);
b69ab31212 });
b69ab31213});
b69ab31214
b69ab31215/**
b69ab31216 * Fields being edited is computed from editedCommitMessage,
b69ab31217 * and reset to only substantially changed fields when changing commits.
b69ab31218 * This state skips the substantial changes check,
b69ab31219 * which allows all fields to be edited for example when clicking "amend...",
b69ab31220 * but without actually changing the underlying edited messages.
b69ab31221 */
b69ab31222export const forceNextCommitToEditAllFields = atom<boolean>(false);
b69ab31223
b69ab31224export const unsavedFieldsBeingEdited = atomFamilyWeak((hashOrHead: Hash | 'head') => {
b69ab31225 return atom(get => {
b69ab31226 const edited = get(editedCommitMessages(hashOrHead));
b69ab31227 const schema = get(commitMessageFieldsSchema);
b69ab31228 if (hashOrHead === 'head') {
b69ab31229 return allFieldsBeingEdited(schema);
b69ab31230 }
b69ab31231 return Object.fromEntries(schema.map(field => [field.key, field.key in edited]));
b69ab31232 });
b69ab31233});
b69ab31234
b69ab31235export const hasUnsavedEditedCommitMessage = atomFamilyWeak((hashOrHead: Hash | 'head') => {
b69ab31236 return atom(get => {
b69ab31237 const beingEdited = get(unsavedFieldsBeingEdited(hashOrHead));
b69ab31238 if (Object.values(beingEdited).some(Boolean)) {
b69ab31239 // Some fields are being edited, let's look more closely to see if anything is actually different.
b69ab31240 const edited = get(editedCommitMessages(hashOrHead));
b69ab31241 const latest = get(latestCommitMessageFields(hashOrHead));
b69ab31242 const schema = get(commitMessageFieldsSchema);
b69ab31243 return anyEditsMade(schema, latest, edited);
b69ab31244 }
b69ab31245 return false;
b69ab31246 });
b69ab31247});
b69ab31248
b69ab31249/**
b69ab31250 * Toggle state between commit/amend modes. Note that this may be "commit" even if
b69ab31251 * the commit info is not looking at the head commit (this allows persistence as you select other commits and come back).
b69ab31252 * We should only behave in "commit" mode when in commit mode AND looking at the head commit.
b69ab31253 * Prefer using `commitMode` atom.
b69ab31254 */
b69ab31255const rawCommitMode = atom<CommitInfoMode>('amend');
b69ab31256
b69ab31257/**
b69ab31258 * Whether the commit info view is in "commit" or "amend" mode.
b69ab31259 * It may only be in the "commit" mode when the commit being viewed is the head commit,
b69ab31260 * though it may be set to "commit" mode even when looking at a non-head commit,
b69ab31261 * and it'll be in commit when when you do look at the head commit.
b69ab31262 */
b69ab31263export const commitMode = atom(
b69ab31264 get => {
b69ab31265 const commitInfoCommit = get(commitInfoViewCurrentCommits);
b69ab31266 const rawMode = get(rawCommitMode);
b69ab31267 if (commitInfoCommit == null) {
b69ab31268 // loading state
b69ab31269 return 'amend';
b69ab31270 }
b69ab31271 if (commitInfoCommit.length === 1 && commitInfoCommit[0].isDot) {
b69ab31272 // allow using "commit" mode only if looking at exactly the single head commit
b69ab31273 return rawMode;
b69ab31274 }
b69ab31275 // otherwise, it's a non-head commit or multi-selection, so only show "amend" mode
b69ab31276 return 'amend';
b69ab31277 },
b69ab31278 (_get, set, newMode: CommitInfoMode | ((m: CommitInfoMode) => CommitInfoMode)) => {
b69ab31279 set(rawCommitMode, newMode);
b69ab31280 },
b69ab31281);
b69ab31282
b69ab31283export const commitInfoViewCurrentCommits = atom(get => {
b69ab31284 const selected = get(selectedCommitInfos);
b69ab31285
b69ab31286 // show selected commit, if there's exactly 1
b69ab31287 const selectedCommit = selected.length === 1 ? selected[0] : undefined;
b69ab31288 const commit = selectedCommit ?? get(dagWithPreviews).resolve('.');
b69ab31289
b69ab31290 if (commit == null) {
b69ab31291 return null;
b69ab31292 } else {
b69ab31293 return selected.length > 1 ? selected : [commit];
b69ab31294 }
b69ab31295});