Skip to content

Commit 4beeac3

Browse files
authored
feat: add npm registry autocomplete to package manager (#959)
1 parent 193e6dc commit 4beeac3

File tree

10 files changed

+458
-95
lines changed

10 files changed

+458
-95
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@octokit/rest": "^16.43.1",
4444
"@sentry/electron": "^2.5.3",
4545
"@types/getos": "^3.0.1",
46+
"algoliasearch": "^4.12.0",
4647
"classnames": "^2.2.6",
4748
"commander": "^7.1.0",
4849
"electron-default-menu": "^1.0.2",
@@ -57,6 +58,7 @@
5758
"monaco-loader": "1.0.0",
5859
"namor": "^2.0.2",
5960
"node-watch": "^0.7.3",
61+
"p-debounce": "^2.0.0",
6062
"react": "^16.14.0",
6163
"react-dom": "^16.14.0",
6264
"react-mosaic-component": "^4.1.1",

src/less/components/mosaic.less

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,8 @@
3939
background-color: @background-4;
4040
}
4141

42-
.mosaic {
43-
height: 100vh;
44-
45-
&.mosaic-blueprint-theme {
46-
background: #bdbdbd15;
47-
}
42+
.mosaic.mosaic-blueprint-theme {
43+
background: #bdbdbd15;
4844
}
4945

5046
.editorContainer,

src/less/components/sidebar.less

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,33 @@
2020
.pointer {
2121
cursor: pointer;
2222
}
23+
24+
.package-manager-result {
25+
em {
26+
font-weight: bold;
27+
}
28+
}
29+
30+
.package-tree {
31+
.bp3-tree-node-content {
32+
.bp3-tree-node-secondary-label {
33+
min-width: 100px;
34+
}
35+
}
36+
37+
.bp3-tree-node-list {
38+
margin: 5px 0;
39+
}
40+
}
41+
42+
.package-tree-version-select {
43+
font-size: 10px;
44+
width: 60px;
45+
text-overflow: 'ellipsis';
46+
background: @background-1;
47+
color: @dark-gray1;
48+
49+
.bp3-dark & {
50+
color: @white;
51+
}
52+
}

src/renderer/components/output-editors-wrapper.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export class OutputEditorsWrapper extends React.Component<
4747
return (
4848
<Mosaic<WrapperEditorId>
4949
renderTile={(id: string) => this.MOSAIC_ELEMENTS[id]}
50-
resize={{ minimumPaneSizePercentage: 0 }}
50+
resize={{ minimumPaneSizePercentage: 15 }}
5151
value={this.state.mosaic}
5252
onChange={this.onChange}
5353
/>
Lines changed: 163 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,202 @@
11
import * as React from 'react';
2-
import { Button, InputGroup, Tree, TreeNodeInfo } from '@blueprintjs/core';
3-
import { AppState } from '../state';
2+
import { Button, MenuItem, Tree, TreeNodeInfo } from '@blueprintjs/core';
3+
import { Suggest } from '@blueprintjs/select';
4+
import { autorun } from 'mobx';
45
import { observer } from 'mobx-react';
6+
import pDebounce from 'p-debounce';
7+
import semver from 'semver';
58

9+
import { AppState } from '../state';
10+
import { npmSearch } from '../npm-search';
611
interface IState {
7-
search: string;
12+
suggestions: Array<AlgoliaHit>;
13+
versionsCache: Map<string, string[]>;
814
}
915

1016
interface IProps {
1117
appState: AppState;
1218
}
1319

20+
// See full schema: https://github.com/algolia/npm-search#schema
21+
interface AlgoliaHit {
22+
name: string;
23+
version: string;
24+
versions: Record<string, string>;
25+
_highlightResult: {
26+
name: {
27+
value: string;
28+
};
29+
};
30+
}
31+
1432
@observer
1533
export class SidebarPackageManager extends React.Component<IProps, IState> {
1634
constructor(props: IProps) {
1735
super(props);
1836
this.state = {
19-
search: '',
37+
suggestions: [],
38+
versionsCache: new Map(),
2039
};
2140
}
2241

23-
public addPackageToSearch = (event: React.KeyboardEvent) => {
24-
const { search } = this.state;
42+
public componentDidMount() {
43+
autorun(async () => {
44+
await this.refreshVersionsCache();
45+
this.coerceInvalidVersionNumbers();
46+
});
47+
}
48+
49+
public addModuleToFiddle = (item: AlgoliaHit) => {
2550
const { appState } = this.props;
26-
if (event.key === 'Enter' && !!search) {
27-
// TODO: allow users to specify the package version
28-
appState.modules.set(search, '*');
29-
this.setState({ search: '' });
30-
}
51+
appState.modules.set(item.name, item.version);
52+
// copy state so react can re-render
53+
this.state.versionsCache.set(item.name, Object.keys(item.versions));
54+
const newCache = new Map(this.state.versionsCache);
55+
this.setState({ suggestions: [], versionsCache: newCache });
3156
};
3257

3358
public render() {
3459
return (
35-
<Tree
36-
contents={[
37-
{
38-
childNodes: [
39-
{
40-
id: '-1',
41-
label: (
42-
<InputGroup
43-
leftIcon="search"
44-
placeholder="Add package"
45-
fill
46-
onChange={(event) =>
47-
this.setState({ search: event.target.value })
48-
}
49-
onKeyPress={this.addPackageToSearch}
50-
value={this.state.search}
51-
/>
52-
),
53-
},
54-
...this.renderModules(),
55-
],
56-
id: 'modules',
57-
hasCaret: false,
58-
icon: 'code-block',
59-
label: 'Modules',
60-
isExpanded: true,
61-
},
62-
]}
63-
/>
60+
<div className="package-tree">
61+
<h5>Modules</h5>
62+
<Suggest
63+
fill={true}
64+
inputValueRenderer={() => ''}
65+
items={this.state.suggestions}
66+
itemRenderer={(item, { modifiers, handleClick }) => (
67+
<MenuItem
68+
active={modifiers.active}
69+
key={item.name}
70+
text={
71+
<span
72+
className="package-manager-result"
73+
dangerouslySetInnerHTML={{
74+
__html: item._highlightResult.name.value,
75+
}}
76+
/>
77+
}
78+
onClick={handleClick}
79+
/>
80+
)}
81+
noResults={<em>Search for modules here...</em>}
82+
onItemSelect={this.addModuleToFiddle}
83+
onQueryChange={pDebounce(async (query) => {
84+
if (query !== '') {
85+
const { hits } = await npmSearch.search(query);
86+
this.setState({
87+
suggestions: hits,
88+
});
89+
} else {
90+
this.setState({
91+
suggestions: [],
92+
});
93+
}
94+
}, 200)}
95+
popoverProps={{ minimal: true, usePortal: false, fill: true }}
96+
resetOnClose={false}
97+
resetOnSelect={true}
98+
/>
99+
<Tree contents={this.getModuleNodes()} />
100+
</div>
64101
);
65102
}
66103

67-
public renderModules = (): TreeNodeInfo[] => {
104+
/**
105+
* Takes the module map and returns an object
106+
* conforming to the BlueprintJS tree schema.
107+
* @returns TreeNodeInfo[]
108+
*/
109+
private getModuleNodes = (): TreeNodeInfo[] => {
68110
const values: TreeNodeInfo[] = [];
69111
const { appState } = this.props;
70-
for (const pkg of appState.modules.keys()) {
112+
for (const [pkg, activeVersion] of appState.modules.entries()) {
71113
values.push({
72114
id: pkg,
73115
label: pkg,
74116
secondaryLabel: (
75-
<Button
76-
minimal
77-
icon="remove"
78-
onClick={() => appState.modules.delete(pkg)}
79-
/>
117+
<div>
118+
<select
119+
className="package-tree-version-select"
120+
name={pkg}
121+
value={activeVersion}
122+
onChange={({ target }) =>
123+
appState.modules.set(target.name, target.value)
124+
}
125+
>
126+
{this.state.versionsCache.get(pkg)?.map((version) => (
127+
<option key={version}>{version}</option>
128+
))}
129+
</select>
130+
131+
<Button
132+
minimal
133+
icon="remove"
134+
onClick={() => appState.modules.delete(pkg)}
135+
/>
136+
</div>
80137
),
81138
});
82139
}
83140

84141
return values;
85142
};
143+
144+
/**
145+
* Attempt to fetch the list of all versions for
146+
* all installed modules. We need this list of versions
147+
* for the version selector.
148+
*/
149+
private refreshVersionsCache = async () => {
150+
const { modules } = this.props.appState;
151+
152+
for (const pkg of modules.keys()) {
153+
if (!this.state.versionsCache.has(pkg)) {
154+
const { hits } = await npmSearch.search(pkg);
155+
const firstMatch = hits[0];
156+
if (firstMatch === undefined || !firstMatch.versions) {
157+
console.warn(
158+
`Attempted to fetch version list for ${pkg} from Algolia but failed!`,
159+
);
160+
} else {
161+
this.state.versionsCache.set(
162+
firstMatch.name,
163+
Object.keys(firstMatch.versions),
164+
);
165+
// React won't re-render when we're adding
166+
// elements to an ES6 Map element. Trigger
167+
// the re-render manually.
168+
this.forceUpdate();
169+
}
170+
}
171+
}
172+
};
173+
174+
/**
175+
* Coerces any invalid semver versions to the latest
176+
* version detected in the versionsCache. This is particularly
177+
* useful for loading gists created with older versions of
178+
* Fiddle that have wildcard (*) versions in their package.json
179+
*
180+
* This function should only be run after the versions cache
181+
* is updated so we have an updated list of all deps and their
182+
* versions.
183+
*/
184+
private coerceInvalidVersionNumbers = () => {
185+
const { modules } = this.props.appState;
186+
187+
for (const [pkg, version] of modules.entries()) {
188+
if (!semver.valid(version)) {
189+
const packageVersions = this.state.versionsCache.get(pkg);
190+
191+
if (Array.isArray(packageVersions) && packageVersions.length > 0) {
192+
const latestVersion = packageVersions[packageVersions.length - 1];
193+
modules.set(pkg, latestVersion);
194+
} else {
195+
console.warn(
196+
`Attempted to coerce latest version for package '${pkg}' but failed.`,
197+
);
198+
}
199+
}
200+
}
201+
};
86202
}

src/renderer/npm-search.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { SearchResponse } from '@algolia/client-search';
2+
import algoliasearch, { SearchIndex } from 'algoliasearch/lite';
3+
4+
// See full schema: https://github.com/algolia/npm-search#schema
5+
interface AlgoliaHit {
6+
name: string;
7+
version: string;
8+
versions: Record<string, string>;
9+
_highlightResult: {
10+
name: {
11+
value: string;
12+
};
13+
};
14+
}
15+
16+
class NPMSearch {
17+
private index: SearchIndex;
18+
private searchCache: Map<string, SearchResponse<AlgoliaHit>>;
19+
constructor() {
20+
const client = algoliasearch(
21+
'OFCNCOG2CU',
22+
'4efa2042cf4dba11be6e96e5c394e1a4',
23+
);
24+
this.index = client.initIndex('npm-search');
25+
this.searchCache = new Map();
26+
}
27+
28+
/**
29+
* Finds a list of packages Algolia's npm search index.
30+
* Naively caches all queries client-side.
31+
* @param query Search query
32+
*/
33+
async search(query: string) {
34+
if (this.searchCache.has(query)) {
35+
return this.searchCache.get(query)!;
36+
} else {
37+
const result = await this.index.search<AlgoliaHit>(query, {
38+
hitsPerPage: 5,
39+
});
40+
this.searchCache.set(query, result);
41+
return result;
42+
}
43+
}
44+
}
45+
46+
const npmSearch = new NPMSearch();
47+
export { npmSearch };

src/renderer/runner.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,9 @@ export class Runner {
278278
* @memberof Runner
279279
*/
280280
public async installModules(pmOptions: PMOperationOptions): Promise<void> {
281-
const modules = Array.from(this.appState.modules.keys());
281+
const modules = Array.from(this.appState.modules.entries()).map(
282+
([pkg, version]) => `${pkg}@${version}`,
283+
);
282284
const { pushOutput } = this.appState;
283285

284286
if (modules && modules.length > 0) {

0 commit comments

Comments
 (0)