Skip to content

Commit 07942df

Browse files
authored
Experimental search completion (#7979)
* Experimental search completion * Simplify a few things * Cleanup trigger logic * Updated golden files * Updated size test * Fix tests * Fix review comments
1 parent 67a8f90 commit 07942df

24 files changed

+1138
-66
lines changed

app/lib/frontend/handlers/custom_api.dart

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
// BSD-style license that can be found in the LICENSE file.
44

55
import 'dart:async';
6+
import 'dart:convert';
67
import 'dart:io';
78

89
import 'package:_pub_shared/data/package_api.dart';
910
import 'package:_pub_shared/search/search_form.dart';
1011
import 'package:gcloud/storage.dart';
12+
import 'package:pub_dev/frontend/templates/views/shared/search_banner.dart';
1113
import 'package:shelf/shelf.dart' as shelf;
1214

1315
import '../../frontend/request_context.dart';
@@ -281,6 +283,170 @@ Future<shelf.Response> apiTopicNameCompletionDataHandler(
281283
});
282284
}
283285

286+
/// Handles requests for
287+
/// - /api/search-input-completion-data
288+
Future<shelf.Response> apiSearchInputCompletionDataHandler(
289+
shelf.Request request,
290+
) async {
291+
// only accept requests which allow JSON response
292+
if (!request.acceptsJsonContent()) {
293+
throw NotAcceptableException(
294+
'Client must send "Accept: application/json" header.',
295+
);
296+
}
297+
298+
final bytes = await cache.searchInputCompletionDataJsonGz().get(() async {
299+
final topicsJson = await storageService
300+
.bucket(activeConfiguration.reportsBucketName!)
301+
.readAsBytes(topicsJsonFileName);
302+
final topicsMap = json.decode(utf8.decode(topicsJson));
303+
final topics = (topicsMap as Map<String, Object?>).keys.toList();
304+
return gzip.encode(utf8.encode(completionDataJson(
305+
topics: topics,
306+
licenses: _commonLicenses,
307+
)));
308+
});
309+
310+
if (!request.acceptsGzipEncoding()) {
311+
// TODO: Consider caching non-compressed response
312+
// Note: We must handle non-gzipped response, as we can't set the
313+
// Content-Encoding header in the browser.
314+
// Though, all browsers will allow gzip :D
315+
return shelf.Response(200, body: gzip.decode(bytes!), headers: {
316+
...jsonResponseHeaders,
317+
...CacheControl.completionData.headers
318+
});
319+
}
320+
321+
return shelf.Response(200, body: bytes, headers: {
322+
...jsonResponseHeaders,
323+
'Content-Encoding': 'gzip',
324+
...CacheControl.completionData.headers
325+
});
326+
}
327+
328+
/// A hardcoded list of common licenses.
329+
///
330+
/// TODO: Extract a list of all licenses used from analyzed data.
331+
const _commonLicenses = [
332+
'0bsd',
333+
'aal',
334+
'afl-1.1',
335+
'afl-1.2',
336+
'afl-2.0',
337+
'afl-2.1',
338+
'afl-3.0',
339+
'agpl-3.0',
340+
'apl-1.0',
341+
'apsl-1.0',
342+
'apsl-1.1',
343+
'apsl-1.2',
344+
'apsl-2.0',
345+
'apache-1.1',
346+
'apache-2.0',
347+
'artistic-1.0',
348+
'artistic-1.0-perl',
349+
'artistic-1.0-cl8',
350+
'artistic-2.0',
351+
'bsd-1-clause',
352+
'bsd-2-clause',
353+
'bsd-2-clause-patent',
354+
'bsd-3-clause',
355+
'bsd-3-clause-lbnl',
356+
'bsl-1.0',
357+
'cal-1.0-combined-work-exception',
358+
'catosl-1.1',
359+
'cddl-1.0',
360+
'cecill-2.1',
361+
'cern-ohl-p-2.0',
362+
'cern-ohl-s-2.0',
363+
'cern-ohl-w-2.0',
364+
'cnri-python',
365+
'cpal-1.0',
366+
'cpl-1.0',
367+
'cua-opl-1.0',
368+
'ecl-1.0',
369+
'ecl-2.0',
370+
'efl-1.0',
371+
'efl-2.0',
372+
'epl-1.0',
373+
'epl-2.0',
374+
'eudatagrid',
375+
'eupl-1.1',
376+
'eupl-1.2',
377+
'entessa',
378+
'fair',
379+
'frameworx-1.0',
380+
'gpl-2.0',
381+
'gpl-3.0',
382+
'hpnd',
383+
'ipa',
384+
'ipl-1.0',
385+
'isc',
386+
'intel',
387+
'lgpl-2.0',
388+
'lgpl-2.1',
389+
'lgpl-3.0',
390+
'lpl-1.0',
391+
'lpl-1.02',
392+
'lppl-1.3c',
393+
'liliq-p-1.1',
394+
'liliq-r-1.1',
395+
'liliq-rplus-1.1',
396+
'mit',
397+
'mit-0',
398+
'mit-modern-variant',
399+
'mpl-1.0',
400+
'mpl-1.1',
401+
'mpl-2.0',
402+
'ms-pl',
403+
'ms-rl',
404+
'miros',
405+
'motosoto',
406+
'mulanpsl-2.0',
407+
'multics',
408+
'nasa-1.3',
409+
'ncsa',
410+
'ngpl',
411+
'nposl-3.0',
412+
'ntp',
413+
'naumen',
414+
'nokia',
415+
'oclc-2.0',
416+
'ofl-1.1',
417+
'ogtsl',
418+
'oldap-2.8',
419+
'oset-pl-2.1',
420+
'osl-1.0',
421+
'osl-2.0',
422+
'osl-2.1',
423+
'osl-3.0',
424+
'php-3.0',
425+
'php-3.01',
426+
'postgresql',
427+
'python-2.0',
428+
'qpl-1.0',
429+
'rpl-1.1',
430+
'rpl-1.5',
431+
'rpsl-1.0',
432+
'rscpl',
433+
'sissl',
434+
'spl-1.0',
435+
'simpl-2.0',
436+
'sleepycat',
437+
'ucl-1.0',
438+
'upl-1.0',
439+
'unicode-dfs-2016',
440+
'unlicense',
441+
'vsl-1.0',
442+
'w3c',
443+
'watcom-1.0',
444+
'xnet',
445+
'zpl-2.0',
446+
'zpl-2.1',
447+
'zlib',
448+
];
449+
284450
/// Handles requests for /api/search
285451
Future<shelf.Response> apiSearchHandler(shelf.Request request) async {
286452
final searchForm = SearchForm.parse(request.requestedUri.queryParameters);

app/lib/frontend/handlers/experimental.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import '../../shared/cookie_utils.dart';
88

99
const _publicFlags = <String>{
1010
'dark',
11+
'search-completion',
1112
};
1213

1314
const _allFlags = <String>{
@@ -83,6 +84,8 @@ class ExperimentalFlags {
8384
return params;
8485
}
8586

87+
bool get isSearchCompletionEnabled => isEnabled('search-completion');
88+
8689
bool get isDarkModeEnabled => isEnabled('dark');
8790
bool get isDarkModeDefault => isEnabled('dark-as-default');
8891

app/lib/frontend/handlers/pubapi.client.dart

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/lib/frontend/handlers/pubapi.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,11 @@ class PubApi {
384384
return apiTopicNameCompletionDataHandler(request);
385385
}
386386

387+
@EndPoint.get('/api/search-input-completion-data')
388+
Future<Response> searchInputCompletionData(Request request) async {
389+
return apiSearchInputCompletionDataHandler(request);
390+
}
391+
387392
@EndPoint.put('/api/packages/<package>/options')
388393
Future<PkgOptions> setPackageOptions(
389394
Request request, String package, PkgOptions body) =>

app/lib/frontend/handlers/pubapi.g.dart

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/lib/frontend/templates/views/shared/search_banner.dart

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
// for details. All rights reserved. Use of this source code is governed by a
33
// BSD-style license that can be found in the LICENSE file.
44

5+
import 'dart:convert';
6+
7+
import 'package:pub_dev/frontend/request_context.dart';
8+
59
import '../../../dom/dom.dart' as d;
610
import '../../../static_files.dart' show staticUrls;
711

@@ -16,17 +20,25 @@ d.Node searchBannerNode({
1620
}) {
1721
return d.form(
1822
classes: ['search-bar', 'banner-item'],
23+
attributes: {
24+
'autocomplete': 'off',
25+
},
1926
action: formUrl,
2027
children: [
2128
d.input(
2229
classes: ['input'],
30+
type: 'search',
2331
name: 'q',
2432
placeholder: placeholder,
25-
autocomplete: 'on',
33+
autocomplete: 'off',
2634
autofocus: autofocus,
2735
value: queryText,
2836
attributes: {
2937
'title': 'Search',
38+
if (requestContext.experimentalFlags.isSearchCompletionEnabled)
39+
'data-widget': 'completion',
40+
'data-completion-src': '/api/search-input-completion-data',
41+
'data-completion-class': 'search-completion',
3042
},
3143
),
3244
d.span(classes: ['icon']),
@@ -64,3 +76,109 @@ d.Node searchBannerNode({
6476
],
6577
);
6678
}
79+
80+
/// Create completion data for search input.
81+
///
82+
/// Format is dictacted by `pkg/web_app/lib/src/completion.dart`.
83+
///
84+
/// If values in `match` is a prefix of what is being typed completion
85+
/// will automatically start. It'll always try to use the longest match.
86+
/// If not specified options available are assumed to be `terminal`, that is
87+
/// they will be followed by whitespace.
88+
/// If `forcedOnly` is specified, completion can only be initiated with
89+
/// Ctrl+Space.
90+
///
91+
/// This can generate completion data of different sizes given [topics] and
92+
/// [licenses].
93+
String completionDataJson({
94+
List<String> topics = const [],
95+
List<String> licenses = const [],
96+
}) =>
97+
json.encode({
98+
// TODO: Write a shared type for this in `pkg/_pub_shared/lib/data/`
99+
'completions': [
100+
{
101+
'match': ['', '-'],
102+
'terminal': false,
103+
'forcedOnly': true,
104+
'options': [
105+
'has:',
106+
'is:',
107+
'license:',
108+
'platform:',
109+
'sdk:',
110+
'show:',
111+
'topic:',
112+
'runtime:',
113+
'dependency:',
114+
'dependency*:',
115+
'publisher:',
116+
],
117+
},
118+
// TODO: Consider completion support for dependency:, dependency*: and publisher:
119+
{
120+
'match': ['is:', '-is:'],
121+
'options': [
122+
'dart3-compatible',
123+
'flutter-favorite',
124+
'legacy',
125+
'null-safe',
126+
'plugin',
127+
'unlisted',
128+
'wasm-ready',
129+
],
130+
},
131+
{
132+
'match': ['has:', '-has:'],
133+
'options': [
134+
'executable',
135+
'screenshot',
136+
],
137+
},
138+
{
139+
'match': ['license:', '-license:'],
140+
'options': [
141+
'osi-approved',
142+
...licenses,
143+
],
144+
},
145+
{
146+
'match': ['show:', '-show:'],
147+
'options': [
148+
'unlisted',
149+
],
150+
},
151+
{
152+
'match': ['sdk:', '-sdk:'],
153+
'options': [
154+
'dart',
155+
'flutter',
156+
],
157+
},
158+
{
159+
'match': ['platform:', '-platform:'],
160+
'options': [
161+
'android',
162+
'ios',
163+
'linux',
164+
'macos',
165+
'web',
166+
'windows',
167+
],
168+
},
169+
{
170+
'match': ['runtime:', '-runtime:'],
171+
'options': [
172+
'native-aot',
173+
'native-jit',
174+
'web',
175+
],
176+
},
177+
{
178+
'match': ['topic:', '-topic:'],
179+
'options': [
180+
...topics,
181+
],
182+
},
183+
],
184+
});

0 commit comments

Comments
 (0)