Skip to content

Commit f45eef3

Browse files
committed
[server][dashboard] Improve 'New Workspace' modal with a search input, keyboard navigation, and a new context URL suggestion API
1 parent 8c3e9a0 commit f45eef3

File tree

9 files changed

+263
-113
lines changed

9 files changed

+263
-113
lines changed

components/dashboard/src/App.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { Experiment } from './experiments';
2424
import { workspacesPathMain } from './workspaces/workspaces.routes';
2525
import { settingsPathAccount, settingsPathIntegrations, settingsPathMain, settingsPathNotifications, settingsPathPlans, settingsPathPreferences, settingsPathTeams, settingsPathTeamsJoin, settingsPathTeamsNew, settingsPathVariables } from './settings/settings.routes';
2626
import { projectsPathInstallGitHubApp, projectsPathMain, projectsPathMainWithParams, projectsPathNew } from './projects/projects.routes';
27+
import { refreshSearchData } from './components/RepositoryFinder';
2728

2829
const Setup = React.lazy(() => import(/* webpackPrefetch: true */ './Setup'));
2930
const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ './workspaces/Workspaces'));
@@ -34,6 +35,7 @@ const Teams = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Te
3435
const EnvironmentVariables = React.lazy(() => import(/* webpackPrefetch: true */ './settings/EnvironmentVariables'));
3536
const Integrations = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Integrations'));
3637
const Preferences = React.lazy(() => import(/* webpackPrefetch: true */ './settings/Preferences'));
38+
const Open = React.lazy(() => import(/* webpackPrefetch: true */ './start/Open'));
3739
const StartWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/StartWorkspace'));
3840
const CreateWorkspace = React.lazy(() => import(/* webpackPrefetch: true */ './start/CreateWorkspace'));
3941
const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/NewTeam'));
@@ -207,6 +209,12 @@ function App() {
207209
return () => window.removeEventListener("click", handleButtonOrAnchorTracking, true);
208210
}, []);
209211

212+
useEffect(() => {
213+
if (user) {
214+
refreshSearchData('', user);
215+
}
216+
}, [user]);
217+
210218
// redirect to website for any website slugs
211219
if (isGitpodIo() && isWebsiteSlug(window.location.pathname)) {
212220
window.location.host = 'www.gitpod.io';
@@ -266,6 +274,7 @@ function App() {
266274
<Menu />
267275
<Switch>
268276
<Route path={projectsPathNew} exact component={NewProject} />
277+
<Route path="/open" exact component={Open} />
269278
<Route path="/setup" exact component={Setup} />
270279
<Route path={workspacesPathMain} exact component={Workspaces} />
271280
<Route path={settingsPathAccount} exact component={Account} />

components/dashboard/src/Menu.tsx

+1-1
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(() => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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, 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 RepositoryFinder(props: { initialQuery?: string }) {
19+
const { user } = useContext(UserContext);
20+
const [searchQuery, setSearchQuery] = useState<string>(props.initialQuery || '');
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+
// Up/Down keyboard navigation between results
41+
const onKeyDown = (event: React.KeyboardEvent) => {
42+
if (!selectedSearchResult) {
43+
return;
44+
}
45+
const selectedIndex = searchResults.indexOf(selectedSearchResult);
46+
const select = (index: number) => {
47+
// Implement a true modulus in order to "wrap around" (e.g. `select(-1)` should select the last result)
48+
// Source: https://stackoverflow.com/a/4467559/3461173
49+
const n = Math.min(searchResults.length, MAX_DISPLAYED_ITEMS);
50+
setSelectedSearchResult(searchResults[((index % n) + n) % n]);
51+
}
52+
if (event.key === 'ArrowDown') {
53+
event.preventDefault();
54+
select(selectedIndex + 1);
55+
return;
56+
}
57+
if (event.key === 'ArrowUp') {
58+
event.preventDefault();
59+
select(selectedIndex - 1);
60+
return;
61+
}
62+
}
63+
64+
const onSubmit = (event: React.FormEvent) => {
65+
event.preventDefault();
66+
if (selectedSearchResult) {
67+
window.location.href = '/#' + selectedSearchResult;
68+
}
69+
}
70+
71+
return <form onSubmit={onSubmit}>
72+
<div className="flex px-4 rounded-xl border border-gray-300 dark:border-gray-500">
73+
<div className="py-4">
74+
<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>
75+
</div>
76+
<input type="search" className="flex-grow" placeholder="Repository" autoFocus value={searchQuery} onChange={e => search(e.target.value)} onKeyDown={onKeyDown} />
77+
</div>
78+
<div className="rounded-xl bg-gray-50 dark:bg-gray-800 flex flex-col" id="search-results">
79+
{searchResults.slice(0, MAX_DISPLAYED_ITEMS).map((result, index) =>
80+
<a className={`px-4 py-2 rounded-xl` + (result === selectedSearchResult ? ' bg-gray-100 dark:bg-gray-700' : '')} href={`/#${result}`} key={`search-result-${index}`}>
81+
{result.split(searchQuery).map((segment, index) => <span>
82+
{index === 0 ? <></> : <strong>{searchQuery}</strong>}
83+
{segment}
84+
</span>)}
85+
</a>
86+
)}
87+
{searchResults.length > MAX_DISPLAYED_ITEMS &&
88+
<span className="px-4 py-2 italic text-sm">{searchResults.length - MAX_DISPLAYED_ITEMS} results not shown</span>}
89+
</div>
90+
</form>;
91+
}
92+
93+
function loadSearchData(): SearchData {
94+
const string = localStorage.getItem(LOCAL_STORAGE_KEY);
95+
if (!string) {
96+
return [];
97+
}
98+
try {
99+
const data = JSON.parse(string);
100+
return data;
101+
} catch (error) {
102+
console.warn('Could not load search data from local storage', error);
103+
return [];
104+
}
105+
}
106+
107+
function saveSearchData(searchData: SearchData): void {
108+
try {
109+
window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(searchData));
110+
} catch (error) {
111+
console.warn('Could not save search data into local storage', error);
112+
}
113+
}
114+
115+
let refreshSearchDataPromise: Promise<boolean> | undefined;
116+
export async function refreshSearchData(query: string, user: User | undefined): Promise<boolean> {
117+
if (refreshSearchDataPromise) {
118+
// Another refresh is already in progress, no need to run another one in parallel.
119+
return refreshSearchDataPromise;
120+
}
121+
refreshSearchDataPromise = actuallyRefreshSearchData(query, user);
122+
const didChange = await refreshSearchDataPromise;
123+
refreshSearchDataPromise = undefined;
124+
return didChange;
125+
}
126+
127+
// Fetch all possible search results and cache them into local storage
128+
async function actuallyRefreshSearchData(query: string, user: User | undefined): Promise<boolean> {
129+
console.log('refreshing search data');
130+
const oldData = loadSearchData();
131+
const newData = await getGitpodService().server.getSuggestedContextURLs();
132+
if (JSON.stringify(oldData) !== JSON.stringify(newData)) {
133+
console.log('new data:', newData);
134+
saveSearchData(newData);
135+
return true;
136+
}
137+
return false;
138+
}
139+
140+
async function findResults(query: string, onResults: (results: string[]) => void) {
141+
const searchData = loadSearchData();
142+
try {
143+
// If the query is a URL, and it's not present in the proposed results, "artificially" add it here.
144+
new URL(query);
145+
if (!searchData.includes(query)) {
146+
searchData.push(query);
147+
}
148+
} catch {
149+
}
150+
// console.log('searching', query, 'in', searchData);
151+
onResults(searchData.filter(result => result.includes(query)));
152+
}
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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 { useEffect, useState } from "react";
8+
import RepositoryFinder from "../components/RepositoryFinder";
9+
10+
export default function Open() {
11+
const [ initialQuery, setInitialQuery ] = useState<string | undefined>();
12+
13+
// Support pre-filling the search bar via the URL hash
14+
useEffect(() => {
15+
const onHashChange = () => {
16+
const hash = window.location.hash.slice(1);
17+
if (hash) {
18+
setInitialQuery(hash);
19+
}
20+
}
21+
onHashChange();
22+
window.addEventListener('hashchange', onHashChange, false);
23+
return () => {
24+
window.removeEventListener('hashchange', onHashChange, false);
25+
}
26+
}, []);
27+
28+
return <div className="mt-24 mx-auto w-96 flex flex-col items-stretch">
29+
<h1 className="text-center">Open in Gitpod</h1>
30+
<div className="mt-8">
31+
<RepositoryFinder initialQuery={initialQuery}/>
32+
</div>
33+
</div>;
34+
}

components/dashboard/src/workspaces/StartWorkspaceModal.tsx

+6-55
Original file line numberDiff line numberDiff line change
@@ -4,70 +4,21 @@
44
* See License-AGPL.txt in the project root for license information.
55
*/
66

7-
import { useEffect, useState } from "react";
87
import Modal from "../components/Modal";
9-
import TabMenuItem from "../components/TabMenuItem";
10-
11-
export interface WsStartEntry {
12-
title: string
13-
description: string
14-
startUrl: string
15-
}
8+
import RepositoryFinder from "../components/RepositoryFinder";
169

1710
interface StartWorkspaceModalProps {
1811
visible: boolean;
19-
recent: WsStartEntry[];
20-
examples: WsStartEntry[];
21-
selected?: Mode;
2212
onClose: () => void;
2313
}
2414

25-
type Mode = 'Recent' | 'Examples';
26-
27-
export function StartWorkspaceModal(p: StartWorkspaceModalProps) {
28-
const computeSelection = () => p.selected || (p.recent.length > 0 ? 'Recent' : 'Examples');
29-
const [selection, setSelection] = useState(computeSelection());
30-
useEffect(() => { !p.visible && setSelection(computeSelection()) }, [p.visible, p.recent, p.selected]);
31-
32-
const list = (selection === 'Recent' ? p.recent : p.examples).map((e, i) =>
33-
<a key={`item-${i}-${e.title}`} href={e.startUrl} className="rounded-xl group hover:bg-gray-100 dark:hover:bg-gray-800 flex p-4 my-1">
34-
<div className="w-full">
35-
<p className="text-base text-gray-800 dark:text-gray-200 font-semibold">{e.title}</p>
36-
<p>{e.description}</p>
37-
</div>
38-
</a>);
39-
40-
return <Modal onClose={p.onClose} visible={p.visible}>
15+
export function StartWorkspaceModal(props: StartWorkspaceModalProps) {
16+
return <Modal onClose={props.onClose} visible={props.visible}>
4117
<h3 className="pb-2">New Workspace</h3>
4218
{/* separator */}
43-
<div className="border-t border-gray-200 dark:border-gray-800 mt-2 -mx-6 px-6 pt-2">
44-
<div className="flex">
45-
<TabMenuItem name='Recent' selected={selection === 'Recent'} onClick={() => setSelection('Recent')} />
46-
{p.examples.length>0 && <TabMenuItem name='Examples' selected={selection === 'Examples'} onClick={() => setSelection('Examples')} />}
47-
</div>
48-
</div>
49-
<div className="border-t border-gray-200 dark:border-gray-800 -mx-6 px-6 py-2">
50-
{list.length > 0 ?
51-
<p className="my-4 text-base">
52-
{selection === 'Recent' ?
53-
'Create a new workspace using the default branch.' :
54-
'Create a new workspace using an example project.'}
55-
</p> : <p className="h-6 my-4"></p>}
56-
<div className="space-y-2 mt-4 overflow-y-scroll h-80 pr-2">
57-
{list.length > 0 ? list :
58-
(selection === 'Recent' ?
59-
<div className="flex flex-col pt-10 items-center px-2">
60-
<h3 className="mb-2 text-gray-500 dark:text-gray-400">No Recent Projects</h3>
61-
<p className="text-center">Projects you use frequently will show up here.</p>
62-
<p className="text-center">Prefix a Git repository URL with {window.location.host}/# or start with an example.</p>
63-
<button onClick={() => setSelection('Examples')} className="font-medium mt-8">Select Example</button>
64-
</div> :
65-
<div className="flex flex-col pt-10 items-center px-2">
66-
<h3 className="mb-2 text-gray-500 dark:text-gray-400">No Example Projects</h3>
67-
<p className="text-center">Sorry there seem to be no example projects, that work with your current Git provider.</p>
68-
</div>)
69-
}
70-
</div>
19+
<div className="border-t border-gray-200 dark:border-gray-800 mt-2 -mx-6 px-6 pt-4 h-96 overflow-scroll">
20+
<h4 className="text-base">Search or paste a repository URL</h4>
21+
<RepositoryFinder />
7122
</div>
7223
</Modal>;
7324
}

0 commit comments

Comments
 (0)