diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index e90c6ac4196f10..7c0cf3aa5857d8 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -25,6 +25,8 @@ import { Experiment } from './experiments'; import { workspacesPathMain } from './workspaces/workspaces.routes'; import { settingsPathAccount, settingsPathIntegrations, settingsPathMain, settingsPathNotifications, settingsPathPlans, settingsPathPreferences, settingsPathTeams, settingsPathTeamsJoin, settingsPathTeamsNew, settingsPathVariables } from './settings/settings.routes'; import { projectsPathInstallGitHubApp, projectsPathMain, projectsPathMainWithParams, projectsPathNew } from './projects/projects.routes'; +import { refreshSearchData } from './components/RepositoryFinder'; +import { StartWorkspaceModal } from './workspaces/StartWorkspaceModal'; const Setup = React.lazy(() => import(/* webpackPrefetch: true */ './Setup')); const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ './workspaces/Workspaces')); @@ -35,6 +37,7 @@ const Teams = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Te const EnvironmentVariables = React.lazy(() => import(/* webpackPrefetch: true */ './settings/EnvironmentVariables')); const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Integrations')); const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Preferences')); +const Open = React.lazy(() => import(/* webpackPrefetch: true */ './start/Open')); const StartWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/StartWorkspace')); const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/CreateWorkspace')); const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/NewTeam')); @@ -216,6 +219,12 @@ function App() { return () => window.removeEventListener("click", handleButtonOrAnchorTracking, true); }, []); + useEffect(() => { + if (user) { + refreshSearchData('', user); + } + }, [user]); + // redirect to website for any website slugs if (isGitpodIo() && isWebsiteSlug(window.location.pathname)) { window.location.host = 'www.gitpod.io'; @@ -275,6 +284,7 @@ function App() { + @@ -383,6 +393,7 @@ function App() { }}> + ; diff --git a/components/dashboard/src/Menu.tsx b/components/dashboard/src/Menu.tsx index e9661a1f483c44..d67cf9239d7f1c 100644 --- a/components/dashboard/src/Menu.tsx +++ b/components/dashboard/src/Menu.tsx @@ -69,7 +69,7 @@ export default function Menu() { } // Hide most of the top menu when in a full-page form. - const isMinimalUI = ['/new', '/teams/new'].includes(location.pathname); + const isMinimalUI = ['/new', '/teams/new', '/open'].includes(location.pathname); const [ teamMembers, setTeamMembers ] = useState>({}); useEffect(() => { diff --git a/components/dashboard/src/components/Modal.tsx b/components/dashboard/src/components/Modal.tsx index a53f46392cba96..4e75a78213e841 100644 --- a/components/dashboard/src/components/Modal.tsx +++ b/components/dashboard/src/components/Modal.tsx @@ -51,7 +51,7 @@ export default function Modal(props: { return (
-
e.stopPropagation()}> +
e.stopPropagation()}> {props.closeable !== false && (
(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); + } + } + + // 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; + } + } + + 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; + } + } + + return
+
+
+ +
+ search(e.target.value)} onKeyDown={onKeyDown} /> +
+
+ {searchQuery === '' && searchResults.length === 0 && +
+ Paste a repository context URL, or start typing to see suggestions from: +
    +
  • Your recent repositories
  • +
  • Your repositories from connected integrations
  • +
  • Example repositories
  • +
+
} + {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 { + const string = localStorage.getItem(LOCAL_STORAGE_KEY); + if (!string) { + return []; + } + try { + const data = JSON.parse(string); + return data; + } catch (error) { + console.warn('Could not load search data from local storage', error); + return []; + } +} + +function saveSearchData(searchData: SearchData): void { + try { + window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(searchData)); + } catch (error) { + console.warn('Could not save search data into local storage', error); + } +} + +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 { + console.log('refreshing search data'); + const oldData = loadSearchData(); + const newData = await getGitpodService().server.getSuggestedContextURLs(); + if (JSON.stringify(oldData) !== JSON.stringify(newData)) { + console.log('new data:', newData); + saveSearchData(newData); + return true; + } + return false; +} + +async function findResults(query: string, onResults: (results: string[]) => void) { + if (!query) { + onResults([]); + return; + } + 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 { + } + // console.log('searching', query, 'in', searchData); + onResults(searchData.filter(result => result.toLowerCase().includes(query.toLowerCase()))); +} diff --git a/components/dashboard/src/index.tsx b/components/dashboard/src/index.tsx index 5057321cacf1ae..ec4e6a3462d755 100644 --- a/components/dashboard/src/index.tsx +++ b/components/dashboard/src/index.tsx @@ -12,6 +12,7 @@ import { AdminContextProvider } from './admin-context'; import { TeamsContextProvider } from './teams/teams-context'; import { ProjectContextProvider } from './projects/project-context'; import { ThemeContextProvider } from './theme-context'; +import { StartWorkspaceModalContextProvider } from './workspaces/start-workspace-modal-context'; import { BrowserRouter } from 'react-router-dom'; import "./index.css" @@ -23,9 +24,11 @@ ReactDOM.render( - - - + + + + + diff --git a/components/dashboard/src/start/Open.tsx b/components/dashboard/src/start/Open.tsx new file mode 100644 index 00000000000000..b8970c099fd95a --- /dev/null +++ b/components/dashboard/src/start/Open.tsx @@ -0,0 +1,34 @@ +/** + * 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 { useEffect, useState } from "react"; +import RepositoryFinder from "../components/RepositoryFinder"; + +export default function Open() { + const [ initialQuery, setInitialQuery ] = useState(); + + // Support pre-filling the search bar via the URL hash + useEffect(() => { + const onHashChange = () => { + const hash = window.location.hash.slice(1); + if (hash) { + setInitialQuery(hash); + } + } + onHashChange(); + window.addEventListener('hashchange', onHashChange, false); + return () => { + window.removeEventListener('hashchange', onHashChange, false); + } + }, []); + + return
+

Open in Gitpod

+
+ +
+
; +} diff --git a/components/dashboard/src/workspaces/StartWorkspaceModal.tsx b/components/dashboard/src/workspaces/StartWorkspaceModal.tsx index 86265bc5b26719..fb353d903933d3 100644 --- a/components/dashboard/src/workspaces/StartWorkspaceModal.tsx +++ b/components/dashboard/src/workspaces/StartWorkspaceModal.tsx @@ -4,70 +4,25 @@ * See License-AGPL.txt in the project root for license information. */ -import { useEffect, useState } from "react"; +import { useContext, useEffect } from "react"; +import { useLocation } from "react-router"; import Modal from "../components/Modal"; -import TabMenuItem from "../components/TabMenuItem"; +import RepositoryFinder from "../components/RepositoryFinder"; +import { StartWorkspaceModalContext } from "./start-workspace-modal-context"; -export interface WsStartEntry { - title: string - description: string - startUrl: string -} - -interface StartWorkspaceModalProps { - visible: boolean; - recent: WsStartEntry[]; - examples: WsStartEntry[]; - selected?: Mode; - onClose: () => void; -} +export function StartWorkspaceModal() { + const { isStartWorkspaceModalVisible, setIsStartWorkspaceModalVisible } = useContext(StartWorkspaceModalContext); + const location = useLocation(); -type Mode = 'Recent' | 'Examples'; + // Close the modal on navigation events. + useEffect(() => { + setIsStartWorkspaceModalVisible(false); + }, [location]); -export function StartWorkspaceModal(p: StartWorkspaceModalProps) { - const computeSelection = () => p.selected || (p.recent.length > 0 ? 'Recent' : 'Examples'); - const [selection, setSelection] = useState(computeSelection()); - useEffect(() => { !p.visible && setSelection(computeSelection()) }, [p.visible, p.recent, p.selected]); - - const list = (selection === 'Recent' ? p.recent : p.examples).map((e, i) => - -
-

{e.title}

-

{e.description}

-
-
); - - return -

New Workspace

- {/* separator */} -
-
- setSelection('Recent')} /> - {p.examples.length>0 && setSelection('Examples')} />} -
-
-
- {list.length > 0 ? -

- {selection === 'Recent' ? - 'Create a new workspace using the default branch.' : - 'Create a new workspace using an example project.'} -

:

} -
- {list.length > 0 ? list : - (selection === 'Recent' ? -
-

No Recent Projects

-

Projects you use frequently will show up here.

-

Prefix a Git repository URL with {window.location.host}/# or start with an example.

- -
: -
-

No Example Projects

-

Sorry there seem to be no example projects, that work with your current Git provider.

-
) - } -
+ return setIsStartWorkspaceModalVisible(false)} onEnter={() => false} visible={!!isStartWorkspaceModalVisible}> +

Open in Gitpod

+
+
; } diff --git a/components/dashboard/src/workspaces/Workspaces.tsx b/components/dashboard/src/workspaces/Workspaces.tsx index 0b8236893257fb..6156f3248ad585 100644 --- a/components/dashboard/src/workspaces/Workspaces.tsx +++ b/components/dashboard/src/workspaces/Workspaces.tsx @@ -5,16 +5,16 @@ */ import { useContext, useEffect, useState } from "react"; -import { WhitelistedRepository, Workspace, WorkspaceInfo } from "@gitpod/gitpod-protocol"; +import { WhitelistedRepository, WorkspaceInfo } from "@gitpod/gitpod-protocol"; import Header from "../components/Header"; import DropDown from "../components/DropDown"; import { WorkspaceModel } from "./workspace-model"; import { WorkspaceEntry } from "./WorkspaceEntry"; -import { getGitpodService, gitpodHostUrl } from "../service/service"; -import { StartWorkspaceModal, WsStartEntry } from "./StartWorkspaceModal"; +import { getGitpodService } from "../service/service"; import { ItemsList } from "../components/ItemsList"; -import { getCurrentTeam, TeamsContext } from "../teams/teams-context"; -import { useLocation, useRouteMatch } from "react-router"; +import { TeamsContext } from "../teams/teams-context"; +import { useLocation } from "react-router"; +import { StartWorkspaceModalContext, StartWorkspaceModalKeyBinding } from "./start-workspace-modal-context"; export interface WorkspacesProps { } @@ -29,62 +29,18 @@ export default function () { const location = useLocation(); const { teams } = useContext(TeamsContext); - const team = getCurrentTeam(location, teams); - const match = useRouteMatch<{ team: string, resource: string }>("/(t/)?:team/:resource"); - const projectSlug = match?.params?.resource !== 'workspaces' ? match?.params?.resource : undefined; const [activeWorkspaces, setActiveWorkspaces] = useState([]); const [inactiveWorkspaces, setInactiveWorkspaces] = useState([]); - const [repos, setRepos] = useState([]); - const [isTemplateModelOpen, setIsTemplateModelOpen] = useState(false); const [workspaceModel, setWorkspaceModel] = useState(); + const { setIsStartWorkspaceModalVisible } = useContext(StartWorkspaceModalContext); useEffect(() => { - // only show example repos on the global user context - if (!team && !projectSlug) { - getGitpodService().server.getFeaturedRepositories().then(setRepos); - } (async () => { const workspaceModel = new WorkspaceModel(setActiveWorkspaces, setInactiveWorkspaces); setWorkspaceModel(workspaceModel); })(); }, [teams, location]); - const showStartWSModal = () => setIsTemplateModelOpen(true); - const hideStartWSModal = () => setIsTemplateModelOpen(false); - - const getRecentSuggestions: () => WsStartEntry[] = () => { - if (workspaceModel) { - const all = workspaceModel.getAllFetchedWorkspaces(); - if (all && all.size > 0) { - const index = new Map(); - for (const ws of Array.from(all.values())) { - const repoUrl = Workspace.getFullRepositoryUrl(ws.workspace); - if (repoUrl) { - const lastUse = WorkspaceInfo.lastActiveISODate(ws); - let entry = index.get(repoUrl); - if (!entry) { - entry = { - title: Workspace.getFullRepositoryName(ws.workspace) || repoUrl, - description: repoUrl, - startUrl: gitpodHostUrl.withContext(repoUrl).toString(), - lastUse, - }; - index.set(repoUrl, entry); - } else { - if (entry.lastUse.localeCompare(lastUse) < 0) { - entry.lastUse = lastUse; - } - } - } - } - const list = Array.from(index.values()); - list.sort((a, b) => b.lastUse.localeCompare(a.lastUse)); - return list; - } - } - return []; - } - return <>
@@ -113,7 +69,7 @@ export default function () { onClick: () => { if (workspaceModel) workspaceModel.limit = 200; } }]} />
- +
@@ -143,24 +99,13 @@ export default function () {

No Workspaces

Prefix any Git repository URL with {window.location.host}/# or create a new workspace for a recently used project. Learn more
- +
)} - ({ - title: r.name, - description: r.description || r.url, - startUrl: gitpodHostUrl.withContext(r.url).toString() - }))} - recent={workspaceModel && activeWorkspaces ? - getRecentSuggestions() - : []} /> ; } diff --git a/components/dashboard/src/workspaces/start-workspace-modal-context.tsx b/components/dashboard/src/workspaces/start-workspace-modal-context.tsx new file mode 100644 index 00000000000000..b1ce5493c6b5c1 --- /dev/null +++ b/components/dashboard/src/workspaces/start-workspace-modal-context.tsx @@ -0,0 +1,37 @@ +/** + * 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, { createContext, useEffect, useState } from 'react'; + +export const StartWorkspaceModalContext = createContext<{ + isStartWorkspaceModalVisible?: boolean, + setIsStartWorkspaceModalVisible: React.Dispatch, +}>({ + setIsStartWorkspaceModalVisible: () => null, +}); + +export const StartWorkspaceModalContextProvider: React.FC = ({ children }) => { + const [ isStartWorkspaceModalVisible, setIsStartWorkspaceModalVisible ] = useState(false); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'o') { + event.preventDefault(); + setIsStartWorkspaceModalVisible(true); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + } + }, []); + + return + {children} + ; +} + +export const StartWorkspaceModalKeyBinding = `${/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? '⌘' : 'Ctrl﹢'}O`; \ No newline at end of file diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index ae7491038e48c0..363bf56f8d796e 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -72,6 +72,7 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, getWorkspaceOwner(workspaceId: string): Promise; getWorkspaceUsers(workspaceId: string): Promise; getFeaturedRepositories(): Promise; + getSuggestedContextURLs(): Promise; /** * **Security:** * Sensitive information like an owner token is erased, since it allows access for all team members. diff --git a/components/server/ee/src/user/eligibility-service.ts b/components/server/ee/src/user/eligibility-service.ts index fca188d7e6a5ca..e9cc15dc8a02f1 100644 --- a/components/server/ee/src/user/eligibility-service.ts +++ b/components/server/ee/src/user/eligibility-service.ts @@ -93,6 +93,7 @@ export class EligibilityService { return { student: false, faculty: false }; } + const logCtx = { userId: user.id }; try { const rawResponse = await fetch("https://education.github.com/api/user", { headers: { @@ -100,15 +101,18 @@ export class EligibilityService { "faculty-check-preview": "true" } }); + if (!rawResponse.ok) { + log.warn(logCtx, `fetching the GitHub Education API failed with status ${rawResponse.status}: ${rawResponse.statusText}`); + } const result : GitHubEducationPack = JSON.parse(await rawResponse.text()); if(result.student && result.faculty) { // That violates the API contract: `student` and `faculty` need to be mutually exclusive - log.warn({userId: user.id}, "result of GitHub Eduction API violates the API contract: student and faculty need to be mutually exclusive", result); + log.warn(logCtx, "result of GitHub Eduction API violates the API contract: student and faculty need to be mutually exclusive", result); return { student: false, faculty: false }; } return result; } catch (err) { - log.warn({ userId: user.id }, "error while checking student pack status", err); + log.warn(logCtx, "error while checking student pack status", err); } return { student: false, faculty: false }; } diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index 60dce27003a58b..70f881e7591eda 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -60,6 +60,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { "getWorkspaceOwner": { group: "default", points: 1 }, "getWorkspaceUsers": { group: "default", points: 1 }, "getFeaturedRepositories": { group: "default", points: 1 }, + "getSuggestedContextURLs": { group: "default", points: 1 }, "getWorkspace": { group: "default", points: 1 }, "isWorkspaceOwner": { group: "default", points: 1 }, "getOwnerToken": { group: "default", points: 1 }, diff --git a/components/server/src/bitbucket/bitbucket-repository-provider.ts b/components/server/src/bitbucket/bitbucket-repository-provider.ts index 9c303fba8ce00c..e6a7ded2f974bc 100644 --- a/components/server/src/bitbucket/bitbucket-repository-provider.ts +++ b/components/server/src/bitbucket/bitbucket-repository-provider.ts @@ -98,4 +98,9 @@ export class BitbucketRepositoryProvider implements RepositoryProvider { authorAvatarUrl: commit.author?.user?.links?.avatar?.href, }; } + + async getUserRepos(user: User): Promise { + // FIXME(janx): Not implemented yet + return []; + } } diff --git a/components/server/src/github/github-repository-provider.ts b/components/server/src/github/github-repository-provider.ts index 6795fcd79f28bb..52707ed244bd0e 100644 --- a/components/server/src/github/github-repository-provider.ts +++ b/components/server/src/github/github-repository-provider.ts @@ -106,4 +106,26 @@ export class GithubRepositoryProvider implements RepositoryProvider { const commit = await this.github.getCommit(user, { repo, owner, ref }); return commit; } + + async getUserRepos(user: User): Promise { + // Hint: Use this to get richer results: + // node { + // nameWithOwner + // shortDescriptionHTML(limit: 120) + // url + // } + const result: any = await this.githubQueryApi.runQuery(user, ` + query { + viewer { + repositoriesContributedTo(includeUserRepositories: true, first: 100) { + edges { + node { + url + } + } + } + } + }`); + return (result.data.viewer?.repositoriesContributedTo?.edges || []).map((edge: any) => edge.node.url) + } } diff --git a/components/server/src/gitlab/gitlab-repository-provider.ts b/components/server/src/gitlab/gitlab-repository-provider.ts index 2c4d93a16d03f6..b534e0ca6c0e84 100644 --- a/components/server/src/gitlab/gitlab-repository-provider.ts +++ b/components/server/src/gitlab/gitlab-repository-provider.ts @@ -94,4 +94,9 @@ export class GitlabRepositoryProvider implements RepositoryProvider { authorAvatarUrl: "", }; } + + async getUserRepos(user: User): Promise { + // FIXME(janx): Not implemented yet + return []; + } } diff --git a/components/server/src/repohost/repository-provider.ts b/components/server/src/repohost/repository-provider.ts index 365171013842d7..f5fa0480ad5918 100644 --- a/components/server/src/repohost/repository-provider.ts +++ b/components/server/src/repohost/repository-provider.ts @@ -12,5 +12,6 @@ export interface RepositoryProvider { getRepo(user: User, owner: string, repo: string): Promise; getBranch(user: User, owner: string, repo: string, branch: string): Promise; getBranches(user: User, owner: string, repo: string): Promise; - getCommitInfo(user: User, owner: string, repo: string, ref: string): Promise + getCommitInfo(user: User, owner: string, repo: string, ref: string): Promise; + getUserRepos(user: User): Promise; } \ No newline at end of file diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index ba04dd7a651431..036f6c69f4faef 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -996,6 +996,77 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { ))).filter(e => e !== undefined) as WhitelistedRepository[]; } + public async getSuggestedContextURLs(ctx: TraceContext): Promise { + const user = this.checkUser("getSuggestedContextURLs"); + const suggestions: string[] = []; + const logCtx: LogContext = { userId: user.id }; + + // Fetch all data sources in parallel for maximum speed (don't await in this scope before `Promise.allSettled(promises)` below!) + const promises = []; + + // Example repositories + promises.push(this.getFeaturedRepositories(ctx).then(exampleRepos => { + exampleRepos.forEach(r => suggestions.push(r.url)); + }).catch(error => { + log.error(logCtx, 'Could not get example repositories', error); + })); + + // User repositories (from Apps) + promises.push(this.getAuthProviders(ctx).then(authProviders => Promise.all(authProviders.map(async (p) => { + try { + const userRepos = await this.getProviderRepositoriesForUser(ctx, { provider: p.host }); + userRepos.forEach(r => suggestions.push(r.cloneUrl.replace(/\.git$/, ''))); + } catch (error) { + log.debug(logCtx, 'Could not get user repositories from App for ' + p.host, error); + } + }))).catch(error => { + log.error(logCtx, 'Could not get auth providers', error); + })); + + // User repositories (from Git hosts directly) + promises.push(this.getAuthProviders(ctx).then(authProviders => Promise.all(authProviders.map(async (p) => { + try { + const hostContext = this.hostContextProvider.get(p.host); + const services = hostContext?.services; + if (!services) { + log.error(logCtx, 'Unsupported repository host: ' + p.host); + return; + } + const userRepos = await services.repositoryProvider.getUserRepos(user); + userRepos.forEach(r => suggestions.push(r.replace(/\.git$/, ''))); + } catch (error) { + log.debug(logCtx, 'Could not get user repositories from host ' + p.host, error); + } + }))).catch(error => { + log.error(logCtx, 'Could not get auth providers', error); + })); + + // Recent repositories + promises.push(this.getWorkspaces(ctx, { /* limit: 20 */ }).then(workspaces => { + workspaces.forEach(ws => { + const repoUrl = Workspace.getFullRepositoryUrl(ws.workspace); + if (repoUrl) { + suggestions.push(repoUrl); + } + }); + }).catch(error => { + log.error(logCtx, 'Could not fetch recent workspace repositories', error); + })); + + await Promise.allSettled(promises); + + const uniqueURLs = new Set(); + return suggestions + .sort((a, b) => a > b ? 1 : -1) + .filter(r => { + if (uniqueURLs.has(r)) { + return false; + } + uniqueURLs.add(r); + return true; + }); + } + public async setWorkspaceTimeout(ctx: TraceContext, workspaceId: string, duration: WorkspaceTimeoutDuration): Promise { throw new ResponseError(ErrorCodes.EE_FEATURE, `Custom workspace timeout is implemented in Gitpod's Enterprise Edition`); }