roxxie-proxy-u-prod 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/assets/{index-CttvagWK.js → index-MRhaNu_Q.js} +195 -135
- package/embed.js +1 -1
- package/examples/single-page.html +2 -2
- package/package.json +42 -43
- package/runtime/index.html +3 -3
- package/scripts/verify.mjs +2 -2
- package/source/packages/adrift-protocol/package.json +2 -2
- package/source/packages/adrift-protocol/src/index.ts +11 -0
- package/source/packages/chrome/src/Tab/History.test.ts +137 -0
- package/source/packages/chrome/src/Tab/History.ts +8 -3
- package/source/packages/chrome/src/Tab/Tab.tsx +13 -2
- package/source/packages/chrome/src/components/Omnibar/Omnibox.tsx +28 -38
- package/source/packages/chrome/src/components/Omnibar/autocomplete.test.ts +51 -0
- package/source/packages/chrome/src/components/Omnibar/autocomplete.ts +30 -0
- package/source/packages/chrome/src/pages/AdvancedSettings.tsx +225 -0
- package/source/packages/chrome/src/pages/SettingsPage.tsx +11 -0
- package/source/packages/chrome/src/pages/nodes.test.ts +94 -0
- package/source/packages/chrome/src/pages/nodes.ts +108 -0
- package/source/packages/chrome/src/proxy/Controller.ts +12 -0
- package/source/packages/chrome/src/proxy/ProxyFrame.ts +19 -2
- package/source/packages/chrome/src/proxy/cache.test.ts +52 -0
- package/source/packages/chrome/src/proxy/cache.ts +55 -11
- package/source/packages/chrome/src/proxy/navigation.test.ts +91 -0
- package/source/packages/chrome/src/proxy/navigation.ts +97 -0
- package/source/packages/chrome/src/proxy/scramjet-config.test.ts +19 -0
- package/source/packages/chrome/src/proxy/scramjet-config.ts +26 -0
- package/source/packages/chrome/src/proxy/scramjet.ts +5 -18
- package/source/packages/chrome/src/proxy/user-agent.test.ts +78 -0
- package/source/packages/chrome/src/services/SettingsService.ts +8 -1
- package/source/packages/roxxie-transport/package.json +1 -1
- package/source/packages/roxxie-transport/src/connection.ts +18 -1
- package/source/packages/roxxie-transport/src/transport.ts +51 -7
- package/source/packages/scramjet/packages/core/src/fetch/fetch.ts +7 -0
- package/source/packages/scramjet/packages/core/src/fetch/headers.ts +8 -0
- package/source/packages/scramjet/packages/core/src/fetch/user-agent.ts +51 -0
- package/source/packages/scramjet/packages/core/src/shared/rewriters/js.ts +1 -2
- package/source/packages/tracker-protocol/package.json +2 -2
- package/source/packages/tracker-protocol/src/index.ts +22 -0
- package/source/pnpm-lock.yaml +2 -2
- package/standalone.html +2 -2
package/embed.js
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
<div id="roxxie-browser"></div>
|
|
19
19
|
<script
|
|
20
20
|
defer
|
|
21
|
-
src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.
|
|
22
|
-
integrity="sha384-
|
|
21
|
+
src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.4/embed.js"
|
|
22
|
+
integrity="sha384-o6wwoyCvQbj3evNh2USZSUAOc625OxxX2yUnWs7o2i4NmKGhII4Y9SxvCG03OjAk"
|
|
23
23
|
crossorigin="anonymous"
|
|
24
24
|
data-roxxie-target="#roxxie-browser"
|
|
25
25
|
></script>
|
package/package.json
CHANGED
|
@@ -1,44 +1,43 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
2
|
+
"name": "roxxie-proxy-u-prod",
|
|
3
|
+
"version": "0.2.4",
|
|
4
|
+
"description": "Production CDN assets and embed loader for Roxxie Proxy U",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"private": false,
|
|
7
|
+
"main": "embed.js",
|
|
8
|
+
"browser": "embed.js",
|
|
9
|
+
"jsdelivr": "embed.js",
|
|
10
|
+
"unpkg": "embed.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"runtime",
|
|
14
|
+
"examples",
|
|
15
|
+
"scripts",
|
|
16
|
+
"source",
|
|
17
|
+
"third_party",
|
|
18
|
+
"embed.js",
|
|
19
|
+
"standalone.html",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"SOURCE.md",
|
|
22
|
+
"THIRD_PARTY_NOTICES.md",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"playwright": "1.59.1"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"roxxie",
|
|
33
|
+
"browser",
|
|
34
|
+
"proxy",
|
|
35
|
+
"webrtc",
|
|
36
|
+
"embed"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "node scripts/build.mjs",
|
|
40
|
+
"smoke": "node scripts/smoke.mjs",
|
|
41
|
+
"verify": "node scripts/verify.mjs"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/runtime/index.html
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
|
-
<link rel="icon" type="image/png" sizes="any" href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.
|
|
5
|
+
<link rel="icon" type="image/png" sizes="any" href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.4/dist/favicon.png" />
|
|
6
6
|
<meta name="theme-color" content="#222230" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
8
|
<title>Roxxie Proxy U</title>
|
|
9
9
|
|
|
10
10
|
<!--ssr-head-->
|
|
11
|
-
<script type="module" crossorigin src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.
|
|
12
|
-
<link rel="stylesheet" crossorigin href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.
|
|
11
|
+
<script type="module" crossorigin src="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.4/dist/assets/index-MRhaNu_Q.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="https://cdn.jsdelivr.net/npm/roxxie-proxy-u-prod@0.2.4/dist/assets/index-EeRhLAof.css">
|
|
13
13
|
</head>
|
|
14
14
|
|
|
15
15
|
<body>
|
package/scripts/verify.mjs
CHANGED
|
@@ -118,8 +118,8 @@ if (
|
|
|
118
118
|
throw new Error("Start screen must remain flat and glow-free");
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
if (manifest.version !== "0.2.
|
|
122
|
-
throw new Error("This production release must be version 0.2.
|
|
121
|
+
if (manifest.version !== "0.2.4") {
|
|
122
|
+
throw new Error("This production release must be version 0.2.4");
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
const embedBytes = await readFile(join(packageRoot, "embed.js"));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roxxie-transports-adrift-protocol",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.1",
|
|
4
4
|
"description": "Browser-safe wire types and codecs for Roxxie Proxy U transports",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"test": "npm run build && node --test dist/test/*.test.js"
|
|
19
19
|
},
|
|
20
20
|
"engines": {
|
|
21
|
-
"node": ">=
|
|
21
|
+
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"publishConfig": {
|
|
24
24
|
"access": "public"
|
|
@@ -47,6 +47,17 @@ export const S2CRequestTypes = {
|
|
|
47
47
|
WSDataEnd: 8,
|
|
48
48
|
WSError: 9,
|
|
49
49
|
Pong: 10,
|
|
50
|
+
/**
|
|
51
|
+
* Terminates a response whose body was cut short after the headers were
|
|
52
|
+
* already delivered. `HTTPResponseEnd` means "the body is complete", so it
|
|
53
|
+
* must never be used for a truncated stream: the client cannot tell a
|
|
54
|
+
* partial body from a whole one and will happily parse and cache it.
|
|
55
|
+
*
|
|
56
|
+
* Purely additive, so `PROTOCOL_VERSION` stays at 6.0. A node that sends
|
|
57
|
+
* this to an older client makes that client fail the session loudly, which
|
|
58
|
+
* is strictly better than the silent corruption it replaces.
|
|
59
|
+
*/
|
|
60
|
+
HTTPResponseError: 11,
|
|
50
61
|
} as const;
|
|
51
62
|
|
|
52
63
|
export type ObjectValue<T> = T[keyof T];
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// History records visited pages into the global profile; stub it so the model
|
|
4
|
+
// can be exercised on its own.
|
|
5
|
+
vi.mock("..", () => ({
|
|
6
|
+
profileService: { globalhistory: [] as unknown[] },
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import { History } from "./History";
|
|
10
|
+
|
|
11
|
+
/** Only the surface History touches on its owning tab. */
|
|
12
|
+
function fakeTab() {
|
|
13
|
+
return {
|
|
14
|
+
url: null as URL | null,
|
|
15
|
+
title: null as string | null,
|
|
16
|
+
icon: null as string | null,
|
|
17
|
+
canGoBack: false,
|
|
18
|
+
canGoForward: false,
|
|
19
|
+
navigated: [] as string[],
|
|
20
|
+
_directnavigate(url: URL) {
|
|
21
|
+
this.navigated.push(url.href);
|
|
22
|
+
this.url = url;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeHistory() {
|
|
28
|
+
const tab = fakeTab();
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
30
|
+
const history = new History(tab as any);
|
|
31
|
+
return { tab, history };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const A = new URL("https://a.example/");
|
|
35
|
+
const B = new URL("https://b.example/");
|
|
36
|
+
const C = new URL("https://c.example/");
|
|
37
|
+
const D = new URL("https://d.example/");
|
|
38
|
+
|
|
39
|
+
describe("tab history", () => {
|
|
40
|
+
let tab: ReturnType<typeof fakeTab>;
|
|
41
|
+
let history: History;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
({ tab, history } = makeHistory());
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("tracks a linear trail and walks back through it", () => {
|
|
48
|
+
history.push(A);
|
|
49
|
+
history.push(B);
|
|
50
|
+
history.push(C);
|
|
51
|
+
|
|
52
|
+
expect(history.current().url.href).toBe(C.href);
|
|
53
|
+
expect(history.canGoBack()).toBe(true);
|
|
54
|
+
expect(history.canGoForward()).toBe(false);
|
|
55
|
+
|
|
56
|
+
expect(history.go(-1).url.href).toBe(B.href);
|
|
57
|
+
expect(history.go(-1).url.href).toBe(A.href);
|
|
58
|
+
expect(history.canGoBack()).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps the current entry when a new navigation forks the trail", () => {
|
|
62
|
+
// Regression: the fork used to splice from the current index rather than
|
|
63
|
+
// after it, deleting the entry the user was on. index then pointed past the
|
|
64
|
+
// end of the array, so states[index] was undefined and going back walked
|
|
65
|
+
// into that hole.
|
|
66
|
+
history.push(A);
|
|
67
|
+
history.push(B);
|
|
68
|
+
history.push(C);
|
|
69
|
+
history.go(-2);
|
|
70
|
+
expect(history.current().url.href).toBe(A.href);
|
|
71
|
+
|
|
72
|
+
history.push(D);
|
|
73
|
+
|
|
74
|
+
expect(history.states.map((s) => s.url.href)).toEqual([A.href, D.href]);
|
|
75
|
+
expect(history.index).toBe(1);
|
|
76
|
+
expect(history.current().url.href).toBe(D.href);
|
|
77
|
+
expect(history.states[history.index]).toBeDefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("goes back to a real page after forking rather than an empty state", () => {
|
|
81
|
+
history.push(A);
|
|
82
|
+
history.push(B);
|
|
83
|
+
history.push(C);
|
|
84
|
+
history.go(-2);
|
|
85
|
+
history.push(D);
|
|
86
|
+
|
|
87
|
+
expect(history.canGoBack()).toBe(true);
|
|
88
|
+
const back = history.go(-1);
|
|
89
|
+
|
|
90
|
+
expect(back).toBeDefined();
|
|
91
|
+
expect(back.url).toBeInstanceOf(URL);
|
|
92
|
+
expect(back.url.href).toBe(A.href);
|
|
93
|
+
// The URL actually handed to the tab must be a real page, never "undefined".
|
|
94
|
+
expect(tab.navigated.at(-1)).toBe(A.href);
|
|
95
|
+
expect(tab.navigated.some((href) => href.includes("undefined"))).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("never reports canGoBack while sitting on the first entry", () => {
|
|
99
|
+
history.push(A);
|
|
100
|
+
history.push(B);
|
|
101
|
+
history.go(-1);
|
|
102
|
+
|
|
103
|
+
expect(history.index).toBe(0);
|
|
104
|
+
expect(history.canGoBack()).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("clamps overshooting travel to the ends of the trail", () => {
|
|
108
|
+
history.push(A);
|
|
109
|
+
history.push(B);
|
|
110
|
+
|
|
111
|
+
expect(history.go(-99).url.href).toBe(A.href);
|
|
112
|
+
expect(history.go(99).url.href).toBe(B.href);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("replaces the current entry without growing the trail", () => {
|
|
116
|
+
history.push(A);
|
|
117
|
+
history.push(B);
|
|
118
|
+
history.replace(C, null, null, false);
|
|
119
|
+
|
|
120
|
+
expect(history.states.map((s) => s.url.href)).toEqual([A.href, C.href]);
|
|
121
|
+
expect(history.index).toBe(1);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("survives a round trip through serialization", () => {
|
|
125
|
+
history.push(A);
|
|
126
|
+
history.push(B);
|
|
127
|
+
history.go(-1);
|
|
128
|
+
|
|
129
|
+
const restoredTab = fakeTab();
|
|
130
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
131
|
+
const restored = new History(restoredTab as any, history.serialize());
|
|
132
|
+
|
|
133
|
+
expect(restored.index).toBe(history.index);
|
|
134
|
+
expect(restored.current().url.href).toBe(A.href);
|
|
135
|
+
expect(restored.canGoForward()).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -94,8 +94,11 @@ export class History extends StatefulClass {
|
|
|
94
94
|
// disown states we're removing
|
|
95
95
|
this.states.slice(this.index + 1).forEach((state) => this.disown(state));
|
|
96
96
|
|
|
97
|
-
// "fork" history tree, creating a new timeline
|
|
98
|
-
|
|
97
|
+
// "fork" history tree, creating a new timeline. Only the entries *after*
|
|
98
|
+
// the current one are dropped: removing the current entry as well left
|
|
99
|
+
// index pointing past the end of the array, so states[index] was
|
|
100
|
+
// undefined and going back navigated into that hole.
|
|
101
|
+
this.states.splice(this.index + 1);
|
|
99
102
|
}
|
|
100
103
|
const hstate = new HistoryState({ url, state, title });
|
|
101
104
|
if (virtual) hstate.virtual = true;
|
|
@@ -129,7 +132,9 @@ export class History extends StatefulClass {
|
|
|
129
132
|
this.current().title = title;
|
|
130
133
|
this.current().favicon = null;
|
|
131
134
|
} else {
|
|
132
|
-
|
|
135
|
+
// push takes (url, title, state, navigate); passing state positionally
|
|
136
|
+
// as the title corrupted the new entry.
|
|
137
|
+
return this.push(url, title, state, navigate);
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
if (navigate) {
|
|
@@ -62,7 +62,7 @@ export class Tab extends StatefulClass {
|
|
|
62
62
|
this.url ??= new URL(`${INTERNAL_URL_PROTOCOL}//newtab`);
|
|
63
63
|
this.id ??= uuid("tab-");
|
|
64
64
|
|
|
65
|
-
this.frame = new ProxyFrame();
|
|
65
|
+
this.frame = new ProxyFrame(() => this.commitExternalNavigation());
|
|
66
66
|
this.history = new History(this, history);
|
|
67
67
|
this.own(this.history);
|
|
68
68
|
this.waitForInit = new Promise((resolve) => {
|
|
@@ -135,6 +135,7 @@ export class Tab extends StatefulClass {
|
|
|
135
135
|
|
|
136
136
|
// only caller should be history.ts for this
|
|
137
137
|
_directnavigate(url: URL) {
|
|
138
|
+
this.frame.cancelPendingNavigation();
|
|
138
139
|
this.url = url;
|
|
139
140
|
this.icon = defaultFaviconUrl;
|
|
140
141
|
if (url.protocol == INTERNAL_URL_PROTOCOL) {
|
|
@@ -188,6 +189,13 @@ export class Tab extends StatefulClass {
|
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
initialLoad() {
|
|
192
|
+
this.commitExternalNavigation();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private commitExternalNavigation() {
|
|
196
|
+
// A late response from a page we have already left must not hide a newly
|
|
197
|
+
// selected internal page.
|
|
198
|
+
if (this.url.protocol === INTERNAL_URL_PROTOCOL) return;
|
|
191
199
|
this.initResolve();
|
|
192
200
|
this.internalpage = null;
|
|
193
201
|
}
|
|
@@ -196,7 +204,10 @@ export class Tab extends StatefulClass {
|
|
|
196
204
|
this.history.push(url, null, null, true, false);
|
|
197
205
|
}
|
|
198
206
|
replaceNavigate(url: URL) {
|
|
199
|
-
|
|
207
|
+
// replace takes (url, title, state, navigate); `true` was landing in the
|
|
208
|
+
// state slot, so the replacement carried a bogus state and never navigated
|
|
209
|
+
// explicitly.
|
|
210
|
+
this.history.replace(url, null, null, true);
|
|
200
211
|
}
|
|
201
212
|
|
|
202
213
|
back() {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type OmniboxResult,
|
|
10
10
|
} from "./suggestions";
|
|
11
11
|
import { trimUrl } from "./utils";
|
|
12
|
+
import { inlineCompletion } from "./autocomplete";
|
|
12
13
|
import { UrlInput } from "@components/Omnibar/UrlInput";
|
|
13
14
|
import { Suggestion } from "@components/Omnibar/Suggestion";
|
|
14
15
|
import { requestUnfocusFrames } from "@components/Shell";
|
|
@@ -97,32 +98,19 @@ export function Omnibox(
|
|
|
97
98
|
// if the user is actually trying to search something we can kill the trending suggestions
|
|
98
99
|
this.trendingSuggestions = [];
|
|
99
100
|
|
|
100
|
-
|
|
101
|
+
// Capture the flag for this run. It is cleared synchronously below, well
|
|
102
|
+
// before fetchSuggestions calls back, so the callback cannot read it.
|
|
103
|
+
const justDeleted = this.suggestionDenied;
|
|
104
|
+
|
|
105
|
+
fetchSuggestions(this.realvalue, justDeleted, (results) => {
|
|
101
106
|
this.searchSuggestions = results;
|
|
102
107
|
|
|
103
108
|
const firstResult = results[0];
|
|
104
109
|
if (!firstResult) return;
|
|
105
|
-
if (firstResult.kind === "search") {
|
|
106
|
-
if (!firstResult.title) return;
|
|
107
|
-
if (this.realvalue.length >= firstResult.title.length) return;
|
|
108
|
-
if (
|
|
109
|
-
!firstResult.title
|
|
110
|
-
.toLowerCase()
|
|
111
|
-
.startsWith(this.realvalue.toLowerCase())
|
|
112
|
-
)
|
|
113
|
-
return;
|
|
114
110
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
currentCursor,
|
|
119
|
-
currentCursor + firstResult.title.length
|
|
120
|
-
);
|
|
121
|
-
this.value = firstResult.title;
|
|
122
|
-
this.input.setSelectionRange(
|
|
123
|
-
currentCursor,
|
|
124
|
-
currentCursor + firstResult.title.length
|
|
125
|
-
);
|
|
111
|
+
let candidate: string | null | undefined;
|
|
112
|
+
if (firstResult.kind === "search") {
|
|
113
|
+
candidate = firstResult.title;
|
|
126
114
|
} else {
|
|
127
115
|
if (!firstResult.url) return;
|
|
128
116
|
|
|
@@ -136,24 +124,26 @@ export function Omnibox(
|
|
|
136
124
|
if (normalizedUrl.endsWith("/") && !this.realvalue.endsWith("/")) {
|
|
137
125
|
normalizedUrl = normalizedUrl.slice(0, -1);
|
|
138
126
|
}
|
|
139
|
-
|
|
140
|
-
if (
|
|
141
|
-
!normalizedUrl.toLowerCase().startsWith(this.realvalue.toLowerCase())
|
|
142
|
-
)
|
|
143
|
-
return;
|
|
144
|
-
|
|
145
|
-
let currentCursor = this.input.selectionStart || 0;
|
|
146
|
-
|
|
147
|
-
this.input.setSelectionRange(
|
|
148
|
-
currentCursor,
|
|
149
|
-
currentCursor + normalizedUrl.length
|
|
150
|
-
);
|
|
151
|
-
this.value = normalizedUrl;
|
|
152
|
-
this.input.setSelectionRange(
|
|
153
|
-
currentCursor,
|
|
154
|
-
currentCursor + normalizedUrl.length
|
|
155
|
-
);
|
|
127
|
+
candidate = normalizedUrl;
|
|
156
128
|
}
|
|
129
|
+
|
|
130
|
+
const completion = inlineCompletion(
|
|
131
|
+
this.realvalue,
|
|
132
|
+
candidate,
|
|
133
|
+
justDeleted
|
|
134
|
+
);
|
|
135
|
+
if (completion === null) return;
|
|
136
|
+
|
|
137
|
+
const currentCursor = this.input.selectionStart || 0;
|
|
138
|
+
this.input.setSelectionRange(
|
|
139
|
+
currentCursor,
|
|
140
|
+
currentCursor + completion.length
|
|
141
|
+
);
|
|
142
|
+
this.value = completion;
|
|
143
|
+
this.input.setSelectionRange(
|
|
144
|
+
currentCursor,
|
|
145
|
+
currentCursor + completion.length
|
|
146
|
+
);
|
|
157
147
|
});
|
|
158
148
|
this.suggestionDenied = false;
|
|
159
149
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { inlineCompletion } from "./autocomplete";
|
|
4
|
+
|
|
5
|
+
describe("omnibox inline completion", () => {
|
|
6
|
+
it("completes a prefix while the user is typing", () => {
|
|
7
|
+
expect(inlineCompletion("ama", "amazon.com", false)).toBe("amazon.com");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("never re-adds text right after a deletion", () => {
|
|
11
|
+
// The completion is written back into the box with its tail selected, so
|
|
12
|
+
// re-adding it after a backspace means the next backspace only removes the
|
|
13
|
+
// completion again and the final character can never be deleted.
|
|
14
|
+
expect(inlineCompletion("a", "amazon.com", true)).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("leaves an emptied box empty", () => {
|
|
18
|
+
expect(inlineCompletion("", "amazon.com", true)).toBeNull();
|
|
19
|
+
expect(inlineCompletion("", "amazon.com", false)).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("ignores candidates that are not a continuation of the input", () => {
|
|
23
|
+
expect(inlineCompletion("xyz", "amazon.com", false)).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("matches case-insensitively but preserves the candidate's casing", () => {
|
|
27
|
+
expect(inlineCompletion("AmA", "amazon.com", false)).toBe("amazon.com");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("adds nothing when the candidate is not longer than the input", () => {
|
|
31
|
+
expect(inlineCompletion("amazon.com", "amazon.com", false)).toBeNull();
|
|
32
|
+
expect(inlineCompletion("amazon.com/x", "amazon.com", false)).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("tolerates a missing candidate", () => {
|
|
36
|
+
expect(inlineCompletion("ama", undefined, false)).toBeNull();
|
|
37
|
+
expect(inlineCompletion("ama", null, false)).toBeNull();
|
|
38
|
+
expect(inlineCompletion("ama", "", false)).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("lets a full deletion sequence reach an empty box", () => {
|
|
42
|
+
// Walk "ama" down to "" the way repeated backspaces would, asserting the
|
|
43
|
+
// box is never refilled at any step.
|
|
44
|
+
let typed = "ama";
|
|
45
|
+
while (typed.length > 0) {
|
|
46
|
+
typed = typed.slice(0, -1);
|
|
47
|
+
expect(inlineCompletion(typed, "amazon.com", true)).toBeNull();
|
|
48
|
+
}
|
|
49
|
+
expect(typed).toBe("");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline omnibox autocompletion.
|
|
3
|
+
*
|
|
4
|
+
* The omnibox completes what you type by writing the candidate into the input
|
|
5
|
+
* and selecting the added tail, so the next keystroke replaces it. That is only
|
|
6
|
+
* correct while text is being *added*. Applying it after a deletion makes the
|
|
7
|
+
* next backspace consume the re-added completion instead of the character the
|
|
8
|
+
* user meant to remove, which strands the final character permanently.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The text to place in the omnibox, or null to leave what the user typed alone.
|
|
13
|
+
*
|
|
14
|
+
* @param typed what the user actually has in the box
|
|
15
|
+
* @param candidate the top suggestion, if any
|
|
16
|
+
* @param justDeleted whether the last edit removed characters
|
|
17
|
+
*/
|
|
18
|
+
export function inlineCompletion(
|
|
19
|
+
typed: string,
|
|
20
|
+
candidate: string | undefined | null,
|
|
21
|
+
justDeleted: boolean
|
|
22
|
+
): string | null {
|
|
23
|
+
// Never re-add text the user is in the middle of removing.
|
|
24
|
+
if (justDeleted) return null;
|
|
25
|
+
if (!typed || !candidate) return null;
|
|
26
|
+
// A candidate that is not strictly longer adds nothing to complete.
|
|
27
|
+
if (candidate.length <= typed.length) return null;
|
|
28
|
+
if (!candidate.toLowerCase().startsWith(typed.toLowerCase())) return null;
|
|
29
|
+
return candidate;
|
|
30
|
+
}
|