diff --git a/components/dashboard/src/start/start-workspace-options.ts b/components/dashboard/src/start/start-workspace-options.ts index 69e56e25d900d6..6ab52011dcefbf 100644 --- a/components/dashboard/src/start/start-workspace-options.ts +++ b/components/dashboard/src/start/start-workspace-options.ts @@ -4,8 +4,13 @@ * See License.AGPL.txt in the project root for license information. */ -import { GitpodServer } from "@gitpod/gitpod-protocol"; +import { IDESettings } from "@gitpod/gitpod-protocol"; +export interface StartWorkspaceOptions { + workspaceClass?: string; + ideSettings?: IDESettings; + autostart?: boolean; +} export namespace StartWorkspaceOptions { // The workspace class to use for the workspace. If not specified, the default workspace class is used. export const WORKSPACE_CLASS = "workspaceClass"; @@ -13,9 +18,12 @@ export namespace StartWorkspaceOptions { // The editor to use for the workspace. If not specified, the default editor is used. export const EDITOR = "editor"; - export function parseSearchParams(search: string): GitpodServer.StartWorkspaceOptions { + // whether the workspace should automatically start + export const AUTOSTART = "autostart"; + + export function parseSearchParams(search: string): StartWorkspaceOptions { const params = new URLSearchParams(search); - const options: GitpodServer.StartWorkspaceOptions = {}; + const options: StartWorkspaceOptions = {}; const workspaceClass = params.get(StartWorkspaceOptions.WORKSPACE_CLASS); if (workspaceClass) { options.workspaceClass = workspaceClass; @@ -34,10 +42,13 @@ export namespace StartWorkspaceOptions { }; } } + if (params.get(StartWorkspaceOptions.AUTOSTART) === "true") { + options.autostart = true; + } return options; } - export function toSearchParams(options: GitpodServer.StartWorkspaceOptions): string { + export function toSearchParams(options: StartWorkspaceOptions): string { const params = new URLSearchParams(); if (options.workspaceClass) { params.set(StartWorkspaceOptions.WORKSPACE_CLASS, options.workspaceClass); @@ -47,6 +58,9 @@ export namespace StartWorkspaceOptions { const latest = options.ideSettings.useLatestVersion; params.set(StartWorkspaceOptions.EDITOR, latest ? ide + "-latest" : ide); } + if (options.autostart) { + params.set(StartWorkspaceOptions.AUTOSTART, "true"); + } return params.toString(); } diff --git a/components/dashboard/src/user-settings/Preferences.tsx b/components/dashboard/src/user-settings/Preferences.tsx index da3633ce03e957..46e252c36f76f3 100644 --- a/components/dashboard/src/user-settings/Preferences.tsx +++ b/components/dashboard/src/user-settings/Preferences.tsx @@ -11,7 +11,7 @@ import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu"; import { ThemeSelector } from "../components/ThemeSelector"; import Alert from "../components/Alert"; import { Link } from "react-router-dom"; -import { Heading2, Subheading } from "../components/typography/headings"; +import { Heading2, Heading3, Subheading } from "../components/typography/headings"; import { useUserMaySetTimeout } from "../data/current-user/may-set-timeout-query"; import { Button } from "../components/Button"; import SelectIDE from "./SelectIDE"; @@ -19,6 +19,7 @@ import { InputField } from "../components/forms/InputField"; import { TextInput } from "../components/forms/TextInputField"; import { useToast } from "../components/toasts/Toasts"; import { useUpdateCurrentUserDotfileRepoMutation } from "../data/current-user/update-mutation"; +import { AdditionalUserData } from "@gitpod/gitpod-protocol"; export type IDEChangedTrackLocation = "workspace_list" | "workspace_start" | "preferences"; @@ -67,12 +68,22 @@ export default function Preferences() { [toast, setUser, workspaceTimeout], ); + const clearAutostartWorkspaceOptions = useCallback(async () => { + if (!user) { + return; + } + AdditionalUserData.set(user, { workspaceAutostartOptions: [] }); + setUser(user); + await getGitpodService().server.updateLoggedInUser(user); + toast("Your autostart options were cleared."); + }, [setUser, toast, user]); + return (
- Editor + New Workspaces - Choose the editor for opening workspaces.{" "} + Choose your default editor.{" "} + Autostart Options + Forget any saved autostart options for all repositories. + diff --git a/components/dashboard/src/workspaces/CreateWorkspacePage.tsx b/components/dashboard/src/workspaces/CreateWorkspacePage.tsx index 317ab6e7c4bd17..9e5fad29744585 100644 --- a/components/dashboard/src/workspaces/CreateWorkspacePage.tsx +++ b/components/dashboard/src/workspaces/CreateWorkspacePage.tsx @@ -4,34 +4,37 @@ * See License.AGPL.txt in the project root for license information. */ -import { CommitContext, GitpodServer, WithReferrerContext } from "@gitpod/gitpod-protocol"; +import { AdditionalUserData, CommitContext, GitpodServer, WithReferrerContext } from "@gitpod/gitpod-protocol"; import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth"; import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred"; -import { FunctionComponent, useCallback, useEffect, useMemo, useState } from "react"; +import { FunctionComponent, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { useHistory, useLocation } from "react-router"; +import { Link } from "react-router-dom"; import { Button } from "../components/Button"; +import Modal from "../components/Modal"; import RepositoryFinder from "../components/RepositoryFinder"; import SelectIDEComponent from "../components/SelectIDEComponent"; import SelectWorkspaceClassComponent from "../components/SelectWorkspaceClassComponent"; -import { Heading1 } from "../components/typography/headings"; import { UsageLimitReachedModal } from "../components/UsageLimitReachedModal"; -import { useCurrentOrg } from "../data/organizations/orgs-query"; +import { CheckboxInputField } from "../components/forms/CheckboxInputField"; +import { Heading1 } from "../components/typography/headings"; +import { useAuthProviders } from "../data/auth-providers/auth-provider-query"; +import { useFeatureFlag } from "../data/featureflag-query"; +import { useCurrentOrg, useOrganizations } from "../data/organizations/orgs-query"; import { useListProjectsQuery } from "../data/projects/list-projects-query"; import { useCreateWorkspaceMutation } from "../data/workspaces/create-workspace-mutation"; import { useListWorkspacesQuery } from "../data/workspaces/list-workspaces-query"; import { useWorkspaceContext } from "../data/workspaces/resolve-context-query"; import { openAuthorizeWindow } from "../provider-utils"; -import { gitpodHostUrl } from "../service/service"; -import { StartWorkspaceOptions } from "../start/start-workspace-options"; +import { getGitpodService, gitpodHostUrl } from "../service/service"; import { StartWorkspaceError } from "../start/StartPage"; -import { useCurrentUser } from "../user-context"; +import { VerifyModal } from "../start/VerifyModal"; +import { StartWorkspaceOptions } from "../start/start-workspace-options"; +import { UserContext, useCurrentUser } from "../user-context"; import { SelectAccountModal } from "../user-settings/SelectAccountModal"; +import { settingsPathPreferences } from "../user-settings/settings.routes"; import { WorkspaceEntry } from "./WorkspaceEntry"; -import { useAuthProviders } from "../data/auth-providers/auth-provider-query"; -import { VerifyModal } from "../start/VerifyModal"; -import { useFeatureFlag } from "../data/featureflag-query"; -import Modal from "../components/Modal"; export const useNewCreateWorkspacePage = () => { const startWithOptions = useFeatureFlag("start_with_options"); @@ -40,8 +43,9 @@ export const useNewCreateWorkspacePage = () => { }; export function CreateWorkspacePage() { - const user = useCurrentUser(); + const { user, setUser } = useContext(UserContext); const currentOrg = useCurrentOrg().data; + const organizations = useOrganizations(); const projects = useListProjectsQuery(); const workspaces = useListWorkspacesQuery({ limit: 50 }); const location = useLocation(); @@ -69,6 +73,42 @@ export function CreateWorkspacePage() { StartWorkspaceOptions.parseContextUrl(location.hash), ); const workspaceContext = useWorkspaceContext(contextURL); + const [rememberOptions, setRememberOptions] = useState(false); + const [autostart, setAutostart] = useState(props.autostart); + + const storeAutoStartOptions = useCallback(() => { + if (!workspaceContext.data || !user || !currentOrg) { + return; + } + const cloneURL = CommitContext.is(workspaceContext.data) && workspaceContext.data.repository.cloneUrl; + if (!cloneURL) { + return; + } + let workspaceAutoStartOptions = (user.additionalData?.workspaceAutostartOptions || []).filter( + (e) => e.cloneURL !== cloneURL, + ); + + // we only keep the last 20 options + workspaceAutoStartOptions = workspaceAutoStartOptions.slice(-20); + + if (rememberOptions) { + workspaceAutoStartOptions.push({ + cloneURL, + organizationId: currentOrg.id, + ideSettings: { + defaultIde: selectedIde, + useLatestVersion: useLatestIde, + }, + workspaceClass: selectedWsClass, + }); + + AdditionalUserData.set(user, { + workspaceAutostartOptions: workspaceAutoStartOptions, + }); + } + setUser(user); + getGitpodService().server.updateLoggedInUser(user).catch(console.error); + }, [currentOrg, rememberOptions, selectedIde, selectedWsClass, setUser, useLatestIde, user, workspaceContext.data]); // see if we have a matching project based on context url and project's repo url const project = useMemo(() => { @@ -99,6 +139,8 @@ export function CreateWorkspacePage() { // This allows the contextURL to persist if user changes orgs, or copies/shares url const handleContextURLChange = useCallback( (newContextURL: string) => { + // we disable auto start if the user changes the context URL + setAutostart(false); setContextURL(newContextURL); history.replace(`#${newContextURL}`); }, @@ -163,11 +205,17 @@ export function CreateWorkspacePage() { console.log("Skipping duplicate createWorkspace call."); return; } + if (rememberOptions) { + storeAutoStartOptions(); + } + // we wait at least 5 secs + const timeout = new Promise((resolve) => setTimeout(resolve, 5000)); const result = await createWorkspaceMutation.mutateAsync({ contextUrl: contextURL, organizationId, ...opts, }); + await timeout; if (result.workspaceURL) { window.location.href = result.workspaceURL; } else if (result.createdWorkspaceId) { @@ -175,38 +223,88 @@ export function CreateWorkspacePage() { } } catch (error) { console.log(error); + } finally { + // we only auto start once, so we don't run into endless start loops on errors + if (autostart) { + setAutostart(false); + } } }, [ - createWorkspaceMutation, - history, contextURL, - selectedIde, - selectedWsClass, currentOrg?.id, user?.additionalData?.isMigratedToTeamOnlyAttribution, + selectedWsClass, + selectedIde, useLatestIde, + createWorkspaceMutation, + rememberOptions, + autostart, + storeAutoStartOptions, + history, ], ); + // listen on auto start changes + useEffect(() => { + if (!autostart) { + return; + } + createWorkspace(); + }, [autostart, createWorkspace]); + + // when workspaceContext is available, we look up if options are remembered + useEffect(() => { + if (!organizations.data) { + return; + } + const cloneURL = CommitContext.is(workspaceContext.data) && workspaceContext.data.repository.cloneUrl; + if (!cloneURL || autostart) { + return undefined; + } + const rememberedOptions = (user?.additionalData?.workspaceAutostartOptions || []).find( + (e) => e.cloneURL === cloneURL, + ); + setRememberOptions(!!rememberedOptions); + if (rememberedOptions) { + // if it's another org, we simply redirect using the same hash and let the reloaded page handle everything again. + if (rememberedOptions.organizationId !== currentOrg?.id) { + const org = organizations.data.find((o) => o.id === rememberedOptions.organizationId); + if (org) { + const redirect = `${location.pathname}?org=${encodeURIComponent(rememberedOptions.organizationId)}${ + location.hash + }`; + window.location.href = redirect; + } else { + console.warn("Could not find organization", rememberedOptions.organizationId); + } + } + if (rememberedOptions.ideSettings?.defaultIde) { + setSelectedIde(rememberedOptions.ideSettings?.defaultIde); + } + setUseLatestIde(!!rememberedOptions.ideSettings?.useLatestVersion); + if (rememberedOptions.workspaceClass) { + setSelectedWsClass(rememberedOptions.workspaceClass); + } + if (autostart === undefined) { + setAutostart(true); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceContext.data]); + // Need a wrapper here so we call createWorkspace w/o any arguments const onClickCreate = useCallback(() => createWorkspace(), [createWorkspace]); - // if the context URL has a referrer prefix, we set the referrerIde as the selected IDE and immediately start a workspace. + // if the context URL has a referrer prefix, we set the referrerIde as the selected IDE and autostart the workspace. useEffect(() => { if (workspaceContext.data && WithReferrerContext.is(workspaceContext.data)) { - let options: Omit | undefined; if (workspaceContext.data.referrerIde) { setSelectedIde(workspaceContext.data.referrerIde); - options = { - ideSettings: { - defaultIde: workspaceContext.data.referrerIde, - }, - }; } - createWorkspace(options); + setAutostart(true); } - }, [workspaceContext.data, createWorkspace]); + }, [workspaceContext.data]); if (SelectAccountPayload.is(selectAccountError)) { return ( @@ -259,7 +357,7 @@ export function CreateWorkspacePage() { {errorWsClass &&
{errorWsClass}
}
-
+
- {existingWorkspaces.length > 0 && ( + {workspaceContext.data && ( + + )} + {existingWorkspaces.length > 0 && !isStarting && (

Running workspaces on this revision

<> @@ -299,6 +404,33 @@ export function CreateWorkspacePage() { ); } +function RememberOptions(params: { disabled?: boolean; checked: boolean; onChange: (checked: boolean) => void }) { + const { disabled, checked, onChange } = params; + + return ( + <> +
+ +
+
+

+ Don't worry, you can reset this anytime in your{" "} + + preferences + + . +

+
+ + ); +} + function tryAuthorize(host: string, scopes?: string[]): Promise { const result = new Deferred(); openAuthorizeWindow({ diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index 5d76e53f75279d..6e41cfbc77fc2d 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -9,6 +9,7 @@ import { RoleOrPermission } from "./permission"; import { Project } from "./teams-projects-protocol"; import { createHash } from "crypto"; import { AttributionId } from "./attribution"; +import { WorkspaceRegion } from "./workspace-cluster"; export interface UserInfo { name?: string; @@ -273,7 +274,19 @@ export interface AdditionalUserData extends Partial { // whether the user has been migrated to team attribution. // a corresponding feature flag (team_only_attribution) triggers the migration. isMigratedToTeamOnlyAttribution?: boolean; + + // remembered workspace auto start options + workspaceAutostartOptions?: WorkspaceAutostartOption[]; +} + +interface WorkspaceAutostartOption { + cloneURL: string; + organizationId: string; + workspaceClass?: string; + ideSettings?: IDESettings; + region?: WorkspaceRegion; } + export namespace AdditionalUserData { export function set(user: User, partialData: Partial): User { if (!user.additionalData) {