From f1fcb4e193c7131906a02a91239bbd742a7d9f6c Mon Sep 17 00:00:00 2001 From: Sven Efftinge Date: Tue, 20 Dec 2022 13:59:01 +0000 Subject: [PATCH] [dashboard] new workspace with options --- .../dashboard/src/components/DropDown2.tsx | 170 +++++++++++++ components/dashboard/src/components/Modal.tsx | 2 +- .../src/components/RepositoryFinder.tsx | 236 +++++++----------- .../src/components/SelectIDEComponent.tsx | 145 +++++++++++ .../SelectWorkspaceClassComponent.tsx | 86 +++++++ .../src/hooks/use-user-and-teams-loader.ts | 8 +- components/dashboard/src/icons/Editor.svg | 3 + components/dashboard/src/icons/Repository.svg | 3 + .../dashboard/src/icons/WorkspaceClass.svg | 4 + .../dashboard/src/start/CreateWorkspace.tsx | 23 +- components/dashboard/src/start/Open.tsx | 2 +- .../src/workspaces/StartWorkspaceModal.tsx | 74 +++++- .../gitpod-protocol/src/ide-protocol.ts | 8 + .../src/util/gitpod-host-url.ts | 31 +++ 14 files changed, 626 insertions(+), 169 deletions(-) create mode 100644 components/dashboard/src/components/DropDown2.tsx create mode 100644 components/dashboard/src/components/SelectIDEComponent.tsx create mode 100644 components/dashboard/src/components/SelectWorkspaceClassComponent.tsx create mode 100644 components/dashboard/src/icons/Editor.svg create mode 100644 components/dashboard/src/icons/Repository.svg create mode 100644 components/dashboard/src/icons/WorkspaceClass.svg diff --git a/components/dashboard/src/components/DropDown2.tsx b/components/dashboard/src/components/DropDown2.tsx new file mode 100644 index 00000000000000..f9f4ba198af054 --- /dev/null +++ b/components/dashboard/src/components/DropDown2.tsx @@ -0,0 +1,170 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import React, { FunctionComponent, useEffect, useMemo, useState } from "react"; +import Arrow from "./Arrow"; + +export interface DropDown2Element { + id: string; + element: JSX.Element; + isSelectable?: boolean; +} + +export interface DropDown2Props { + getElements: (searchString: string) => DropDown2Element[]; + searchPlaceholder?: string; + disableSearch?: boolean; + expanded?: boolean; + onSelectionChange: (id: string) => void; +} + +export const DropDown2: FunctionComponent = (props) => { + const [showDropDown, setShowDropDown] = useState(!!props.expanded); + const onSelected = useMemo( + () => (elementId: string) => { + props.onSelectionChange(elementId); + setShowDropDown(false); + }, + [props], + ); + const [search, setSearch] = useState(""); + const filteredOptions = props.getElements(search); + const [selectedElementTemp, setSelectedElementTemp] = useState(filteredOptions[0]?.id); + + // reset search when the drop down is expanded or closed + useEffect(() => { + setSearch(""); + }, [showDropDown]); + + const onKeyDown = useMemo( + () => (e: React.KeyboardEvent) => { + if (!showDropDown) { + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + let idx = filteredOptions.findIndex((e) => e.id === selectedElementTemp); + while (idx++ < filteredOptions.length - 1) { + const candidate = filteredOptions[idx]; + if (candidate.isSelectable) { + setSelectedElementTemp(candidate.id); + return; + } + } + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + let idx = filteredOptions.findIndex((e) => e.id === selectedElementTemp); + while (idx-- > 0) { + const candidate = filteredOptions[idx]; + if (candidate.isSelectable) { + setSelectedElementTemp(candidate.id); + return; + } + } + return; + } + if (e.key === "Escape") { + setShowDropDown(false); + e.preventDefault(); + } + if (e.key === "Enter" && selectedElementTemp && filteredOptions.some((e) => e.id === selectedElementTemp)) { + e.preventDefault(); + props.onSelectionChange(selectedElementTemp); + setShowDropDown(false); + } + }, + [filteredOptions, props, selectedElementTemp, showDropDown], + ); + + const handleBlur = useMemo( + () => () => { + // postpone a little, so it doesn't fire before a click event for the main element. + setTimeout(() => setShowDropDown(false), 100); + }, + [setShowDropDown], + ); + + const toggleDropDown = useMemo( + () => () => { + setShowDropDown(!showDropDown); + }, + [setShowDropDown, showDropDown], + ); + + return ( +
+
+ {props.children} +
+
+ +
+
+ {showDropDown && ( +
+ { +
+ setSearch(e.target.value)} + /> +
+ } +
    + {filteredOptions.length > 0 ? ( + filteredOptions.map((element) => { + let selectionClasses = `dark:bg-gray-800 cursor-pointer`; + if (element.id === selectedElementTemp) { + selectionClasses = `bg-gray-200 dark:bg-gray-700 cursor-pointer`; + } + if (!element.isSelectable) { + selectionClasses = ``; + } + return ( +
  • { + if (element.isSelectable) { + setSelectedElementTemp(element.id); + onSelected(element.id); + } + }} + onMouseOver={() => setSelectedElementTemp(element.id)} + > + {element.element} +
  • + ); + }) + ) : ( +
  • +
    No results
    +
  • + )} +
+
+ )} +
+ ); +}; diff --git a/components/dashboard/src/components/Modal.tsx b/components/dashboard/src/components/Modal.tsx index 38c8cd894cafc7..9f90194bceb61b 100644 --- a/components/dashboard/src/components/Modal.tsx +++ b/components/dashboard/src/components/Modal.tsx @@ -123,7 +123,7 @@ type ModalBodyProps = { export const ModalBody = ({ children, hideDivider = false }: ModalBodyProps) => { return (
diff --git a/components/dashboard/src/components/RepositoryFinder.tsx b/components/dashboard/src/components/RepositoryFinder.tsx index 27679e6f9358cf..73d59feed0453b 100644 --- a/components/dashboard/src/components/RepositoryFinder.tsx +++ b/components/dashboard/src/components/RepositoryFinder.tsx @@ -4,137 +4,96 @@ * See License.AGPL.txt in the project root for license information. */ -import { User } from "@gitpod/gitpod-protocol"; -import React, { useContext, useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { getGitpodService } from "../service/service"; -import { UserContext } from "../user-context"; - -type SearchResult = string; -type SearchData = SearchResult[]; +import { DropDown2, DropDown2Element } from "./DropDown2"; +import Repository from "../icons/Repository.svg"; const LOCAL_STORAGE_KEY = "open-in-gitpod-search-data"; -const MAX_DISPLAYED_ITEMS = 20; - -export default function RepositoryFinder(props: { initialQuery?: string }) { - const { user } = useContext(UserContext); - const [searchQuery, setSearchQuery] = useState(props.initialQuery || ""); - const [searchResults, setSearchResults] = useState([]); - const [selectedSearchResult, setSelectedSearchResult] = useState(); - - const onResults = (results: SearchResult[]) => { - if (JSON.stringify(results) !== JSON.stringify(searchResults)) { - setSearchResults(results); - setSelectedSearchResult(results[0]); - } - }; - - const search = async (query: string) => { - setSearchQuery(query); - await findResults(query, onResults); - if (await refreshSearchData(query, user)) { - // Re-run search if the underlying search data has changed - await findResults(query, onResults); - } - }; - - useEffect(() => { - search(""); - }, []); - // Up/Down keyboard navigation between results - const onKeyDown = (event: React.KeyboardEvent) => { - if (!selectedSearchResult) { - return; - } - const selectedIndex = searchResults.indexOf(selectedSearchResult); - const select = (index: number) => { - // Implement a true modulus in order to "wrap around" (e.g. `select(-1)` should select the last result) - // Source: https://stackoverflow.com/a/4467559/3461173 - const n = Math.min(searchResults.length, MAX_DISPLAYED_ITEMS); - setSelectedSearchResult(searchResults[((index % n) + n) % n]); - }; - if (event.key === "ArrowDown") { - event.preventDefault(); - select(selectedIndex + 1); - return; - } - if (event.key === "ArrowUp") { - event.preventDefault(); - select(selectedIndex - 1); - return; - } - }; +interface RepositoryFinderProps { + initialValue?: string; + maxDisplayItems?: number; + setSelection: (selection: string) => void; +} +export default function RepositoryFinder(props: RepositoryFinderProps) { + const [suggestedContextURLs, setSuggestedContextURLs] = useState(loadSearchData()); useEffect(() => { - const element = document.querySelector(`a[href='/#${selectedSearchResult}']`); - if (element) { - element.scrollIntoView({ behavior: "smooth", block: "nearest" }); - } - }, [selectedSearchResult]); - - const onSubmit = (event: React.FormEvent) => { - event.preventDefault(); - if (selectedSearchResult) { - window.location.href = "/#" + selectedSearchResult; - } - }; + getGitpodService() + .server.getSuggestedContextURLs() + .then((urls) => { + setSuggestedContextURLs(urls); + saveSearchData(urls); + }); + }, [suggestedContextURLs]); + + const getElements = useMemo( + () => (searchString: string) => { + const result = [...suggestedContextURLs]; + try { + // If the searchString is a URL, and it's not present in the proposed results, "artificially" add it here. + new URL(searchString); + if (!result.includes(searchString)) { + result.push(searchString); + } + } catch {} + return result + .filter((e) => e.toLowerCase().indexOf(searchString.toLowerCase()) !== -1) + .map( + (e) => + ({ + id: e, + element: ( +
+
+
+ {e.substring(e.indexOf("//") + 2)} +
+
{}
+
+
{}
+
+ ), + isSelectable: true, + } as DropDown2Element), + ); + }, + [suggestedContextURLs], + ); return ( -
-
-
- - - + +
+
+ logo +
+
+
+
Repository
+
+
+ {displayContextUrl(props.initialValue) || "Select a repository"} +
- search(e.target.value)} - onKeyDown={onKeyDown} - /> -
-
- {searchResults.slice(0, MAX_DISPLAYED_ITEMS).map((result, index) => ( - setSelectedSearchResult(result)} - > - {searchQuery.length < 2 ? ( - {result} - ) : ( - result.split(searchQuery).map((segment, index) => ( - - {index === 0 ? <> : {searchQuery}} - {segment} - - )) - )} - - ))} - {searchResults.length > MAX_DISPLAYED_ITEMS && ( - - {searchResults.length - MAX_DISPLAYED_ITEMS} more result - {searchResults.length - MAX_DISPLAYED_ITEMS === 1 ? "" : "s"} found - - )}
- +
); } -function loadSearchData(): SearchData { +function displayContextUrl(contextUrl?: string) { + if (!contextUrl) { + return undefined; + } + return contextUrl.substring(contextUrl.indexOf("//") + 2); +} + +function loadSearchData(): string[] { const string = localStorage.getItem(LOCAL_STORAGE_KEY); if (!string) { return []; @@ -148,7 +107,7 @@ function loadSearchData(): SearchData { } } -function saveSearchData(searchData: SearchData): void { +function saveSearchData(searchData: string[]): void { try { window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(searchData)); } catch (error) { @@ -156,37 +115,10 @@ function saveSearchData(searchData: SearchData): void { } } -let refreshSearchDataPromise: Promise | undefined; -export async function refreshSearchData(query: string, user: User | undefined): Promise { - if (refreshSearchDataPromise) { - // Another refresh is already in progress, no need to run another one in parallel. - return refreshSearchDataPromise; - } - refreshSearchDataPromise = actuallyRefreshSearchData(query, user); - const didChange = await refreshSearchDataPromise; - refreshSearchDataPromise = undefined; - return didChange; -} - -// Fetch all possible search results and cache them into local storage -async function actuallyRefreshSearchData(query: string, user: User | undefined): Promise { - const oldData = loadSearchData(); - const newData = await getGitpodService().server.getSuggestedContextURLs(); - if (JSON.stringify(oldData) !== JSON.stringify(newData)) { - saveSearchData(newData); - return true; - } - return false; -} - -async function findResults(query: string, onResults: (results: string[]) => void) { - const searchData = loadSearchData(); - try { - // If the query is a URL, and it's not present in the proposed results, "artificially" add it here. - new URL(query); - if (!searchData.includes(query)) { - searchData.push(query); - } - } catch {} - onResults(searchData.filter((result) => result.toLowerCase().includes(query.toLowerCase()))); +export function refreshSearchData() { + getGitpodService() + .server.getSuggestedContextURLs() + .then((urls) => { + saveSearchData(urls); + }); } diff --git a/components/dashboard/src/components/SelectIDEComponent.tsx b/components/dashboard/src/components/SelectIDEComponent.tsx new file mode 100644 index 00000000000000..b35d8305ac7567 --- /dev/null +++ b/components/dashboard/src/components/SelectIDEComponent.tsx @@ -0,0 +1,145 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { IDEOption, IDEOptions } from "@gitpod/gitpod-protocol/lib/ide-protocol"; +import { useEffect, useMemo, useState } from "react"; +import { getGitpodService } from "../service/service"; +import { DropDown2, DropDown2Element } from "./DropDown2"; +import Editor from "../icons/Editor.svg"; + +interface SelectIDEComponentProps { + selectedIdeOption?: string; + useLatest?: boolean; + onSelectionChange: (ide: string, latest: boolean) => void; +} + +export default function SelectIDEComponent(props: SelectIDEComponentProps) { + const [ideOptions, setIdeOptions] = useState(); + useEffect(() => { + getGitpodService().server.getIDEOptions().then(setIdeOptions); + }, []); + const getElements = useMemo(() => { + return (search: string) => { + if (!ideOptions) { + return []; + } + const options = IDEOptions.asArray(ideOptions); + const result: DropDown2Element[] = []; + for (const ide of options.filter( + (ide) => + `${ide.label}${ide.title}${ide.notes}${ide.id}`.toLowerCase().indexOf(search.toLowerCase()) !== -1, + )) { + result.push({ + id: ide.id, + element: , + isSelectable: true, + }); + if (ide.imageVersion !== ide.latestImageVersion) { + result.push({ + id: ide.id + "-latest", + element: , + isSelectable: true, + }); + } + } + return result; + }; + }, [ideOptions]); + const internalOnSelectionChange = (id: string) => { + const { ide, useLatest } = parseId(id); + props.onSelectionChange(ide, useLatest); + }; + const ide = props.selectedIdeOption || ideOptions?.defaultIde || ""; + return ( + + + + ); +} + +function parseId(id: string): { ide: string; useLatest: boolean } { + const useLatest = id.endsWith("-latest"); + const ide = useLatest ? id.slice(0, -7) : id; + return { ide, useLatest }; +} + +interface IdeOptionElementProps { + option: IDEOption | undefined; + useLatest: boolean; +} + +function IdeOptionElementSelected(p: IdeOptionElementProps): JSX.Element { + const { option, useLatest } = p; + if (!option) { + return <>; + } + const version = useLatest ? option.latestImageVersion : option.imageVersion; + const label = option.type === "desktop" ? "" : option.type; + + return ( +
+
+ logo +
+
+
Editor
+
+
{option.title}
+ {version && ( + <> +
·
+
{version}
+ + )} + {label && ( + <> +
·
+
{label && label[0].toLocaleUpperCase() + label.slice(1)}
+ + )} + {useLatest &&
Latest
} +
+
+
+ ); +} + +function IdeOptionElementInDropDown(p: IdeOptionElementProps): JSX.Element { + const { option, useLatest } = p; + if (!option) { + return <>; + } + const version = useLatest ? option.latestImageVersion : option.imageVersion; + const label = option.type && option.type[0].toLocaleUpperCase() + option.type.slice(1); + + return ( +
+
+ logo +
+
+
{option.title}
+ {version && ( + <> +
·
+
{version}
+ + )} + {label && ( + <> +
·
+
{label}
+ + )} + {useLatest &&
Latest
} +
+
+ ); +} diff --git a/components/dashboard/src/components/SelectWorkspaceClassComponent.tsx b/components/dashboard/src/components/SelectWorkspaceClassComponent.tsx new file mode 100644 index 00000000000000..c3352ee1b0accc --- /dev/null +++ b/components/dashboard/src/components/SelectWorkspaceClassComponent.tsx @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License.AGPL.txt in the project root for license information. + */ + +import { SupportedWorkspaceClass } from "@gitpod/gitpod-protocol/lib/workspace-class"; +import { useEffect, useMemo, useState } from "react"; +import { getGitpodService } from "../service/service"; +import WorkspaceClass from "../icons/WorkspaceClass.svg"; +import { DropDown2, DropDown2Element } from "./DropDown2"; + +interface SelectWorkspaceClassProps { + selectedWorkspaceClass?: string; + onSelectionChange: (workspaceClass: string) => void; +} + +export default function SelectWorkspaceClassComponent(props: SelectWorkspaceClassProps) { + const [workspaceClasses, setWorkspaceClasses] = useState(); + useEffect(() => { + getGitpodService().server.getSupportedWorkspaceClasses().then(setWorkspaceClasses); + }, []); + const getElements = useMemo(() => { + return () => { + if (!workspaceClasses) { + return []; + } + return [ + ...workspaceClasses.map( + (c) => + ({ + id: c.id, + element: , + isSelectable: true, + } as DropDown2Element), + ), + ]; + }; + }, [workspaceClasses]); + return ( + + ws.id === (props.selectedWorkspaceClass || workspaceClasses.find((ws) => ws.isDefault)?.id), + )} + /> + + ); +} + +function WorkspaceClassDropDownElementSelected(props: { wsClass?: SupportedWorkspaceClass }): JSX.Element { + const c = props.wsClass; + return ( +
+
+ logo +
+
+
Class
+
+
{c?.displayName}
+
·
+
{c?.description}
+
+
+
+ ); +} + +function WorkspaceClassDropDownElement(props: { wsClass: SupportedWorkspaceClass }): JSX.Element { + const c = props.wsClass; + return ( +
+
{c.displayName}
+
·
+
+
{c?.description}
+
+
+ ); +} diff --git a/components/dashboard/src/hooks/use-user-and-teams-loader.ts b/components/dashboard/src/hooks/use-user-and-teams-loader.ts index 156379dd2fe801..aee0c133736c7c 100644 --- a/components/dashboard/src/hooks/use-user-and-teams-loader.ts +++ b/components/dashboard/src/hooks/use-user-and-teams-loader.ts @@ -31,6 +31,7 @@ export const useUserAndTeamsLoader = () => { try { loggedInUser = await getGitpodService().server.getLoggedInUser(); setUser(loggedInUser); + refreshSearchData(); // TODO: atm this feature-flag won't have been set yet, as it's dependant on user/teams // so it will always be false when this runs @@ -79,12 +80,5 @@ export const useUserAndTeamsLoader = () => { refreshUserBillingMode(); }, [teams, refreshUserBillingMode]); - // TODO: Can this check happen when we load the user rather than a separate effect? - useEffect(() => { - if (user) { - refreshSearchData("", user); - } - }, [user]); - return { user, teams, loading, isSetupRequired }; }; diff --git a/components/dashboard/src/icons/Editor.svg b/components/dashboard/src/icons/Editor.svg new file mode 100644 index 00000000000000..00c82baf216a88 --- /dev/null +++ b/components/dashboard/src/icons/Editor.svg @@ -0,0 +1,3 @@ + + + diff --git a/components/dashboard/src/icons/Repository.svg b/components/dashboard/src/icons/Repository.svg new file mode 100644 index 00000000000000..5780dcdd0be8b6 --- /dev/null +++ b/components/dashboard/src/icons/Repository.svg @@ -0,0 +1,3 @@ + + + diff --git a/components/dashboard/src/icons/WorkspaceClass.svg b/components/dashboard/src/icons/WorkspaceClass.svg new file mode 100644 index 00000000000000..fd54c45cc1d79f --- /dev/null +++ b/components/dashboard/src/icons/WorkspaceClass.svg @@ -0,0 +1,4 @@ + + + + diff --git a/components/dashboard/src/start/CreateWorkspace.tsx b/components/dashboard/src/start/CreateWorkspace.tsx index 52d74ac2f41cea..15edeb937a73a8 100644 --- a/components/dashboard/src/start/CreateWorkspace.tsx +++ b/components/dashboard/src/start/CreateWorkspace.tsx @@ -27,6 +27,7 @@ import { isGitpodIo } from "../utils"; import { BillingAccountSelector } from "../components/BillingAccountSelector"; import { FeatureFlagContext } from "../contexts/FeatureFlagContext"; import { UsageLimitReachedModal } from "../components/UsageLimitReachedModal"; +import { StartOptions } from "@gitpod/gitpod-protocol/lib/util/gitpod-host-url"; export interface CreateWorkspaceProps { contextUrl: string; @@ -39,6 +40,22 @@ export interface CreateWorkspaceState { stillParsing: boolean; } +function parseSearchParams(search: string): GitpodServer.StartWorkspaceOptions { + const params = new URLSearchParams(search); + const options: GitpodServer.StartWorkspaceOptions = {}; + if (params.has(StartOptions.WORKSPACE_CLASS)) { + options.workspaceClass = params.get(StartOptions.WORKSPACE_CLASS)!; + } + if (params.has(StartOptions.EDITOR)) { + const useLatestVersion = params.get(StartOptions.USE_LATEST_EDITOR) === "true"; + options.ideSettings = { + defaultIde: params.get(StartOptions.EDITOR)!, + useLatestVersion, + }; + } + return options; +} + export default class CreateWorkspace extends React.Component { constructor(props: CreateWorkspaceProps) { super(props); @@ -53,13 +70,17 @@ export default class CreateWorkspace extends React.Component this.setState({ stillParsing: false }), 3000); try { const result = await getGitpodService().server.createWorkspace({ contextUrl: this.props.contextUrl, - ...options, + ...opts, }); if (result.workspaceURL) { window.location.href = result.workspaceURL; diff --git a/components/dashboard/src/start/Open.tsx b/components/dashboard/src/start/Open.tsx index 81a2ae813f959d..955a24790c51b8 100644 --- a/components/dashboard/src/start/Open.tsx +++ b/components/dashboard/src/start/Open.tsx @@ -29,7 +29,7 @@ export default function Open() {

Open in Gitpod

- + {}} />
); diff --git a/components/dashboard/src/workspaces/StartWorkspaceModal.tsx b/components/dashboard/src/workspaces/StartWorkspaceModal.tsx index 3e8a5f0a11dc7d..dbee7127d977c1 100644 --- a/components/dashboard/src/workspaces/StartWorkspaceModal.tsx +++ b/components/dashboard/src/workspaces/StartWorkspaceModal.tsx @@ -4,31 +4,91 @@ * See License.AGPL.txt in the project root for license information. */ -import { useContext, useEffect } from "react"; +import { StartOptions } from "@gitpod/gitpod-protocol/lib/util/gitpod-host-url"; +import { useContext, useEffect, useMemo, useState } from "react"; import { useLocation } from "react-router"; import Modal from "../components/Modal"; import RepositoryFinder from "../components/RepositoryFinder"; +import SelectIDEComponent from "../components/SelectIDEComponent"; +import SelectWorkspaceClassComponent from "../components/SelectWorkspaceClassComponent"; +import { UserContext } from "../user-context"; import { StartWorkspaceModalContext } from "./start-workspace-modal-context"; export function StartWorkspaceModal() { + const { user } = useContext(UserContext); const { isStartWorkspaceModalVisible, setIsStartWorkspaceModalVisible } = useContext(StartWorkspaceModalContext); const location = useLocation(); + const [useLatestIde, setUseLatestIde] = useState( + !!user?.additionalData?.ideSettings?.useLatestVersion, + ); + const [selectedIde, setSelectedIde] = useState(user?.additionalData?.ideSettings?.defaultIde); + const [selectedWsClass, setSelectedWsClass] = useState(); + const [repo, setRepo] = useState(""); + const onSelectEditorChange = (ide: string, useLatest: boolean) => { + setSelectedIde(ide); + setUseLatestIde(useLatest); + }; + + const startWorkspace = useMemo(() => { + return () => { + if (!repo) { + return false; + } + const url = new URL(window.location.href); + url.pathname = ""; + const searchParams = new URLSearchParams(); + if (selectedWsClass) { + searchParams.set(StartOptions.WORKSPACE_CLASS, selectedWsClass); + } + if (selectedIde) { + searchParams.set(StartOptions.EDITOR, selectedIde); + searchParams.set(StartOptions.USE_LATEST_EDITOR, useLatestIde ? "true" : "false"); + } + url.search = searchParams.toString(); + url.hash = "#" + repo; + window.location.href = url.toString(); + return true; + }; + }, [repo, selectedIde, selectedWsClass, useLatestIde]); // Close the modal on navigation events. useEffect(() => { setIsStartWorkspaceModalVisible(false); - }, [location]); + }, [location, setIsStartWorkspaceModalVisible]); return ( - // TODO: Use title and buttons props setIsStartWorkspaceModalVisible(false)} - onEnter={() => false} + onEnter={startWorkspace} visible={!!isStartWorkspaceModalVisible} + title="Open in Gitpod" + buttons={[ + , + , + ]} > -

Open in Gitpod

-
- +
+
Select a repository and configure workspace options.
+
+ +
+
+ +
+
+ +
); diff --git a/components/gitpod-protocol/src/ide-protocol.ts b/components/gitpod-protocol/src/ide-protocol.ts index 584a1c64c5ff92..8c8685b80e679b 100644 --- a/components/gitpod-protocol/src/ide-protocol.ts +++ b/components/gitpod-protocol/src/ide-protocol.ts @@ -36,6 +36,14 @@ export interface IDEOptions { clients?: { [id: string]: IDEClient }; } +export namespace IDEOptions { + export function asArray(options: IDEOptions): (IDEOption & { id: string })[] { + return Object.keys(options.options) + .map((id) => ({ ...options.options[id], id })) + .sort((a, b) => (a.orderKey || "").localeCompare(b.orderKey || "")); + } +} + export interface IDEClient { /** * The default desktop IDE when the user has not specified one. diff --git a/components/gitpod-protocol/src/util/gitpod-host-url.ts b/components/gitpod-protocol/src/util/gitpod-host-url.ts index d81198440a7b1c..2fe9c146c04978 100644 --- a/components/gitpod-protocol/src/util/gitpod-host-url.ts +++ b/components/gitpod-protocol/src/util/gitpod-host-url.ts @@ -21,6 +21,12 @@ const workspaceIDRegex = RegExp(`^${baseWorkspaceIDRegex}$`); // this pattern matches URL prefixes of workspaces const workspaceUrlPrefixRegex = RegExp(`^([0-9]{4,6}-)?${baseWorkspaceIDRegex}\\.`); +export namespace StartOptions { + export const WORKSPACE_CLASS = "workspaceClass"; + export const EDITOR = "editor"; + export const USE_LATEST_EDITOR = "useLatestEditor"; +} + export class GitpodHostUrl { readonly url: URL; @@ -124,6 +130,31 @@ export class GitpodHostUrl { }); } + asCreateWorkspace( + contextUrl: string, + o?: { + workspaceClass?: string; + editor?: string; + useLatestEditor?: boolean; + }, + ): GitpodHostUrl { + const searchParams: URLSearchParams = new URLSearchParams(); + if (o?.workspaceClass && o?.workspaceClass.length > 0) { + searchParams.append(StartOptions.WORKSPACE_CLASS, o.workspaceClass); + } + if (o?.editor && o?.editor?.length > 0) { + searchParams.append(StartOptions.EDITOR, o.editor); + } + if (o?.useLatestEditor !== undefined) { + searchParams.append(StartOptions.USE_LATEST_EDITOR, o.useLatestEditor.toString()); + } + return this.withoutWorkspacePrefix().with({ + pathname: "/", + search: searchParams.toString(), + hash: "#" + contextUrl, + }); + } + asWorkspaceAuth(instanceID: string, redirect?: boolean): GitpodHostUrl { return this.with((url) => ({ pathname: `/api/auth/workspace-cookie/${instanceID}`,