Skip to content

Commit 282795a

Browse files
committed
[server][dashboard] Improve 'New Workspace' modal with a search input, keyboard navigation, and a new context URL suggestions API
1 parent a150551 commit 282795a

File tree

6 files changed

+233
-1
lines changed

6 files changed

+233
-1
lines changed

components/dashboard/src/App.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { trackButtonOrAnchor, trackPathChange, trackLocation } from './Analytics
2121
import { User } from '@gitpod/gitpod-protocol';
2222
import * as GitpodCookie from '@gitpod/gitpod-protocol/lib/util/gitpod-cookie';
2323
import { Experiment } from './experiments';
24+
import { refreshSearchData } from './start/Open';
2425

2526
const Setup = React.lazy(() => import(/* webpackPrefetch: true */ './Setup'));
2627
const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ './workspaces/Workspaces'));
@@ -31,6 +32,7 @@ const Teams = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Te
3132
const EnvironmentVariables = React.lazy(() => import(/* webpackPrefetch: true */ './settings/EnvironmentVariables'));
3233
const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Integrations'));
3334
const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Preferences'));
35+
const Open = React.lazy(() => import(/* webpackPrefetch: true */ './start/Open'));
3436
const StartWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/StartWorkspace'));
3537
const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/CreateWorkspace'));
3638
const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/NewTeam'));
@@ -204,6 +206,12 @@ function App() {
204206
return () => window.removeEventListener("click", handleButtonOrAnchorTracking, true);
205207
}, []);
206208

209+
useEffect(() => {
210+
if (user) {
211+
refreshSearchData('', user);
212+
}
213+
}, [user]);
214+
207215
// redirect to website for any website slugs
208216
if (isGitpodIo() && isWebsiteSlug(window.location.pathname)) {
209217
window.location.host = 'www.gitpod.io';
@@ -263,6 +271,7 @@ function App() {
263271
<Menu />
264272
<Switch>
265273
<Route path="/new" exact component={NewProject} />
274+
<Route path="/open" exact component={Open} />
266275
<Route path="/setup" exact component={Setup} />
267276
<Route path="/workspaces" exact component={Workspaces} />
268277
<Route path="/account" exact component={Account} />

components/dashboard/src/Menu.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export default function Menu() {
6969
}
7070

7171
// Hide most of the top menu when in a full-page form.
72-
const isMinimalUI = ['/new', '/teams/new'].includes(location.pathname);
72+
const isMinimalUI = ['/new', '/teams/new', '/open'].includes(location.pathname);
7373

7474
const [ teamMembers, setTeamMembers ] = useState<Record<string, TeamMemberInfo[]>>({});
7575
useEffect(() => {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License-AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { User } from "@gitpod/gitpod-protocol";
8+
import React, { useContext, useEffect, useState } from "react";
9+
import { getGitpodService } from "../service/service";
10+
import { UserContext } from "../user-context";
11+
12+
type SearchResult = string;
13+
type SearchData = SearchResult[];
14+
15+
const LOCAL_STORAGE_KEY = 'open-in-gitpod-search-data';
16+
const MAX_DISPLAYED_ITEMS = 20;
17+
18+
export default function Open() {
19+
const { user } = useContext(UserContext);
20+
const [ searchQuery, setSearchQuery ] = useState<string>('');
21+
const [ searchResults, setSearchResults ] = useState<SearchResult[]>([]);
22+
const [ selectedSearchResult, setSelectedSearchResult ] = useState<SearchResult | undefined>();
23+
24+
const onResults = (results: SearchResult[]) => {
25+
if (JSON.stringify(results) !== JSON.stringify(searchResults)) {
26+
setSearchResults(results);
27+
setSelectedSearchResult(results[0]);
28+
}
29+
}
30+
31+
const search = async (query: string) => {
32+
setSearchQuery(query);
33+
await findResults(query, onResults);
34+
if (await refreshSearchData(query, user)) {
35+
// Re-run search if the underlying search data has changed
36+
await findResults(query, onResults);
37+
}
38+
}
39+
40+
// Support pre-filling the search bar via the URL hash
41+
useEffect(() => {
42+
const onHashChange = () => {
43+
const hash = window.location.hash.slice(1);
44+
if (hash) {
45+
search(hash);
46+
}
47+
}
48+
onHashChange();
49+
window.addEventListener('hashchange', onHashChange, false);
50+
return () => {
51+
window.removeEventListener('hashchange', onHashChange, false);
52+
}
53+
}, []);
54+
55+
// Up/Down keyboard navigation between results
56+
const onKeyDown = (event: React.KeyboardEvent) => {
57+
if (!selectedSearchResult) {
58+
return;
59+
}
60+
const selectedIndex = searchResults.indexOf(selectedSearchResult);
61+
const select = (index: number) => {
62+
// Implement a true modulus in order to "wrap around" (e.g. `select(-1)` should select the last result)
63+
// Source: https://stackoverflow.com/a/4467559/3461173
64+
const n = Math.min(searchResults.length, MAX_DISPLAYED_ITEMS);
65+
setSelectedSearchResult(searchResults[((index % n) + n) % n]);
66+
}
67+
if (event.key === 'ArrowDown') {
68+
event.preventDefault();
69+
select(selectedIndex + 1);
70+
return;
71+
}
72+
if (event.key === 'ArrowUp') {
73+
event.preventDefault();
74+
select(selectedIndex - 1);
75+
return;
76+
}
77+
}
78+
79+
const onSubmit = (event: React.FormEvent) => {
80+
event.preventDefault();
81+
if (selectedSearchResult) {
82+
window.location.href = '/#' + selectedSearchResult;
83+
}
84+
}
85+
86+
return <form className="mt-24 mx-auto w-96 flex flex-col items-stretch" onSubmit={onSubmit}>
87+
<h1 className="text-center">Open in Gitpod</h1>
88+
<div className="mt-8 flex px-4 rounded-xl border border-gray-300 dark:border-gray-500">
89+
<div className="py-4">
90+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16" width="16" height="16"><path fill="#A8A29E" d="M6 2a4 4 0 100 8 4 4 0 000-8zM0 6a6 6 0 1110.89 3.477l4.817 4.816a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 010 6z"/></svg>
91+
</div>
92+
<input type="search" className="flex-grow" placeholder="Repository" autoFocus value={searchQuery} onChange={e => search(e.target.value)} onKeyDown={onKeyDown} />
93+
</div>
94+
<div className="rounded-xl bg-gray-50 dark:bg-gray-800 flex flex-col" id="search-results">
95+
{searchResults.slice(0, MAX_DISPLAYED_ITEMS).map((result, index) =>
96+
<a className={`px-4 py-2 rounded-xl` + (result === selectedSearchResult ? ' bg-gray-100 dark:bg-gray-700' : '')} href={`/#${result}`} key={`search-result-${index}`}>
97+
{result.split(searchQuery).map((segment, index) => <span>
98+
{index === 0 ? <></> : <strong>{searchQuery}</strong>}
99+
{segment}
100+
</span>)}
101+
</a>
102+
)}
103+
{searchResults.length > MAX_DISPLAYED_ITEMS &&
104+
<span className="px-4 py-2 italic text-sm">{searchResults.length - MAX_DISPLAYED_ITEMS} results not shown</span>}
105+
</div>
106+
</form>;
107+
}
108+
109+
function loadSearchData(): SearchData {
110+
const string = localStorage.getItem(LOCAL_STORAGE_KEY);
111+
if (!string) {
112+
return [];
113+
}
114+
try {
115+
const data = JSON.parse(string);
116+
return data;
117+
} catch(error) {
118+
console.warn('Could not load search data from local storage', error);
119+
return [];
120+
}
121+
}
122+
123+
function saveSearchData(searchData: SearchData): void {
124+
try {
125+
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(searchData));
126+
} catch(error) {
127+
console.warn('Could not save search data into local storage', error);
128+
}
129+
}
130+
131+
let refreshSearchDataPromise: Promise<boolean> | undefined;
132+
export async function refreshSearchData(query: string, user: User | undefined): Promise<boolean> {
133+
if (refreshSearchDataPromise) {
134+
// Another refresh is already in progress, no need to run another one in parallel.
135+
return refreshSearchDataPromise;
136+
}
137+
refreshSearchDataPromise = actuallyRefreshSearchData(query, user);
138+
const didChange = await refreshSearchDataPromise;
139+
refreshSearchDataPromise = undefined;
140+
return didChange;
141+
}
142+
143+
// Fetch all possible search results and cache them into local storage
144+
async function actuallyRefreshSearchData(query: string, user: User | undefined): Promise<boolean> {
145+
console.log('refreshing search data');
146+
const oldData = loadSearchData();
147+
const newData = await getGitpodService().server.getSuggestedContextURLs();
148+
if (JSON.stringify(oldData) !== JSON.stringify(newData)) {
149+
console.log('new data:', newData);
150+
saveSearchData(newData);
151+
return true;
152+
}
153+
return false;
154+
}
155+
156+
async function findResults(query: string, onResults: (results: string[]) => void) {
157+
const searchData = loadSearchData();
158+
try {
159+
// If the query is a URL, and it's not present in the proposed results, "artificially" add it here.
160+
new URL(query);
161+
if (!searchData.includes(query)) {
162+
searchData.push(query);
163+
}
164+
} catch {
165+
}
166+
// console.log('searching', query, 'in', searchData);
167+
onResults(searchData.filter(result => result.includes(query)));
168+
}

components/gitpod-protocol/src/gitpod-service.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
7171
getWorkspaceOwner(workspaceId: string): Promise<UserInfo | undefined>;
7272
getWorkspaceUsers(workspaceId: string): Promise<WorkspaceInstanceUser[]>;
7373
getFeaturedRepositories(): Promise<WhitelistedRepository[]>;
74+
getSuggestedContextURLs(): Promise<string[]>;
7475
getWorkspace(id: string): Promise<WorkspaceInfo>;
7576
isWorkspaceOwner(workspaceId: string): Promise<boolean>;
7677

components/server/src/auth/rate-limiter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig {
5555
"getWorkspaceOwner": { group: "default", points: 1 },
5656
"getWorkspaceUsers": { group: "default", points: 1 },
5757
"getFeaturedRepositories": { group: "default", points: 1 },
58+
"getSuggestedContextURLs": { group: "default", points: 1 },
5859
"getWorkspace": { group: "default", points: 1 },
5960
"isWorkspaceOwner": { group: "default", points: 1 },
6061
"createWorkspace": { group: "default", points: 1 },

components/server/src/workspace/gitpod-server-impl.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -961,6 +961,59 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
961961
))).filter(e => e !== undefined) as WhitelistedRepository[];
962962
}
963963

964+
public async getSuggestedContextURLs(ctx: TraceContext): Promise<string[]> {
965+
const user = this.checkUser("getSuggestedContextURLs");
966+
const suggestions: string[] = [];
967+
968+
// Fetch all data sources in parallel for maximum speed (don't await before `Promise.allSettled(promises)` below!)
969+
const promises = [];
970+
971+
// Example repositories
972+
promises.push(this.getFeaturedRepositories(ctx).then(exampleRepos => {
973+
// log('got example repos', exampleRepos);
974+
exampleRepos.forEach(r => suggestions.push(r.url));
975+
}));
976+
977+
// User repositories
978+
user.identities.forEach(identity => {
979+
const provider = {
980+
'Public-GitLab': 'gitlab.com',
981+
'Public-GitHub': 'github.com',
982+
'Public-Bitbucket': 'bitbucket.org',
983+
}[identity.authProviderId];
984+
if (!provider) {
985+
return;
986+
}
987+
promises.push(this.getProviderRepositoriesForUser(ctx, { provider }).then(userRepos => {
988+
// log('got', provider, 'user repos', userRepos)
989+
userRepos.forEach(r => suggestions.push(r.cloneUrl.replace(/\.git$/, '')));
990+
}));
991+
});
992+
993+
// Recent repositories
994+
promises.push(this.getWorkspaces(ctx, { /* limit: 20 */ }).then(workspaces => {
995+
workspaces.forEach(ws => {
996+
const repoUrl = Workspace.getFullRepositoryUrl(ws.workspace);
997+
if (repoUrl) {
998+
suggestions.push(repoUrl);
999+
}
1000+
});
1001+
}));
1002+
1003+
await Promise.allSettled(promises);
1004+
1005+
const uniqueURLs = new Set();
1006+
return suggestions
1007+
.sort((a, b) => a > b ? 1 : -1)
1008+
.filter(r => {
1009+
if (uniqueURLs.has(r)) {
1010+
return false;
1011+
}
1012+
uniqueURLs.add(r);
1013+
return true;
1014+
});
1015+
}
1016+
9641017
public async setWorkspaceTimeout(ctx: TraceContext, workspaceId: string, duration: WorkspaceTimeoutDuration): Promise<SetWorkspaceTimeoutResult> {
9651018
throw new ResponseError(ErrorCodes.EE_FEATURE, `Custom workspace timeout is implemented in Gitpod's Enterprise Edition`);
9661019
}

0 commit comments

Comments
 (0)