From f0f06ff7438155570a80536cc7186217252ddbdb Mon Sep 17 00:00:00 2001 From: Jan Keromnes Date: Thu, 6 Jan 2022 08:49:07 +0000 Subject: [PATCH 1/7] [server][dashboard] Improve 'New Workspace' modal with a search input, keyboard navigation, and a new context URL suggestion API --- components/dashboard/src/App.tsx | 9 + components/dashboard/src/Menu.tsx | 2 +- components/dashboard/src/components/Modal.tsx | 2 +- .../src/components/RepositoryFinder.tsx | 161 ++++++++++++++++++ components/dashboard/src/start/Open.tsx | 34 ++++ .../src/workspaces/StartWorkspaceModal.tsx | 61 +------ .../dashboard/src/workspaces/Workspaces.tsx | 63 +------ .../gitpod-protocol/src/gitpod-service.ts | 1 + components/server/src/auth/rate-limiter.ts | 1 + .../src/workspace/gitpod-server-impl.ts | 53 ++++++ 10 files changed, 273 insertions(+), 114 deletions(-) create mode 100644 components/dashboard/src/components/RepositoryFinder.tsx create mode 100644 components/dashboard/src/start/Open.tsx diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index e90c6ac4196f10..8fcc8f3157f6b0 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -25,6 +25,7 @@ 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'; const Setup = React.lazy(() => import(/* webpackPrefetch: true */ './Setup')); const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ './workspaces/Workspaces')); @@ -35,6 +36,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 +218,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 +283,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} /> +
+
+ {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} results not shown} +
+ ; +} + +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) { + 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.includes(query))); +} 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..4f3d22a497c6a5 100644 --- a/components/dashboard/src/workspaces/StartWorkspaceModal.tsx +++ b/components/dashboard/src/workspaces/StartWorkspaceModal.tsx @@ -4,70 +4,21 @@ * See License-AGPL.txt in the project root for license information. */ -import { useEffect, useState } from "react"; import Modal from "../components/Modal"; -import TabMenuItem from "../components/TabMenuItem"; - -export interface WsStartEntry { - title: string - description: string - startUrl: string -} +import RepositoryFinder from "../components/RepositoryFinder"; interface StartWorkspaceModalProps { visible: boolean; - recent: WsStartEntry[]; - examples: WsStartEntry[]; - selected?: Mode; onClose: () => void; } -type Mode = 'Recent' | 'Examples'; - -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 +export function StartWorkspaceModal(props: StartWorkspaceModalProps) { + return false} visible={props.visible}>

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.

-
) - } -
+
+

Search or paste a repository URL

+
; } diff --git a/components/dashboard/src/workspaces/Workspaces.tsx b/components/dashboard/src/workspaces/Workspaces.tsx index 0b8236893257fb..d8f1b8c5752012 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 { StartWorkspaceModal } from "./StartWorkspaceModal"; 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"; export interface WorkspacesProps { } @@ -29,20 +29,12 @@ 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(); 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); @@ -52,39 +44,6 @@ export default function () { 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 <>
@@ -150,17 +109,7 @@ export default function () {
)} - ({ - title: r.name, - description: r.description || r.url, - startUrl: gitpodHostUrl.withContext(r.url).toString() - }))} - recent={workspaceModel && activeWorkspaces ? - getRecentSuggestions() - : []} /> + ; } 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/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/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index ba04dd7a651431..53ba90667906e3 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -996,6 +996,59 @@ 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[] = []; + + // Fetch all data sources in parallel for maximum speed (don't await before `Promise.allSettled(promises)` below!) + const promises = []; + + // Example repositories + promises.push(this.getFeaturedRepositories(ctx).then(exampleRepos => { + // log('got example repos', exampleRepos); + exampleRepos.forEach(r => suggestions.push(r.url)); + })); + + // User repositories + user.identities.forEach(identity => { + const provider = { + 'Public-GitLab': 'gitlab.com', + 'Public-GitHub': 'github.com', + 'Public-Bitbucket': 'bitbucket.org', + }[identity.authProviderId]; + if (!provider) { + return; + } + promises.push(this.getProviderRepositoriesForUser(ctx, { provider }).then(userRepos => { + // log('got', provider, 'user repos', userRepos) + userRepos.forEach(r => suggestions.push(r.cloneUrl.replace(/\.git$/, ''))); + })); + }); + + // 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); + } + }); + })); + + 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`); } From 075ea6ef951f1787f48141ba6e1caca8ebc32f29 Mon Sep 17 00:00:00 2001 From: Jan Keromnes Date: Fri, 21 Jan 2022 15:08:48 +0000 Subject: [PATCH 2/7] [dashboard] Open 'New Workspace' Modal on {ctrl,cmd} + {k,o,p} --- components/dashboard/src/workspaces/Workspaces.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/components/dashboard/src/workspaces/Workspaces.tsx b/components/dashboard/src/workspaces/Workspaces.tsx index d8f1b8c5752012..80a4f33343e63d 100644 --- a/components/dashboard/src/workspaces/Workspaces.tsx +++ b/components/dashboard/src/workspaces/Workspaces.tsx @@ -44,6 +44,19 @@ export default function () { const showStartWSModal = () => setIsTemplateModelOpen(true); const hideStartWSModal = () => setIsTemplateModelOpen(false); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && ['k', 'o', 'p'].includes(event.key)) { + event.preventDefault(); + showStartWSModal(); + } + }; + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('keydown', onKeyDown); + } + }, []); + return <>
From 7e95890507f5cbb1c7145ed954254fb424b9ef6e Mon Sep 17 00:00:00 2001 From: Jan Keromnes Date: Tue, 25 Jan 2022 16:52:20 +0000 Subject: [PATCH 3/7] [dashboard] Enable the 'New Workspace' modal on all dashboard pages (Cmd/Ctrl+O) --- components/dashboard/src/App.tsx | 2 + components/dashboard/src/index.tsx | 9 +++-- .../src/workspaces/StartWorkspaceModal.tsx | 12 +++--- .../dashboard/src/workspaces/Workspaces.tsx | 25 ++----------- .../start-workspace-modal-context.tsx | 37 +++++++++++++++++++ 5 files changed, 54 insertions(+), 31 deletions(-) create mode 100644 components/dashboard/src/workspaces/start-workspace-modal-context.tsx diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index 8fcc8f3157f6b0..7c0cf3aa5857d8 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -26,6 +26,7 @@ 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')); @@ -392,6 +393,7 @@ function App() { }}> +
; 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/workspaces/StartWorkspaceModal.tsx b/components/dashboard/src/workspaces/StartWorkspaceModal.tsx index 4f3d22a497c6a5..42273875308e66 100644 --- a/components/dashboard/src/workspaces/StartWorkspaceModal.tsx +++ b/components/dashboard/src/workspaces/StartWorkspaceModal.tsx @@ -4,18 +4,16 @@ * See License-AGPL.txt in the project root for license information. */ +import { useContext } from "react"; import Modal from "../components/Modal"; import RepositoryFinder from "../components/RepositoryFinder"; +import { StartWorkspaceModalContext } from "./start-workspace-modal-context"; -interface StartWorkspaceModalProps { - visible: boolean; - onClose: () => void; -} +export function StartWorkspaceModal() { + const { isStartWorkspaceModalVisible, setIsStartWorkspaceModalVisible } = useContext(StartWorkspaceModalContext); -export function StartWorkspaceModal(props: StartWorkspaceModalProps) { - return false} visible={props.visible}> + return setIsStartWorkspaceModalVisible(false)} onEnter={() => false} visible={!!isStartWorkspaceModalVisible}>

New Workspace

- {/* separator */}

Search or paste a repository URL

diff --git a/components/dashboard/src/workspaces/Workspaces.tsx b/components/dashboard/src/workspaces/Workspaces.tsx index 80a4f33343e63d..35b06db107b4de 100644 --- a/components/dashboard/src/workspaces/Workspaces.tsx +++ b/components/dashboard/src/workspaces/Workspaces.tsx @@ -11,10 +11,10 @@ import DropDown from "../components/DropDown"; import { WorkspaceModel } from "./workspace-model"; import { WorkspaceEntry } from "./WorkspaceEntry"; import { getGitpodService } from "../service/service"; -import { StartWorkspaceModal } from "./StartWorkspaceModal"; import { ItemsList } from "../components/ItemsList"; import { TeamsContext } from "../teams/teams-context"; import { useLocation } from "react-router"; +import { StartWorkspaceModalContext, StartWorkspaceModalKeyBinding } from "./start-workspace-modal-context"; export interface WorkspacesProps { } @@ -31,8 +31,8 @@ export default function () { const { teams } = useContext(TeamsContext); const [activeWorkspaces, setActiveWorkspaces] = useState([]); const [inactiveWorkspaces, setInactiveWorkspaces] = useState([]); - const [isTemplateModelOpen, setIsTemplateModelOpen] = useState(false); const [workspaceModel, setWorkspaceModel] = useState(); + const { setIsStartWorkspaceModalVisible } = useContext(StartWorkspaceModalContext); useEffect(() => { (async () => { @@ -41,22 +41,6 @@ export default function () { })(); }, [teams, location]); - const showStartWSModal = () => setIsTemplateModelOpen(true); - const hideStartWSModal = () => setIsTemplateModelOpen(false); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && ['k', 'o', 'p'].includes(event.key)) { - event.preventDefault(); - showStartWSModal(); - } - }; - window.addEventListener('keydown', onKeyDown); - return () => { - window.removeEventListener('keydown', onKeyDown); - } - }, []); - return <>
@@ -85,7 +69,7 @@ export default function () { onClick: () => { if (workspaceModel) workspaceModel.limit = 200; } }]} />
- +
@@ -115,14 +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
- +
)} - ; } 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..8c0a6f4be195a4 --- /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 From 5b1a984f98a6ffc0912a770237a324d09e7da6b7 Mon Sep 17 00:00:00 2001 From: Jan Keromnes Date: Thu, 3 Feb 2022 16:04:38 +0000 Subject: [PATCH 4/7] [server] Log response status when GitHub Education API calls fail --- components/server/ee/src/user/eligibility-service.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 }; } From d18033fff24cb254cddffdf001776405c55b9a1e Mon Sep 17 00:00:00 2001 From: Jan Keromnes Date: Fri, 28 Jan 2022 14:34:23 +0000 Subject: [PATCH 5/7] [server] Get repository suggestions from all auth providers --- .../src/workspace/gitpod-server-impl.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 53ba90667906e3..f0338515ec1ac9 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -997,33 +997,33 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { } public async getSuggestedContextURLs(ctx: TraceContext): Promise { - const user = this.checkUser("getSuggestedContextURLs"); + this.checkUser("getSuggestedContextURLs"); const suggestions: string[] = []; + const logCtx: LogContext = { userId: user.id }; - // Fetch all data sources in parallel for maximum speed (don't await before `Promise.allSettled(promises)` below!) + // 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 => { - // log('got example repos', exampleRepos); exampleRepos.forEach(r => suggestions.push(r.url)); + }).catch(error => { + log.error(logCtx, 'Could not get example repositories', error); })); // User repositories - user.identities.forEach(identity => { - const provider = { - 'Public-GitLab': 'gitlab.com', - 'Public-GitHub': 'github.com', - 'Public-Bitbucket': 'bitbucket.org', - }[identity.authProviderId]; - if (!provider) { - return; - } - promises.push(this.getProviderRepositoriesForUser(ctx, { provider }).then(userRepos => { - // log('got', provider, 'user repos', userRepos) + promises.push(this.getAuthProviders(ctx).then(authProviders => Promise.all(authProviders.map(async (p) => { + // TODO(janx): Refactor this in order not to limit results to app installations & not fetch projects. + // This should be entirely about proposing great matches for a user, no matter an app is installed. + 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); + })); // Recent repositories promises.push(this.getWorkspaces(ctx, { /* limit: 20 */ }).then(workspaces => { @@ -1033,6 +1033,8 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { suggestions.push(repoUrl); } }); + }).catch(error => { + log.error(logCtx, 'Could not fetch recent workspace repositories', error); })); await Promise.allSettled(promises); From fc2328a436a6cce34ec9ef08e2d96a7aa5f5fefc Mon Sep 17 00:00:00 2001 From: Jan Keromnes Date: Wed, 2 Feb 2022 16:57:15 +0000 Subject: [PATCH 6/7] [server] Implement a RepositoryProvider.getUserRepos() method --- .../bitbucket-repository-provider.ts | 5 ++++ .../src/github/github-repository-provider.ts | 22 +++++++++++++++++ .../src/gitlab/gitlab-repository-provider.ts | 5 ++++ .../src/repohost/repository-provider.ts | 3 ++- .../src/workspace/gitpod-server-impl.ts | 24 +++++++++++++++---- 5 files changed, 54 insertions(+), 5 deletions(-) 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 f0338515ec1ac9..036f6c69f4faef 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -997,7 +997,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { } public async getSuggestedContextURLs(ctx: TraceContext): Promise { - this.checkUser("getSuggestedContextURLs"); + const user = this.checkUser("getSuggestedContextURLs"); const suggestions: string[] = []; const logCtx: LogContext = { userId: user.id }; @@ -1011,10 +1011,8 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { log.error(logCtx, 'Could not get example repositories', error); })); - // User repositories + // User repositories (from Apps) promises.push(this.getAuthProviders(ctx).then(authProviders => Promise.all(authProviders.map(async (p) => { - // TODO(janx): Refactor this in order not to limit results to app installations & not fetch projects. - // This should be entirely about proposing great matches for a user, no matter an app is installed. try { const userRepos = await this.getProviderRepositoriesForUser(ctx, { provider: p.host }); userRepos.forEach(r => suggestions.push(r.cloneUrl.replace(/\.git$/, ''))); @@ -1025,6 +1023,24 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { 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 => { From 0eac52fcec17ac804a37763a709a2a85c88fa65b Mon Sep 17 00:00:00 2001 From: Jan Keromnes Date: Mon, 31 Jan 2022 11:23:48 +0000 Subject: [PATCH 7/7] [dashboard] Update 'Open in Gitpod' modal design to spec --- .../src/components/RepositoryFinder.tsx | 22 +++++++++++++++---- .../src/workspaces/StartWorkspaceModal.tsx | 12 +++++++--- .../dashboard/src/workspaces/Workspaces.tsx | 4 ++-- .../start-workspace-modal-context.tsx | 2 +- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/components/dashboard/src/components/RepositoryFinder.tsx b/components/dashboard/src/components/RepositoryFinder.tsx index 28ef892988831d..352c9560dc678b 100644 --- a/components/dashboard/src/components/RepositoryFinder.tsx +++ b/components/dashboard/src/components/RepositoryFinder.tsx @@ -6,6 +6,7 @@ import { User } from "@gitpod/gitpod-protocol"; import React, { useContext, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; import { getGitpodService } from "../service/service"; import { UserContext } from "../user-context"; @@ -80,9 +81,18 @@ export default function RepositoryFinder(props: { initialQuery?: string }) {
- search(e.target.value)} onKeyDown={onKeyDown} /> + 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 @@ -94,7 +104,7 @@ export default function RepositoryFinder(props: { initialQuery?: string }) { )} {searchResults.length > MAX_DISPLAYED_ITEMS && - {searchResults.length - MAX_DISPLAYED_ITEMS} results not shown} + {searchResults.length - MAX_DISPLAYED_ITEMS} more result{(searchResults.length - MAX_DISPLAYED_ITEMS) === 1 ? '' : 's'} found}
; } @@ -147,6 +157,10 @@ async function actuallyRefreshSearchData(query: string, user: User | undefined): } 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. @@ -157,5 +171,5 @@ async function findResults(query: string, onResults: (results: string[]) => void } catch { } // console.log('searching', query, 'in', searchData); - onResults(searchData.filter(result => result.includes(query))); + onResults(searchData.filter(result => result.toLowerCase().includes(query.toLowerCase()))); } diff --git a/components/dashboard/src/workspaces/StartWorkspaceModal.tsx b/components/dashboard/src/workspaces/StartWorkspaceModal.tsx index 42273875308e66..fb353d903933d3 100644 --- a/components/dashboard/src/workspaces/StartWorkspaceModal.tsx +++ b/components/dashboard/src/workspaces/StartWorkspaceModal.tsx @@ -4,18 +4,24 @@ * See License-AGPL.txt in the project root for license information. */ -import { useContext } from "react"; +import { useContext, useEffect } from "react"; +import { useLocation } from "react-router"; import Modal from "../components/Modal"; import RepositoryFinder from "../components/RepositoryFinder"; import { StartWorkspaceModalContext } from "./start-workspace-modal-context"; export function StartWorkspaceModal() { const { isStartWorkspaceModalVisible, setIsStartWorkspaceModalVisible } = useContext(StartWorkspaceModalContext); + const location = useLocation(); + + // Close the modal on navigation events. + useEffect(() => { + setIsStartWorkspaceModalVisible(false); + }, [location]); return setIsStartWorkspaceModalVisible(false)} onEnter={() => false} visible={!!isStartWorkspaceModalVisible}> -

New Workspace

+

Open in Gitpod

-

Search or paste a repository URL

; diff --git a/components/dashboard/src/workspaces/Workspaces.tsx b/components/dashboard/src/workspaces/Workspaces.tsx index 35b06db107b4de..6156f3248ad585 100644 --- a/components/dashboard/src/workspaces/Workspaces.tsx +++ b/components/dashboard/src/workspaces/Workspaces.tsx @@ -69,7 +69,7 @@ export default function () { onClick: () => { if (workspaceModel) workspaceModel.limit = 200; } }]} />
- +
@@ -99,7 +99,7 @@ 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
- + diff --git a/components/dashboard/src/workspaces/start-workspace-modal-context.tsx b/components/dashboard/src/workspaces/start-workspace-modal-context.tsx index 8c0a6f4be195a4..b1ce5493c6b5c1 100644 --- a/components/dashboard/src/workspaces/start-workspace-modal-context.tsx +++ b/components/dashboard/src/workspaces/start-workspace-modal-context.tsx @@ -34,4 +34,4 @@ export const StartWorkspaceModalContextProvider: React.FC = ({ children }) => { ; } -export const StartWorkspaceModalKeyBinding = `${/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? '⌘' : 'Ctrl+'}O`; \ No newline at end of file +export const StartWorkspaceModalKeyBinding = `${/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform) ? '⌘' : 'Ctrl﹢'}O`; \ No newline at end of file