diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index 42172a05dbc..14665420394 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -433,6 +433,30 @@ export default [ 'no-undef': 'off', }, }, + { + files: [ + 'lib/dashboard/src/**/*.ts', + 'lib/dashboard/src/**/*.mts', + 'lib/dashboard/src/**/*.cts', + 'lib/dashboard/src/**/*.tsx', + 'lib/dashboard/src/**/*.mtsx', + 'lib/dashboard/src/**/*.ctsx', + ], + rules: { + 'no-restricted-properties': [ + 'error', + { + object: 'console', + message: 'Avoid leaving debugging statements when committing code', + }, + { + object: 'hooks', + property: 'useDebugState', + message: 'Avoid leaving debugging statements when committing code', + }, + ], + }, + }, { files: ['**/*.d.ts'], rules: { diff --git a/app/ide-desktop/lib/content/src/index.ts b/app/ide-desktop/lib/content/src/index.ts index b974e66e122..b5aec160b6f 100644 --- a/app/ide-desktop/lib/content/src/index.ts +++ b/app/ide-desktop/lib/content/src/index.ts @@ -219,13 +219,13 @@ class Main implements AppRunner { const isOpeningMainEntryPoint = contentConfig.OPTIONS.groups.startup.options.entry.value === contentConfig.OPTIONS.groups.startup.options.entry.default - const isNotOpeningProject = - contentConfig.OPTIONS.groups.startup.options.project.value === '' - if ( - (isUsingAuthentication || isUsingNewDashboard) && - isOpeningMainEntryPoint && - isNotOpeningProject - ) { + // This MUST be removed as it would otherwise override the `startup.project` passed + // explicitly in `ide.tsx`. + if (isOpeningMainEntryPoint && url.searchParams.has('startup.project')) { + url.searchParams.delete('startup.project') + history.replaceState(null, '', url.toString()) + } + if ((isUsingAuthentication || isUsingNewDashboard) && isOpeningMainEntryPoint) { this.runAuthentication(isInAuthenticationFlow, inputConfig) } else { void this.runApp(inputConfig) @@ -235,6 +235,8 @@ class Main implements AppRunner { /** Begins the authentication UI flow. */ runAuthentication(isInAuthenticationFlow: boolean, inputConfig?: StringConfig) { + const initialProjectName = + contentConfig.OPTIONS.groups.startup.options.project.value || null /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/345 * `content` and `dashboard` packages **MUST BE MERGED INTO ONE**. The IDE * should only have one entry point. Right now, we have two. One for the cloud @@ -250,6 +252,7 @@ class Main implements AppRunner { supportsLocalBackend: SUPPORTS_LOCAL_BACKEND, supportsDeepLinks: SUPPORTS_DEEP_LINKS, showDashboard: contentConfig.OPTIONS.groups.featurePreview.options.newDashboard.value, + initialProjectName, onAuthenticated: () => { if (isInAuthenticationFlow) { const initialUrl = localStorage.getItem(INITIAL_URL_KEY) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx index 527074fd132..b343ba68b31 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/registration.tsx @@ -81,6 +81,7 @@ function Registration() { { setInitialized(true) setUserSession(OFFLINE_USER_SESSION) - setBackend(new localBackend.LocalBackend()) + setBackendWithoutSavingType(new localBackend.LocalBackend()) } const goOffline = () => { diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx index f5a6925764c..7159aa59ee3 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/components/app.tsx @@ -89,6 +89,8 @@ export interface AppProps { supportsDeepLinks: boolean /** Whether the dashboard should be rendered. */ showDashboard: boolean + /** The name of the project to open on startup, if any. */ + initialProjectName: string | null onAuthenticated: () => void appRunner: AppRunner } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts index 296366f6359..3dfebe2c968 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/backend.ts @@ -1,4 +1,5 @@ /** @file Type definitions common between all backends. */ + import * as dateTime from './dateTime' import * as newtype from '../newtype' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx index 78fbf6552bb..34f76bb5929 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/changePasswordModal.tsx @@ -72,6 +72,8 @@ function ChangePasswordModal() { type="password" name="old_password" placeholder="Old Password" + pattern={validation.PREVIOUS_PASSWORD_PATTERN} + title={validation.PREVIOUS_PASSWORD_TITLE} value={oldPassword} setValue={setOldPassword} className="text-sm sm:text-base placeholder-gray-500 pl-10 pr-4 rounded-lg border border-gray-400 w-full py-2 focus:outline-none focus:border-blue-400" diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx index 14362dc1423..8952304f4cb 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx @@ -1,4 +1,5 @@ /** @file Modal for confirming delete of any type of asset. */ +import * as react from 'react' import toast from 'react-hot-toast' import * as modalProvider from '../../providers/modal' @@ -23,14 +24,23 @@ function ConfirmDeleteModal(props: ConfirmDeleteModalProps) { const { assetType, name, doDelete, onSuccess } = props const { unsetModal } = modalProvider.useSetModal() + const [isSubmitting, setIsSubmitting] = react.useState(false) + const onSubmit = async () => { - unsetModal() - await toast.promise(doDelete(), { - loading: `Deleting ${assetType}...`, - success: `Deleted ${assetType}.`, - error: `Could not delete ${assetType}.`, - }) - onSuccess() + if (!isSubmitting) { + try { + setIsSubmitting(true) + await toast.promise(doDelete(), { + loading: `Deleting ${assetType}...`, + success: `Deleted ${assetType}.`, + error: `Could not delete ${assetType}.`, + }) + unsetModal() + onSuccess() + } finally { + setIsSubmitting(false) + } + } } return ( @@ -52,18 +62,25 @@ function ConfirmDeleteModal(props: ConfirmDeleteModalProps) { Are you sure you want to delete the {assetType} '{name}'?
-
Delete -
-
+
+
diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx index fb7a254cb60..d65a1db64b3 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx @@ -25,9 +25,13 @@ export interface ContextMenuProps { function ContextMenu(props: react.PropsWithChildren) { const { children, event } = props const contextMenuRef = react.useRef(null) + const [top, setTop] = react.useState(event.pageY) + // This must be the original height before the returned element affects the `scrollHeight`. + const [bodyHeight] = react.useState(document.body.scrollHeight) react.useEffect(() => { if (contextMenuRef.current != null) { + setTop(Math.min(top, bodyHeight - contextMenuRef.current.clientHeight)) const boundingBox = contextMenuRef.current.getBoundingClientRect() const scrollBy = boundingBox.bottom - innerHeight + SCROLL_MARGIN if (scrollBy > 0) { @@ -39,7 +43,8 @@ function ContextMenu(props: react.PropsWithChildren) { return (
{children} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx index c45834a9277..0dab37ba088 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx @@ -8,16 +8,18 @@ import * as react from 'react' /** Props for a {@link ContextMenuEntry}. */ export interface ContextMenuEntryProps { disabled?: boolean + title?: string onClick: (event: react.MouseEvent) => void } // This component MUST NOT use `useState` because it is not rendered directly. /** An item in a `ContextMenu`. */ function ContextMenuEntry(props: react.PropsWithChildren) { - const { children, disabled, onClick } = props + const { children, disabled, title, onClick } = props return (
- +
{columnsFor(columnDisplayMode, backend.type).map(column => ( @@ -948,7 +1060,7 @@ function Dashboard(props: DashboardProps) { > items={visibleProjectAssets} - getKey={proj => proj.id} + getKey={projectAsset => projectAsset.id} isLoading={isLoadingAssets} placeholder={ @@ -979,8 +1091,11 @@ function Dashboard(props: DashboardProps) { event.preventDefault() event.stopPropagation() const doOpenForEditing = () => { - // FIXME[sb]: Switch to IDE tab - // once merged with `show-and-open-workspace` branch. + unsetModal() + setProjectEvent({ + type: projectActionButton.ProjectEventType.open, + projectId: projectAsset.id, + }) } const doOpenAsFolder = () => { // FIXME[sb]: Uncomment once backend support @@ -1031,9 +1146,12 @@ function Dashboard(props: DashboardProps) { /> )) } + const isDisabled = + backend.type === backendModule.BackendType.local && + (projectDatas[projectAsset.id]?.isRunning ?? false) setModal(() => ( - - + + Open for editing {backend.type !== backendModule.BackendType.local && ( @@ -1044,7 +1162,15 @@ function Dashboard(props: DashboardProps) { Rename - + Delete @@ -1059,7 +1185,7 @@ function Dashboard(props: DashboardProps) { backendModule.Asset > items={visibleDirectoryAssets} - getKey={dir => dir.id} + getKey={directoryAsset => directoryAsset.id} isLoading={isLoadingAssets} placeholder={ @@ -1091,11 +1217,14 @@ function Dashboard(props: DashboardProps) { : [directoryAsset] ) }} - onContextMenu={(_directory, event) => { + onContextMenu={(directoryAsset, event) => { event.preventDefault() event.stopPropagation() setModal(() => ( - + )) }} /> @@ -1153,7 +1282,7 @@ function Dashboard(props: DashboardProps) { )) } setModal(() => ( - + Delete @@ -1224,7 +1353,7 @@ function Dashboard(props: DashboardProps) { /** TODO: Wait for backend endpoint. */ } setModal(() => ( - + Copy diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx index 5bd03780574..5551bb5d7cd 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectActionButton.tsx @@ -1,15 +1,51 @@ /** @file An interactive button displaying the status of a project. */ import * as react from 'react' +import toast from 'react-hot-toast' import * as backendModule from '../backend' import * as backendProvider from '../../providers/backend' import * as localBackend from '../localBackend' +import * as modalProvider from '../../providers/modal' import * as svg from '../../components/svg' // ============= // === Types === // ============= +/** Data associated with a project, used for rendering. + * FIXME[sb]: This is a hack that is required because each row does not carry its own extra state. + * It will be obsoleted by the implementation in https://github.com/enso-org/enso/pull/6546. */ +export interface ProjectData { + isRunning: boolean +} + +/** Possible types of project state change. */ +export enum ProjectEventType { + open = 'open', + cancelOpeningAll = 'cancelOpeningAll', +} + +/** Properties common to all project state change events. */ +interface ProjectBaseEvent { + type: Type +} + +/** Requests the specified project to be opened. */ +export interface ProjectOpenEvent extends ProjectBaseEvent { + // FIXME: provide projectId instead + /** This must be a name because it may be specified by name on the command line. + * Note that this will not work properly with the cloud backend if there are multiple projects + * with the same name. */ + projectId: backendModule.ProjectId +} + +/** Requests the specified project to be opened. */ +export interface ProjectCancelOpeningAllEvent + extends ProjectBaseEvent {} + +/** Every possible type of project event. */ +export type ProjectEvent = ProjectCancelOpeningAllEvent | ProjectOpenEvent + /** The state of the spinner. It should go from initial, to loading, to done. */ enum SpinnerState { initial = 'initial', @@ -21,10 +57,26 @@ enum SpinnerState { // === Constants === // ================= +/** The default {@link ProjectData} associated with a {@link backendModule.Project}. */ +export const DEFAULT_PROJECT_DATA: ProjectData = Object.freeze({ + isRunning: false, +}) +const LOADING_MESSAGE = + 'Your environment is being created. It will take some time, please be patient.' /** The interval between requests checking whether the IDE is ready. */ const CHECK_STATUS_INTERVAL_MS = 5000 /** The interval between requests checking whether the VM is ready. */ const CHECK_RESOURCES_INTERVAL_MS = 1000 +/** The fallback project state, when it is set to `null` before it is first set. */ +const DEFAULT_PROJECT_STATE = backendModule.ProjectState.created +/** The corresponding {@link SpinnerState} for each {@link backendModule.ProjectState}. */ +const SPINNER_STATE: Record = { + [backendModule.ProjectState.closed]: SpinnerState.initial, + [backendModule.ProjectState.created]: SpinnerState.initial, + [backendModule.ProjectState.new]: SpinnerState.initial, + [backendModule.ProjectState.openInProgress]: SpinnerState.loading, + [backendModule.ProjectState.opened]: SpinnerState.done, +} const SPINNER_CSS_CLASSES: Record = { [SpinnerState.initial]: 'dasharray-5 ease-linear', @@ -39,7 +91,12 @@ const SPINNER_CSS_CLASSES: Record = { /** Props for a {@link ProjectActionButton}. */ export interface ProjectActionButtonProps { project: backendModule.Asset + projectData: ProjectData + setProjectData: react.Dispatch> appRunner: AppRunner | null + event: ProjectEvent | null + /** Called when the project is opened via the {@link ProjectActionButton}. */ + doOpenManually: () => void onClose: () => void openIde: () => void doRefresh: () => void @@ -47,63 +104,139 @@ export interface ProjectActionButtonProps { /** An interactive button displaying the status of a project. */ function ProjectActionButton(props: ProjectActionButtonProps) { - const { project, onClose, appRunner, openIde, doRefresh } = props + const { + project, + setProjectData, + event, + appRunner, + doOpenManually, + onClose, + openIde, + doRefresh, + } = props const { backend } = backendProvider.useBackend() + const { unsetModal } = modalProvider.useSetModal() - const [state, setState] = react.useState(backendModule.ProjectState.created) + const [state, setState] = react.useState(null) const [isCheckingStatus, setIsCheckingStatus] = react.useState(false) const [isCheckingResources, setIsCheckingResources] = react.useState(false) - const [spinnerState, setSpinnerState] = react.useState(SpinnerState.done) + const [spinnerState, setSpinnerState] = react.useState(SpinnerState.initial) + const [shouldOpenWhenReady, setShouldOpenWhenReady] = react.useState(false) + const [toastId, setToastId] = react.useState(null) + + react.useEffect(() => { + if (toastId != null) { + return () => { + toast.dismiss(toastId) + } + } else { + return + } + }, [toastId]) + + react.useEffect(() => { + // Ensure that the previous spinner state is visible for at least one frame. + requestAnimationFrame(() => { + setSpinnerState(SPINNER_STATE[state ?? DEFAULT_PROJECT_STATE]) + }) + }, [state]) + + react.useEffect(() => { + if (toastId != null && state !== backendModule.ProjectState.openInProgress) { + toast.dismiss(toastId) + } + }, [state]) react.useEffect(() => { switch (project.projectState.type) { case backendModule.ProjectState.opened: setState(backendModule.ProjectState.openInProgress) - setSpinnerState(SpinnerState.initial) setIsCheckingResources(true) break case backendModule.ProjectState.openInProgress: setState(backendModule.ProjectState.openInProgress) - setSpinnerState(SpinnerState.initial) setIsCheckingStatus(true) break default: - setState(project.projectState.type) + // Some functions below set the state to something different to + // the backend state. In that case, the state should not be overridden. + setState(oldState => oldState ?? project.projectState.type) break } }, []) react.useEffect(() => { - if (backend.type === backendModule.BackendType.local) { - if (project.id !== localBackend.LocalBackend.currentlyOpeningProjectId) { - setIsCheckingResources(false) - setIsCheckingStatus(false) - setState(backendModule.ProjectState.closed) - setSpinnerState(SpinnerState.done) + if (event != null) { + switch (event.type) { + case ProjectEventType.open: { + if (event.projectId !== project.id) { + setShouldOpenWhenReady(false) + } else { + setShouldOpenWhenReady(true) + void openProject() + } + break + } + case ProjectEventType.cancelOpeningAll: { + setShouldOpenWhenReady(false) + } } } + }, [event]) + + react.useEffect(() => { + if (shouldOpenWhenReady && state === backendModule.ProjectState.opened) { + openIde() + setShouldOpenWhenReady(false) + } + }, [shouldOpenWhenReady, state]) + + react.useEffect(() => { + if ( + backend.type === backendModule.BackendType.local && + project.id !== localBackend.LocalBackend.currentlyOpeningProjectId + ) { + setState(backendModule.ProjectState.closed) + } }, [project, state, localBackend.LocalBackend.currentlyOpeningProjectId]) react.useEffect(() => { if (!isCheckingStatus) { return } else { + let handle: number | null = null + let continuePolling = true + let previousTimestamp = 0 const checkProjectStatus = async () => { - const response = await backend.getProjectDetails(project.id) - if (response.state.type === backendModule.ProjectState.opened) { - setIsCheckingStatus(false) - setIsCheckingResources(true) - } else { - setState(response.state.type) + try { + const response = await backend.getProjectDetails(project.id) + handle = null + if ( + continuePolling && + response.state.type === backendModule.ProjectState.opened + ) { + continuePolling = false + setIsCheckingStatus(false) + setIsCheckingResources(true) + } + } finally { + if (continuePolling) { + const nowTimestamp = Number(new Date()) + const delay = CHECK_STATUS_INTERVAL_MS - (nowTimestamp - previousTimestamp) + previousTimestamp = nowTimestamp + handle = window.setTimeout( + () => void checkProjectStatus(), + Math.max(0, delay) + ) + } } } - const handle = window.setInterval( - () => void checkProjectStatus(), - CHECK_STATUS_INTERVAL_MS - ) void checkProjectStatus() return () => { - clearInterval(handle) + continuePolling = false + if (handle != null) { + clearTimeout(handle) + } } } }, [isCheckingStatus]) @@ -112,85 +245,144 @@ function ProjectActionButton(props: ProjectActionButtonProps) { if (!isCheckingResources) { return } else { + let handle: number | null = null + let continuePolling = true + let previousTimestamp = 0 const checkProjectResources = async () => { - if (!('checkResources' in backend)) { + if (backend.type === backendModule.BackendType.local) { setState(backendModule.ProjectState.opened) setIsCheckingResources(false) - setSpinnerState(SpinnerState.done) } else { try { // This call will error if the VM is not ready yet. await backend.checkResources(project.id) - setState(backendModule.ProjectState.opened) - setIsCheckingResources(false) - setSpinnerState(SpinnerState.done) + handle = null + if (continuePolling) { + continuePolling = false + setState(backendModule.ProjectState.opened) + setIsCheckingResources(false) + } } catch { - // Ignored. + if (continuePolling) { + const nowTimestamp = Number(new Date()) + const delay = + CHECK_RESOURCES_INTERVAL_MS - (nowTimestamp - previousTimestamp) + previousTimestamp = nowTimestamp + handle = window.setTimeout( + () => void checkProjectResources(), + Math.max(0, delay) + ) + } } } } - const handle = window.setInterval( - () => void checkProjectResources(), - CHECK_RESOURCES_INTERVAL_MS - ) void checkProjectResources() return () => { - clearInterval(handle) + continuePolling = false + if (handle != null) { + clearTimeout(handle) + } } } }, [isCheckingResources]) - const closeProject = () => { + const closeProject = async () => { + onClose() + setShouldOpenWhenReady(false) setState(backendModule.ProjectState.closed) appRunner?.stopApp() - void backend.closeProject(project.id) setIsCheckingStatus(false) setIsCheckingResources(false) - onClose() + try { + await backend.closeProject(project.id) + } finally { + // This is not 100% correct, but it is better than never setting `isRunning` to `false`, + // which would prevent the project from ever being deleted. + setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: false })) + } } const openProject = async () => { setState(backendModule.ProjectState.openInProgress) - setSpinnerState(SpinnerState.initial) - // The `setTimeout` is required so that the completion percentage goes from - // the `initial` fraction to the `loading` fraction, - // rather than starting at the `loading` fraction. - setTimeout(() => { - setSpinnerState(SpinnerState.loading) - }, 0) - switch (backend.type) { - case backendModule.BackendType.remote: - await backend.openProject(project.id) - doRefresh() - setIsCheckingStatus(true) - break - case backendModule.BackendType.local: - await backend.openProject(project.id) - doRefresh() - setState(backendModule.ProjectState.opened) - setSpinnerState(SpinnerState.done) - break + try { + switch (backend.type) { + case backendModule.BackendType.remote: + setToastId(toast.loading(LOADING_MESSAGE)) + await backend.openProject(project.id) + setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: true })) + doRefresh() + setIsCheckingStatus(true) + break + case backendModule.BackendType.local: + await backend.openProject(project.id) + setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: true })) + setState(oldState => { + if (oldState === backendModule.ProjectState.openInProgress) { + doRefresh() + return backendModule.ProjectState.opened + } else { + return oldState + } + }) + break + } + } catch { + setIsCheckingStatus(false) + setIsCheckingResources(false) + toast.error(`Error opening project '${project.title}'.`) + setState(backendModule.ProjectState.closed) } } switch (state) { + case null: case backendModule.ProjectState.created: case backendModule.ProjectState.new: case backendModule.ProjectState.closed: - return + return ( + + ) case backendModule.ProjectState.openInProgress: return ( - ) case backendModule.ProjectState.opened: return ( <> - - + ) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx index e27bd6362e4..b3d2600a1d7 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx @@ -27,22 +27,28 @@ function RenameModal(props: RenameModalProps) { const { assetType, name, namePattern, title, doRename, onSuccess } = props const { unsetModal } = modalProvider.useSetModal() + const [isSubmitting, setIsSubmitting] = react.useState(false) const [newName, setNewName] = react.useState(null) const onSubmit = async (event: React.FormEvent) => { event.preventDefault() if (newName == null) { toast.error('Please provide a new name.') - } else { - unsetModal() - await toast.promise(doRename(newName), { - loading: `Renaming ${assetType}...`, - success: `Renamed ${assetType}.`, - // This is UNSAFE, as the original function's parameter is of type `any`. - error: (promiseError: Error) => - `Error renaming ${assetType}: ${promiseError.message}`, - }) - onSuccess() + } else if (!isSubmitting) { + try { + setIsSubmitting(true) + await toast.promise(doRename(newName), { + loading: `Renaming ${assetType}...`, + success: `Renamed ${assetType}.`, + // This is UNSAFE, as the original function's parameter is of type `any`. + error: (promiseError: Error) => + `Error renaming ${assetType}: ${promiseError.message}`, + }) + unsetModal() + onSuccess() + } finally { + setIsSubmitting(false) + } } } @@ -65,9 +71,10 @@ function RenameModal(props: RenameModalProps) { -
Cancel -
+ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx index ecec1468da7..57a340db61c 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx @@ -74,7 +74,7 @@ function TemplatesRender(props: TemplatesRenderProps) { onClick={() => { onTemplateClick(null) }} - className="h-40 cursor-pointer" + className="h-40 w-60 cursor-pointer" >
@@ -91,7 +91,7 @@ function TemplatesRender(props: TemplatesRenderProps) { {templates.map(template => (