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 && (
@@ -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`);
}