addons/isl/src/GotoTimeMenu.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 {Button} from 'isl-components/Button';
b69ab319import {Checkbox} from 'isl-components/Checkbox';
b69ab3110import {DatetimePicker} from 'isl-components/DatetimePicker';
b69ab3111import {TextField} from 'isl-components/TextField';
b69ab3112import {useEffect, useRef, useState} from 'react';
b69ab3113import {tracker} from './analytics';
b69ab3114import {t, T} from './i18n';
b69ab3115import {GotoOperation} from './operations/GotoOperation';
b69ab3116import {RebaseOperation} from './operations/RebaseOperation';
b69ab3117import {useRunOperation} from './operationsState';
b69ab3118import {type ExactRevset, exactRevset} from './types';
b69ab3119
b69ab3120import './GotoTimeMenu.css';
b69ab3121
b69ab3122/**
b69ab3123 * Generates a succeedable revset for a specific number of hours ago
b69ab3124 * @param hours Number of hours ago
b69ab3125 * @returns A ExactRevset object
b69ab3126 */
b69ab3127function getRevsetForHoursAgo(hours: number): ExactRevset {
b69ab3128 const date = new Date();
b69ab3129 // setHours() correctly handles going back a day/month/year as needed, if it would take us from, eg, Jan 1st to Dec 31st
b69ab3130 date.setHours(date.getHours() - hours);
b69ab3131 const datetimeStr = formatDateTimeHelper(date);
b69ab3132 return getRevsetForDate(datetimeStr);
b69ab3133}
b69ab3134
b69ab3135/**
b69ab3136 * Generates a succeedable revset for a specific date string
b69ab3137 * @param dateString The date string to use in the revset
b69ab3138 * @returns A ExactRevset object
b69ab3139 */
b69ab3140function getRevsetForDate(dateString: string): ExactRevset {
b69ab3141 return exactRevset(`bsearch(date(">${dateString}"),max(public()))`);
b69ab3142}
b69ab3143
b69ab3144/**
b69ab3145 * Formats a date into a string compatible with the datetime-local input and SL revset.date()
b69ab3146 * @param date The date to format
b69ab3147 * @returns A string in the format YYYY-MM-DDTHH:MM
b69ab3148 */
b69ab3149function formatDateTimeHelper(date: Date): string {
b69ab3150 // Date.toISOString() is close to what we want, but it has fractional seconds and isn't in local time
b69ab3151 // Date.toLocaleString() is in local time, but uses slashes rather than dashes
b69ab3152 // Format date as YYYY-MM-DD in local timezone
b69ab3153 const year = date.getFullYear();
b69ab3154 const month = String(date.getMonth() + 1).padStart(2, '0'); // getMonth() is 0-indexed
b69ab3155 const day = String(date.getDate()).padStart(2, '0');
b69ab3156
b69ab3157 // Format time as HH:MM in local timezone
b69ab3158 const hours = String(date.getHours()).padStart(2, '0');
b69ab3159 const minutes = String(date.getMinutes()).padStart(2, '0');
b69ab3160
b69ab3161 // Return in format YYYY-MM-DDTHH:MM (required by datetime-local input and compatible with revset.date())
b69ab3162 return `${year}-${month}-${day}T${hours}:${minutes}`;
b69ab3163}
b69ab3164
b69ab3165/**
b69ab3166 * GotoTimeContent component that can be used directly or wrapped in an expander
b69ab3167 */
b69ab3168export function GotoTimeContent({dismiss}: {dismiss?: () => unknown}) {
b69ab3169 const runOperation = useRunOperation();
b69ab3170 const [shouldRebase, setShouldRebase] = useState(false);
b69ab3171 const [hours, setHours] = useState('');
b69ab3172 const [datetime, setDatetime] = useState('');
b69ab3173 const maxDatetime = useRef('');
b69ab3174 const hoursInputRef = useRef(null);
b69ab3175
b69ab3176 useEffect(() => {
b69ab3177 if (hoursInputRef.current) {
b69ab3178 (hoursInputRef.current as HTMLInputElement).focus();
b69ab3179 }
b69ab3180
b69ab3181 // Initialize datetime and maxDatetime with current time.
b69ab3182 const now = new Date();
b69ab3183 const nowFormatted = formatDateTimeHelper(now);
b69ab3184 setDatetime(nowFormatted);
b69ab3185 maxDatetime.current = nowFormatted;
b69ab3186 }, [hoursInputRef]);
b69ab3187
b69ab3188 // When hours is edited, clear the datetime picker. Must be one or the other
b69ab3189 const handleHoursChange = (value: string) => {
b69ab3190 setHours(value);
b69ab3191 if (value.trim().length > 0) {
b69ab3192 setDatetime('');
b69ab3193 }
b69ab3194 };
b69ab3195
b69ab3196 // When datetime is edited, clear the hours input. Must be one or the other
b69ab3197 const handleDatetimeChange = (value: string) => {
b69ab3198 setDatetime(value);
b69ab3199 if (value.trim().length > 0) {
b69ab31100 setHours('');
b69ab31101 }
b69ab31102 };
b69ab31103
b69ab31104 const doGoToCommit = () => {
b69ab31105 tracker.track('ClickGotoTimeButton', {
b69ab31106 extras: {
b69ab31107 isHours: hours.trim().length > 0,
b69ab31108 shouldRebase,
b69ab31109 },
b69ab31110 });
b69ab31111
b69ab31112 // Get the destination revset based on "hours ago" or datetime, whichever has a value
b69ab31113 let destinationRevset: ExactRevset;
b69ab31114
b69ab31115 if (hours.trim().length > 0) {
b69ab31116 const hoursValue = parseFloat(hours);
b69ab31117 if (isNaN(hoursValue)) {
b69ab31118 return;
b69ab31119 }
b69ab31120 destinationRevset = getRevsetForHoursAgo(hoursValue);
b69ab31121 } else if (datetime.trim().length > 0) {
b69ab31122 destinationRevset = getRevsetForDate(datetime);
b69ab31123 } else {
b69ab31124 // No valid input
b69ab31125 return;
b69ab31126 }
b69ab31127
b69ab31128 if (shouldRebase) {
b69ab31129 // Rebase current work onto the commit at the specified time
b69ab31130 runOperation(new RebaseOperation(exactRevset('.'), destinationRevset));
b69ab31131 } else {
b69ab31132 // Go to the commit at the specified time
b69ab31133 runOperation(new GotoOperation(destinationRevset));
b69ab31134 }
b69ab31135
b69ab31136 // Dismiss the tooltip/dialog if it exists
b69ab31137 if (dismiss) {
b69ab31138 dismiss();
b69ab31139 }
b69ab31140 };
b69ab31141
b69ab31142 return (
b69ab31143 <div className="goto-time-content">
b69ab31144 <div className="goto-time-input-row">
b69ab31145 <TextField
b69ab31146 width="100%"
b69ab31147 placeholder={t('Hours ago')}
b69ab31148 value={hours}
b69ab31149 data-testid="goto-time-input"
b69ab31150 onInput={e => handleHoursChange((e.target as unknown as {value: string})?.value ?? '')}
b69ab31151 onKeyDown={e => {
b69ab31152 if (e.key === 'Enter') {
b69ab31153 if (hours.trim().length > 0) {
b69ab31154 doGoToCommit();
b69ab31155 }
b69ab31156 }
b69ab31157 }}
b69ab31158 ref={hoursInputRef}
b69ab31159 />
b69ab31160 </div>
b69ab31161
b69ab31162 <div className="goto-time-or-divider">
b69ab31163 <T>or</T>
b69ab31164 </div>
b69ab31165
b69ab31166 <div className="goto-time-datetime-inputs">
b69ab31167 <DatetimePicker
b69ab31168 width="100%"
b69ab31169 value={datetime}
b69ab31170 max={maxDatetime.current}
b69ab31171 onInput={e => handleDatetimeChange((e.target as unknown as {value: string})?.value ?? '')}
b69ab31172 onKeyDown={e => {
b69ab31173 if (e.key === 'Enter' && datetime.trim().length > 0) {
b69ab31174 doGoToCommit();
b69ab31175 } else if (datetime.trim().length === 0) {
b69ab31176 setHours(''); // Clear hours on keyDown, not just onInput, which is only fired for a complete/valid date
b69ab31177 }
b69ab31178 }}
b69ab31179 />
b69ab31180 </div>
b69ab31181
b69ab31182 <div className="goto-time-actions">
b69ab31183 <Checkbox checked={shouldRebase} onChange={setShouldRebase}>
b69ab31184 <T>Rebase current stack here</T>
b69ab31185 </Checkbox>
b69ab31186 <Button
b69ab31187 data-testid="goto-time-button"
b69ab31188 primary
b69ab31189 disabled={hours.trim().length === 0 && datetime.trim().length === 0}
b69ab31190 onClick={doGoToCommit}>
b69ab31191 <T>Goto</T>
b69ab31192 </Button>
b69ab31193 </div>
b69ab31194 </div>
b69ab31195 );
b69ab31196}