[
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: File a bug\ntitle: \"\"\nlabels: \"\"\nassignees: \"\"\n---\n\n**Describe the bug**\n\nInclude a clear bug description.\n\n**To Reproduce**\n\nSteps to reproduce the behavior:\n\n1. Go to URL '...'\n2. Click on '....'\n\nInclude a screenshot if applicable.\n\n**Browser and Vimium version**\n\nIf you're using Chrome, include the Chrome and OS version found at chrome://version. Also include\nthe Vimium version found at chrome://extensions.\n\nIf you're using Firefox, report the Firefox and OS version found at about:support. Also include the\nVimium version found at about:addons.\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "## Description\n\nProvide a rationale for this PR, and a reference to the corresponding issue, if there is one.\n\nPlease review the \"Which pull requests get merged?\" section in `CONTRIBUTING.md`.\n"
  },
  {
    "path": ".gitignore",
    "content": "dist"
  },
  {
    "path": "CHANGELOG.md",
    "content": "2.4.1, 2.4.2 (2026-03-07)\n\n- Fix issue where existing users were mistakenly opted-in to\n  [Vimium's new tab page](https://github.com/philc/vimium/pull/4795) for the `createTab` command.\n  (https://github.com/philc/vimium/issues/4859)\n- Fix exclusion rules is empty in downloaded backups. (https://github.com/philc/vimium/issues/4839)\n\n2.4.0 (2026-01-27)\n\n- Support a Vimium new tab experience: the browser can be configured to open a blank Vimium page as\n  the new tab page. In Vimium's settings, the Vomnibar can configured to open on new tabs. See\n  [instructions](https://github.com/philc/vimium?tab=readme-ov-file#how-to-allow-vimium-to-work-on-new-tab-pages).\n  (https://github.com/philc/vimium/pull/4795)\n- Make Google search result links work on sub-tabs like \"Web\".\n  (https://github.com/philc/vimium/issues/4750)\n\n2.3.1 (2025-11-12)\n\n- Fix Vimium to work with Chrome 144. (https://github.com/philc/vimium/issues/4785)\n\n2.3.0 (2025-06-30)\n\n- Add a command listing page, which documents all commands and their options. Access it\n  [on the web](https://vimium.github.io/commands/), or from the Vimium Options page > Show available\n  commands.\n- Some internal CSS classes were changed for Vimium's UI. This may affect those who have customized\n  Vimium's CSS via the options page. (https://github.com/philc/vimium/issues/4668)\n- Breaking change: when creating a mapping for `setZoom`, a `level` argument is now required. E.g.:\n  `map z2 setZoom level=2.0`.\n- Make `Vomnibar.activateBookmark` accept a `query` option.\n  (https://github.com/philc/vimium/pull/4591)\n- Fix `openCopiedUrlInCurrentTab` doesn't launch search queries.\n  (https://github.com/philc/vimium/issues/4657)\n- Make `openCopiedUrlInCurrentTab` accept a `position` option.\n- Update `goPrevious` and `goNext` commands to handle google.com's new layout.\n  (https://github.com/philc/vimium/issues/4650)\n- Add a \"hide update notifications\" option for silencing \"Vimium has been updated\" notifications.\n  (https://github.com/philc/vimium/issues/4346)\n- Use dark mode styles in the HUD when the browser is in dark mode.\n- Bug fixes.\n\n2.2.1 (2025-03-20)\n\n- Fix findSelected and findSelectedBackwards commands (https://github.com/philc/vimium/issues/4655)\n- Fix openCopiedUrlInCurrentTab (https://github.com/philc/vimium/issues/4654)\n\n2.2.0 (2025-03-08)\n\n- Use the browser's default search engine. [(#2598)](https://github.com/philc/vimium/issues/2598)\n- Add \"reload hard\" command (R). ([#4445](https://github.com/philc/vimium/pull/4445)).\n- Add zoomIn (zi), zoomOut (zo), zoomReset (z0), and setZoom commands.\n  ([#4488](https://github.com/philc/vimium/pull/4488))\n- Add findSelected and findSelectedBackwards commands.\n  ([#4502](https://github.com/philc/vimium/pull/4502))\n- Options page: improve UI, add error validation.\n- Make tab commands handle Firefox hidden tabs.\n- Bug fixes.\n\n2.1.2 (2024-04-03)\n\n- Better fix for Vomnibar doesn't always list tabs by recency.\n  ([#4368](https://github.com/philc/vimium/issues/4368))\n- Add a workaround to make link hints work on Github Enterprise.\n  ([#4446](https://github.com/philc/vimium/issues/4446))\n- Fix position=end is ignored in createTab command\n  ([#4450](https://github.com/philc/vimium/issues/4450))\n\n2.1.1 (2024-03-29)\n\n- Fix exclusion rule popup not working. ([#4447](https://github.com/philc/vimium/issues/4447))\n\n2.1.0 (2024-03-27)\n\n- Fix Vomnibar doesn't always list tabs by recency.\n  ([#4368](https://github.com/philc/vimium/issues/4368))\n- Better domain detection in the Vomnibar ([#3268](https://github.com/philc/vimium/issues/3268))\n- Exclude keys based on the top frame URL, not a subframe's URL. This fixes many cases where the\n  excluded keys feature didn't seem to work. ([#4402](https://github.com/philc/vimium/issues/4402))\n- After selecting a link, if ESC is pressed, mouse out of the link. With this, Wikipedia's and\n  Github's link preview popups can be dismissed after following a link.\n  ([#3073](https://github.com/philc/vimium/issues/3073))\n- Fix link hints do not appear for links inside of github's popups. This fix is available on Chrome\n  114+, and soon Firefox. ([#4408](https://github.com/philc/vimium/issues/4408))\n\n2.0.5, 2.0.6 (2023-11-06)\n\n- Fix bug where \"esc\" wouldn't unfocus a textarea like it should.\n  ([#4336](https://github.com/philc/vimium/issues/4336))\n- Fix passNextKey command.\n\n2.0.4 (2023-10-19)\n\n- Bug fixes: ([#4340](https://github.com/philc/vimium/issues/4340)),\n  ([#4341](https://github.com/philc/vimium/issues/4341)),\n  ([#4342](https://github.com/philc/vimium/issues/4342)).\n\n2.0.2, 2.0.3 (2023-10-11)\n\n- Fix Vomnibar tab search doesn't get pre-populated with recently visited tabs.\n  ([#4326](https://github.com/philc/vimium/issues/4326))\n- Fix bookmarklets not working when opened from the Vomnibar. This is a partial fix; a full fix is\n  waiting on a new extensions API. See [#4329](https://github.com/philc/vimium/issues/4329) for\n  discussion.\n\n2.0.1 (2023-10-04)\n\n- Fix exception when migrating some pre-v2.0 settings.\n  ([#4323](https://github.com/philc/vimium/issues/4323))\n\n2.0.0 (2023-09-28)\n\n- Support manifest v3, as now required by Chrome. This involved a partial rewrite and many changes.\n  Please report any new issues [here](https://github.com/philc/vimium/issues).\n- The storage format for Vimium's options has changed in v2.x. That means an options backup from\n  Vimium v2.x cannot be loaded on Vimium v1.x installations.\n- Revamp the action bar UI, which configures which keys Vimium ignores on a particular site.\n- Improve Vimium's options UI.\n- Show link hints for image maps. ([#3493](https://github.com/philc/vimium/issues/3493))\n- Remove the use of window.unload handlers, in preparation for Chrome's bfcache.\n  ([#4265](https://github.com/philc/vimium/issues/4265))\n- Allow find mode to work when using only private windows.\n  ([#3614](https://github.com/philc/vimium/issues/3614))\n- Add a count option to closeTabsOnLeft and closeTabsOnRight commands, to allow binding a key to\n  \"close just 1 tab on the left/right\" rather than closing all tabs, as is the default. E.g.\n  `map cl\n  closeTabsOnLeft count=1`. ([#4296](https://github.com/philc/vimium/pull/4296))\n- Add search completions for Brave Search. ([#3851](https://github.com/philc/vimium/pull/3851))\n- Make regular expressions in find mode work again; other find mode improvements.\n  ([#4261](https://github.com/philc/vimium/issues/4261))\n- Bug fixes. ([#3944](https://github.com/philc/vimium/pull/3944),\n  [#3752](https://github.com/philc/vimium/pull/3752),\n  [#3675](https://github.com/philc/vimium/pull/3675))\n\n1.67.7 (2023-07-12)\n\n- Fix an issue where focusing the google search box puts the cursor at the start, rather than end,\n  of the search box. ([#4247](https://github.com/philc/vimium/issues/4247))\n\n1.67.6 (2022-12-19)\n\n- Fix a spurious issue preventing approval on the Mozilla addons site\n  ([#4195](https://github.com/philc/vimium/issues/4195))\n\n1.67.5 (2022-12-17)\n\n- For Firefox only, add back the clipboard read and write permissions. This fixes the Vimium\n  commands which use the clipboard in Firefox ([#4186](https://github.com/philc/vimium/pull/4186))\n\n1.67.4 (2022-12-01)\n\n- Remove clipboard read/write permissions. We no longer need them since 1.67.2 (see #4120).\n- Fix Vimium's dark mode styling, take 2 (see [#4156](https://github.com/philc/vimium/issues/4156),\n  [#4159](https://github.com/philc/vimium/pull/4159))\n\n1.67.3 (2022-10-29)\n\n- Fix copy-to-clipboard issue ([#4147](https://github.com/philc/vimium/issues/4147)) in visual mode.\n- Fix Vimium's dark mode styling in latest Firefox.\n  ([#4148](https://github.com/philc/vimium/issues/4148))\n\n1.67.2 (2022-10-17)\n\n- In Firefox, remove use of deprecated InstallTrigger, which was issuing a console warning\n  ([#4033](https://github.com/philc/vimium/issues/4033))\n- Fix the Vimium toolbar icon to accurately reflect whether keys are excluded\n  ([#4118](https://github.com/philc/vimium/pull/4118))\n- Fix usage of deprecated clipboard APIs, which affected commands using copy and paste\n  ([#4120](https://github.com/philc/vimium/issues/4120))\n- Fix bug preventing going into caret mode ([#3877](https://github.com/philc/vimium/pull/3877))\n\n1.67.1 (2022-01-19)\n\n- In Firefox 96+, make link hints open one tab, not two\n  ([#3985](https://github.com/philc/vimium/pull/3985))\n\n1.67 (2021-07-09)\n\n- Dark mode: Vimium's UI (URL bar, help dialog, option page, etc.) are dark if the browser is\n  configured for dark mode. Vimium's dark mode is also compatible when using the popular\n  [DarkReader extension](https://github.com/darkreader/darkreader).\n- Convert the code base from Coffeescript to Javascript, to simplify the dev experience and allow\n  more developers to work on Vimium.\n- Make search mode work in newer versions of Firefox (#3801)\n- Make buttons on the Vimium options page work again in newer versions of Firefox (#3624)\n- Allow Vimium to work in LibreWolf (a Firefox fork)\n- Fixes to visual mode (#3568, #3779)\n\n1.66 (2020-03-02)\n\n- Show tabs in the Vomnibar bar search results ('o')\n  ([#2656](https://github.com/philc/vimium/pull/2656))\n- Add commands to hover or focus a link ([#3097](https://github.com/philc/vimium/pull/3097)) (see\n  [wiki)](https://github.com/philc/vimium/wiki/Tips-and-Tricks#hovering-over-links-using-linkhints)\n- Allow shift as a modifier for keybindings (e.g. `<s-left>`)\n  ([#2388](https://github.com/philc/vimium/pull/2388))\n- Fix some issues with link hints [(#3499](https://github.com/philc/vimium/pull/3499),\n  [#3505](https://github.com/philc/vimium/pull/3505),\n  [#3509](https://github.com/philc/vimium/pull/3509))\n- Other fixes.\n\n1.65.2 (2020-02-10)\n\n- No code changes; trying to debug a permissions issue as shown in the chrome store\n  ([#3489](https://github.com/philc/vimium/issues/3489)).\n\n1.65.1 (2020-02-09)\n\n- Fix an issue with the HUD preventing some link hints from being shown\n  ([#3486](https://github.com/philc/vimium/issues/3486)).\n\n1.65 (2020-02-08)\n\n- Many fixes for Firefox ([#3483](https://github.com/philc/vimium/pull/3483),\n  [#2893](https://github.com/philc/vimium/issues/2893),\n  [#3106](https://github.com/philc/vimium/issues/3106),\n  [#3409](https://github.com/philc/vimium/pull/3409),\n  [#3288](https://github.com/philc/vimium/pull/3288))\n- Fix javascript bookmarks, broken by Chrome 71+\n  [(#3473)](https://github.com/philc/vimium/pull/3437)\n- Improved link hints: show hints on sites with shadow DOM\n  [(#3406)](https://github.com/philc/vimium/pull/3406), don't show hints for obstructed/invisible\n  links ([#2251](https://github.com/philc/vimium/pull/2251))\n- Fix scrolling on Reddit.com ([#3327](https://github.com/philc/vimium/pull/3327))\n- Show favicons when using the tab switcher ([#2878](https://github.com/philc/vimium/pull/2878))\n- The createTab command can now take arguments (start, end, before, after)\n  ([#2895](https://github.com/philc/vimium/pull/2895))\n- When using the Vomnibar, you can manually edit the suggested URL by typing ctrl-enter\n  [(#2464)](https://github.com/philc/vimium/pull/2914)\n- Other fixes\n\n1.64.6 (2019-05-12)\n\n- Fix the find mode, and copying the page's URL to the clipboard, which were broken by Chrome 74+.\n  ([#3260](https://github.com/philc/vimium/issues/3260))\n\n1.64.5 (2019-02-16)\n\n- Fix error in Chrome Store distribution.\n\n1.64.4 (2019-02-16)\n\n- Fix [Vomnibar focus issue](https://github.com/philc/vimium/issues/3242).\n\n1.64.3 (2018-12-26)\n\n- When yanking email addresses with `yf`, Vimium now strips the leading `mailto:`.\n- For custom search engines, if you use `%S` (instead of `%s`), then your search terms are not URI\n  encoded.\n- Bug fixes (including horizontal scrolling broken).\n\n1.64.2 (2018-12-16)\n\n- Better scrolling on new Reddit ~~and GMail~~.\n\n1.64 (2018-08-30)\n\n- Custom search engines can now be `javascript:` URLs (eg., search the current\n  [site](https://github.com/philc/vimium/issues/2956#issuecomment-366509915)).\n- You can now using local marks to mark a hash/anchor. This is particularly useful for marking\n  labels on GMail.\n- For filtered hints, you can now start typing the link text before the hints have been generated.\n- On Twitter, expanded tweets are now scrollable.\n- Fix bug whereby `<Enter>` wasn't recognised in the Vomnibar in some circumstances.\n- Various minor bug fixes.\n\n1.63 (2018-02-16)\n\n- The `reload` command now accepts a count prefix; so `999r` reloads all tabs (in the current\n  window).\n- Better detection of click listeners for link hints.\n- Display version number in page popup.\n- The Vomnibar is now loaded on demand (not preloaded). This should fix some issues with the dev\n  console.\n- The `\\I` control (case sensitivity) for find mode has been removed. Find mode uses smartcase.\n- Various bug fixes.\n- 1.63.1 (Firefox only):\n  - Fix [#2958](https://github.com/philc/vimium/issues/2958#issuecomment-366488659), link hints\n    broken for `target=\"_blank\"` links.\n- 1.63.2 (Firefox only):\n  - Fix [#2962](https://github.com/philc/vimium/issues/2962), find mode broken on Firefox Quantum.\n- 1.63.3:\n  - Fix [#2997](https://github.com/philc/vimium/issues/2997), Vimium's DOM injection breaks Google\n    Pay site.\n\n1.62 (2017-12-09)\n\n- Backup and restore Vimium options (see the very bottom of the options page, below _Advanced\n  Options_).\n- It is now possible to map `<tab>`, `<enter>`, `<delete>`, `<insert>`, `<home>` and `<end>`.\n- New command options for `createTab` to create new normal and incognito windows\n  ([examples](https://github.com/philc/vimium/wiki/Tips-and-Tricks#creating-tabs-with-urls-and-windows)).\n- Firefox only:\n  - Fix copy and paste commands.\n  - When upgrading, you will be asked to re-validate permissions. The only new permission is \"copy\n    and paste to/from clipboard\" (the `clipboardWrite` permission). This is necessary to support\n    copy/paste on Firefox.\n- Various bug fixes.\n- 1.62.1: Swap global and local marks (1.62.1). In a browser, some people find global marks more\n  useful than local marks. Example:\n\n```\nmap X Marks.activateCreateMode swap\nmap Y Marks.activateGotoMode swap\n```\n\n- Other minor versions:\n  - 1.62.2: Fixes [#2868](https://github.com/philc/vimium/issues/2868) (`createTab` with multiple\n    URLs).\n  - 1.62.4: Fixes bug affecting the enabled state, and really fix `createTab`.\n\n1.61 (2017-10-27)\n\n- For _filtered hints_, you can now use alphabetical hint characters instead of digits; use\n  `<Shift>` for hint characters.\n- With `map R reload hard`, the reload command now asks Chrome to bypass its cache.\n- You can now map `<c-[>` to a command (in which case it will not be treated as `Escape`).\n- Various bug fixes, particularly for Firefox.\n- Minor versions:\n  - 1.61.1: Fix `map R reload hard`.\n\n1.60 (2017-09-14)\n\n- Features:\n  - There's a new (advanced) option to ignore the keyboard layout; this can be helpful for users of\n    non-Latin keyboards.\n  - Firefox support. This is a work in progress; please report any issues\n    [here](https://github.com/philc/vimium/issues?q=is%3Aopen+sort%3Aupdated-desc); see the\n    [add on](https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/).\n\n- Bug fixes:\n  - Fixed issue affecting hint placement when the display is zoomed.\n  - Fixed search completion for Firefox (released as 1.59.1, Firefox only).\n\n- Minor versions:\n  - 1.60.1: fix [#2642](https://github.com/philc/vimium/issues/2642).\n  - 1.60.2: revert previous fix for HiDPI screens. This was breaking link-hint positioning for some\n    users.\n  - 1.60.3: [fix](https://github.com/philc/vimium/pull/2649) link-hint positioning.\n  - 1.60.4: [fix](https://github.com/philc/vimium/pull/2602) hints opening in new tab (Firefox\n    only).\n\n1.59 (2017-04-07)\n\n- Features:\n  - Some commands now work on PDF tabs (`J`, `K`, `o`, `b`, etc.). Scrolling and other\n    content-related commands still do not work.\n\n1.58 (2017-03-08)\n\n- Features:\n  - The `createTab` command can now open specific URLs (e.g,\n    `map X createTab http://www.bbc.com/news`).\n  - With pass keys defined for a site (such as GMail), you can now use Vimium's bindings again with,\n    for example, `map \\ passNextKey normal`; this reactivates normal mode temporarily, but _without\n    any pass keys_.\n  - You can now map multi-modifier keys, for example: `<c-a-X>`.\n  - Vimium can now do simple key mapping in some modes; see\n    [here](https://github.com/philc/vimium/wiki/Tips-and-Tricks#key-mapping). This can be helpful\n    with some non-English keyboards (and can also be used to remap `Escape`).\n  - For _Custom key mappings_ on the options page, lines which end with `\\` are now continued on the\n    following line.\n- Process:\n  - In order to provide faster bug fixes, we may in future push new releases without the noisy\n    notification.\n\n- Post-release minor fixes:\n  - 1.58.1 (2017-03-09) fix bug in `LinkHints.activateModeWithQueue` (#2445).\n  - 1.58.2 (2017-03-19) fix key handling bug (#2453).\n\n1.57 (2016-10-01)\n\n- New commands:\n  - `toggleMuteTab` - mute or unmute the current tab (default binding `<a-m>`), see also\n    [advanced usage](https://github.com/philc/vimium/wiki/Tips-and-Tricks#muting-tabs).\n- Other new features:\n  - You can now map `<backspace>` to a Vimium command (e.g. `map <backspace> goBack`).\n  - For link hints, when one hint marker is covered by another, `<Space>` now rotates the stacking\n    order. If you use filtered hints, you'll need to use a modifier (e.g. `<c-Space>`).\n- Changes:\n  - Global marks now search for an existing matching tab by prefix (rather than exact match). This\n    allows global marks to be used as quick bookmarks on sites (like Facebook, Gmail, etc) where the\n    URL changes as you navigate around.\n- Bug fixes:\n  - `/i` can no longer hang Vimium while the page is loading.\n  - `<c-a-[>` is no longer handled (incorrectly) as `Escape`. This also affects `<Alt-Gr-[>`.\n  - If `goX` is mapped, then `go` no longer launches the vomnibar. This only affects three-key (or\n    longer) bindings.\n\n1.56 (2016-06-11)\n\n- Vimium now works around a Chromium bug affecting users with non-standard keyboard layouts (see\n  #2147).\n- Fixed a bug preventing visual line mode (`V`) from working.\n\n1.55 (2016-05-26)\n\n- New commands:\n  - `visitPreviousTab` - visit the previous tab (by recency) with `^`, or the tab before that with\n    `2^`.\n  - `passNextKey` - pass the next key to the page. For example, using `map <c-]> passNextKey`, you\n    can close Facebook's messenger popups with `<c-]><Esc>`.\n- Link hints:\n  - Now work across all frames in the tab.\n  - Now select frames and scrollable elements.\n  - Now accept a count prefix; `3F` opens three new background tabs, `999F` opens many tabs.\n  - For filtered link hints, a new option on the settings page requires you to press `Enter` to\n    activate a link; this prevents unintentionally triggering Vimium commands with trailing\n    keystrokes.\n- Miscellaneous:\n  - `gg` now accepts a `count` prefix.\n  - `W` now accepts a count prefix; `3W` moves three tabs to a new window.\n  - With smooth scrolling, `2j`-and-hold now gives a faster scroll than `j`-and-hold.\n  - You can now bind keys to a command with a defined count prefix; for example,\n    `map d scrollDown count=4`.\n  - You can now bind three-key (or longer) sequences; for example, `map abc enterInsertMode`.\n  - `c-y` and `c-e` now scroll in visual mode.\n  - The Vimium help dialog has been re-styled.\n- Bug fixes:\n  - `<c-a-[>` is no longer treated as escape.\n  - Fix icon display and memory leak due to a regression in recent Chrome versions (49+).\n- For web-devs only:\n  - When disabled on a tab, Vimium no longer pollutes the dev console with network requests.\n\n1.54 (2016-01-30)\n\n- Fix occasional endless scrolling (#1911).\n\n1.53 (2015-09-25)\n\n- Vimium now works on the new-tab page for Chrome 47.\n- `g0` and `g$` now accept count prefixes; so `2g0` selects the second tab, and so on.\n- Bug fixes:\n  - Fix `moveTabLeft` and `moveTabRight` for pinned tabs (#1814 and #1815).\n\n1.52 (2015-09-09)\n\n- Search completion for selected custom search engines (details on the\n  [wiki](https://github.com/philc/vimium/wiki/Search-Completion)).\n- Use `Tab` on an empty Vomnibar to repeat or edit recent queries (details on the\n  [wiki](https://github.com/philc/vimium/wiki/Tips-and-Tricks#repeat-recent-vomnibar-queries)).\n- Marks:\n  - Use <tt>\\`\\`</tt> to jump back to the previous position after jump-like movements: <br/> (`gg`,\n    `G`, `n`, `N`, `/` and local mark movements).\n  - Global marks are now persistent and synced.\n- For numeric link hints, you can now use `Tab` and `Enter` to select hints, and hints are ordered\n  by the best match.\n- The Find Mode text entry box now supports editing, pasting, and better handles non-latin\n  characters.\n- Vimium now works on XML pages.\n- Bug fixes.\n\n1.51 (2015-05-02)\n\n- Bug\n  [fixes](https://github.com/philc/vimium/pulls?utf8=%E2%9C%93&q=is%3Apr+sort%3Aupdated-desc+is%3Aclosed+merged%3A%3E%3D2015-04-26+merged%3A%3C2015-05-02+state%3Amerged).\n\n1.50 (2015-04-26)\n\n- Visual mode (in beta): use `v` and then vim-like keystrokes to select text on the page. Use `y` to\n  yank or `p` and `P` to search with your default search engine.. Please provide feedback on Github.\n- Added the option to prevent pages from stealing focus from Vimium when loaded.\n- Many bugfixes for custom search engines, and search engines can now have a description.\n- Better support for frames: key exclusion rules are much improved and work within frames; the\n  Vomnibar is always activated in the main frame; and a new command (`gF`) focuses the main frame.\n- Find mode now has history. Use the up arrow to select previous searches.\n- Ctrl and Shift when using link hints changes the tab in which links are opened in (reinstated\n  feature).\n- Focus input (`gi`) remembers previously-visited inputs.\n- Bug fixes.\n\n1.49 (2014-12-16)\n\n- An option to toggle smooth scrolling.\n- Make Vimium work on older versions of Chrome.\n\n1.46, 1.47, 1.48 (2014-12-15)\n\n- Site-specific excluded keys: you can disable some Vimium key bindings on sites like gmail.com, so\n  you can use the key bindings provided by the site itself.\n- Smooth scrolling.\n- The Vomnibar now orders tabs by recency. Use this to quickly switch between your most\n  recently-used tabs.\n- New commands: \"close tabs to the left\", \"close tabs to the right\", \"close all other tabs\".\n- Usability improvements.\n- Bug fixes.\n\n1.45 (2014-07-20)\n\n- Vimium's settings are now synced across computers.\n- New commands: \"open link in new tab and focus\", \"move tab left\", \"move tab right\", \"pin/unpin\n  tab\".\n- Vomnibar can now use\n  [search engine shortcuts](https://github.com/philc/vimium/wiki/Search-Engines), similar to\n  Chrome's Omnibar.\n- Due to significant ranking improvements, Vomnibar's search results are now even more helpful.\n- When reopening a closed tab, its history is now preserved.\n- Bug fixes.\n\n1.44 (2013-11-06)\n\n- Add support for recent versions of Chromium.\n- Bug fixes.\n\n1.43 (2013-05-18)\n\n- Relevancy improvements to the Vomnibar's domain & history search.\n- Added `gU`, which goes to the root of the current URL.\n- Added `yt`, which duplicates the current tab.\n- Added `W`, which moves the current tab to a new window.\n- Added marks for saving and jumping to sections of a page. `mX` to set a mark and `` `X `` to\n  return to it.\n- Added \"LinkHints.activateModeToOpenIncognito\", currently an advanced, unbound command.\n- Disallowed repeat tab closings, since this causes trouble for many people.\n- Update our Chrome APIs so Vimium works on Chrome 28+.\n- Bug fixes.\n\n1.42 (2012-11-03)\n\n- Bug fixes.\n\n1.41 (2012-10-27)\n\n- Bug fixes.\n\n1.40 (2012-10-27)\n\n- Bug fixes.\n- Added options for search engines and regex find.\n- Pressing unmapped keys in hints mode now deactivates the mode.\n\n1.39 (2012-09-09)\n\n- Bug fixes.\n\n1.38 (2012-09-08)\n\n- `O` now opens Vomnibar results in a new tab. `B` does the same for bookmarks only.\n- Add a browser icon to quickly add sites to Vimium's exclude list.\n- Restyle options page.\n- `gi` now launches a new mode that allows the user to tab through the input elements on the page.\n- Bug fixes.\n\n1.37 (2012-07-07)\n\n- Select the first result by default in Vomnibar tab and bookmark modes.\n\n1.36 (2012-07-07)\n\n- `b` brings up a bookmark-only Vomnibar.\n- Better support for some bookmarklets.\n\n1.35 (2012-07-05)\n\n- Bug fixes.\n\n1.34 (2012-07-03)\n\n- A bug fix for bookmarklets in Vomnibar.\n\n1.33 (2012-07-02)\n\n- A Vomnibar, which allows you to open sites from history, bookmarks, and tabs using Vimium's UI.\n  Type `o` to try it.\n\n1.32 (2012-03-05)\n\n- More tweaks to the next / previous link-detection algorithm.\n- Minor bug fixes.\n\n1.31 (2012-02-28)\n\n- Improve style of link hints, and use fewer characters for hints.\n- Add an option to hide the heads up display (HUD). Notably, the HUD obscures Facebook Chat's\n  textbox.\n- Detection and following of next / previous links has been improved.\n- Addition of `g0` and `g$` commands, for switching tabs.\n- Addition of `p`/`P` commands for URL pasting.\n- A new find mode which optionally supports case sensitivity and regular expressions.\n- Bug fixes.\n\n1.30 (2011-12-04)\n\n- Support for image maps in link hints.\n- Counts now work with forward & backward navigation.\n- `Tab` & `shift-tab` to navigate bookmarks dialog.\n- An alternate link hints mode: type the title of a link to select it. You can enable it in Vimium's\n  Advanced Preferences.\n- Bug fixes.\n\n1.29 (2012-07-30)\n\n- `yf` to copy a link hint url to the clipboard.\n- Scatter link hints to prevent clustering on dense sites.\n- Don't show insert mode notification unless you specifically hit `i`.\n- Remove zooming functionality now that Chrome does it all natively.\n\n1.28 (2011-06-29)\n\n- Support for opening bookmarks (`b` and `B`).\n- Support for contenteditable text boxes.\n- Speed improvements and bug fixes.\n\n1.27 (2011-03-24)\n\n- Improvements and bug fixes.\n\n1.26 (2011-02-17)\n\n- `<c-d>`, `<c-f>` and related are no longer bound by default. You can rebind them on the options\n  page.\n- Faster link hinting.\n\n1.22, 1.23, 1.24, 1.25 (2011-02-10)\n\n- Some sites are now excluded by default.\n- View source (`gs`) now opens in a new tab.\n- Support for browsing paginated sites using `]]` and `[[` to go forward and backward respectively.\n- Many of the less-used commands are now marked as \"advanced\" and hidden in the help dialog by\n  default, so that the core command set is more focused and approachable.\n- Improvements to link hinting.\n- Bug fixes.\n\n1.21 (2010-10-24)\n\n- Critical bug fix for an excluded URLs regression due to frame support.\n\n1.20 (2010-10-24)\n\n- In link hints mode, holding down the shift key will now toggle between opening in the current tab\n  and opening in a new tab.\n- Two new commands (`zH` and `zL`) to scroll to the left and right edges of the page.\n- A new command (`gi`) to focus the first (or n-th) visible text input.\n- A new command (`<a-f>`) to open up multiple links at a time in new tabs.\n- Frame support.\n- More robust support for non-US keyboard layouts.\n- Numerous bug fixes.\n\n1.19 (2010-06-29)\n\n- A critical bug fix for development channel Chromium.\n- Vimium icons for the Chrome extensions panel and other places.\n\n1.18 (2010-06-22)\n\n- Vimium now runs on pages with file:/// and ftp:///\n- The Options page is now linked from the Help dialog.\n- Arrow keys and function keys can now be mapped using &lt;left&gt;, &lt;right&gt;, &lt;up&gt;,\n  &lt;down&gt;, &lt;f1&gt;, &lt;f2&gt;, etc. in the mappings interface.\n- There is a new command `goUp` (mapped to `gu` by default) that will go up one level in the URL\n  hierarchy. For example: from https://vimium.github.io/foo/bar to https://vimium.github.io/foo. At\n  the moment, `goUp` does not support command repetition.\n- Bug fixes and optimizations.\n\n1.17 (2010-04-18)\n\n- `u` now restores tabs that were closed by the mouse or with native shortcuts. Tabs are also\n  restored in their prior position.\n- New `unmapAll` command in the key mappings interface to remove all default mappings.\n- Link hints are now faster and more reliable.\n- Bug fixes.\n\n1.16 (2010-03-09)\n\n- Add support for configurable key mappings under Advanced Options.\n- A help dialog which shows all currently bound keyboard shortcuts. Type `?` to see it.\n- Bug fixes related to key stroke handling.\n\n1.15 (2010-01-31)\n\n- Make the CSS used by the link hints configurable. It's under Advanced Options.\n- Add a notification linking to the changelog when Vimium is updated in the background.\n- Link-hinting performance improvements and bug fixes.\n- `Ctrl+D` and `Ctrl+U` now scroll by 1/2 page instead of a fixed amount, to mirror Vim's behavior.\n\n1.14 (2010-01-21)\n\n- Fixed a bug introduced in 1.13 that prevented excluded URLs from being saved.\n\n1.13 (2010-01-21)\n\n- `<c-f>` and `<c-b>` are now mapped to scroll a full page up or down respectively.\n- Bug fixes related to entering insert mode when the page first loads, and when focusing Flash\n  embeds.\n- Added command listing to the Options page for easy reference.\n- `J` & `K` have reversed for tab switching: `J` goes left and `K` goes right.\n- `<c-[>` is now equivalent to `Esc`, to match the behavior of VIM.\n- `<c-e>` and `<c-y>` are now mapped to scroll down and up respectively.\n- The characters used for link hints are now configurable under Advanced Options.\n\n1.11, 1.12 (2010-01-08)\n\n- Commands `gt` & `gT` to move to the next & previous tab.\n- Command `yy` to yank (copy) the current tab's url to the clipboard.\n- Better Linux support.\n- Fix for `Shift+F` link hints.\n- `Esc` now clears the keyQueue. So, for example, hitting `g`, `Esc`, `g` will no longer scroll the\n  page.\n\n1.1 (2010-01-03)\n\n- A nicer looking settings page.\n- An exclusion list that allows you to define URL patterns for which Vimium will be disabled (e.g.\n  http\\*://mail.google.com/\\*).\n- Vimium-interpreted keystrokes are no longer sent to the page.\n- Better Windows support.\n- Various miscellaneous bug fixes and UI improvements.\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to Vimium\n\n## Reporting a bug\n\nFile the issue [here](https://github.com/philc/vimium/issues).\n\n## Contributing code\n\nYou'd like to fix a bug or implement a feature? Great! Before getting started, understand Vimium's\ndesign principles and the goals of the maintainers.\n\n### Vimium design principles\n\nWhen people first start using Vimium, it provides an incredibly powerful workflow improvement and it\nmakes them feel awesome. Surprisingly, Vimium is applicable to a huge, broad population of people,\nnot just users of Vim.\n\nIn addition to power, a secondary goal of Vimium is approachability: minimizing the barriers which\nprevent a new user from feeling awesome. Many of Vimium's users haven't used Vim before -- about 1\nin 5 Chrome Store reviews say this -- and most people have strong web browsing habits forged from\nyears of browsing. Given that, it's a great experience when Vimium feels like a natural addition to\nChrome which augments, but doesn't break, the user's current browsing habits.\n\n**Principles:**\n\n1. **Easy to understand**. Even if you're not very familiar with Vim. The Vimium video shows you all\n   you need to know to start using Vimium and feel awesome.\n2. **Reliable**. The core feature set works on most sites on the web.\n3. **Immediately useful**. Vimium doesn't require any configuration or doc-reading before it's\n   useful. Just watch the video or hit `?`. You can transition into using Vimium piecemeal; you\n   don't need to jump in whole-hog from the start.\n4. **Feels native**. Vimium doesn't drastically change the way Chrome looks or behaves.\n5. **Simple**. The core feature set isn't overwhelming. This principle is particularly vulnerable as\n   we add to Vimium, so it requires our active effort to maintain this simplicity.\n6. **Code simplicity**. Developers find the Vimium codebase relatively simple and easy to jump into.\n   This allows more people to fix bugs and implement features.\n\n### Which pull requests get merged?\n\n**Goals of the maintainers**\n\nThe maintainers of Vimium have limited bandwidth, which influences which PRs we can review and\nmerge.\n\nOur goals are generally to keep Vimium small, maintainable, and really nail the broad appeal use\ncases. This is in contrast to adding and maintaining an increasing number of complex or niche\nfeatures. We recommend those live in forked repos rather than the mainline Vimium repo.\n\nPRs we'll likely merge:\n\n- Reflect all of the Vimium design principles.\n- Are useful for lots of Vimium users.\n- Have simple implementations (straightforward code, few lines of code).\n\nPRs we likely won't:\n\n- Violate one or more of our design principles.\n- Are niche.\n- Have complex implementations -- more code than they're worth.\n\nTips for preparing a PR:\n\n- If you want to check with us first before implementing something big, open an issue proposing the\n  idea. You'll get feedback from the maintainers as to whether it's something we'll likely merge.\n- Try to keep PRs around 50 LOC or less. Bigger PRs create inertia for review.\n\nHere's the rationale behind this policy:\n\n- Vimium is a volunteer effort. To make it possible to keep the project up-to-date as the web and\n  browsers evolve, the codebase has to remain small and maintainable.\n- If the maintainers don't use a feature, and most other users don't, then the feature will likely\n  get neglected.\n- Every feature, particularly neglected ones, increase the complexity of the codebase and makes it\n  more difficult and less pleasant to work on.\n- Adding a new feature is only part of the work. Once it's added, a feature must be maintained\n  forever.\n- Vimium is a project which suffers from the\n  [stadium model of open source](https://github.com/philc/book-notes/blob/master/engineering/working%20in%20public%20-%20nadia%20eghbal.md#the-structure-of-an-open-source-project-chap-2):\n  there are many users but unfortunately few maintainers. As a result, there is bandwidth to\n  maintain only a limited number of features in the main repo.\n\n### Installing From Source\n\nVimium is written in Javascript. To install Vimium from source:\n\n**On Chrome/Chromium:**\n\n1. Navigate to `chrome://extensions`\n1. Toggle into Developer Mode\n1. Click on \"Load Unpacked Extension...\"\n1. Select the Vimium directory you've cloned from Github.\n\n**On Firefox:**\n\nFirefox needs a modified version of the manifest.json that's used for Chrome. To generate this, run\n\n`./make.js write-firefox-manifest`\n\nAfter that:\n\n1. Open Firefox\n1. Enter \"about:debugging\" in the URL bar\n1. Click \"This Firefox\" on the left side\n1. Click \"Load Temporary Add-on\"\n1. Open the Vimium directory you've cloned from Github, and select any file inside.\n\n### Running the tests\n\nOur tests use [shoulda.js](https://github.com/philc/shoulda.js) and\n[Puppeteer](https://github.com/puppeteer/puppeteer). To run the tests:\n\n1. Install [Deno](https://deno.land/) if you don't have it already.\n2. `deno run -A npm:puppeteer browsers install chrome` to install puppeteer\n3. `./make.js test` to build the code and run the tests.\n\n### Coding Style\n\n- Run `deno fmt` at the root of the Vimium project to format your code.\n- We generally follow the recommendations from the\n  [Airbnb Javascript style guide](https://github.com/airbnb/javascript).\n- We wrap lines at 100 characters.\n- When writing comments, uppercase the first letter of your sentence, and put a period at the end.\n- We're currently using JavaScript language features from ES2018 or earlier. If we desire to use\n  something introduced in a later version of JavaScript, we need to remember to update the minimum\n  Chrome and Firefox versions required.\n"
  },
  {
    "path": "CREDITS",
    "content": "Authors & Maintainers:\n  Ilya Sukhar <ilya.sukhar@gmail.com> (github: ilya)\n  Phil Crosby <phil.crosby@gmail.com> (github: philc)\n\nContributors:\n  acrollet\n  Adam Lindberg <hello@alind.io> (github: eproxus)\n  akhilman\n  Ângelo Otávio Nuffer Nunes <angelonuffer@gmail.com> (github: angelonuffer)\n  Bernardo B. Marques <bernardo.fire@gmail.com> (github: bernardofire)\n  Bill Casarin <jb@jb55.com> (github: jb55)\n  Bill Mill (github: llimllib)\n  Branden Rolston <brolston@gmail.com> (github: branden)\n  Caleb Spare <cespare@gmail.com> (github: cespare)\n  Carl Helmertz <helmertz@gmail.com> (github: chelmertz)\n  Christian Stefanescu (github: stchris)\n  ConradIrwin\n  Daniel MacDougall <dmacdougall@gmail.com> (github: dmacdougall)\n  drizzd\n  gpurkins\n  hogelog\n  int3\n  Johannes Emerich (github: knuton)\n  Julian Naydichev <rublind@gmail.com> (github: naydichev)\n  Justin Blake <justin@hentzia.com> (github: blaix)\n  Knorkebrot\n  lack\n  markstos\n  Matthew Cline <matt@nightrealms.com>\n  Matt Garriott (github: mgarriott)\n  Matthew Ryan (github: mrmr1993)\n  Michael Hauser-Raspe (github: mijoharas)\n  Murph (github: pandeiro)\n  Niklas Baumstark <niklas.baumstark@gmail.com> (github: niklasb)\n  rodimius\n  Stephen Blott (github: smblott-github)\n  Svein-Erik Larsen <feinom@gmail.com> (github: feinom)\n  Tim Morgan <tim@timmorgan.org> (github: seven1m)\n  tsigo\n  R.T. Lechow <rtlechow@gmail.com> (github: rtlechow)\n  Wang Ning <daning106@gmail.com> (github:daning)\n  Werner Laurensse (github: ab3)\n  Timo Sand <timo.j.sand@gmail.com> (github: deiga)\n  Shiyong Chen <billbill290@gmail.com> (github: UncleBill)\n  Utkarsh Upadhyay <musically.ut@gmail.com) (github: musically-ut)\n  Michael Salihi <admin@prestance-informatique.fr> (github: PrestanceDesign)\n  Dahan Gong <gdh1995@qq.com> (github: gdh1995)\n  Scott Pinkelman <scott@scottpinkelman.com> (github: sco-tt)\n  Darryl Pogue <darryl@dpogue.ca> (github: dpogue)\n  tobimensch\n  Ramiro Araujo <rama.araujo@gmail.com> (github: ramiroaraujo)\n  Daniel Skogly <daniel@wishy.gift> (github: poacher2k)\n  Matt Wanchap <matt@wanchap.com> (github: mwanchap)\n  Leo Solidum <leo.g.solidum@gmail.com> (github: leosolid)\n\nFeel free to add real names in addition to GitHub usernames.\n"
  },
  {
    "path": "MIT-LICENSE.txt",
    "content": "Copyright (c) 2010 Phil Crosby, Ilya Sukhar.\n\nPermission is hereby granted, free of charge, to any person\nobtaining a copy of this software and associated documentation\nfiles (the \"Software\"), to deal in the Software without\nrestriction, including without limitation the rights to use,\ncopy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES\nOF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT\nHOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,\nWHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR\nOTHER DEALINGS IN THE SOFTWARE."
  },
  {
    "path": "README.md",
    "content": "# Vimium - The Hacker's Browser\n\nVimium is a browser extension that provides keyboard-based navigation and control of the web in the\nspirit of the Vim editor.\n\n[Watch the demo video](https://www.youtube.com/watch?v=t67Sn0RGK54).\n\n**Installation instructions:**\n\n- Chrome:\n  [Chrome web store](https://chromewebstore.google.com/detail/vimium/dbepggeogbaibhgnhhndojpepiihcmeb)\n- Edge:\n  [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/vimium/djmieaghokpkpjfbpelnlkfgfjapaopa)\n- Firefox: [Firefox Add-ons](https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/)\n\nTo install from source, see [here](CONTRIBUTING.md#installing-from-source).\n\nVimium's Options page can be reached via a link on the help dialog (type `?`) or via the button next\nto Vimium on the extension pages of Chrome and Edge (`chrome://extensions`), or Firefox\n(`about:addons`).\n\n## Keyboard Bindings\n\nModifier keys are specified as `<c-x>`, `<m-x>`, and `<a-x>` for ctrl+x, meta+x, and alt+x\nrespectively. For shift+x and ctrl-shift-x, just type `X` and `<c-X>`. See the next section for how\nto customize these bindings.\n\nOnce you have Vimium installed, you can see this list of key bindings at any time by typing `?`.\n\nNavigating the current page:\n\n    ?       show the help dialog for a list of all available keys\n    h       scroll left\n    j       scroll down\n    k       scroll up\n    l       scroll right\n    gg      scroll to top of the page\n    G       scroll to bottom of the page\n    d       scroll down half a page\n    u       scroll up half a page\n    f       open a link in the current tab\n    F       open a link in a new tab\n    r       reload\n    gs      view source\n    i       enter insert mode -- all commands will be ignored until you hit Esc to exit\n    yy      copy the current url to the clipboard\n    yf      copy a link url to the clipboard\n    gf      cycle forward to the next frame\n    gF      focus the main/top frame\n\nNavigating to new pages:\n\n    o       Open URL, bookmark, or history entry\n    O       Open URL, bookmark, history entry in a new tab\n    b       Open bookmark\n    B       Open bookmark in a new tab\n\nUsing find:\n\n    /       enter find mode\n              -- type your search query and hit enter to search, or Esc to cancel\n    n       cycle forward to the next find match\n    N       cycle backward to the previous find match\n\nFor advanced usage, see [regular expressions](https://github.com/philc/vimium/wiki/Find-Mode) on the\nwiki.\n\nNavigating your history:\n\n    H       go back in history\n    L       go forward in history\n\nManipulating tabs:\n\n    J, gT   go one tab left\n    K, gt   go one tab right\n    g0      go to the first tab. Use ng0 to go to n-th tab\n    g$      go to the last tab\n    ^       visit the previously-visited tab\n    t       create tab\n    yt      duplicate current tab\n    x       close current tab\n    X       restore closed tab (i.e. unwind the 'x' command)\n    T       search through your open tabs\n    W       move current tab to new window\n    <a-p>   pin/unpin current tab\n\nUsing marks:\n\n    ma, mA  set local mark \"a\" (global mark \"A\")\n    `a, `A  jump to local mark \"a\" (global mark \"A\")\n    ``      jump back to the position before the previous jump\n              -- that is, before the previous gg, G, n, N, / or `a\n\nAdditional advanced browsing commands:\n\n    ]], [[  Follow the link labeled 'next' or '>' ('previous' or '<')\n              - helpful for browsing paginated sites\n    <a-f>   open multiple links in a new tab\n    gi      focus the first (or n-th) text input box on the page. Use <tab> to cycle through options.\n    gu      go up one level in the URL hierarchy\n    gU      go up to root of the URL hierarchy\n    ge      edit the current URL\n    gE      edit the current URL and open in a new tab\n    zH      scroll all the way left\n    zL      scroll all the way right\n    v       enter visual mode; use p/P to paste-and-go, use y to yank\n    V       enter visual line mode\n    R       Hard reload the page (skip the cache)\n\nVimium supports command repetition so, for example, hitting `5t` will open 5 tabs in rapid\nsuccession. `<Esc>` (or `<c-[>`) will clear any partial commands in the queue and will also exit\ninsert and find modes.\n\nThere are additional commands which aren't included in this README; refer to the help dialog (type\n`?`) for a full list.\n\n## Custom Key Mappings\n\nYou may remap or unmap any of the default key bindings in the \"Custom key mappings\" on the options\npage.\n\nEnter one of the following key mapping statements per line:\n\n- `map key command`: Maps a key to a Vimium command. Overrides Chrome's default behavior for that\n  key, if any.\n- `unmap key`: Unmaps a key and restores Chrome's default behavior (if any).\n- `unmapAll`: Unmaps all bindings. This is useful if you want to completely wipe Vimium's defaults\n  and start from scratch with your own setup.\n\nExamples:\n\n- `map <c-d> scrollPageDown` maps ctrl+d to scrolling the page down. Chrome's default behavior of\n  showing a bookmark dialog is suppressed.\n- `map r reload hard` maps the r key to reloading the page, and also includes the \"hard\" option to\n  hard-reload the page.\n- `unmap <c-d>` removes any mapping for ctrl+d and restores Chrome's default behavior.\n- `unmap r` removes any mapping for the r key.\n\nSee the [docs](https://vimium.github.io/commands/) for every Vimium command and its options.\n\nYou can add comments to key mappings by starting a line with `\"` or `#`.\n\nThe following special keys are available for mapping:\n\n- `<c-*>`, `<a-*>`, `<s-*>`, `<m-*>` for ctrl, alt, shift, and meta (command on Mac) respectively\n  with any key. Replace `*` with the key of choice.\n- `<left>`, `<right>`, `<up>`, `<down>` for the arrow keys.\n- `<f1>` through `<f12>` for the function keys.\n- `<space>` for the space key.\n- `<tab>`, `<enter>`, `<delete>`, `<backspace>`, `<insert>`, `<home>` and `<end>` for the\n  corresponding non-printable keys.\n\nShifts are automatically detected so, for example, `<c-&>` corresponds to ctrl+shift+7 on an English\nkeyboard.\n\n## How to allow Vimium to work on new tab pages\n\n- Vimium will work on new tab pages which are opened with Vimium's `createTab` command (mapped to\n  `t` by default).\n- To have Vimium work on <em>all</em> new tab pages opened by the browser (e.g. via `cmd-t` or\n  `ctrl-t` shortcuts), a companion\n  [Vimium New Tab Page extension](https://github.com/philc/vimium-new-tab/) is required.\n- Once that is installed, all new tabs will open a blank Vimium new tab page.\n\n## More documentation\n\n- [FAQ](https://github.com/philc/vimium/wiki/FAQ)\n- [Command listing](https://vimium.github.io/commands/)\n- [Vimium's GitHub wiki](https://github.com/philc/vimium/wiki): documentation for the more advanced\n  features.\n\n## Contributing\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md) for details.\n\n## Release Notes\n\nSee [CHANGELOG](CHANGELOG.md) for the major changes in each release.\n\n## License\n\nCopyright (c) Phil Crosby, Ilya Sukhar. See [MIT-LICENSE.txt](MIT-LICENSE.txt) for details.\n"
  },
  {
    "path": "background_scripts/all_commands.js",
    "content": "// This is the order they will be shown in the help dialog.\n//\n// Properties:\n// - advanced: advanced commands are not shown in the help dialog by default.\n// - background: whether this command has to be run by the background page.\n// - desc: shown in the help dialog and command listing page.\n// - details: extra help information that will only be shown on the command listing page.\n// - group: commands are displayed in groups in the help dialog and command listing.\n// - noRepeat: whether this command can be used with a count key prefix.\n// - repeatLimit: the number of allowed repetitions of this command before the user is prompted for\n//   confirmation.\n// - topFrame: whether this command must be run only in the top frame of a page.\n//\nconst allCommands = [\n  //\n  // Navigation\n  //\n\n  {\n    name: \"scrollDown\",\n    desc: \"Scroll down\",\n    group: \"navigation\",\n  },\n\n  {\n    name: \"scrollUp\",\n    desc: \"Scroll up\",\n    group: \"navigation\",\n  },\n\n  {\n    name: \"scrollToTop\",\n    desc: \"Scroll to the top of the page\",\n    group: \"navigation\",\n  },\n\n  {\n    name: \"scrollToBottom\",\n    desc: \"Scroll to the bottom of the page\",\n    group: \"navigation\",\n  },\n\n  {\n    name: \"scrollPageDown\",\n    desc: \"Scroll a half page down\",\n    group: \"navigation\",\n  },\n\n  {\n    name: \"scrollPageUp\",\n    desc: \"Scroll a half page up\",\n    group: \"navigation\",\n  },\n\n  {\n    name: \"scrollFullPageDown\",\n    desc: \"Scroll a full page down\",\n    group: \"navigation\",\n  },\n\n  {\n    name: \"scrollFullPageUp\",\n    desc: \"Scroll a full page up\",\n    group: \"navigation\",\n  },\n\n  {\n    name: \"scrollLeft\",\n    desc: \"Scroll left\",\n    group: \"navigation\",\n  },\n\n  {\n    name: \"scrollRight\",\n    desc: \"Scroll right\",\n    group: \"navigation\",\n    advanced: true,\n  },\n\n  {\n    name: \"scrollToLeft\",\n    desc: \"Scroll all the way to the left\",\n    group: \"navigation\",\n    advanced: true,\n  },\n\n  {\n    name: \"scrollToRight\",\n    desc: \"Scroll all the way to the right\",\n    group: \"navigation\",\n  },\n\n  {\n    name: \"reload\",\n    desc: \"Reload the page\",\n    group: \"navigation\",\n    background: true,\n    options: {\n      hard: \"Perform a hard reload, forcing the browser to bypass its cache.\",\n    },\n  },\n\n  {\n    name: \"copyCurrentUrl\",\n    desc: \"Copy the current URL to the clipboard\",\n    group: \"navigation\",\n    noRepeat: true,\n  },\n\n  {\n    name: \"openCopiedUrlInCurrentTab\",\n    desc: \"Open the clipboard's URL in the current tab\",\n    group: \"navigation\",\n    noRepeat: true,\n  },\n\n  {\n    name: \"openCopiedUrlInNewTab\",\n    desc: \"Open the clipboard's URL in a new tab\",\n    group: \"navigation\",\n    noRepeat: true,\n    options: {\n      position: \"Where to place the tab in the tab bar. \" +\n        \"One of `start`, `before`, `after`, `end`. `after` is the default.\",\n    },\n  },\n\n  {\n    name: \"goUp\",\n    desc: \"Go up the URL hierarchy\",\n    group: \"navigation\",\n    advanced: true,\n  },\n\n  {\n    name: \"goToRoot\",\n    desc: \"Go to the root of current URL hierarchy\",\n    group: \"navigation\",\n    advanced: true,\n  },\n\n  {\n    name: \"enterInsertMode\",\n    desc: \"Enter insert mode\",\n    group: \"navigation\",\n    noRepeat: true,\n  },\n\n  {\n    name: \"enterVisualMode\",\n    desc: \"Enter visual mode\",\n    group: \"navigation\",\n    noRepeat: true,\n  },\n\n  {\n    name: \"enterVisualLineMode\",\n    desc: \"Enter visual line mode\",\n    group: \"navigation\",\n    advanced: true,\n    noRepeat: true,\n  },\n\n  {\n    name: \"passNextKey\",\n    desc: \"Pass the next key to the page\",\n    options: {\n      normal: \"Optional. Enter Vimium's normal mode, and ignore any defined pass keys.\",\n    },\n    group: \"navigation\",\n    advanced: true,\n  },\n\n  {\n    name: \"focusInput\",\n    desc: \"Focus the first text input on the page\",\n    group: \"navigation\",\n  },\n\n  {\n    name: \"LinkHints.activateMode\",\n    desc: \"Open a link in the current tab\",\n    options: {\n      action: \"one of `hover`, `focus`, `copy-text`. When a link is selected, \" +\n        \"instead of clicking on the link, perform the specified action.\",\n    },\n    group: \"navigation\",\n    advanced: true,\n  },\n\n  {\n    name: \"LinkHints.activateModeToOpenInNewTab\",\n    desc: \"Open a link in a new tab\",\n    group: \"navigation\",\n  },\n\n  {\n    name: \"LinkHints.activateModeToOpenInNewForegroundTab\",\n    desc: \"Open a link in a new tab & switch to it\",\n    group: \"navigation\",\n  },\n\n  {\n    name: \"LinkHints.activateModeWithQueue\",\n    desc: \"Open multiple links in a new tab\",\n    group: \"navigation\",\n    advanced: true,\n    noRepeat: true,\n  },\n\n  {\n    name: \"LinkHints.activateModeToDownloadLink\",\n    desc: \"Download link url\",\n    group: \"navigation\",\n    advanced: true,\n  },\n\n  {\n    name: \"LinkHints.activateModeToOpenIncognito\",\n    desc: \"Open a link in incognito window\",\n    group: \"navigation\",\n    advanced: true,\n  },\n\n  {\n    name: \"LinkHints.activateModeToCopyLinkUrl\",\n    desc: \"Copy a link URL to the clipboard\",\n    group: \"navigation\",\n    advanced: true,\n  },\n\n  {\n    name: \"goPrevious\",\n    desc: \"Follow the link labeled previous or <\",\n    group: \"navigation\",\n    advanced: true,\n    noRepeat: true,\n  },\n\n  {\n    name: \"goNext\",\n    desc: \"Follow the link labeled next or >\",\n    group: \"navigation\",\n    advanced: true,\n    noRepeat: true,\n  },\n\n  {\n    name: \"nextFrame\",\n    desc: \"Select the next frame on the page\",\n    group: \"navigation\",\n    background: true,\n  },\n\n  {\n    name: \"mainFrame\",\n    desc: \"Select the page's main/top frame\",\n    group: \"navigation\",\n    topFrame: true,\n    noRepeat: true,\n  },\n\n  {\n    name: \"Marks.activateCreateMode\",\n    desc: \"Create a new mark\",\n    details: \"Do this by typing the key bound to this command, and then a letter. \" +\n      \"This will set a mark bound to that letter. Lowercase letters are local marks and uppercase \" +\n      \"letters are global marks.\",\n    options: {\n      swap: \"Swap global and local marks. This option exists because in a browser, global marks \" +\n        \"are generally more useful than local marks, and so it may be desirable to make lowercase \" +\n        \"letters represent global marks rather than local marks.\",\n    },\n    group: \"navigation\",\n    advanced: true,\n    noRepeat: true,\n  },\n\n  {\n    name: \"Marks.activateGotoMode\",\n    desc: \"Jump to a mark\",\n    options: {\n      swap: \"Swap global and local marks. This option exists because in a browser, global marks \" +\n        \"are generally more useful than local marks, and so it may be desirable to make lowercase \" +\n        \"letters represent global marks rather than local marks.\",\n    },\n    group: \"navigation\",\n    advanced: true,\n    noRepeat: true,\n  },\n\n  //\n  // Vomnibar\n  //\n\n  {\n    name: \"Vomnibar.activate\",\n    desc: \"Open URL, bookmark or history entry\",\n    options: {\n      query: \"The text to prefill the Vomnibar with.\",\n      keyword: 'The keyword of a search engine defined in the \"Custom search engines\" ' +\n        \"section of the Vimium Options page. The Vomnibar will be scoped to use that search engine.\",\n    },\n    group: \"vomnibar\",\n    topFrame: true,\n  },\n\n  {\n    name: \"Vomnibar.activateInNewTab\",\n    desc: \"Open URL, bookmark or history entry in a new tab\",\n    group: \"vomnibar\",\n    options: {\n      query: \"The text to prefill the Vomnibar with.\",\n      keyword: 'The keyword of a search engine defined in the \"Custom search engines\" ' +\n        \"section of the Vimium Options page. The Vomnibar will be scoped to use that search engine.\",\n    },\n    topFrame: true,\n  },\n\n  {\n    name: \"Vomnibar.activateBookmarks\",\n    desc: \"Open a bookmark\",\n    group: \"vomnibar\",\n    options: {\n      query: \"The text to prefill the Vomnibar with.\",\n    },\n    topFrame: true,\n  },\n\n  {\n    name: \"Vomnibar.activateBookmarksInNewTab\",\n    desc: \"Open a bookmark in a new tab\",\n    group: \"vomnibar\",\n    options: {\n      query: \"The text to prefill the Vomnibar with.\",\n    },\n    topFrame: true,\n  },\n\n  {\n    name: \"Vomnibar.activateTabSelection\",\n    desc: \"Search through your open tabs\",\n    group: \"vomnibar\",\n    topFrame: true,\n  },\n\n  {\n    name: \"Vomnibar.activateEditUrl\",\n    desc: \"Edit the current URL\",\n    group: \"vomnibar\",\n    topFrame: true,\n  },\n\n  {\n    name: \"Vomnibar.activateEditUrlInNewTab\",\n    desc: \"Edit the current URL and open in a new tab\",\n    group: \"vomnibar\",\n    topFrame: true,\n  },\n\n  //\n  // Find\n  //\n\n  {\n    name: \"enterFindMode\",\n    desc: \"Enter find mode.\",\n    group: \"find\",\n    noRepeat: true,\n  },\n\n  {\n    name: \"performFind\",\n    desc: \"Cycle forward to the next find match\",\n    group: \"find\",\n  },\n\n  {\n    name: \"performBackwardsFind\",\n    desc: \"Cycle backward to the previous find match\",\n    group: \"find\",\n  },\n\n  {\n    name: \"findSelected\",\n    desc: \"Find the selected text\",\n    group: \"find\",\n    advanced: true,\n  },\n\n  {\n    name: \"findSelectedBackwards\",\n    desc: \"Find the selected text, searching backwards\",\n    group: \"find\",\n    advanced: true,\n  },\n\n  //\n  // History\n  //\n\n  {\n    name: \"goBack\",\n    desc: \"Go back in history\",\n    group: \"history\",\n  },\n\n  {\n    name: \"goForward\",\n    desc: \"Go forward in history\",\n    group: \"history\",\n  },\n\n  //\n  // Tabs\n  //\n\n  {\n    name: \"createTab\",\n    desc: \"Create new tab\",\n    options: {\n      \"(any url)\": \"Open this URL, rather than the browser's new tab page. \" +\n        \"E.g.: `map X createTab https://example.com`\",\n      window: \"Create the tab in a new window\",\n      incognito: \"Create the tab in an incognito window\",\n      position: \"Where to place the tab in the tab bar. \" +\n        \"One of `start`, `before`, `after`, `end`. `after` is the default.\",\n    },\n    group: \"tabs\",\n    background: true,\n    repeatLimit: 20,\n  },\n\n  {\n    name: \"previousTab\",\n    desc: \"Go one tab left\",\n    group: \"tabs\",\n    background: true,\n  },\n\n  {\n    name: \"nextTab\",\n    desc: \"Go one tab right\",\n    group: \"tabs\",\n    background: true,\n  },\n\n  {\n    name: \"visitPreviousTab\",\n    desc: \"Go to previously-visited tab\",\n    group: \"tabs\",\n    background: true,\n  },\n\n  {\n    name: \"firstTab\",\n    desc: \"Go to the first tab\",\n    group: \"tabs\",\n    background: true,\n  },\n\n  {\n    name: \"lastTab\",\n    desc: \"Go to the last tab\",\n    group: \"tabs\",\n    background: true,\n  },\n\n  {\n    name: \"duplicateTab\",\n    desc: \"Duplicate current tab\",\n    group: \"tabs\",\n    background: true,\n    repeatLimit: 20,\n  },\n\n  {\n    name: \"togglePinTab\",\n    desc: \"Pin or unpin current tab\",\n    group: \"tabs\",\n    background: true,\n  },\n\n  {\n    name: \"toggleMuteTab\",\n    desc: \"Mute or unmute current tab\",\n    options: {\n      all: \"Mute all tabs.\",\n      other: \"Mute every tab except the current one.\",\n    },\n    group: \"tabs\",\n    background: true,\n    noRepeat: true,\n  },\n\n  {\n    name: \"removeTab\",\n    desc: \"Close current tab\",\n    group: \"tabs\",\n    background: true,\n    // Don't close (in one command invocation) more than the number of tabs that can be re-opened by\n    // the browser.\n    repeatLimit: chrome.sessions?.MAX_SESSION_RESULTS || 25,\n  },\n\n  {\n    name: \"restoreTab\",\n    desc: \"Restore closed tab\",\n    group: \"tabs\",\n    background: true,\n    repeatLimit: 20,\n  },\n\n  {\n    name: \"moveTabToNewWindow\",\n    desc: \"Move tab to new window\",\n    group: \"tabs\",\n    advanced: true,\n    background: true,\n  },\n\n  {\n    name: \"closeTabsOnLeft\",\n    desc: \"Close tabs on the left\",\n    group: \"tabs\",\n    advanced: true,\n    background: true,\n  },\n\n  {\n    name: \"closeTabsOnRight\",\n    desc: \"Close tabs on the right\",\n    group: \"tabs\",\n    advanced: true,\n    background: true,\n  },\n\n  {\n    name: \"closeOtherTabs\",\n    desc: \"Close all other tabs\",\n    group: \"tabs\",\n    advanced: true,\n    background: true,\n    noRepeat: true,\n  },\n\n  {\n    name: \"moveTabLeft\",\n    desc: \"Move tab to the left\",\n    group: \"tabs\",\n    advanced: true,\n    background: true,\n  },\n\n  {\n    name: \"moveTabRight\",\n    desc: \"Move tab to the right\",\n    group: \"tabs\",\n    advanced: true,\n    background: true,\n  },\n\n  {\n    name: \"setZoom\",\n    desc: \"Set zoom\",\n    group: \"tabs\",\n    advanced: true,\n    background: true,\n    options: {\n      level: \"The zoom level. This can be a range of [0.25, 5.0]. 1.0 is the default.\",\n    },\n  },\n\n  {\n    name: \"zoomIn\",\n    desc: \"Zoom in\",\n    group: \"tabs\",\n    advanced: true,\n    background: true,\n  },\n\n  {\n    name: \"zoomOut\",\n    desc: \"Zoom out\",\n    group: \"tabs\",\n    advanced: true,\n    background: true,\n  },\n\n  {\n    name: \"zoomReset\",\n    desc: \"Reset zoom\",\n    group: \"tabs\",\n    advanced: true,\n    background: true,\n  },\n\n  //\n  // Misc\n  //\n\n  {\n    name: \"toggleViewSource\",\n    desc: \"View page source\",\n    group: \"misc\",\n    advanced: true,\n    noRepeat: true,\n  },\n\n  {\n    name: \"showHelp\",\n    desc: \"Show help\",\n    group: \"misc\",\n    noRepeat: true,\n    topFrame: true,\n  },\n];\n\nexport { allCommands };\n"
  },
  {
    "path": "background_scripts/bg_utils.js",
    "content": "import { TabRecency } from \"./tab_recency.js\";\n\n// We're using browser.runtime to determine the browser name and version for Firefox. That API is\n// only available on the background page. We're not using window.navigator because it's unreliable.\n// Sometimes browser vendors will provide fake values, like when `privacy.resistFingerprinting` is\n// enabled on `about:config` of Firefox.\nexport function isFirefox() {\n  // We want this browser check to also cover Firefox variants, like LibreWolf. See #3773.\n  // We could also just check browserInfo.name against Firefox and Librewolf.\n  return globalThis.browser?.runtime.getURL(\"\").startsWith(\"moz\") ?? false;\n}\n\nexport async function getFirefoxVersion() {\n  return isFirefox() ? (await browser.runtime.getBrowserInfo()).version : null;\n}\n\n// TODO(philc): tabRecency imports bg_utils. We should resovle the cycle for the sake of clarity.\nexport const tabRecency = new TabRecency();\ntabRecency.init();\n"
  },
  {
    "path": "background_scripts/commands.js",
    "content": "import { allCommands } from \"./all_commands.js\";\n\n// A specification for a command that's currently bound to a key sequence, as defined by the default\n// key bindings, or as it appears in the user's keymapping settings.\nexport class RegistryEntry {\n  // Array of keys.\n  keySequence;\n  // Name of the command.\n  command;\n  // Whether this command can be used with a count key prefix.\n  noRepeat;\n  // The number of allowed repetitions of this command before the user is prompted for confirmation.\n  repeatLimit;\n  // Whether this command has to be run by the background page.\n  background;\n  // Whether this command must be run only in the top frame of a page.\n  topFrame;\n  // The map of options for this command. This is a parsed, sanitized version of the user's options\n  // for this command.\n  options;\n\n  constructor(o) {\n    Object.seal(this);\n    if (o) Object.assign(this, o);\n  }\n}\n\n// This is intentionally a superset of valid modifiers (a, c, m, s).\nconst modifier = \"(?:[a-zA-Z]-)\";\nconst namedKey = \"(?:[a-z][a-z0-9]+)\"; // E.g. \"left\" or \"f12\" (always two characters or more).\nconst modifiedKey = `(?:${modifier}+(?:.|${namedKey}))`; // E.g. \"c-*\" or \"c-left\".\nconst specialKeyRegexp = new RegExp(`^<(${namedKey}|${modifiedKey})>(.*)`, \"i\");\n\n// Remove comments and leading/trailing whitespace from a list of lines, and merge lines where the\n// last character on the preceding line is \"\\\".\nfunction parseLines(text) {\n  return text.replace(/\\\\\\n/g, \"\")\n    .split(\"\\n\")\n    .map((line) => line.trim())\n    .filter((line) => (line.length > 0) && !(Array.from('#\"').includes(line[0])));\n}\n\n// Returns the index of the nth occurrence of the regexp in the string. -1 if not found.\nfunction nthRegexIndex(str, regex, n) {\n  if (!regex.global) {\n    regex = new RegExp(regex.source, regex.flags + \"g\");\n  }\n  let match;\n  let count = 0;\n  while ((match = regex.exec(str)) !== null) {\n    count++;\n    if (count === n) {\n      return match.index;\n    }\n    // Prevent infinite loop for zero-length matches.\n    if (match.index === regex.lastIndex) {\n      regex.lastIndex++;\n    }\n  }\n  return -1;\n}\n\nconst KeyMappingsParser = {\n  // Parses the text supplied by the user in their \"keyMappings\" setting.\n  // - shouldLogWarnings: if true, logs to the console when part of the user's config is invalid.\n  // Returns { keyToRegistryEntry, keyToMappedKey, validationErrors }.\n  parse(configText, shouldLogWarnings) {\n    let keyToRegistryEntry = {};\n    let mapKeyRegistry = {};\n    let errors = [];\n    const configLines = parseLines(configText);\n    const commandsByName = Utils.keyBy(allCommands, \"name\");\n\n    const validModifiers = [\"a\", \"c\", \"m\", \"s\"];\n    const validateParsedKey = function (key) {\n      if (!key?.match(modifiedKey)) return;\n      // Check that the modifier is valid and not capitalized.\n      const mod = key.split(\"-\")[0].slice(1);\n      if (!validModifiers.includes(mod)) {\n        return `${key} has an invalid modifier; valid modifiers are ${validModifiers}`;\n      }\n    };\n    const validateUrl = function (str) {\n      try {\n        new URL(str);\n        return true;\n      } catch {\n        return false;\n      }\n    };\n\n    for (const line of configLines) {\n      const tokens = line.split(/\\s+/);\n      const action = tokens[0].toLowerCase();\n      switch (action) {\n        case \"map\": {\n          if (tokens.length < 3) {\n            errors.push(`\"map requires at least 2 arguments on line ${line}`);\n            continue;\n          }\n          const [_, key, command] = tokens;\n          let optionString;\n          const optionsStart = nthRegexIndex(line, /\\s+/, 3);\n          if (optionsStart == -1) {\n            optionString = \"\";\n          } else {\n            optionString = line.slice(optionsStart).trim();\n          }\n          const commandInfo = commandsByName[command];\n          if (!commandInfo) {\n            errors.push(`\"${command}\" is not a valid command in the line: ${line}`);\n            continue;\n          }\n          const keySequence = this.parseKeySequence(key);\n          const keyErrors = keySequence.map((k) => validateParsedKey(k)).filter((e) => e);\n          if (keyErrors.length > 0) {\n            errors = errors.concat(keyErrors);\n            continue;\n          }\n          const options = this.parseCommandOptions(optionString);\n          const allowedOptions = Object.keys(commandInfo.options || {});\n          if (!commandInfo.noRepeat) {\n            allowedOptions.push(\"count\");\n          }\n          let hasUnknownOption = false;\n          for (const option of Object.keys(options)) {\n            if (allowedOptions.includes(option)) continue;\n            if (allowedOptions.includes(\"(any url)\")) {\n              // Since this command allows for any URL as an argument, we perform some basic\n              // validation to ensure the provided option string is indeed a URL.\n              if (validateUrl(option)) continue;\n              hasUnknownOption = true;\n              errors.push(\n                `Command ${command} does not support option ${option}. ` +\n                  `Is this meant to be a valid URL?`,\n              );\n              break;\n            } else {\n              hasUnknownOption = true;\n              errors.push(`Command ${command} does not support option ${option}`);\n              break;\n            }\n          }\n          if (hasUnknownOption) break;\n          keyToRegistryEntry[key] = new RegistryEntry({\n            keySequence,\n            command,\n            noRepeat: commandInfo.noRepeat,\n            repeatLimit: commandInfo.repeatLimit,\n            background: commandInfo.background,\n            topFrame: commandInfo.topFrame,\n            options,\n          });\n          break;\n        }\n        case \"unmap\": {\n          if (tokens.length != 2) {\n            errors.push(`Incorrect usage for unmap in the line: ${line}`);\n            continue;\n          }\n          const key = tokens[1];\n          delete keyToRegistryEntry[key];\n          delete mapKeyRegistry[key];\n          break;\n        }\n        case \"unmapall\": {\n          keyToRegistryEntry = {};\n          mapKeyRegistry = {};\n          break;\n        }\n        case \"mapkey\": {\n          if (tokens.length != 3) {\n            errors.push(`Incorrect usage for mapKey in the line: ${line}`);\n            continue;\n          }\n          const fromChar = this.parseKeySequence(tokens[1]);\n          const toChar = this.parseKeySequence(tokens[2]);\n          // NOTE(philc): I'm not sure why we enforce that the fromChar and toChar have to be\n          // length one. It's been that way since this feature was introduced in 6596e30.\n          const isValid = fromChar.length == toChar.length && toChar.length === 1;\n          if (isValid) {\n            mapKeyRegistry[fromChar[0]] = toChar[0];\n          } else {\n            errors.push(\n              `mapkey only supports mapping keys which are single characters. Line: ${line}`,\n            );\n          }\n          break;\n        }\n        default:\n          errors.push(`\"${action}\" is not a valid config command in line: ${line}`);\n      }\n    }\n\n    return {\n      keyToRegistryEntry,\n      keyToMappedKey: mapKeyRegistry,\n      validationErrors: errors,\n    };\n  },\n\n  // Lower-case the appropriate portions of named keys.\n  //\n  // A key name is one of three forms exemplified by <c-a> <left> or <c-f12> (prefixed normal key,\n  // named key, or prefixed named key). Internally, for simplicity, we would like prefixes and key\n  // names to be lowercase, though humans may prefer other forms <Left> or <C-a>.\n  // On the other hand, <c-a> and <c-A> are different named keys - for one of them you have to press\n  // \"shift\" as well.\n  // We sort modifiers here to match the order used in keyboard_utils.js.\n  // The return value is a sequence of keys: e.g. \"<Space><c-A>b\" -> [\"<space>\", \"<c-A>\", \"b\"].\n  parseKeySequence(key) {\n    if (key.length === 0) {\n      return [];\n      // Parse \"<c-a>bcd\" as \"<c-a>\" and \"bcd\".\n    } else if (0 === key.search(specialKeyRegexp)) {\n      const array = RegExp.$1.split(\"-\");\n      const adjustedLength = Math.max(array.length, 1);\n      let modifiers = array.slice(0, adjustedLength - 1);\n      let keyChar = array[adjustedLength - 1];\n      if (keyChar.length !== 1) {\n        keyChar = keyChar.toLowerCase();\n      }\n      modifiers = modifiers.map((m) => m.toLowerCase());\n      modifiers.sort();\n      return [\n        \"<\" + modifiers.concat([keyChar]).join(\"-\") + \">\",\n        ...this.parseKeySequence(RegExp.$2),\n      ];\n    } else {\n      return [key[0], ...this.parseKeySequence(key.slice(1))];\n    }\n  },\n\n  // Command options follow command mappings, and are of one of these forms:\n  //   key=value     - a value\n  //   key=\"value\"   - a value surrounded by quotes\n  //   key           - a flag\n  parseCommandOptions(optionString) {\n    const options = {};\n    while (optionString != \"\") {\n      // Note that option names are allowed to be letters only; no numbers.\n      let match, matchedString, key, value;\n      // Case: option value surrounded by quotes (key= \"a b\"). Spaces are allowed in the value.\n      if (match = optionString.match(/^([a-zA-Z]+)=\"([^\"]+)\"(\\s+|$)/)) {\n        matchedString = match[0];\n        key = match[1];\n        value = match[2];\n      } // Case: option value not surrounded by quotes (key=value). Spaces aren't allowed.\n      else if (match = optionString.match(/^([a-zA-Z]+)=(\\S+)(\\s+|$)/)) {\n        matchedString = match[0];\n        key = match[1];\n        value = match[2];\n      } // Case: single option (flag), or \"any URL\". This correctly parses URLs because URLs cannot\n      // contain unescaped equals or space characters. The key will be the option's name (or the\n      // URL), and the value will be true.\n      else if (match = optionString.match(/^([^\\s\"]+)(\\s+|$)/)) {\n        matchedString = match[0];\n        key = match[1];\n        value = true;\n      }\n      // NOTE(philc): If this string doesn't match any of our option regexps, we could throw an\n      // error here or use an assert. I think this might only happen in the case where there's a\n      // single equals sign. For now, just add the whole string as a flag option. If the command in\n      // question doesn't accept this option, then an error will get surfaced to the user.\n      if (match == null) {\n        console.log(`Warning: '${optionString}' isn't a valid option string.`);\n        options[optionString] = true;\n        break;\n      }\n\n      options[key] = value;\n      optionString = optionString.slice(matchedString.length);\n    }\n\n    // We parse any `count` option immediately (to avoid having to parse it repeatedly later).\n    if (\"count\" in options) {\n      options.count = parseInt(options.count);\n      if (isNaN(options.count)) {\n        delete options.count;\n      }\n    }\n\n    return options;\n  },\n};\n\nconst Commands = {\n  // A map of keyString => RegistryEntry\n  keyToRegistryEntry: null,\n  // A map of typed key => key it's mapped to (via the `mapkey` config statement).\n  mapKeyRegistry: null,\n\n  async init() {\n    await Settings.onLoaded();\n    Settings.addEventListener(\"change\", async () => {\n      await this.loadKeyMappings(Settings.get(\"keyMappings\"));\n    });\n    await this.loadKeyMappings(Settings.get(\"keyMappings\"));\n  },\n\n  // Parses the user's keyMapping config text and persists the parsed key mappings into the\n  // extension's storage, for use by the other parts of this extension.\n  async loadKeyMappings(userKeyMappingsConfigText) {\n    let key, command;\n    this.keyToRegistryEntry = {};\n    this.mapKeyRegistry = {};\n\n    const defaultKeyConfig = Object.keys(defaultKeyMappings).map((key) =>\n      `map ${key} ${defaultKeyMappings[key]}`\n    ).join(\"\\n\");\n\n    const parsed = KeyMappingsParser.parse(\n      defaultKeyConfig + \"\\n\" + userKeyMappingsConfigText,\n      true,\n    );\n    this.mapKeyRegistry = parsed.keyToMappedKey;\n    this.keyToRegistryEntry = parsed.keyToRegistryEntry;\n\n    await chrome.storage.session.set({ mapKeyRegistry: this.mapKeyRegistry });\n    await this.installKeyStateMapping();\n    this.prepareHelpPageData();\n\n    // Push the key mappings from any passNextKey commands into storage so that they're's available\n    // to the front end so they can be detected during insert mode. We exclude single-key mappings\n    // for this command (i.e. printable keys) because we're considering that a configuration error:\n    // when users press printable keys in insert mode, they expect that character to be input, not\n    // to be droppped into a special Vimium mode.\n    const passNextKeys = Object.entries(this.keyToRegistryEntry)\n      .filter(([key, v]) => v.command == \"passNextKey\" && key.length > 1)\n      .map(([key, v]) => key);\n    await chrome.storage.session.set({ passNextKeyKeys: passNextKeys });\n  },\n\n  // This generates and installs a nested key-to-command mapping structure. There is an example in\n  // mode_key_handler.js.\n  async installKeyStateMapping() {\n    const keyStateMapping = {};\n    for (const keys of Object.keys(this.keyToRegistryEntry || {})) {\n      const registryEntry = this.keyToRegistryEntry[keys];\n      let currentMapping = keyStateMapping;\n      for (let index = 0; index < registryEntry.keySequence.length; index++) {\n        const key = registryEntry.keySequence[index];\n        if (currentMapping[key] != null ? currentMapping[key].command : undefined) {\n          // Do not overwrite existing command bindings, they take priority. NOTE(smblott) This is\n          // the legacy behaviour.\n          break;\n        } else if (index < (registryEntry.keySequence.length - 1)) {\n          currentMapping = currentMapping[key] != null\n            ? currentMapping[key]\n            : (currentMapping[key] = {});\n        } else {\n          currentMapping[key] = Object.assign({}, registryEntry);\n          // We don't need these properties in the content scripts.\n          for (const prop of [\"keySequence\"]) {\n            delete currentMapping[key][prop];\n          }\n        }\n      }\n    }\n    await chrome.storage.session.set({\n      normalModeKeyStateMapping: keyStateMapping,\n      // Inform `KeyboardUtils.isEscape()` whether `<c-[>` should be interpreted as `Escape` (which it\n      // is by default).\n      useVimLikeEscape: !(\"<c-[>\" in keyStateMapping),\n    });\n  },\n\n  // Build the \"commandToOptionsToKeys\" data structure and place it in chrome's session storage.\n  // This is used by the help page and commands listing.\n  prepareHelpPageData() {\n    /*\n      Map of commands to option sets to keys to trigger that command option set.\n      Commands with no options will have the empty string options set.\n      Example:\n      {\n        \"zoomReset\": {\n          \"\": [\"z0\", \"zz\"] // No options, with two key maps, ie: `map zz zoomReset`\n        },\n        \"setZoom\": {\n          \"1.1\": [\"z1\"], // `map z1 setZoom 1.1`\n          \"1.2\": [\"z2\"], // `map z2 setZoom 1.2`\n        }\n      }\n    */\n    const commandToOptionsToKeys = {};\n    const formatOptionString = (options) => {\n      return Object.entries(options)\n        .map(([k, v]) => {\n          // When the value of an option is true, then it was parsed as a flag.\n          if (v === true) {\n            return k;\n          } else {\n            return `${k}=${v}`;\n          }\n        })\n        .join(\" \");\n    };\n    for (const key of Object.keys(this.keyToRegistryEntry || {})) {\n      const registryEntry = this.keyToRegistryEntry[key];\n      const optionString = formatOptionString(registryEntry.options || {});\n      commandToOptionsToKeys[registryEntry.command] ||= {};\n      commandToOptionsToKeys[registryEntry.command][optionString] ||= [];\n      commandToOptionsToKeys[registryEntry.command][optionString].push(key);\n    }\n    chrome.storage.session.set({ commandToOptionsToKeys });\n  },\n};\n\nconst defaultKeyMappings = {\n  // Navigating the current page\n  \"j\": \"scrollDown\",\n  \"k\": \"scrollUp\",\n  \"h\": \"scrollLeft\",\n  \"l\": \"scrollRight\",\n  \"gg\": \"scrollToTop\",\n  \"G\": \"scrollToBottom\",\n  \"zH\": \"scrollToLeft\",\n  \"zL\": \"scrollToRight\",\n  \"<c-e>\": \"scrollDown\",\n  \"<c-y>\": \"scrollUp\",\n  \"d\": \"scrollPageDown\",\n  \"u\": \"scrollPageUp\",\n  \"r\": \"reload\",\n  \"R\": \"reload hard\",\n  \"yy\": \"copyCurrentUrl\",\n  \"p\": \"openCopiedUrlInCurrentTab\",\n  \"P\": \"openCopiedUrlInNewTab\",\n  \"gi\": \"focusInput\",\n  \"[[\": \"goPrevious\",\n  \"]]\": \"goNext\",\n  \"gf\": \"nextFrame\",\n  \"gF\": \"mainFrame\",\n  \"gu\": \"goUp\",\n  \"gU\": \"goToRoot\",\n  \"i\": \"enterInsertMode\",\n  \"v\": \"enterVisualMode\",\n  \"V\": \"enterVisualLineMode\",\n\n  // Link hints\n  \"f\": \"LinkHints.activateMode\",\n  \"F\": \"LinkHints.activateModeToOpenInNewTab\",\n  \"<a-f>\": \"LinkHints.activateModeWithQueue\",\n  \"yf\": \"LinkHints.activateModeToCopyLinkUrl\",\n\n  // Using find\n  \"/\": \"enterFindMode\",\n  \"n\": \"performFind\",\n  \"N\": \"performBackwardsFind\",\n  \"*\": \"findSelected\",\n  \"#\": \"findSelectedBackwards\",\n\n  // Vomnibar\n  \"o\": \"Vomnibar.activate\",\n  \"O\": \"Vomnibar.activateInNewTab\",\n  \"T\": \"Vomnibar.activateTabSelection\",\n  \"b\": \"Vomnibar.activateBookmarks\",\n  \"B\": \"Vomnibar.activateBookmarksInNewTab\",\n  \"ge\": \"Vomnibar.activateEditUrl\",\n  \"gE\": \"Vomnibar.activateEditUrlInNewTab\",\n\n  // Navigating history\n  \"H\": \"goBack\",\n  \"L\": \"goForward\",\n\n  // Manipulating tabs\n  \"K\": \"nextTab\",\n  \"J\": \"previousTab\",\n  \"gt\": \"nextTab\",\n  \"gT\": \"previousTab\",\n  \"^\": \"visitPreviousTab\",\n  \"<<\": \"moveTabLeft\",\n  \">>\": \"moveTabRight\",\n  \"g0\": \"firstTab\",\n  \"g$\": \"lastTab\",\n  \"W\": \"moveTabToNewWindow\",\n  \"t\": \"createTab\",\n  \"yt\": \"duplicateTab\",\n  \"x\": \"removeTab\",\n  \"X\": \"restoreTab\",\n  \"<a-p>\": \"togglePinTab\",\n  \"<a-m>\": \"toggleMuteTab\",\n  \"zi\": \"zoomIn\",\n  \"zo\": \"zoomOut\",\n  \"z0\": \"zoomReset\",\n\n  // Marks\n  \"m\": \"Marks.activateCreateMode\",\n  \"`\": \"Marks.activateGotoMode\",\n\n  // Misc\n  \"?\": \"showHelp\",\n  \"gs\": \"toggleViewSource\",\n};\n\nexport {\n  Commands,\n  // Exported for unit tests.\n  defaultKeyMappings,\n  KeyMappingsParser,\n  parseLines,\n};\n"
  },
  {
    "path": "background_scripts/completion/completers.js",
    "content": "// This file contains the definition of the completers used for the Vomnibar's suggestion UI. A\n// completer will take a query (whatever the user typed into the Vomnibar) and return a list of\n// Suggestions, e.g. bookmarks, domains, URLs from history.\n//\n// The Vomnibar frontend script makes a \"filterCompleter\" request to the background page, which in\n// turn calls filter() on each these completers.\n//\n// A completer is a class which has three functions:\n//  - filter(query): \"query\" will be whatever the user typed into the Vomnibar.\n//  - refresh(): (optional) refreshes the completer's data source (e.g. refetches the list of\n//    bookmarks).\n//  - cancel(): (optional) cancels any pending, cancelable action.\n\nimport * as bgUtils from \"./../bg_utils.js\";\nimport * as completionSearch from \"./search_wrapper.js\";\nimport * as userSearchEngines from \"../user_search_engines.js\";\nimport * as ranking from \"./ranking.js\";\nimport { RegexpCache } from \"./ranking.js\";\n\n// Set this to true to render relevancy when debugging the ranking scores.\nconst showRelevancy = false;\n\n// TODO(philc): Consider moving out the \"computeRelevancy\" function.\nexport class Suggestion {\n  queryTerms;\n  description;\n  url;\n  // A shortened URL (URI-decoded, protocol removed) suitable for dispaly purposes.\n  shortUrl;\n  title = \"\";\n  // A computed relevancy value.\n  relevancy;\n  relevancyFunction;\n  relevancyData;\n  // When true, then this suggestion is automatically pre-selected in the vomnibar. This only affects\n  // the suggestion in slot 0 in the vomnibar.\n  autoSelect = false;\n  // When true, we highlight matched terms in the title and URL. Otherwise we don't.\n  highlightTerms = true;\n\n  // The text to insert into the vomnibar input when this suggestion is selected.\n  insertText;\n  // This controls whether this suggestion is a candidate for deduplication after simplifying\n  // its URL.\n  deDuplicate = true;\n  // The tab represented by this suggestion. Populated by TabCompleter.\n  tabId;\n  // Whether this is a suggestion provided by a user's custom search engine.\n  isCustomSearch;\n  // Whether this is meant to be the first suggestion from the user's custom search engine which\n  // represents their query as typed, verbatim.\n  isPrimarySuggestion = false;\n  // The generated HTML string for showing this suggestion in the Vomnibar.\n  html;\n  searchUrl;\n\n  constructor(options) {\n    Object.seal(this);\n    Object.assign(this, options);\n  }\n\n  // Returns the relevancy score.\n  computeRelevancy() {\n    // We assume that, once the relevancy has been set, it won't change. Completers must set\n    // either @relevancy or @relevancyFunction.\n    if (this.relevancy == null) {\n      this.relevancy = this.relevancyFunction(this);\n    }\n    return this.relevancy;\n  }\n\n  generateHtml() {\n    if (this.html) return this.html;\n    const relevancyHtml = showRelevancy\n      ? `<span class='relevancy'>${this.computeRelevancy()}</span>`\n      : \"\";\n    const insertTextClass = this.insertText ? \"\" : \"no-insert-text\";\n    const insertTextIndicator = \"&#8618;\"; // A right hooked arrow.\n    if (this.insertText && this.isCustomSearch) {\n      this.title = this.insertText;\n    }\n    let faviconHtml = \"\";\n    if (this.description === \"tab\" && !bgUtils.isFirefox()) {\n      const faviconUrl = new URL(chrome.runtime.getURL(\"/_favicon/\"));\n      faviconUrl.searchParams.set(\"pageUrl\", this.url);\n      faviconUrl.searchParams.set(\"size\", \"16\");\n      faviconHtml = `<img class=\"icon\" src=\"${faviconUrl.toString()}\" />`;\n    }\n    if (this.isCustomSearch) {\n      this.html = `\\\n<div class=\"top-half\">\n   <span class=\"source ${insertTextClass}\">${insertTextIndicator}</span><span class=\"source\">${this.description}</span>\n   <span class=\"title\">${this.highlightQueryTerms(Utils.escapeHtml(this.title))}</span>\n   ${relevancyHtml}\n </div>\\\n`;\n    } else {\n      this.html = `\\\n<div class=\"top-half\">\n   <span class=\"source ${insertTextClass}\">${insertTextIndicator}</span><span class=\"source\">${this.description}</span>\n   <span class=\"title\">${this.highlightQueryTerms(Utils.escapeHtml(this.title))}</span>\n </div>\n <div class=\"bottom-half\">\n  <span class=\"source no-insert-text\">${insertTextIndicator}</span>${faviconHtml}<span class=\"url\">${\n        this.highlightQueryTerms(Utils.escapeHtml(this.shortenUrl()))\n      }</span>\n  ${relevancyHtml}\n</div>\\\n`;\n    }\n    return this.html;\n  }\n\n  // Use neat trick to snatch a domain (http://stackoverflow.com/a/8498668).\n  getUrlRoot(url) {\n    const a = document.createElement(\"a\");\n    a.href = url;\n    return a.protocol + \"//\" + a.hostname;\n  }\n\n  getHostname(url) {\n    const a = document.createElement(\"a\");\n    a.href = url;\n    return a.hostname;\n  }\n\n  stripTrailingSlash(url) {\n    if (url[url.length - 1] === \"/\") {\n      url = url.substring(url, url.length - 1);\n    }\n    return url;\n  }\n\n  // Push the ranges within `string` which match `term` onto `ranges`.\n  pushMatchingRanges(string, term, ranges) {\n    let textPosition = 0;\n    // Split `string` into a (flat) list of pairs:\n    //   - for i=0,2,4,6,...\n    //     - splits[i] is unmatched text\n    //     - splits[i+1] is the following matched text (matching `term`)\n    //       (except for the final element, for which there is no following matched text).\n    // Example:\n    //   - string = \"Abacab\"\n    //   - term = \"a\"\n    //   - splits = [ \"\", \"A\",    \"b\", \"a\",    \"c\", \"a\",    b\" ]\n    //                UM   M       UM   M       UM   M      UM      (M=Matched, UM=Unmatched)\n    const splits = string.split(RegexpCache.get(term, \"(\", \")\"));\n    for (let index = 0, end = splits.length - 2; index <= end; index += 2) {\n      const unmatchedText = splits[index];\n      const matchedText = splits[index + 1];\n      // Add the indices spanning `matchedText` to `ranges`.\n      textPosition += unmatchedText.length;\n      ranges.push([textPosition, textPosition + matchedText.length]);\n      textPosition += matchedText.length;\n    }\n  }\n\n  // Wraps each occurence of the query terms in the given string in a <span>.\n  highlightQueryTerms(string) {\n    if (!this.highlightTerms) return string;\n    let ranges = [];\n    const escapedTerms = this.queryTerms.map((term) => Utils.escapeHtml(term));\n    for (const term of escapedTerms) {\n      this.pushMatchingRanges(string, term, ranges);\n    }\n\n    if (ranges.length === 0) {\n      return string;\n    }\n\n    ranges = this.mergeRanges(ranges.sort((a, b) => a[0] - b[0]));\n    // Replace portions of the string from right to left.\n    ranges = ranges.sort((a, b) => b[0] - a[0]);\n    for (const [start, end] of ranges) {\n      string = string.substring(0, start) +\n        `<span class='match'>${string.substring(start, end)}</span>` +\n        string.substring(end);\n    }\n    return string;\n  }\n\n  // Merges the given list of ranges such that any overlapping regions are combined. E.g.\n  //   mergeRanges([0, 4], [3, 6]) => [0, 6]. A range is [startIndex, endIndex].\n  mergeRanges(ranges) {\n    let previous = ranges.shift();\n    const mergedRanges = [previous];\n    for (const range of ranges) {\n      if (previous[1] >= range[0]) {\n        previous[1] = Math.max(range[1], previous[1]);\n      } else {\n        mergedRanges.push(range);\n        previous = range;\n      }\n    }\n    return mergedRanges;\n  }\n\n  // Simplify a suggestion's URL (by removing those parts which aren't useful for display or\n  // comparison).\n  shortenUrl() {\n    if (this.shortUrl != null) {\n      return this.shortUrl;\n    }\n    // We get easier-to-read shortened URLs if we URI-decode them.\n    let url = (Utils.decodeURIByParts(this.url) || this.url).toLowerCase();\n    for (const [filter, replacements] of Suggestion.stripPatterns) {\n      if (new RegExp(filter).test(url)) {\n        for (const replace of replacements) {\n          url = url.replace(replace, \"\");\n        }\n      }\n    }\n\n    this.shortUrl = url;\n    return this.shortUrl;\n  }\n\n  // Boost a relevancy score by a factor (in the range (0,1.0)), while keeping the score in the\n  // range [0,1]. This makes greater adjustments to scores near the middle of the range (so, very\n  // poor relevancy scores remain very poor).\n  static boostRelevancyScore(factor, score) {\n    return score + (score < 0.5 ? score * factor : (1.0 - score) * factor);\n  }\n}\n\n// Patterns to strip from URLs; of the form [ [ filter, replacements ], [ filter, replacements ], ... ]\n//   - filter is a regexp string; a URL must match this regexp first.\n//   - replacements (itself a list) is a list of regexp objects, each of which is removed from URLs\n//     matching the filter.\n//\n// Note. This includes site-specific patterns for very-popular sites with URLs which don't work well\n// in the vomnibar.\n//\nSuggestion.stripPatterns = [\n  // Google search specific replacements; this replaces query parameters which are known to not be\n  // helpful. There's some additional information here:\n  // http://www.teknoids.net/content/google-search-parameters-2012\n  [\n    \"^https?://www\\\\.google\\\\.(com|ca|com\\\\.au|co\\\\.uk|ie)/.*[&?]q=\",\n    \"ei gws_rd url ved usg sa usg sig2 bih biw cd aqs ie sourceid es_sm\"\n      .split(/\\s+/).map((param) => new RegExp(`\\&${param}=[^&]+`)),\n  ],\n\n  // On Google maps, we get a new history entry for every pan and zoom event.\n  [\"^https?://www\\\\.google\\\\.(com|ca|com\\\\.au|co\\\\.uk|ie)/maps/place/.*/@\", [new RegExp(\"/@.*\")]],\n\n  // General replacements; replaces leading and trailing fluff.\n  [\".\", [\"^https?://\", \"\\\\W+$\"].map((re) => new RegExp(re))],\n];\n\nconst folderSeparator = \"/\";\n\n// If these names occur as top-level bookmark names, then they are not included in the names of\n// bookmark folders.\nconst ignoredTopLevelBookmarks = {\n  \"Other Bookmarks\": true,\n  \"Mobile Bookmarks\": true,\n  \"Bookmarks Bar\": true,\n};\n\n// this.bookmarks are loaded asynchronously when refresh() is called.\nexport class BookmarkCompleter {\n  async filter({ queryTerms }) {\n    if (!this.bookmarks) await this.refresh();\n\n    // If the folder separator character is the first character in any query term, then use the\n    // bookmark's full path as its title. Otherwise, just use the its regular title.\n    let results;\n    const usePathAndTitle = queryTerms.reduce(\n      (prev, term) => prev || term.startsWith(folderSeparator),\n      false,\n    );\n    if (queryTerms.length > 0) {\n      results = this.bookmarks.filter((bookmark) => {\n        const suggestionTitle = usePathAndTitle ? bookmark.pathAndTitle : bookmark.title;\n        if (bookmark.hasJavascriptProtocol == null) {\n          bookmark.hasJavascriptProtocol = UrlUtils.hasJavascriptProtocol(bookmark.url);\n        }\n        if (bookmark.hasJavascriptProtocol && bookmark.shortUrl == null) {\n          bookmark.shortUrl = \"javascript:...\";\n        }\n        const suggestionUrl = bookmark.shortUrl != null ? bookmark.shortUrl : bookmark.url;\n        return ranking.matches(queryTerms, suggestionUrl, suggestionTitle);\n      });\n    } else {\n      results = [];\n    }\n    const suggestions = results.map((bookmark) => {\n      return new Suggestion({\n        queryTerms,\n        description: \"bookmark\",\n        url: bookmark.url,\n        title: usePathAndTitle ? bookmark.pathAndTitle : bookmark.title,\n        relevancyFunction: this.computeRelevancy,\n        shortUrl: bookmark.shortUrl,\n        deDuplicate: bookmark.shortUrl == null,\n      });\n    });\n    return suggestions;\n  }\n\n  async refresh() {\n    // In case refresh() is called multiple times before chrome.bookmarks.getTree() completes, only\n    // call chrome.bookmarks.getTree() once.\n    if (this.bookmarksTreePromise) {\n      await this.bookmarksTreePromise;\n      return;\n    }\n\n    this.bookmarksTreePromise = chrome.bookmarks.getTree();\n    const bookmarksTree = await this.bookmarksTreePromise;\n    this.bookmarks = this.traverseBookmarks(bookmarksTree)\n      .filter((b) => b.url != null);\n    this.bookmarksTreePromise = null;\n  }\n\n  // Traverses the bookmark hierarchy, and returns a flattened list of all bookmarks.\n  traverseBookmarks(bookmarks) {\n    const results = [];\n    for (const folder of bookmarks) {\n      this.traverseBookmarksRecursive(folder, results);\n    }\n    return results;\n  }\n\n  // Recursive helper for `traverseBookmarks`.\n  traverseBookmarksRecursive(bookmark, results, parent) {\n    if (parent == null) {\n      parent = { pathAndTitle: \"\" };\n    }\n    if (\n      bookmark.title &&\n      !((parent.pathAndTitle === \"\") && ignoredTopLevelBookmarks[bookmark.title])\n    ) {\n      bookmark.pathAndTitle = parent.pathAndTitle + folderSeparator + bookmark.title;\n    } else {\n      bookmark.pathAndTitle = parent.pathAndTitle;\n    }\n    results.push(bookmark);\n    if (bookmark.children) {\n      for (const child of bookmark.children) {\n        this.traverseBookmarksRecursive(child, results, bookmark);\n      }\n    }\n  }\n\n  computeRelevancy(suggestion) {\n    return ranking.wordRelevancy(\n      suggestion.queryTerms,\n      suggestion.shortUrl || suggestion.url,\n      suggestion.title,\n    );\n  }\n}\n\nexport class HistoryCompleter {\n  // - seenTabToOpenCompletionList: true if the user has typed only <Tab>, and nothing else.\n  //   We interpret this to mean that they want to see all of their history in the Vomnibar, sorted\n  //   by recency.\n  async filter({ queryTerms, seenTabToOpenCompletionList }) {\n    await HistoryCache.onLoaded();\n\n    let results;\n    if (queryTerms.length > 0) {\n      results = HistoryCache.history\n        .filter((entry) => ranking.matches(queryTerms, entry.url, entry.title));\n    } else if (seenTabToOpenCompletionList) {\n      // The user has typed <Tab> to open the entire history (sorted by recency).\n      results = HistoryCache.history;\n    } else {\n      results = [];\n    }\n\n    const suggestions = results.map((entry) => {\n      return new Suggestion({\n        queryTerms,\n        description: \"history\",\n        url: entry.url,\n        title: entry.title,\n        relevancyFunction: this.computeRelevancy,\n        relevancyData: entry,\n      });\n    });\n    return suggestions;\n  }\n\n  computeRelevancy(suggestion) {\n    const historyEntry = suggestion.relevancyData;\n    const recencyScore = ranking.recencyScore(historyEntry.lastVisitTime);\n    // If there are no query terms, then relevancy is based on recency alone.\n    if (suggestion.queryTerms.length === 0) return recencyScore;\n    const wordRelevancy = ranking.wordRelevancy(\n      suggestion.queryTerms,\n      suggestion.url,\n      suggestion.title,\n    );\n    // Average out the word score and the recency. Recency has the ability to pull the score up, but\n    // not down.\n    return (wordRelevancy + Math.max(recencyScore, wordRelevancy)) / 2;\n  }\n}\n\n// The domain completer is designed to match a single-word query which looks like it is a domain.\n// This supports the user experience where they quickly type a partial domain, hit tab -> enter, and\n// expect to arrive there.\nexport class DomainCompleter {\n  // A map of domain -> { entry: <historyEntry>, referenceCount: <count> }\n  // - `entry` is the most recently accessed page in the History within this domain.\n  // - `referenceCount` is a count of the number of History entries within this domain.\n  //    If `referenceCount` goes to zero, the domain entry can and should be deleted.\n  domains;\n\n  async filter({ queryTerms, query }) {\n    const isMultiWordQuery = /\\S\\s/.test(query);\n    if ((queryTerms.length === 0) || isMultiWordQuery) return [];\n    if (!this.domains) await this.populateDomains();\n\n    const firstTerm = queryTerms[0];\n    const domains = Object.keys(this.domains || []).filter((d) => d.includes(firstTerm));\n    const domainsAndScores = this.sortDomainsByRelevancy(queryTerms, domains);\n    const result = new Suggestion({\n      queryTerms,\n      description: \"domain\",\n      // This should be the URL or the domain, or an empty string, but not null.\n      url: domainsAndScores[0]?.[0] || \"\",\n      relevancy: 2.0,\n    });\n    return result.url.length > 0 ? [result] : [];\n  }\n\n  // Returns a list of domains of the form: [ [domain, relevancy], ... ]\n  sortDomainsByRelevancy(queryTerms, domainCandidates) {\n    const results = [];\n    for (const domain of domainCandidates) {\n      const recencyScore = ranking.recencyScore(this.domains[domain].entry.lastVisitTime || 0);\n      const wordRelevancy = ranking.wordRelevancy(queryTerms, domain, null);\n      const score = (wordRelevancy + Math.max(recencyScore, wordRelevancy)) / 2;\n      results.push([domain, score]);\n    }\n    results.sort((a, b) => b[1] - a[1]);\n    return results;\n  }\n\n  async populateDomains() {\n    await HistoryCache.onLoaded();\n    this.domains = {};\n    for (const entry of HistoryCache.history) {\n      this.onVisited(entry);\n    }\n    chrome.history.onVisited.addListener(this.onVisited.bind(this));\n    chrome.history.onVisitRemoved.addListener(this.onVisitRemoved.bind(this));\n  }\n\n  onVisited(newPage) {\n    const domain = this.parseDomainAndScheme(newPage.url);\n    if (domain) {\n      const slot = this.domains[domain] ||\n        (this.domains[domain] = { entry: newPage, referenceCount: 0 });\n      // We want each entry in our domains map to point to the most recent History entry for that\n      // domain.\n      if (slot.entry.lastVisitTime < newPage.lastVisitTime) {\n        slot.entry = newPage;\n      }\n      slot.referenceCount += 1;\n    }\n  }\n\n  onVisitRemoved(toRemove) {\n    if (toRemove.allHistory) {\n      this.domains = {};\n    } else {\n      for (const url of toRemove.urls) {\n        const domain = this.parseDomainAndScheme(url);\n        const entry = this.domains[domain];\n        if (entry == null) continue;\n        entry.referenceCount--;\n        if (entry.referenceCount <= 0) {\n          delete this.domains[domain];\n        }\n      }\n    }\n  }\n\n  // Return something like \"http://www.example.com\" or false.\n  parseDomainAndScheme(url) {\n    if (UrlUtils.urlHasProtocol(url) && !UrlUtils.hasChromeProtocol(url)) {\n      return url.split(\"/\", 3).join(\"/\");\n    }\n  }\n}\n\n// Searches through all open tabs, matching on title and URL.\n// If the query is empty, then return a list of open tabs, sorted by recency.\nexport class TabCompleter {\n  async filter({ queryTerms }) {\n    await bgUtils.tabRecency.init();\n    // We search all tabs, not just those in the current window.\n    const tabs = await chrome.tabs.query({});\n    const results = tabs.filter((tab) => ranking.matches(queryTerms, tab.url, tab.title));\n    const suggestions = results\n      .map((tab) => {\n        const suggestion = new Suggestion({\n          queryTerms,\n          description: \"tab\",\n          url: tab.url,\n          title: tab.title,\n          tabId: tab.id,\n          deDuplicate: false,\n        });\n        suggestion.relevancy = this.computeRelevancy(suggestion);\n        return suggestion;\n      })\n      .sort((a, b) => b.relevancy - a.relevancy);\n    // Boost relevancy with a multiplier so a relevant tab doesn't get crowded out by results from\n    // competing completers. To prevent tabs from crowding out everything else in turn, penalize\n    // them for being further down the results list by scaling on a hyperbola starting at 1 and\n    // approaching 0 asymptotically for higher indexes. The multiplier and the curve fall-off were\n    // subjectively chosen on the grounds that they seem to work pretty well.\n    suggestions.forEach(function (suggestion, i) {\n      suggestion.relevancy *= 8;\n      suggestion.relevancy /= (i / 4) + 1;\n    });\n    return suggestions;\n  }\n\n  computeRelevancy(suggestion) {\n    if (suggestion.queryTerms.length > 0) {\n      return ranking.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title);\n    } else {\n      return bgUtils.tabRecency.recencyScore(suggestion.tabId);\n    }\n  }\n}\n\nexport class SearchEngineCompleter {\n  cancel() {\n    completionSearch.cancel();\n  }\n\n  // Returns the UserSearchEngine for the given query. Returns null if the query does not begin with\n  // a keyword from one of the user's search engines.\n  getUserSearchEngineForQuery(query) {\n    const parts = query.trimStart().split(/\\s+/);\n    // For a keyword \"w\", we match \"w search terms\" and \"w \", but not \"w\" on its own.\n    const keyword = parts[0];\n    if (parts.length <= 1) return null;\n    // Don't match queries for built-in properties like \"constructor\". See #4396.\n    if (Object.hasOwn(userSearchEngines.keywordToEngine, keyword)) {\n      return userSearchEngines.keywordToEngine[keyword];\n    }\n    return null;\n  }\n\n  refresh() {\n    userSearchEngines.set(Settings.get(\"searchEngines\"));\n  }\n\n  async filter(request) {\n    const { queryTerms } = request;\n\n    const keyword = queryTerms[0];\n    const queryTermsWithoutKeyword = queryTerms.slice(1);\n\n    const userSearchEngine = userSearchEngines.keywordToEngine[keyword];\n    if (!userSearchEngine) return [];\n\n    const searchUrl = userSearchEngine.url;\n\n    const completions = await completionSearch.complete(searchUrl, queryTermsWithoutKeyword);\n\n    const makeSuggestion = (query) => {\n      const url = UrlUtils.createSearchUrl(query, searchUrl);\n      return new Suggestion({\n        queryTerms,\n        description: userSearchEngine.description,\n        url,\n        title: query,\n        searchUrl,\n        highlightTerms: false,\n        isCustomSearch: true,\n        relevancy: null,\n        relevancyFunction: this.computeRelevancy,\n      });\n    };\n\n    const suggestions = completions.map((completion) => {\n      const s = makeSuggestion(completion);\n      s.insertText = completion;\n      return s;\n    });\n\n    if (suggestions[0]) suggestions[0].relevancy = 1.0;\n\n    // This is a suggestion which contains the user's query. It's the \"search for exactly what I\n    // just typed\" option. It should always appear first in the list.\n    const primarySuggestion = makeSuggestion(queryTermsWithoutKeyword.join(\" \"));\n    primarySuggestion.relevancy = 2;\n    primarySuggestion.isPrimarySuggestion = true;\n    primarySuggestion.autoSelect = true;\n    suggestions.unshift(primarySuggestion);\n\n    return suggestions;\n  }\n\n  computeRelevancy({ queryTerms, title }) {\n    // Tweaks:\n    // - Calibration: we boost relevancy scores to try to achieve an appropriate balance between\n    //   relevancy scores here, and those provided by other completers.\n    // - Relevancy depends only on the title (which is the search terms), and not on the URL.\n    return Suggestion.boostRelevancyScore(\n      0.5,\n      0.7 * ranking.wordRelevancy(queryTerms, title, title),\n    );\n  }\n}\n\nSearchEngineCompleter.debug = false;\n\n// A completer which calls filter() on many completers, aggregates the results, ranks them, and\n// returns the top 10. All queries from the vomnibar come through a multi completer.\nconst maxResults = 10;\n\nexport class MultiCompleter {\n  constructor(completers) {\n    this.completers = completers;\n  }\n\n  refresh() {\n    for (const c of this.completers) {\n      if (c.refresh) c.refresh();\n    }\n  }\n\n  cancel() {\n    for (const c of this.completers) {\n      c.cancel?.();\n    }\n  }\n\n  async filter(request) {\n    const searchEngineCompleter = this.completers.find((c) => c instanceof SearchEngineCompleter);\n    const query = request.query;\n    const queryTerms = request.queryTerms;\n\n    // The only UX where we support showing results when there are no query terms is via\n    // Vomnibar.activateTabSelection, where we show the list of open tabs by recency.\n    const isTabCompleter = this.completers.length == 1 &&\n      this.completers[0] instanceof TabCompleter;\n    if (queryTerms.length == 0 && !isTabCompleter) {\n      return [];\n    }\n\n    const queryMatchesUserSearchEngine = searchEngineCompleter?.getUserSearchEngineForQuery(query);\n\n    // If the user's query matches one of their custom search engines, then use only that engine to\n    // provide completions for their query.\n    const completers = queryMatchesUserSearchEngine\n      ? [searchEngineCompleter]\n      : this.completers.filter((c) => c != searchEngineCompleter);\n\n    RegexpCache.clear();\n\n    const promises = completers.map((c) => c.filter(request));\n    let results = (await Promise.all(promises)).flat(1);\n    results = this.postProcessSuggestions(request, queryTerms, results);\n    return results;\n  }\n\n  // Rank them, simplify the URLs, and de-duplicate suggestions with the same simplified URL.\n  postProcessSuggestions(request, queryTerms, suggestions) {\n    for (const s of suggestions) {\n      s.computeRelevancy(queryTerms);\n    }\n    suggestions.sort((a, b) => b.relevancy - a.relevancy);\n\n    // Simplify URLs and remove duplicates (duplicate simplified URLs, that is).\n    let count = 0;\n    const seenUrls = {};\n\n    const dedupedSuggestions = [];\n    for (const s of suggestions) {\n      const url = s.shortenUrl();\n      if (s.deDuplicate && seenUrls[url]) continue;\n      if (count++ === maxResults) break;\n      seenUrls[url] = s;\n      dedupedSuggestions.push(s);\n    }\n\n    // Give each completer the opportunity to tweak the suggestions.\n    for (const completer of this.completers) {\n      if (completer.postProcessSuggestions) {\n        completer.postProcessSuggestions(request, dedupedSuggestions);\n      }\n    }\n\n    // Generate HTML for the remaining suggestions and return them.\n    for (const s of dedupedSuggestions) {\n      s.generateHtml(request);\n    }\n\n    return dedupedSuggestions;\n  }\n}\n\n// Provides cached access to Chrome's history. As the user browses to new pages, we add those pages\n// to this history cache.\nexport const HistoryCache = {\n  size: 20000,\n  // An array of History items returned from Chrome.\n  history: null,\n\n  reset() {\n    this.history = null;\n    chrome.history.onVisited.removeListener(this._onVisitedListener);\n    chrome.history.onVisitRemoved.removeListener(this._onVisitRemovedListener);\n  },\n\n  async onLoaded() {\n    if (this.history) return;\n    await this.fetchHistory();\n  },\n\n  async fetchHistory() {\n    if (this.chromeHistoryPromise) {\n      await this.chromeHistoryPromise;\n      return;\n    }\n    this.chromeHistoryPromise = chrome.history.search({\n      text: \"\",\n      maxResults: this.size,\n      startTime: 0,\n    });\n\n    const history = await this.chromeHistoryPromise;\n\n    // On Firefox, some history entries do not have titles.\n    for (const entry of history) {\n      if (entry.title == null) entry.title = \"\";\n    }\n    history.sort(this.compareHistoryByUrl);\n    this.history = history;\n    chrome.history.onVisited.addListener(this._onVisitedListener);\n    chrome.history.onVisitRemoved.addListener(this._onVisitRemovedListener);\n    this.chromeHistoryPromise = null;\n  },\n\n  compareHistoryByUrl(a, b) {\n    if (a.url === b.url) return 0;\n    if (a.url > b.url) return 1;\n    return -1;\n  },\n\n  // When a page we've seen before has been visited again, be sure to replace our History item so it\n  // has the correct \"lastVisitTime\". That's crucial for ranking Vomnibar suggestions.\n  onVisited(newPage) {\n    // On Firefox, some history entries do not have titles.\n    if (newPage.title == null) newPage.title = \"\";\n    const i = HistoryCache.binarySearch(newPage, this.history, this.compareHistoryByUrl);\n    const pageWasFound = this.history[i]?.url == newPage.url;\n    if (pageWasFound) {\n      this.history[i] = newPage;\n    } else {\n      this.history.splice(i, 0, newPage);\n    }\n  },\n\n  // When a page is removed from the chrome history, remove it from the vimium history too.\n  onVisitRemoved(toRemove) {\n    if (toRemove.allHistory) {\n      this.history = [];\n    } else {\n      for (const url of toRemove.urls) {\n        const i = HistoryCache.binarySearch({ url }, this.history, this.compareHistoryByUrl);\n        if ((i < this.history.length) && (this.history[i].url === url)) {\n          this.history.splice(i, 1);\n        }\n      }\n    }\n  },\n};\n\nHistoryCache._onVisitedListener = HistoryCache.onVisited.bind(HistoryCache);\nHistoryCache._onVisitRemovedListener = HistoryCache.onVisitRemoved.bind(HistoryCache);\n\n// Returns the matching index or the closest matching index if the element is not found. That means\n// you must check the element at the returned index to know whether the element was actually found.\n// This method is used for quickly searching through our history cache.\nHistoryCache.binarySearch = function (targetElement, array, compareFunction) {\n  let element, middle;\n  let high = array.length - 1;\n  let low = 0;\n\n  while (low <= high) {\n    middle = Math.floor((low + high) / 2);\n    element = array[middle];\n    const compareResult = compareFunction(element, targetElement);\n    if (compareResult > 0) {\n      high = middle - 1;\n    } else if (compareResult < 0) {\n      low = middle + 1;\n    } else {\n      return middle;\n    }\n  }\n  // We didn't find the element. Return the position where it should be in this array.\n  if (compareFunction(element, targetElement) < 0) {\n    return middle + 1;\n  } else {\n    return middle;\n  }\n};\n"
  },
  {
    "path": "background_scripts/completion/ranking.js",
    "content": "// Utilities which help us compute a relevancy score for a given item.\n\n// Whether the given things (usually URLs or titles) match any one of the query terms.\n// This is used to prune out irrelevant suggestions before we try to rank them, and for\n// calculating word relevancy. Every term must match at least one thing.\nexport function matches(queryTerms, ...things) {\n  for (const term of queryTerms) {\n    const regexp = RegexpCache.get(term);\n    let matchedTerm = false;\n    for (const thing of things) {\n      if (!matchedTerm) {\n        matchedTerm = thing.match(regexp);\n      }\n    }\n    if (!matchedTerm) return false;\n  }\n  return true;\n}\n\n// Weights used for scoring matches.\nconst matchWeights = {\n  matchAnywhere: 1,\n  matchStartOfWord: 1,\n  matchWholeWord: 1,\n  // The following must be the sum of the three weights above; it is used for normalization.\n  maximumScore: 3,\n  //\n  // Calibration factor for balancing word relevancy and recency.\n  recencyCalibrator: 2.0 / 3.0,\n};\n\n// The current value of 2.0/3.0 has the effect of:\n//   - favoring the contribution of recency when matches are not on word boundaries ( because 2.0/3.0 > (1)/3     )\n//   - favoring the contribution of word relevance when matches are on whole words  ( because 2.0/3.0 < (1+1+1)/3 )\n\n// Calculate a score for matching term against string.\n// The score is in the range [0, matchWeights.maximumScore], see above.\n// Returns: [ score, count ], where count is the number of matched characters in string.\nfunction scoreTerm(term, string) {\n  let score = 0;\n  let count = 0;\n  const nonMatching = string.split(RegexpCache.get(term));\n  if (nonMatching.length > 1) {\n    // Have match.\n    score = matchWeights.matchAnywhere;\n    count = nonMatching.reduce((p, c) => p - c.length, string.length);\n    if (RegexpCache.get(term, \"\\\\b\").test(string)) {\n      // Have match at start of word.\n      score += matchWeights.matchStartOfWord;\n      if (RegexpCache.get(term, \"\\\\b\", \"\\\\b\").test(string)) {\n        // Have match of whole word.\n        score += matchWeights.matchWholeWord;\n      }\n    }\n  }\n  return [score, count < string.length ? count : string.length];\n}\n\n// Returns a number between [0, 1] indicating how often the query terms appear in the url and title.\nexport function wordRelevancy(queryTerms, url, title) {\n  let titleCount, titleScore;\n  let urlScore = (titleScore = 0.0);\n  let urlCount = (titleCount = 0);\n  // Calculate initial scores.\n  for (const term of queryTerms) {\n    let [s, c] = scoreTerm(term, url);\n    urlScore += s;\n    urlCount += c;\n    if (title) {\n      [s, c] = scoreTerm(term, title);\n      titleScore += s;\n      titleCount += c;\n    }\n  }\n\n  const maximumPossibleScore = matchWeights.maximumScore * queryTerms.length;\n\n  // Normalize scores.\n  urlScore /= maximumPossibleScore;\n  urlScore *= normalizeDifference(urlCount, url.length);\n\n  if (title) {\n    titleScore /= maximumPossibleScore;\n    titleScore *= normalizeDifference(titleCount, title.length);\n  } else {\n    titleScore = urlScore;\n  }\n\n  // Prefer matches in the title over matches in the URL.\n  // In other words, don't let a poor urlScore pull down the titleScore.\n  // For example, urlScore can be unreasonably poor if the URL is very long.\n  if (urlScore < titleScore) {\n    urlScore = titleScore;\n  }\n\n  // Return the average.\n  return (urlScore + titleScore) / 2;\n}\n\n// Untested alternative to the above:\n//   - Don't let a poor urlScore pull down a good titleScore, and don't let a poor titleScore pull\n//     down a good urlScore.\n//\n// return Math.max(urlScore, titleScore)\n\nlet oneMonthAgo = 1000 * 60 * 60 * 24 * 30;\n\n// Returns a score between [0, 1] which indicates how recent the given timestamp is. Items which\n// are over a month old are counted as 0. This range is quadratic, so an item from one day ago has\n// a much stronger score than an item from two days ago.\nexport function recencyScore(lastAccessedTime) {\n  const recency = Date.now() - lastAccessedTime;\n  const recencyDifference = Math.max(0, oneMonthAgo - recency) / oneMonthAgo;\n\n  // recencyScore is between [0, 1]. It is 1 when recenyDifference is 0. This quadratic equation\n  // will incresingly discount older history entries.\n  let recencyScore = recencyDifference * recencyDifference * recencyDifference;\n\n  // Calibrate recencyScore vis-a-vis word-relevancy scores.\n  return recencyScore *= matchWeights.recencyCalibrator;\n}\n\n// Takes the difference of two numbers and returns a number between [0, 1] (the percentage difference).\nfunction normalizeDifference(a, b) {\n  const max = Math.max(a, b);\n  return (max - Math.abs(a - b)) / max;\n}\n\n// We cache regexps because we use them frequently when comparing a query to history entries and\n// bookmarks, and we don't want to create fresh objects for every comparison.\nexport const RegexpCache = {\n  init() {\n    this.initialized = true;\n    this.clear();\n  },\n\n  clear() {\n    this.cache = {};\n  },\n\n  // Get rexexp for `string` from cache, creating it if necessary.\n  // Regexp meta-characters in `string` are escaped.\n  // Regexp is wrapped in `prefix`/`suffix`, which may contain meta-characters (these are not\n  // escaped).\n  // With their default values, `prefix` and `suffix` have no effect.\n  // Example:\n  //   - string=\"go\", prefix=\"\\b\", suffix=\"\"\n  //   - this returns regexp matching \"google\", but not \"agog\" (the \"go\" must occur at the start of\n  //     a word)\n  // TODO: `prefix` and `suffix` might be useful in richer word-relevancy scoring.\n  get(string, prefix, suffix) {\n    if (prefix == null) prefix = \"\";\n    if (suffix == null) suffix = \"\";\n    if (!this.initialized) this.init();\n    let regexpString = Utils.escapeRegexSpecialCharacters(string);\n    // Avoid cost of constructing new strings if prefix/suffix are empty (which is expected to be a\n    // common case).\n    if (prefix) regexpString = prefix + regexpString;\n    if (suffix) regexpString = regexpString + suffix;\n    // Smartcase: Regexp is case insensitive, unless `string` contains a capital letter (testing\n    // `string`, not `regexpString`).\n    return this.cache[regexpString] ||\n      (this.cache[regexpString] = new RegExp(regexpString, Utils.hasUpperCase(string) ? \"\" : \"i\"));\n  },\n};\n"
  },
  {
    "path": "background_scripts/completion/search_engines.js",
    "content": "// An engine provides search suggestions for a online search engine.\n//\n// An \"engineUrl\" is used for fetching suggestions, whereas a \"searchUrl\" is used for the actual\n// search itself.\n//\n// Each engine defines:\n//\n//   1. An \"engineUrl\". This is the URL to use for search completions and is passed as the option\n//      \"engineUrl\" to the \"BaseEngine\" constructor.\n//\n//   2. One or more regular expressions which define the custom search engine URLs for which the\n//      completion engine will be used. This is passed as the \"regexps\" option to the \"BaseEngine\"\n//      constructor.\n//\n//   3. A \"parse\" function. This takes the text body of an HTTP response and returns a list of\n//      suggestions (a list of strings). This method is always executed within the context of a\n//      try/catch block, so errors do not propagate.\n//\n//   4. Each completion engine *must* include an example custom search engine. The example must\n//      include an example \"keyword\" and an example \"searchUrl\", and may include an example\n//      \"description\" and an \"explanation\". This info is shown as documentation to the user.\n//\n// Each new completion engine must be added to the list \"CompletionEngines\" at the bottom of this\n// file.\n//\n// The lookup logic which uses these completion engines is in \"./completers.js\".\n//\n\n// A base class for common regexp-based matching engines. \"options\" must define:\n//   options.engineUrl: the URL to use for the completion engine. This must be a string.\n//   options.regexps: one or regular expressions. This may either a single string or a list of\n//   strings.\n//   options.example: an example object containing at least \"keyword\" and \"searchUrl\", and optional\n//   \"description\".\n// TODO(philc): This base class is doing very little. We should remove it and use composition.\nclass BaseEngine {\n  constructor(options) {\n    Object.assign(this, options);\n    this.regexps = this.regexps.map((regexp) => new RegExp(regexp));\n  }\n\n  match(searchUrl) {\n    return Utils.matchesAnyRegexp(this.regexps, searchUrl);\n  }\n  getUrl(queryTerms) {\n    return UrlUtils.createSearchUrl(queryTerms.join(\" \"), this.engineUrl);\n  }\n}\n\nexport class Google extends BaseEngine {\n  constructor() {\n    super({\n      engineUrl: \"http://suggestqueries.google.com/complete/search?client=chrome&q=%s\",\n      regexps: [\"^https?://[a-z]+\\\\.google\\\\.(com|ie|co\\\\.(uk|jp)|ca|com\\\\.au)/\"],\n      example: {\n        searchUrl: \"https://www.google.com/search?q=%s\",\n        keyword: \"g\",\n      },\n    });\n  }\n\n  parse(text) {\n    return JSON.parse(text)[1];\n  }\n}\n\nconst googleMapsPrefix = \"map of \";\n\nexport class GoogleMaps extends BaseEngine {\n  constructor() {\n    super({\n      engineUrl:\n        `http://suggestqueries.google.com/complete/search?client=chrome&ds=yt&q=${googleMapsPrefix}%s`,\n      regexps: [\"^https?://[a-z]+\\\\.google\\\\.(com|ie|co\\\\.(uk|jp)|ca|com\\\\.au)/maps\"],\n      example: {\n        searchUrl: \"https://www.google.com/maps?q=%s\",\n        keyword: \"m\",\n        explanation: `\\\nThis uses regular Google completion, but prepends the text \"<tt>map of </tt>\" to the query.  It works\nwell for places, countries, states, geographical regions and the like, but will not perform address\nsearch.\\\n`,\n      },\n    });\n  }\n\n  parse(text) {\n    return JSON.parse(text)[1]\n      .filter((suggestion) => suggestion.startsWith(googleMapsPrefix))\n      .map((suggestion) => suggestion.slice(googleMapsPrefix));\n  }\n}\n\nexport class Youtube extends BaseEngine {\n  constructor() {\n    super({\n      engineUrl: \"http://suggestqueries.google.com/complete/search?client=chrome&ds=yt&q=%s\",\n      regexps: [\"^https?://[a-z]+\\\\.youtube\\\\.com/results\"],\n      example: {\n        searchUrl: \"https://www.youtube.com/results?search_query=%s\",\n        keyword: \"y\",\n      },\n    });\n  }\n\n  parse(text) {\n    return JSON.parse(text)[1];\n  }\n}\n\nexport class Wikipedia extends BaseEngine {\n  constructor() {\n    super({\n      engineUrl: \"https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=%s\",\n      regexps: [\"^https?://[a-z]+\\\\.wikipedia\\\\.org/\"],\n      example: {\n        searchUrl: \"https://www.wikipedia.org/w/index.php?title=Special:Search&search=%s\",\n        keyword: \"w\",\n      },\n    });\n  }\n\n  parse(text) {\n    return JSON.parse(text)[1];\n  }\n}\n\nexport class Bing extends BaseEngine {\n  constructor() {\n    super({\n      engineUrl: \"https://api.bing.com/osjson.aspx?query=%s\",\n      regexps: [\"^https?://www\\\\.bing\\\\.com/search\"],\n      example: {\n        searchUrl: \"https://www.bing.com/search?q=%s\",\n        keyword: \"b\",\n      },\n    });\n  }\n\n  parse(text) {\n    return JSON.parse(text)[1];\n  }\n}\n\nexport class Amazon extends BaseEngine {\n  constructor() {\n    super({\n      engineUrl:\n        \"https://completion.amazon.com/api/2017/suggestions?mid=ATVPDKIKX0DER&alias=aps&prefix=%s\",\n      regexps: [\"^https?://(www|smile)\\\\.amazon\\\\.(com|co\\\\.uk|ca|de|com\\\\.au)/s/\"],\n      example: {\n        searchUrl: \"https://www.amazon.com/s/?field-keywords=%s\",\n        keyword: \"a\",\n      },\n    });\n  }\n\n  parse(text) {\n    return JSON.parse(text).suggestions.map((suggestion) => suggestion.value);\n  }\n}\n\nexport class DuckDuckGo extends BaseEngine {\n  constructor() {\n    super({\n      engineUrl: \"https://duckduckgo.com/ac/?q=%s\",\n      regexps: [\"^https?://([a-z]+\\\\.)?duckduckgo\\\\.com/\"],\n      example: {\n        searchUrl: \"https://duckduckgo.com/?q=%s\",\n        keyword: \"d\",\n      },\n    });\n  }\n\n  parse(text) {\n    return JSON.parse(text).map((suggestion) => suggestion.phrase);\n  }\n}\n\nexport class Webster extends BaseEngine {\n  constructor() {\n    super({\n      engineUrl: \"https://www.merriam-webster.com/lapi/v1/mwol-search/autocomplete?search=%s\",\n      regexps: [\"^https?://www.merriam-webster.com/dictionary/\"],\n      example: {\n        searchUrl: \"https://www.merriam-webster.com/dictionary/%s\",\n        keyword: \"dw\",\n        description: \"Dictionary\",\n      },\n    });\n  }\n\n  parse(text) {\n    return JSON.parse(text).docs.map((suggestion) => suggestion.word);\n  }\n}\n\n// Qwant is a privacy-friendly search engine.\nexport class Qwant extends BaseEngine {\n  constructor() {\n    super({\n      engineUrl: \"https://api.qwant.com/api/suggest?q=%s\",\n      regexps: [\"^https?://www\\\\.qwant\\\\.com/\"],\n      example: {\n        searchUrl: \"https://www.qwant.com/?q=%s\",\n        keyword: \"qw\",\n      },\n    });\n  }\n\n  parse(text) {\n    return JSON.parse(text).data.items.map((suggestion) => suggestion.value);\n  }\n}\n\n// Brave is a privacy-friendly search engine.\nexport class Brave extends BaseEngine {\n  constructor() {\n    super({\n      engineUrl: \"https://search.brave.com/api/suggest?rich=false&q=%s\",\n      regexps: [\"^https?://search\\\\.brave\\\\.com/\"],\n      example: {\n        searchUrl: \"https://search.brave.com/search?q=%s\",\n        keyword: \"br\",\n      },\n    });\n  }\n\n  parse(text) {\n    return JSON.parse(text)[1];\n  }\n}\n\n// Kagi is a paid ad-free search engine\nexport class Kagi extends BaseEngine {\n  constructor() {\n    super({\n      engineUrl: \"https://kagi.com/autosuggest?q=%s\",\n      regexps: [\"^https?://www\\\\.kagi\\\\.com/\"],\n      example: {\n        searchUrl: \"https://www.kagi.com/search?q=%s\",\n        keyword: \"k\",\n      },\n    });\n  }\n\n  parse(text) {\n    return JSON.parse(text).map((suggestion) => suggestion.t);\n  }\n}\n\n// On the user-facing documentation page pages/doc_search_completion.html, the completion search\n// engines will be shown in this order.\nexport const list = [\n  Youtube,\n  GoogleMaps,\n  Google,\n  DuckDuckGo,\n  Wikipedia,\n  Bing,\n  Amazon,\n  Webster,\n  Brave,\n  Qwant,\n  Kagi,\n];\n"
  },
  {
    "path": "background_scripts/completion/search_wrapper.js",
    "content": "import * as searchEngines from \"./search_engines.js\";\n\n// This is a wrapper class for completion engines. It handles the case where a custom search engine\n// includes a prefix query term (or terms). For example:\n//\n//   https://www.google.com/search?q=javascript+%s\n//\n// In this case, we get better suggestions if we include the term \"javascript\" in queries sent to\n// the completion engine. This wrapper handles adding such prefixes to completion-engine queries and\n// removing them from the resulting suggestions.\nclass EnginePrefixWrapper {\n  constructor(searchUrl, engine) {\n    this.searchUrl = searchUrl;\n    this.engine = engine;\n  }\n\n  getUrl(queryTerms) {\n    // This tests whether @searchUrl contains something of the form \"...=abc+def+%s...\", from which\n    // we extract a prefix of the form \"abc def \".\n    if (/\\=.+\\+%s/.test(this.searchUrl)) {\n      let terms = this.searchUrl.replace(/\\+%s.*/, \"\");\n      terms = terms.replace(/.*=/, \"\");\n      terms = terms.replace(/\\+/g, \" \");\n\n      queryTerms = [...terms.split(\" \"), ...queryTerms];\n      const prefix = `${terms} `;\n\n      this.transformSuggestionsFn = (suggestions) => {\n        return suggestions\n          .filter((s) => s.startsWith(prefix))\n          .map((s) => s.slice(prefix.length));\n      };\n    }\n\n    return this.engine.getUrl(queryTerms);\n  }\n\n  parse(responseText) {\n    const suggestions = this.engine.parse(responseText);\n    return this.transformSuggestionsFn ? this.transformSuggestionsFn(suggestions) : suggestions;\n  }\n}\n\nlet debug = false;\nconst inTransit = {};\nconst completionCache = new SimpleCache(2 * 60 * 60 * 1000, 5000); // Two hours, 5000 entries.\nconst engineCache = new SimpleCache(1000 * 60 * 60 * 1000); // 1000 hours.\n\n// The amount of time to wait for new requests before launching the current request (for example,\n// if the user is still typing).\nconst DELAY = 100;\n\n// This gets incremented each time we make a request to the completion engine. This allows us to\n// dedupe requets which overlap, which is the case when the user is typing fast.\nlet requestId = 0;\n\nasync function get(url) {\n  const timeoutDuration = 2500;\n  const controller = new AbortController();\n  let isError = false;\n  let responseText;\n  const timer = Utils.setTimeout(timeoutDuration, () => controller.abort());\n\n  try {\n    const response = await fetch(url, { signal: controller.signal });\n    responseText = await response.text();\n  } catch {\n    // Fetch throws an error if the network is unreachable, etc.\n    isError = true;\n  }\n\n  clearTimeout(timer);\n\n  return isError ? null : responseText;\n}\n\n// Look up the completion engine for this searchUrl.\nfunction lookupEngine(searchUrl) {\n  if (engineCache.has(searchUrl)) {\n    return engineCache.get(searchUrl);\n  } else {\n    for (const engineClass of searchEngines.list) {\n      const engine = new engineClass();\n      if (engine.match(searchUrl)) {\n        return engineCache.set(searchUrl, engine);\n      }\n    }\n  }\n}\n\n// This is the main entry point.\n//  - searchUrl is the search engine's URL, e.g. Settings.get(\"searchUrl\"), or a custom search\n//    engine's URL. This is only used as a key for determining the relevant completion engine.\n//  - queryTerms are the query terms.\nexport async function complete(searchUrl, queryTerms) {\n  const query = queryTerms.join(\" \").toLowerCase();\n\n  // We don't complete queries which are too short: the results are usually useless.\n  if (query.length < 4) return [];\n\n  // We don't complete regular URLs or Javascript URLs.\n  if (queryTerms.length == 1 && await UrlUtils.isUrl(query)) return [];\n  if (UrlUtils.hasJavascriptProtocol(query)) return [];\n\n  const engine = lookupEngine(searchUrl);\n  if (!engine) return [];\n\n  const completionCacheKey = JSON.stringify([searchUrl, queryTerms]);\n  if (completionCache.has(completionCacheKey)) {\n    if (debug) console.log(\"hit\", completionCacheKey);\n    return completionCache.get(completionCacheKey);\n  }\n\n  const createTimeoutPromise = (ms) => {\n    return new Promise((resolve) => {\n      setTimeout(() => {\n        resolve();\n      }, ms);\n    });\n  };\n\n  requestId++;\n  const lastRequestId = requestId;\n\n  // We delay sending a completion request in case the user is still typing.\n  await createTimeoutPromise(DELAY);\n\n  // If the user has issued a new query while we were waiting, then this query is old; abort it.\n  if (lastRequestId != requestId) return [];\n\n  const engineWrapper = new EnginePrefixWrapper(searchUrl, engine);\n  const url = engineWrapper.getUrl(queryTerms);\n\n  if (debug) console.log(\"GET\", url);\n  const responseText = await get(url);\n\n  // Parsing the response may fail if we receive an unexpectedly-formatted response. In all cases,\n  // we fall back to the catch clause, below. Therefore, we \"fail safe\" in the case of incorrect\n  // or out-of-date completion engine implementations.\n  let suggestions = [];\n  let isError = responseText == null;\n  if (!isError) {\n    try {\n      suggestions = engineWrapper.parse(responseText)\n        // Make all suggestions lower case. It looks odd when suggestions from one\n        // completion engine are upper case, and those from another are lower case.\n        .map((s) => s.toLowerCase())\n        // Filter out the query itself. It's not adding anything.\n        .filter((s) => s !== query);\n    } catch (error) {\n      if (debug) console.log(\"error:\", error);\n      isError = true;\n    }\n  }\n  if (isError) {\n    // We allow failures to be cached too, but remove them after just thirty seconds.\n    Utils.setTimeout(\n      30 * 1000,\n      () => completionCache.set(completionCacheKey, null),\n    );\n  }\n\n  completionCache.set(completionCacheKey, suggestions);\n  return suggestions;\n}\n\n// Cancel any pending (ie. blocked on @delay) queries. Does not cancel in-flight queries. This is\n// called whenever the user is typing.\nexport function cancel() {\n  requestId++;\n}\n"
  },
  {
    "path": "background_scripts/exclusions.js",
    "content": "// This module manages manages the exclusion rule setting. An exclusion is an object with two\n// attributes: pattern and passKeys. The exclusion rules are an array of such objects.\n\nconst ExclusionRegexpCache = {\n  cache: {},\n  clear(cache) {\n    this.cache = cache || {};\n  },\n  get(pattern) {\n    if (pattern in this.cache) {\n      return this.cache[pattern];\n    } else {\n      let result;\n      // We use try/catch to ensure that a broken regexp doesn't wholly cripple Vimium.\n      try {\n        result = new RegExp(\"^\" + pattern.replace(/\\*/g, \".*\") + \"$\");\n      } catch {\n        if (!globalThis.isUnitTests) {\n          console.log(`bad regexp in exclusion rule: ${pattern}`);\n        }\n        result = /^$/; // Match the empty string.\n      }\n      this.cache[pattern] = result;\n      return result;\n    }\n  },\n};\n\n// Make RegexpCache, which is required on the page popup, accessible via the Exclusions object.\nconst RegexpCache = ExclusionRegexpCache;\n\n// Merge the matching rules for URL, or null. In the normal case, we use the configured @rules;\n// hence, this is the default. However, when called from the page popup, we are testing what\n// effect candidate new rules would have on the current tab. In this case, the candidate rules are\n// provided by the caller.\nfunction getRule(url, rules) {\n  if (rules == null) {\n    rules = Settings.get(\"exclusionRules\");\n  }\n  const matchingRules = rules.filter((r) =>\n    r.pattern && (url.search(ExclusionRegexpCache.get(r.pattern)) >= 0)\n  );\n  // An absolute exclusion rule (one with no passKeys) takes priority.\n  for (const rule of matchingRules) {\n    if (!rule.passKeys) return rule;\n  }\n  // Strip whitespace from all matching passKeys strings, and join them together.\n  const passKeys = matchingRules.map((r) => r.passKeys.split(/\\s+/).join(\"\")).join(\"\");\n  // TODO(philc): Remove this commented out code.\n  // passKeys = (rule.passKeys.split(/\\s+/).join \"\" for rule in matchingRules).join \"\"\n  if (matchingRules.length > 0) {\n    return { passKeys: Utils.distinctCharacters(passKeys) };\n  } else {\n    return null;\n  }\n}\n\nexport function isEnabledForUrl(url) {\n  const rule = getRule(url);\n  return {\n    isEnabledForUrl: !rule || (rule.passKeys.length > 0),\n    passKeys: rule ? rule.passKeys : \"\",\n  };\n}\n\nfunction setRules(rules) {\n  // Callers map a rule to null to have it deleted, and rules without a pattern are useless.\n  const newRules = rules.filter((rule) => rule?.pattern);\n  Settings.set(\"exclusionRules\", newRules);\n}\n\nfunction onSettingsUpdated() {\n  // NOTE(mrmr1993): In FF, the |rules| argument will be garbage collected when the exclusions\n  // popup is closed. Do NOT store it/use it asynchronously.\n  ExclusionRegexpCache.clear();\n}\n\nSettings.addEventListener(\"change\", () => onSettingsUpdated());\n"
  },
  {
    "path": "background_scripts/main.js",
    "content": "import \"../lib/utils.js\";\nimport \"../lib/settings.js\";\nimport \"../lib/url_utils.js\";\nimport \"../background_scripts/tab_recency.js\";\nimport * as bgUtils from \"../background_scripts/bg_utils.js\";\nimport \"../background_scripts/all_commands.js\";\nimport { Commands } from \"../background_scripts/commands.js\";\nimport * as exclusions from \"../background_scripts/exclusions.js\";\nimport \"../background_scripts/completion/search_engines.js\";\nimport \"../background_scripts/completion/search_wrapper.js\";\nimport \"../background_scripts/completion/completers.js\";\nimport \"../background_scripts/tab_operations.js\";\nimport * as marks from \"../background_scripts/marks.js\";\n\nimport {\n  BookmarkCompleter,\n  DomainCompleter,\n  HistoryCompleter,\n  MultiCompleter,\n  SearchEngineCompleter,\n  TabCompleter,\n} from \"./completion/completers.js\";\n\n// NOTE(philc): This file has many superfluous return statements in its functions, as a result of\n// converting from coffeescript to es6. Many can be removed, but I didn't take the time to\n// diligently track down precisely which return statements could be removed when I was doing the\n// conversion.\n\nimport * as TabOperations from \"./tab_operations.js\";\n\n// Allow Vimium's content scripts to access chrome.storage.session. Otherwise,\n// chrome.storage.session will be null in content scripts.\nchrome.storage.session.setAccessLevel({ accessLevel: \"TRUSTED_AND_UNTRUSTED_CONTEXTS\" });\n\n// This is exported for use by \"marks.js\".\nglobalThis.tabLoadedHandlers = {}; // tabId -> function()\n\n// A Vimium secret, available only within the current browser session. The secret is a generated\n// strong random string.\nconst randomArray = globalThis.crypto.getRandomValues(new Uint8Array(32)); // 32-byte random token.\nconst secretToken = randomArray.reduce((a, b) => a.toString(16) + b.toString(16));\nchrome.storage.session.set({ vimiumSecret: secretToken });\n\nconst completionSources = {\n  bookmarks: new BookmarkCompleter(),\n  history: new HistoryCompleter(),\n  domains: new DomainCompleter(),\n  tabs: new TabCompleter(),\n  searchEngines: new SearchEngineCompleter(),\n};\n\nconst completers = {\n  omni: new MultiCompleter([\n    completionSources.bookmarks,\n    completionSources.history,\n    completionSources.domains,\n    completionSources.tabs,\n    completionSources.searchEngines,\n  ]),\n  bookmarks: new MultiCompleter([completionSources.bookmarks]),\n  tabs: new MultiCompleter([completionSources.tabs]),\n};\n\n// A query dictionary for `chrome.tabs.query` that will return only the visible tabs.\nconst visibleTabsQueryArgs = { currentWindow: true };\nif (bgUtils.isFirefox()) {\n  // Only Firefox supports hidden tabs.\n  visibleTabsQueryArgs.hidden = false;\n}\n\nfunction onURLChange(details) {\n  // sendMessage will throw \"Error: Could not establish connection. Receiving end does not exist.\"\n  // if there is no Vimium content script loaded in the given tab. This can occur if the user\n  // navigated to a page where Vimium doesn't have permissions, like chrome:// URLs. This error is\n  // noisy and mysterious (it usually doesn't have a valid line number), so we silence it.\n  const message = {\n    handler: \"checkEnabledAfterURLChange\",\n    silenceLogging: true,\n  };\n  chrome.tabs.sendMessage(details.tabId, message, { frameId: details.frameId })\n    .catch(() => {});\n}\n\n// Re-check whether Vimium is enabled for a frame when the URL changes without a reload.\n// There's no reliable way to detect when the URL has changed in the content script, so we\n// have to use the webNavigation API in our background script.\nchrome.webNavigation.onHistoryStateUpdated.addListener(onURLChange); // history.pushState.\nchrome.webNavigation.onReferenceFragmentUpdated.addListener(onURLChange); // Hash changed.\n\nif (!globalThis.isUnitTests) {\n  // Cache \"content_scripts/vimium.css\" in chrome.storage.session for UI components.\n  (function () {\n    const url = chrome.runtime.getURL(\"content_scripts/vimium.css\");\n    fetch(url).then(async (response) => {\n      if (response.ok) {\n        chrome.storage.session.set({ vimiumCSSInChromeStorage: await response.text() });\n      }\n    });\n  })();\n}\n\nfunction muteTab(tab) {\n  chrome.tabs.update(tab.id, { muted: !tab.mutedInfo.muted });\n}\n\nfunction toggleMuteTab(request, sender) {\n  const currentTab = request.tab;\n  const tabId = request.tabId;\n  const registryEntry = request.registryEntry;\n\n  if ((registryEntry.options.all != null) || (registryEntry.options.other != null)) {\n    // If there are any audible, unmuted tabs, then we mute them; otherwise we unmute any muted tabs.\n    chrome.tabs.query({ audible: true }, function (tabs) {\n      let tab;\n      if (registryEntry.options.other != null) {\n        tabs = tabs.filter((t) => t.id !== currentTab.id);\n      }\n      const audibleUnmutedTabs = tabs.filter((t) => t.audible && !t.mutedInfo.muted);\n      if (audibleUnmutedTabs.length >= 0) {\n        chrome.tabs.sendMessage(tabId, {\n          frameId: sender.frameId,\n          handler: \"showMessage\",\n          message: `Muting ${audibleUnmutedTabs.length} tab(s).`,\n        });\n        for (tab of audibleUnmutedTabs) {\n          muteTab(tab);\n        }\n      } else {\n        chrome.tabs.sendMessage(tabId, {\n          frameId: sender.frameId,\n          handler: \"showMessage\",\n          message: \"Unmuting all muted tabs.\",\n        });\n        for (tab of tabs) {\n          if (tab.mutedInfo.muted) {\n            muteTab(tab);\n          }\n        }\n      }\n    });\n  } else {\n    if (currentTab.mutedInfo.muted) {\n      chrome.tabs.sendMessage(tabId, {\n        frameId: sender.frameId,\n        handler: \"showMessage\",\n        message: \"Unmuted tab.\",\n      });\n    } else {\n      chrome.tabs.sendMessage(tabId, {\n        frameId: sender.frameId,\n        handler: \"showMessage\",\n        message: \"Muted tab.\",\n      });\n    }\n    muteTab(currentTab);\n  }\n}\n\n// Find a tab's actual index in a given tab array returned by chrome.tabs.query. In Firefox, there\n// may be hidden tabs, so tab.tabIndex may not be the actual index into the array of visible tabs.\nfunction getTabIndex(tab, tabs) {\n  // First check if the tab is where we expect it, to avoid searching the array.\n  if (tabs.length > tab.index && tabs[tab.index].index === tab.index) {\n    return tab.index;\n  } else {\n    return tabs.findIndex((t) => t.index === tab.index);\n  }\n}\n\n//\n// Selects the tab with the ID specified in request.id\n//\nasync function selectSpecificTab(request) {\n  const tab = await chrome.tabs.get(request.id);\n  // Focus the tab's window. TODO(philc): Why are we null-checking chrome.windows here?\n  if (chrome.windows != null) {\n    await chrome.windows.update(tab.windowId, { focused: true });\n  }\n  await chrome.tabs.update(request.id, { active: true });\n}\n\nfunction moveTab({ count, tab, registryEntry }) {\n  if (registryEntry.command === \"moveTabLeft\") {\n    count = -count;\n  }\n  return chrome.tabs.query(visibleTabsQueryArgs, function (tabs) {\n    const pinnedCount = (tabs.filter((tab) => tab.pinned)).length;\n    const minIndex = tab.pinned ? 0 : pinnedCount;\n    const maxIndex = (tab.pinned ? pinnedCount : tabs.length) - 1;\n    // The tabs array index of the new position.\n    const moveIndex = Math.max(minIndex, Math.min(maxIndex, getTabIndex(tab, tabs) + count));\n    return chrome.tabs.move(tab.id, {\n      index: tabs[moveIndex].index,\n    });\n  });\n}\n\nfunction createRepeatCommand(command) {\n  return async function (request) {\n    let i = request.count - 1;\n    const r = Object.assign({}, request);\n    delete r.count;\n    while (i >= 0) {\n      i--;\n      await command(r);\n    }\n  };\n}\n\nfunction nextZoomLevel(currentZoom, steps) {\n  // Chrome's default zoom levels.\n  const chromeLevels = [0.25, 0.33, 0.5, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5];\n  // Firefox's default zoom levels.\n  const firefoxLevels = [0.3, 0.5, 0.67, 0.8, 0.9, 1, 1.1, 1.2, 1.33, 1.5, 1.7, 2, 2.4, 3, 4, 5];\n\n  let zoomLevels = chromeLevels; // Chrome by default\n  if (bgUtils.isFirefox()) {\n    zoomLevels = firefoxLevels;\n  }\n\n  if (steps === 0) { // Nothing\n    return currentZoom;\n  } else if (steps > 0) { // In\n    // Chrome sometimes returns values with floating point errors.\n    // Example: Chrome gives 0.32999999999999996 instead of 0.33.\n    currentZoom += 0.0000001; // This is needed to solve floating point bugs in Chrome.\n    const nextIndex = zoomLevels.findIndex((level) => level > currentZoom);\n    const floorIndex = nextIndex == -1 ? zoomLevels.length : nextIndex - 1;\n    return zoomLevels[Math.min(zoomLevels.length - 1, floorIndex + steps)];\n  } else if (steps < 0) { // Out\n    currentZoom -= 0.0000001; // This is needed to solve floating point bugs in Chrome.\n    let ceilIndex = zoomLevels.findIndex((level) => level >= currentZoom);\n    ceilIndex = ceilIndex == -1 ? zoomLevels.length : ceilIndex;\n    return zoomLevels[Math.max(0, ceilIndex + steps)];\n  }\n}\n\n// These are commands which are bound to keystrokes which must be handled by the background page.\n// They are mapped in commands.js.\nconst BackgroundCommands = {\n  // Create a new tab. Also, with:\n  //     map X createTab http://www.bbc.com/news\n  // create a new tab with the given URL.\n  createTab: createRepeatCommand(async function (request) {\n    if (request.urls == null) {\n      if (request.url) {\n        // If the request contains a URL, then use it.\n        request.urls = [request.url];\n      } else {\n        // Otherwise, if we have a registryEntry containing URLs, then use them.\n        const options = Object.keys(request.registryEntry.options);\n        const promises = options.map((opt) => UrlUtils.isUrl(opt));\n        const isUrl = await Promise.all(promises);\n        const urlList = options.filter((_, i) => isUrl[i]);\n        if (urlList.length > 0) {\n          request.urls = urlList;\n        } else {\n          // Otherwise, just create a new tab.\n          let url;\n          const destination = Settings.get(\"newTabDestination\");\n          const customUrl = Settings.get(\"newTabCustomUrl\");\n          if (destination == Settings.newTabDestinations.vimiumNewTabPage) {\n            url = Settings.vimiumNewTabPageUrl;\n          } else if (destination == Settings.newTabDestinations.customUrl && customUrl.length > 0) {\n            url = customUrl;\n          } else {\n            url = UrlUtils.chromeNewTabUrl;\n          }\n          request.urls = [url];\n        }\n      }\n    }\n    if (request.registryEntry.options.incognito || request.registryEntry.options.window) {\n      // Firefox does not allow an incognito window to be created with the URL about:newtab. It\n      // throws this error: \"Illegal URL: about:newtab\".\n      const urls = request.urls.filter((u) => u != UrlUtils.chromeNewTabUrl);\n      const windowConfig = {\n        url: urls,\n        incognito: request.registryEntry.options.incognito || false,\n      };\n      await chrome.windows.create(windowConfig);\n    } else {\n      const urls = request.urls.slice().reverse();\n      if (request.position == null) {\n        request.position = request.registryEntry.options.position;\n      }\n      while (urls.length > 0) {\n        const url = urls.pop();\n        const tab = await TabOperations.openUrlInNewTab(Object.assign(request, { url }));\n        // Ensure subsequent invocations of this command place the next tab directly after this one.\n        Object.assign(request, { tab, position: \"after\", active: false });\n      }\n    }\n  }),\n\n  duplicateTab: createRepeatCommand(async (request) => {\n    const tab = await chrome.tabs.duplicate(request.tabId);\n    // Ensure subsequent invocations of this command place the next tab directly after this one.\n    request.tabId = tab.id;\n  }),\n\n  moveTabToNewWindow({ count, tab }) {\n    // TODO(philc): Switch to the promise API of chrome.tabs.query.\n    chrome.tabs.query(visibleTabsQueryArgs, function (tabs) {\n      const activeTabIndex = getTabIndex(tab, tabs);\n      const startTabIndex = Math.max(0, Math.min(activeTabIndex, tabs.length - count));\n      [tab, ...tabs] = tabs.slice(startTabIndex, startTabIndex + count);\n      chrome.windows.create({ tabId: tab.id, incognito: tab.incognito }, function (window) {\n        chrome.tabs.move(tabs.map((t) => t.id), { windowId: window.id, index: -1 });\n      });\n    });\n  },\n\n  nextTab(request) {\n    return selectTab(\"next\", request);\n  },\n  previousTab(request) {\n    return selectTab(\"previous\", request);\n  },\n  firstTab(request) {\n    return selectTab(\"first\", request);\n  },\n  lastTab(request) {\n    return selectTab(\"last\", request);\n  },\n  async removeTab({ count, tab }) {\n    await forCountTabs(count, tab, (tab) => {\n      // In Firefox, Ctrl-W will not close a pinned tab, but on Chrome, it will. We try to be\n      // consistent with each browser's UX for pinned tabs.\n      if (tab.pinned && bgUtils.isFirefox()) return;\n      chrome.tabs.remove(tab.id);\n    });\n  },\n  restoreTab: createRepeatCommand(async (request) => {\n    await chrome.sessions.restore(null);\n  }),\n  async togglePinTab({ count, tab }) {\n    await forCountTabs(count, tab, (tab) => {\n      chrome.tabs.update(tab.id, { pinned: !tab.pinned });\n    });\n  },\n  toggleMuteTab,\n  moveTabLeft: moveTab,\n  moveTabRight: moveTab,\n\n  async setZoom({ tabId, registryEntry }) {\n    const level = registryEntry.options?.[\"level\"] ?? \"1\";\n    const newZoom = parseFloat(level);\n    if (!isNaN(newZoom)) {\n      chrome.tabs.setZoom(tabId, newZoom);\n    }\n  },\n  async zoomIn({ count, tabId }) {\n    const currentZoom = await chrome.tabs.getZoom(tabId);\n    const newZoom = nextZoomLevel(currentZoom, count);\n    chrome.tabs.setZoom(tabId, newZoom);\n  },\n  async zoomOut({ count, tabId }) {\n    const currentZoom = await chrome.tabs.getZoom(tabId);\n    const newZoom = nextZoomLevel(currentZoom, -count);\n    chrome.tabs.setZoom(tabId, newZoom);\n  },\n  async zoomReset({ tabId }) {\n    chrome.tabs.setZoom(tabId, 0); // setZoom of 0 sets to the tab default.\n  },\n\n  async nextFrame({ count, tabId }) {\n    // We're assuming that these frames are returned in the order that they appear on the page. This\n    // seems to be the case empirically. If it's ever needed, we could also sort by frameId.\n    let frameIds = await getFrameIdsForTab(tabId);\n    const promises = frameIds.map(async (frameId) => {\n      // It is possible that this sendMessage call fails, if a frame gets unloaded while the request\n      // is in flight.\n      let isError = false;\n      const status = await (chrome.tabs.sendMessage(tabId, { handler: \"getFocusStatus\" }, {\n        frameId: frameId,\n      }).catch((_) => {\n        isError = true;\n      }));\n      return { frameId, status, isError };\n    });\n\n    const frameResponses = (await Promise.all(promises)).filter((r) => !r.isError);\n\n    const focusedFrameId = frameResponses.find(({ status }) => status.focused)?.frameId;\n    // It's theoretically possible that focusedFrameId is null if the user switched tabs or away\n    // from the browser while the request is in flight.\n    if (focusedFrameId == null) return;\n\n    // Prune any frames which gave an error response (i.e. they disappeared).\n    frameIds = frameResponses.filter((r) => r.status.focusable).map((r) => r.frameId);\n\n    const index = frameIds.indexOf(focusedFrameId);\n    count = count ?? 1;\n    const nextIndex = (index + count) % frameIds.length;\n    if (index == nextIndex) return;\n    await chrome.tabs.sendMessage(tabId, { handler: \"focusFrame\", highlight: true }, {\n      frameId: frameIds[nextIndex],\n    });\n  },\n\n  async closeTabsOnLeft(request) {\n    await removeTabsRelative(\"before\", request);\n  },\n  async closeTabsOnRight(request) {\n    await removeTabsRelative(\"after\", request);\n  },\n  async closeOtherTabs(request) {\n    await removeTabsRelative(\"both\", request);\n  },\n\n  async visitPreviousTab({ count, tab }) {\n    await bgUtils.tabRecency.init();\n    let tabIds = bgUtils.tabRecency.getTabsByRecency();\n    tabIds = tabIds.filter((tabId) => tabId !== tab.id);\n    if (tabIds.length > 0) {\n      const id = tabIds[(count - 1) % tabIds.length];\n      selectSpecificTab({ id });\n    }\n  },\n\n  async reload({ count, tab, registryEntry }) {\n    const bypassCache = registryEntry.options.hard != null ? registryEntry.options.hard : false;\n    await forCountTabs(count, tab, (tab) => {\n      chrome.tabs.reload(tab.id, { bypassCache });\n    });\n  },\n};\n\nasync function forCountTabs(count, currentTab, callback) {\n  const tabs = await chrome.tabs.query(visibleTabsQueryArgs);\n  const activeTabIndex = getTabIndex(currentTab, tabs);\n  const startTabIndex = Math.max(0, Math.min(activeTabIndex, tabs.length - count));\n  for (const tab of tabs.slice(startTabIndex, startTabIndex + count)) {\n    callback(tab);\n  }\n}\n\n// Remove tabs before, after, or either side of the currently active tab\nasync function removeTabsRelative(direction, { count, tab }) {\n  // count is null if the user didn't type a count prefix before issuing this command and didn't\n  // specify a count=n option in their keymapping settings. Interpret this as closing all tabs on\n  // either side.\n  if (count == null) count = 99999;\n  const activeTab = tab;\n  const tabs = await chrome.tabs.query(visibleTabsQueryArgs);\n  const activeIndex = getTabIndex(activeTab, tabs);\n  const toRemove = tabs.filter((tab, tabIndex) => {\n    if (tab.pinned || tab.id == activeTab.id) {\n      return false;\n    }\n    switch (direction) {\n      case \"before\":\n        return tabIndex < activeIndex &&\n          tabIndex >= activeIndex - count;\n      case \"after\":\n        return tabIndex > activeIndex &&\n          tabIndex <= activeIndex + count;\n      case \"both\":\n        return true;\n    }\n  });\n\n  await chrome.tabs.remove(toRemove.map((t) => t.id));\n}\n\n// Selects a tab before or after the currently selected tab.\n// - direction: \"next\", \"previous\", \"first\" or \"last\".\nfunction selectTab(direction, { count, tab }) {\n  chrome.tabs.query(visibleTabsQueryArgs, function (tabs) {\n    if (tabs.length > 1) {\n      const toSelect = (() => {\n        switch (direction) {\n          case \"next\":\n            return (getTabIndex(tab, tabs) + count) % tabs.length;\n          case \"previous\":\n            return ((getTabIndex(tab, tabs) - count) + (count * tabs.length)) % tabs.length;\n          case \"first\":\n            return Math.min(tabs.length - 1, count - 1);\n          case \"last\":\n            return Math.max(0, tabs.length - count);\n        }\n      })();\n      chrome.tabs.update(tabs[toSelect].id, { active: true });\n    }\n  });\n}\n\nchrome.webNavigation.onCommitted.addListener(async ({ tabId, frameId }) => {\n  // Vimium can't run on all tabs (e.g. chrome:// URLs). insertCSS will throw an error on such tabs,\n  // which is expected, and noise. Swallow that error.\n  const swallowError = () => {};\n  await Settings.onLoaded();\n  await chrome.scripting.insertCSS({\n    css: Settings.get(\"userDefinedLinkHintCss\"),\n    target: {\n      tabId: tabId,\n      frameIds: [frameId],\n    },\n  }).catch(swallowError);\n});\n\n// Returns all frame IDs for the given tab. Note that in Chrome, this will omit frame IDs for frames\n// or iFrames which contain chrome-extension:// URLs, even if those pages are listed in Vimium's\n// web_accessible_resources in manifest.json.\nasync function getFrameIdsForTab(tabId) {\n  // getAllFrames unfortunately excludes frames and iframes from chrome-extension:// URLs.\n  // In Firefox, by contrast, pages with moz-extension:// URLs are included.\n  const frames = await chrome.webNavigation.getAllFrames({ tabId: tabId });\n  return frames.map((f) => f.frameId);\n}\n\nconst HintCoordinator = {\n  // Forward the message in \"request\" to all frames the in sender's tab.\n  broadcastLinkHintsMessage(request, sender) {\n    chrome.tabs.sendMessage(\n      sender.tab.id,\n      Object.assign(request, { handler: \"linkHintsMessage\" }),\n    );\n  },\n\n  // This is sent by the content script once the user issues the link hints command.\n  async prepareToActivateLinkHintsMode(\n    tabId,\n    originatingFrameId,\n    { modeIndex, requestedByHelpDialog, isExtensionPage },\n  ) {\n    const frameIds = await getFrameIdsForTab(tabId);\n    // If link hints was triggered on a Vimium extension page (like the vimium help dialog or\n    // options page), we cannot directly retrieve the frameIds for those pages using the\n    // getFrameIdsForTab. However, as a workaround, if those pages were the pages activating hints,\n    // their frameId is equal to originatingFrameId.\n    if (isExtensionPage && !frameIds.includes(originatingFrameId)) {\n      frameIds.push(originatingFrameId);\n    }\n    const timeout = 3000;\n    let promises = frameIds.map(async (frameId) => {\n      let promise = chrome.tabs.sendMessage(\n        tabId,\n        {\n          handler: \"linkHintsMessage\",\n          messageType: \"getHintDescriptors\",\n          modeIndex,\n          requestedByHelpDialog,\n        },\n        { frameId },\n      );\n\n      promise = Utils.promiseWithTimeout(promise, timeout)\n        .catch((error) => Utils.debugLog(\"Swallowed getHintDescriptors error:\", error));\n\n      const descriptors = await promise;\n\n      return {\n        frameId,\n        descriptors,\n      };\n    });\n\n    const responses = (await Promise.all(promises))\n      .filter((r) => r.descriptors != null);\n\n    const frameIdToDescriptors = {};\n    for (const { frameId, descriptors } of responses) {\n      frameIdToDescriptors[frameId] = descriptors;\n    }\n\n    promises = responses.map(({ frameId }) => {\n      // Don't send this frame's own link hints back to it -- they're already stored in that frame's\n      // content script. At the time that we wrote this, this resulted in a 150% speedup for link\n      // busy sites like Reddit.\n      const outgoingFrameIdToHintDescriptors = Object.assign({}, frameIdToDescriptors);\n      delete outgoingFrameIdToHintDescriptors[frameId];\n      return chrome.tabs.sendMessage(\n        tabId,\n        {\n          handler: \"linkHintsMessage\",\n          messageType: \"activateMode\",\n          frameId: frameId,\n          originatingFrameId: originatingFrameId,\n          frameIdToHintDescriptors: outgoingFrameIdToHintDescriptors,\n          modeIndex: modeIndex,\n        },\n        { frameId },\n      ).catch((error) => {\n        Utils.debugLog(\n          \"Swallowed linkHints activateMode error:\",\n          error,\n          \"tabId\",\n          tabId,\n          \"frameId\",\n          frameId,\n        );\n      });\n    });\n    await Promise.all(promises);\n  },\n};\n\nconst sendRequestHandlers = {\n  runBackgroundCommand(request, sender) {\n    return BackgroundCommands[request.registryEntry.command](request, sender);\n  },\n  // getCurrentTabUrl is used by the content scripts to get their full URL, because window.location\n  // cannot help with Chrome-specific URLs like \"view-source:http:..\".\n  getCurrentTabUrl({ tab }) {\n    return tab.url;\n  },\n  openUrlInNewTab: createRepeatCommand(async (request, callback) => {\n    await TabOperations.openUrlInNewTab(request, callback);\n  }),\n  async openUrlInNewWindow(request) {\n    await TabOperations.openUrlInNewWindow(request);\n  },\n  async openUrlInIncognito(request) {\n    await chrome.windows.create({\n      incognito: true,\n      url: await UrlUtils.convertToUrl(request.url),\n    });\n  },\n  openUrlInCurrentTab: TabOperations.openUrlInCurrentTab,\n  openOptionsPageInNewTab(request) {\n    return chrome.tabs.create({\n      url: chrome.runtime.getURL(\"pages/options.html\"),\n      index: request.tab.index + 1,\n    });\n  },\n\n  launchSearchQuery({ query, openInNewTab }) {\n    const disposition = openInNewTab ? \"NEW_TAB\" : \"CURRENT_TAB\";\n    chrome.search.query({ disposition, text: query });\n  },\n\n  domReady(_, sender) {\n    const isTopFrame = sender.frameId == 0;\n    if (!isTopFrame) return;\n    const tabId = sender.tab.id;\n    // The only feature that uses tabLoadedHandlers is marks.\n    if (tabLoadedHandlers[tabId]) tabLoadedHandlers[tabId]();\n    delete tabLoadedHandlers[tabId];\n  },\n\n  nextFrame: BackgroundCommands.nextFrame,\n  selectSpecificTab,\n  createMark: marks.create,\n  gotoMark: marks.goto,\n  // Send a message to all frames in the current tab. If request.frameId is provided, then send\n  // messages to only the frame with that ID.\n  sendMessageToFrames(request, sender) {\n    const newRequest = Object.assign({}, request.message);\n    const options = request.frameId != null ? { frameId: request.frameId } : {};\n    chrome.tabs.sendMessage(sender.tab.id, newRequest, options);\n  },\n  broadcastLinkHintsMessage(request, sender) {\n    HintCoordinator.broadcastLinkHintsMessage(request, sender);\n  },\n  prepareToActivateLinkHintsMode(request, sender) {\n    HintCoordinator.prepareToActivateLinkHintsMode(sender.tab.id, sender.frameId, request);\n  },\n\n  async initializeFrame(request, sender) {\n    // Check whether the extension is enabled for the top frame's URL, rather than the URL of the\n    // specific frame that sent this request.\n    const enabledState = exclusions.isEnabledForUrl(sender.tab.url);\n\n    const isTopFrame = sender.frameId == 0;\n    if (isTopFrame) {\n      let whichIcon;\n      if (!enabledState.isEnabledForUrl) {\n        whichIcon = \"disabled\";\n      } else if (enabledState.passKeys.length > 0) {\n        whichIcon = \"partial\";\n      } else {\n        whichIcon = \"enabled\";\n      }\n\n      let iconSet = {\n        \"enabled\": {\n          \"16\": \"../icons/action_enabled_16.png\",\n          \"32\": \"../icons/action_enabled_32.png\",\n        },\n        \"partial\": {\n          \"16\": \"../icons/action_partial_16.png\",\n          \"32\": \"../icons/action_partial_32.png\",\n        },\n        \"disabled\": {\n          \"16\": \"../icons/action_disabled_16.png\",\n          \"32\": \"../icons/action_disabled_32.png\",\n        },\n      };\n\n      if (bgUtils.isFirefox()) {\n        // Only Firefox supports SVG icons.\n        iconSet = {\n          \"enabled\": \"../icons/action_enabled.svg\",\n          \"partial\": \"../icons/action_partial.svg\",\n          \"disabled\": \"../icons/action_disabled.svg\",\n        };\n      }\n\n      chrome.action.setIcon({ path: iconSet[whichIcon], tabId: sender.tab.id });\n    }\n\n    const response = Object.assign({\n      isFirefox: bgUtils.isFirefox(),\n      firefoxVersion: await bgUtils.getFirefoxVersion(),\n      frameId: sender.frameId,\n    }, enabledState);\n\n    return response;\n  },\n\n  async getBrowserInfo() {\n    return {\n      isFirefox: bgUtils.isFirefox(),\n      firefoxVersion: await bgUtils.getFirefoxVersion(),\n    };\n  },\n\n  async filterCompletions(request) {\n    const completer = completers[request.completerName];\n    let response = await completer.filter(request);\n\n    // NOTE(smblott): response contains `relevancyFunction` (function) properties which cause\n    // postMessage, below, to fail in Firefox. See #2576. We cannot simply delete these methods,\n    // as they're needed elsewhere. Converting the response to JSON and back is a quick and easy\n    // way to sanitize the object.\n    response = JSON.parse(JSON.stringify(response));\n\n    return response;\n  },\n\n  refreshCompletions(request) {\n    const completer = completers[request.completerName];\n    completer.refresh();\n  },\n\n  cancelCompletions(request) {\n    const completer = completers[request.completerName];\n    completer.cancel();\n  },\n};\n\nUtils.addChromeRuntimeOnMessageListener(\n  Object.keys(sendRequestHandlers),\n  async function (request, sender) {\n    Utils.debugLog(\n      \"main.js: onMessage:%ourl:%otab:%oframe:%o\",\n      request.handler,\n      sender.url.replace(/https?:\\/\\//, \"\"),\n      sender.tab?.id,\n      sender.frameId,\n      // request // Often useful for debugging.\n    );\n    // NOTE(philc): We expect all messages to come from a content script in a tab. I've observed in\n    // Firefox when the extension is first installed, domReady and initializeFrame messages come from\n    // content scripts in about:blank URLs, which have a null sender.tab. I don't know what this\n    // corresponds to. Since we expect a valid sender.tab, ignore those messages.\n    if (sender.tab == null) return;\n    await Settings.onLoaded();\n    request = Object.assign({ count: 1 }, request, {\n      tab: sender.tab,\n      tabId: sender.tab.id,\n    });\n    const handler = sendRequestHandlers[request.handler];\n    const result = handler ? await handler(request, sender) : null;\n    return result;\n  },\n);\n\n// Remove chrome.storage.local/findModeRawQueryListIncognito if there are no remaining\n// incognito-mode windows. Since the common case is that there are none to begin with, we first\n// check whether the key is set at all.\nchrome.tabs.onRemoved.addListener(function (tabId) {\n  if (tabLoadedHandlers[tabId]) {\n    delete tabLoadedHandlers[tabId];\n  }\n  chrome.storage.session.get(\"findModeRawQueryListIncognito\", function (items) {\n    if (items.findModeRawQueryListIncognito) {\n      return chrome.windows != null\n        ? chrome.windows.getAll(null, function (windows) {\n          for (const window of windows) {\n            if (window.incognito) return;\n          }\n          // There are no remaining incognito-mode tabs, and findModeRawQueryListIncognito is set.\n          return chrome.storage.session.remove(\"findModeRawQueryListIncognito\");\n        })\n        : undefined;\n    }\n  });\n});\n\n// Convenience function for development use.\nglobalThis.runTests = () => open(chrome.runtime.getURL(\"tests/dom_tests/dom_tests.html\"));\n\n//\n// Begin initialization.\n//\n\n// True if the major version of Vimium has changed.\n// - previousVersion: this will be null for new installs.\nfunction majorVersionHasIncreased(previousVersion) {\n  const currentVersion = Utils.getCurrentVersion();\n  if (previousVersion == null) return false;\n  const currentMajorVersion = currentVersion.split(\".\").slice(0, 2).join(\".\");\n  const previousMajorVersion = previousVersion.split(\".\").slice(0, 2).join(\".\");\n  return Utils.compareVersions(currentMajorVersion, previousMajorVersion) == 1;\n}\n\n// Show notification on upgrade.\nasync function showUpgradeMessageIfNecessary(onInstalledDetails) {\n  const currentVersion = Utils.getCurrentVersion();\n  // We do not show an upgrade message for patch/silent releases. Such releases have the same\n  // major and minor version numbers.\n  if (\n    !majorVersionHasIncreased(onInstalledDetails.previousVersion) ||\n    Settings.get(\"hideUpdateNotifications\")\n  ) {\n    return;\n  }\n\n  // NOTE(philc): These notifications use the system notification UI. So, if you don't have\n  // notifications enabled from your browser (e.g. in Notification Settings in OSX), then\n  // chrome.notification.create will succeed, but you won't see it.\n  const notificationId = \"VimiumUpgradeNotification\";\n  await chrome.notifications.create(\n    notificationId,\n    {\n      type: \"basic\",\n      iconUrl: chrome.runtime.getURL(\"icons/icon128.png\"),\n      title: \"Vimium Upgrade\",\n      message:\n        `Vimium has been upgraded to version ${currentVersion}. Click here for more information.`,\n      isClickable: true,\n    },\n  );\n  if (!chrome.runtime.lastError) {\n    chrome.notifications.onClicked.addListener(async function (id) {\n      if (id != notificationId) return;\n      const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n      const tab = tabs[0];\n      TabOperations.openUrlInNewTab({\n        tab,\n        tabId: tab.id,\n        url: \"https://github.com/philc/vimium/blob/master/CHANGELOG.md\",\n      });\n    });\n  }\n}\n\nasync function injectContentScriptsAndCSSIntoExistingTabs() {\n  const manifest = chrome.runtime.getManifest();\n  const contentScriptConfig = manifest.content_scripts[0];\n  const contentScripts = contentScriptConfig.js;\n  const cssFiles = contentScriptConfig.css;\n\n  // The scripting.executeScript and scripting.insertCSS APIs can fail if we don't have permissions\n  // to run scripts in a given tab. Examples are: chrome:// URLs, file:// pages (if the user hasn't\n  // granted Vimium access to file URLs), and probably incognito tabs (unconfirmed). Calling these\n  // APIs on such tabs results in an error getting logged on the background page. To avoid this\n  // noise, we swallow the failures. We could instead try to determine if the tab is scriptable by\n  // checking its URL scheme before calling these APIs, but that approach has some nuance to it.\n  // This is simpler.\n  const swallowError = (_) => {};\n\n  const tabs = await chrome.tabs.query({ status: \"complete\" });\n  for (const tab of tabs) {\n    const target = { tabId: tab.id, allFrames: true };\n\n    // Inject all of our content javascripts.\n    chrome.scripting.executeScript({\n      files: contentScripts,\n      target: target,\n    }).catch(swallowError);\n\n    // Inject our extension's CSS.\n    chrome.scripting.insertCSS({\n      files: cssFiles,\n      target: target,\n    }).catch(swallowError);\n\n    // Inject the user's link hint CSS.\n    chrome.scripting.insertCSS({\n      css: Settings.get(\"userDefinedLinkHintCss\"),\n      target: target,\n    }).catch(swallowError);\n  }\n}\n\nasync function initializeExtension() {\n  await Settings.onLoaded();\n  await Commands.init();\n}\n\n// The browser may have tabs already open. We inject the content scripts and Vimium's CSS\n// immediately so that the extension is running on the pages immediately after install, rather than\n// having to reload those pages.\nchrome.runtime.onInstalled.addListener(async (details) => {\n  Utils.debugLog(\"chrome.runtime.onInstalled\");\n\n  // NOTE(philc): In my testing, when the onInstalled event occurs, the onStartup event does not\n  // also occur, so we need to initialize Vimium here.\n  await initializeExtension();\n\n  const shouldInjectContentScripts =\n    // NOTE(philc): 2023-06-16: we do not install the content scripts in all tabs on Firefox.\n    // I believe this is because Firefox does this already. See https://stackoverflow.com/a/37132144\n    // for commentary.\n    !bgUtils.isFirefox() &&\n    ([\"chrome_update\", \"shared_module_update\"].includes(details.reason));\n  if (shouldInjectContentScripts) injectContentScriptsAndCSSIntoExistingTabs();\n\n  await showUpgradeMessageIfNecessary(details);\n});\n\n// Note that this event is not fired when an incognito profile is started.\nchrome.runtime.onStartup.addListener(async () => {\n  Utils.debugLog(\"chrome.runtime.onStartup\");\n  await initializeExtension();\n});\n\nObject.assign(globalThis, {\n  TabOperations,\n  // Exported for tests:\n  HintCoordinator,\n  BackgroundCommands,\n  majorVersionHasIncreased,\n  nextZoomLevel,\n});\n\n// The chrome.runtime.onStartup and onInstalled events are not fired when disabling and then\n// re-enabling the extension in developer mode, so we also initialize the extension here.\ninitializeExtension();\n"
  },
  {
    "path": "background_scripts/marks.js",
    "content": "import * as TabOperations from \"./tab_operations.js\";\n\n// This returns the key which is used for storing mark locations in chrome.storage.sync.\n// Exported for tests.\nexport function getLocationKey(markName) {\n  return `vimiumGlobalMark|${markName}`;\n}\n\n// Get the part of a URL we use for matching here (that is, everything up to the first anchor).\nfunction getBaseUrl(url) {\n  return url.split(\"#\")[0];\n}\n\n// Create a global mark. We record vimiumSecret with the mark so that we can tell later, when the\n// mark is used, whether this is the original Vimium session or a subsequent session. This affects\n// whether or not tabId can be considered valid.\nexport async function create(req, sender) {\n  const items = await chrome.storage.session.get(\"vimiumSecret\");\n  const markInfo = {\n    vimiumSecret: items.vimiumSecret,\n    markName: req.markName,\n    url: getBaseUrl(sender.tab.url),\n    tabId: sender.tab.id,\n    scrollX: req.scrollX,\n    scrollY: req.scrollY,\n  };\n\n  if ((markInfo.scrollX != null) && (markInfo.scrollY != null)) {\n    saveMark(markInfo);\n  } else {\n    // The front-end frame hasn't provided the scroll position (because it's not the top frame\n    // within its tab). We need to ask the top frame what its scroll position is.\n    chrome.tabs.sendMessage(sender.tab.id, { handler: \"getScrollPosition\" }, (response) => {\n      saveMark(Object.assign(markInfo, { scrollX: response.scrollX, scrollY: response.scrollY }));\n    });\n  }\n}\n\nfunction saveMark(markInfo) {\n  const item = {};\n  item[getLocationKey(markInfo.markName)] = markInfo;\n  chrome.storage.local.set(item);\n}\n\n// Goto a global mark. We try to find the original tab. If we can't find that, then we try to find\n// another tab with the original URL, and use that. And if we can't find such an existing tab, then\n// we create a new one. Whichever of those we do, we then set the scroll position to the original\n// scroll position.\nexport async function goto(req) {\n  const vimiumSecret = (await chrome.storage.session.get(\"vimiumSecret\"))[\"vimiumSecret\"];\n  const key = getLocationKey(req.markName);\n  const items = await chrome.storage.local.get(key);\n  const markInfo = items[key];\n  if (markInfo.vimiumSecret !== vimiumSecret) {\n    // This is a different Vimium instantiation, so markInfo.tabId is definitely out of date.\n    Utils.debugLog(\"marks: vimiumSecret is incorrect.\");\n    await focusOrLaunch(markInfo, req);\n  } else {\n    // Check whether markInfo.tabId still exists. According to\n    // https://developer.chrome.com/extensions/tabs, tab Ids are unqiue within a Chrome\n    // session. So, if we find a match, we can use it.\n    let tab;\n    // This will throw an error if the tab doesn't exist.\n    try {\n      tab = await chrome.tabs.get(markInfo.tabId);\n    } catch {\n      // Swallow.\n    }\n    const originalTabStillExists = tab?.url && (markInfo.url === getBaseUrl(tab.url));\n    if (originalTabStillExists) {\n      await gotoPositionInTab(markInfo);\n    } else {\n      await focusOrLaunch(markInfo, req);\n    }\n  }\n}\n\n// Focus an existing tab and scroll to the given position within it.\nasync function gotoPositionInTab({ tabId, scrollX, scrollY }) {\n  const tab = await chrome.tabs.update(tabId, { active: true });\n  chrome.windows.update(tab.windowId, { focused: true });\n  chrome.tabs.sendMessage(tabId, { handler: \"setScrollPosition\", scrollX, scrollY });\n}\n\n// The tab we're trying to find no longer exists. We either find another tab with a matching URL and\n// use it, or we create a new tab.\nasync function focusOrLaunch(markInfo, req) {\n  // If we're not going to be scrolling to a particular position in the tab, then we choose all tabs\n  // with a matching URL prefix. Otherwise, we require an exact match (because it doesn't make sense\n  // to scroll unless there's an exact URL match).\n  const markIsScrolled = markInfo.scrollX > 0 || markInfo.scrollY > 0;\n  const query = markIsScrolled ? markInfo.url : `${markInfo.url}*`;\n  const tabs = await chrome.tabs.query({ url: query });\n  if (tabs.length > 0) {\n    // There is at least one matching tab. Pick one and go to it.\n    const tab = await pickTab(tabs);\n    gotoPositionInTab(Object.assign(markInfo, { tabId: tab.id }));\n  } else {\n    // There is no existing matching tab. We'll have to create one.\n    TabOperations.openUrlInNewTab(\n      Object.assign(req, { url: getBaseUrl(markInfo.url) }),\n      (tab) => {\n        // Note. tabLoadedHandlers is defined in \"main.js\". The handler below will be called when\n        // the tab is loaded, its DOM is ready and it registers with the background page.\n        return tabLoadedHandlers[tab.id] = () =>\n          gotoPositionInTab(Object.assign(markInfo, { tabId: tab.id }));\n      },\n    );\n  }\n}\n\n// Given a list of tabs candidate tabs, pick one. Prefer tabs in the current window and tabs with\n// shorter (matching) URLs.\nasync function pickTab(tabs) {\n  // NOTE(philc): We assume getCurrent() can return null, but I didn't confirm this. Also, it should\n  // be impossible for the user to invoke Vimium-related keys if all windows are closed.\n  const window = await chrome.windows.getCurrent();\n  const windowId = window?.id;\n  // Prefer tabs in the current window, if there are any.\n  const tabsInWindow = tabs.filter((tab) => tab.windowId === windowId);\n  if (tabsInWindow.length > 0) tabs = tabsInWindow;\n  // If more than one tab remains and the current tab is still a candidate, then don't pick the\n  // current tab (because jumping to it does nothing).\n  if (tabs.length > 1) {\n    tabs = tabs.filter((t) => !t.active);\n  }\n\n  // Prefer shorter URLs.\n  tabs.sort((a, b) => a.url.length - b.url.length);\n  return tabs[0];\n}\n"
  },
  {
    "path": "background_scripts/reload.js",
    "content": "// Used as part of a debugging workflow when developing the extension.\n\nconst tabs = await chrome.tabs.query({});\n// Clear the background page's console log, if its console window is open.\nconsole.clear();\nawait chrome.runtime.reload();\n\n// Chrome does not execute past this point. This is for Firefox-based browsers. Note that Chrome\n// will not reload every tab that Vimium was open in. That must be done outside of Vimium, e.g. via\n// an Applescript on Mac.\n\n// Firefox will reload every tab as a result of chrome.runtime.reload(). However, the console\n// on those pages does not get cleared for some reason, so we manually clear it.\nfor (const tab of tabs) {\n  chrome.scripting.executeScript({\n    target: { tabId: tab.id },\n    func: () => {\n      console.clear();\n    },\n  });\n}\n\n// We want to close the reload.html page as part of reloading the extension. In both Chrome and\n// Firefox, the browser will automatically close every tab that's specific to this extension,\n// including this page. However, in Firefox, if there's an error in manifest.json and the extension\n// can't reload, then the extension's pages will not get closed, so close this page manually.\n// globalThis.close();\n"
  },
  {
    "path": "background_scripts/tab_operations.js",
    "content": "//\n// Functions for opening URLs in tabs.\n//\n\nimport * as bgUtils from \"../background_scripts/bg_utils.js\";\nimport \"../lib/url_utils.js\";\n\n// Opens request.url in the current tab. If the URL is keywords, search for them in the default\n// search engine. If the URL is a javascript: snippet, execute it in the current tab.\nexport async function openUrlInCurrentTab(request) {\n  const urlStr = await UrlUtils.convertToUrl(request.url);\n  if (urlStr == null) {\n    // The requested destination is not a URL, so treat it like a search query.\n    chrome.search.query({ text: request.url });\n  } else if (UrlUtils.hasJavascriptProtocol(urlStr)) {\n    // Note that when injecting JavaScript, it's subject to the site's CSP. Sites with strict CSPs\n    // (like github.com, developer.mozilla.org) will raise an error when we try to run this code.\n    // See https://github.com/philc/vimium/issues/4331.\n    const scriptingArgs = {\n      target: { tabId: request.tabId },\n      func: (text) => {\n        const prefix = \"javascript:\";\n        text = text.slice(prefix.length).trim();\n        // TODO(philc): Why do we try to double decode here? Discover and then document it.\n        text = decodeURIComponent(text);\n        try {\n          text = decodeURIComponent(text);\n        } catch {\n          // Swallow\n        }\n        const el = document.createElement(\"script\");\n        el.textContent = text;\n        document.head.appendChild(el);\n      },\n      args: [urlStr],\n    };\n    if (!bgUtils.isFirefox()) {\n      // The MAIN world -- where the webpage runs -- is less privileged than the ISOLATED world.\n      // Specifying a world is required for Chrome, but not Firefox.\n      // As of Firefox 118, specifying \"MAIN\" as the world is not yet supported.\n      scriptingArgs.world = \"MAIN\";\n    }\n    chrome.scripting.executeScript(scriptingArgs);\n  } else {\n    // The requested destination is a regular URL.\n    chrome.tabs.update(request.tabId, { url: urlStr });\n  }\n}\n\n// Opens request.url in new tab and switches to it.\n// Returns the created tab.\nexport async function openUrlInNewTab(request) {\n  const urlStr = await UrlUtils.convertToUrl(request.url);\n  const tabConfig = { windowId: request.tab.windowId };\n  const position = request.position;\n  let tabIndex = null;\n  switch (position) {\n    case \"start\":\n      tabIndex = 0;\n      break;\n    case \"before\":\n      tabIndex = request.tab.index;\n      break;\n    // if on Chrome or on Firefox but without openerTabId, `tabs.create` opens a tab at the end.\n    // but on Firefox and with openerTabId, it opens a new tab next to the opener tab\n    case \"end\":\n      tabIndex = bgUtils.isFirefox() ? 9999 : null;\n      break;\n    // \"after\" is the default case when there are no options.\n    default:\n      tabIndex = request.tab.index + 1;\n  }\n  tabConfig.index = tabIndex;\n  tabConfig.active = request.active ?? true;\n  tabConfig.openerTabId = request.tab.id;\n\n  let newTab;\n\n  if (urlStr == null) {\n    // The requested destination is not a URL, so treat it like a search query.\n    //\n    // The chrome.search.query API lets us open the search in a new tab, but it doesn't let us\n    // control the precise position of that tab. So, we open a new blank tab using our position\n    // parameter, and then execute the search in that tab.\n\n    // In Chrome, if we create a blank tab and call chrome.search.query, the omnibar is focused,\n    // which we don't want. To work around that, first create an empty page. This is not needed in\n    // Firefox. And in fact, firefox doesn't support a data:text URL to the chrome.tab.create API.\n    tabConfig.url = bgUtils.isFirefox() ? null : \"data:text/html,<html></html>\";\n    newTab = await chrome.tabs.create(tabConfig);\n    const query = request.url;\n    await chrome.search.query({ text: query, tabId: newTab.id });\n  } else {\n    // The requested destination is a regular URL.\n    // Firefox does not support \"about:newtab\" in chrome.tabs.create, so omit it.\n    if (urlStr != UrlUtils.chromeNewTabUrl) {\n      tabConfig.url = urlStr;\n    }\n    newTab = await chrome.tabs.create(tabConfig);\n  }\n  return newTab;\n}\n\n// Open request.url in new window and switch to it.\nexport async function openUrlInNewWindow(request) {\n  const winConfig = {\n    url: await UrlUtils.convertToUrl(request.url),\n    active: true,\n  };\n  if (request.active != null) {\n    winConfig.active = request.active;\n  }\n  // Firefox does not support \"about:newtab\" in chrome.tabs.create, so omit it.\n  if (tabConfig[\"url\"] === UrlUtils.chromeNewTabUrl) {\n    delete winConfig[\"url\"];\n  }\n  await chrome.windows.create(winConfig);\n}\n"
  },
  {
    "path": "background_scripts/tab_recency.js",
    "content": "// TabRecency associates an integer with each tab id representing how recently it has been accessed.\n// The order of tabs as tracked by TabRecency is used to provide a recency-based ordering in the\n// tabs vomnibar.\n//\n// The values are persisted to chrome.storage.session so that they're not lost when the extension's\n// background page is unloaded.\n//\n// Callers must await TabRecency.init before calling recencyScore or getTabsByRecency.\n//\n// In theory, the browser's tab.lastAccessed timestamp field should allow us to sort tabs by\n// recency, but in practice it does not work across several edge cases. See the comments on #4368.\nclass TabRecency {\n  constructor() {\n    this.counter = 1;\n    this.tabIdToCounter = {};\n    this.loaded = false;\n    this.queuedActions = [];\n  }\n\n  // Add listeners to chrome.tabs, and load the index from session storage.\n  async init() {\n    if (this.initPromise) {\n      await this.initPromise;\n      return;\n    }\n    let resolveFn;\n    this.initPromise = new Promise((resolve, _reject) => {\n      resolveFn = resolve;\n    });\n\n    chrome.tabs.onActivated.addListener((activeInfo) => {\n      this.queueAction(\"register\", activeInfo.tabId);\n    });\n    chrome.tabs.onRemoved.addListener((tabId) => {\n      this.queueAction(\"deregister\", tabId);\n    });\n\n    chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {\n      this.queueAction(\"deregister\", removedTabId);\n      this.queueAction(\"register\", addedTabId);\n    });\n\n    chrome.windows.onFocusChanged.addListener(async (windowId) => {\n      if (windowId == chrome.windows.WINDOW_ID_NONE) return;\n      const tabs = await chrome.tabs.query({ windowId, active: true });\n      if (tabs[0]) {\n        this.queueAction(\"register\", tabs[0].id);\n      }\n    });\n\n    await this.loadFromStorage();\n    while (this.queuedActions.length > 0) {\n      const [action, tabId] = this.queuedActions.shift();\n      this.handleAction(action, tabId);\n    }\n    this.loaded = true;\n    resolveFn();\n  }\n\n  // Loads the index from session storage.\n  async loadFromStorage() {\n    const tabsPromise = chrome.tabs.query({});\n    const storagePromise = chrome.storage.session.get(\"tabRecency\");\n    const [tabs, storage] = await Promise.all([tabsPromise, storagePromise]);\n    if (storage.tabRecency == null) return;\n\n    let maxCounter = 0;\n    for (const counter of Object.values(storage.tabRecency)) {\n      if (maxCounter < counter) maxCounter = counter;\n    }\n    if (this.counter < maxCounter) {\n      this.counter = maxCounter;\n    }\n\n    this.tabIdToCounter = Object.assign({}, storage.tabRecency);\n\n    // Remove any tab IDs which aren't currently loaded.\n    const tabIds = new Set(tabs.map((t) => t.id));\n    for (const id in this.tabIdToCounter) {\n      if (!tabIds.has(parseInt(id))) {\n        delete this.tabIdToCounter[id];\n      }\n    }\n  }\n\n  async saveToStorage() {\n    await chrome.storage.session.set({ tabRecency: this.tabIdToCounter });\n  }\n\n  // - action: \"register\" or \"unregister\".\n  queueAction(action, tabId) {\n    if (!this.loaded) {\n      this.queuedActions.push([action, tabId]);\n    } else {\n      this.handleAction(action, tabId);\n    }\n  }\n\n  // - action: \"register\" or \"unregister\".\n  handleAction(action, tabId) {\n    if (action == \"register\") {\n      this.register(tabId);\n    } else if (action == \"deregister\") {\n      this.deregister(tabId);\n    } else {\n      throw new Error(`Unexpected action type: ${action}`);\n    }\n  }\n\n  register(tabId) {\n    this.counter++;\n    this.tabIdToCounter[tabId] = this.counter;\n    this.saveToStorage();\n  }\n\n  deregister(tabId) {\n    delete this.tabIdToCounter[tabId];\n    this.saveToStorage();\n  }\n\n  // Recently-visited tabs get a higher score (except the current tab, which gets a low score).\n  recencyScore(tabId) {\n    if (!this.loaded) throw new Error(\"TabRecency hasn't yet been loaded.\");\n    const tabCounter = this.tabIdToCounter[tabId];\n    const isCurrentTab = tabCounter == this.counter;\n    if (isCurrentTab) return 0;\n    return (tabCounter ?? 1) / this.counter; // tabCounter may be null.\n  }\n\n  // Returns a list of tab Ids sorted by recency, most recent tab first.\n  getTabsByRecency() {\n    if (!this.loaded) throw new Error(\"TabRecency hasn't yet been loaded.\");\n    const ids = Object.keys(this.tabIdToCounter);\n    ids.sort((a, b) => this.tabIdToCounter[b] - this.tabIdToCounter[a]);\n    return ids.map((id) => parseInt(id));\n  }\n}\n\nexport { TabRecency };\n"
  },
  {
    "path": "background_scripts/user_search_engines.js",
    "content": "import \"../lib/url_utils.js\";\nimport * as commands from \"./commands.js\";\n\n// A struct representing a search engine entry in the \"searchEngine\" setting.\nexport class UserSearchEngine {\n  keyword;\n  url;\n  description;\n  constructor(o) {\n    Object.seal(this);\n    if (o) Object.assign(this, o);\n  }\n}\n\n// Parses a user's search engine configuration from Settings, and stores the parsed results.\n// TODO(philc): Should this be responsible for updating itself when Settings changes, rather than\n// the callers doing so? Or, remove this class and re-parse the configuration every keystroke in\n// Vomnibar, so we don't introduce another layer of caching in the code.\nexport let keywordToEngine = {};\n\n// Returns a result of the shape: { keywordToEngine, validationErrors }.\nexport function parseConfig(configText) {\n  const results = {};\n  const errors = [];\n  for (const line of commands.parseLines(configText)) {\n    const tokens = line.split(/\\s+/);\n    if (tokens.length < 2) {\n      errors.push(`This line has less than two tokens: ${line}`);\n      continue;\n    }\n    if (!tokens[0].includes(\":\")) {\n      errors.push(`This line doesn't include a \":\" character: ${line}`);\n      continue;\n    }\n    const keyword = tokens[0].split(\":\")[0];\n    const url = tokens[1];\n    const description = tokens.length > 2 ? tokens.slice(2).join(\" \") : `search (${keyword})`;\n\n    if (!UrlUtils.urlHasProtocol(url) && !UrlUtils.hasJavascriptProtocol(url)) {\n      errors.push(`This search engine doesn't have a valid URL: ${line}`);\n      continue;\n    }\n    results[keyword] = new UserSearchEngine({ keyword, url, description });\n  }\n  return {\n    keywordToEngine: results,\n    validationErrors: errors,\n  };\n}\n\nexport function set(searchEnginesConfigText) {\n  keywordToEngine = parseConfig(searchEnginesConfigText).keywordToEngine;\n}\n"
  },
  {
    "path": "build_scripts/write_command_listing_page.js",
    "content": "#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env\n// Write a static version of the command_listing.html page to dist, to be hosted on vimium.github.io\n// as an online reference.\n\nimport * as testHelper from \"../tests/unit_tests/test_helper.js\";\nimport \"../tests/unit_tests/test_chrome_stubs.js\";\nimport * as commandListing from \"../pages/command_listing.js\";\nimport * as fs from \"@std/fs\";\nimport * as path from \"@std/path\";\n\nconst scriptDir = path.dirname(path.fromFileUrl(import.meta.url));\n\nchrome.storage.session.get = async (key) => {\n  if (key == \"commandToOptionsToKeys\") {\n    return { commandToOptionsToKeys: {} };\n  }\n};\n\nawait testHelper.jsdomStub(path.join(scriptDir, \"../pages/command_listing.html\"));\nawait Settings.onLoaded();\n\nawait commandListing.populatePage();\n\nconst dist = path.join(scriptDir, \"../dist/command_listing_page\");\nif (await fs.exists(dist)) {\n  await Deno.remove(dist, { recursive: true });\n}\n\nawait Deno.mkdir(dist, { recursive: true });\n\n// Write out all required CSS files to disk.\nconst linkEls = document.head.querySelectorAll(\"link[rel=stylesheet]\");\nfor (const el of linkEls) {\n  const cssPath = el.getAttribute(\"href\");\n  const src = path.join(scriptDir, \"../pages/\" + cssPath);\n  const dest = path.join(dist, path.basename(cssPath));\n  await Deno.copyFile(src, dest);\n  el.setAttribute(\"href\", path.basename(cssPath));\n}\n\n// Remove any external javascripts. Since this page's HTML has already been generated, it doesn't\n// need JS at runtime.\nfor (const el of document.head.querySelectorAll(\"script\")) {\n  el.remove();\n}\n\n// Indicate that this is the hosted version of the page. This causes a link back to the\n// Github repo to be shown.\ndocument.querySelector(\"html\").classList.add(\"hosted-version\");\n\n// Use the website's favicon.\nconst favicon = document.createElement(\"link\");\nfavicon.setAttribute(\"rel\", \"shortcut icon\");\nfavicon.href = \"../vimium_logo.svg\";\ndocument.head.appendChild(favicon);\n\n// The doctype tag is not included in outerHTML; add it back in.\nconst html = \"<!DOCTYPE html>\" + document.documentElement.outerHTML;\nawait Deno.writeTextFile(path.join(dist, \"index.html\"), html);\n"
  },
  {
    "path": "content_scripts/file_urls.css",
    "content": "/* Chrome file:// URLs set draggable=true for links to files (CSS selector .icon.file). This\n * automatically sets -webkit-user-select: none, which disables selecting the file names and so\n * prevents Vimium's search from working as expected. Here, we reset the value back to default. */\n.icon.file {\n  -webkit-user-select: auto !important;\n}\n"
  },
  {
    "path": "content_scripts/hud.js",
    "content": "//\n// A heads-up-display (HUD) for showing Vimium page operations.\n// Note: you cannot interact with the HUD until document.body is available.\n//\nconst HUD = {\n  tween: null,\n  hudUI: null,\n  findMode: null,\n  abandon() {\n    if (this.hudUI) {\n      this.hudUI.hide(false);\n    }\n  },\n\n  // Set by @pasteFromClipboard to handle the value returned by pasteResponse\n  pasteListener: null,\n\n  // This HUD is styled to precisely mimick the chrome HUD on Mac. Use the\n  // \"has_popup_and_link_hud.html\" test harness to tweak these styles to match Chrome's. One\n  // limitation of our HUD display is that it doesn't sit on top of horizontal scrollbars like\n  // Chrome's HUD does.\n\n  handleUIComponentMessage({ data }) {\n    const handlers = {\n      hideFindMode: this.hideFindMode,\n      search: this.search,\n      unfocusIfFocused: this.unfocusIfFocused,\n      pasteResponse: this.pasteResponse,\n      showClipboardUnavailableMessage: this.showClipboardUnavailableMessage,\n    };\n    const handler = handlers[data.name];\n    if (handler) {\n      return handler.bind(this)(data);\n    }\n  },\n\n  async init(focusable) {\n    await Settings.onLoaded();\n    if (focusable == null) {\n      focusable = true;\n    }\n    if (this.hudUI == null) {\n      const queryString = globalThis.vimiumDomTestsAreRunning ? \"?dom_tests=true\" : \"\";\n      this.hudUI = new UIComponent();\n      this.hudUI.load(\n        `pages/hud_page.html${queryString}`,\n        \"vimium-hud-frame\",\n        this.handleUIComponentMessage.bind(this),\n      );\n    }\n    // this[data.name]? data\n    if (this.tween == null) {\n      this.tween = new Tween(\n        \"iframe.vimium-hud-frame.vimium-ui-component-visible\",\n        this.hudUI.shadowDOM,\n      );\n    }\n    const classList = this.hudUI.iframeElement.classList;\n    if (focusable) {\n      classList.remove(\"vimium-non-clickable\");\n      classList.add(\"vimium-clickable\");\n      // Note(gdh1995): Chrome 74 only acknowledges text selection when a frame has been visible.\n      // See more in #3277.\n      // Note(mrmr1993): Show the HUD frame, so Firefox will actually perform the paste.\n      this.hudUI.setIframeVisible(true);\n      // Force the re-computation of styles, so Chrome sends a visibility change message to the\n      // child frame. See https://github.com/philc/vimium/pull/3277#issuecomment-487363284\n      getComputedStyle(this.hudUI.iframeElement).display;\n    } else {\n      classList.remove(\"vimium-non-clickable\");\n      classList.add(\"vimium-clickable\");\n    }\n  },\n\n  // duration - if omitted, the message will show until dismissed.\n  async show(text, duration) {\n    await DomUtils.documentComplete();\n    clearTimeout(this._showForDurationTimerId);\n    // @hudUI.activate will take charge of making it visible\n    await this.init(false);\n    this.hudUI.show({ name: \"show\", text });\n    this.tween.fade(1.0, 150);\n\n    if (duration != null) {\n      this._showForDurationTimerId = setTimeout(() => this.hide(), duration);\n    }\n  },\n\n  async showFindMode(findMode = null) {\n    this.findMode = findMode;\n    await DomUtils.documentComplete();\n    await this.init();\n    this.hudUI.show({ name: \"showFindMode\" });\n    this.tween.fade(1.0, 150);\n  },\n\n  search(data) {\n    // NOTE(mrmr1993): On Firefox, window.find moves the window focus away from the HUD. We use\n    // postFindFocus to put it back, so the user can continue typing.\n    this.findMode.findInPlace(data.query, {\n      \"postFindFocus\": this.hudUI.iframeElement.contentWindow,\n    });\n\n    // Show the number of matches in the HUD UI.\n    const matchCount = FindMode.query.parsedQuery.length > 0 ? FindMode.query.matchCount : 0;\n    const showMatchText = FindMode.query.rawQuery.length > 0;\n    this.hudUI.postMessage({ name: \"updateMatchesCount\", matchCount, showMatchText });\n  },\n\n  // Hide the HUD.\n  // If :immediate is falsy, then the HUD is faded out smoothly (otherwise it is hidden\n  // immediately).\n  // If :updateIndicator is truthy, then we also refresh the mode indicator. The only time we don't\n  // update the mode indicator, is when hide() is called for the mode indicator itself.\n  hide(immediate, updateIndicator) {\n    if (immediate == null) {\n      immediate = false;\n    }\n    if (updateIndicator == null) {\n      updateIndicator = true;\n    }\n    if ((this.hudUI != null) && (this.tween != null)) {\n      clearTimeout(this._showForDurationTimerId);\n      this.tween.stop();\n      if (immediate) {\n        if (updateIndicator) {\n          Mode.setIndicator();\n        } else {\n          this.hudUI.hide();\n        }\n      } else {\n        this.tween.fade(0, 150, () => this.hide(true, updateIndicator));\n      }\n    }\n  },\n\n  // These parameters describe the reason find mode is exiting, and come from the HUD UI component.\n  hideFindMode({ exitEventIsEnter, exitEventIsEscape }) {\n    let postExit;\n    this.findMode.checkReturnToViewPort();\n\n    // An element won't receive a focus event if the search landed on it while we were in the HUD\n    // iframe. To end up with the correct modes active, we create a focus/blur event manually after\n    // refocusing this window.\n    globalThis.focus();\n\n    const focusNode = DomUtils.getSelectionFocusElement();\n    if (document.activeElement != null) {\n      document.activeElement.blur();\n    }\n\n    if (focusNode && focusNode.focus) {\n      focusNode.focus();\n    }\n\n    if (exitEventIsEnter) {\n      FindMode.handleEnter();\n      if (FindMode.query.hasResults) {\n        postExit = () => newPostFindMode();\n      }\n    } else if (exitEventIsEscape) {\n      // We don't want FindMode to handle the click events that FindMode.handleEscape can generate,\n      // so we wait until the mode is closed before running it.\n      postExit = FindMode.handleEscape;\n    }\n\n    this.findMode.exit();\n    if (postExit) {\n      postExit();\n    }\n  },\n\n  // These commands manage copying and pasting from the clipboard in the HUD frame.\n  // NOTE(mrmr1993): We need this to copy and paste on Firefox:\n  // * an element can't be focused in the background page, so copying/pasting doesn't work\n  // * we don't want to disrupt the focus in the page, in case the page is listening for focus/blur\n  // * events.\n  // * the HUD shouldn't be active for this frame while any of the copy/paste commands are running.\n  async copyToClipboard(text) {\n    await DomUtils.documentComplete();\n    await this.init();\n    this.hudUI.postMessage({ name: \"copyToClipboard\", data: text });\n  },\n\n  async pasteFromClipboard(pasteListener) {\n    this.pasteListener = pasteListener;\n    await DomUtils.documentComplete();\n    await this.init();\n    this.tween.fade(0, 0);\n    this.hudUI.postMessage({ name: \"pasteFromClipboard\" });\n  },\n\n  pasteResponse({ data }) {\n    // Hide the HUD frame again.\n    this.hudUI.setIframeVisible(false);\n    this.unfocusIfFocused();\n    this.pasteListener(data);\n  },\n\n  unfocusIfFocused() {\n    // On Firefox, if an <iframe> disappears when it's focused, then it will keep \"focused\", which\n    // means keyboard events will always be dispatched to the HUD iframe\n    if (this.hudUI && this.hudUI.showing) {\n      this.hudUI.iframeElement.blur();\n      globalThis.focus();\n    }\n  },\n\n  // Navigator.clipboard is only available in secure contexts. Show a warning when clipboard actions\n  // fail on non-HTTPS sites. See #4572.\n  async showClipboardUnavailableMessage() {\n    await DomUtils.documentComplete();\n    await this.init();\n    // Since the message is long and surprising, show it for longer to allow more time to reading.\n    this.show(\"Clipboard actions available only on HTTPS sites\", 4000);\n  },\n};\n\nclass Tween {\n  constructor(cssSelector, insertionPoint) {\n    this.opacity = 0;\n    this.intervalId = -1;\n    this.styleElement = null;\n    this.cssSelector = cssSelector;\n    if (insertionPoint == null) insertionPoint = document.documentElement;\n    this.styleElement = DomUtils.createElement(\"style\");\n\n    if (!this.styleElement.style) {\n      // We're in an XML document, so we shouldn't inject any elements. See the comment in\n      // UIComponent.\n      Tween.prototype.fade = Tween.prototype.stop = Tween.prototype.updateStyle = function () {};\n      return;\n    }\n\n    this.styleElement.type = \"text/css\";\n    this.styleElement.innerHTML = \"\";\n    insertionPoint.appendChild(this.styleElement);\n  }\n\n  fade(toAlpha, duration, onComplete) {\n    clearInterval(this.intervalId);\n    const startTime = (new Date()).getTime();\n    const fromAlpha = this.opacity;\n    const alphaStep = toAlpha - fromAlpha;\n\n    const performStep = () => {\n      const elapsed = (new Date()).getTime() - startTime;\n      if (elapsed >= duration) {\n        clearInterval(this.intervalId);\n        this.updateStyle(toAlpha);\n        if (onComplete) {\n          onComplete();\n        }\n      } else {\n        const value = ((elapsed / duration) * alphaStep) + fromAlpha;\n        this.updateStyle(value);\n      }\n    };\n\n    this.updateStyle(this.opacity);\n    this.intervalId = setInterval(performStep, 50);\n  }\n\n  stop() {\n    clearInterval(this.intervalId);\n  }\n\n  updateStyle(opacity) {\n    this.opacity = opacity;\n    this.styleElement.innerHTML = `\\\n${this.cssSelector} {\n  opacity: ${this.opacity};\n}\\\n`;\n  }\n}\n\nglobalThis.HUD = HUD;\n"
  },
  {
    "path": "content_scripts/link_hints.js",
    "content": "//\n// This implements link hinting. Typing \"F\" will enter link-hinting mode, where all clickable items\n// on the page have a hint marker displayed containing a sequence of letters. Typing those letters\n// will select a link.\n//\n// In our 'default' mode, the characters we use to show link hints are a user-configurable option.\n// By default they're the home row. The CSS which is used on the link hints is also a configurable\n// option.\n//\n// In 'filter' mode, our link hints are numbers, and the user can narrow down the range of\n// possibilities by typing the text of the link itself.\n//\n\n// A DOM element that sits on top of a link, showing the key the user should type to select the\n// link.\nclass HintMarker {\n  hintDescriptor;\n  localHint;\n  linkText; // Used in FilterHints\n  hintString; // Used in AlphabetHints\n  markerRect; // Cached rectangle of the element, used for rotating hints.\n  // Element is null if the hint marker reflects a hint that's owned by another frame.\n  element;\n  // Cached book-keeping when computing a marker's score against a query.\n  linkWords;\n  score;\n  stableSortCount;\n  constructor() {\n    Object.seal(this);\n  }\n  isLocalMarker() {\n    return this.localHint != null;\n  }\n}\n\n// A clickable element in the current frame, plus metadata about how to show a hint marker for it.\nclass LocalHint {\n  element; // The clickable element.\n  image; // When element is an <area> (image map), `image` is its associated image.\n  rect; // The rectangle where the hint should shown, to avoid overlapping with other hints.\n  linkText; // Used in FilterHints.\n  showLinkText; // Used in FilterHints.\n  // The reason that an element has a link hint when the reason isn't obvious, e.g. the body of a\n  // frame so that the frame can be focused. This reason is shown to the user in the hint's caption.\n  reason;\n  // \"secondClassCitizen\" means the element isn't clickable, but does have a tab index. We show\n  // hints for these elements unless their hit box collides with another clickable element.\n  secondClassCitizen;\n  // An element that may be clickable based on our heuristics. It's a \"false positive\" if one of its\n  // child elements is detected as clickable.\n  possibleFalsePositive;\n  constructor(o) {\n    Object.seal(this);\n    if (o) Object.assign(this, o);\n  }\n}\n\n// Metadata about each LocalHint which is transferred to other frames in the current tab, so that\n// every frame can be aware of every other frame's local hints.\nclass HintDescriptor {\n  frameId; // The frameId that the hint is local to.\n  localIndex; // An index into the owner frame's localHints.\n  linkText; // The link's text. This is non-null only for FilterHints.\n  constructor(o) {\n    Object.seal(this);\n    if (o) Object.assign(this, o);\n  }\n}\n\n// The \"name\" property below is a short-form name to appear in the link-hints mode's name. It's for\n// debug only.\n//\nconst isMac = KeyboardUtils.platform === \"Mac\";\nconst OPEN_IN_CURRENT_TAB = {\n  name: \"curr-tab\",\n  indicator: \"Open link in current tab\",\n};\nconst OPEN_IN_NEW_BG_TAB = {\n  name: \"bg-tab\",\n  indicator: \"Open link in new tab\",\n  clickModifiers: { metaKey: isMac, ctrlKey: !isMac },\n};\nconst OPEN_IN_NEW_FG_TAB = {\n  name: \"fg-tab\",\n  indicator: \"Open link in new tab and switch to it\",\n  clickModifiers: { shiftKey: true, metaKey: isMac, ctrlKey: !isMac },\n};\nconst OPEN_WITH_QUEUE = {\n  name: \"queue\",\n  indicator: \"Open multiple links in new tabs\",\n  clickModifiers: { metaKey: isMac, ctrlKey: !isMac },\n};\nconst COPY_LINK_URL = {\n  name: \"link\",\n  indicator: \"Copy link URL to Clipboard\",\n  linkActivator(link) {\n    if (link.href != null) {\n      let url = link.href;\n      if (url.slice(0, 7) === \"mailto:\") url = url.slice(7);\n      HUD.copyToClipboard(url);\n      if (28 < url.length) url = url.slice(0, 26) + \"....\";\n      HUD.show(`Yanked ${url}`, 2000);\n    } else {\n      HUD.show(\"No link to yank.\", 2000);\n    }\n  },\n};\nconst OPEN_INCOGNITO = {\n  name: \"incognito\",\n  indicator: \"Open link in incognito window\",\n  linkActivator(link) {\n    chrome.runtime.sendMessage({ handler: \"openUrlInIncognito\", url: link.href });\n  },\n};\nconst DOWNLOAD_LINK_URL = {\n  name: \"download\",\n  indicator: \"Download link URL\",\n  clickModifiers: { altKey: true, ctrlKey: false, metaKey: false },\n};\nconst COPY_LINK_TEXT = {\n  name: \"copy-link-text\",\n  indicator: \"Copy link text\",\n  linkActivator(link) {\n    let text = link.textContent;\n    if (text.length > 0) {\n      HUD.copyToClipboard(text);\n      if (28 < text.length) text = text.slice(0, 26) + \"....\";\n      HUD.show(`Yanked ${text}`, 2000);\n    } else {\n      HUD.show(\"No text to yank.\", 2000);\n    }\n  },\n};\nconst HOVER_LINK = {\n  name: \"hover\",\n  indicator: \"Hover link\",\n  linkActivator(link) {\n    new HoverMode(link);\n  },\n};\nconst FOCUS_LINK = {\n  name: \"focus\",\n  indicator: \"Focus link\",\n  linkActivator(link) {\n    link.focus();\n  },\n};\n\nconst availableModes = [\n  OPEN_IN_CURRENT_TAB,\n  OPEN_IN_NEW_BG_TAB,\n  OPEN_IN_NEW_FG_TAB,\n  OPEN_WITH_QUEUE,\n  COPY_LINK_URL,\n  OPEN_INCOGNITO,\n  DOWNLOAD_LINK_URL,\n  COPY_LINK_TEXT,\n  HOVER_LINK,\n  FOCUS_LINK,\n];\n\nconst HintCoordinator = {\n  onExit: [],\n  localHints: null,\n  cacheAllKeydownEvents: null,\n\n  // A WeakRef to the last clicked element. We track this so that we can mouse of it if the user\n  // types ESC after clicking on it. See #3073.\n  lastClickedElementRef: null,\n\n  // Returns if the HintCoordinator will handle a given LinkHintsMessage.\n  // Some messages will not be handled in the case where the help dialog is shown, and is then\n  // hidden, but is still receiving link hints messages via broadcastLinkHintsMessage.\n  willHandleMessage(messageType) {\n    if (this.linkHintsMode) return true;\n    return [\"prepareToActivateMode\", \"activateMode\", \"getHintDescriptors\", \"exit\"].includes(\n      messageType,\n    );\n  },\n\n  sendMessage(messageType, request) {\n    if (request == null) request = {};\n    request = Object.assign(request, { messageType, handler: \"broadcastLinkHintsMessage\" });\n    chrome.runtime.sendMessage(request);\n  },\n\n  prepareToActivateMode(mode, onExit) {\n    // We need to communicate with the background page (and other frames) to initiate link-hints\n    // mode. To prevent other Vimium commands from being triggered before link-hints mode is\n    // launched, we install a temporary mode to block (and cache) keyboard events.\n    let cacheAllKeydownEvents;\n    this.cacheAllKeydownEvents = cacheAllKeydownEvents = new CacheAllKeydownEvents({\n      name: \"link-hints/suppress-keyboard-events\",\n      singleton: \"link-hints-mode\",\n      indicator: \"Collecting hints...\",\n      exitOnEscape: true,\n    });\n    // FIXME(smblott) Global link hints is currently insufficiently reliable. If the mode above is\n    // left in place, then Vimium blocks. As a temporary measure, we install a timer to remove it.\n    // TODO(philc): I believe link hints is sufficiently reliable after the manifest V3 port\n    // that this safeguard can now be removed.\n    Utils.setTimeout(1000, function () {\n      if (cacheAllKeydownEvents && cacheAllKeydownEvents.modeIsActive) {\n        cacheAllKeydownEvents.exit();\n      }\n    });\n    this.onExit = [onExit];\n    const protocol = window.location.protocol;\n    // chrome-extension, moz-extension (Firefox), extension (Edge).\n    const isExtensionPage = protocol.endsWith(\"extension:\");\n    chrome.runtime.sendMessage({\n      handler: \"prepareToActivateLinkHintsMode\",\n      modeIndex: availableModes.indexOf(mode),\n      isExtensionPage,\n      requestedByHelpDialog: globalThis.isVimiumHelpDialog,\n    });\n  },\n\n  // Returns a list of HintDescriptors. Hint descriptors are global. They include all of the\n  // information necessary for each frame to determine whether and when a hint from *any* frame is\n  // selected.\n  getHintDescriptors({ modeIndex, requestedByHelpDialog }, _sender) {\n    if (!DomUtils.isReady() || DomUtils.windowIsTooSmall()) return [];\n\n    const requireHref = [COPY_LINK_URL, OPEN_INCOGNITO].includes(availableModes[modeIndex]);\n    // If link hints is launched within the help dialog, then we only offer hints from that frame.\n    // This improves the usability of the help dialog on the options page (particularly for\n    // selecting command names).\n    if (requestedByHelpDialog && !globalThis.isVimiumHelpDialog) {\n      this.localHints = [];\n    } else {\n      this.localHints = LocalHints.getLocalHints(requireHref);\n    }\n    this.localHintDescriptors = this.localHints.map(({ linkText }, localIndex) => (\n      new HintDescriptor({\n        frameId,\n        localIndex,\n        linkText,\n      })\n    ));\n    return this.localHintDescriptors;\n  },\n\n  // We activate LinkHintsMode() in every frame and provide every frame with exactly the same hint\n  // descriptors. We also propagate the key state between frames. Therefore, the hint-selection\n  // process proceeds in lock step in every frame, and this.linkHintsMode is in the same state in\n  // every frame.\n  activateMode({ frameId, frameIdToHintDescriptors, modeIndex, originatingFrameId }) {\n    // We do not receive the frame's own hint descritors back from the background page. Instead, we\n    // merge them with the hint descriptors from other frames here. Note that\n    // this.localHintDescriptors can be null if \"getHintDescriptors\" failed in this frame when it\n    // was last called, or if this frame didn't exist at the time that hints were requested.\n    frameIdToHintDescriptors[frameId] = this.localHintDescriptors || [];\n    this.localHintDescriptors = null;\n\n    const hintDescriptors = Object.keys(frameIdToHintDescriptors)\n      .sort()\n      .flatMap((frame) => frameIdToHintDescriptors[frame]);\n\n    if (this.cacheAllKeydownEvents?.modeIsActive) {\n      this.cacheAllKeydownEvents.exit();\n    }\n    if (frameId !== originatingFrameId) {\n      this.onExit = [];\n    }\n    this.linkHintsMode = new LinkHintsMode(hintDescriptors, availableModes[modeIndex]);\n    // Replay keydown events which we missed (but for filtered hints only).\n    if (Settings.get(\"filterLinkHints\" && this.cacheAllKeydownEvents)) {\n      this.cacheAllKeydownEvents.replayKeydownEvents();\n    }\n    this.cacheAllKeydownEvents = null;\n  },\n\n  // The following messages are exchanged between frames while link-hints mode is active.\n  updateKeyState(request) {\n    this.linkHintsMode.updateKeyState(request);\n  },\n  rotateHints() {\n    this.linkHintsMode.rotateHints();\n  },\n  setOpenLinkMode({ modeIndex }) {\n    this.linkHintsMode.setOpenLinkMode(availableModes[modeIndex], false);\n  },\n  activateActiveHintMarker() {\n    this.linkHintsMode.activateLink(this.linkHintsMode.markerMatcher.activeHintMarker);\n  },\n  getLocalHint(hint) {\n    return this.localHints[hint.localIndex];\n  },\n\n  exit({ isSuccess }) {\n    if (this.linkHintsMode != null) {\n      this.linkHintsMode.deactivateMode();\n    }\n    while (this.onExit.length > 0) {\n      this.onExit.pop()(isSuccess);\n    }\n    this.linkHintsMode = this.localHints = null;\n  },\n\n  mouseOutOfLastClickedElement() {\n    if (this.lastClickedElementRef == null) return;\n    const el = this.lastClickedElementRef.deref();\n    if (el) {\n      DomUtils.simulateMouseEvent(\"mouseout\", el, null);\n    }\n    this.lastClickedElementRef = null;\n  },\n};\n\nconst LinkHints = {\n  activateMode(count, { mode, registryEntry }) {\n    if (count == null) count = 1;\n    if (mode == null) mode = OPEN_IN_CURRENT_TAB;\n\n    switch (registryEntry?.options.action) {\n      case \"copy-text\":\n        mode = COPY_LINK_TEXT;\n        break;\n      case \"hover\":\n        mode = HOVER_LINK;\n        break;\n      case \"focus\":\n        mode = FOCUS_LINK;\n        break;\n    }\n\n    if ((count > 0) || (mode === OPEN_WITH_QUEUE)) {\n      HintCoordinator.prepareToActivateMode(mode, function (isSuccess) {\n        if (isSuccess) {\n          // Wait for the next tick to allow the previous mode to exit. It might yet generate a\n          // click event, which would cause our new mode to exit immediately.\n          Utils.nextTick(() => LinkHints.activateMode(count - 1, { mode }));\n        }\n      });\n    }\n  },\n\n  activateModeToOpenInNewTab(count) {\n    this.activateMode(count, { mode: OPEN_IN_NEW_BG_TAB });\n  },\n  activateModeToOpenInNewForegroundTab(count) {\n    this.activateMode(count, { mode: OPEN_IN_NEW_FG_TAB });\n  },\n  activateModeToCopyLinkUrl(count) {\n    this.activateMode(count, { mode: COPY_LINK_URL });\n  },\n  activateModeWithQueue() {\n    this.activateMode(1, { mode: OPEN_WITH_QUEUE });\n  },\n  activateModeToOpenIncognito(count) {\n    this.activateMode(count, { mode: OPEN_INCOGNITO });\n  },\n  activateModeToDownloadLink(count) {\n    this.activateMode(count, { mode: DOWNLOAD_LINK_URL });\n  },\n};\n\nclass LinkHintsMode {\n  // @mode: One of the enums listed at the top of this file.\n  constructor(hintDescriptors, mode) {\n    if (mode == null) mode = OPEN_IN_CURRENT_TAB;\n    this.mode = mode;\n    // We need documentElement to be ready in order to append links.\n    if (!document.documentElement) return;\n\n    this.containerEl = null;\n    // Function that does the appropriate action on the selected link.\n    this.linkActivator = undefined;\n    // The link-hints \"mode\" (in the key-handler, indicator sense).\n    this.hintMode = null;\n    // A count of the number of Tab presses since the last non-Tab keyboard event.\n    this.tabCount = 0;\n\n    if (hintDescriptors.length === 0) {\n      HUD.show(\"No links to select.\", 2000);\n      return;\n    }\n\n    // This count is used to rank equal-scoring hints when sorting, thereby making JavaScript's sort\n    // stable.\n    this.stableSortCount = 0;\n    this.hintMarkers = hintDescriptors.map((desc) => this.createMarkerFor(desc));\n    this.markerMatcher = Settings.get(\"filterLinkHints\") ? new FilterHints() : new AlphabetHints();\n    this.markerMatcher.fillInMarkers(this.hintMarkers);\n\n    this.hintMode = new Mode();\n    this.hintMode.init({\n      name: `hint/${this.mode.name}`,\n      indicator: false,\n      singleton: \"link-hints-mode\",\n      suppressAllKeyboardEvents: true,\n      suppressTrailingKeyEvents: true,\n      exitOnEscape: true,\n      exitOnClick: true,\n      keydown: this.onKeyDownInMode.bind(this),\n    });\n\n    this.hintMode.onExit((event) => {\n      const hintsWereCancelled = (event?.type === \"click\") ||\n        ((event?.type === \"keydown\") &&\n          (KeyboardUtils.isEscape(event) || KeyboardUtils.isBackspace(event)));\n      if (hintsWereCancelled) {\n        HintCoordinator.sendMessage(\"exit\", { isSuccess: false });\n      }\n    });\n\n    this.renderHints();\n    this.setIndicator();\n  }\n\n  renderHints() {\n    if (this.containerEl == null) {\n      const div = DomUtils.createElement(\"div\");\n      div.id = \"vimium-hint-marker-container\";\n      div.className = \"vimium-reset\";\n      this.containerEl = div;\n      document.documentElement.appendChild(div);\n    }\n\n    // Append these markers as top level children instead of as child nodes to the link itself,\n    // because some clickable elements cannot contain children, e.g. submit buttons.\n    const markerEls = this.hintMarkers.filter((m) => m.isLocalMarker()).map((m) => m.element);\n    for (const el of markerEls) {\n      this.containerEl.appendChild(el);\n    }\n\n    // TODO(philc): 2024-03-27 Remove this hasPopoverSupport check once Firefox has popover support.\n    // Also move this CSS into vimium.css.\n    const hasPopoverSupport = this.containerEl.showPopover != null;\n    if (hasPopoverSupport) {\n      this.containerEl.popover = \"manual\";\n      this.containerEl.showPopover();\n      Object.assign(this.containerEl.style, {\n        top: 0,\n        left: 0,\n        position: \"absolute\",\n        // This display: block is required to override Github Enterprise's CSS circa 2024-04-01. See\n        // #4446.\n        display: \"block\",\n        width: \"100%\",\n        height: \"100%\",\n        overflow: \"visible\",\n      });\n    }\n\n    this.setIndicator();\n  }\n\n  setOpenLinkMode(mode, shouldPropagateToOtherFrames) {\n    this.mode = mode;\n    if (shouldPropagateToOtherFrames == null) {\n      shouldPropagateToOtherFrames = true;\n    }\n    if (shouldPropagateToOtherFrames) {\n      HintCoordinator.sendMessage(\"setOpenLinkMode\", {\n        modeIndex: availableModes.indexOf(this.mode),\n      });\n    } else {\n      this.setIndicator();\n    }\n  }\n\n  setIndicator() {\n    if (windowIsFocused()) {\n      const typedCharacters = this.markerMatcher.linkTextKeystrokeQueue\n        ? this.markerMatcher.linkTextKeystrokeQueue.join(\"\")\n        : \"\";\n      const indicator = this.mode.indicator + (typedCharacters ? `: \\\"${typedCharacters}\\\"` : \"\") +\n        \".\";\n      this.hintMode.setIndicator(indicator);\n    }\n  }\n\n  // Creates a link marker for the given link.\n  createMarkerFor(desc) {\n    const marker = new HintMarker();\n    const isLocalMarker = desc.frameId === frameId;\n    if (isLocalMarker) {\n      const localHint = HintCoordinator.getLocalHint(desc);\n      const el = DomUtils.createElement(\"div\");\n      el.style.left = localHint.rect.left + \"px\";\n      el.style.top = localHint.rect.top + \"px\";\n      // Note that Vimium's CSS is user-customizable. We're adding the \"vimiumHintMarker\" class here\n      // for users to customize. See further comments about this in vimium.css.\n      el.className = \"vimium-reset internal-vimium-hint-marker vimiumHintMarker\";\n      Object.assign(marker, {\n        element: el,\n        localHint,\n      });\n    }\n\n    return Object.assign(marker, {\n      hintDescriptor: desc,\n      linkText: desc.linkText,\n      stableSortCount: ++this.stableSortCount,\n    });\n  }\n\n  // Handles all keyboard events.\n  onKeyDownInMode(event) {\n    if (event.repeat) return;\n\n    // NOTE(smblott) The modifier behaviour here applies only to alphabet hints.\n    if (\n      [\"Control\", \"Shift\"].includes(event.key) && !Settings.get(\"filterLinkHints\") &&\n      [OPEN_IN_CURRENT_TAB, OPEN_WITH_QUEUE, OPEN_IN_NEW_BG_TAB, OPEN_IN_NEW_FG_TAB].includes(\n        this.mode,\n      )\n    ) {\n      // Toggle whether to open the link in a new or current tab.\n      const previousMode = this.mode;\n      const key = event.key;\n\n      switch (key) {\n        case \"Shift\":\n          this.setOpenLinkMode(\n            this.mode === OPEN_IN_CURRENT_TAB ? OPEN_IN_NEW_BG_TAB : OPEN_IN_CURRENT_TAB,\n          );\n          break;\n        case \"Control\":\n          this.setOpenLinkMode(\n            this.mode === OPEN_IN_NEW_FG_TAB ? OPEN_IN_NEW_BG_TAB : OPEN_IN_NEW_FG_TAB,\n          );\n          break;\n      }\n\n      this.hintMode.push({\n        keyup: (event) => {\n          if (event.key === key) {\n            handlerStack.remove();\n            this.setOpenLinkMode(previousMode);\n          }\n          return true; // Continue bubbling the event.\n        },\n      });\n    } else if (KeyboardUtils.isBackspace(event)) {\n      if (this.markerMatcher.popKeyChar()) {\n        this.tabCount = 0;\n        this.updateVisibleMarkers();\n      } else {\n        // Exit via @hintMode.exit(), so that the LinkHints.activate() \"onExit\" callback sees the\n        // key event and knows not to restart hints mode.\n        this.hintMode.exit(event);\n      }\n    } else if (event.key === \"Enter\") {\n      // Activate the active hint, if there is one.  Only FilterHints uses an active hint.\n      if (this.markerMatcher.activeHintMarker) {\n        HintCoordinator.sendMessage(\"activateActiveHintMarker\");\n      }\n    } else if (event.key === \"Tab\") {\n      if (event.shiftKey) {\n        this.tabCount--;\n      } else {\n        this.tabCount++;\n      }\n      this.updateVisibleMarkers();\n    } else if ((event.key === \" \") && this.markerMatcher.shouldRotateHints(event)) {\n      HintCoordinator.sendMessage(\"rotateHints\");\n    } else {\n      if (!event.repeat) {\n        let keyChar = Settings.get(\"filterLinkHints\")\n          ? KeyboardUtils.getKeyChar(event)\n          : KeyboardUtils.getKeyChar(event).toLowerCase();\n        if (keyChar) {\n          if (keyChar === \"space\") {\n            keyChar = \" \";\n          }\n          if (keyChar.length === 1) {\n            this.tabCount = 0;\n            this.markerMatcher.pushKeyChar(keyChar);\n            this.updateVisibleMarkers();\n          } else {\n            return handlerStack.suppressPropagation;\n          }\n        }\n      }\n    }\n\n    return handlerStack.suppressEvent;\n  }\n\n  updateVisibleMarkers() {\n    const { hintKeystrokeQueue, linkTextKeystrokeQueue } = this.markerMatcher;\n    return HintCoordinator.sendMessage(\"updateKeyState\", {\n      hintKeystrokeQueue,\n      linkTextKeystrokeQueue,\n      tabCount: this.tabCount,\n    });\n  }\n\n  updateKeyState({ hintKeystrokeQueue, linkTextKeystrokeQueue, tabCount }) {\n    Object.assign(this.markerMatcher, { hintKeystrokeQueue, linkTextKeystrokeQueue });\n\n    const { linksMatched, userMightOverType } = this.markerMatcher.getMatchingHints(\n      this.hintMarkers,\n      tabCount,\n    );\n    if (linksMatched.length === 0) {\n      this.deactivateMode();\n    } else if (linksMatched.length === 1) {\n      this.activateLink(linksMatched[0], userMightOverType);\n    } else {\n      for (const marker of this.hintMarkers) {\n        this.hideMarker(marker);\n      }\n      for (const matched of linksMatched) {\n        this.showMarker(matched, this.markerMatcher.hintKeystrokeQueue.length);\n      }\n    }\n\n    return this.setIndicator();\n  }\n\n  markerOverlapsStack(marker, stack) {\n    for (const otherMarker of stack) {\n      if (Rect.intersects(marker.markerRect, otherMarker.markerRect)) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  // Rotate the hints' z-index values so that hidden hints become visible.\n  rotateHints() {\n    // Partitions array into two arrays, based on the bool return value of predicate.\n    function partition(array, predicate) {\n      const a = [];\n      const b = [];\n      for (const item of array) {\n        const target = predicate(item) ? a : b;\n        target.push(item);\n      }\n      return [a, b];\n    }\n\n    // Get local, visible hint markers.\n    const [localMarkers, otherMarkers] = partition(\n      this.hintMarkers,\n      (m) => m.isLocalMarker() && (m.element.style.display !== \"none\"),\n    );\n\n    // Fill in the markers' rects, if necessary.\n    for (const m of localMarkers) {\n      if (m.markerRect == null) {\n        m.markerRect = m.element.getClientRects()[0];\n      }\n    }\n\n    // Calculate the overlapping groups of hints. We call each group a \"stack\". This is O(n^2).\n    let stacks = [];\n    for (const m of localMarkers) {\n      let stackForThisMarker = null;\n      const results = [];\n      for (const stack of stacks) {\n        const markerOverlapsThisStack = this.markerOverlapsStack(m, stack);\n        if (markerOverlapsThisStack && (stackForThisMarker == null)) {\n          // We've found an existing stack for this marker.\n          stack.push(m);\n          stackForThisMarker = stack;\n          results.push(stack);\n        } else if (markerOverlapsThisStack && (stackForThisMarker != null)) {\n          // This marker overlaps a second (or subsequent) stack; merge that stack into\n          // stackForThisMarker and discard it.\n          stackForThisMarker.push(...stack);\n          continue; // Discard this stack.\n        } else {\n          stack; // Keep this stack.\n          results.push(stack);\n        }\n      }\n      stacks = results;\n\n      if (stackForThisMarker == null) {\n        stacks.push([m]);\n      }\n    }\n\n    let newMarkers = [];\n    for (let stack of stacks) {\n      if (stack.length > 1) {\n        // Push the last element to the beginning.\n        stack = stack.splice(-1, 1).concat(stack);\n      }\n      newMarkers.push(...stack);\n    }\n\n    newMarkers = newMarkers.concat(otherMarkers);\n    this.hintMarkers = newMarkers;\n    this.renderHints();\n  }\n\n  // When only one hint remains, activate it in the appropriate way. The current frame may or may\n  // not contain the matched link, and may or may not have the focus. The resulting four cases are\n  // accounted for here by selectively pushing the appropriate HintCoordinator.onExit handlers.\n  activateLink(linkMatched, userMightOverType) {\n    let clickEl;\n    if (userMightOverType == null) {\n      userMightOverType = false;\n    }\n    this.removeHintMarkers();\n\n    if (linkMatched.isLocalMarker()) {\n      const localHint = linkMatched.localHint;\n      clickEl = localHint.element;\n      HintCoordinator.onExit.push((isSuccess) => {\n        if (isSuccess) {\n          if (localHint.reason === \"Frame.\") {\n            return Utils.nextTick(() => focusThisFrame({ highlight: true }));\n          } else if (localHint.reason === \"Scroll.\") {\n            // Tell the scroller that this is the activated element.\n            return handlerStack.bubbleEvent(Utils.isFirefox() ? \"click\" : \"DOMActivate\", {\n              target: clickEl,\n            });\n          } else if (localHint.reason === \"Open.\") {\n            return clickEl.open = !clickEl.open;\n          } else if (DomUtils.isSelectable(clickEl)) {\n            globalThis.focus();\n            return DomUtils.simulateSelect(clickEl);\n          } else {\n            const clickActivator = (modifiers) => (link) => DomUtils.simulateClick(link, modifiers);\n            const linkActivator = this.mode.linkActivator != null\n              ? this.mode.linkActivator\n              : clickActivator(this.mode.clickModifiers);\n            // Note(gdh1995): Here we should allow special elements to get focus,\n            // <select>: latest Chrome refuses `mousedown` event, and we can only focus it to let\n            //     user press space to activate the popup menu\n            // <object> & <embed>: for Flash games which have their own key event handlers since we\n            //     have been able to blur them by pressing `Escape`\n            if ([\"input\", \"select\", \"object\", \"embed\"].includes(clickEl.nodeName.toLowerCase())) {\n              clickEl.focus();\n            }\n            HintCoordinator.lastClickedElementRef = new WeakRef(clickEl);\n            return linkActivator(clickEl);\n          }\n        }\n      });\n    }\n\n    // If flash elements are created, then this function can be used later to remove them.\n    let removeFlashElements = function () {};\n    if (linkMatched.isLocalMarker()) {\n      const { top: viewportTop, left: viewportLeft } = DomUtils.getViewportTopLeft();\n      const flashElements = Array.from(clickEl.getClientRects()).map((rect) =>\n        DomUtils.addFlashRect(Rect.translate(rect, viewportLeft, viewportTop))\n      );\n      removeFlashElements = () => flashElements.map((flashEl) => DomUtils.removeElement(flashEl));\n    }\n\n    // If we're using a keyboard blocker, then the frame with the focus sends the \"exit\" message,\n    // otherwise the frame containing the matched link does.\n    if (userMightOverType) {\n      HintCoordinator.onExit.push(removeFlashElements);\n      if (windowIsFocused()) {\n        const callback = (isSuccess) => HintCoordinator.sendMessage(\"exit\", { isSuccess });\n        return Settings.get(\"waitForEnterForFilteredHints\")\n          ? new WaitForEnter(callback)\n          : new TypingProtector(200, callback);\n      }\n    } else if (linkMatched.isLocalMarker()) {\n      Utils.setTimeout(400, removeFlashElements);\n      return HintCoordinator.sendMessage(\"exit\", { isSuccess: true });\n    }\n  }\n\n  // Shows the marker, highlighting matchingCharCount characters.\n  showMarker(linkMarker, matchingCharCount) {\n    if (!linkMarker.isLocalMarker()) return;\n\n    linkMarker.element.style.display = \"\";\n    for (let j = 0, end = linkMarker.element.childNodes.length; j < end; j++) {\n      if (j < matchingCharCount) {\n        linkMarker.element.childNodes[j].classList.add(\"matchingCharacter\");\n      } else {\n        linkMarker.element.childNodes[j].classList.remove(\"matchingCharacter\");\n      }\n    }\n  }\n\n  hideMarker(marker) {\n    if (marker.isLocalMarker()) {\n      marker.element.style.display = \"none\";\n    }\n  }\n\n  deactivateMode() {\n    this.removeHintMarkers();\n    if (this.hintMode != null) this.hintMode.exit();\n  }\n\n  removeHintMarkers() {\n    if (this.containerEl) {\n      DomUtils.removeElement(this.containerEl);\n    }\n    this.containerEl = null;\n  }\n}\n\n// Use characters for hints, and do not filter links by their text.\nclass AlphabetHints {\n  constructor() {\n    this.linkHintCharacters = Settings.get(\"linkHintCharacters\").toLowerCase();\n    // Ensure we have more than 1 character to generate hint strings. With 1 character, every hint\n    // will be another hint's prefix (\"1\", \"11\", ...).\n    if (this.linkHintCharacters.length <= 1) {\n      throw new Error(\"The linkHintCharacters setting must have more than 1 character.\");\n    }\n    this.hintKeystrokeQueue = [];\n  }\n\n  fillInMarkers(hintMarkers) {\n    const hintStrings = this.hintStrings(hintMarkers.length);\n    if (hintMarkers.length != hintStrings.length) {\n      // This can only happen if the user's linkHintCharacters setting is empty.\n      console.warn(\"Unable to generate link hint strings.\");\n    } else {\n      for (let i = 0; i < hintMarkers.length; i++) {\n        const marker = hintMarkers[i];\n        marker.hintString = hintStrings[i];\n        if (marker.isLocalMarker()) {\n          marker.element.innerHTML = spanWrap(marker.hintString.toUpperCase());\n        }\n      }\n    }\n  }\n\n  //\n  // Returns a list of hint strings which will uniquely identify the given number of links. The hint\n  // strings may be of different lengths.\n  //\n  hintStrings(linkCount) {\n    let hints = [\"\"];\n    let offset = 0;\n    while (((hints.length - offset) < linkCount) || (hints.length === 1)) {\n      const hint = hints[offset++];\n      for (const ch of this.linkHintCharacters) {\n        hints.push(ch + hint);\n      }\n    }\n    hints = hints.slice(offset, offset + linkCount);\n\n    // Shuffle the hints so that they're scattered; hints starting with the same character and short\n    // hints are spread evenly throughout the array.\n    return hints.sort().map((str) => str.reverse());\n  }\n\n  getMatchingHints(hintMarkers) {\n    const matchString = this.hintKeystrokeQueue.join(\"\");\n    return {\n      linksMatched: hintMarkers.filter((m) => m.hintString.startsWith(matchString)),\n    };\n  }\n\n  pushKeyChar(keyChar) {\n    this.hintKeystrokeQueue.push(keyChar);\n  }\n\n  popKeyChar() {\n    return this.hintKeystrokeQueue.pop();\n  }\n\n  // For alphabet hints, <Space> always rotates the hints, regardless of modifiers.\n  shouldRotateHints() {\n    return true;\n  }\n}\n\n// Use characters for hints, and also filter links by their text.\nclass FilterHints {\n  constructor() {\n    this.linkHintNumbers = Settings.get(\"linkHintNumbers\").toUpperCase();\n    // Ensure we have more than 1 character to generate hint strings. With 1 character, every hint\n    // will be another hint's prefix (\"1\", \"11\", ...).\n    if (this.linkHintNumbers.length <= 1) {\n      throw new Error(\"The linkHintNumbers setting must have more than 1 character.\");\n    }\n\n    this.hintKeystrokeQueue = [];\n    this.linkTextKeystrokeQueue = [];\n    this.activeHintMarker = null;\n    // The regexp for splitting typed text and link texts. We split on sequences of non-word\n    // characters and link-hint numbers.\n    this.splitRegexp = new RegExp(\n      `[\\\\W${Utils.escapeRegexSpecialCharacters(this.linkHintNumbers)}]+`,\n    );\n  }\n\n  generateHintString(linkHintNumber) {\n    const base = this.linkHintNumbers.length;\n    const hint = [];\n    while (linkHintNumber > 0) {\n      hint.push(this.linkHintNumbers[Math.floor(linkHintNumber % base)]);\n      linkHintNumber = Math.floor(linkHintNumber / base);\n    }\n    return hint.reverse().join(\"\");\n  }\n\n  // Populates the marker's element with the correct caption.\n  renderMarker(marker) {\n    let linkText = marker.linkText;\n    if (linkText.length > 35) {\n      linkText = linkText.slice(0, 33) + \"...\";\n    }\n    const caption = marker.hintString + (marker.localHint.showLinkText ? \": \" + linkText : \"\");\n    marker.element.innerHTML = spanWrap(caption);\n  }\n\n  fillInMarkers(hintMarkers) {\n    for (const marker of hintMarkers) {\n      if (marker.isLocalMarker()) {\n        this.renderMarker(marker);\n      }\n    }\n\n    // We use getMatchingHints() here (although we know that all of the hints will match) to get an\n    // order on the hints and highlight the first one.\n    return this.getMatchingHints(hintMarkers, 0);\n  }\n\n  getMatchingHints(hintMarkers, tabCount) {\n    // At this point, linkTextKeystrokeQueue and hintKeystrokeQueue have been updated to reflect the\n    // latest input. Use them to filter the link hints accordingly.\n    const matchString = this.hintKeystrokeQueue.join(\"\");\n    let linksMatched = this.filterLinkHints(hintMarkers);\n    linksMatched = linksMatched.filter((linkMarker) =>\n      linkMarker.hintString.startsWith(matchString)\n    );\n\n    // Visually highlight the active hint (that is, the one that will be activated if the user types\n    // <Enter>).\n    tabCount = ((linksMatched.length * Math.abs(tabCount)) + tabCount) % linksMatched.length;\n\n    if (this.activeHintMarker?.element) {\n      this.activeHintMarker.element.classList.remove(\"vimiumActiveHintMarker\");\n    }\n\n    this.activeHintMarker = linksMatched[tabCount];\n\n    if (this.activeHintMarker?.element) {\n      this.activeHintMarker.element.classList.add(\"vimiumActiveHintMarker\");\n    }\n\n    return {\n      linksMatched,\n      userMightOverType: (this.hintKeystrokeQueue.length === 0) &&\n        (this.linkTextKeystrokeQueue.length > 0),\n    };\n  }\n\n  pushKeyChar(keyChar) {\n    if (this.linkHintNumbers.indexOf(keyChar) >= 0) {\n      this.hintKeystrokeQueue.push(keyChar);\n    } else if (\n      (keyChar.toLowerCase() !== keyChar) &&\n      (this.linkHintNumbers.toLowerCase() !== this.linkHintNumbers.toUpperCase())\n    ) {\n      // The keyChar is upper case and the link hint \"numbers\" contain characters (e.g.\n      // [a-zA-Z]). We don't want some upper-case letters matching hints (above) and some matching\n      // text (below), so we ignore such keys.\n      return;\n      // We only accept <Space> and characters which are not used for splitting (e.g. \"a\", \"b\",\n      // etc., but not \"-\").\n    } else if ((keyChar === \" \") || !this.splitRegexp.test(keyChar)) {\n      // Since we might renumber the hints, we should reset the current hintKeyStrokeQueue.\n      this.hintKeystrokeQueue = [];\n      this.linkTextKeystrokeQueue.push(keyChar.toLowerCase());\n    }\n  }\n\n  popKeyChar() {\n    return this.hintKeystrokeQueue.pop() || this.linkTextKeystrokeQueue.pop();\n  }\n\n  // Filter link hints by search string, renumbering the hints as necessary.\n  filterLinkHints(hintMarkers) {\n    const scoreFunction = this.scoreLinkHint(this.linkTextKeystrokeQueue.join(\"\"));\n    const matchingHintMarkers = hintMarkers\n      .filter((linkMarker) => {\n        linkMarker.score = scoreFunction(linkMarker);\n        return (this.linkTextKeystrokeQueue.length === 0) || (linkMarker.score > 0);\n      }).sort(function (a, b) {\n        if (b.score === a.score) return b.stableSortCount - a.stableSortCount;\n        else return b.score - a.score;\n      });\n\n    if (\n      (matchingHintMarkers.length === 0) && (this.hintKeystrokeQueue.length === 0) &&\n      (this.linkTextKeystrokeQueue.length > 0)\n    ) {\n      // We don't accept typed text which doesn't match any hints.\n      this.linkTextKeystrokeQueue.pop();\n      return this.filterLinkHints(hintMarkers);\n    } else {\n      let linkHintNumber = 1;\n      return matchingHintMarkers.map((m) => {\n        m.hintString = this.generateHintString(linkHintNumber++);\n        if (m.isLocalMarker()) this.renderMarker(m);\n        return m;\n      });\n    }\n  }\n\n  // Assign a score to a filter match (higher is better). We assign a higher score for matches at\n  // the start of a word, and a considerably higher score still for matches which are whole words.\n  scoreLinkHint(linkSearchString) {\n    const searchWords = linkSearchString.trim().toLowerCase().split(this.splitRegexp);\n    return (linkMarker) => {\n      if (!(searchWords.length > 0)) return 0;\n\n      // We only keep non-empty link words. Empty link words cannot be matched, and leading empty\n      // link words disrupt the scoring of matches at the start of the text.\n      if (!linkMarker.linkWords) {\n        linkMarker.linkWords = linkMarker.linkText.toLowerCase().split(this.splitRegexp).filter(\n          (term) => term,\n        );\n      }\n\n      const linkWords = linkMarker.linkWords;\n\n      const searchWordScores = searchWords.map((searchWord) => {\n        const linkWordScores = linkWords.map((linkWord, idx) => {\n          const position = linkWord.indexOf(searchWord);\n          if (position < 0) {\n            return 0; // No match.\n          } else if ((position === 0) && (searchWord.length === linkWord.length)) {\n            if (idx === 0) return 8;\n            else return 4; // Whole-word match.\n          } else if (position === 0) {\n            if (idx === 0) return 6;\n            else return 2; // Match at the start of a word.\n          } else {\n            return 1;\n          }\n        }); // 0 < position; other match.\n\n        return Math.max(...linkWordScores);\n      });\n\n      if (searchWordScores.includes(0)) {\n        return 0;\n      } else {\n        const addFunc = (a, b) => a + b;\n        const score = searchWordScores.reduce(addFunc, 0);\n        // Prefer matches in shorter texts. To keep things balanced for links without any text, we\n        // just weight them as if their length was 100 (so, quite long).\n        return score / Math.log(1 + (linkMarker.linkText.length || 100));\n      }\n    };\n  }\n\n  // For filtered hints, we require a modifier (because <Space> on its own is a token separator).\n  shouldRotateHints(event) {\n    return event.ctrlKey || event.altKey || event.metaKey || event.shiftKey;\n  }\n}\n\n//\n// Make each hint character a span, so that we can highlight the typed characters as you type them.\n//\nconst spanWrap = (hintString) => {\n  const innerHTML = [];\n  for (const char of hintString) {\n    innerHTML.push(\"<span class='vimium-reset'>\" + char + \"</span>\");\n  }\n  return innerHTML.join(\"\");\n};\n\nconst LocalHints = {\n  // Returns an array of LocalHints if the element is visible and clickable, and computes the rect\n  // which bounds this element in the viewport. We return an array because there may be more than\n  // one part of element which is clickable (for example, if it's an image); if so, each LocalHint\n  // represents one of the clickable rectangles of the element.\n  getLocalHintsForElement(element) {\n    // Get the tag name. However, `element.tagName` can be an element (not a string, see #2035), so\n    // we guard against that.\n    const tagName = element.tagName.toLowerCase?.() || \"\";\n    let isClickable = false;\n    let onlyHasTabIndex = false;\n    let possibleFalsePositive = false;\n    const hints = [];\n    const imageMapAreas = [];\n    let reason = null;\n\n    // Insert area elements that provide click functionality to an img.\n    if (tagName === \"img\") {\n      let mapName = element.getAttribute(\"usemap\");\n      if (mapName) {\n        const imgClientRects = element.getClientRects();\n        mapName = mapName.replace(/^#/, \"\").replace('\"', '\\\\\"');\n        const map = document.querySelector(`map[name=\\\"${mapName}\\\"]`);\n        if (map && (imgClientRects.length > 0)) {\n          isClickable = true;\n          const areas = map.getElementsByTagName(\"area\");\n          let areasAndRects = DomUtils.getClientRectsForAreas(imgClientRects[0], areas);\n          // We use this image property when detecting overlapping links.\n          areasAndRects = areasAndRects.map((o) => Object.assign(o, { image: element }));\n          imageMapAreas.push(...areasAndRects);\n        }\n      }\n    }\n\n    // Check aria properties to see if the element should be ignored.\n    // Note that we're showing hints for elements with aria-hidden=true. See #3501 for discussion.\n    const ariaDisabled = element.getAttribute(\"aria-disabled\");\n    if (ariaDisabled && [\"\", \"true\"].includes(ariaDisabled.toLowerCase())) {\n      return []; // This element should never have a link hint.\n    }\n\n    // Check for AngularJS listeners on the element.\n    if (!this.checkForAngularJs) {\n      this.checkForAngularJs = (function () {\n        const angularElements = document.getElementsByClassName(\"ng-scope\");\n        if (angularElements.length === 0) {\n          return () => false;\n        } else {\n          const ngAttributes = [];\n          for (const prefix of [\"\", \"data-\", \"x-\"]) {\n            for (const separator of [\"-\", \":\", \"_\"]) {\n              ngAttributes.push(`${prefix}ng${separator}click`);\n            }\n          }\n          return function (element) {\n            for (const attribute of ngAttributes) {\n              if (element.hasAttribute(attribute)) return true;\n            }\n            return false;\n          };\n        }\n      })();\n    }\n\n    if (!isClickable) isClickable = this.checkForAngularJs(element);\n\n    if (element.hasAttribute(\"onclick\")) {\n      isClickable = true;\n    } else {\n      const role = element.getAttribute(\"role\");\n      const clickableRoles = [\n        \"button\",\n        \"tab\",\n        \"link\",\n        \"checkbox\",\n        \"menuitem\",\n        \"menuitemcheckbox\",\n        \"menuitemradio\",\n        \"radio\",\n        \"textbox\",\n      ];\n      if (role != null && clickableRoles.includes(role.toLowerCase())) {\n        isClickable = true;\n      } else {\n        const contentEditable = element.getAttribute(\"contentEditable\");\n        if (\n          contentEditable != null &&\n          [\"\", \"contenteditable\", \"true\"].includes(contentEditable.toLowerCase())\n        ) {\n          isClickable = true;\n        }\n      }\n    }\n\n    // Check for jsaction event listeners on the element.\n    if (!isClickable && element.hasAttribute(\"jsaction\")) {\n      const jsactionRules = element.getAttribute(\"jsaction\").split(\";\");\n      for (const jsactionRule of jsactionRules) {\n        const ruleSplit = jsactionRule.trim().split(\":\");\n        if ((ruleSplit.length >= 1) && (ruleSplit.length <= 2)) {\n          const [eventType, namespace, actionName] = ruleSplit.length === 1\n            ? [\"click\", ...ruleSplit[0].trim().split(\".\"), \"_\"]\n            : [ruleSplit[0], ...ruleSplit[1].trim().split(\".\"), \"_\"];\n          if (!isClickable) {\n            isClickable = (eventType === \"click\") && (namespace !== \"none\") && (actionName !== \"_\");\n          }\n        }\n      }\n    }\n\n    // Check for tagNames which are natively clickable.\n    switch (tagName) {\n      case \"a\":\n        isClickable = true;\n        break;\n      case \"textarea\":\n        isClickable ||= !element.disabled && !element.readOnly;\n        break;\n      case \"input\":\n        isClickable ||= !((element.getAttribute(\"type\")?.toLowerCase() == \"hidden\") ||\n          element.disabled ||\n          (element.readOnly && DomUtils.isSelectable(element)));\n        break;\n      case \"button\":\n      case \"select\":\n        isClickable ||= !element.disabled;\n        break;\n      case \"object\":\n      case \"embed\":\n        isClickable = true;\n        break;\n      case \"label\":\n        isClickable ||= (element.control != null) &&\n          !element.control.disabled &&\n          ((this.getLocalHintsForElement(element.control)).length === 0);\n        break;\n      case \"body\":\n        isClickable ||= (element === document.body) && !windowIsFocused() &&\n            (globalThis.innerWidth > 3) && (globalThis.innerHeight > 3) &&\n            ((document.body != null ? document.body.tagName.toLowerCase() : undefined) !==\n              \"frameset\")\n          ? (reason = \"Frame.\")\n          : undefined;\n        isClickable ||= (element === document.body) && windowIsFocused() &&\n            Scroller.isScrollableElement(element)\n          ? (reason = \"Scroll.\")\n          : undefined;\n        break;\n      case \"img\":\n        isClickable ||= [\"zoom-in\", \"zoom-out\"].includes(element.style.cursor);\n        break;\n      case \"div\":\n      case \"ol\":\n      case \"ul\":\n        isClickable ||=\n          (element.clientHeight < element.scrollHeight) && Scroller.isScrollableElement(element)\n            ? (reason = \"Scroll.\")\n            : undefined;\n        break;\n      case \"details\":\n        isClickable = true;\n        reason = \"Open.\";\n        break;\n    }\n\n    // NOTE(smblott) Disabled pending resolution of #2997.\n    // # Detect elements with \"click\" listeners installed with `addEventListener()`.\n    // isClickable ||= element.hasAttribute \"_vimium-has-onclick-listener\"\n\n    // An element with a class name containing the text \"button\" might be clickable. However, real\n    // clickables are often wrapped in elements with such class names. So, when we find clickables\n    // based only on their class name, we mark them as unreliable.\n    if (!isClickable) {\n      const className = element.getAttribute(\"class\")?.toLowerCase();\n      if (className?.includes(\"button\") || className?.includes(\"btn\")) {\n        isClickable = true;\n        possibleFalsePositive = true;\n      }\n    }\n\n    // If the span is clickable but wraps something else that is clickable, we want to instead favor\n    // showing hints for descendants which are clickable. Flag the span as a possible false postive.\n    if (tagName == \"span\") {\n      possibleFalsePositive = true;\n    }\n\n    // Elements with tabindex are sometimes useful, but usually not. We can treat them as second\n    // class citizens when it improves UX, so take special note of them.\n    const tabIndexValue = element.getAttribute(\"tabindex\");\n    const tabIndex = tabIndexValue ? parseInt(tabIndexValue) : -1;\n    if (!isClickable && !(tabIndex < 0) && !isNaN(tabIndex)) {\n      isClickable = true;\n      onlyHasTabIndex = true;\n    }\n\n    if (isClickable) {\n      // An image map has multiple clickable areas, and so can represent multiple LocalHints.\n      if (imageMapAreas.length > 0) {\n        const mapHints = imageMapAreas.map((areaAndRect) => {\n          return new LocalHint({\n            element: areaAndRect.element,\n            image: element,\n            // element,\n            rect: areaAndRect.rect,\n            secondClassCitizen: onlyHasTabIndex,\n            possibleFalsePositive,\n            reason,\n          });\n        });\n        hints.push(...mapHints);\n      } else {\n        const clientRect = DomUtils.getVisibleClientRect(element, true);\n        if (clientRect !== null) {\n          const hint = new LocalHint({\n            element,\n            rect: clientRect,\n            secondClassCitizen: onlyHasTabIndex,\n            possibleFalsePositive,\n            reason,\n          });\n          hints.push(hint);\n        }\n      }\n    }\n\n    return hints;\n  },\n\n  //\n  // Returns element at a given (x,y) with an optional root element.\n  // If the returned element is a shadow root, descend into that shadow root recursively until we\n  // hit an actual element.\n  getElementFromPoint(x, y, root, stack) {\n    if (root == null) root = document;\n    if (stack == null) stack = [];\n    const element = root.elementsFromPoint\n      ? root.elementsFromPoint(x, y)[0]\n      : root.elementFromPoint(x, y);\n\n    if (stack.includes(element)) return element;\n\n    stack.push(element);\n\n    if (element && element.shadowRoot) {\n      // A shadow root can contain just a text node; see #4620. In that case, return the shadow root\n      // itself.\n      return LocalHints.getElementFromPoint(x, y, element.shadowRoot, stack) || element;\n    }\n\n    return element;\n  },\n\n  // Returns an array of LocalHints representing all clickable elements that are not hidden and are\n  // in the current viewport, along with rectangles at which (parts of) the elements are displayed.\n  // In the process, we try to find rects where elements do not overlap so that link hints are\n  // unambiguous. Because of this, the rects returned will frequently *NOT* be equivalent to the\n  // rects for the whole element.\n  // - requireHref: true if the hintable element must have an href, because an href is required for\n  //   commands like \"LinkHints.activateModeToCopyLinkUrl\".\n  getLocalHints(requireHref) {\n    // We need documentElement to be ready in order to find links.\n    if (!document.documentElement) return [];\n\n    // Find all elements, recursing into shadow DOM if present.\n    const getAllElements = (root, elements) => {\n      if (elements == null) elements = [];\n      for (const element of Array.from(root.querySelectorAll(\"*\"))) {\n        elements.push(element);\n        if (element.shadowRoot) {\n          getAllElements(element.shadowRoot, elements);\n        }\n      }\n      return elements;\n    };\n\n    const elements = getAllElements(document.documentElement);\n    let localHints = [];\n\n    // The order of elements here is important; they should appear in the order they are in the DOM,\n    // so that we can work out which element is on top when multiple elements overlap. Detecting\n    // elements in this loop is the sensible, efficient way to ensure this happens.\n    // NOTE(mrmr1993): Our previous method (combined XPath and DOM traversal for jsaction) couldn't\n    // provide this, so it's necessary to check whether elements are clickable in order, as we do\n    // below.\n    for (const element of Array.from(elements)) {\n      if (!requireHref || !!element.href) {\n        const hints = this.getLocalHintsForElement(element);\n        localHints.push(...hints);\n      }\n    }\n\n    // Traverse the DOM from descendants to ancestors, so later elements show above earlier elements.\n    localHints = localHints.reverse();\n\n    // Filter out suspected false positives. A false positive is taken to be an element marked as a\n    // possible false positive for which a close descendant is already clickable. False positives\n    // tend to be close together in the DOM, so - to keep the cost down - we only search nearby\n    // elements. NOTE(smblott): The visible elements have already been reversed, so we're visiting\n    // descendants before their ancestors.\n    // This determines how many descendants we're willing to consider.\n    const descendantsToCheck = [1, 2, 3];\n    localHints = localHints.filter((hint, position) => {\n      if (!hint.possibleFalsePositive) return true;\n      // Determine if the clickable element is indeed a false positive.\n      const lookbackWindow = 6;\n      let index = Math.max(0, position - lookbackWindow);\n      while (index < position) {\n        let candidateDescendant = localHints[index].element;\n        for (const _ of descendantsToCheck) {\n          candidateDescendant = candidateDescendant?.parentElement;\n          if (candidateDescendant === hint.element) {\n            // This is a false positive; exclude it from visibleElements.\n            return false;\n          }\n        }\n        index += 1;\n      }\n      return true;\n    });\n\n    // This loop will check if any corner or center of element is clickable.\n    // document.elementFromPoint will find an element at a x,y location.\n    // Node.contain checks to see if an element contains another. note: someNode.contains(someNode)\n    // === true. If we do not find our element as a descendant of any element we find, assume it's\n    // completely covered.\n\n    const nonOverlappingHints = localHints.filter((hint) => {\n      if (hint.secondClassCitizen) return false;\n      const rect = hint.rect;\n\n      // Check middle of element first, as this is perhaps most likely to return true.\n      const elementFromMiddlePoint = LocalHints.getElementFromPoint(\n        rect.left + (rect.width * 0.5),\n        rect.top + (rect.height * 0.5),\n      );\n      const hasIntersection = elementFromMiddlePoint &&\n        (hint.element.contains(elementFromMiddlePoint) ||\n          elementFromMiddlePoint.contains(hint.element));\n      if (hasIntersection) return true;\n\n      // Handle image maps\n      if (hint.element.localName == \"area\" && elementFromMiddlePoint == hint.image) {\n        return true;\n      }\n\n      // If not in middle, try corners.\n      // Adjusting the rect by 0.1 towards the upper left, which empirically fixes some cases where\n      // another element would've been found instead. NOTE(philc): This isn't well explained.\n      // Originated in #2251.\n      const verticalCoords = [rect.top + 0.1, rect.bottom - 0.1];\n      const horizontalCoords = [rect.left + 0.1, rect.right - 0.1];\n\n      for (const verticalCoord of verticalCoords) {\n        for (const horizontalCoord of horizontalCoords) {\n          const elementFromPoint = LocalHints.getElementFromPoint(horizontalCoord, verticalCoord);\n          const hasIntersection = elementFromPoint &&\n            (hint.element.contains(elementFromPoint) || elementFromPoint.contains(hint.element));\n          if (hasIntersection) return true;\n        }\n      }\n    });\n\n    nonOverlappingHints.reverse();\n\n    // Position the rects within the window.\n    const { top, left } = DomUtils.getViewportTopLeft();\n    for (const hint of nonOverlappingHints) {\n      hint.rect.top += top;\n      hint.rect.left += left;\n    }\n\n    if (Settings.get(\"filterLinkHints\")) {\n      for (const hint of nonOverlappingHints) {\n        Object.assign(hint, this.generateLinkText(hint));\n      }\n    }\n    return nonOverlappingHints;\n  },\n\n  generateLinkText(hint) {\n    const element = hint.element;\n    let linkText = \"\";\n    let showLinkText = false;\n    // toLowerCase is necessary as html documents return \"IMG\" and xhtml documents return \"img\"\n    const nodeName = element.nodeName.toLowerCase();\n\n    if (nodeName === \"input\") {\n      if ((element.labels != null) && (element.labels.length > 0)) {\n        linkText = element.labels[0].textContent.trim();\n        // Remove trailing \":\" commonly found in labels.\n        if (linkText[linkText.length - 1] === \":\") {\n          linkText = linkText.slice(0, linkText.length - 1);\n        }\n        showLinkText = true;\n      } else if ((element.getAttribute(\"type\") || \"\").toLowerCase() === \"file\") {\n        linkText = \"Choose File\";\n      } else if (element.type !== \"password\") {\n        linkText = element.value;\n        if (!linkText && \"placeholder\" in element) {\n          linkText = element.placeholder;\n        }\n      }\n      // Check if there is an image embedded in the <a> tag.\n    } else if (\n      (nodeName === \"a\") && !element.textContent.trim() &&\n      element.firstElementChild &&\n      (element.firstElementChild.nodeName.toLowerCase() === \"img\")\n    ) {\n      linkText = element.firstElementChild.alt || element.firstElementChild.title;\n      if (linkText) {\n        showLinkText = true;\n      }\n    } else if (hint.reason != null) {\n      linkText = hint.reason;\n      showLinkText = true;\n    } else if (element.textContent.length > 0) {\n      linkText = element.textContent.slice(0, 256);\n    } else if (element.hasAttribute(\"title\")) {\n      linkText = element.getAttribute(\"title\");\n    } else {\n      linkText = element.innerHTML.slice(0, 256);\n    }\n\n    return { linkText: linkText.trim(), showLinkText };\n  },\n};\n\n// Suppress all keyboard events until the user stops typing for sufficiently long.\nclass TypingProtector extends Mode {\n  constructor(delay, callback) {\n    super();\n    this.init({\n      name: \"hint/typing-protector\",\n      suppressAllKeyboardEvents: true,\n      keydown: resetExitTimer,\n      keypress: resetExitTimer,\n    });\n\n    this.timer = Utils.setTimeout(delay, () => this.exit());\n\n    const resetExitTimer = () => {\n      clearTimeout(this.timer);\n      this.timer = Utils.setTimeout(delay, () => this.exit());\n    };\n\n    this.onExit(() => callback(true)); // true -> isSuccess.\n  }\n}\n\nclass WaitForEnter extends Mode {\n  constructor(callback) {\n    super();\n    this.init({\n      name: \"hint/wait-for-enter\",\n      suppressAllKeyboardEvents: true,\n      indicator: \"Hit <Enter> to proceed...\",\n    });\n\n    this.push({\n      keydown: (event) => {\n        if (event.key === \"Enter\") {\n          this.exit();\n          return callback(true); // true -> isSuccess.\n        } else if (KeyboardUtils.isEscape(event)) {\n          this.exit();\n          return callback(false);\n        }\n      },\n    }); // false -> isSuccess.\n  }\n}\n\nclass HoverMode extends Mode {\n  constructor(link) {\n    super();\n    super.init({ name: \"hover-mode\", singleton: \"hover-mode\", exitOnEscape: true });\n    this.link = link;\n    DomUtils.simulateHover(this.link);\n    this.onExit(() => DomUtils.simulateUnhover(this.link));\n  }\n}\n\nObject.assign(globalThis, {\n  LinkHints,\n  HintCoordinator,\n  // Exported for tests.\n  LinkHintsMode,\n  LocalHints,\n  AlphabetHints,\n  FilterHints,\n  WaitForEnter,\n});\n"
  },
  {
    "path": "content_scripts/marks.js",
    "content": "const Marks = {\n  previousPositionRegisters: [\"`\", \"'\"],\n  localRegisters: {},\n  currentRegistryEntry: null,\n  mode: null,\n\n  exit(continuation = null) {\n    if (this.mode != null) {\n      this.mode.exit();\n    }\n    this.mode = null;\n    if (continuation) {\n      return continuation(); // TODO(philc): Is this return necessary?\n    }\n  },\n\n  // This returns the key which is used for storing mark locations in localStorage.\n  getLocationKey(keyChar) {\n    return `vimiumMark|${globalThis.location.href.split(\"#\")[0]}|${keyChar}`;\n  },\n\n  getMarkString() {\n    return JSON.stringify({\n      scrollX: globalThis.scrollX,\n      scrollY: globalThis.scrollY,\n      hash: globalThis.location.hash,\n    });\n  },\n\n  setPreviousPosition() {\n    const markString = this.getMarkString();\n    for (const reg of this.previousPositionRegisters) {\n      this.localRegisters[reg] = markString;\n    }\n  },\n\n  showMessage(message, keyChar) {\n    HUD.show(`${message} \\\"${keyChar}\\\".`, 1000);\n  },\n\n  // If <Shift> is depressed, then it's a global mark, otherwise it's a local mark. This is\n  // consistent vim's [A-Z] for global marks and [a-z] for local marks. However, it also admits\n  // other non-Latin characters. The exceptions are \"`\" and \"'\", which are always considered local\n  // marks. The \"swap\" command option inverts global and local marks.\n  isGlobalMark(event, keyChar) {\n    let shiftKey = event.shiftKey;\n    if (this.currentRegistryEntry.options.swap) {\n      shiftKey = !shiftKey;\n    }\n    return shiftKey && !this.previousPositionRegisters.includes(keyChar);\n  },\n\n  activateCreateMode(_count, { registryEntry }) {\n    this.currentRegistryEntry = registryEntry;\n    this.mode = new Mode();\n    this.mode.init({\n      name: \"create-mark\",\n      indicator: \"Create mark...\",\n      exitOnEscape: true,\n      suppressAllKeyboardEvents: true,\n      keydown: (event) => {\n        if (KeyboardUtils.isPrintable(event)) {\n          const keyChar = KeyboardUtils.getKeyChar(event);\n          this.exit(() => {\n            if (this.isGlobalMark(event, keyChar)) {\n              // We record the current scroll position, but only if this is the top frame within the\n              // tab. Otherwise, we'll fetch the scroll position of the top frame from the\n              // background page later.\n              let scrollX, scrollY;\n              if (DomUtils.isTopFrame()) {\n                [scrollX, scrollY] = [globalThis.scrollX, globalThis.scrollY];\n              }\n              chrome.runtime.sendMessage({\n                handler: \"createMark\",\n                markName: keyChar,\n                scrollX,\n                scrollY,\n              }, () => this.showMessage(\"Created global mark\", keyChar));\n            } else {\n              localStorage[this.getLocationKey(keyChar)] = this.getMarkString();\n              this.showMessage(\"Created local mark\", keyChar);\n            }\n          });\n          return handlerStack.suppressEvent;\n        }\n      },\n    });\n  },\n\n  activateGotoMode(_count, { registryEntry }) {\n    this.currentRegistryEntry = registryEntry;\n    this.mode = new Mode();\n    this.mode.init({\n      name: \"goto-mark\",\n      indicator: \"Go to mark...\",\n      exitOnEscape: true,\n      suppressAllKeyboardEvents: true,\n      keydown: (event) => {\n        if (KeyboardUtils.isPrintable(event)) {\n          this.exit(() => {\n            const keyChar = KeyboardUtils.getKeyChar(event);\n            if (this.isGlobalMark(event, keyChar)) {\n              // This key must match @getLocationKey() in the back end.\n              const key = `vimiumGlobalMark|${keyChar}`;\n              chrome.storage.local.get(key, function (items) {\n                if (key in items) {\n                  chrome.runtime.sendMessage({ handler: \"gotoMark\", markName: keyChar });\n                  HUD.show(`Jumped to global mark '${keyChar}'`, 1000);\n                } else {\n                  HUD.show(`Global mark not set '${keyChar}'`, 1000);\n                }\n              });\n            } else {\n              const markString = this.localRegisters[keyChar] != null\n                ? this.localRegisters[keyChar]\n                : localStorage[this.getLocationKey(keyChar)];\n              if (markString != null) {\n                this.setPreviousPosition();\n                const position = JSON.parse(markString);\n                if (position.hash && (position.scrollX === 0) && (position.scrollY === 0)) {\n                  globalThis.location.hash = position.hash;\n                } else {\n                  globalThis.scrollTo(position.scrollX, position.scrollY);\n                }\n                this.showMessage(\"Jumped to local mark\", keyChar);\n              } else {\n                this.showMessage(\"Local mark not set\", keyChar);\n              }\n            }\n          });\n          return handlerStack.suppressEvent;\n        }\n      },\n    });\n  },\n};\n\nglobalThis.Marks = Marks;\n"
  },
  {
    "path": "content_scripts/mode.js",
    "content": "//\n// A mode implements a number of keyboard (and possibly other) event handlers which are pushed onto\n// the handler stack when the mode is activated, and popped off when it is deactivated. The Mode\n// class constructor takes a single argument \"options\" which can define (amongst other things):\n//\n// name:\n//   A name for this mode.\n//\n// keydown:\n// keypress:\n// keyup:\n//   Key handlers. Optional: provide these as required. The default is to continue bubbling all key\n//   events.\n//\n// Further options are described in the constructor, below.\n//\n// Additional handlers associated with a mode can be added by using the push method. For example, if\n// a mode responds to \"focus\" events, then push an additional handler:\n//   @push\n//     \"focus\": (event) => ....\n// Such handlers are removed when the mode is deactivated.\n//\n// The following events can be handled:\n//   keydown, keypress, keyup, click, focus and blur\n\n// Debug only.\nlet count = 0;\n\nclass Mode {\n  // This is a function rather than a constructor, becausae often subclasses need to reference\n  // `this` when setting up the options argument. `this` can't be referenced in subclasses prior to\n  // calling their superclass constructor.\n  init(options) {\n    // Constants; short, readable names for the return values expected by handlerStack.bubbleEvent,\n    // used here and by subclasses.\n    if (options == null) {\n      options = {};\n    }\n    this.options = options;\n    this.continueBubbling = handlerStack.continueBubbling;\n    this.suppressEvent = handlerStack.suppressEvent;\n    this.passEventToPage = handlerStack.passEventToPage;\n    this.suppressPropagation = handlerStack.suppressPropagation;\n    this.restartBubbling = handlerStack.restartBubbling;\n\n    this.alwaysContinueBubbling = handlerStack.alwaysContinueBubbling;\n    this.alwaysSuppressPropagation = handlerStack.alwaysSuppressPropagation;\n\n    this.handlers = [];\n    this.exitHandlers = [];\n    this.modeIsActive = true;\n    this.modeIsExiting = false;\n    this.name = this.options.name || \"anonymous\";\n\n    this.count = ++count;\n    this.id = `${this.name}-${this.count}`;\n    this.log(\"activate:\", this.id);\n\n    // If options.suppressAllKeyboardEvents is truthy, then all keyboard events are suppressed. This\n    // avoids the need for modes which suppress all keyboard events 1) to provide handlers for all\n    // of those events, or 2) to worry about event suppression and event-handler return values.\n    if (this.options.suppressAllKeyboardEvents) {\n      // TODO(philc): Make a let statement.\n      const downHanlder = this.options[\"keydown\"];\n      this.options[\"keydown\"] = (event) =>\n        this.alwaysSuppressPropagation(() => {\n          if (downHanlder) {\n            return downHanlder(event);\n          }\n        });\n      const pressHandler = this.options[\"keypress\"];\n      this.options[\"keypress\"] = (event) =>\n        this.alwaysSuppressPropagation(() => {\n          if (pressHandler) {\n            return pressHandler(event);\n          }\n        });\n    }\n\n    this.push({\n      keydown: this.options.keydown || null,\n      keypress: this.options.keypress || null,\n      keyup: this.options.keyup || null,\n      indicator: () => {\n        // Update the mode indicator. Setting @options.indicator to a string shows a mode indicator\n        // in the HUD. Setting @options.indicator to 'false' forces no mode indicator.\n        // If @options.indicator is undefined, then the request propagates to the next mode.\n        // The active indicator can also be changed with @setIndicator().\n        if (this.options.indicator != null) {\n          if (this.options.indicator) {\n            HUD.show(this.options.indicator);\n          } else {\n            HUD.hide(true, false);\n          }\n          return this.passEventToPage;\n        } else {\n          return this.continueBubbling;\n        }\n      },\n    });\n\n    // If @options.exitOnEscape is truthy, then the mode will exit when the escape key is pressed.\n    if (this.options.exitOnEscape) {\n      // Note. This handler ends up above the mode's own key handlers on the handler stack, so it\n      // takes priority.\n      this.push({\n        _name: `mode-${this.id}/exitOnEscape`,\n        \"keydown\": (event) => {\n          if (!KeyboardUtils.isEscape(event)) {\n            return this.continueBubbling;\n          }\n          this.exit(event, event.target);\n          return this.suppressEvent;\n        },\n      });\n    }\n\n    // If @options.exitOnBlur is truthy, then it should be an element. The mode will exit when that\n    // element loses the focus.\n    if (this.options.exitOnBlur) {\n      this.push({\n        _name: `mode-${this.id}/exitOnBlur`,\n        \"blur\": (event) =>\n          this.alwaysContinueBubbling(() => {\n            if (event.target === this.options.exitOnBlur) {\n              return this.exit(event);\n            }\n          }),\n      });\n    }\n\n    // If @options.exitOnClick is truthy, then the mode will exit on any click event.\n    if (this.options.exitOnClick) {\n      this.push({\n        _name: `mode-${this.id}/exitOnClick`,\n        \"click\": (event) => this.alwaysContinueBubbling(() => this.exit(event)),\n      });\n    }\n\n    //If @options.exitOnFocus is truthy, then the mode will exit whenever a focusable element is\n    //activated.\n    if (this.options.exitOnFocus) {\n      this.push({\n        _name: `mode-${this.id}/exitOnFocus`,\n        \"focus\": (event) =>\n          this.alwaysContinueBubbling(() => {\n            if (DomUtils.isFocusable(event.target)) {\n              return this.exit(event);\n            }\n          }),\n      });\n    }\n\n    // If @options.exitOnScroll is truthy, then the mode will exit on any scroll event.\n    if (this.options.exitOnScroll) {\n      this.push({\n        _name: `mode-${this.id}/exitOnScroll`,\n        \"scroll\": (event) => this.alwaysContinueBubbling(() => this.exit(event)),\n      });\n    }\n\n    // Some modes are singletons: there may be at most one instance active at any time. A mode is a\n    // singleton if @options.singleton is set. The value of @options.singleton should be the key\n    // which is intended to be unique. New instances deactivate existing instances with the same\n    // key.\n    if (this.options.singleton) {\n      const singletons = Mode.singletons || (Mode.singletons = {});\n      const key = this.options.singleton;\n      this.onExit(() => delete singletons[key]);\n      if (singletons[key] != null) {\n        singletons[key].exit();\n      }\n      singletons[key] = this;\n    }\n\n    // if @options.suppressTrailingKeyEvents is set, then -- on exit -- we suppress all key events\n    // until a subsquent (non-repeat) keydown or keypress. In particular, the intention is to catch\n    // keyup events for keys which we have handled, but which otherwise might trigger page actions\n    // (if the page is listening for keyup events).\n    if (this.options.suppressTrailingKeyEvents) {\n      this.onExit(function () {\n        const handler = function (event) {\n          if (event.repeat) {\n            return handlerStack.suppressEvent;\n          } else {\n            this.remove();\n            return handlerStack.continueBubbling;\n          }\n        };\n\n        return handlerStack.push({\n          name: \"suppress-trailing-key-events\",\n          keydown: handler,\n          keypress: handler,\n        });\n      });\n    }\n\n    Mode.modes.push(this);\n    this.setIndicator();\n    this.logModes();\n  }\n  // End of Mode constructor.\n\n  setIndicator(indicator) {\n    if (indicator) {\n      this.options.indicator = indicator;\n    }\n    return Mode.setIndicator();\n  }\n\n  static setIndicator() {\n    return handlerStack.bubbleEvent(\"indicator\");\n  }\n\n  push(handlers) {\n    if (!handlers._name) {\n      handlers._name = `mode-${this.id}`;\n    }\n    return this.handlers.push(handlerStack.push(handlers));\n  }\n\n  unshift(handlers) {\n    if (!handlers._name) {\n      handlers._name = `mode-${this.id}`;\n    }\n    this.handlers.push(handlerStack.unshift(handlers));\n  }\n\n  onExit(handler) {\n    this.exitHandlers.push(handler);\n  }\n\n  exit(...args) {\n    if (this.modeIsExiting || !this.modeIsActive) {\n      return;\n    }\n\n    this.log(\"deactivate:\", this.id);\n    this.modeIsExiting = true;\n\n    for (const handler of this.exitHandlers) {\n      // TODO(philc): Is this array.from necessary?\n      handler(...Array.from(args || []));\n    }\n\n    for (const handlerId of this.handlers) {\n      handlerStack.remove(handlerId);\n    }\n\n    Mode.modes = Mode.modes.filter((mode) => mode !== this);\n\n    this.modeIsActive = false;\n    return this.setIndicator();\n  }\n\n  // Debugging routines.\n  logModes() {\n    if (Mode.debug) {\n      this.log(\"active modes (top to bottom):\");\n      for (const mode of Mode.modes.slice().reverse()) {\n        this.log(\" \", mode.id);\n      }\n    }\n  }\n\n  log(...args) {\n    if (Mode.debug) {\n      console.log(...Array.from(args || []));\n    }\n  }\n\n  // For tests only.\n  static top() {\n    return this.modes[this.modes.length - 1];\n  }\n\n  // For tests only.\n  static reset() {\n    for (const mode of this.modes) {\n      mode.exit();\n    }\n    this.modes = [];\n  }\n}\n\n// If Mode.debug is true, then we generate a trace of modes being activated and deactivated on the\n// console.\nMode.debug = false;\nMode.modes = [];\n\nclass SuppressAllKeyboardEvents extends Mode {\n  constructor(options) {\n    if (options == null) {\n      options = {};\n    }\n    super();\n    const defaults = {\n      name: \"suppressAllKeyboardEvents\",\n      suppressAllKeyboardEvents: true,\n    };\n    super.init(Object.assign(defaults, options));\n  }\n}\n\nclass CacheAllKeydownEvents extends SuppressAllKeyboardEvents {\n  constructor(options) {\n    if (options == null) {\n      options = {};\n    }\n    const keydownEvents = [];\n    const defaults = {\n      name: \"cacheAllKeydownEvents\",\n      keydown(event) {\n        return keydownEvents.push(event);\n      },\n    };\n    super(Object.assign(defaults, options));\n    this.keydownEvents = [];\n  }\n\n  replayKeydownEvents() {\n    return this.keydownEvents.map((event) => handlerStack.bubbleEvent(\"keydown\", event));\n  }\n}\n\nObject.assign(globalThis, { Mode, SuppressAllKeyboardEvents, CacheAllKeydownEvents });\n"
  },
  {
    "path": "content_scripts/mode_find.js",
    "content": "// NOTE(smblott). Ultimately, all of the FindMode-related code should be moved here.\n\n// This prevents unmapped printable characters from being passed through to underlying page; see\n// #1415. Only used by PostFindMode, below.\nclass SuppressPrintable extends Mode {\n  constructor(options) {\n    super();\n    super.init(options);\n    const handler = (event) =>\n      KeyboardUtils.isPrintable(event) ? this.suppressEvent : this.continueBubbling;\n    const initialType = globalThis.getSelection().type;\n\n    // We use unshift here, so we see events after normal mode, so we only see unmapped keys.\n    this.unshift({\n      _name: `mode-${this.id}/suppress-printable`,\n      keydown: handler,\n      keypress: handler,\n      keyup: () => {\n        // If the selection type has changed (usually, no longer \"Range\"), then the user is\n        // interacting with the input element, so we get out of the way. See discussion of option 5c\n        // from #1415.\n        if (globalThis.getSelection().type !== initialType) {\n          return this.exit();\n        }\n      },\n    });\n  }\n}\n\n// When we use find, the selection/focus can land in a focusable/editable element. In this\n// situation, special considerations apply. We implement three special cases:\n//   1. Disable insert mode, because the user hasn't asked to enter insert mode. We do this by using\n//      InsertMode.suppressEvent.\n//   2. Prevent unmapped printable keyboard events from propagating to the page; see #1415. We do\n//      this by inheriting from SuppressPrintable.\n//   3. If the very-next keystroke is Escape, then drop immediately into insert mode.\n//\nconst newPostFindMode = function () {\n  if (!document.activeElement || !DomUtils.isEditable(document.activeElement)) {\n    return;\n  }\n  return new PostFindMode();\n};\n\nclass PostFindMode extends SuppressPrintable {\n  constructor() {\n    const element = document.activeElement;\n    super({\n      name: \"post-find\",\n      // PostFindMode shares a singleton with focusInput; each displaces the other.\n      singleton: \"post-find-mode/focus-input\",\n      exitOnBlur: element,\n      exitOnClick: true,\n      // Always truthy, so always continues bubbling.\n      keydown(event) {\n        return InsertMode.suppressEvent(event);\n      },\n      keypress(event) {\n        return InsertMode.suppressEvent(event);\n      },\n      keyup(event) {\n        return InsertMode.suppressEvent(event);\n      },\n    });\n\n    // If the very-next keydown is Escape, then exit immediately, thereby passing subsequent keys to\n    // the underlying insert-mode instance.\n    this.push({\n      _name: `mode-${this.id}/handle-escape`,\n      keydown: (event) => {\n        if (KeyboardUtils.isEscape(event)) {\n          this.exit();\n          return this.suppressEvent;\n        } else {\n          handlerStack.remove();\n          return this.continueBubbling;\n        }\n      },\n    });\n  }\n}\n\nclass FindMode extends Mode {\n  constructor(options) {\n    super();\n\n    if (options == null) {\n      options = {};\n    }\n\n    // TODO(philc): I don't think this.query is ever used/accessed, because it's only accessed from\n    // static methods. Consider splitting the static portions of this class into a separate class\n    // called FindModeSingleton. Blending the two together is confusing.\n    this.query = {\n      rawQuery: \"\",\n      parsedQuery: \"\",\n      matchCount: 0,\n      hasResults: false,\n    };\n\n    // Save the selection, so findInPlace can restore it.\n    this.initialRange = getCurrentRange();\n    FindMode.query = { rawQuery: \"\" };\n\n    if (options.returnToViewport) {\n      this.scrollX = globalThis.scrollX;\n      this.scrollY = globalThis.scrollY;\n    }\n\n    super.init(Object.assign(options, {\n      name: \"find\",\n      indicator: false,\n      exitOnClick: true,\n      exitOnEscape: true,\n      // This prevents further Vimium commands launching before the find-mode HUD receives the\n      // focus. E.g. \"/\" followed quickly by \"i\" should not leave us in insert mode.\n      suppressAllKeyboardEvents: true,\n    }));\n\n    HUD.showFindMode(this);\n  }\n\n  exit(event) {\n    HUD.unfocusIfFocused();\n    super.exit();\n    if (event) {\n      FindMode.handleEscape();\n    }\n  }\n\n  restoreSelection() {\n    if (!this.initialRange) {\n      return;\n    }\n    const range = this.initialRange;\n    const selection = getSelection();\n    selection.removeAllRanges();\n    selection.addRange(range);\n  }\n\n  findInPlace(query, options) {\n    // If requested, restore the scroll position (so that failed searches leave the scroll position\n    // unchanged).\n    this.checkReturnToViewPort();\n    FindMode.updateQuery(query);\n    // Restore the selection. That way, we're always searching forward from the same place, so we\n    // find the right match as the user adds matching characters, or removes previously-matched\n    // characters. See #1434.\n    this.restoreSelection();\n    query = FindMode.query.isRegex\n      ? FindMode.getQueryFromRegexMatches()\n      : FindMode.query.parsedQuery;\n    FindMode.query.hasResults = FindMode.execute(query, options);\n  }\n\n  static updateQuery(query) {\n    let pattern;\n    if (!this.query) {\n      this.query = {};\n    }\n    this.query.rawQuery = query;\n    // the query can be treated differently (e.g. as a plain string versus regex) depending on the\n    // presence of escape sequences. '\\' is the escape character and needs to be escaped itself to\n    // be used as a normal character. here we grep for the relevant escape sequences.\n    this.query.isRegex = Settings.get(\"regexFindMode\");\n    this.query.parsedQuery = this.query.rawQuery.replace(\n      /(\\\\{1,2})([rRI]?)/g,\n      (match, slashes, flag) => {\n        if ((flag === \"\") || (slashes.length !== 1)) {\n          return match;\n        }\n\n        switch (flag) {\n          case \"r\":\n            this.query.isRegex = true;\n            break;\n          case \"R\":\n            this.query.isRegex = false;\n            break;\n        }\n        return \"\";\n      },\n    );\n\n    // Implement smartcase.\n    this.query.ignoreCase = !Utils.hasUpperCase(this.query.parsedQuery);\n\n    const regexPattern = this.query.isRegex\n      ? this.query.parsedQuery\n      : Utils.escapeRegexSpecialCharacters(this.query.parsedQuery);\n\n    // Grep for all matches in every text node,\n    // so we can show a the number of results.\n    try {\n      pattern = new RegExp(regexPattern, `g${this.query.ignoreCase ? \"i\" : \"\"}`);\n    } catch {\n      // If we catch a SyntaxError, assume the user is not done typing yet and return quietly.\n      return;\n    }\n\n    const textNodes = getAllTextNodes();\n    const matchedNodes = textNodes.filter((node) => {\n      return node.textContent.match(pattern);\n    });\n    const regexMatches = matchedNodes.map((node) => node.textContent.match(pattern));\n    this.query.regexMatches = regexMatches;\n    this.query.regexPattern = pattern;\n    this.query.regexMatchedNodes = matchedNodes;\n    this.updateActiveRegexIndices();\n\n    return this.query.matchCount = regexMatches != null ? regexMatches.flat().length : null;\n  }\n\n  // set activeRegexIndices near the latest selection\n  static updateActiveRegexIndices() {\n    let activeNodeIndex = -1;\n    const matchedNodes = this.query.regexMatchedNodes;\n    const selection = globalThis.getSelection();\n    if (selection.rangeCount > 0) {\n      activeNodeIndex = matchedNodes.indexOf(selection.anchorNode);\n\n      if (activeNodeIndex === -1) {\n        activeNodeIndex = this.query.regexMatchedNodes.findIndex((node) => {\n          const range = selection.getRangeAt(0);\n\n          if (range) {\n            let sourceRange = document.createRange();\n            sourceRange.setStart(node, 0);\n            return range.compareBoundaryPoints(Range.START_TO_START, sourceRange) <= 0;\n          } else {\n            return false;\n          }\n        });\n      }\n    }\n    this.query.activeRegexIndices = [Math.max(activeNodeIndex, 0), 0];\n  }\n\n  static getQueryFromRegexMatches() {\n    // find()ing an empty query always returns false\n    if (!this.query.regexMatches?.length) {\n      return \"\";\n    }\n    let [row, col] = this.query.activeRegexIndices;\n    return this.query.regexMatches[row][col];\n  }\n\n  static getNextQueryFromRegexMatches(backwards) {\n    // find()ing an empty query always returns false\n    if (!this.query.regexMatches?.length) {\n      return \"\";\n    }\n    const stepSize = backwards ? -1 : 1;\n\n    let [row, col] = this.query.activeRegexIndices;\n    let numRows = this.query.regexMatches.length;\n    col += stepSize;\n    while (col < 0 || col >= this.query.regexMatches[row].length) {\n      if (col < 0) {\n        row += numRows - 1;\n        row %= numRows;\n        col += this.query.regexMatches[row].length;\n      } else {\n        col -= this.query.regexMatches[row].length;\n        row += 1;\n        row %= numRows;\n      }\n    }\n    this.query.activeRegexIndices = [row, col];\n\n    return this.query.regexMatches[row][col];\n  }\n\n  // Returns null if no search has been performed yet.\n  static getQuery(backwards) {\n    if (!this.query) return;\n    // check if the query has been changed by a script in another frame\n    const mostRecentQuery = FindModeHistory.getQuery();\n    if (mostRecentQuery !== this.query.rawQuery) {\n      this.updateQuery(mostRecentQuery);\n    }\n\n    return this.getNextQueryFromRegexMatches(backwards);\n  }\n\n  static saveQuery() {\n    FindModeHistory.saveQuery(this.query.rawQuery);\n  }\n\n  // :options is an optional dict. valid parameters are 'caseSensitive' and 'backwards'.\n  static execute(query, options) {\n    let result = null;\n    options = Object.assign({\n      backwards: false,\n      caseSensitive: !this.query.ignoreCase,\n      colorSelection: true,\n    }, options);\n    if (query == null) {\n      query = FindMode.getQuery(options.backwards);\n    }\n\n    if (options.colorSelection) {\n      document.body.classList.add(\"vimium-find-mode\");\n      // ignore the selectionchange event generated by find()\n      document.removeEventListener(\"selectionchange\", this.restoreDefaultSelectionHighlight, true);\n    }\n\n    if (this.query.regexMatches?.length) {\n      const [row, col] = this.query.activeRegexIndices;\n      const node = this.query.regexMatchedNodes[row];\n      const text = node.textContent;\n      const matchIndices = getRegexMatchIndices(text, this.query.regexPattern);\n      if (matchIndices.length > 0) {\n        const startIndex = matchIndices[col];\n        result = highlight(node, startIndex, query.length);\n      }\n    }\n\n    // window.find focuses the |window| that it is called on. This gives us an opportunity to\n    // (re-)focus another element/window, if that isn't the behaviour we want.\n    if (options.postFindFocus != null) {\n      options.postFindFocus.focus();\n    }\n\n    if (options.colorSelection) {\n      setTimeout(\n        () =>\n          document.addEventListener(\"selectionchange\", this.restoreDefaultSelectionHighlight, true),\n        0,\n      );\n    }\n\n    // We are either in normal mode (\"n\"), or find mode (\"/\"). We are not in insert mode.\n    // Nevertheless, if a previous find landed in an editable element, then that element may still\n    // be activated. In this case, we don't want to leave it behind (see #1412).\n    if (document.activeElement && DomUtils.isEditable(document.activeElement)) {\n      if (!DomUtils.isSelected(document.activeElement)) {\n        document.activeElement.blur();\n      }\n    }\n\n    return result;\n  }\n\n  // The user has found what they're looking for and is finished searching. We enter insert mode, if\n  // possible.\n  static handleEscape() {\n    document.body.classList.remove(\"vimium-find-mode\");\n    // Removing the class does not re-color existing selections. we recreate the current selection\n    // so it reverts back to the default color.\n    const selection = globalThis.getSelection();\n    if (!selection.isCollapsed) {\n      const range = globalThis.getSelection().getRangeAt(0);\n      globalThis.getSelection().removeAllRanges();\n      globalThis.getSelection().addRange(range);\n    }\n    return focusFoundLink() || selectFoundInputElement();\n  }\n\n  // Save the query so the user can do further searches with it.\n  static handleEnter() {\n    focusFoundLink();\n    document.body.classList.add(\"vimium-find-mode\");\n    return FindMode.saveQuery();\n  }\n\n  static findNext(backwards) {\n    // Bail out if we don't have any query text.\n    const nextQuery = FindMode.getQuery(backwards);\n    if (!nextQuery) {\n      HUD.show(\"No query to find.\", 1000);\n      return;\n    }\n\n    Marks.setPreviousPosition();\n    FindMode.query.hasResults = FindMode.execute(nextQuery, { backwards });\n\n    if (FindMode.query.hasResults) {\n      focusFoundLink();\n      return newPostFindMode();\n    } else {\n      return HUD.show(`No matches for '${FindMode.query.rawQuery}'`, 1000);\n    }\n  }\n\n  checkReturnToViewPort() {\n    if (this.options.returnToViewport) {\n      globalThis.scrollTo(this.scrollX, this.scrollY);\n    }\n  }\n}\n\nFindMode.restoreDefaultSelectionHighlight = forTrusted(() =>\n  document.body.classList.remove(\"vimium-find-mode\")\n);\n\nconst getCurrentRange = function () {\n  const selection = getSelection();\n  if (selection.type === \"None\") {\n    const range = document.createRange();\n    range.setStart(document.body, 0);\n    range.setEnd(document.body, 0);\n    return range;\n  }\n\n  if (selection.type === \"Range\") {\n    selection.collapseToStart();\n  }\n\n  return selection.getRangeAt(0);\n};\n\nconst getLinkFromSelection = function () {\n  let node = globalThis.getSelection().anchorNode;\n  while (node && (node !== document.body)) {\n    if (node.nodeName.toLowerCase() === \"a\") {\n      return node;\n    }\n    node = node.parentNode;\n  }\n  return null;\n};\n\nconst focusFoundLink = function () {\n  if (FindMode.query.hasResults) {\n    const link = getLinkFromSelection();\n    if (link) {\n      link.focus();\n    }\n  }\n};\n\nconst selectFoundInputElement = function () {\n  // Since the last focused element might not be the one currently pointed to by find (e.g. the\n  // current one might be disabled and therefore unable to receive focus), we use the approximate\n  // heuristic of checking that the last anchor node is an ancestor of our element.\n  const findModeAnchorNode = document.getSelection().anchorNode;\n  if (\n    FindMode.query.hasResults && document.activeElement &&\n    DomUtils.isSelectable(document.activeElement) &&\n    DomUtils.isDOMDescendant(findModeAnchorNode, document.activeElement)\n  ) {\n    return DomUtils.simulateSelect(document.activeElement);\n  }\n};\n\n// Retrieve the starting indices of all matches of the queried pattern within the given text.\nconst getRegexMatchIndices = (text, regex) => {\n  const indices = [];\n  let match;\n\n  while ((match = regex.exec(text)) !== null) {\n    if (!match[0]) {\n      break;\n    }\n    indices.push(match.index);\n  }\n\n  return indices;\n};\n\n// Highlights text starting from the given startIndex with the specified length.\nconst highlight = (textNode, startIndex, length) => {\n  if (startIndex === -1) {\n    return false;\n  }\n  const selection = globalThis.getSelection();\n  const range = document.createRange();\n  range.setStart(textNode, startIndex);\n  range.setEnd(textNode, startIndex + length);\n  selection.removeAllRanges();\n  selection.addRange(range);\n\n  // Ensure the highlighted element is visible within the viewport.\n  const rect = range.getBoundingClientRect();\n  if (rect.top < 0 || rect.bottom > globalThis.innerHeight) {\n    const screenHeight = globalThis.innerHeight;\n    globalThis.scrollTo({\n      top: globalThis.scrollY + rect.top + rect.height / 2 - screenHeight / 2,\n      // Scroll instantly when we find a search result. This matches the behavior of Chrome and\n      // Firefox's native search UI. See #4661.\n      behavior: \"instant\",\n    });\n  }\n\n  return true;\n};\n\nconst getAllTextNodes = () => {\n  const textNodes = [];\n\n  function getAllTextNodes(node) {\n    if (node.nodeType === Node.TEXT_NODE) {\n      textNodes.push(node);\n    } else if (\n      node.nodeType === Node.ELEMENT_NODE &&\n      (node.checkVisibility() || node.style.display === \"contents\")\n    ) {\n      const children = node.childNodes;\n      for (const child of children) {\n        getAllTextNodes(child, textNodes);\n      }\n    }\n  }\n\n  getAllTextNodes(document.body);\n  return textNodes;\n};\n\nglobalThis.PostFindMode = PostFindMode;\nglobalThis.FindMode = FindMode;\n"
  },
  {
    "path": "content_scripts/mode_insert.js",
    "content": "class InsertMode extends Mode {\n  constructor(options) {\n    super();\n    if (options == null) {\n      options = {};\n    }\n\n    // There is one permanently-installed instance of InsertMode. It tracks focus changes and\n    // activates/deactivates itself (by setting @insertModeLock) accordingly.\n    this.permanent = options.permanent;\n\n    // If truthy, then we were activated by the user (with \"i\").\n    this.global = options.global;\n\n    this.passNextKeyKeys = [];\n\n    // This list of keys is parsed from the user's key mapping config by commands.js, and stored in\n    // chrome.storage.session.\n    chrome.storage.session.get(\"passNextKeyKeys\").then((value) => {\n      this.passNextKeyKeys = value.passNextKeyKeys || [];\n    });\n\n    chrome.storage.onChanged.addListener(async (changes, areaName) => {\n      if (areaName != \"local\") return;\n      if (changes.passNextKeyKeys == null) return;\n      this.passNextKeyKeys = changes.passNextKeyKeys.newValue;\n    });\n\n    const handleKeyEvent = (event) => {\n      if (!this.isActive(event)) {\n        return this.continueBubbling;\n      }\n\n      // See comment here:\n      // https://github.com/philc/vimium/commit/48c169bd5a61685bb4e67b1e76c939dbf360a658\n      const activeElement = this.getActiveElement();\n      if ((activeElement === document.body) && activeElement.isContentEditable) {\n        return this.passEventToPage;\n      }\n\n      // Check for a pass-next-key key.\n      const keyString = KeyboardUtils.getKeyCharString(event);\n      if (this.passNextKeyKeys.includes(keyString)) {\n        new PassNextKeyMode();\n      } else if ((event.type === \"keydown\") && KeyboardUtils.isEscape(event)) {\n        if (DomUtils.isFocusable(activeElement)) {\n          activeElement.blur();\n        }\n\n        if (!this.permanent) {\n          this.exit();\n        }\n      } else {\n        return this.passEventToPage;\n      }\n\n      return this.suppressEvent;\n    };\n\n    const defaults = {\n      name: \"insert\",\n      indicator: !this.permanent && !Settings.get(\"hideHud\") ? \"Insert mode\" : null,\n      keypress: handleKeyEvent,\n      keydown: handleKeyEvent,\n    };\n\n    super.init(Object.assign(defaults, options));\n\n    // Only for tests. This gives us a hook to test the status of the permanently-installed\n    // instance.\n    if (this.permanent) {\n      InsertMode.permanentInstance = this;\n    }\n  }\n\n  isActive(event) {\n    if (event === InsertMode.suppressedEvent) {\n      return false;\n    }\n    if (this.global) {\n      return true;\n    }\n    return DomUtils.isFocusable(this.getActiveElement());\n  }\n\n  getActiveElement() {\n    let activeElement = document.activeElement;\n    while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) {\n      activeElement = activeElement.shadowRoot.activeElement;\n    }\n    return activeElement;\n  }\n\n  static suppressEvent(event) {\n    return this.suppressedEvent = event;\n  }\n}\n\n// This allows PostFindMode to suppress the permanently-installed InsertMode instance.\nInsertMode.suppressedEvent = null;\n\n// This implements the pasNexKey command.\nclass PassNextKeyMode extends Mode {\n  constructor(count) {\n    if (count == null) {\n      count = 1;\n    }\n    super();\n    let seenKeyDown = false;\n    let keyDownCount = 0;\n\n    super.init({\n      name: \"pass-next-key\",\n      indicator: \"Pass next key.\",\n      // We exit on blur because, once we lose the focus, we can no longer track key events.\n      exitOnBlur: globalThis,\n      keypress: () => {\n        return this.passEventToPage;\n      },\n\n      keydown: () => {\n        seenKeyDown = true;\n        keyDownCount += 1;\n        return this.passEventToPage;\n      },\n\n      keyup: () => {\n        if (seenKeyDown) {\n          if (!(--keyDownCount > 0)) {\n            if (!(--count > 0)) {\n              this.exit();\n            }\n          }\n        }\n        return this.passEventToPage;\n      },\n    });\n  }\n}\n\nglobalThis.InsertMode = InsertMode;\nglobalThis.PassNextKeyMode = PassNextKeyMode;\n"
  },
  {
    "path": "content_scripts/mode_key_handler.js",
    "content": "// Example key mapping (@keyMapping):\n//   i:\n//     command: \"enterInsertMode\", ... # This is a registryEntry object (as too are the other commands).\n//   g:\n//     g:\n//       command: \"scrollToTop\", ...\n//     t:\n//       command: \"nextTab\", ...\n//\n// This key-mapping structure is generated by Commands.installKeyStateMapping and may be\n// arbitrarily deep. Observe that @keyMapping[\"g\"] is itself also a valid key mapping. At any point,\n// the key state (@keyState) consists of a (non-empty) list of such mappings.\n\nclass KeyHandlerMode extends Mode {\n  setKeyMapping(keyMapping) {\n    this.keyMapping = keyMapping;\n    this.reset();\n  }\n  setPassKeys(passKeys) {\n    this.passKeys = passKeys;\n    this.reset();\n  }\n\n  // Only for tests.\n  setCommandHandler(commandHandler) {\n    this.commandHandler = commandHandler;\n  }\n\n  // Reset the key state, optionally retaining the count provided.\n  reset(countPrefix) {\n    if (countPrefix == null) countPrefix = 0;\n    this.countPrefix = countPrefix;\n    this.keyState = [this.keyMapping];\n  }\n\n  init(options) {\n    const args = Object.assign(options, { keydown: this.onKeydown.bind(this) });\n    super.init(args);\n\n    this.commandHandler = options.commandHandler || (function () {});\n    this.setKeyMapping(options.keyMapping || {});\n\n    if (options.exitOnEscape) {\n      // If we're part way through a command's key sequence, then a first Escape should reset the\n      // key state, and only a second Escape should actually exit this mode.\n      this.push({\n        _name: \"key-handler-escape-listener\",\n        keydown: (event) => {\n          if (KeyboardUtils.isEscape(event) && !this.isInResetState()) {\n            this.reset();\n            return this.suppressEvent;\n          } else {\n            return this.continueBubbling;\n          }\n        },\n      });\n    }\n  }\n\n  onKeydown(event) {\n    const keyChar = KeyboardUtils.getKeyCharString(event);\n    const isEscape = KeyboardUtils.isEscape(event);\n    if (isEscape && ((this.countPrefix !== 0) || (this.keyState.length !== 1))) {\n      return DomUtils.consumeKeyup(event, () => this.reset());\n    } else if (isEscape && HelpDialog && HelpDialog.isShowing()) {\n      // If the help dialog loses the focus, then Escape should hide it; see point 2 in #2045.\n      HelpDialog.toggle();\n      return this.suppressEvent;\n    } else if (isEscape) {\n      // Some links stay \"open\" after clicking, until you mouse off of them, like Wikipedia's link\n      // preview popups. If the user types escape, issue a mouseout event here. See #3073.\n      HintCoordinator.mouseOutOfLastClickedElement();\n      return this.continueBubbling;\n    } else if (this.isMappedKey(keyChar)) {\n      this.handleKeyChar(keyChar);\n      return this.suppressEvent;\n    } else if (this.isCountKey(keyChar)) {\n      const digit = parseInt(keyChar);\n      this.reset(this.keyState.length === 1 ? (this.countPrefix * 10) + digit : digit);\n      return this.suppressEvent;\n    } else {\n      if (keyChar) this.reset();\n      return this.continueBubbling;\n    }\n  }\n\n  // This tests whether there is a mapping of keyChar in the current key state (and accounts for\n  // pass keys).\n  isMappedKey(keyChar) {\n    // TODO(philc): tweak the generated js.\n    return ((this.keyState.filter((mapping) => keyChar in mapping))[0] != null) &&\n      !this.isPassKey(keyChar);\n  }\n\n  // This tests whether keyChar is a digit (and accounts for pass keys).\n  isCountKey(keyChar) {\n    return keyChar &&\n      ((this.countPrefix > 0 ? \"0\" : \"1\") <= keyChar && keyChar <= \"9\") &&\n      !this.isPassKey(keyChar);\n  }\n\n  // Keystrokes are *never* considered pass keys if the user has begun entering a command. So, for\n  // example, if 't' is a passKey, then the \"t\"-s of 'gt' and '99t' are neverthless handled as\n  // regular keys.\n  isPassKey(keyChar) {\n    // Find all *continuation* mappings for keyChar in the current key state (i.e. not the full key\n    // mapping).\n    const mappings = this.keyState.filter((mapping) =>\n      keyChar in mapping && (mapping !== this.keyMapping)\n    );\n    // If there are no continuation mappings, and there's no count prefix, and keyChar is a pass\n    // key, then it's a pass key.\n    return mappings.length == 0 &&\n      this.countPrefix == 0 &&\n      this.passKeys &&\n      this.passKeys.includes(keyChar);\n  }\n\n  isInResetState() {\n    return (this.countPrefix === 0) && (this.keyState.length === 1);\n  }\n\n  handleKeyChar(keyChar) {\n    // A count prefix applies only so long a keyChar is mapped in @keyState[0]; e.g. 7gj should be 1j.\n    if (!(keyChar in this.keyState[0])) {\n      this.countPrefix = 0;\n    }\n\n    // Advance the key state. The new key state is the current mappings of keyChar, plus @keyMapping.\n    const state = this.keyState.filter((mapping) => keyChar in mapping).map((mapping) =>\n      mapping[keyChar]\n    );\n    state.push(this.keyMapping);\n    this.keyState = state;\n\n    if (this.keyState[0].command != null) {\n      const command = this.keyState[0];\n      const count = this.countPrefix > 0 ? this.countPrefix : null;\n      this.reset();\n      this.commandHandler({ command, count });\n      if ((this.options.count != null) && (--this.options.count <= 0)) {\n        this.exit();\n      }\n    }\n    return this.suppressEvent;\n  }\n}\n\nglobalThis.KeyHandlerMode = KeyHandlerMode;\n"
  },
  {
    "path": "content_scripts/mode_normal.js",
    "content": "class NormalMode extends KeyHandlerMode {\n  init(options) {\n    if (options == null) {\n      options = {};\n    }\n\n    const defaults = {\n      name: \"normal\",\n      indicator: false, // There is normally no mode indicator in normal mode.\n      commandHandler: this.commandHandler.bind(this),\n    };\n\n    super.init(Object.assign(defaults, options));\n\n    chrome.storage.session.get(\n      \"normalModeKeyStateMapping\",\n      (items) => this.setKeyMapping(items.normalModeKeyStateMapping),\n    );\n\n    chrome.storage.onChanged.addListener((changes, area) => {\n      if (area === \"session\" && changes.normalModeKeyStateMapping?.newValue) {\n        this.setKeyMapping(changes.normalModeKeyStateMapping.newValue);\n      }\n    });\n  }\n\n  commandHandler({ command: registryEntry, count }) {\n    if (registryEntry.options.count) {\n      count = (count ?? 1) * registryEntry.options.count;\n    }\n\n    // closeTabsOnLeft and closeTabsOnRight interpret a null count as \"close all tabs in\n    // {direction}\", so don't default the count to 1 for those commands. See #4296.\n    const allowNullCount = [\"closeTabsOnLeft\", \"closeTabsOnRight\"].includes(registryEntry.command);\n    if (!allowNullCount && count == null) {\n      count = 1;\n    }\n\n    if (registryEntry.noRepeat && count) {\n      count = 1;\n    }\n\n    if ((registryEntry.repeatLimit != null) && (registryEntry.repeatLimit < count)) {\n      const result = confirm(\n        `You have asked Vimium to perform ${count} repetitions of the ` +\n          `command \"${registryEntry.command}\". Are you sure you want to continue?`,\n      );\n      if (!result) return;\n    }\n\n    if (registryEntry.topFrame) {\n      // We never return to a UI-component frame (e.g. the help dialog), it might have lost the\n      // focus.\n      const sourceFrameId = globalThis.isVimiumUIComponent ? 0 : frameId;\n      chrome.runtime.sendMessage({\n        handler: \"sendMessageToFrames\",\n        message: { handler: \"runInTopFrame\", sourceFrameId, registryEntry },\n      });\n    } else if (registryEntry.background) {\n      chrome.runtime.sendMessage({ handler: \"runBackgroundCommand\", registryEntry, count });\n    } else {\n      const commandFn = NormalModeCommands[registryEntry.command];\n      commandFn(count, { registryEntry });\n    }\n  }\n}\n\nconst enterNormalMode = function (count) {\n  const mode = new NormalMode();\n  mode.init({\n    indicator: \"Normal mode (pass keys disabled)\",\n    exitOnEscape: true,\n    singleton: \"enterNormalMode\",\n    count,\n  });\n  return mode;\n};\n\nfunction findSelectedHelper(backwards) {\n  const selection = window.getSelection().toString();\n  if (!selection) return;\n  FindMode.updateQuery(selection);\n  FindMode.saveQuery();\n  FindMode.findNext(backwards);\n}\n\nconst NormalModeCommands = {\n  // Scrolling.\n  scrollToBottom() {\n    Marks.setPreviousPosition();\n    Scroller.scrollTo(\"y\", \"max\");\n  },\n  scrollToTop(count) {\n    Marks.setPreviousPosition();\n    Scroller.scrollTo(\"y\", (count - 1) * Settings.get(\"scrollStepSize\"));\n  },\n  scrollToLeft() {\n    Scroller.scrollTo(\"x\", 0);\n  },\n  scrollToRight() {\n    Scroller.scrollTo(\"x\", \"max\");\n  },\n  scrollUp(count) {\n    Scroller.scrollBy(\"y\", -1 * Settings.get(\"scrollStepSize\") * count);\n  },\n  scrollDown(count) {\n    Scroller.scrollBy(\"y\", Settings.get(\"scrollStepSize\") * count);\n  },\n  scrollPageUp(count) {\n    Scroller.scrollBy(\"y\", \"viewSize\", (-1 / 2) * count);\n  },\n  scrollPageDown(count) {\n    Scroller.scrollBy(\"y\", \"viewSize\", (1 / 2) * count);\n  },\n  scrollFullPageUp(count) {\n    Scroller.scrollBy(\"y\", \"viewSize\", -1 * count);\n  },\n  scrollFullPageDown(count) {\n    Scroller.scrollBy(\"y\", \"viewSize\", 1 * count);\n  },\n  scrollLeft(count) {\n    Scroller.scrollBy(\"x\", -1 * Settings.get(\"scrollStepSize\") * count);\n  },\n  scrollRight(count) {\n    Scroller.scrollBy(\"x\", Settings.get(\"scrollStepSize\") * count);\n  },\n\n  // Tab navigation: back, forward.\n  goBack(count) {\n    history.go(-count);\n  },\n  goForward(count) {\n    history.go(count);\n  },\n\n  // Url manipulation.\n  goUp(count) {\n    let url = globalThis.location.href;\n    if (url.endsWith(\"/\")) {\n      url = url.substring(0, url.length - 1);\n    }\n\n    let urlsplit = url.split(\"/\");\n    // make sure we haven't hit the base domain yet\n    if (urlsplit.length > 3) {\n      urlsplit = urlsplit.slice(0, Math.max(3, urlsplit.length - count));\n      globalThis.location.href = urlsplit.join(\"/\");\n    }\n  },\n\n  goToRoot() {\n    globalThis.location.href = globalThis.location.origin;\n  },\n\n  toggleViewSource() {\n    chrome.runtime.sendMessage({ handler: \"getCurrentTabUrl\" }, function (url) {\n      if (url.substr(0, 12) === \"view-source:\") {\n        url = url.substr(12, url.length - 12);\n      } else {\n        url = \"view-source:\" + url;\n      }\n      chrome.runtime.sendMessage({ handler: \"openUrlInNewTab\", url });\n    });\n  },\n\n  copyCurrentUrl() {\n    chrome.runtime.sendMessage({ handler: \"getCurrentTabUrl\" }, function (url) {\n      HUD.copyToClipboard(url);\n      // This length is determined empirically based on a 350px width of the HUD. An alternate\n      // solution is to have the HUD ellipsize based on its width.\n      const maxLength = 40;\n      if (url.length > maxLength) {\n        url = url.slice(0, maxLength - 2) + \"...\";\n      }\n      HUD.show(`Yanked ${url}`, 2000);\n    });\n  },\n\n  openCopiedUrlInNewTab(count, request) {\n    HUD.pasteFromClipboard((url) =>\n      chrome.runtime.sendMessage({\n        handler: \"openUrlInNewTab\",\n        position: request.registryEntry.options.position,\n        url,\n        count,\n      })\n    );\n  },\n\n  openCopiedUrlInCurrentTab() {\n    HUD.pasteFromClipboard((url) =>\n      chrome.runtime.sendMessage({ handler: \"openUrlInCurrentTab\", url })\n    );\n  },\n\n  // Mode changes.\n  enterInsertMode() {\n    // If a focusable element receives the focus, then we exit and leave the permanently-installed\n    // insert-mode instance to take over.\n    return new InsertMode({ global: true, exitOnFocus: true });\n  },\n\n  enterVisualMode() {\n    const mode = new VisualMode();\n    mode.init({ userLaunchedMode: true });\n    return mode;\n  },\n\n  enterVisualLineMode() {\n    const mode = new VisualLineMode();\n    mode.init({ userLaunchedMode: true });\n    return mode;\n  },\n\n  enterFindMode() {\n    Marks.setPreviousPosition();\n    return new FindMode();\n  },\n\n  // Find.\n  performFind(count) {\n    for (let i = 0, end = count; i < end; i++) {\n      FindMode.findNext(false);\n    }\n  },\n\n  performBackwardsFind(count) {\n    for (let i = 0, end = count; i < end; i++) {\n      FindMode.findNext(true);\n    }\n  },\n\n  findSelected() {\n    findSelectedHelper(false);\n  },\n\n  findSelectedBackwards() {\n    findSelectedHelper(true);\n  },\n\n  // Misc.\n  mainFrame() {\n    return focusThisFrame({ highlight: true, forceFocusThisFrame: true });\n  },\n  showHelp(sourceFrameId) {\n    return HelpDialog.toggle({ sourceFrameId });\n  },\n\n  passNextKey(count, options) {\n    // TODO(philc): OK to remove return statement?\n    if (options.registryEntry.options.normal) {\n      return enterNormalMode(count);\n    } else {\n      return new PassNextKeyMode(count);\n    }\n  },\n\n  goPrevious() {\n    const previousPatterns = Settings.get(\"previousPatterns\") || \"\";\n    const previousStrings = previousPatterns.split(\",\").filter((s) => s.trim().length);\n    const target = findElementWithRelValue(\"prev\") || findLink(previousStrings);\n    if (target) followLink(target);\n  },\n\n  goNext() {\n    const nextPatterns = Settings.get(\"nextPatterns\") || \"\";\n    const nextStrings = nextPatterns.split(\",\").filter((s) => s.trim().length);\n    const target = findElementWithRelValue(\"next\") || findLink(nextStrings);\n    if (target) followLink(target);\n  },\n\n  focusInput(count) {\n    // Focus the first input element on the page, and create overlays to highlight all the input\n    // elements, with the currently-focused element highlighted specially. Tabbing will shift focus\n    // to the next input element. Pressing any other key will remove the overlays and the special\n    // tab behavior.\n    let element, selectedInputIndex;\n    const resultSet = DomUtils.evaluateXPath(\n      textInputXPath,\n      XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,\n    );\n    const visibleInputs = [];\n\n    for (let i = 0, end = resultSet.snapshotLength; i < end; i++) {\n      element = resultSet.snapshotItem(i);\n      if (!DomUtils.getVisibleClientRect(element, true)) {\n        continue;\n      }\n      visibleInputs.push({ element, index: i, rect: Rect.copy(element.getBoundingClientRect()) });\n    }\n\n    visibleInputs.sort(\n      function ({ element: element1, index: i1 }, { element: element2, index: i2 }) {\n        // Put elements with a lower positive tabIndex first, keeping elements in DOM order.\n        if (element1.tabIndex > 0) {\n          if (element2.tabIndex > 0) {\n            const tabDifference = element1.tabIndex - element2.tabIndex;\n            if (tabDifference !== 0) {\n              return tabDifference;\n            } else {\n              return i1 - i2;\n            }\n          } else {\n            return -1;\n          }\n        } else if (element2.tabIndex > 0) {\n          return 1;\n        } else {\n          return i1 - i2;\n        }\n      },\n    );\n\n    if (visibleInputs.length === 0) {\n      HUD.show(\"There are no inputs to focus.\", 1000);\n      return;\n    }\n\n    // This is a hack to improve usability on the Vimium options page. We prime the recently-focused\n    // input to be the key-mappings input. Arguably, this is the input that the user is most likely\n    // to use.\n    const recentlyFocusedElement = lastFocusedInput();\n\n    if (count === 1) {\n      // As the starting index, we pick that of the most recently focused input element (or 0).\n      const elements = visibleInputs.map((visibleInput) => visibleInput.element);\n      selectedInputIndex = Math.max(0, elements.indexOf(recentlyFocusedElement));\n    } else {\n      selectedInputIndex = Math.min(count, visibleInputs.length) - 1;\n    }\n\n    const hints = visibleInputs.map((tuple) => {\n      const hint = DomUtils.createElement(\"div\");\n      hint.className = \"vimium-reset internal-vimium-input-hint vimiumInputHint\";\n\n      // minus 1 for the border\n      hint.style.left = (tuple.rect.left - 1) + globalThis.scrollX + \"px\";\n      hint.style.top = (tuple.rect.top - 1) + globalThis.scrollY + \"px\";\n      hint.style.width = tuple.rect.width + \"px\";\n      hint.style.height = tuple.rect.height + \"px\";\n\n      return hint;\n    });\n\n    return new FocusSelector(hints, visibleInputs, selectedInputIndex);\n  },\n\n  \"LinkHints.activateMode\": LinkHints.activateMode.bind(LinkHints),\n  \"LinkHints.activateModeToOpenInNewTab\": LinkHints.activateModeToOpenInNewTab.bind(LinkHints),\n  \"LinkHints.activateModeToOpenInNewForegroundTab\": LinkHints.activateModeToOpenInNewForegroundTab\n    .bind(LinkHints),\n  \"LinkHints.activateModeWithQueue\": LinkHints.activateModeWithQueue.bind(LinkHints),\n  \"LinkHints.activateModeToOpenIncognito\": LinkHints.activateModeToOpenIncognito.bind(LinkHints),\n  \"LinkHints.activateModeToDownloadLink\": LinkHints.activateModeToDownloadLink.bind(LinkHints),\n  \"LinkHints.activateModeToCopyLinkUrl\": LinkHints.activateModeToCopyLinkUrl.bind(LinkHints),\n\n  \"Vomnibar.activate\": Vomnibar.activate.bind(Vomnibar),\n  \"Vomnibar.activateInNewTab\": Vomnibar.activateInNewTab.bind(Vomnibar),\n  \"Vomnibar.activateTabSelection\": Vomnibar.activateTabSelection.bind(Vomnibar),\n  \"Vomnibar.activateBookmarks\": Vomnibar.activateBookmarks.bind(Vomnibar),\n  \"Vomnibar.activateBookmarksInNewTab\": Vomnibar.activateBookmarksInNewTab.bind(Vomnibar),\n  \"Vomnibar.activateEditUrl\": Vomnibar.activateEditUrl.bind(Vomnibar),\n  \"Vomnibar.activateEditUrlInNewTab\": Vomnibar.activateEditUrlInNewTab.bind(Vomnibar),\n\n  \"Marks.activateCreateMode\": Marks.activateCreateMode.bind(Marks),\n  \"Marks.activateGotoMode\": Marks.activateGotoMode.bind(Marks),\n};\n\n// The types in <input type=\"...\"> that we consider for focusInput command. Right now this is\n// recalculated in each content script. Alternatively we could calculate it once in the background\n// page and use a request to fetch it each time.\n// Should we include the HTML5 date pickers here?\n\n// The corresponding XPath for such elements.\nconst textInputXPath = (function () {\n  const textInputTypes = [\"text\", \"search\", \"email\", \"url\", \"number\", \"password\", \"date\", \"tel\"];\n  const inputElements = [\n    \"input[\" +\n    \"(\" + textInputTypes.map((type) => '@type=\"' + type + '\"').join(\" or \") + \"or not(@type))\" +\n    \" and not(@disabled or @readonly)]\",\n    \"textarea\",\n    \"*[@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']\",\n  ];\n  if (typeof DomUtils !== \"undefined\" && DomUtils !== null) {\n    return DomUtils.makeXPath(inputElements);\n  }\n})();\n\n// used by the findAndFollow* functions.\nconst followLink = function (linkElement) {\n  if (linkElement.nodeName.toLowerCase() === \"link\") {\n    globalThis.location.href = linkElement.href;\n  } else {\n    // if we can click on it, don't simply set location.href: some next/prev links are meant to\n    // trigger AJAX calls, like the 'more' button on GitHub's newsfeed.\n    linkElement.scrollIntoView();\n    DomUtils.simulateClick(linkElement);\n  }\n};\n\n// Find links which have text matching any one of `linkStrings`. If there are multiple candidates,\n// they are prioritized for shortness, by their position in `linkStrings`, how far down the page\n// they are located, and finally by whether the match is exact. Practically speaking, this means we\n// favor 'next page' over 'the next big thing', and 'more' over 'nextcompany', even if 'next' occurs\n// before 'more' in `linkStrings`.\nfunction findLink(linkStrings) {\n  const linksXPath = DomUtils.makeXPath([\n    \"a\",\n    \"*[@onclick or @role='link' or contains(@class, 'button')]\",\n  ]);\n  const links = DomUtils.evaluateXPath(linksXPath, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE);\n  let candidateLinks = [];\n\n  // At the end of this loop, candidateLinks will contain all visible links that match our patterns\n  // links lower in the page are more likely to be the ones we want, so we loop through the snapshot\n  // backwards.\n  for (let i = links.snapshotLength - 1; i >= 0; i--) {\n    const link = links.snapshotItem(i);\n\n    // NOTE(philc): We used to enforce the bounding client rect on the link had nonzero width and\n    // height. However, that's not a valid requirement. If an anchor tag has a single floated span\n    // as a child, the anchor is still clickable even though it appears to have zero height. This is\n    // the case with Google Search's \"next\" links as of 2025-06. See #4650.\n\n    const computedStyle = globalThis.getComputedStyle(link, null);\n    const isHidden = computedStyle.getPropertyValue(\"visibility\") != \"visible\" ||\n      computedStyle.getPropertyValue(\"display\") == \"none\";\n    if (isHidden) continue;\n\n    let linkMatches = false;\n    for (const linkString of linkStrings) {\n      // SVG elements can have a null innerText.\n      const matches = link.innerText?.toLowerCase().includes(linkString) ||\n        link.value?.includes?.(linkString) ||\n        link.getAttribute(\"title\")?.toLowerCase().includes(linkString) ||\n        link.getAttribute(\"aria-label\")?.toLowerCase().includes(linkString);\n      if (matches) {\n        linkMatches = true;\n        break;\n      }\n    }\n\n    if (!linkMatches) continue;\n\n    candidateLinks.push(link);\n  }\n\n  if (candidateLinks.length == 0) return;\n\n  for (const link of candidateLinks) {\n    link.wordCount = link.innerText.trim().split(/\\s+/).length;\n  }\n\n  // We can use this trick to ensure that Array.sort is stable. We need this property to retain the\n  // reverse in-page order of the links.\n\n  candidateLinks.forEach((a, i) => a.originalIndex = i);\n\n  // favor shorter links, and ignore those that are more than one word longer than the shortest link\n  candidateLinks = candidateLinks\n    .sort(function (a, b) {\n      if (a.wordCount === b.wordCount) {\n        return a.originalIndex - b.originalIndex;\n      } else {\n        return a.wordCount - b.wordCount;\n      }\n    })\n    .filter((a) => a.wordCount <= (candidateLinks[0].wordCount + 1));\n\n  for (const linkString of linkStrings) {\n    const exactWordRegex = /\\b/.test(linkString[0]) || /\\b/.test(linkString[linkString.length - 1])\n      ? new RegExp(\"\\\\b\" + linkString + \"\\\\b\", \"i\")\n      : new RegExp(linkString, \"i\");\n    for (const candidateLink of candidateLinks) {\n      if (\n        candidateLink.innerText.match(exactWordRegex) ||\n        candidateLink.value?.match(exactWordRegex) ||\n        candidateLink.getAttribute(\"title\")?.match(exactWordRegex) ||\n        candidateLink.getAttribute(\"aria-label\")?.match(exactWordRegex)\n      ) {\n        return candidateLink;\n      }\n    }\n  }\n}\n\nfunction findElementWithRelValue(value) {\n  const relTags = [\"link\", \"a\", \"area\"];\n  for (const tag of relTags) {\n    const els = document.getElementsByTagName(tag);\n    for (const el of Array.from(els)) {\n      if (el.hasAttribute(\"rel\") && (el.rel.toLowerCase() === value)) {\n        return el;\n      }\n    }\n  }\n}\n\nclass FocusSelector extends Mode {\n  constructor(hints, visibleInputs, selectedInputIndex) {\n    super(...arguments);\n    super.init({\n      name: \"focus-selector\",\n      exitOnClick: true,\n      keydown: (event) => {\n        if (event.key === \"Tab\") {\n          hints[selectedInputIndex].classList.remove(\"internal-vimium-selected-input-hint\");\n          selectedInputIndex += hints.length + (event.shiftKey ? -1 : 1);\n          selectedInputIndex %= hints.length;\n          hints[selectedInputIndex].classList.add(\"internal-vimium-selected-input-hint\");\n          DomUtils.simulateSelect(visibleInputs[selectedInputIndex].element);\n          return this.suppressEvent;\n        } else if (event.key !== \"Shift\") {\n          this.exit();\n          // Give the new mode the opportunity to handle the event.\n          return this.restartBubbling;\n        }\n      },\n    });\n\n    const div = DomUtils.createElement(\"div\");\n    div.id = \"vimiumInputMarkerContainer\";\n    div.className = \"vimium-reset\";\n    for (const el of hints) {\n      div.appendChild(el);\n    }\n    this.hintContainerEl = div;\n    document.documentElement.appendChild(div);\n\n    DomUtils.simulateSelect(visibleInputs[selectedInputIndex].element);\n    if (visibleInputs.length === 1) {\n      this.exit();\n      return;\n    } else {\n      hints[selectedInputIndex].classList.add(\"internal-vimium-selected-input-hint\");\n    }\n  }\n\n  exit() {\n    super.exit();\n    DomUtils.removeElement(this.hintContainerEl);\n    if (document.activeElement && DomUtils.isEditable(document.activeElement)) {\n      return new InsertMode({\n        singleton: \"post-find-mode/focus-input\",\n        targetElement: document.activeElement,\n        indicator: false,\n      });\n    }\n  }\n}\n\nglobalThis.NormalMode = NormalMode;\nglobalThis.NormalModeCommands = NormalModeCommands;\n"
  },
  {
    "path": "content_scripts/mode_visual.js",
    "content": "// Symbolic names for some common strings.\nconst forward = \"forward\";\nconst backward = \"backward\";\nconst character = \"character\";\nconst word = \"word\";\nconst line = \"line\";\nconst sentence = \"sentence\";\nconst vimword = \"vimword\";\nconst lineboundary = \"lineboundary\";\n\n// This implements various selection movements.\nclass Movement {\n  constructor(alterMethod) {\n    this.alterMethod = alterMethod;\n    this.opposite = { forward: backward, backward: forward };\n    this.selection = globalThis.getSelection();\n  }\n\n  // Return the character following (to the right of) the focus, and leave the selection unchanged,\n  // or return undefined.\n  getNextForwardCharacter() {\n    const beforeText = this.selection.toString();\n    if ((beforeText.length === 0) || (this.getDirection() === forward)) {\n      this.selection.modify(\"extend\", forward, character);\n      const afterText = this.selection.toString();\n      if (beforeText !== afterText) {\n        this.selection.modify(\"extend\", backward, character);\n        return afterText[afterText.length - 1];\n      }\n    } else {\n      return beforeText[0]; // The existing range selection is backwards.\n    }\n  }\n\n  // Test whether the character following the focus is a word character (and leave the selection unchanged).\n  nextCharacterIsWordCharacter() {\n    // This regexp matches \"word\" characters.\n    // From http://stackoverflow.com/questions/150033/regular-expression-to-match-non-english-characters.\n    if (!this.regexp) {\n      this.regexp =\n        /[_0-9\\u0041-\\u005A\\u0061-\\u007A\\u00AA\\u00B5\\u00BA\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02C1\\u02C6-\\u02D1\\u02E0-\\u02E4\\u02EC\\u02EE\\u0370-\\u0374\\u0376\\u0377\\u037A-\\u037D\\u0386\\u0388-\\u038A\\u038C\\u038E-\\u03A1\\u03A3-\\u03F5\\u03F7-\\u0481\\u048A-\\u0527\\u0531-\\u0556\\u0559\\u0561-\\u0587\\u05D0-\\u05EA\\u05F0-\\u05F2\\u0620-\\u064A\\u066E\\u066F\\u0671-\\u06D3\\u06D5\\u06E5\\u06E6\\u06EE\\u06EF\\u06FA-\\u06FC\\u06FF\\u0710\\u0712-\\u072F\\u074D-\\u07A5\\u07B1\\u07CA-\\u07EA\\u07F4\\u07F5\\u07FA\\u0800-\\u0815\\u081A\\u0824\\u0828\\u0840-\\u0858\\u08A0\\u08A2-\\u08AC\\u0904-\\u0939\\u093D\\u0950\\u0958-\\u0961\\u0971-\\u0977\\u0979-\\u097F\\u0985-\\u098C\\u098F\\u0990\\u0993-\\u09A8\\u09AA-\\u09B0\\u09B2\\u09B6-\\u09B9\\u09BD\\u09CE\\u09DC\\u09DD\\u09DF-\\u09E1\\u09F0\\u09F1\\u0A05-\\u0A0A\\u0A0F\\u0A10\\u0A13-\\u0A28\\u0A2A-\\u0A30\\u0A32\\u0A33\\u0A35\\u0A36\\u0A38\\u0A39\\u0A59-\\u0A5C\\u0A5E\\u0A72-\\u0A74\\u0A85-\\u0A8D\\u0A8F-\\u0A91\\u0A93-\\u0AA8\\u0AAA-\\u0AB0\\u0AB2\\u0AB3\\u0AB5-\\u0AB9\\u0ABD\\u0AD0\\u0AE0\\u0AE1\\u0B05-\\u0B0C\\u0B0F\\u0B10\\u0B13-\\u0B28\\u0B2A-\\u0B30\\u0B32\\u0B33\\u0B35-\\u0B39\\u0B3D\\u0B5C\\u0B5D\\u0B5F-\\u0B61\\u0B71\\u0B83\\u0B85-\\u0B8A\\u0B8E-\\u0B90\\u0B92-\\u0B95\\u0B99\\u0B9A\\u0B9C\\u0B9E\\u0B9F\\u0BA3\\u0BA4\\u0BA8-\\u0BAA\\u0BAE-\\u0BB9\\u0BD0\\u0C05-\\u0C0C\\u0C0E-\\u0C10\\u0C12-\\u0C28\\u0C2A-\\u0C33\\u0C35-\\u0C39\\u0C3D\\u0C58\\u0C59\\u0C60\\u0C61\\u0C85-\\u0C8C\\u0C8E-\\u0C90\\u0C92-\\u0CA8\\u0CAA-\\u0CB3\\u0CB5-\\u0CB9\\u0CBD\\u0CDE\\u0CE0\\u0CE1\\u0CF1\\u0CF2\\u0D05-\\u0D0C\\u0D0E-\\u0D10\\u0D12-\\u0D3A\\u0D3D\\u0D4E\\u0D60\\u0D61\\u0D7A-\\u0D7F\\u0D85-\\u0D96\\u0D9A-\\u0DB1\\u0DB3-\\u0DBB\\u0DBD\\u0DC0-\\u0DC6\\u0E01-\\u0E30\\u0E32\\u0E33\\u0E40-\\u0E46\\u0E81\\u0E82\\u0E84\\u0E87\\u0E88\\u0E8A\\u0E8D\\u0E94-\\u0E97\\u0E99-\\u0E9F\\u0EA1-\\u0EA3\\u0EA5\\u0EA7\\u0EAA\\u0EAB\\u0EAD-\\u0EB0\\u0EB2\\u0EB3\\u0EBD\\u0EC0-\\u0EC4\\u0EC6\\u0EDC-\\u0EDF\\u0F00\\u0F40-\\u0F47\\u0F49-\\u0F6C\\u0F88-\\u0F8C\\u1000-\\u102A\\u103F\\u1050-\\u1055\\u105A-\\u105D\\u1061\\u1065\\u1066\\u106E-\\u1070\\u1075-\\u1081\\u108E\\u10A0-\\u10C5\\u10C7\\u10CD\\u10D0-\\u10FA\\u10FC-\\u1248\\u124A-\\u124D\\u1250-\\u1256\\u1258\\u125A-\\u125D\\u1260-\\u1288\\u128A-\\u128D\\u1290-\\u12B0\\u12B2-\\u12B5\\u12B8-\\u12BE\\u12C0\\u12C2-\\u12C5\\u12C8-\\u12D6\\u12D8-\\u1310\\u1312-\\u1315\\u1318-\\u135A\\u1380-\\u138F\\u13A0-\\u13F4\\u1401-\\u166C\\u166F-\\u167F\\u1681-\\u169A\\u16A0-\\u16EA\\u1700-\\u170C\\u170E-\\u1711\\u1720-\\u1731\\u1740-\\u1751\\u1760-\\u176C\\u176E-\\u1770\\u1780-\\u17B3\\u17D7\\u17DC\\u1820-\\u1877\\u1880-\\u18A8\\u18AA\\u18B0-\\u18F5\\u1900-\\u191C\\u1950-\\u196D\\u1970-\\u1974\\u1980-\\u19AB\\u19C1-\\u19C7\\u1A00-\\u1A16\\u1A20-\\u1A54\\u1AA7\\u1B05-\\u1B33\\u1B45-\\u1B4B\\u1B83-\\u1BA0\\u1BAE\\u1BAF\\u1BBA-\\u1BE5\\u1C00-\\u1C23\\u1C4D-\\u1C4F\\u1C5A-\\u1C7D\\u1CE9-\\u1CEC\\u1CEE-\\u1CF1\\u1CF5\\u1CF6\\u1D00-\\u1DBF\\u1E00-\\u1F15\\u1F18-\\u1F1D\\u1F20-\\u1F45\\u1F48-\\u1F4D\\u1F50-\\u1F57\\u1F59\\u1F5B\\u1F5D\\u1F5F-\\u1F7D\\u1F80-\\u1FB4\\u1FB6-\\u1FBC\\u1FBE\\u1FC2-\\u1FC4\\u1FC6-\\u1FCC\\u1FD0-\\u1FD3\\u1FD6-\\u1FDB\\u1FE0-\\u1FEC\\u1FF2-\\u1FF4\\u1FF6-\\u1FFC\\u2071\\u207F\\u2090-\\u209C\\u2102\\u2107\\u210A-\\u2113\\u2115\\u2119-\\u211D\\u2124\\u2126\\u2128\\u212A-\\u212D\\u212F-\\u2139\\u213C-\\u213F\\u2145-\\u2149\\u214E\\u2183\\u2184\\u2C00-\\u2C2E\\u2C30-\\u2C5E\\u2C60-\\u2CE4\\u2CEB-\\u2CEE\\u2CF2\\u2CF3\\u2D00-\\u2D25\\u2D27\\u2D2D\\u2D30-\\u2D67\\u2D6F\\u2D80-\\u2D96\\u2DA0-\\u2DA6\\u2DA8-\\u2DAE\\u2DB0-\\u2DB6\\u2DB8-\\u2DBE\\u2DC0-\\u2DC6\\u2DC8-\\u2DCE\\u2DD0-\\u2DD6\\u2DD8-\\u2DDE\\u2E2F\\u3005\\u3006\\u3031-\\u3035\\u303B\\u303C\\u3041-\\u3096\\u309D-\\u309F\\u30A1-\\u30FA\\u30FC-\\u30FF\\u3105-\\u312D\\u3131-\\u318E\\u31A0-\\u31BA\\u31F0-\\u31FF\\u3400-\\u4DB5\\u4E00-\\u9FCC\\uA000-\\uA48C\\uA4D0-\\uA4FD\\uA500-\\uA60C\\uA610-\\uA61F\\uA62A\\uA62B\\uA640-\\uA66E\\uA67F-\\uA697\\uA6A0-\\uA6E5\\uA717-\\uA71F\\uA722-\\uA788\\uA78B-\\uA78E\\uA790-\\uA793\\uA7A0-\\uA7AA\\uA7F8-\\uA801\\uA803-\\uA805\\uA807-\\uA80A\\uA80C-\\uA822\\uA840-\\uA873\\uA882-\\uA8B3\\uA8F2-\\uA8F7\\uA8FB\\uA90A-\\uA925\\uA930-\\uA946\\uA960-\\uA97C\\uA984-\\uA9B2\\uA9CF\\uAA00-\\uAA28\\uAA40-\\uAA42\\uAA44-\\uAA4B\\uAA60-\\uAA76\\uAA7A\\uAA80-\\uAAAF\\uAAB1\\uAAB5\\uAAB6\\uAAB9-\\uAABD\\uAAC0\\uAAC2\\uAADB-\\uAADD\\uAAE0-\\uAAEA\\uAAF2-\\uAAF4\\uAB01-\\uAB06\\uAB09-\\uAB0E\\uAB11-\\uAB16\\uAB20-\\uAB26\\uAB28-\\uAB2E\\uABC0-\\uABE2\\uAC00-\\uD7A3\\uD7B0-\\uD7C6\\uD7CB-\\uD7FB\\uF900-\\uFA6D\\uFA70-\\uFAD9\\uFB00-\\uFB06\\uFB13-\\uFB17\\uFB1D\\uFB1F-\\uFB28\\uFB2A-\\uFB36\\uFB38-\\uFB3C\\uFB3E\\uFB40\\uFB41\\uFB43\\uFB44\\uFB46-\\uFBB1\\uFBD3-\\uFD3D\\uFD50-\\uFD8F\\uFD92-\\uFDC7\\uFDF0-\\uFDFB\\uFE70-\\uFE74\\uFE76-\\uFEFC\\uFF21-\\uFF3A\\uFF41-\\uFF5A\\uFF66-\\uFFBE\\uFFC2-\\uFFC7\\uFFCA-\\uFFCF\\uFFD2-\\uFFD7\\uFFDA-\\uFFDC]/;\n    }\n    return this.regexp.test(this.getNextForwardCharacter());\n  }\n\n  // Run a movement. This is the core movement method, all movements happen here. For convenience,\n  // the following three argument forms are supported:\n  //   runMovement(\"forward word\")\n  //   runMovement([\"forward\", \"word\"])\n  //   runMovement(\"forward\", \"word\")\n  //\n  // The granularities are word, \"character\", \"line\", \"lineboundary\", \"sentence\" and \"paragraph\". In\n  // addition, we implement the pseudo granularity \"vimword\", which implements vim-like word\n  // movement (e.g. \"w\").\n  //\n  runMovement(...args) {\n    // Normalize the various argument forms.\n    const [direction, granularity] = (typeof (args[0]) === \"string\") && (args.length === 1)\n      ? args[0].trim().split(/\\s+/)\n      : (args.length === 1 ? args[0] : args.slice(0, 2));\n\n    // Native word movements behave differently on Linux and Windows, see #1441. So we implement\n    // some of them character-by-character.\n    if ((granularity === vimword) && (direction === forward)) {\n      // Extend selection to the end of the 'vimword'.\n      while (this.nextCharacterIsWordCharacter()) {\n        if (this.extendByOneCharacter(forward) === 0) {\n          return;\n        }\n      }\n      // Extend selection after the 'vimword' to position before next word.\n      while (this.getNextForwardCharacter() && !this.nextCharacterIsWordCharacter()) {\n        if (this.extendByOneCharacter(forward) === 0) {\n          return;\n        }\n      }\n      // In Caret Mode collapse selection to the end.\n      if (this.alterMethod === \"move\") {\n        this.selection.collapseToEnd();\n      }\n      return;\n    }\n\n    // As above, we implement this character-by-character to get consistent behavior on Windows and\n    // Linux.\n    if ((granularity === word) && (direction === forward)) {\n      // Extend selection to the start of the next 'word' (non-word characters, e.g. whitespace).\n      while (this.getNextForwardCharacter() && !this.nextCharacterIsWordCharacter()) {\n        if (this.extendByOneCharacter(forward) === 0) {\n          return;\n        }\n      }\n      // Extend selection to the end of the 'word'.\n      while (this.nextCharacterIsWordCharacter()) {\n        if (this.extendByOneCharacter(forward) === 0) {\n          return;\n        }\n      }\n      // In Caret Mode collapse selection to the end.\n      if (this.alterMethod === \"move\") {\n        this.selection.collapseToEnd();\n      }\n      return;\n    } else {\n      this.selection.modify(this.alterMethod, direction, granularity);\n      return;\n    }\n  }\n\n  // Swap the anchor node/offset and the focus node/offset. This allows us to work with both ends of\n  // the selection, and implements \"o\" for visual mode.\n  reverseSelection() {\n    const direction = this.getDirection();\n    const element = document.activeElement;\n    if (element && DomUtils.isEditable(element) && !element.isContentEditable) {\n      // Note(smblott). This implementation is expensive if the selection is large. We only use it\n      // here because the normal method (below) does not work within text areas, etc.\n      const length = this.selection.toString().length;\n      this.collapseSelectionToFocus();\n      for (let i = 0, end = length; i < end; i++) {\n        this.runMovement(this.opposite[direction], character);\n      }\n    } else {\n      // Normal method.\n      const original = this.selection.getRangeAt(0).cloneRange();\n      const range = original.cloneRange();\n      range.collapse(direction === backward);\n      this.setSelectionRange(range);\n      const which = direction === forward ? \"start\" : \"end\";\n      this.selection.extend(original[`${which}Container`], original[`${which}Offset`]);\n    }\n  }\n\n  // Try to extend the selection by one character in direction. Return positive, negative or 0,\n  // indicating whether the selection got bigger, or smaller, or is unchanged.\n  extendByOneCharacter(direction) {\n    const length = this.selection.toString().length;\n    this.selection.modify(\"extend\", direction, character);\n    return this.selection.toString().length - length;\n  }\n\n  // Get the direction of the selection. The selection is \"forward\" if the focus is at or after the\n  // anchor, and \"backward\" otherwise.\n  // NOTE(smblott). This could be better, see: https://dom.spec.whatwg.org/#interface-range\n  // (however, that probably wouldn't work for inputs).\n  getDirection() {\n    // Try to move the selection forward or backward, check whether it got bigger or smaller (then\n    // restore it).\n    for (const direction of [forward, backward]) {\n      const change = this.extendByOneCharacter(direction);\n      if (change) {\n        this.extendByOneCharacter(this.opposite[direction]);\n        if (change > 0) {\n          return direction;\n        } else {\n          return this.opposite[direction];\n        }\n      }\n    }\n    return forward;\n  }\n\n  collapseSelectionToAnchor() {\n    if (this.selection.toString().length == 0) return;\n    if (this.getDirection() === backward) {\n      this.selection.collapseToEnd();\n    } else {\n      this.selection.collapseToStart();\n    }\n  }\n\n  collapseSelectionToFocus() {\n    if (this.selection.toString().length == 0) return;\n    if (this.getDirection() === forward) {\n      this.selection.collapseToEnd();\n    } else {\n      this.selection.collapseToStart();\n    }\n  }\n\n  setSelectionRange(range) {\n    this.selection.removeAllRanges();\n    // TODO(philc): Is this return needed?\n    return this.selection.addRange(range);\n  }\n\n  // For \"aw\", \"as\". We don't do \"ap\" (for paragraphs), because Chrome paragraph movements are\n  // weird.\n  selectLexicalEntity(entity, count) {\n    if (count == null) {\n      count = 1;\n    }\n    this.collapseSelectionToFocus();\n    // This makes word movements a bit more vim-like.\n    if (entity === word) {\n      this.runMovement([forward, character]);\n    }\n    this.runMovement([backward, entity]);\n    this.collapseSelectionToFocus();\n    for (let i = 0, end = count; i < end; i++) {\n      this.runMovement([forward, entity]);\n    }\n  }\n\n  selectLine(count) {\n    // Even under caret mode, we still need an extended selection here.\n    this.alterMethod = \"extend\";\n    if (this.getDirection() === forward) this.reverseSelection();\n    this.runMovement(backward, lineboundary);\n    this.reverseSelection();\n    for (let i = 1, end = count; i < end; i++) this.runMovement(forward, line);\n    this.runMovement(forward, lineboundary);\n    // Include the next character if that character is a newline.\n    if (this.getNextForwardCharacter() === \"\\n\") return this.runMovement(forward, character);\n  }\n\n  // Scroll the focus into view.\n  scrollIntoView() {\n    if (this.selection.type !== \"None\") {\n      const elementWithFocus = DomUtils.getElementWithFocus(\n        this.selection,\n        this.getDirection() === backward,\n      );\n      if (elementWithFocus) return Scroller.scrollIntoView(elementWithFocus);\n    }\n  }\n}\n\nclass VisualMode extends KeyHandlerMode {\n  init(options) {\n    let movement;\n    if (options == null) {\n      options = {};\n    }\n    this.movement = new Movement(options.alterMethod != null ? options.alterMethod : \"extend\");\n    this.selection = this.movement.selection;\n\n    // Build the key mapping structure required by KeyHandlerMode. This only handles one- and\n    // two-key mappings.\n    const keyMapping = {};\n    for (const keys of Object.keys(this.movements || {})) {\n      movement = this.movements[keys];\n      if (\"function\" === typeof movement) {\n        movement = movement.bind(this);\n      }\n      if (keys.length === 1) {\n        keyMapping[keys] = { command: movement };\n      } else { // keys.length == 2\n        if (keyMapping[keys[0]] == null) {\n          keyMapping[keys[0]] = {};\n        }\n        Object.assign(keyMapping[keys[0]], { [keys[1]]: { command: movement } });\n      }\n    }\n\n    // Aliases and complex bindings.\n    Object.assign(keyMapping, {\n      \"B\": keyMapping.b,\n      \"W\": keyMapping.w,\n      \"<c-e>\": {\n        command(count) {\n          return Scroller.scrollBy(\"y\", count * Settings.get(\"scrollStepSize\"), 1, false);\n        },\n      },\n      \"<c-y>\": {\n        command(count) {\n          return Scroller.scrollBy(\"y\", -count * Settings.get(\"scrollStepSize\"), 1, false);\n        },\n      },\n    });\n\n    super.init(Object.assign(options, {\n      name: options.name != null ? options.name : \"visual\",\n      indicator: options.indicator != null ? options.indicator : \"Visual mode\",\n      // Visual mode, visual-line mode and caret mode each displace each other.\n      singleton: \"visual-mode-group\",\n      exitOnEscape: true,\n      suppressAllKeyboardEvents: true,\n      keyMapping,\n      commandHandler: this.commandHandler.bind(this),\n    }));\n\n    // If there was a range selection when the user lanuched visual mode, then we retain the\n    // selection on exit.\n    this.shouldRetainSelectionOnExit = this.options.userLaunchedMode &&\n      (this.selection.type === \"Range\");\n\n    this.onExit((event = null) => {\n      // Retain any selection, regardless of how we exit.\n      if (this.shouldRetainSelectionOnExit) {\n        // This mimics vim: when leaving visual mode via Escape, collapse to focus, otherwise\n        // collapse to anchor.\n      } else if (\n        event && (event.type === \"keydown\") && KeyboardUtils.isEscape(event) &&\n        (this.name !== \"caret\")\n      ) {\n        this.movement.collapseSelectionToFocus();\n      } else {\n        this.movement.collapseSelectionToAnchor();\n      }\n\n      // Don't leave the user in insert mode just because they happen to have selected an input.\n      if (document.activeElement && DomUtils.isEditable(document.activeElement)) {\n        if ((event != null ? event.type : undefined) !== \"click\") {\n          return document.activeElement.blur();\n        }\n      }\n    });\n\n    this.push({\n      _name: `${this.id}/enter/click`,\n      // Yank on <Enter>.\n      keypress: (event) => {\n        if (event.key === \"Enter\") {\n          if (!event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) {\n            this.yank();\n            return this.suppressEvent;\n          }\n        }\n        return this.continueBubbling;\n      },\n      // Click in a focusable element exits.\n      click: (event) =>\n        this.alwaysContinueBubbling(() => {\n          if (DomUtils.isFocusable(event.target)) {\n            return this.exit(event);\n          }\n        }),\n    });\n\n    // Establish or use the initial selection. If that's not possible, then enter caret mode.\n    if (this.name !== \"caret\") {\n      if ([\"Caret\", \"Range\"].includes(this.selection.type)) {\n        let selectionRect = this.selection.getRangeAt(0).getBoundingClientRect();\n        if (globalThis.vimiumDomTestsAreRunning) {\n          // We're running the DOM tests, where getBoundingClientRect() isn't available.\n          if (!selectionRect) {\n            selectionRect = { top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 };\n          }\n        }\n        selectionRect = Rect.intersect(\n          selectionRect,\n          Rect.create(0, 0, globalThis.innerWidth, globalThis.innerHeight),\n        );\n        if ((selectionRect.height >= 0) && (selectionRect.width >= 0)) {\n          // The selection is visible in the current viewport.\n          if (this.selection.type === \"Caret\") {\n            // The caret is in the viewport. Make make it visible.\n            this.movement.extendByOneCharacter(forward) ||\n              this.movement.extendByOneCharacter(backward);\n          }\n        } else {\n          // The selection is outside of the viewport: clear it. We guess that the user has moved\n          // on, and is more likely to be interested in visible content.\n          this.selection.removeAllRanges();\n        }\n      }\n\n      if ((this.selection.type !== \"Range\") && (this.name !== \"caret\")) {\n        new CaretMode().init();\n        return HUD.show(\"No usable selection, entering caret mode...\", 2500);\n      }\n    }\n  }\n\n  commandHandler({ command: { command }, count }) {\n    if (count == null) count = 1;\n    switch (typeof command) {\n      case \"string\":\n        for (let i = 0, end = count; i < end; i++) {\n          this.movement.runMovement(command);\n        }\n        break;\n      case \"function\":\n        command(count);\n        break;\n    }\n    return this.movement.scrollIntoView();\n  }\n\n  // find: (count, backwards) =>\n  find(count, backwards) {\n    const initialRange = this.selection.getRangeAt(0).cloneRange();\n    for (let i = 0, end = count; i < end; i++) {\n      const nextQuery = FindMode.getQuery(backwards);\n      if (!nextQuery) {\n        HUD.show(\"No query to find.\", 1000);\n        return;\n      }\n      if (!FindMode.execute(nextQuery, { colorSelection: false, backwards })) {\n        this.movement.setSelectionRange(initialRange);\n        HUD.show(`No matches for '${FindMode.query.rawQuery}'`, 1000);\n        return;\n      }\n    }\n\n    // The find was successfull. If we're in caret mode, then we should now have a selection, so we\n    // can drop back into visual mode.\n    if ((this.name === \"caret\") && (this.selection.toString().length > 0)) {\n      const mode = new VisualMode();\n      mode.init();\n      return mode;\n    }\n  }\n\n  // Yank the selection; always exits; collapses the selection; set @yankedText and return it.\n  yank(args) {\n    if (args == null) {\n      args = {};\n    }\n    this.yankedText = this.selection.toString();\n    this.exit();\n    HUD.copyToClipboard(this.yankedText);\n\n    let message = this.yankedText.replace(/\\s+/g, \" \");\n    if (15 < this.yankedText.length) {\n      message = message.slice(0, 12) + \"...\";\n    }\n    const plural = this.yankedText.length === 1 ? \"\" : \"s\";\n    HUD.show(`Yanked ${this.yankedText.length} character${plural}: \\\"${message}\\\".`, 2500);\n\n    return this.yankedText;\n  }\n}\n\n// A movement can be either a string or a function.\nVisualMode.prototype.movements = {\n  \"l\": \"forward character\",\n  \"h\": \"backward character\",\n  \"j\": \"forward line\",\n  \"k\": \"backward line\",\n  \"e\": \"forward word\",\n  \"b\": \"backward word\",\n  \"w\": \"forward vimword\",\n  \")\": \"forward sentence\",\n  \"(\": \"backward sentence\",\n  \"}\": \"forward paragraph\",\n  \"{\": \"backward paragraph\",\n  \"0\": \"backward lineboundary\",\n  \"$\": \"forward lineboundary\",\n  \"G\": \"forward documentboundary\",\n  \"gg\": \"backward documentboundary\",\n\n  \"aw\"(count) {\n    return this.movement.selectLexicalEntity(word, count);\n  },\n  \"as\"(count) {\n    return this.movement.selectLexicalEntity(sentence, count);\n  },\n\n  \"n\"(count) {\n    return this.find(count, false);\n  },\n  \"N\"(count) {\n    return this.find(count, true);\n  },\n  \"/\"() {\n    this.exit();\n    return new FindMode({ returnToViewport: true }).onExit(() => new VisualMode().init());\n  },\n\n  \"y\"() {\n    return this.yank();\n  },\n  \"Y\"(count) {\n    this.movement.selectLine(count);\n    return this.yank();\n  },\n  \"p\"() {\n    return chrome.runtime.sendMessage({ handler: \"openUrlInCurrentTab\", url: this.yank() });\n  },\n  \"P\"() {\n    return chrome.runtime.sendMessage({ handler: \"openUrlInNewTab\", url: this.yank() });\n  },\n  \"v\"() {\n    return new VisualMode().init();\n  },\n  \"V\"() {\n    return new VisualLineMode().init();\n  },\n  \"c\"() {\n    // If we're already in caret mode, or if the selection looks the same as it would in caret mode,\n    // then callapse to anchor (so that the caret-mode selection will seem unchanged). Otherwise,\n    // we're in visual mode and the user has moved the focus, so collapse to that.\n    if ((this.name === \"caret\") || (this.selection.toString().length <= 1)) {\n      this.movement.collapseSelectionToAnchor();\n    } else {\n      this.movement.collapseSelectionToFocus();\n    }\n    return new CaretMode().init();\n  },\n  \"o\"() {\n    return this.movement.reverseSelection();\n  },\n};\n\nclass VisualLineMode extends VisualMode {\n  init(options) {\n    if (options == null) {\n      options = {};\n    }\n    super.init(Object.assign(options, { name: \"visual/line\", indicator: \"Visual mode (line)\" }));\n    return this.extendSelection();\n  }\n\n  commandHandler({ command: { command }, count }) {\n    if (count == null) count = 1;\n    switch (typeof command) {\n      case \"string\":\n        for (let i = 0, end = count; i < end; i++) {\n          this.movement.runMovement(command);\n          // If the current selection\n          //  * has only 1 line (the line that is selected when we # enter the visual line mode), and\n          //  * its direction is different from the command,\n          // then the command will in effect unselect that line. In this case, we restore that line\n          // and reverse its direction, keeping that line selected.\n          if (this.selection.isCollapsed) {\n            this.extendSelection();\n            const [direction, granularity] = command.split(\" \");\n            if ((this.movement.getDirection() !== direction) && (granularity === \"line\")) {\n              this.movement.reverseSelection();\n            }\n            this.movement.runMovement(command);\n          }\n        }\n        break;\n      case \"function\":\n        command(count);\n        break;\n    }\n    this.movement.scrollIntoView();\n    if (this.modeIsActive) {\n      return this.extendSelection();\n    }\n  }\n\n  extendSelection() {\n    const initialDirection = this.movement.getDirection();\n    // TODO(philc): Reformat this to be a plain loop rather than a closure.\n    return (() => {\n      const result = [];\n      for (const direction of [initialDirection, this.movement.opposite[initialDirection]]) {\n        this.movement.runMovement(direction, lineboundary);\n        result.push(this.movement.reverseSelection());\n      }\n      return result;\n    })();\n  }\n}\n\nclass CaretMode extends VisualMode {\n  init(options) {\n    if (options == null) {\n      options = {};\n    }\n    super.init(\n      Object.assign(options, { name: \"caret\", indicator: \"Caret mode\", alterMethod: \"move\" }),\n    );\n\n    // Establish the initial caret.\n    switch (this.selection.type) {\n      case \"None\":\n        this.establishInitialSelectionAnchor();\n        if (this.selection.type === \"None\") {\n          this.exit();\n          HUD.show(\"Create a selection before entering visual mode.\", 2500);\n          return;\n        }\n        break;\n      case \"Range\":\n        this.movement.collapseSelectionToAnchor();\n        break;\n    }\n\n    this.movement.extendByOneCharacter(forward);\n    return this.movement.scrollIntoView();\n  }\n\n  commandHandler(...args) {\n    this.movement.collapseSelectionToAnchor();\n    super.commandHandler(...(args || []));\n    if (this.modeIsActive) {\n      return this.movement.extendByOneCharacter(forward);\n    }\n  }\n\n  // When visual mode starts and there's no existing selection, we launch CaretMode and try to\n  // establish a selection. As a heuristic, we pick the first non-whitespace character of the first\n  // visible text node which seems to be big enough to be interesting.\n  // TODO(smblott). It might be better to do something similar to Clearly or Readability; that is,\n  // try to find the start of the page's main textual content.\n  establishInitialSelectionAnchor() {\n    let node;\n    const nodes = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);\n    while ((node = nodes.nextNode())) {\n      // Don't choose short text nodes; they're likely to be part of a banner.\n      if ((node.nodeType === 3) && (50 <= node.data.trim().length)) {\n        const element = node.parentElement;\n        if (DomUtils.getVisibleClientRect(element) && !DomUtils.isEditable(element)) {\n          // Start at the offset of the first non-whitespace character.\n          const offset = node.data.length - node.data.replace(/^\\s+/, \"\").length;\n          const range = document.createRange();\n          range.setStart(node, offset);\n          range.setEnd(node, offset);\n          this.movement.setSelectionRange(range);\n          return true;\n        }\n      }\n    }\n    return false;\n  }\n}\n\nglobalThis.VisualMode = VisualMode;\nglobalThis.VisualLineMode = VisualLineMode;\n"
  },
  {
    "path": "content_scripts/scroller.js",
    "content": "// activatedElement is different from document.activeElement -- the latter seems to be reserved\n// mostly for input elements. This mechanism allows us to decide whether to scroll a div or to\n// scroll the whole document.\nlet activatedElement = null;\n\n// Previously, the main scrolling element was document.body. If the \"experimental web platform\n// features\" flag is enabled, then we need to use document.scrollingElement instead. There's an\n// explanation in #2168: https://github.com/philc/vimium/pull/2168#issuecomment-236488091\n\nconst getScrollingElement = () =>\n  getSpecialScrollingElement() || document.scrollingElement || document.body;\n\n// Return 0, -1 or 1: the sign of the argument.\n// NOTE(smblott; 2014/12/17) We would like to use Math.sign(). However, according to this site\n// (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sign)\n// Math.sign() was only introduced in Chrome 38. This caused problems in R1.48 for users with old\n// Chrome installations. We can replace this with Math.sign() at some point.\n// TODO(philc): 2020-04-28: now we can make this replacement.\nconst getSign = function (val) {\n  if (!val) {\n    return 0;\n  } else {\n    if (val < 0) {\n      return -1;\n    } else {\n      return 1;\n    }\n  }\n};\n\nconst scrollProperties = {\n  x: {\n    axisName: \"scrollLeft\",\n    max: \"scrollWidth\",\n    viewSize: \"clientWidth\",\n  },\n  y: {\n    axisName: \"scrollTop\",\n    max: \"scrollHeight\",\n    viewSize: \"clientHeight\",\n  },\n};\n\n// Translate a scroll request into a number (which will be interpreted by `scrollBy` as a relative\n// amount, or by `scrollTo` as an absolute amount). :direction must be \"x\" or \"y\". :amount may be\n// either a number (in which case it is simply returned) or a string. If :amount is a string, then\n// it is either \"max\" (meaning the height or width of element), or \"viewSize\". In both cases, we\n// look up and return the requested amount, either in `element` or in `window`, as appropriate.\nconst getDimension = function (el, direction, amount) {\n  if (Utils.isString(amount)) {\n    const name = amount;\n    // the clientSizes of the body are the dimensions of the entire page, but the viewport should\n    // only be the part visible through the window\n    if ((name === \"viewSize\") && (el === getScrollingElement())) {\n      // TODO(smblott) Should we not be returning the width/height of element, here?\n      return (direction === \"x\") ? globalThis.innerWidth : globalThis.innerHeight;\n    } else {\n      return el[scrollProperties[direction][name]];\n    }\n  } else {\n    return amount;\n  }\n};\n\n// Perform a scroll. Return true if we successfully scrolled by any amount, and false otherwise.\nconst performScroll = function (element, direction, amount) {\n  const axisName = scrollProperties[direction].axisName;\n  const before = element[axisName];\n  if (element.scrollBy) {\n    const scrollArg = { behavior: \"instant\" };\n    scrollArg[direction === \"x\" ? \"left\" : \"top\"] = amount;\n    element.scrollBy(scrollArg);\n  } else {\n    element[axisName] += amount;\n  }\n  return element[axisName] !== before;\n};\n\n// Test whether `element` should be scrolled. E.g. hidden elements should not be scrolled.\nconst shouldScroll = function (element, direction) {\n  const computedStyle = globalThis.getComputedStyle(element);\n  // Elements with `overflow: hidden` must not be scrolled.\n  if (computedStyle.getPropertyValue(`overflow-${direction}`) === \"hidden\") {\n    return false;\n  }\n  // Elements which are not visible should not be scrolled.\n  if ([\"hidden\", \"collapse\"].includes(computedStyle.getPropertyValue(\"visibility\"))) {\n    return false;\n  }\n  if (computedStyle.getPropertyValue(\"display\") === \"none\") {\n    return false;\n  }\n  return true;\n};\n\n// Test whether element does actually scroll in the direction required when asked to do so. Due to\n// chrome bug 110149, scrollHeight and clientHeight cannot be used to reliably determine whether an\n// element will scroll. Instead, we scroll the element by 1 or -1 and see if it moved (then put it\n// back). :factor is the factor by which :scrollBy and :scrollTo will later scale the scroll amount.\n// :factor can be negative, so we need it here in order to decide whether we should test a forward\n// scroll or a backward scroll.\n// Bug last verified in Chrome 38.0.2125.104.\nconst doesScroll = function (element, direction, amount, factor) {\n  // amount is treated as a relative amount, which is correct for relative scrolls. For absolute\n  // scrolls (only gg, G, and friends), amount can be either a string (\"max\" or \"viewSize\") or zero.\n  // In the former case, we're definitely scrolling forwards, so any positive value will do for\n  // delta. In the latter, we're definitely scrolling backwards, so a delta of -1 will do. For\n  // absolute scrolls, factor is always 1.\n  let delta = (factor * getDimension(element, direction, amount)) || -1;\n  delta = getSign(delta); // 1 or -1\n  return performScroll(element, direction, delta) && performScroll(element, direction, -delta);\n};\n\nconst isScrollableElement = function (element, direction, amount, factor) {\n  if (direction == null) direction = \"y\";\n  if (amount == null) amount = 1;\n  if (factor == null) factor = 1;\n  return doesScroll(element, direction, amount, factor) && shouldScroll(element, direction);\n};\n\n// From element and its parents, find the first which we should scroll and which does scroll.\nconst findScrollableElement = function (element, direction, amount, factor) {\n  while (\n    (element !== getScrollingElement()) && !isScrollableElement(element, direction, amount, factor)\n  ) {\n    element = DomUtils.getContainingElement(element) || getScrollingElement();\n  }\n  return element;\n};\n\n// On some pages, the scrolling element is not actually scrollable. Here, we search the document for\n// the largest visible element which does scroll vertically. This is used to initialize\n// activatedElement. See #1358.\nconst firstScrollableElement = function (element = null) {\n  let child;\n  if (!element) {\n    const scrollingElement = getScrollingElement();\n    if (doesScroll(scrollingElement, \"y\", 1, 1) || doesScroll(scrollingElement, \"y\", -1, 1)) {\n      return scrollingElement;\n    } else {\n      element = document.body || getScrollingElement();\n    }\n  }\n\n  if (doesScroll(element, \"y\", 1, 1) || doesScroll(element, \"y\", -1, 1)) {\n    return element;\n  } else {\n    // children = children.filter (c) -> c.rect # Filter out non-visible elements.\n    const children = Array.from(element.children)\n      .map((c) => ({ \"element\": c, \"rect\": DomUtils.getVisibleClientRect(c) }))\n      .filter((child) => child.rect); // Filter out non-visible elements.\n    children.map((child) => child.area = child.rect.width * child.rect.height);\n    for (child of children.sort((a, b) => b.area - a.area)) { // Largest to smallest by visible area.\n      const el = firstScrollableElement(child.element);\n      if (el) {\n        return el;\n      }\n    }\n    return null;\n  }\n};\n\nconst checkVisibility = function (element) {\n  // If the activated element has been scrolled completely offscreen, then subsequent changes in its\n  // scroll position will not provide any more visual feedback to the user. Therefore, we deactivate\n  // it so that subsequent scrolls affect the parent element.\n  const rect = activatedElement.getBoundingClientRect();\n  if (\n    (rect.bottom < 0) || (rect.top > globalThis.innerHeight) || (rect.right < 0) ||\n    (rect.left > globalThis.innerWidth)\n  ) {\n    return activatedElement = element;\n  }\n};\n\n// How scrolling is handled by CoreScroller.\n//   - For jump scrolling, the entire scroll happens immediately.\n//   - For smooth scrolling with distinct key presses, a separate animator is initiated for each key\n//     press. Therefore, several animators may be active at the same time. This ensures that two\n//     quick taps on `j` scroll to the same position as two slower taps.\n//   - For smooth scrolling with keyboard repeat (continuous scrolling), the most recently-activated\n//     animator continues scrolling at least until its keyup event is received. We never initiate a\n//     new animator on keyboard repeat.\n\n// CoreScroller contains the core function (scroll) and logic for relative scrolls. All scrolls are\n// ultimately translated to relative scrolls. CoreScroller is not exported.\nconst CoreScroller = {\n  init() {\n    this.time = 0;\n    this.lastEvent = this.keyDownKey = null;\n    this.installCancelEventListener();\n  },\n\n  // This installs listeners for events which should cancel smooth scrolling.\n  installCancelEventListener() {\n    // NOTE(smblott) With extreme keyboard configurations, Chrome sometimes does not get a keyup\n    // event for every keydown, in which case tapping \"j\" scrolls indefinitely. This appears to be a\n    // Chrome/OS/XOrg bug of some kind. See #1549.\n    // TODO(philc): I believe some of these returns are unnecessary.\n    return handlerStack.push({\n      _name: \"scroller/track-key-status\",\n      keydown: (event) => {\n        return handlerStack.alwaysContinueBubbling(() => {\n          this.keyDownKey = event.code;\n          if (!event.repeat) this.time += 1;\n          this.lastEvent = event;\n        });\n      },\n      keyup: (event) => {\n        return handlerStack.alwaysContinueBubbling(() => {\n          if (event.code === this.keyDownKey) {\n            this.keyDownKey = null;\n            this.time += 1;\n          }\n        });\n      },\n      blur: (event) => {\n        return handlerStack.alwaysContinueBubbling(() => {\n          if (event.target === window) this.time += 1;\n        });\n      },\n    });\n  },\n\n  // Return true if CoreScroller would not initiate a new scroll right now.\n  wouldNotInitiateScroll() {\n    return this.lastEvent && this.lastEvent.repeat && Settings.get(\"smoothScroll\");\n  },\n\n  // Calibration fudge factors for continuous scrolling. The calibration value starts at 1.0. We\n  // then increase it (until it exceeds @maxCalibration) if we guess that the scroll is too slow, or\n  // decrease it (until it is less than @minCalibration) if we guess that the scroll is too fast.\n  // The cutoff point for which guess we make is @calibrationBoundary. We require: 0\n  // < @minCalibration <= 1 <= @maxCalibration.\n  // Controls how much we're willing to slow scrolls down; smaller means more slow down.\n  minCalibration: 0.5,\n  // Controls how much we're willing to speed scrolls up; bigger means more speed up.\n  maxCalibration: 1.6,\n  // Boundary between scrolls which are considered too slow, or too fast.\n  calibrationBoundary: 150,\n\n  // Scroll element by a relative amount (a number) in some direction.\n  scroll(element, direction, amount, continuous) {\n    if (continuous == null) continuous = true;\n    if (!amount) {\n      return;\n    }\n\n    if (!Settings.get(\"smoothScroll\")) {\n      // Jump scrolling.\n      performScroll(element, direction, amount);\n      checkVisibility(element);\n      return;\n    }\n\n    // We don't activate new animators on keyboard repeats; rather, the most-recently activated\n    // animator continues scrolling.\n    if (this.lastEvent != null ? this.lastEvent.repeat : undefined) {\n      return;\n    }\n\n    const activationTime = ++this.time;\n    const myKeyIsStillDown = () => (this.time === activationTime) && this.keyDownKey != null;\n\n    // Store amount's sign and make amount positive; the arithmetic is clearer when amount is\n    // positive.\n    const sign = getSign(amount);\n    amount = Math.abs(amount);\n\n    // Initial intended scroll duration (in ms). We allow a bit longer for longer scrolls.\n    const duration = Math.max(100, 20 * Math.log(amount));\n\n    let totalDelta = 0;\n    let totalElapsed = 0.0;\n    let calibration = 1.0;\n    let previousTimestamp = null;\n    const cancelEventListener = this.installCancelEventListener();\n\n    const animate = (timestamp) => {\n      if (previousTimestamp == null) {\n        previousTimestamp = timestamp;\n      }\n      if (timestamp === previousTimestamp) {\n        return requestAnimationFrame(animate);\n      }\n\n      // The elapsed time is typically about 16ms.\n      const elapsed = timestamp - previousTimestamp;\n      totalElapsed += elapsed;\n      previousTimestamp = timestamp;\n\n      // The constants in the duration calculation, above, are chosen to provide reasonable scroll\n      // speeds for distinct keypresses. For continuous scrolls, some scrolls are too slow, and\n      // others too fast. Here, we speed up the slower scrolls, and slow down the faster scrolls.\n      if (\n        myKeyIsStillDown() && (75 <= totalElapsed) &&\n        (this.minCalibration <= calibration && calibration <= this.maxCalibration)\n      ) {\n        // Speed up slow scrolls.\n        if ((1.05 * calibration * amount) < this.calibrationBoundary) {\n          calibration *= 1.05;\n        }\n        // Slow down fast scrolls.\n        if (this.calibrationBoundary < (0.95 * calibration * amount)) {\n          calibration *= 0.95;\n        }\n      }\n\n      // Calculate the initial delta, rounding up to ensure progress. Then, adjust delta to account\n      // for the current scroll state.\n      let delta = Math.ceil(amount * (elapsed / duration) * calibration);\n      delta = myKeyIsStillDown() ? delta : Math.max(0, Math.min(delta, amount - totalDelta));\n\n      if (delta && performScroll(element, direction, sign * delta)) {\n        totalDelta += delta;\n        return requestAnimationFrame(animate);\n      } else {\n        // We're done.\n        handlerStack.remove(cancelEventListener);\n        return checkVisibility(element);\n      }\n    };\n\n    // If we've been asked not to be continuous, then we advance time, so the myKeyIsStillDown test\n    // always fails.\n    if (!continuous) {\n      ++this.time;\n    }\n\n    // Start scrolling.\n    requestAnimationFrame(animate);\n  },\n};\n\n// Scroller contains the two main scroll functions which are used by clients.\nconst Scroller = {\n  init() {\n    const handler = { _name: \"scroller/active-element\" };\n    // Only Chrome has a DOMActivate event. On Firefox, we must listen for click. See #3287.\n    const eventName = Utils.isFirefox() ? \"click\" : \"DOMActivate\";\n    handler[eventName] = (event) =>\n      handlerStack.alwaysContinueBubbling(function () {\n        // If event.path is present, the true event taget (potentially inside a Shadow DOM inside\n        // event.target) can be found as its first element.\n        // NOTE(mrmr1993): event.path has been renamed to event.deepPath in the spec, but this\n        // change is not yet implemented by Chrome.\n        const path = event.deepPath || event.path;\n        return activatedElement = path ? path[0] : event.target;\n      });\n    handlerStack.push(handler);\n    CoreScroller.init();\n    this.reset();\n  },\n\n  reset() {\n    activatedElement = null;\n  },\n\n  // scroll the active element in :direction by :amount * :factor.\n  // :factor is needed because :amount can take on string values, which scrollBy converts to element\n  // dimensions.\n  scrollBy(direction, amount, factor, continuous) {\n    // if this is called before domReady, just use the window scroll function\n    if (factor == null) {\n      factor = 1;\n    }\n    if (continuous == null) {\n      continuous = true;\n    }\n    if (!getScrollingElement() && amount instanceof Number) {\n      if (direction === \"x\") {\n        globalThis.scrollBy(amount, 0);\n      } else {\n        globalThis.scrollBy(0, amount);\n      }\n      return;\n    }\n\n    if (!activatedElement) {\n      activatedElement = (getScrollingElement() && firstScrollableElement()) ||\n        getScrollingElement();\n    }\n    if (!activatedElement) {\n      return;\n    }\n\n    // Avoid the expensive scroll calculation if it will not be used. This reduces costs during\n    // smooth, continuous scrolls, and is just an optimization.\n    if (!CoreScroller.wouldNotInitiateScroll()) {\n      const element = findScrollableElement(activatedElement, direction, amount, factor);\n      const elementAmount = factor * getDimension(element, direction, amount);\n      return CoreScroller.scroll(element, direction, elementAmount, continuous);\n    }\n  },\n\n  scrollTo(direction, pos) {\n    if (!activatedElement) {\n      activatedElement = (getScrollingElement() && firstScrollableElement()) ||\n        getScrollingElement();\n    }\n    if (!activatedElement) {\n      return;\n    }\n\n    const element = findScrollableElement(activatedElement, direction, pos, 1);\n    const amount = getDimension(element, direction, pos) -\n      element[scrollProperties[direction].axisName];\n    return CoreScroller.scroll(element, direction, amount);\n  },\n\n  // Is element scrollable and not the activated element?\n  isScrollableElement(element) {\n    if (!activatedElement) {\n      activatedElement = (getScrollingElement() && firstScrollableElement()) ||\n        getScrollingElement();\n    }\n    return (element !== activatedElement) && isScrollableElement(element);\n  },\n\n  // Scroll the top, bottom, left and right of element into view. The is used by visual mode to\n  // ensure the focus remains visible.\n  scrollIntoView(element) {\n    if (!activatedElement) {\n      activatedElement = getScrollingElement() && firstScrollableElement();\n    }\n    const rects = element.getClientRects();\n    const rect = rects ? rects[0] : undefined;\n    if (rect) {\n      // Scroll y axis.\n      let amount;\n      if (rect.bottom < 0) {\n        amount = rect.bottom - Math.min(rect.height, globalThis.innerHeight);\n        element = findScrollableElement(element, \"y\", amount, 1);\n        CoreScroller.scroll(element, \"y\", amount, false);\n      } else if (globalThis.innerHeight < rect.top) {\n        amount = rect.top + Math.min(rect.height - globalThis.innerHeight, 0);\n        element = findScrollableElement(element, \"y\", amount, 1);\n        CoreScroller.scroll(element, \"y\", amount, false);\n      }\n\n      // Scroll x axis.\n      if (rect.right < 0) {\n        amount = rect.right - Math.min(rect.width, globalThis.innerWidth);\n        element = findScrollableElement(element, \"x\", amount, 1);\n        CoreScroller.scroll(element, \"x\", amount, false);\n      } else if (globalThis.innerWidth < rect.left) {\n        amount = rect.left + Math.min(rect.width - globalThis.innerWidth, 0);\n        element = findScrollableElement(element, \"x\", amount, 1);\n        CoreScroller.scroll(element, \"x\", amount, false);\n      }\n    }\n  },\n};\n\nconst getSpecialScrollingElement = function () {\n  const selector = specialScrollingElementMap[globalThis.location.host];\n  if (selector) {\n    return document.querySelector(selector);\n  }\n};\n\nconst specialScrollingElementMap = {\n  \"twitter.com\": \"div.permalink-container div.permalink[role=main]\",\n  \"reddit.com\": \"#overlayScrollContainer\",\n  \"new.reddit.com\": \"#overlayScrollContainer\",\n  \"www.reddit.com\": \"#overlayScrollContainer\",\n  \"web.telegram.org\": \".MessageList\",\n};\n\nglobalThis.Scroller = Scroller;\n"
  },
  {
    "path": "content_scripts/ui_component.js",
    "content": "// A UIComponent is an iframe containing a Vimium extension page, like the Vomnibar. This class\n// provides methods that content scripts can use to interact with that page:\n// - show\n// - hide\n// - postMessage\n//\n// When the iframe has not yet been loaded, all messages will be queued until it's done loading. The\n// page in the iframe uses the module ui_component_messenger.js to manage message passing back to\n// this class. Since the iframe's page can receive messages from untrusted javascript, secure\n// message passing is achieved using ports from MessageChannel() and a vimiumSecret handshake.\nclass UIComponent {\n  iframeElement;\n  iframePort;\n  showing = false;\n  // An optional message handler for handling messages from the iFrame.\n  messageHandler;\n  iframeFrameId;\n  // These are the focus options set when show() is invoked. We store them while the UIComponent\n  // is visible so we know how to revert focus once it's dismissed.\n  focusOptions = {};\n  shadowDOM;\n  // When we open ports to the iframe using MessageChannel, we save them so that our unit tests can\n  // close the ports. See ui_component_test.js for details.\n  messageChannelPorts;\n\n  // - iframeUrl:\n  // - className: the CSS class to add to the iframe.\n  // - messageHandler: optional; a function to handle messages from the iframe's page.\n  async load(iframeUrl, className, messageHandler) {\n    if (this.iframeFrameElement) throw new Error(\"init should only be called once.\");\n    this.messageHandler = messageHandler;\n    const isDomTests = iframeUrl.includes(\"?dom_tests=true\");\n    this.iframeElement = DomUtils.createElement(\"iframe\");\n\n    // Allow Vimium's iframes to have clipboard access in Chrome. This is needed when triggering\n    // some commands, like link hints or copyCurrentUrl, from within the help dialog. Firefox does\n    // not support clipboard-read and clipboard-write in the allow attribute. NOTE(philc): this\n    // permission has to be set before we append the iframe to the DOM, or Chrome will log the\n    // console error \"Potential permissions policy violation: clipboard-read is not allowed in this\n    // document.\"\n    if (!Utils.isFirefox()) {\n      this.iframeElement.allow = \"clipboard-read; clipboard-write\";\n    }\n\n    const styleSheet = DomUtils.createElement(\"style\");\n    styleSheet.type = \"text/css\";\n    // Default to everything hidden while the stylesheet loads.\n    styleSheet.innerHTML = \"iframe {display: none;}\";\n\n    // Fetch \"content_scripts/vimium.css\" from chrome.storage.session; the background page caches\n    // it there.\n    chrome.storage.session.get(\"vimiumCSSInChromeStorage\")\n      .then((items) => styleSheet.innerHTML = items.vimiumCSSInChromeStorage);\n\n    this.iframeElement.className = className;\n\n    const shadowWrapper = DomUtils.createElement(\"div\");\n    // Prevent the page's CSS from interfering with this container div.\n    shadowWrapper.className = \"vimium-reset\";\n    this.shadowDOM = shadowWrapper.attachShadow({ mode: \"open\" });\n    this.shadowDOM.appendChild(styleSheet);\n    // Allow a user's custom CSS to style iframe element inside this shadow DOM.\n    DomUtils.injectUserCss(this.shadowDOM);\n    this.shadowDOM.appendChild(this.iframeElement);\n\n    // Load the iframe and pass it a port via window.postMessage so we can communicate privately\n    // with the iframe. Use a promise here so that requests to message this iframe's port will\n    // block until it's ready. See #1679.\n    let resolveFn;\n    this.iframePort = new Promise((resolve, _reject) => {\n      resolveFn = resolve;\n    });\n\n    this.setIframeVisible(false);\n    this.iframeElement.src = chrome.runtime.getURL(iframeUrl);\n    await DomUtils.documentReady();\n    this.handleDarkReaderFilter();\n    document.documentElement.appendChild(shadowWrapper);\n\n    const secret = (await chrome.storage.session.get(\"vimiumSecret\")).vimiumSecret;\n    const { port1, port2 } = new MessageChannel();\n    this.messageChannelPorts = [port1, port2];\n    this.iframeElement.addEventListener(\"load\", () => {\n      // Get vimiumSecret so the iframe can determine that our message isn't the page\n      // impersonating us.\n      // Outside of tests, target origin starts with chrome-extension://{vimium's-id}\n      const targetOrigin = isDomTests ? \"*\" : chrome.runtime.getURL(\"\");\n      this.iframeElement.contentWindow.postMessage(secret, targetOrigin, [port2]);\n      port1.onmessage = (event) => {\n        let eventName = null;\n        // TODO(philc): Why are we using both data and data.name as the name? Pick one.\n        if (event) {\n          eventName = (event.data ? event.data.name : undefined) || event.data;\n        }\n\n        switch (eventName) {\n          case \"uiComponentIsReady\":\n            // If this frame receives the focus, then hide the UI component.\n            globalThis.addEventListener(\n              \"focus\",\n              forTrusted((event) => {\n                if ((event.target === window) && this.focusOptions.focus) {\n                  this.hide(false);\n                }\n                // Continue propagating the event.\n                return true;\n              }),\n              true,\n            );\n            // Set the iframe's port, thereby rendering the UI component ready.\n            resolveFn(port1);\n            break;\n          case \"setIframeFrameId\":\n            this.iframeFrameId = event.data.iframeFrameId;\n            break;\n          case \"hide\":\n            return this.hide();\n          default:\n            this.messageHandler?.(event);\n        }\n      };\n    });\n  }\n\n  // This ensures that Vimium's UI elements (HUD, Vomnibar) honor the browser's light/dark theme\n  // preference, even when the user is also using the DarkReader extension. DarkReader is the most\n  // popular dark mode Chrome extension in use as of 2020.\n  handleDarkReaderFilter() {\n    const reverseFilterClass = \"vimium-reverse-dark-reader-filter\";\n    const reverseFilterIfExists = () => {\n      // The DarkReader extension creates this element if it's actively modifying the current page.\n      const darkReaderElement = document.getElementById(\"dark-reader-style\");\n      if (darkReaderElement && darkReaderElement.innerHTML.includes(\"filter\")) {\n        this.iframeElement.classList.add(reverseFilterClass);\n      } else {\n        this.iframeElement.classList.remove(reverseFilterClass);\n      }\n    };\n\n    reverseFilterIfExists();\n\n    const observer = new MutationObserver(reverseFilterIfExists);\n    observer.observe(document.head, { characterData: true, subtree: true, childList: true });\n  }\n\n  setIframeVisible(visible) {\n    const classes = this.iframeElement.classList;\n    if (visible) {\n      classes.remove(\"vimium-ui-component-hidden\");\n      classes.add(\"vimium-ui-component-visible\");\n    } else {\n      classes.add(\"vimium-ui-component-hidden\");\n      classes.remove(\"vimium-ui-component-visible\");\n    }\n  }\n\n  // Send a message to this UIComponent's iframe's page.\n  // - data: an object with at least a `name` field.\n  async postMessage(data) {\n    (await this.iframePort).postMessage(data);\n  }\n\n  // Show the UIComponent.\n  // - messageData: a message to send to the underlying iframe via `postMessage`.\n  // - focusOptions: optional. {\n  //     focus: whether the UIComponent should be focused once it's ready.\n  //     sourceFrameId: which frame should the focus when this component is dismissed.\n  //   }\n  async show(messageData = {}, focusOptions = {}) {\n    if (focusOptions) {\n      Utils.assertType({ focus: \"boolean\", sourceFrameId: \"number\" }, focusOptions);\n    }\n    this.focusOptions = focusOptions;\n    await this.postMessage(messageData);\n    this.setIframeVisible(true);\n    if (this.focusOptions.focus) {\n      this.iframeElement.focus();\n    }\n    this.showing = true;\n  }\n\n  async hide(shouldRefocusOriginalFrame) {\n    if (shouldRefocusOriginalFrame == null) shouldRefocusOriginalFrame = true;\n\n    await this.iframePort;\n    if (!this.showing) return;\n    this.showing = false;\n    this.setIframeVisible(false);\n    if (this.focusOptions.focus) {\n      this.iframeElement.blur();\n      if (shouldRefocusOriginalFrame) {\n        if (this.focusOptions.sourceFrameId != null) {\n          chrome.runtime.sendMessage({\n            handler: \"sendMessageToFrames\",\n            frameId: this.focusOptions.sourceFrameId,\n            message: {\n              handler: \"focusFrame\",\n              forceFocusThisFrame: true,\n            },\n          });\n        } else {\n          Utils.nextTick(() => globalThis.focus());\n        }\n      }\n    }\n    this.focusOptions = {};\n    this.postMessage({ name: \"hidden\" }); // Inform the UI component that it is hidden.\n  }\n}\n\nglobalThis.UIComponent = UIComponent;\n"
  },
  {
    "path": "content_scripts/vimium.css",
    "content": "/*\n * Many CSS class names in this file use the verbose \"vimium-\" as the class name. This is so we\n * don't use the same CSS class names that the page is using, so the page's CSS doesn't mess with\n * the style of our Vimium dialogs.\n *\n * We use the maximum z-index value for all Vimium elements to guarantee that they always appear on\n * top. Chrome supports z-index values up to 2,147,483,647 (= 2^31 - 1). We utilize the maximum\n * z-index value allowable to ensure Vimium elements have precedence over all other page elements.\n */\n\n/*\n * This vimium-reset class can be added to any of our UI elements to give it a clean slate. This is\n * useful in case the page has declared a broad rule like \"a { padding: 50px; }\" which will mess up\n * our UI. These declarations contain more specifiers than necessary to increase their specificity\n * (precedence).\n */\n\n:root {\n  --vimium-background-color: white;\n  --vimium-background-text-color: black;\n  --vimium-foreground-color: white;\n  --vimium-foreground-text-color: black;\n  --vimium-link-color: blue;\n}\n\n.vimium-reset,\ndiv.vimium-reset,\nspan.vimium-reset,\ntable.vimium-reset,\na.vimium-reset,\na:visited.vimium-reset,\na:link.vimium-reset,\na:hover.vimium-reset,\ntd.vimium-reset,\ntr.vimium-reset {\n  background: none;\n  border: none;\n  bottom: auto;\n  box-shadow: none;\n  color: black;\n  cursor: auto;\n  display: inline;\n  float: none;\n  font-family: \"Helvetica Neue\", \"Helvetica\", \"Arial\", sans-serif;\n  font-size: inherit;\n  font-style: normal;\n  font-variant: normal;\n  font-weight: normal;\n  height: auto;\n  left: auto;\n  letter-spacing: 0;\n  line-height: 100%;\n  margin: 0;\n  max-height: none;\n  max-width: none;\n  min-height: 0;\n  min-width: 0;\n  opacity: 1;\n  padding: 0;\n  position: static;\n  right: auto;\n  text-align: left;\n  text-decoration: none;\n  text-indent: 0;\n  text-shadow: none;\n  text-transform: none;\n  top: auto;\n  vertical-align: baseline;\n  white-space: normal;\n  width: auto;\n  z-index: 2147483647;\n}\n\nthead.vimium-reset, tbody.vimium-reset {\n  display: table-header-group;\n}\n\ntbody.vimium-reset {\n  display: table-row-group;\n}\n\n/*\n * Linkhints CSS\n */\n\n/* Prior to 2025, we used camel case for all CSS selectors. We're preserving some camel case class\n * names here in link hints for backwards compatibility, because they're user-customizable via the\n * userDefinedLinkHintCss setting.\n *\n * Our default example value for userDefinedLinkHintCss refers to the \"vimiumHintMarker\" and\n * \"matchingCharacter\" classes. We'll assume these are user-facing and should not be changed:\n *   - vimiumHintMarker\n *   - matchingCharacter\n *   - vimiumActiveHintMarker\n *\n * Note that link hint elements have both internal-vimium-hint-marker and vimiumHintMarker classes.\n * Presumably the \"internal-\" classes were meant to contain non-user-facing styles, and\n * vimiumHintMarker was meant to be an empty vessel for users to attach their own styles to.\n */\n\ndiv#vimium-hint-marker-container {\n  pointer-events: none;\n}\n\ndiv.internal-vimium-hint-marker {\n  position: absolute;\n  display: block;\n  top: -1px;\n  left: -1px;\n  white-space: nowrap;\n  overflow: hidden;\n  font-size: 11px;\n  padding: 1px 3px 0px 3px;\n  background: linear-gradient(to bottom, #fff785 0%, #ffc542 100%);\n  border: solid 1px #c38a22;\n  border-radius: 3px;\n  box-shadow: 0px 3px 7px 0px rgba(0, 0, 0, 0.3);\n  z-index: 2147483647;\n}\n\ndiv.internal-vimium-hint-marker span {\n  color: #302505;\n  font-family: Helvetica, Arial, sans-serif;\n  font-weight: bold;\n  font-size: 11px;\n  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.6);\n}\n\ndiv.internal-vimium-hint-marker > .matchingCharacter {\n  color: #d4ac3a;\n}\n\ndiv > .vimiumActiveHintMarker span {\n  color: #a07555 !important;\n}\n\n/* Input hints CSS */\n\ndiv.internal-vimium-input-hint {\n  position: absolute;\n  display: block;\n  background-color: rgba(255, 247, 133, 0.3);\n  border: solid 1px #c38a22;\n  pointer-events: none;\n}\n\ndiv.internal-vimium-selected-input-hint {\n  background-color: rgba(255, 102, 102, 0.3);\n  border: solid 1px #993333 !important;\n}\n\ndiv.internal-vimium-selected-input-hint span {\n  color: white !important;\n}\n\ndiv.vimium-highlighted-frame {\n  position: fixed;\n  top: 0px;\n  left: 0px;\n  width: 100%;\n  height: 100%;\n  padding: 0px;\n  margin: 0px;\n  border: 5px solid yellow;\n  box-sizing: border-box;\n  pointer-events: none;\n}\n\niframe.vimium-help-dialog-frame {\n  background-color: rgba(10, 10, 10, 0.6);\n  padding: 0px;\n  top: 0px;\n  left: 0px;\n  width: 100%;\n  height: 100%;\n  display: block;\n  position: fixed;\n  border: none;\n  z-index: 2147483647;\n}\n\niframe.vimium-hud-frame {\n  background-color: transparent;\n  padding: 0px;\n  overflow: hidden;\n  display: block;\n  position: fixed;\n  width: 20%;\n  min-width: 350px;\n  height: 58px;\n  bottom: -14px;\n  right: 20px;\n  margin: 0 0 0 -40%;\n  border: none;\n  z-index: 2147483647;\n  opacity: 0;\n}\n\nbody.vimium-find-mode ::selection {\n  background: #ff9632;\n}\n\niframe.vomnibar-frame {\n  background-color: transparent;\n  padding: 0px;\n  overflow: hidden;\n\n  display: block;\n  position: fixed;\n  width: calc(80% + 20px); /* same adjustment as in pages/vomnibar_page.js */\n  min-width: 400px;\n  height: calc(100% - 70px);\n  top: 70px;\n  left: 50%;\n  /* TODO(philc): Why are we doing this negative margin, rather than just using right: 20% ? */\n  margin: 0 0 0 -40%;\n  border: none;\n  font-family: sans-serif;\n  z-index: 2147483647;\n}\n\ndiv.vimium-flash {\n  box-shadow: 0px 0px 4px 2px #4183c4;\n  padding: 1px;\n  background-color: transparent;\n  position: absolute;\n  z-index: 2147483647;\n}\n\n/* UIComponent CSS */\niframe.vimium-ui-component-hidden {\n  display: none;\n}\n\niframe.vimium-ui-component-visible {\n  display: block;\n  color-scheme: light dark;\n}\n\niframe.vimium-non-clickable {\n  pointer-events: none;\n}\n\n@media (prefers-color-scheme: dark) {\n  :root {\n    --vimium-background-color: #1d1d1f;\n    --vimium-background-text-color: #e3e3e3;\n    --vimium-foreground-color: #292a2d;\n    --vimium-foreground-text-color: #e8eaed;\n    --vimium-link-color: #8ab4f8;\n  }\n\n  /* DarkReader is a popular dark mode browser extension. It can apply an invert filter to the whole\n   * page to make the page dark, when used in Filter and Filter+ modes. We want to reverse/invert\n   * that filter again for Vimium's UI elements, because Vimium is already dark mode aware. */\n  iframe.vimium-reverse-dark-reader-filter {\n    -webkit-filter: invert(100%) hue-rotate(180deg) !important;\n    filter: invert(100%) hue-rotate(180deg) !important;\n  }\n\n  /* Dark mode CSS for options page and exclusions */\n\n  body.vimium-body {\n    background-color: var(--vimium-background-color);\n    color: var(--vimium-background-text-color);\n  }\n\n  body.vimium-body a,\n  body.vimium-body a:visited {\n    color: var(--vimium-link-color);\n  }\n\n  body.vimium-body textarea,\n  body.vimium-body input {\n    background-color: var(--vimium-foreground-color);\n    border-color: var(--vimium-foreground-color);\n    color: var(--vimium-foreground-text-color);\n  }\n\n  body.vimium-body div.example {\n    color: #8a9096;\n  }\n}\n"
  },
  {
    "path": "content_scripts/vimium_frontend.js",
    "content": "//\n// This content script must be run prior to domReady so that we perform some operations very early.\n//\n\nlet isEnabledForUrl = true;\nlet normalMode = null;\n\n// This is set by initializeFrame. We can only get this frame's ID from the background page.\nglobalThis.frameId = null;\n\n// We track whther the current window has the focus or not.\nlet windowHasFocus = null;\nfunction windowIsFocused() {\n  return windowHasFocus;\n}\n\nfunction initWindowIsFocused() {\n  DomUtils.documentReady().then(() => windowHasFocus = document.hasFocus());\n  globalThis.addEventListener(\n    \"focus\",\n    forTrusted(function (event) {\n      if (event.target === window) {\n        windowHasFocus = true;\n      }\n      return true;\n    }),\n    true,\n  );\n  globalThis.addEventListener(\n    \"blur\",\n    forTrusted(function (event) {\n      if (event.target === window) {\n        windowHasFocus = false;\n      }\n      return true;\n    }),\n    true,\n  );\n}\n\n// True if this window should be focusable by various Vim commands (e.g. \"nextFrame\").\nfunction isWindowFocusable() {\n  // Avoid focusing tiny frames. See #1317.\n  return !DomUtils.windowIsTooSmall() && (document.body?.tagName.toLowerCase() != \"frameset\");\n}\n\n// If an input grabs the focus before the user has interacted with the page, then grab it back (if\n// the grabBackFocus option is set).\nclass GrabBackFocus extends Mode {\n  constructor() {\n    super();\n    let listener;\n    const exitEventHandler = () => {\n      return this.alwaysContinueBubbling(() => {\n        this.exit();\n        chrome.runtime.sendMessage({\n          handler: \"sendMessageToFrames\",\n          message: { handler: \"userIsInteractingWithThePage\" },\n        });\n      });\n    };\n\n    super.init({\n      name: \"grab-back-focus\",\n      keydown: exitEventHandler,\n    });\n\n    // True after we've grabbed back focus to the page and logged it via console.log , so web devs\n    // using Vimium don't get confused.\n    this.logged = false;\n\n    this.push({\n      _name: \"grab-back-focus-mousedown\",\n      mousedown: exitEventHandler,\n    });\n\n    if (this.modeIsActive) {\n      if (Settings.get(\"grabBackFocus\")) {\n        this.push({\n          _name: \"grab-back-focus-focus\",\n          focus: (event) => this.grabBackFocus(event.target),\n        });\n        // An input may already be focused. If so, grab back the focus.\n        if (document.activeElement) {\n          this.grabBackFocus(document.activeElement);\n        }\n      } else {\n        this.exit();\n      }\n    }\n\n    // This mode is active in all frames. A user might have begun interacting with one frame without\n    // other frames detecting this. When one GrabBackFocus mode exits, we broadcast a message to\n    // inform all GrabBackFocus modes that they should exit; see #2296.\n    chrome.runtime.onMessage.addListener(\n      listener = ({ name }) => {\n        if (name === \"userIsInteractingWithThePage\") {\n          chrome.runtime.onMessage.removeListener(listener);\n          if (this.modeIsActive) {\n            this.exit();\n          }\n        }\n        // We will not be calling sendResponse.\n        return false;\n      },\n    );\n  }\n\n  grabBackFocus(element) {\n    if (!DomUtils.isFocusable(element)) {\n      return this.continueBubbling;\n    }\n\n    if (!this.logged && (element !== document.body)) {\n      this.logged = true;\n      if (!globalThis.vimiumDomTestsAreRunning) {\n        console.log(\"An auto-focusing action on this page was blocked by Vimium.\");\n      }\n    }\n    element.blur();\n    return this.suppressEvent;\n  }\n}\n\n// Pages can load new content dynamically and change the displayed URL using history.pushState.\n// Since this can often be indistinguishable from an actual new page load for the user, we should\n// also re-start GrabBackFocus for these as well. This fixes issue #1622.\nhandlerStack.push({\n  _name: \"GrabBackFocus-pushState-monitor\",\n  click(event) {\n    // If a focusable element is focused, the user must have clicked on it. Retain focus and bail.\n    if (DomUtils.isFocusable(document.activeElement)) {\n      return true;\n    }\n\n    let target = event.target;\n\n    while (target) {\n      // Often, a link which triggers a content load and url change with javascript will also have\n      // the new url as it's href attribute.\n      if (\n        (target.tagName === \"A\") &&\n        (target.origin === document.location.origin) &&\n        // Clicking the link will change the url of this frame.\n        ((target.pathName !== document.location.pathName) ||\n          (target.search !== document.location.search)) &&\n        ([\"\", \"_self\"].includes(target.target) ||\n          ((target.target === \"_parent\") && (globalThis.parent === window)) ||\n          ((target.target === \"_top\") && (globalThis.top === window)))\n      ) {\n        return new GrabBackFocus();\n      } else {\n        target = target.parentElement;\n      }\n    }\n    return true;\n  },\n});\n\nfunction installModes() {\n  // Install the permanent modes. The permanently-installed insert mode tracks focus/blur events,\n  // and activates/deactivates itself accordingly.\n  normalMode = new NormalMode();\n  normalMode.init();\n  // Initialize components upon which normal mode depends.\n  Scroller.init();\n  FindModeHistory.init();\n  new InsertMode({ permanent: true });\n  if (isEnabledForUrl) {\n    new GrabBackFocus();\n  }\n  // Return the normalMode object (for the tests).\n  return normalMode;\n}\n\n// document is null in our tests.\nlet previousUrl = globalThis.document?.location.href;\n\n// When we're informed by the background page that a URL in this tab has changed, we check if we\n// have the correct enabled state (but only if this frame has the focus).\nconst checkEnabledAfterURLChange = forTrusted(function (_request) {\n  // The background page can't tell if the URL has actually changed after a client-side\n  // history.pushState call. To limit log spam, ignore spurious URL change events where the URL\n  // didn't actually change.\n  if (previousUrl == document.location.href) {\n    return;\n  } else {\n    previousUrl = document.location.href;\n  }\n  // The URL changing feels like navigation to the user, so reset the scroller (see #3119).\n  Scroller.reset();\n  if (windowIsFocused()) {\n    checkIfEnabledForUrl();\n  }\n});\n\n// If our extension gets uninstalled, reloaded, or updated, the content scripts for the old version\n// become orphaned: they remain running but cannot communicate with the background page or invoke\n// most extension APIs. There is no Chrome API to be notified of this event, so we test for it every\n// time a keystroke is pressed before we act on that keystroke. https://stackoverflow.com/a/64407849\nconst extensionHasBeenUnloaded = () => chrome.runtime?.id == null;\n\n// Wrapper to install event listeners.  Syntactic sugar.\nfunction installListener(element, event, callback) {\n  element.addEventListener(\n    event,\n    forTrusted(function () {\n      if (extensionHasBeenUnloaded()) {\n        console.log(\"Vimium extension has been unloaded. Unloading content script.\");\n        onUnload();\n        return;\n      }\n      if (isEnabledForUrl) {\n        return callback.apply(this, arguments);\n      } else {\n        return true;\n      }\n    }),\n    true,\n  );\n}\n\n// Installing or uninstalling listeners is error prone. Instead we elect to check isEnabledForUrl\n// each time so we know whether the listener should run or not.\n// Note: We install the listeners even if Vimium is disabled. See comment in commit\n// 6446cf04c7b44c3d419dc450a73b60bcaf5cdf02.\nconst installListeners = Utils.makeIdempotent(function () {\n  // Key event handlers fire on window before they do on document. Prefer window for key events so\n  // the page can't set handlers to grab the keys before us.\n  const events = [\"keydown\", \"keypress\", \"keyup\", \"click\", \"focus\", \"blur\", \"mousedown\", \"scroll\"];\n  for (const type of events) {\n    installListener(globalThis, type, (event) => handlerStack.bubbleEvent(type, event));\n  }\n  installListener(\n    document,\n    \"DOMActivate\",\n    (event) => handlerStack.bubbleEvent(\"DOMActivate\", event),\n  );\n});\n\n// Whenever we get the focus, check if we should be enabled.\nconst onFocus = forTrusted(function (event) {\n  if (event.target === window) {\n    checkIfEnabledForUrl();\n  }\n});\n\n// We install these listeners directly (that is, we don't use installListener) because we still need\n// to receive events when Vimium is not enabled.\nglobalThis.addEventListener(\"focus\", onFocus, true);\nglobalThis.addEventListener(\"hashchange\", checkEnabledAfterURLChange, true);\n\nasync function initializeOnDomReady() {\n  // Tell the background page we're in the domReady state.\n  await chrome.runtime.sendMessage({ handler: \"domReady\" });\n\n  const isVimiumNewTabPage = document.location.href == Settings.vimiumNewTabPageUrl;\n  if (!isVimiumNewTabPage) return;\n\n  // Show the Vomnibar.\n  await Settings.onLoaded();\n  if (Settings.get(\"openVomnibarOnNewTabPage\")) {\n    await Utils.populateBrowserInfo();\n    DomUtils.injectUserCss();\n    Vomnibar.activate(0, {});\n  }\n}\n\nconst onUnload = Utils.makeIdempotent(() => {\n  HintCoordinator.exit({ isSuccess: false });\n  handlerStack.reset();\n  isEnabledForUrl = false;\n  globalThis.removeEventListener(\"focus\", onFocus, true);\n  globalThis.removeEventListener(\"hashchange\", checkEnabledAfterURLChange, true);\n});\n\nfunction setScrollPosition({ scrollX, scrollY }) {\n  DomUtils.documentReady().then(() => {\n    if (!DomUtils.isTopFrame()) return;\n    Utils.nextTick(function () {\n      globalThis.focus();\n      document.body.focus();\n      if ((scrollX > 0) || (scrollY > 0)) {\n        Marks.setPreviousPosition();\n        globalThis.scrollTo(scrollX, scrollY);\n      }\n    });\n  });\n}\n\nconst flashFrame = (() => {\n  let highlightedFrameElement = null;\n  return () => {\n    if (highlightedFrameElement == null) {\n      highlightedFrameElement = DomUtils.createElement(\"div\");\n\n      // Create a shadow DOM wrapping the frame so the page's styles don't interfere with ours.\n      const shadowDOM = highlightedFrameElement.attachShadow({ mode: \"open\" });\n\n      // Inject stylesheet.\n      const styleEl = DomUtils.createElement(\"style\");\n      const vimiumCssUrl = chrome.runtime.getURL(\"content_scripts/vimium.css\");\n      styleEl.textContent = `@import url(\"${vimiumCssUrl}\");`;\n      shadowDOM.appendChild(styleEl);\n\n      const frameEl = DomUtils.createElement(\"div\");\n      frameEl.className = \"vimium-reset vimium-highlighted-frame\";\n      shadowDOM.appendChild(frameEl);\n    }\n\n    document.documentElement.appendChild(highlightedFrameElement);\n    Utils.setTimeout(200, () => highlightedFrameElement.remove());\n  };\n})();\n\n//\n// Called from the backend in order to change frame focus.\n//\nfunction focusThisFrame(request) {\n  // It should never be the case that we get a forceFocusThisFrame request on a window that isn't\n  // focusable, because the background script checks that the window is focusable before sending the\n  // focusFrame message.\n  if (!request.forceFocusThisFrame && !isWindowFocusable()) return;\n\n  Utils.nextTick(function () {\n    globalThis.focus();\n    // On Firefox, window.focus doesn't always draw focus back from a child frame (bug 554039). We\n    // blur the active element if it is an iframe, which gives the window back focus as intended.\n    if (document.activeElement.tagName.toLowerCase() === \"iframe\") {\n      document.activeElement.blur();\n    }\n    if (request.highlight) {\n      flashFrame();\n    }\n  });\n}\n\n// Used by the focusInput command.\nglobalThis.lastFocusedInput = (function () {\n  // Track the most recently focused input element.\n  let recentlyFocusedElement = null;\n  globalThis.addEventListener(\n    \"focus\",\n    forTrusted(function (event) {\n      if (DomUtils.isEditable(event.target)) {\n        recentlyFocusedElement = event.target;\n      }\n    }),\n    true,\n  );\n  return () => recentlyFocusedElement;\n})();\n\nconst messageHandlers = {\n  getFocusStatus(_request, _sender) {\n    return {\n      focused: windowIsFocused(),\n      focusable: isWindowFocusable(),\n    };\n  },\n  focusFrame(request) {\n    focusThisFrame(request);\n  },\n  getScrollPosition(_ignoredA, _ignoredB) {\n    if (DomUtils.isTopFrame()) {\n      return { scrollX: globalThis.scrollX, scrollY: globalThis.scrollY };\n    }\n  },\n  setScrollPosition,\n  checkEnabledAfterURLChange,\n  runInTopFrame({ sourceFrameId, registryEntry }) {\n    // TODO(philc): it seems to me that we should be able to get rid of this runInTopFrame\n    // command, and instead use chrome.tabs.sendMessage with a frameId 0 from the background page.\n    if (DomUtils.isTopFrame()) {\n      return NormalModeCommands[registryEntry.command](sourceFrameId, registryEntry);\n    }\n  },\n  linkHintsMessage(request, sender) {\n    if (HintCoordinator.willHandleMessage(request.messageType)) {\n      return HintCoordinator[request.messageType](request, sender);\n    }\n  },\n  showMessage(request) {\n    HUD.show(request.message, 2000);\n  },\n};\n\nasync function handleMessage(request, sender) {\n  // Some requests are so frequent and noisy (like checkEnabledAfterURLChange on\n  // docs.google.com) that we silence debug logging for just those requests so the rest remain\n  // useful.\n  if (!request.silenceLogging) {\n    Utils.debugLog(\n      \"frontend.js: onMessage:%otype:%o\",\n      request.handler,\n      request.messageType,\n      // request // Often useful for debugging.\n    );\n  }\n  request.isTrusted = true;\n  // Some request are handled elsewhere in the code base; ignore them here.\n  const shouldHandleMessage = request.handler !== \"userIsInteractingWithThePage\" &&\n    (isEnabledForUrl ||\n      [\"checkEnabledAfterURLChange\", \"runInTopFrame\"].includes(request.handler));\n  if (shouldHandleMessage) {\n    const result = await messageHandlers[request.handler](request, sender);\n    return result;\n  }\n}\n\n//\n// Complete initialization work that should be done prior to DOMReady.\n//\nasync function initializePreDomReady() {\n  // Run this as early as possible, so the page can't register any event handlers before us.\n  installListeners();\n  // NOTE(philc): I'm blocking further Vimium initialization on this, for simplicity. If necessary\n  // we could allow other tasks to run concurrently.\n  await checkIfEnabledForUrl();\n\n  Utils.addChromeRuntimeOnMessageListener(\n    Object.keys(messageHandlers),\n    handleMessage,\n  );\n}\n\n// Check if Vimium should be enabled or not based on the top frame's URL.\nasync function checkIfEnabledForUrl() {\n  const promises = [];\n  promises.push(chrome.runtime.sendMessage({ handler: \"initializeFrame\" }));\n  if (!Settings.isLoaded()) {\n    promises.push(Settings.onLoaded());\n  }\n  const [response, ...unused] = await Promise.all(promises);\n\n  isEnabledForUrl = response.isEnabledForUrl;\n\n  // This browser info is used by other content scripts, but can only be determinted by the\n  // background page.\n  Utils._isFirefox = response.isFirefox;\n  Utils._firefoxVersion = response.firefoxVersion;\n  Utils._browserInfoLoaded = true;\n  // This is the first time we learn what this frame's ID is.\n  globalThis.frameId = response.frameId;\n\n  if (normalMode == null) installModes();\n  normalMode.setPassKeys(response.passKeys);\n  // Hide the HUD if we're not enabled.\n  if (!isEnabledForUrl) HUD.hide(true, false);\n}\n\n// If this content script is running in the help dialog's iframe, then use the HelpDialogPage's\n// methods to control the dialog. Otherwise, load the help dialog in a UIComponent iframe.\nconst HelpDialog = {\n  helpUI: null,\n\n  isShowing() {\n    if (globalThis.isVimiumHelpDialogPage) return true;\n    return this.helpUI && this.helpUI.showing;\n  },\n\n  abort() {\n    if (globalThis.isVimiumHelpDialogPage) throw new Error(\"This should be impossible.\");\n    if (this.isShowing()) {\n      return this.helpUI.hide(false);\n    }\n  },\n\n  async toggle(request) {\n    // If we're in the help dialog page already and the user has typed a key to show the help\n    // dialog, then we should hide it.\n    if (globalThis.isVimiumHelpDialogPage) return HelpDialogPage.hide();\n\n    if (this.helpUI == null) {\n      await DomUtils.documentComplete();\n      this.helpUI = new UIComponent();\n      this.helpUI.load(\"pages/help_dialog_page.html\", \"vimium-help-dialog-frame\");\n    }\n    if (this.isShowing()) {\n      this.helpUI.hide();\n    } else {\n      return this.helpUI.show(\n        { name: \"show\" },\n        { focus: true, sourceFrameId: request.sourceFrameId },\n      );\n    }\n  },\n};\n\nconst testEnv = globalThis.window == null;\nif (!testEnv) {\n  initWindowIsFocused();\n  initializePreDomReady();\n  DomUtils.documentReady().then(initializeOnDomReady);\n}\n\nObject.assign(globalThis, {\n  HelpDialog,\n  handlerStack,\n  windowIsFocused,\n  // These are exported for normal mode and link-hints mode.\n  focusThisFrame,\n  // Exported only for tests.\n  installModes,\n});\n"
  },
  {
    "path": "content_scripts/vomnibar.js",
    "content": "//\n// This wraps the vomnibar iframe, which we inject into the page to provide the vomnibar.\n//\nconst Vomnibar = {\n  vomnibarUI: null,\n\n  // sourceFrameId here (and below) is the ID of the frame from which this request originates, which\n  // may be different from the current frame.\n\n  activate(sourceFrameId, registryEntry) {\n    const options = Object.assign({}, registryEntry.options, { completer: \"omni\" });\n    this.open(sourceFrameId, options);\n  },\n\n  activateInNewTab(sourceFrameId, registryEntry) {\n    const options = Object.assign({}, registryEntry.options, { completer: \"omni\", newTab: true });\n    this.open(sourceFrameId, options);\n  },\n\n  activateTabSelection(sourceFrameId) {\n    this.open(sourceFrameId, {\n      completer: \"tabs\",\n      selectFirst: true,\n    });\n  },\n\n  activateBookmarks(sourceFrameId, registryEntry) {\n    const options = Object.assign({}, registryEntry.options, {\n      completer: \"bookmarks\",\n      selectFirst: true,\n    });\n    this.open(sourceFrameId, options);\n  },\n\n  activateBookmarksInNewTab(sourceFrameId, registryEntry) {\n    const options = Object.assign({}, registryEntry.options, {\n      completer: \"bookmarks\",\n      selectFirst: true,\n      newTab: true,\n    });\n    this.open(sourceFrameId, options);\n  },\n\n  activateEditUrl(sourceFrameId) {\n    this.open(sourceFrameId, {\n      completer: \"omni\",\n      selectFirst: false,\n      query: globalThis.location.href,\n    });\n  },\n\n  activateEditUrlInNewTab(sourceFrameId) {\n    this.open(sourceFrameId, {\n      completer: \"omni\",\n      selectFirst: false,\n      query: globalThis.location.href,\n      newTab: true,\n    });\n  },\n\n  init() {\n    if (!this.vomnibarUI) {\n      this.vomnibarUI = new UIComponent();\n      this.vomnibarUI.load(\"pages/vomnibar_page.html\", \"vomnibar-frame\");\n    }\n  },\n\n  // Opens the vomnibar.\n  // - vomnibarShowOptions:\n  //     completer: The name of the completer to fetch results from.\n  //     query: Optional. Text to prefill the Vomnibar with.\n  //     selectFirst: Optional. Whether to select the first entry.\n  //     newTab: Optional. Whether to open the result in a new tab.\n  //     keyword: A keyword which will scope the search to a UserSearchEngine.\n  open(sourceFrameId, vomnibarShowOptions) {\n    this.init();\n    // The Vomnibar cannot coexist with the help dialog (it causes focus issues).\n    HelpDialog.abort();\n    Utils.assertType(VomnibarShowOptions, vomnibarShowOptions);\n    this.vomnibarUI.show(\n      Object.assign(vomnibarShowOptions, { name: \"activate\" }),\n      { sourceFrameId, focus: true },\n    );\n  },\n};\n\nglobalThis.Vomnibar = Vomnibar;\n"
  },
  {
    "path": "deno.json",
    "content": "{\n  \"fmt\": {\n    \"exclude\": [\"icons/*.svg\"],\n    \"lineWidth\": 100\n  },\n  \"imports\": {\n    \"@b-fuze/deno-dom\": \"jsr:@b-fuze/deno-dom@^0.1.49\",\n    \"@std/fs\": \"jsr:@std/fs@^1.0.8\",\n    \"@std/http\": \"jsr:@std/http@^1.0.12\",\n    \"@std/path\": \"jsr:@std/path@^1.0.8\",\n    \"jsdom\": \"npm:jsdom@^26.0.0\",\n    \"json5\": \"npm:json5@^2.2.3\"\n  }\n}\n"
  },
  {
    "path": "lib/chrome_api_stubs.js",
    "content": "//\n// Mock the Chrome extension API for our tests. In Deno and Pupeteer, the Chrome extension APIs are\n// not available.\n//\n\nconst shouldInstallStubs = globalThis.document?.location.pathname.includes(\"dom_tests.html\") ||\n  // This query string is added to pages that we load in iframes from dom_tests.html, like\n  // hud_page.html\n  globalThis.document?.location.search.includes(\"dom_tests=true\");\n\nif (shouldInstallStubs) {\n  globalThis.chromeMessages = [];\n\n  document.hasFocus = () => true;\n\n  globalThis.forTrusted = (handler) => handler;\n\n  const fakeManifest = {\n    version: \"1.51\",\n  };\n\n  globalThis.chrome = {\n    runtime: {\n      id: 123456,\n\n      connect() {\n        return {\n          onMessage: {\n            addListener() {},\n          },\n          onDisconnect: {\n            addListener() {},\n          },\n          postMessage() {},\n        };\n      },\n      onMessage: {\n        addListener() {},\n      },\n      sendMessage(message) {\n        // TODO(philc): This stub should return a an empty Promise, not the length of the\n        // chromeMessages array. Some portion fo the dom_tests.html setup depends on this value, so\n        // the tests break. Fix.\n        return chromeMessages.unshift(message);\n      },\n      getManifest() {\n        return fakeManifest;\n      },\n      getURL(url) {\n        return `../../${url}`;\n      },\n    },\n    storage: {\n      local: {\n        async get() {\n          return await {};\n        },\n        async set() {},\n      },\n      sync: {\n        async get() {\n          return await {};\n        },\n        async set() {},\n      },\n      session: {\n        async get() {\n          return await {};\n        },\n        async set() {},\n      },\n      onChanged: {\n        addListener() {},\n      },\n    },\n    extension: {\n      inIncognitoContext: false,\n      getURL(url) {\n        return chrome.runtime.getURL(url);\n      },\n    },\n  };\n}\n"
  },
  {
    "path": "lib/dom_utils.js",
    "content": "const DomUtils = {\n  //\n  // Runs :callback if the DOM has loaded, otherwise runs it on load\n  //\n  isReady() {\n    return document.readyState !== \"loading\";\n  },\n\n  // Returns a promise which resolves when the DOMContentLoaded event has been fired.\n  documentReady() {\n    return new Promise((resolve) => {\n      if (this.isReady()) {\n        resolve();\n        return;\n      }\n      globalThis.addEventListener(\"DOMContentLoaded\", forTrusted(resolve));\n    });\n  },\n\n  // Returns a promise which resolves when the load event has been fired.\n  documentComplete() {\n    return new Promise((resolve) => {\n      const isComplete = document.readyState === \"complete\";\n      if (isComplete) {\n        resolve();\n        return;\n      }\n      const handler = forTrusted((event) => {\n        // TODO(philc): Is this event.target check necessary? Why do we have it?\n        // The target is ensured to be on document. See\n        // https://w3c.github.io/uievents/#event-type-load\n        if (event.target !== document) return;\n        resolve();\n      });\n      globalThis.addEventListener(\"load\", handler);\n    });\n  },\n\n  // TODO(philc): Why is this necessary? Find the introducing commit and document it.\n  createElement(tagName) {\n    const element = document.createElement(tagName);\n    if (element instanceof HTMLElement) {\n      // The document namespace provides (X)HTML elements, so we can use them directly.\n      this.createElement = (tagName) => document.createElement(tagName);\n      return element;\n    } else {\n      // The document namespace doesn't give (X)HTML elements, so we create them with the correct\n      // namespace manually.\n      this.createElement = (tagName) =>\n        document.createElementNS(\"http://www.w3.org/1999/xhtml\", tagName);\n      return this.createElement(tagName);\n    }\n  },\n\n  //\n  // Remove an element from its DOM tree.\n  //\n  removeElement(el) {\n    return el.parentNode.removeChild(el);\n  },\n\n  //\n  // Test whether the current frame is the top/main frame.\n  //\n  isTopFrame() {\n    return globalThis.top === globalThis.self;\n  },\n\n  //\n  // Takes an array of XPath selectors, adds the necessary namespaces (currently only XHTML), and\n  // applies them to the document root. The namespaceResolver in evaluateXPath should be kept in\n  // sync with the namespaces here.\n  //\n  makeXPath(elementArray) {\n    const xpath = [];\n    for (const element of elementArray) {\n      xpath.push(\".//\" + element, \".//xhtml:\" + element);\n    }\n    return xpath.join(\" | \");\n  },\n\n  // Evaluates an XPath on the whole document, or on the contents of the fullscreen element if an\n  // element is fullscreen.\n  evaluateXPath(xpath, resultType) {\n    const contextNode = document.webkitIsFullScreen\n      ? document.webkitFullscreenElement\n      : document.documentElement;\n    const namespaceResolver = function (namespace) {\n      if (namespace === \"xhtml\") return \"http://www.w3.org/1999/xhtml\";\n      else return null;\n    };\n    return document.evaluate(xpath, contextNode, namespaceResolver, resultType, null);\n  },\n\n  //\n  // Returns the first visible clientRect of an element if it exists. Otherwise it returns null.\n  //\n  // WARNING: If testChildren = true then the rects of visible (eg. floated) children may be\n  // returned instead. This is used for LinkHints and focusInput, **BUT IS UNSUITABLE FOR MOST OTHER\n  // PURPOSES**.\n  //\n  getVisibleClientRect(element, testChildren) {\n    // Note: this call will be expensive if we modify the DOM in between calls.\n    let clientRect;\n    if (testChildren == null) testChildren = false;\n    const clientRects = (() => {\n      const result = [];\n      for (clientRect of element.getClientRects()) {\n        result.push(Rect.copy(clientRect));\n      }\n      return result;\n    })();\n\n    // Inline elements with font-size: 0px; will declare a height of zero, even if a child with\n    // non-zero font-size contains text.\n    let isInlineZeroHeight = function () {\n      const elementComputedStyle = globalThis.getComputedStyle(element, null);\n      const isInlineZeroFontSize =\n        (0 === elementComputedStyle.getPropertyValue(\"display\").indexOf(\"inline\")) &&\n        (elementComputedStyle.getPropertyValue(\"font-size\") === \"0px\");\n      // Override the function to return this value for the rest of this context.\n      isInlineZeroHeight = () => isInlineZeroFontSize;\n      return isInlineZeroFontSize;\n    };\n\n    for (clientRect of clientRects) {\n      // If the link has zero dimensions, it may be wrapping visible but floated elements. Check for\n      // this.\n      let computedStyle;\n      if (((clientRect.width === 0) || (clientRect.height === 0)) && testChildren) {\n        for (const child of Array.from(element.children)) {\n          computedStyle = globalThis.getComputedStyle(child, null);\n          // Ignore child elements which are not floated and not absolutely positioned for parent\n          // elements with zero width/height, as long as the case described at isInlineZeroHeight\n          // does not apply.\n          // NOTE(mrmr1993): This ignores floated/absolutely positioned descendants nested within\n          // inline children.\n          const position = computedStyle.getPropertyValue(\"position\");\n          if (\n            (computedStyle.getPropertyValue(\"float\") === \"none\") &&\n            !([\"absolute\", \"fixed\"].includes(position)) &&\n            !((clientRect.height === 0) && isInlineZeroHeight() &&\n              (0 === computedStyle.getPropertyValue(\"display\").indexOf(\"inline\")))\n          ) {\n            continue;\n          }\n          const childClientRect = this.getVisibleClientRect(child, true);\n          if (\n            (childClientRect === null) || (childClientRect.width < 3) ||\n            (childClientRect.height < 3)\n          ) continue;\n          return childClientRect;\n        }\n      } else {\n        clientRect = this.cropRectToVisible(clientRect);\n\n        if ((clientRect === null) || (clientRect.width < 3) || (clientRect.height < 3)) continue;\n\n        // eliminate invisible elements (see test_harnesses/visibility_test.html)\n        computedStyle = globalThis.getComputedStyle(element, null);\n        if (computedStyle.getPropertyValue(\"visibility\") !== \"visible\") continue;\n\n        return clientRect;\n      }\n    }\n\n    return null;\n  },\n\n  //\n  // Bounds the rect by the current viewport dimensions. If the rect is offscreen or has a height or\n  // width < 3 then null is returned instead of a rect.\n  //\n  cropRectToVisible(rect) {\n    const boundedRect = Rect.create(\n      Math.max(rect.left, 0),\n      Math.max(rect.top, 0),\n      rect.right,\n      rect.bottom,\n    );\n    if (\n      (boundedRect.top >= (globalThis.innerHeight - 4)) ||\n      (boundedRect.left >= (globalThis.innerWidth - 4))\n    ) {\n      return null;\n    } else {\n      return boundedRect;\n    }\n  },\n\n  //\n  // Get the client rects for the <area> elements in a <map> based on the position of the <img>\n  // element using the map. Returns an array of rects.\n  //\n  getClientRectsForAreas(imgClientRect, areaEls) {\n    const rects = [];\n    for (const areaEl of areaEls) {\n      let x1, x2, y1, y2;\n      const coords = areaEl.coords.split(\",\").map((coord) => parseInt(coord, 10));\n      const shape = areaEl.shape.toLowerCase();\n      if ([\"rect\", \"rectangle\"].includes(shape)) { // \"rectangle\" is an IE non-standard.\n        if (coords.length == 4) {\n          [x1, y1, x2, y2] = coords;\n        }\n      } else if ([\"circle\", \"circ\"].includes(shape)) { // \"circ\" is an IE non-standard.\n        if (coords.length == 3) {\n          const [x, y, r] = coords;\n          const diff = r / Math.sqrt(2); // Gives us an inner square\n          x1 = x - diff;\n          x2 = x + diff;\n          y1 = y - diff;\n          y2 = y + diff;\n        }\n      } else if (shape === \"default\") {\n        if (coords.length == 2) {\n          [x1, y1, x2, y2] = [0, 0, imgClientRect.width, imgClientRect.height];\n        }\n      } else {\n        if (coords.length >= 4) {\n          // Just consider the rectangle surrounding the first two points in a polygon. It's possible\n          // to do something more sophisticated, but likely not worth the effort.\n          [x1, y1, x2, y2] = coords;\n        }\n      }\n\n      let rect = Rect.translate(Rect.create(x1, y1, x2, y2), imgClientRect.left, imgClientRect.top);\n      rect = this.cropRectToVisible(rect);\n\n      // The wrong numbere of coords in a <map> element, or malformed numbers, can result in NaN\n      // values.\n      const isValid = rect && !isNaN(rect.top) && !isNaN(rect.left) && !isNaN(rect.width) &&\n        !isNaN(rect.height);\n      if (isValid) rects.push({ element: areaEl, rect });\n    }\n    return rects;\n  },\n\n  //\n  // Selectable means that we should use the simulateSelect method to activate the element instead\n  // of a click.\n  //\n  // The html5 input types that should use simulateSelect are:\n  //   [\"date\", \"datetime\", \"datetime-local\", \"email\", \"month\", \"number\", \"password\", \"range\",\n  //    \"search\", \"tel\", \"text\", \"time\", \"url\", \"week\"]\n  // An unknown type will be treated the same as \"text\", in the same way that the browser does.\n  //\n  isSelectable(element) {\n    if (!(element instanceof Element)) return false;\n    const unselectableTypes = [\n      \"button\",\n      \"checkbox\",\n      \"color\",\n      \"file\",\n      \"hidden\",\n      \"image\",\n      \"radio\",\n      \"reset\",\n      \"submit\",\n    ];\n    return ((element.nodeName.toLowerCase() === \"input\") &&\n      (unselectableTypes.indexOf(element.type) === -1)) ||\n      (element.nodeName.toLowerCase() === \"textarea\") || element.isContentEditable;\n  },\n\n  // Input or text elements are considered focusable and able to receieve their own keyboard events,\n  // and will enter insert mode if focused. Also note that the \"contentEditable\" attribute can be\n  // set on any element which makes it a rich text editor, like the notes on jjot.com.\n  isEditable(element) {\n    return (this.isSelectable(element)) ||\n      ((element.nodeName != null ? element.nodeName.toLowerCase() : undefined) === \"select\");\n  },\n\n  // Embedded elements like Flash and quicktime players can obtain focus.\n  isEmbed(element) {\n    const nodeName = element.nodeName != null ? element.nodeName.toLowerCase() : null;\n    return [\"embed\", \"object\"].includes(nodeName);\n  },\n\n  isFocusable(element) {\n    return element && (this.isEditable(element) || this.isEmbed(element));\n  },\n\n  isDOMDescendant(parent, child) {\n    let node = child;\n    while (node !== null) {\n      if (node === parent) return true;\n      node = node.parentNode;\n    }\n    return false;\n  },\n\n  // True if element is editable and contains the active selection range.\n  isSelected(element) {\n    const selection = document.getSelection();\n    if (element.isContentEditable) {\n      const node = selection.anchorNode;\n      return node && this.isDOMDescendant(element, node);\n    }\n\n    if ((selection.type === \"Range\") && selection.isCollapsed) {\n      // The selection is inside the Shadow DOM of a node. We can check the node it registers as\n      // being before, since this represents the node whose Shadow DOM it's inside.\n      const containerNode = selection.anchorNode.childNodes[selection.anchorOffset];\n      return element === containerNode; // True if the selection is inside the Shadow DOM of our element.\n    }\n\n    return false;\n  },\n\n  simulateSelect(element) {\n    // If element is already active, then we don't move the selection. However, we also won't get a\n    // new focus event. So, instead we pretend (to any active modes which care, e.g. PostFindMode)\n    // that element has been clicked.\n    if ((element === document.activeElement) && DomUtils.isEditable(document.activeElement)) {\n      return handlerStack.bubbleEvent(\"click\", { target: element });\n    } else {\n      element.focus();\n      if ((element.tagName.toLowerCase() !== \"textarea\") || (element.value.indexOf(\"\\n\") < 0)) {\n        // If the cursor is at the start of the (non-multiline-textarea) element's contents, send it\n        // to the end.\n        // Motivation:\n        // * the end is a more useful place to focus than the start,\n        // * this way preserves the last used position (except when it's at the beginning), so the\n        //   user can 'resume where they left off'.\n        // This works well for single-line inputs, however, the UX is *bad* for multiline inputs\n        // (such as text areas), and doubly so if the end of the input happens to be out of the\n        // viewport, that's why multiline-textareas are excluded.\n        // NOTE(mrmr1993): Some elements throw an error when we try to access their selection\n        // properties, so wrap this with a try.\n        try {\n          if ((element.selectionStart === 0) && (element.selectionEnd === 0)) {\n            return element.setSelectionRange(element.value.length, element.value.length);\n          }\n        } catch {\n          // Swallow\n        }\n      }\n    }\n  },\n\n  simulateClick(element, modifiers) {\n    if (modifiers == null) modifiers = {};\n    const eventSequence = [\n      \"pointerover\",\n      \"mouseover\",\n      \"pointerdown\",\n      \"mousedown\",\n      \"pointerup\",\n      \"mouseup\",\n      \"click\",\n    ];\n    for (const event of eventSequence) {\n      this.simulateMouseEvent(event, element, modifiers);\n    }\n  },\n\n  // Returns false if the event is cancellable and one of the handlers called\n  // event.preventDefault().\n  simulateMouseEvent(event, element, modifiers) {\n    if (modifiers == null) modifiers = {};\n    if (event === \"mouseout\") {\n      // Allow unhovering the last hovered element by passing undefined.\n      if (element == null) element = this.lastHoveredElement;\n      this.lastHoveredElement = undefined;\n      if (element == null) return;\n    } else if (event === \"mouseover\") {\n      // Simulate moving the mouse off the previous element first, as if we were a real mouse.\n      this.simulateMouseEvent(\"mouseout\", undefined, modifiers);\n      this.lastHoveredElement = element;\n    }\n\n    const mouseEvent = new MouseEvent(event, {\n      bubbles: true,\n      cancelable: true,\n      composed: true,\n      view: window,\n      detail: 1,\n      ctrlKey: modifiers.ctrlKey,\n      altKey: modifiers.altKey,\n      shiftKey: modifiers.shiftKey,\n      metaKey: modifiers.metaKey,\n    });\n    return element.dispatchEvent(mouseEvent);\n  },\n\n  simulateClickDefaultAction(element, modifiers) {\n    let newTabModifier;\n    if (modifiers == null) modifiers = {};\n    if (\n      ((element.tagName != null ? element.tagName.toLowerCase() : undefined) !== \"a\") ||\n      !element.href\n    ) return;\n\n    const { ctrlKey, shiftKey, metaKey, altKey } = modifiers;\n\n    // Mac uses a different new tab modifier (meta vs. ctrl).\n    if (KeyboardUtils.platform === \"Mac\") {\n      newTabModifier = (metaKey === true) && (ctrlKey === false);\n    } else {\n      newTabModifier = (metaKey === false) && (ctrlKey === true);\n    }\n\n    if (newTabModifier) {\n      // Open in new tab. Shift determines whether the tab is focused when created. Alt is ignored.\n      chrome.runtime.sendMessage({\n        handler: \"openUrlInNewTab\",\n        url: element.href,\n        active: shiftKey === true,\n      });\n    } else if (\n      (shiftKey === true) && (metaKey === false) && (ctrlKey === false) && (altKey === false)\n    ) {\n      // Open in new window.\n      chrome.runtime.sendMessage({ handler: \"openUrlInNewWindow\", url: element.href });\n    } else if (element.target === \"_blank\") {\n      chrome.runtime.sendMessage({ handler: \"openUrlInNewTab\", url: element.href, active: true });\n    }\n  },\n\n  simulateHover(element, modifiers) {\n    if (modifiers == null) modifiers = {};\n    return this.simulateMouseEvent(\"mouseover\", element, modifiers);\n  },\n\n  simulateUnhover(element, modifiers) {\n    if (modifiers == null) modifiers = {};\n    return this.simulateMouseEvent(\"mouseout\", element, modifiers);\n  },\n\n  addFlashRect(rect) {\n    const flashEl = this.createElement(\"div\");\n    flashEl.classList.add(\"vimium-reset\");\n    flashEl.classList.add(\"vimium-flash\");\n    flashEl.style.left = rect.left + \"px\";\n    flashEl.style.top = rect.top + \"px\";\n    flashEl.style.width = rect.width + \"px\";\n    flashEl.style.height = rect.height + \"px\";\n    document.documentElement.appendChild(flashEl);\n    return flashEl;\n  },\n\n  getViewportTopLeft() {\n    const box = document.documentElement;\n    const style = getComputedStyle(box);\n    const rect = box.getBoundingClientRect();\n    if ((style.position === \"static\") && !/content|paint|strict/.test(style.contain || \"\")) {\n      // The margin is included in the client rect, so we need to subtract it back out.\n      const marginTop = parseInt(style.marginTop);\n      const marginLeft = parseInt(style.marginLeft);\n      return { top: -rect.top + marginTop, left: -rect.left + marginLeft };\n    } else {\n      let clientLeft, clientTop;\n      if (Utils.isFirefox()) {\n        // These are always 0 for documentElement on Firefox, so we derive them from CSS border.\n        clientTop = parseInt(style.borderTopWidth);\n        clientLeft = parseInt(style.borderLeftWidth);\n      } else {\n        ({ clientTop, clientLeft } = box);\n      }\n      return { top: -rect.top - clientTop, left: -rect.left - clientLeft };\n    }\n  },\n\n  suppressPropagation(event) {\n    event.stopImmediatePropagation();\n  },\n\n  suppressEvent(event) {\n    event.preventDefault();\n    this.suppressPropagation(event);\n  },\n\n  consumeKeyup: (function () {\n    let handlerId = null;\n\n    return function (event, callback = null, suppressPropagation) {\n      if (!event.repeat) {\n        if (handlerId != null) handlerStack.remove(handlerId);\n        const {\n          code,\n        } = event;\n        handlerId = handlerStack.push({\n          _name: \"dom_utils/consumeKeyup\",\n          keyup(event) {\n            if (event.code !== code) return handlerStack.continueBubbling;\n            this.remove();\n            if (suppressPropagation) {\n              DomUtils.suppressPropagation(event);\n            } else {\n              DomUtils.suppressEvent(event);\n            }\n            return handlerStack.continueBubbling;\n          },\n          // We cannot track keyup events if we lose the focus.\n          blur(event) {\n            if (event.target === window) this.remove();\n            return handlerStack.continueBubbling;\n          },\n        });\n      }\n      if (typeof callback === \"function\") {\n        callback();\n      }\n      if (suppressPropagation) {\n        DomUtils.suppressPropagation(event);\n        return handlerStack.suppressPropagation;\n      } else {\n        DomUtils.suppressEvent(event);\n        return handlerStack.suppressEvent;\n      }\n    };\n  })(),\n\n  // Adapted from: http://roysharon.com/blog/37.\n  // This finds the element containing the selection focus.\n  getElementWithFocus(selection, backwards) {\n    let t;\n    let r = (t = selection.getRangeAt(0));\n    if (selection.type === \"Range\") {\n      r = t.cloneRange();\n      r.collapse(backwards);\n    }\n    t = r.startContainer;\n    if (t.nodeType === 1) t = t.childNodes[r.startOffset];\n    let o = t;\n    while (o && (o.nodeType !== 1)) o = o.previousSibling;\n    t = o || (t != null ? t.parentNode : undefined);\n    return t;\n  },\n\n  getSelectionFocusElement() {\n    const sel = globalThis.getSelection();\n    let node = sel.focusNode;\n    if ((node == null)) {\n      return null;\n    }\n    if ((node === sel.anchorNode) && (sel.focusOffset === sel.anchorOffset)) {\n      // If the selection is not a caret inside a `#text`, which has no child nodes, then it either\n      // *is* an element, or is inside an opaque element (eg. <input>).\n      node = node.childNodes[sel.focusOffset] || node;\n    }\n    if (node.nodeType !== Node.ELEMENT_NODE) return node.parentElement;\n    else return node;\n  },\n\n  // Get the element in the DOM hierachy that contains `element`.\n  // If the element is rendered in a shadow DOM via a <content> element, the <content> element will\n  // be returned, so the shadow DOM is traversed rather than passed over.\n  getContainingElement(element) {\n    return (typeof element.getDestinationInsertionPoints === \"function\"\n      ? element.getDestinationInsertionPoints()[0]\n      : undefined) || element.parentElement;\n  },\n\n  // This tests whether a window is too small to be useful.\n  windowIsTooSmall() {\n    return (globalThis.innerWidth < 3) || (globalThis.innerHeight < 3);\n  },\n\n  // Inject the user's Vimium CSS styles onto the page. This is only necessary for our\n  // chrome-extension:// pages and frames.\n  // - parent: The node to append the style tag to. Optional.\n  injectUserCss(parent) {\n    const style = document.createElement(\"style\");\n    style.type = \"text/css\";\n    style.textContent = Settings.get(\"userDefinedLinkHintCss\");\n    parent = parent ?? document.head;\n    parent.appendChild(style);\n  },\n};\n\nglobalThis.DomUtils = DomUtils;\n"
  },
  {
    "path": "lib/find_mode_history.js",
    "content": "// This // implements find-mode query history as a list of raw queries, most recent first.\n// This is under lib/ since it is used by both content scripts and iframes from pages/.\nconst FindModeHistory = {\n  storage: chrome.storage.session,\n  key: \"findModeRawQueryList\",\n  max: 50,\n  rawQueryList: null,\n\n  async init() {\n    this.isIncognitoMode = chrome.extension.inIncognitoContext;\n\n    if (!this.rawQueryList) {\n      if (this.isIncognitoMode) this.key = \"findModeRawQueryListIncognito\";\n\n      let result = await this.storage.get(this.key);\n      if (this.isIncognitoMode) {\n        // This is the first incognito tab, so we need to initialize the incognito-mode query\n        // history.\n        result = await this.storage.get(\"findModeRawQueryList\");\n        this.rawQueryList = result.findModeRawQueryList || [];\n        this.storage.set({ findModeRawQueryListIncognito: this.rawQueryList });\n      } else {\n        this.rawQueryList = result[this.key] || [];\n      }\n    }\n\n    chrome.storage.onChanged.addListener((changes, _area) => {\n      if (changes[this.key]) {\n        this.rawQueryList = changes[this.key].newValue;\n      }\n    });\n  },\n\n  getQuery(index) {\n    if (index == null) index = 0;\n    return this.rawQueryList[index] || \"\";\n  },\n\n  async saveQuery(query) {\n    if (query.length == 0) return;\n    this.rawQueryList = this.refreshRawQueryList(query, this.rawQueryList);\n    const newSetting = {};\n    newSetting[this.key] = this.rawQueryList;\n    await this.storage.set(newSetting);\n    // If there are any active incognito-mode tabs, then propagate this query to those tabs too.\n    if (!this.isIncognitoMode) {\n      const result = await this.storage.get(\"findModeRawQueryListIncognito\");\n      if (result.findModeRawQueryListIncognito) {\n        await this.storage.set({\n          findModeRawQueryListIncognito: this.refreshRawQueryList(\n            query,\n            result.findModeRawQueryListIncognito,\n          ),\n        });\n      }\n    }\n  },\n\n  refreshRawQueryList(query, rawQueryList) {\n    return ([query].concat(rawQueryList.filter((q) => q !== query))).slice(0, this.max + 1);\n  },\n};\n\nglobalThis.FindModeHistory = FindModeHistory;\n"
  },
  {
    "path": "lib/handler_stack.js",
    "content": "class HandlerStack {\n  constructor() {\n    this.debug = false;\n    this.eventNumber = 0;\n    this.stack = [];\n    this.counter = 0;\n\n    // A handler should return this value to immediately discontinue bubbling and pass the event on\n    // to the underlying page.\n    this.passEventToPage = new Object();\n\n    // A handler should return this value to indicate that the event has been consumed, and no\n    // further processing should take place. The event does not propagate to the underlying page.\n    this.suppressPropagation = new Object();\n\n    // A handler should return this value to indicate that bubbling should be restarted. Typically,\n    // this is used when, while bubbling an event, a new mode is pushed onto the stack.\n    this.restartBubbling = new Object();\n\n    // A handler should return this value to continue bubbling the event.\n    this.continueBubbling = true;\n\n    // A handler should return this value to suppress an event.\n    this.suppressEvent = false;\n  }\n\n  // Adds a handler to the top of the stack. Returns a unique ID for that handler that can be used\n  // to remove it later.\n  push(handler) {\n    handler.id = ++this.counter;\n    if (!handler._name) handler._name = `anon-${handler.id}`;\n    this.stack.push(handler);\n    return handler.id = ++this.counter;\n  }\n\n  // As above, except the new handler is added to the bottom of the stack.\n  unshift(handler) {\n    handler.id = ++this.counter;\n    if (!handler._name) handler._name = `anon-${handler.id}`;\n    handler._name += \"/unshift\";\n    this.stack.unshift(handler);\n    return handler.id = ++this.counter;\n  }\n\n  // Called whenever we receive a key or other event. Each individual handler has the option to stop\n  // the event's propagation by returning a falsy value, or stop bubbling by\n  // returning @suppressPropagation or\n  // @passEventToPage.\n  bubbleEvent(type, event) {\n    this.eventNumber += 1;\n    const eventNumber = this.eventNumber;\n    for (const handler of this.stack.slice().reverse()) {\n      // A handler might have been removed (handler.id == null), so check; or there might just be no\n      // handler for this type of event.\n      if (!(handler != null ? handler.id : undefined) || !handler[type]) {\n        if (this.debug) {\n          this.logResult(eventNumber, type, event, handler, `skip [${(handler[type] != null)}]`);\n        }\n      } else {\n        this.currentId = handler.id;\n        const result = handler[type].call(this, event);\n        if (this.debug) this.logResult(eventNumber, type, event, handler, result);\n        if (result === this.passEventToPage) {\n          return true;\n        } else if (result === this.suppressPropagation) {\n          if (type === \"keydown\") {\n            DomUtils.consumeKeyup(event, null, true);\n          } else {\n            DomUtils.suppressPropagation(event);\n          }\n          return false;\n        } else if (result === this.restartBubbling) {\n          return this.bubbleEvent(type, event);\n        } else if (\n          (result === this.continueBubbling) || (result && (result !== this.suppressEvent))\n        ) {\n          true; // Do nothing, but continue bubbling.\n        } else {\n          // result is @suppressEvent or falsy.\n          if (this.isChromeEvent(event)) {\n            if (type === \"keydown\") {\n              DomUtils.consumeKeyup(event);\n            } else {\n              DomUtils.suppressEvent(event);\n            }\n          }\n          return false;\n        }\n      }\n    }\n\n    // None of our handlers care about this event, so pass it to the page.\n    return true;\n  }\n\n  remove(id) {\n    if (id == null) id = this.currentId;\n    for (let i = this.stack.length - 1; i >= 0; i--) {\n      const handler = this.stack[i];\n      if (handler.id === id) {\n        // Mark the handler as removed.\n        handler.id = null;\n        this.stack.splice(i, 1);\n        break;\n      }\n    }\n  }\n\n  // The handler stack handles chrome events (which may need to be suppressed) and internal (pseudo)\n  // events. This checks whether the event at hand is a chrome event.\n  isChromeEvent(event) {\n    // TODO(philc): Shorten this.\n    return ((event != null ? event.preventDefault : undefined) != null) ||\n      ((event != null ? event.stopImmediatePropagation : undefined) != null);\n  }\n\n  // Convenience wrappers. Handlers must return an approriate value. These are wrappers which\n  // handlers can use to always return the same value. This then means that the handler itself can\n  // be implemented without regard to its return value.\n  alwaysContinueBubbling(handler = null) {\n    if (typeof handler === \"function\") {\n      handler();\n    }\n    return this.continueBubbling;\n  }\n\n  alwaysSuppressPropagation(handler = null) {\n    // TODO(philc): Shorten this.\n    if ((typeof handler === \"function\" ? handler() : undefined) === this.suppressEvent) {\n      return this.suppressEvent;\n    } else return this.suppressPropagation;\n  }\n\n  // Debugging.\n  logResult(eventNumber, type, event, handler, result) {\n    if ((event != null ? event.type : undefined) === \"keydown\") { // Tweak this as needed.\n      let label = (() => {\n        switch (result) {\n          case this.passEventToPage:\n            return \"passEventToPage\";\n          case this.suppressEvent:\n            return \"suppressEvent\";\n          case this.suppressPropagation:\n            return \"suppressPropagation\";\n          case this.restartBubbling:\n            return \"restartBubbling\";\n          case \"skip\":\n            return \"skip\";\n          case true:\n            return \"continue\";\n        }\n      })();\n      if (!label) label = result ? \"continue/truthy\" : \"suppress\";\n      console.log(`${eventNumber}`, type, handler._name, label);\n    }\n  }\n\n  show() {\n    console.log(`${this.eventNumber}:`);\n    for (const handler of this.stack.slice().reverse()) {\n      console.log(\"  \", handler._name);\n    }\n  }\n\n  // For tests only.\n  reset() {\n    this.stack = [];\n  }\n}\n\nglobalThis.HandlerStack = HandlerStack;\nglobalThis.handlerStack = new HandlerStack();\n"
  },
  {
    "path": "lib/keyboard_utils.js",
    "content": "let mapKeyRegistry = {};\nUtils.monitorChromeSessionStorage(\"mapKeyRegistry\", (value) => {\n  return mapKeyRegistry = value;\n});\n\nconst KeyboardUtils = {\n  // This maps event.key key names to Vimium key names.\n  keyNames: {\n    \"ArrowLeft\": \"left\",\n    \"ArrowUp\": \"up\",\n    \"ArrowRight\": \"right\",\n    \"ArrowDown\": \"down\",\n    \" \": \"space\",\n    \"\\n\": \"enter\", // on a keypress event of Ctrl+Enter, tested on Chrome 92 and Windows 10\n  },\n\n  init() {\n    // TODO(philc): Remove this guard clause once Deno has a userAgent.\n    // https://github.com/denoland/deno/issues/14362\n    // As of 2022-04-30, Deno does not have userAgent defined on navigator.\n    if (navigator.userAgent == null) {\n      this.platform = \"Unknown\";\n      return;\n    }\n    if (navigator.userAgent.indexOf(\"Mac\") !== -1) {\n      this.platform = \"Mac\";\n    } else if (navigator.userAgent.indexOf(\"Linux\") !== -1) {\n      this.platform = \"Linux\";\n    } else {\n      this.platform = \"Windows\";\n    }\n  },\n\n  getKeyChar(event) {\n    let key;\n    const canUseEventKey = !Settings.get(\"ignoreKeyboardLayout\") &&\n      // On MacOS, when alt (option) is pressed, event.key is a symbol. E.g. the <a-c> key press\n      // yields ç. In such cases, use event.code instead to identify which key was pressed, so that\n      // the user can intuitively map <a-c> in their keymappings, rather than <a-ç>. See #3197.\n      !(this.platform == \"Mac\" && event.altKey);\n\n    if (canUseEventKey) {\n      key = event.key;\n    } else if (!event.code) {\n      key = event.key != null ? event.key : \"\"; // Fall back to event.key (see #3099).\n    } else if (event.code.slice(0, 6) === \"Numpad\") {\n      // We cannot correctly emulate the numpad, so fall back to event.key; see #2626.\n      key = event.key;\n    } else {\n      // The logic here is from the vim-like-key-notation project\n      // (https://github.com/lydell/vim-like-key-notation).\n      key = event.code;\n      if (key.slice(0, 3) === \"Key\") key = key.slice(3);\n      // Translate some special keys to event.key-like strings and handle <Shift>.\n      if (this.enUsTranslations[key]) {\n        key = event.shiftKey ? this.enUsTranslations[key][1] : this.enUsTranslations[key][0];\n      } else if ((key.length === 1) && !event.shiftKey) {\n        key = key.toLowerCase();\n      }\n    }\n\n    // It appears that key is not always defined (see #2453).\n    if (!key) {\n      return \"\";\n    } else if (key in this.keyNames) {\n      return this.keyNames[key];\n    } else if (this.isModifier(event)) {\n      return \"\"; // Don't resolve modifier keys.\n    } else if (key.length === 1) {\n      return key;\n    } else {\n      return key.toLowerCase();\n    }\n  },\n\n  getKeyCharString(event) {\n    let keyChar = this.getKeyChar(event);\n    if (!keyChar) {\n      return;\n    }\n\n    const modifiers = [];\n\n    if (event.shiftKey && (keyChar.length === 1)) keyChar = keyChar.toUpperCase();\n    // These must be in alphabetical order (to match the sorted modifier order in\n    // Commands.normalizeKey).\n    if (event.altKey) modifiers.push(\"a\");\n    if (event.ctrlKey) modifiers.push(\"c\");\n    if (event.metaKey) modifiers.push(\"m\");\n    if (event.shiftKey && (keyChar.length > 1)) modifiers.push(\"s\");\n\n    keyChar = [...modifiers, keyChar].join(\"-\");\n    if (1 < keyChar.length) keyChar = `<${keyChar}>`;\n    keyChar = mapKeyRegistry[keyChar] != null ? mapKeyRegistry[keyChar] : keyChar;\n    return keyChar;\n  },\n\n  isEscape: (function () {\n    let useVimLikeEscape = true;\n    Utils.monitorChromeSessionStorage(\"useVimLikeEscape\", (value) => useVimLikeEscape = value);\n\n    return function (event) {\n      // <c-[> is mapped to Escape in Vim by default.\n      // Escape with a keyCode 229 means that this event comes from IME, and should not be treated\n      // as a direct/normal Escape event. IME will handle the event, not vimium.\n      // See https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html\n      return ((event.key === \"Escape\") && (event.keyCode !== 229)) ||\n        (useVimLikeEscape && (this.getKeyCharString(event) === \"<c-[>\"));\n    };\n  })(),\n\n  isBackspace(event) {\n    return [\"Backspace\", \"Delete\"].includes(event.key);\n  },\n\n  isPrintable(event) {\n    const s = this.getKeyCharString(event);\n    return s && s.length == 1;\n  },\n\n  isModifier(event) {\n    return [\"Control\", \"Shift\", \"Alt\", \"OS\", \"AltGraph\", \"Meta\"].includes(event.key);\n  },\n\n  enUsTranslations: {\n    \"Backquote\": [\"`\", \"~\"],\n    \"Minus\": [\"-\", \"_\"],\n    \"Equal\": [\"=\", \"+\"],\n    \"Backslash\": [\"\\\\\", \"|\"],\n    \"IntlBackslash\": [\"\\\\\", \"|\"],\n    \"BracketLeft\": [\"[\", \"{\"],\n    \"BracketRight\": [\"]\", \"}\"],\n    \"Semicolon\": [\";\", \":\"],\n    \"Quote\": [\"'\", '\"'],\n    \"Comma\": [\",\", \"<\"],\n    \"Period\": [\".\", \">\"],\n    \"Slash\": [\"/\", \"?\"],\n    \"Space\": [\" \", \" \"],\n    \"Digit1\": [\"1\", \"!\"],\n    \"Digit2\": [\"2\", \"@\"],\n    \"Digit3\": [\"3\", \"#\"],\n    \"Digit4\": [\"4\", \"$\"],\n    \"Digit5\": [\"5\", \"%\"],\n    \"Digit6\": [\"6\", \"^\"],\n    \"Digit7\": [\"7\", \"&\"],\n    \"Digit8\": [\"8\", \"*\"],\n    \"Digit9\": [\"9\", \"(\"],\n    \"Digit0\": [\"0\", \")\"],\n  },\n};\n\nKeyboardUtils.init();\n\nglobalThis.KeyboardUtils = KeyboardUtils;\n"
  },
  {
    "path": "lib/rect.js",
    "content": "// Commands for manipulating rects.\nconst Rect = {\n  // Create a rect given the top left and bottom right corners.\n  create(x1, y1, x2, y2) {\n    return {\n      bottom: y2,\n      top: y1,\n      left: x1,\n      right: x2,\n      width: x2 - x1,\n      height: y2 - y1,\n    };\n  },\n\n  copy(rect) {\n    return {\n      bottom: rect.bottom,\n      top: rect.top,\n      left: rect.left,\n      right: rect.right,\n      width: rect.width,\n      height: rect.height,\n    };\n  },\n\n  // Translate a rect by x horizontally and y vertically.\n  translate(rect, x, y) {\n    if (x == null) x = 0;\n    if (y == null) y = 0;\n    return {\n      bottom: rect.bottom + y,\n      top: rect.top + y,\n      left: rect.left + x,\n      right: rect.right + x,\n      width: rect.width,\n      height: rect.height,\n    };\n  },\n\n  // Subtract rect2 from rect1, returning an array of rects which are in rect1 but not rect2.\n  subtract(rect1, rect2) {\n    // Bound rect2 by rect1\n    rect2 = this.create(\n      Math.max(rect1.left, rect2.left),\n      Math.max(rect1.top, rect2.top),\n      Math.min(rect1.right, rect2.right),\n      Math.min(rect1.bottom, rect2.bottom),\n    );\n\n    // If bounding rect2 has made the width or height negative, rect1 does not contain rect2.\n    if ((rect2.width < 0) || (rect2.height < 0)) return [Rect.copy(rect1)];\n\n    //\n    // All the possible rects, in the order\n    // +-+-+-+\n    // |1|2|3|\n    // +-+-+-+\n    // |4| |5|\n    // +-+-+-+\n    // |6|7|8|\n    // +-+-+-+\n    // where the outer rectangle is rect1 and the inner rectangle is rect 2. Note that the rects may\n    // be of width or height 0.\n    //\n    const rects = [\n      // Top row.\n      this.create(rect1.left, rect1.top, rect2.left, rect2.top),\n      this.create(rect2.left, rect1.top, rect2.right, rect2.top),\n      this.create(rect2.right, rect1.top, rect1.right, rect2.top),\n      // Middle row.\n      this.create(rect1.left, rect2.top, rect2.left, rect2.bottom),\n      this.create(rect2.right, rect2.top, rect1.right, rect2.bottom),\n      // Bottom row.\n      this.create(rect1.left, rect2.bottom, rect2.left, rect1.bottom),\n      this.create(rect2.left, rect2.bottom, rect2.right, rect1.bottom),\n      this.create(rect2.right, rect2.bottom, rect1.right, rect1.bottom),\n    ];\n\n    return rects.filter((rect) => (rect.height > 0) && (rect.width > 0));\n  },\n\n  // Determine whether two rects overlap.\n  intersects(rect1, rect2) {\n    return (rect1.right > rect2.left) &&\n      (rect1.left < rect2.right) &&\n      (rect1.bottom > rect2.top) &&\n      (rect1.top < rect2.bottom);\n  },\n\n  // Determine whether two rects overlap, including 0-width intersections at borders.\n  intersectsStrict(rect1, rect2) {\n    return (rect1.right >= rect2.left) && (rect1.left <= rect2.right) &&\n      (rect1.bottom >= rect2.top) && (rect1.top <= rect2.bottom);\n  },\n\n  equals(rect1, rect2) {\n    for (const property of [\"top\", \"bottom\", \"left\", \"right\", \"width\", \"height\"]) {\n      if (rect1[property] !== rect2[property]) return false;\n    }\n    return true;\n  },\n\n  intersect(rect1, rect2) {\n    return this.create(\n      Math.max(rect1.left, rect2.left),\n      Math.max(rect1.top, rect2.top),\n      Math.min(rect1.right, rect2.right),\n      Math.min(rect1.bottom, rect2.bottom),\n    );\n  },\n};\n\nglobalThis.Rect = Rect;\n"
  },
  {
    "path": "lib/settings.js",
    "content": "// The possible destinations for new tabs opened using Vimium's `createTab` command.\nconst newTabDestinations = {\n  browserNewTabPage: \"browserNewTabPage\",\n  vimiumNewTabPage: \"vimiumNewTabPage\",\n  customUrl: \"customUrl\",\n};\n\nconst vimiumNewTabPageUrl = \"https://vimium.github.io/new-tab/\";\n\nconst defaultOptions = {\n  scrollStepSize: 60,\n  smoothScroll: true,\n  keyMappings: \"# Insert your preferred key mappings here.\",\n  linkHintCharacters: \"sadfjklewcmpgh\",\n  linkHintNumbers: \"0123456789\",\n  filterLinkHints: false,\n  hideHud: false,\n  hideUpdateNotifications: false,\n  userDefinedLinkHintCss: `\\\ndiv > .vimiumHintMarker {\n/* linkhint boxes */\nbackground: -webkit-gradient(linear, left top, left bottom, color-stop(0%,#FFF785),\n  color-stop(100%,#FFC542));\nborder: 1px solid #E3BE23;\n}\n\ndiv > .vimiumHintMarker span {\n/* linkhint text */\ncolor: black;\nfont-weight: bold;\nfont-size: 12px;\n}\n\ndiv > .vimiumHintMarker > .matchingCharacter {\n}\\\n`,\n  // Default exclusion rules.\n  exclusionRules: [\n    // Disable Vimium on Gmail.\n    {\n      passKeys: \"\",\n      pattern: \"https?://mail.google.com/*\",\n    },\n  ],\n\n  // NOTE: If a page contains both a single angle-bracket link and a double angle-bracket link,\n  // then in most cases the single bracket link will be \"prev/next page\" and the double bracket\n  // link will be \"first/last page\", so we put the single bracket first in the pattern string so\n  // that it gets searched for first.\n\n  // \"\\bprev\\b,\\bprevious\\b,\\bback\\b,<,‹,←,«,≪,<<\"\n  previousPatterns: \"prev,previous,back,older,<,\\u2039,\\u2190,\\xab,\\u226a,<<\",\n  // \"\\bnext\\b,\\bmore\\b,>,›,→,»,≫,>>\"\n  nextPatterns: \"next,more,newer,>,\\u203a,\\u2192,\\xbb,\\u226b,>>\",\n  // default/fall back search engine\n  searchUrl: \"https://www.google.com/search?q=\",\n  // put in an example search engine\n  searchEngines: `\\\nw: https://www.wikipedia.org/w/index.php?title=Special:Search&search=%s Wikipedia\n\n# More examples.\n#\n# (Vimium supports search completion Wikipedia, as\n# above, and for these.)\n#\n# g: https://www.google.com/search?q=%s Google\n# l: https://www.google.com/search?q=%s&btnI I'm feeling lucky...\n# y: https://www.youtube.com/results?search_query=%s Youtube\n# gm: https://www.google.com/maps?q=%s Google maps\n# b: https://www.bing.com/search?q=%s Bing\n# d: https://duckduckgo.com/?q=%s DuckDuckGo\n# az: https://www.amazon.com/s/?field-keywords=%s\n# qw: https://www.qwant.com/?q=%s Qwant\\\n`,\n  newTabDestination: newTabDestinations.vimiumNewTabPage,\n  newTabCustomUrl: \"\",\n  openVomnibarOnNewTabPage: true,\n  grabBackFocus: false,\n  regexFindMode: false,\n  waitForEnterForFilteredHints: true,\n  helpDialog_showAdvancedCommands: false,\n  ignoreKeyboardLayout: false,\n};\n\n/*\n * This class fetches and exposes the view over Vimium's settings data, which is stored in\n * chrome.storage. It merges the user's customizations into the default setting values.\n * It dispatches the \"change\" event when the settings have been changed.\n */\nconst Settings = {\n  _settings: null,\n  _chromeStorageListenerInstalled: false,\n\n  defaultOptions,\n  newTabDestinations,\n  vimiumNewTabPageUrl,\n\n  async onLoaded() {\n    if (!this.isLoaded()) {\n      await this.load();\n    }\n  },\n\n  async chromeStorageOnChanged(_changes, area) {\n    // We store data with keys [settings-v1, ...] into the local storage. Only broadcast an event if\n    // the object stored with the settings key has changed.\n    // We only store settings in the sync area, so storage.sync changes must be settings changes.\n    if (area == \"sync\") {\n      await this.load();\n      this.dispatchEvent(\"change\");\n    }\n  },\n\n  async load() {\n    // NOTE(philc): If we change the schema of the settings object in a backwards-incompatible way,\n    // then we can fetch the whole storage object here and migrate any old settings the user has to\n    // the new schema.\n    if (!this._chromeStorageListenerInstalled) {\n      this._chromeStorageListenerInstalled = true;\n      chrome.storage.onChanged.addListener((changes, area) =>\n        this.chromeStorageOnChanged(changes, area)\n      );\n    }\n\n    let result = await chrome.storage.sync.get(null); // Get every key.\n    result = this.migrateSettingsIfNecessary(result);\n    result[\"settingsVersion\"] = Utils.getCurrentVersion();\n    this._settings = Object.assign(globalThis.structuredClone(defaultOptions), result);\n  },\n\n  isLoaded() {\n    return this._settings != null;\n  },\n\n  get(key) {\n    if (!this.isLoaded()) {\n      throw new Error(`Getting the setting ${key} before settings have been loaded.`);\n    }\n    return globalThis.structuredClone(this._settings[key]);\n  },\n\n  async set(key, value) {\n    if (!this.isLoaded()) {\n      throw new Error(`Writing the setting ${key} before settings have been loaded.`);\n    }\n    this._settings[key] = value;\n    await this.setSettings(this._settings);\n  },\n\n  getSettings() {\n    return globalThis.structuredClone(this._settings);\n  },\n\n  migratePre2_0(settings) {\n    // Prior to Vimium version 2.0.0:\n    // - In chrome.storages.sync, the value of each setting was encoded as a JSON string using\n    //   JSON.stringify. This was probably an artifact of originally using localStorage, but is no\n    //   longer necessary now that chrome.storage exists and can store objects. Note that when\n    //   exporting a backup of settings on the options page, the values were not encoded as JSON\n    //   strings.\n    // - We only stored the settingsVersion key in the JSON payload when a user exported a backup of\n    //   their settings. It wasn't set when writing the settings to chrome.storage.sync.\n\n    // NOTE(philc): We want to migrate settings which have JSON-encoded values. Based on the notes\n    // above, that should mean we only need to migrate if the settings object is missing a\n    // \"settingsVersion\" key.\n    const shouldMigrate = settings[\"settingsVersion\"] == null;\n    if (!shouldMigrate) return settings;\n\n    // Migration for v2.0.0: decode all values so that they're not JSON string encoded.\n    const newSettings = {};\n    for (const [k, v] of Object.entries(settings)) {\n      // Most pre-2.0 settings were strings, but the global marks were stored as native values. See\n      // #4323. So check the setting value's type before migrating.\n      if (typeof v === \"string\") {\n        newSettings[k] = JSON.parse(v);\n      } else {\n        newSettings[k] = v;\n      }\n    }\n    // This key is no longer stored in our settings, but rather chrome.storage.session.\n    delete newSettings.passNextKeyKeys;\n    return newSettings;\n  },\n\n  migratePre2_4(settings) {\n    const version = settings[\"settingsVersion\"];\n    // In 2.4 we added some new settings which control the URL that new tabs are opened in.\n    const shouldMigrate = version && (Utils.compareVersions(version, \"2.4\") < 0);\n    if (!shouldMigrate) return settings;\n    const previousDefaultNewTabUrl = \"about:newtab\";\n    if (!settings.newTabUrl || settings.newTabUrl == previousDefaultNewTabUrl) {\n      // newTabUrl was absent (user never changed from the default, so it was pruned from storage)\n      // or was explicitly set to the browser's default new tab URL.\n      settings.newTabDestination = newTabDestinations.browserNewTabPage;\n    } else if (settings.newTabUrl == \"pages/blank.html\") {\n      // This was meant to be used as a blank page, but we no longer include this page in Vimium.\n      // We use \"vimium.github.io/new-tab/\" instead.\n      settings.newTabDestination = newTabDestinations.vimiumNewTabPage;\n    } else if (settings.newTabUrl) {\n      // It's some other custom URL the user has set.\n      settings.newTabDestination = newTabDestinations.customUrl;\n      settings.newTabCustomUrl = settings.newTabUrl;\n    }\n    delete settings.newTabUrl;\n    return settings;\n  },\n\n  migratePre2_4_1(settings) {\n    // In migratePre2_4, there was a bug: pre-2.4 users who had never changed their newTabUrl (which\n    // defaulted to \"about:newtab\") were incorrectly given newTabDestination = \"vimiumNewTabPage\".\n    // Fix this by setting newTabDestination = \"browserNewTabPage\" for any 2.4.0 user who hasn't set\n    // their newTabDestination to something other than \"vimiumNewTabPage\" (the default value).\n    const version = settings[\"settingsVersion\"];\n    // Only run this for users that went through the buggy 2.4.0 migration (version >= \"2.4\" but <\n    // \"2.4.1\"). Users upgrading directly from pre-2.4 are handled correctly by the (since\n    // corrected) migratePre2_4 and should not have this fixup applied.\n    const shouldMigrate = version &&\n      (Utils.compareVersions(version, \"2.4\") >= 0) &&\n      (Utils.compareVersions(version, \"2.4.1\") < 0);\n    if (!shouldMigrate) return settings;\n    const dest = settings.newTabDestination;\n    if (!dest || dest == newTabDestinations.vimiumNewTabPage) {\n      settings.newTabDestination = newTabDestinations.browserNewTabPage;\n    }\n    return settings;\n  },\n\n  // Returns a settings object and performs any migrations required if the settings object is from\n  // an older version of Vimium.\n  migrateSettingsIfNecessary(settings) {\n    settings = this.migratePre2_0(settings);\n    settings = this.migratePre2_4(settings);\n    settings = this.migratePre2_4_1(settings);\n    return settings;\n  },\n\n  async setSettings(settings) {\n    settings = this.migrateSettingsIfNecessary(settings);\n    settings[\"settingsVersion\"] = Utils.getCurrentVersion();\n    const result = this.pruneOutDefaultValues(settings);\n    // If, after pruning, some keys were removed because their values are now equal to the default\n    // values, then explicitly clear those from storage. Otherwise they will remain.\n    // NOTE(philc): This kind of sharp edge is a reason to switch to storing the settings object as\n    // one big object, rather than as top-level keys in chrome.storage. The tradeoff is that each\n    // value in chrome-storage has a maximum size.\n    const resultKeys = Object.keys(result);\n    const removedKeys = Object.keys(settings).filter((key) => !resultKeys.includes(key));\n    await chrome.storage.sync.remove(removedKeys);\n    await chrome.storage.sync.set(result);\n    await this.load();\n  },\n\n  // Returns a new object, removing the keys from `settings` which are equal to the default values\n  // for those keys.\n  pruneOutDefaultValues(settings) {\n    const clonedSettings = globalThis.structuredClone(settings);\n    for (const [k, v] of Object.entries(settings)) {\n      if (JSON.stringify(v) == JSON.stringify(defaultOptions[k])) {\n        delete clonedSettings[k];\n      }\n    }\n    return clonedSettings;\n  },\n\n  // Used only by tests.\n  async clear() {\n    this._settings = null;\n    await chrome.storage.sync.clear();\n  },\n};\n\nObject.assign(Settings, EventDispatcher);\n\nglobalThis.Settings = Settings;\n"
  },
  {
    "path": "lib/types.js",
    "content": "// A centralized file of types which can be shared by both content scripts and background pages.\n\nglobalThis.VomnibarShowOptions = {\n  // The name of the completer to fetch results from.\n  completer: \"string\",\n  // Text to prefill the Vomnibar with.\n  query: \"string\",\n  // Whether to open the result in a new tab.\n  newTab: \"boolean\",\n  // Whether to select the first entry.\n  selectFirst: \"boolean\",\n  // A keyword which will scope the search to a UserSearchEngine.\n  keyword: \"string\",\n};\n"
  },
  {
    "path": "lib/url_utils.js",
    "content": "const UrlUtils = {\n  chromeNewTabUrl: \"about:newtab\",\n\n  // A set of top-level domains, e.g. [\"com\"] recognized by https://www.iana.org/domains/root/db\n  tlds: null,\n\n  // Other hard-coded TLDs that we want to recognize as URLs.\n  otherTlds: [\n    // Multicast DNS uses 'local' to resolve hostnames to IP addresses within small networks.\n    \"local\",\n    // A pseudo-domain used by TOR browsers.\n    \"onion\",\n  ],\n\n  async init() {\n    if (this.tlds != null) return;\n    // Load the tlds.txt file relative to this module. This is required for this URL\n    // to be valid both when running tests, and in the browser.\n    const inUnitTests = globalThis.Deno;\n    const path = \"./resources/tlds.txt\";\n    let text;\n    // Deno and the browser require different URLs to resolve tlds.txt. If we change\n    // url_utils.js to be imported as a module, then can both use an import path\n    // that's relative to the module:\n    // const tldsFileUrl = new URL(\"resources/tlds.txt\", new URL(import.meta.url));\n    if (inUnitTests) {\n      text = await Deno.readTextFile(path);\n    } else {\n      const response = await fetch(chrome.runtime.getURL(path));\n      text = await response.text();\n    }\n    this.tlds = new Set(text.split(\"\\n\"));\n  },\n\n  // Tries to detect if :str is a valid URL.\n  async isUrl(str) {\n    if (this.tlds == null) {\n      await this.init();\n    }\n\n    // Must not contain spaces\n    if (str.includes(\" \")) return false;\n\n    // Starts with a scheme: URL\n    if (this.urlHasProtocol(str)) return true;\n\n    // More or less RFC compliant URL host part parsing. This should be sufficient for our needs\n    const urlRegex = new RegExp(\n      \"^(?:([^:]+)(?::([^:]+))?@)?\" + // user:password (optional) => \\1, \\2\n        \"([^:]+|\\\\[[^\\\\]]+\\\\])\" + // host name (IPv6 addresses in square brackets allowed) => \\3\n        \"(?::(\\\\d+))?$\", // port number (optional) => \\4\n    );\n\n    const specialHostNames = [\"localhost\"];\n\n    // Try to parse the URL into its meaningful parts. If matching fails we're pretty sure that we\n    // don't have some kind of URL here.\n    // TODO(philc): Can't we use URL() here? This code might've been written before the URL class\n    // existed.\n    const match = urlRegex.exec((str.split(\"/\"))[0]);\n    if (!match) return false;\n    const hostName = match[3];\n\n    // Allow known special host names\n    if (specialHostNames.includes(hostName)) return true;\n\n    // Allow IPv6 addresses (need to be wrapped in brackets as required by RFC). It is sufficient to\n    // check for a colon, as the regex wouldn't match colons in the host name unless it's an v6\n    // address\n    if (hostName.includes(\":\")) return true;\n\n    // At this point we have to make a decision. As a heuristic, we check if the input has dots in\n    // it. If yes, and if the last part could be a TLD, treat it as an URL\n    const dottedParts = hostName.split(\".\");\n\n    if (dottedParts.length > 1) {\n      const lastPart = dottedParts.pop();\n      if (this.tlds.has(lastPart) || this.otherTlds.includes(lastPart)) {\n        return true;\n      }\n    }\n\n    // Allow IPv4 addresses\n    if (/^(\\d{1,3}\\.){3}\\d{1,3}$/.test(hostName)) return true;\n\n    // Fallback: no URL\n    return false;\n  },\n\n  // Converts string into a full URL if it's not already one. We don't escape characters as the\n  // browser will do that for us.\n  async convertToUrl(string) {\n    string = string.trim();\n\n    // Special-case about:[url], view-source:[url] and the like\n    if (this.hasChromeProtocol(string)) {\n      return string;\n    } else if (this.hasJavascriptProtocol(string)) {\n      return string;\n    } else if (await this.isUrl(string)) {\n      return this.urlHasProtocol(string) ? string : `http://${string}`;\n    } else {\n      return null;\n    }\n  },\n\n  _chromePrefixes: [\"about:\", \"view-source:\", \"extension:\", \"chrome-extension:\", \"data:\"],\n  hasChromeProtocol(url) {\n    return this._chromePrefixes.some((prefix) => url.startsWith(prefix));\n  },\n\n  hasJavascriptProtocol(url) {\n    return url.startsWith(\"javascript:\");\n  },\n\n  _urlPrefix: new RegExp(\"^[a-z][-+.a-z0-9]{2,}://.\"),\n  urlHasProtocol(url) {\n    return this._urlPrefix.test(url);\n  },\n\n  // Create a search URL from the given :query using the provided search URL.\n  createSearchUrl(query, searchUrl) {\n    if (![\"%s\", \"%S\"].some((token) => searchUrl.indexOf(token) >= 0)) {\n      searchUrl += \"%s\";\n    }\n    searchUrl = searchUrl.replace(/%S/g, query);\n\n    // Map a search query to its URL encoded form. E.g. \"BBC Sport\" -> \"BBC%20Sport\".\n    const parts = query.split(/\\s+/);\n    const encodedQuery = parts.map(encodeURIComponent).join(\"%20\");\n    return searchUrl.replace(/%s/g, encodedQuery);\n  },\n};\n\nglobalThis.UrlUtils = UrlUtils;\n"
  },
  {
    "path": "lib/utils.js",
    "content": "// Only pass events to the handler if they are marked as trusted by the browser.\n// This is kept in the global namespace for brevity and ease of use.\nif (globalThis.forTrusted == null) {\n  globalThis.forTrusted = (handler) => {\n    return function (event) {\n      if (event && event.isTrusted) {\n        return handler.apply(this, arguments);\n      } else {\n        return true;\n      }\n    };\n  };\n}\n\n// Firefox does not have the storage.session API as of 2023-05-20. Until it does, use storage.local.\n// Firefox 115 has beta support for storage.session, but this storage is not exposed to content\n// scripts unless we use `setAccessLevel`, and that API is not yet implemented in Firefox 115.\nif (chrome.storage.session == null || chrome.storage.session.setAccessLevel == null) {\n  chrome.storage.session = chrome.storage.local;\n  // Polyfill chrome.storage.session.setAccessLevel.\n  chrome.storage.session.setAccessLevel = function () {};\n}\n\nconst Utils = {\n  debug: false,\n\n  debugLog() {\n    if (this.debug) {\n      console.log.apply(console, arguments);\n    }\n  },\n\n  // The Firefox browser name and version can only be reliably accessed from the browser page using\n  // browser.runtime.getBrowserInfo(). This information is passed to the frontend via the\n  // initializeFrame message, which sets each of these values. These values can also be set using\n  // Utils.populateBrowserInfo().\n  _browserInfoLoaded: false,\n  _firefoxVersion: null,\n  _isFirefox: null,\n\n  // This should only be used by content scripts. Background pages should use BgUtils.isFirefox().\n  isFirefox() {\n    if (!this._browserInfoLoaded) throw new Error(\"browserInfo has not yet loaded.\");\n    return this._isFirefox;\n  },\n\n  // This should only be used by content scripts. Background pages should use\n  // bg_utils.firefoxVersion().\n  firefoxVersion() {\n    if (!this._browserInfoLoaded) throw new Error(\"browserInfo has not yet loaded.\");\n    return this._firefoxVersion;\n  },\n\n  getCurrentVersion() {\n    return chrome.runtime.getManifest().version;\n  },\n\n  async populateBrowserInfo() {\n    if (this._browserInfoLoaded) return;\n    const result = await chrome.runtime.sendMessage({ handler: \"getBrowserInfo\" });\n    this._isFirefox = result.isFirefox;\n    this._firefoxVersion = result.firefoxVersion;\n    this._browserInfoLoaded = true;\n  },\n\n  // Escape all special characters, so RegExp will parse the string 'as is'.\n  // Taken from http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex\n  escapeRegexSpecialCharacters: (function () {\n    const escapeRegex = /[\\-\\[\\]\\/\\{\\}\\(\\)\\*\\+\\?\\.\\\\\\^\\$\\|]/g;\n    return (str) => str.replace(escapeRegex, \"\\\\$&\");\n  })(),\n\n  escapeHtml(string) {\n    return string.replace(/</g, \"&lt;\").replace(/>/g, \"&gt;\");\n  },\n\n  // Generates a unique ID\n  createUniqueId: (function () {\n    let id = 0;\n    return () => id += 1;\n  })(),\n\n  // Decode valid escape sequences in a URI. This is intended to mimic the best-effort decoding\n  // Chrome itself seems to apply when a Javascript URI is enetered into the omnibox (or clicked).\n  // See https://code.google.com/p/chromium/issues/detail?id=483000, #1611 and #1636.\n  decodeURIByParts(uri) {\n    return uri.split(/(?=%)/).map(function (uriComponent) {\n      try {\n        return decodeURIComponent(uriComponent);\n      } catch {\n        return uriComponent;\n      }\n    }).join(\"\");\n  },\n\n  // Extract a query from url if it appears to be a URL created from the given search URL.\n  // For example, map \"https://www.google.ie/search?q=star+wars&foo&bar\" to \"star wars\".\n  // TODO(philc): Currently unused; delete.\n  extractQuery: (() => {\n    const queryTerminator = new RegExp(\"[?&#/]\");\n    const httpProtocolRegexp = new RegExp(\"^https?://\");\n    return function (searchUrl, url) {\n      let suffixTerms;\n      url = url.replace(httpProtocolRegexp);\n      searchUrl = searchUrl.replace(httpProtocolRegexp);\n      [searchUrl, ...suffixTerms] = searchUrl.split(\"%s\");\n      // We require the URL to start with the search URL.\n      if (!url.startsWith(searchUrl)) return null;\n      // We require any remaining terms in the search URL to also be present in the URL.\n      for (const suffix of suffixTerms) {\n        if (!(0 <= url.indexOf(suffix))) return null;\n      }\n      // We use try/catch because decodeURIComponent can throw an exception.\n      try {\n        return url.slice(searchUrl.length).split(queryTerminator)[0].split(\"+\").map(\n          decodeURIComponent,\n        ).join(\" \");\n      } catch {\n        return null;\n      }\n    };\n  })(),\n\n  // detects both literals and dynamically created strings\n  // TODO(philc): There's only one caller. Inline this.\n  isString(obj) {\n    return (typeof obj === \"string\") || obj instanceof String;\n  },\n\n  // Transform \"zjkjkabz\" into \"abjkz\".\n  distinctCharacters(str) {\n    const chars = str.split(\"\");\n    return Array.from(new Set(chars)).sort().join(\"\");\n  },\n\n  // Compares two version strings (e.g. \"1.1\" and \"1.5\") and returns\n  // -1 if versionA is < versionB, 0 if they're equal, and 1 if versionA is > versionB.\n  compareVersions(versionA, versionB) {\n    versionA = versionA.split(\".\");\n    versionB = versionB.split(\".\");\n    for (let i = 0, end = Math.max(versionA.length, versionB.length); i < end; i++) {\n      const a = parseInt(versionA[i] || 0, 10);\n      const b = parseInt(versionB[i] || 0, 10);\n      if (a < b) {\n        return -1;\n      } else if (a > b) {\n        return 1;\n      }\n    }\n    return 0;\n  },\n\n  // Group items in an array by a key function. Inspired by lodash's implementation.\n  // - key: either a string property name, or a function which takes an item from the array and\n  //   returns the value of a key.\n  // Example: keyBy([{ k: \"a\" }, { k: \"b\" }], \"k\") =>\n  //   {\n  //     \"a\": { k: \"a\" },\n  //     \"b\": { k: \"b\" },\n  //   }\n  keyBy(array, key) {\n    return array.reduce((result, item) => {\n      const keyValue = typeof key === \"function\" ? key(item) : item[key];\n      result[keyValue] = item;\n      return result;\n    }, {});\n  },\n\n  // Zip two (or more) arrays:\n  //   - Utils.zip([ [a,b], [1,2] ]) returns [ [a,1], [b,2] ]\n  //   - Length of result is `arrays[0].length`.\n  //   - Adapted from: http://stackoverflow.com/questions/4856717/javascript-equivalent-of-pythons-zip-function\n  zip(arrays) {\n    return arrays[0].map((_, i) => arrays.map((array) => array[i]));\n  },\n\n  // Returns a copy of `object`, but only with the properties in `propertyList`.\n  pick(object, propertyList) {\n    const result = {};\n    for (const property of propertyList) {\n      if (Object.prototype.hasOwnProperty.call(object, property)) {\n        result[property] = object[property];\n      }\n    }\n    return result;\n  },\n\n  // locale-sensitive uppercase detection\n  hasUpperCase(s) {\n    return s.toLowerCase() !== s;\n  },\n\n  // Does string match any of these regexps?\n  matchesAnyRegexp(regexps, string) {\n    for (const re of regexps) {\n      if (re.test(string)) return true;\n    }\n    return false;\n  },\n\n  // Convenience wrapper for setTimeout (with the arguments around the other way).\n  setTimeout(ms, func) {\n    return setTimeout(func, ms);\n  },\n\n  // Like Nodejs's nextTick.\n  nextTick(func) {\n    return this.setTimeout(0, func);\n  },\n\n  promiseWithTimeout(promise, ms) {\n    const timeoutPromise = new Promise((_resolve, reject) => {\n      setTimeout(() => reject(new Error(`Promise timed out after ${ms}ms.`)), ms);\n    });\n    return Promise.race([promise, timeoutPromise]);\n  },\n\n  // Make an idempotent function.\n  makeIdempotent(func) {\n    return function (...args) {\n      // TODO(philc): Clean up this transpiled code.\n      let _, ref;\n      const result = ([_, func] = Array.from(ref = [func, null]), ref)[0];\n      if (result) {\n        return result(...Array.from(args || []));\n      }\n    };\n  },\n\n  monitorChromeSessionStorage(key, setter) {\n    return chrome.storage.session.get(key, (obj) => {\n      if (obj[key] != null) setter(obj[key]);\n      return chrome.storage.onChanged.addListener((changes, _area) => {\n        if (changes[key] && (changes[key].newValue !== undefined)) {\n          return setter(changes[key].newValue);\n        }\n      });\n    });\n  },\n\n  // Logs a backtrace when an assertion fails, and also halts execution by throwing an error. We do\n  // both, because logged objects in console.assert are easier to read from the DevTools console\n  // than just the output from an error.\n  assert(expression, ...messages) {\n    console.assert.apply(console, [expression].concat(messages));\n    if (!expression) {\n      throw new Error(messages.join(\" \"));\n    }\n  },\n\n  // This is a wrapper around chrome.runtime.onMessage.addListener.\n  // As of 2023-06-26 Chrome doesn't support passing an async function argument to the addListener\n  // function. If you do, the return value to the caller of chrome.runtime.sendMessage is always\n  // null. To work around this, we use an anonymous async function inside the handler that we\n  // pass to addListener.\n  // See here for workarounds: https://stackoverflow.com/q/44056271\n  // Also see MDN's page on runtime.onMessage regarding \"responding with a Promise.\n  // - listenerFn: this can be async, and can return a value to the message sender.\n  // - requestsHandled: a list of strings indicating which request types this listener will handle.\n  //   The request type is indicated by request.handler. This is required because, while most\n  //   message types are handled by just one listener (in vimium_frontend.js, or\n  //   background_scripts/main.js), when the current page is a background page (like the Options\n  //   page, or the Help dialog), then both listeners will receive all message types, and so each\n  //   message handler must be able to distinguish which message types to respond to.\n  addChromeRuntimeOnMessageListener(requestsHandled, listenerFn) {\n    chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {\n      Utils.assert(request.handler != null, \"Request is missing handler\", request);\n      if (!requestsHandled.includes(request.handler)) {\n        return false; // Signal that we will not handle this message.\n      }\n      (async function () {\n        const result = await listenerFn(request, sender);\n        sendResponse(result);\n      })();\n      return true; // Indicate that we will be calling sendResponse, asynchronously.\n    });\n  },\n\n  // Throws an error if object is null, or has properties which don't match the provided schema.\n  // This is like a minimal version of the Zod library.\n  //\n  // - schema: a map describing the desired shape of the object. E.g.\n  //   { name: \"string\", age: \"number\" }. Properites are allowed to be nulls, which means\n  //   if an object is missing a property, it's not an error.\n  // - o: the object to validate\n  assertType(schema, o) {\n    const knownTypes = [\"boolean\", \"number\", \"string\"];\n    if (schema == null) throw new Error(\"The schema argument is required.\");\n    if (o == null) throw new Error(\"The object argument is required.\");\n    for (const key of Object.keys(o)) {\n      if (!Object.hasOwn(schema, key)) {\n        throw new TypeError(`Object has unexpected property named \"${key}\": ${o}`);\n      }\n      const _type = schema[key];\n      // A null type means no assertion on the actual type, just that the object property is allowed\n      // to exist.\n      if (_type == null) continue;\n      if (!knownTypes.includes(_type)) {\n        throw new Exception(`Schema contains an unknown type: ${key} with type ${_type}.`);\n      }\n      const val = o[key];\n      if (val == null) continue; // By default all values are allowd to be null.\n      if (typeof val != _type) {\n        throw new TypeError(\n          `Object property ${key} is expected to be type ${_type} but it's ${typeof val}: ${val}`,\n        );\n      }\n    }\n  },\n};\n\nArray.copy = (array) => Array.prototype.slice.call(array, 0);\n\nString.prototype.reverse = function () {\n  return this.split(\"\").reverse().join(\"\");\n};\n\n// A cache. Entries used within two expiry periods are retained, otherwise they are discarded. At\n// most 2 * maxEntries are retained.\n// TODO(philc): Why is this capped at 2*maxEntries rather than maxEntries?\nclass SimpleCache {\n  // - expiry: expiry time in milliseconds (default, one hour)\n  // - maxEntries: maximum number of entries in the `cache` (there may be up to this many entries in\n  //   `previous`, too)\n  constructor(expiry, maxEntries) {\n    if (expiry == null) expiry = 60 * 60 * 1000;\n    this.expiry = expiry;\n    if (maxEntries == null) maxEntries = 1000;\n    this.maxEntries = maxEntries;\n    this.cache = {};\n    this.previous = {};\n    this.lastRotation = new Date();\n  }\n\n  has(key) {\n    this.rotate();\n    return (key in this.cache) || key in this.previous;\n  }\n\n  // Set value, and return that value.  If value is null, then delete key.\n  set(key, value = null) {\n    this.rotate();\n    delete this.previous[key];\n    if (value != null) {\n      return this.cache[key] = value;\n    } else {\n      delete this.cache[key];\n      return null;\n    }\n  }\n\n  get(key) {\n    this.rotate();\n    if (key in this.cache) {\n      return this.cache[key];\n    } else if (key in this.previous) {\n      this.cache[key] = this.previous[key];\n      delete this.previous[key];\n      return this.cache[key];\n    } else {\n      return null;\n    }\n  }\n\n  rotate(force) {\n    if (force == null) force = false;\n    Utils.nextTick(() => {\n      if (\n        force || (this.maxEntries < Object.keys(this.cache).length) ||\n        (this.expiry < (new Date() - this.lastRotation))\n      ) {\n        this.lastRotation = new Date();\n        this.previous = this.cache;\n        return this.cache = {};\n      }\n    });\n  }\n\n  clear() {\n    this.rotate(true);\n    return this.rotate(true);\n  }\n}\n\n// Mixin functions for enabling a class to dispatch methods.\nconst EventDispatcher = {\n  addEventListener(eventName, listener) {\n    this.events = this.events || [];\n    this.events[eventName] = this.events[eventName] || [];\n    this.events[eventName].push(listener);\n  },\n\n  dispatchEvent(eventName) {\n    this.events = this.events || [];\n    for (const listener of this.events[eventName] || []) {\n      listener();\n    }\n  },\n\n  removeEventListener(eventName, listener) {\n    const events = this.events || {};\n    const listeners = events[eventName] || [];\n    if (listeners.length > 0) {\n      events[eventName] = listeners.filter((l) => l != listener);\n    }\n  },\n};\n\nObject.assign(globalThis, {\n  Utils,\n  SimpleCache,\n  EventDispatcher,\n});\n"
  },
  {
    "path": "make.js",
    "content": "#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --allow-net --allow-run --allow-sys\n// Usage: ./make.js command. Use -l to list commands.\n// This is a set of tasks for building and testing Vimium in development.\nimport * as fs from \"@std/fs\";\nimport * as path from \"@std/path\";\nimport { abort, desc, run, task } from \"https://deno.land/x/drake@v1.5.1/mod.ts\";\nimport puppeteer from \"npm:puppeteer\";\n// We use a vendored version of shoulda, rather than jsr:@philc/shoulda, because shoulda.js is used\n// in dom_tests.js which is loaded by Puppeteer, which doesn't have access to Deno's module system.\nimport * as shoulda from \"./tests/vendor/shoulda.js\";\nimport JSON5 from \"npm:json5\";\nimport { DOMParser } from \"@b-fuze/deno-dom\";\nimport * as fileServer from \"@std/http/file-server\";\n\nconst projectPath = new URL(\".\", import.meta.url).pathname;\n\nasync function shell(procName, argsArray = []) {\n  // NOTE(philc): Does drake's `sh` function work on Windows? If so, that can replace this function.\n  if (Deno.build.os == \"windows\") {\n    // if win32, prefix arguments with \"/c {original command}\"\n    // e.g. \"mkdir c:\\git\\vimium\" becomes \"cmd.exe /c mkdir c:\\git\\vimium\"\n    optArray.unshift(\"/c\", procName);\n    procName = \"cmd.exe\";\n  }\n  const p = Deno.run({ cmd: [procName].concat(argsArray) });\n  const status = await p.status();\n  if (!status.success) {\n    throw new Error(`${procName} ${argsArray} exited with status ${status.code}`);\n  }\n}\n\n// Clones and augments the manifest.json that we use for Chrome with the keys needed for Firefox.\nfunction createFirefoxManifest(manifest) {\n  manifest = JSON.parse(JSON.stringify(manifest)); // Deep clone.\n\n  manifest.permissions = manifest.permissions\n    // The favicon permission is not yet supported by Firefox.\n    .filter((p) => p != \"favicon\")\n    // Firefox needs clipboardRead and clipboardWrite for commands like \"copyCurrentUrl\", but Chrome\n    // does not. See #4186.\n    .concat([\"clipboardRead\", \"clipboardWrite\"]);\n\n  // As of 2023-07-08 Firefox doesn't yet support background.service_worker.\n  delete manifest.background[\"service_worker\"];\n  Object.assign(manifest.background, {\n    \"scripts\": [\"background_scripts/main.js\"],\n  });\n\n  // This key is only supported by Firefox.\n  Object.assign(manifest.action, {\n    \"default_area\": \"navbar\",\n  });\n\n  Object.assign(manifest, {\n    \"browser_specific_settings\": {\n      \"gecko\": {\n        // This ID was generated by the Firefox store upon first submission. It's needed in\n        // development mode, or many extension APIs don't work.\n        \"id\": \"{d7742d87-e61d-4b78-b8a1-b469842139fa}\",\n        \"strict_min_version\": \"112.0\",\n        \"data_collection_permissions\": {\n          \"required\": [\"none\"],\n        },\n      },\n    },\n  });\n\n  // Firefox supports SVG icons.\n  Object.assign(manifest, {\n    \"icons\": {\n      \"16\": \"icons/icon.svg\",\n      \"32\": \"icons/icon.svg\",\n      \"48\": \"icons/icon.svg\",\n      \"64\": \"icons/icon.svg\",\n      \"96\": \"icons/icon.svg\",\n      \"128\": \"icons/icon.svg\",\n    },\n  });\n\n  Object.assign(manifest.action, {\n    \"default_icon\": \"icons/action_disabled.svg\",\n  });\n\n  return manifest;\n}\n\nasync function parseManifestFile() {\n  // Chrome's manifest.json supports JavaScript comment syntax. However, the Chrome Store rejects\n  // manifests with JavaScript comments in them! So here we use the JSON5 library, rather than JSON\n  // library, to parse our manifest.json and remove its comments.\n  return JSON5.parse(await Deno.readTextFile(\"./manifest.json\"));\n}\n\nasync function checkForCommonBuildIssues() {\n  // Ensure the version number is properly formed.\n  const chromeManifest = await parseManifestFile();\n  const version = chromeManifest[\"version\"];\n  const versionRegexp = /^\\d\\.\\d+\\.\\d+$/;\n  if (!versionRegexp.test(version)) {\n    throw new Error(`The version string \"${version}\" is malformed.`);\n  }\n\n  // Ensure debug logging is turned off.\n  const text = await Deno.readTextFile(\"./lib/utils.js\");\n  if (!text.includes(\"debug: false\")) {\n    throw new Error(\n      \"It looks like debug logging is turned on in lib/utils.js. \" +\n        \"It should be off in builds for the store.\",\n    );\n  }\n}\n\n// Verify all files referenced in the manifest are present in dist.\nasync function checkFilesFromManifestArePresent(manifest) {\n  const t = getPathsFromManifest(manifest);\n  const missing = [];\n\n  for (const file of getPathsFromManifest(manifest)) {\n    const exists = await fs.exists(path.join(\"dist/vimium\", file));\n    if (!exists) {\n      missing.push(file);\n    }\n  }\n\n  if (missing.length > 0) {\n    const msg = \"These files are referenced in manifest.json but missing from the build:\\n\" +\n      missing.map((f) => `  ${f}`).join(\"\\n\");\n    throw new Error(msg);\n  }\n}\n\n// Returns all file paths referenced in a parsed manifest object, excluding glob patterns.\nfunction getPathsFromManifest(manifest) {\n  let files = [];\n\n  files = files.concat(Object.values(manifest.icons));\n\n  if (manifest.background.service_worker) {\n    files.push(manifest.background.service_worker);\n  }\n\n  if (manifest.background.scripts) {\n    files = files.concat(manifest.background.scripts);\n  }\n\n  files.push(manifest.options_ui.page);\n  files.push(manifest.action.default_popup);\n\n  // The shape of the default_icon structure is different in Chrome vs. Firefox.\n  const icon = manifest.action.default_icon;\n  if (typeof icon === \"string\") {\n    files.push(icon);\n  } else {\n    files = files.concat(Object.values(icon));\n  }\n\n  for (const script of manifest.content_scripts) {\n    if (script.js) {\n      files = files.concat(script.js);\n    }\n    if (script.css) {\n      files = files.concat(script.css);\n    }\n  }\n\n  for (const obj of manifest.web_accessible_resources) {\n    for (const resource of (obj.resources)) {\n      // Skip files with glob patterns.\n      if (resource.includes(\"*\")) {\n        continue;\n      }\n      files.push(resource);\n    }\n  }\n\n  if (files.some((f) => f == null)) {\n    throw new Error(\"manifest.json is missing a path that was expected by getPathsFromManifest\");\n  }\n  // Remove duplicates.\n  return Array.from(new Set(files)).sort();\n}\n\n// Builds a zip file for submission to the Chrome and Firefox stores. The output is in dist/.\nasync function buildStorePackage() {\n  await checkForCommonBuildIssues();\n\n  const excludeList = [\n    \"*.md\",\n    \".*\",\n    \"CREDITS\",\n    \"MIT-LICENSE.txt\",\n    \"build_scripts\",\n    \"dist\",\n    \"make.js\",\n    \"deno.json\",\n    \"deno.lock\",\n    // These reload scripts are used for development only and shouldn't appear in the build.\n    \"reload.html\",\n    \"reload.js\",\n    \"test_harnesses\",\n    \"tests\",\n  ];\n\n  const chromeManifest = await parseManifestFile();\n  const rsyncOptions = [\"-r\", \".\", \"dist/vimium\"].concat(\n    ...excludeList.map((item) => [\"--exclude\", item]),\n  );\n  const version = chromeManifest[\"version\"];\n  const writeDistManifest = async (manifest) => {\n    await Deno.writeTextFile(\"dist/vimium/manifest.json\", JSON.stringify(manifest, null, 2));\n  };\n  // cd into \"dist/vimium\" before building the zip, so that the files in the zip don't each have the\n  // path prefix \"dist/vimium\".\n  // --filesync ensures that files in the archive which are no longer on disk are deleted. It's\n  // equivalent to removing the zip file before the build.\n  const zipCommand = \"cd dist/vimium && zip -r --filesync \";\n\n  await shell(\"rm\", [\"-rf\", \"dist/vimium\"]);\n  await shell(\"mkdir\", [\n    \"-p\",\n    \"dist/vimium\",\n    \"dist/chrome-canary\",\n    \"dist/chrome-store\",\n    \"dist/firefox\",\n  ]);\n  await shell(\"rsync\", rsyncOptions);\n\n  await checkFilesFromManifestArePresent(chromeManifest);\n\n  // Build the Firefox / Mozilla Addons store package.\n  const firefoxManifest = createFirefoxManifest(chromeManifest);\n  await writeDistManifest(firefoxManifest);\n  // Exclude PNG icons from the Firefox build, because we use the SVG directly.\n  await shell(\"bash\", [\n    \"-c\",\n    `${zipCommand} ../firefox/vimium-firefox-${version}.zip . -x icons/*.png`,\n  ]);\n\n  await checkFilesFromManifestArePresent(firefoxManifest);\n\n  // Build the Chrome Store package.\n  await writeDistManifest(chromeManifest);\n  await shell(\"bash\", [\n    \"-c\",\n    `${zipCommand} ../chrome-store/vimium-chrome-store-${version}.zip .`,\n  ]);\n\n  // Build the Chrome Store dev package.\n  await writeDistManifest(Object.assign({}, chromeManifest, {\n    name: \"Vimium Canary\",\n    description: \"This is the development branch of Vimium (it is beta software).\",\n  }));\n  await shell(\"bash\", [\n    \"-c\",\n    `${zipCommand} ../chrome-canary/vimium-canary-${version}.zip .`,\n  ]);\n}\n\nasync function runUnitTests() {\n  // Import every test file.\n  const dir = path.join(projectPath, \"tests/unit_tests\");\n  const files = Array.from(Deno.readDirSync(dir)).map((f) => f.name).sort();\n  for (let f of files) {\n    if (f.endsWith(\"_test.js\")) {\n      await import(path.join(dir, f));\n    }\n  }\n\n  return await shoulda.run();\n}\n\nfunction setupPuppeteerPageForTests(page) {\n  // The \"console\" event emitted has arguments which are promises. To obtain the values to be\n  // printed, we must resolve those promises. However, if many console messages are emitted at once,\n  // resolving the promises often causes the console.log messages to be printed out of order. Here,\n  // we use a queue to strictly enforce that the messages appear in the order in which they were\n  // logged.\n  const messageQueue = [];\n  let processing = false;\n  const processMessageQueue = async () => {\n    while (messageQueue.length > 0) {\n      const values = await Promise.all(messageQueue.shift());\n      console.log(...values);\n    }\n    processing = false;\n  };\n  page.on(\"console\", async (msg) => {\n    const values = msg.args().map((a) => a.jsonValue());\n    messageQueue.push(values);\n    if (!processing) {\n      processing = true;\n      processMessageQueue();\n    }\n  });\n\n  page.on(\"error\", (err) => {\n    // NOTE(philc): As far as I can tell, this handler never gets executed.\n    console.error(err);\n  });\n  // pageerror catches the same events that window.onerror would, like JavaScript parsing errors.\n  page.on(\"pageerror\", (error) => {\n    // This is an arbitrary field we're writing to the page object.\n    page.receivedErrorOutput = true;\n    // Whatever type error is, it requires toString() to print the message.\n    console.log(error.toString());\n  });\n  page.on(\"requestfailed\", (request) => {\n    console.log(`${request.failure().errorText} ${request.url()}`);\n  });\n}\n\n// Navigates the Puppeteer `page` to `url` and invokes shoulda.run().\nasync function runPuppeteerTest(page, url) {\n  page.goto(url);\n  await page.waitForNavigation({ waitUntil: \"load\" });\n  const success = await page.evaluate(async () => {\n    return await shoulda.run();\n  });\n  return success;\n}\n\ndesc(\"Download and parse list of top-level domains (TLDs)\");\ntask(\"fetch-tlds\", [], async () => {\n  const suffixListUrl = \"https://www.iana.org/domains/root/db\";\n  const response = await fetch(suffixListUrl);\n  const text = await response.text();\n  const doc = new DOMParser().parseFromString(text, \"text/html\");\n  const els = doc.querySelectorAll(\"span.domain.tld\");\n  // Each span contains a TLD, e.g. \".com\". Trim off the leading period.\n  const domains = Array.from(els).map((el) => el.textContent.slice(1));\n  const str = domains.join(\"\\n\");\n  await Deno.writeTextFile(\"./resources/tlds.txt\", str);\n});\n\ndesc(\"Run unit tests\");\ntask(\"test-unit\", [], async () => {\n  const success = await runUnitTests();\n  if (!success) {\n    abort(\"test-unit failed\");\n  }\n});\n\nfunction isPortAvailable(number) {\n  try {\n    const listener = Deno.listen({ port: number });\n    listener.close();\n    return true;\n  } catch (error) {\n    return false;\n  }\n}\n\nfunction getAvailablePort() {\n  const min = 7000;\n  const max = 65535;\n  let count = 0;\n  const getRandomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;\n  let port = getRandomInt(min, max);\n  while (!isPortAvailable(port) && count < max - min) {\n    port++;\n    if (port > max) {\n      port = min;\n    }\n    if (isPortAvailable(port)) {\n      return port;\n    }\n    count++;\n    if (count >= max - min) {\n      throw new Error(`No port is available in the range ${min} - ${max}`);\n    }\n  }\n  return port;\n}\n\nasync function testDom() {\n  const port = getAvailablePort();\n  let served404 = false;\n  const httpServer = Deno.serve({ port }, async (req) => {\n    const url = new URL(req.url);\n    let path = decodeURIComponent(url.pathname);\n    if (path.startsWith(\"/\")) {\n      path = \".\" + path;\n    }\n    if (!(await fs.exists(path))) {\n      console.error(\"dom-tests: requested missing file (not found):\", path);\n      served404 = true;\n      return new Response(null, { status: 404 });\n    } else {\n      return fileServer.serveFile(req, path);\n    }\n  });\n\n  const files = [\"dom_tests.html\"];\n  const browser = await puppeteer.launch();\n  let success = true;\n  for (const file of files) {\n    const page = await browser.newPage();\n    console.log(\"Running\", file);\n    setupPuppeteerPageForTests(page);\n    const url = `http://localhost:${port}/tests/dom_tests/${file}?dom_tests=true`;\n    const result = await runPuppeteerTest(page, url);\n    success = success && result;\n    if (served404) {\n      console.log(`${file} failed: a background or content script requested a missing file.`);\n    }\n    if (page.receivedErrorOutput) {\n      console.log(`${file} failed: there was a page level error.`);\n      success = false;\n    }\n    // If we close the puppeteer page (tab) via page.close(), we can get innocuous but noisy output\n    // like this:\n    // net::ERR_ABORTED http://localhost:43524/pages/hud_page.html?dom_tests=true\n    // There's probably a way to prevent that, but as a work around, we avoid closing the page.\n    // browser.close() will close all of its owned pages.\n  }\n  // NOTE(philc): At one point in development, I noticed that the output from Deno would suddenly\n  // pause, prior to the tests fully finishing, so closing the browser here may be racy. If it\n  // occurs again, we may need to add \"await delay(200)\".\n  await browser.close();\n  await httpServer.shutdown();\n  if (served404 || !success) {\n    abort(\"test-dom failed.\");\n  }\n}\n\ndesc(\"Run DOM tests\");\ntask(\"test-dom\", [], testDom);\n\ndesc(\"Run unit and DOM tests\");\ntask(\"test\", [\"test-unit\", \"test-dom\"]);\n\ndesc(\"Builds a zip file for submission to the Chrome and Firefox stores. The output is in dist/\");\ntask(\"package\", [\"write-command-listing\"], async () => {\n  await buildStorePackage();\n});\n\ndesc(\"Build a static version of command_listing.html, to be hosted on vimium.gihub.io\");\ntask(\"write-command-listing\", [], async () => {\n  // Run this script in a separate shell so it doesn't pollute our JS environment.\n  await shell(\"./build_scripts/write_command_listing_page.js\", []);\n});\n\ndesc(\"Replaces manifest.json with a Firefox-compatible version, for development\");\ntask(\"write-firefox-manifest\", [], async () => {\n  const firefoxManifest = createFirefoxManifest(await parseManifestFile());\n  await Deno.writeTextFile(\"./manifest.json\", JSON.stringify(firefoxManifest, null, 2));\n});\n\nrun();\n"
  },
  {
    "path": "manifest.json",
    "content": "{\n  \"manifest_version\": 3,\n  \"name\": \"Vimium\",\n  \"version\": \"2.4.2\",\n  \"description\": \"The Hacker's Browser. Vimium provides keyboard shortcuts for navigation and control in the spirit of Vim.\",\n  \"icons\": {\n    \"16\": \"icons/icon16.png\",\n    \"48\": \"icons/icon48.png\",\n    \"128\": \"icons/icon128.png\"\n  },\n  \"minimum_chrome_version\": \"117.0\",\n  \"background\": {\n    \"service_worker\": \"background_scripts/main.js\",\n    \"type\": \"module\"\n    // Uncomment when developing in Firefox.\n    // \"scripts\": [\"background_scripts/main.js\"]\n  },\n  \"options_ui\": {\n    \"page\": \"pages/options.html\",\n    \"browser_style\": false,\n    \"open_in_tab\": true\n  },\n  \"host_permissions\": [\n    \"<all_urls>\"\n  ],\n  \"permissions\": [\n    \"tabs\",\n    \"bookmarks\",\n    \"history\",\n    \"storage\",\n    \"sessions\",\n    // Notifications are used to show a message when Vimium's major version has been upgraded.\n    \"notifications\",\n    // We're using the scripting permission to 1) inject our content scripts and CSS into existing\n    // tabs when Vimium is first installed, and 2) inject the user's link hints CSS when a page\n    // loads. This permission was required when moving to manifest V3.\n    \"scripting\",\n    \"favicon\", // The favicon permission is not yet supported by Firefox.\n    \"webNavigation\",\n    \"search\"\n  ],\n  \"content_scripts\": [\n    {\n      \"matches\": [\n        \"<all_urls>\"\n      ],\n      \"js\": [\n        \"lib/types.js\",\n        \"lib/utils.js\",\n        \"lib/keyboard_utils.js\",\n        \"lib/dom_utils.js\",\n        \"lib/rect.js\",\n        \"lib/handler_stack.js\",\n        \"lib/settings.js\",\n        \"lib/find_mode_history.js\",\n        \"content_scripts/mode.js\",\n        \"content_scripts/ui_component.js\",\n        \"content_scripts/link_hints.js\",\n        \"content_scripts/vomnibar.js\",\n        \"content_scripts/scroller.js\",\n        \"content_scripts/marks.js\",\n        \"content_scripts/mode_insert.js\",\n        \"content_scripts/mode_find.js\",\n        \"content_scripts/mode_key_handler.js\",\n        \"content_scripts/mode_visual.js\",\n        \"content_scripts/hud.js\",\n        \"content_scripts/mode_normal.js\",\n        \"content_scripts/vimium_frontend.js\"\n      ],\n      \"css\": [\n        \"content_scripts/vimium.css\"\n      ],\n      \"run_at\": \"document_start\",\n      \"all_frames\": true,\n      \"match_about_blank\": true\n    },\n    {\n      \"matches\": [\n        \"file:///\",\n        \"file:///*/\"\n      ],\n      \"css\": [\n        \"content_scripts/file_urls.css\"\n      ],\n      \"run_at\": \"document_start\",\n      \"all_frames\": true\n    }\n  ],\n  // Uncomment when developing in Firefox.\n  // \"browser_specific_settings\": {\n  //   \"gecko\": {\n  //     // This ID was generated by the Firefox store upon first submission.\n  //     \"id\": \"{d7742d87-e61d-4b78-b8a1-b469842139fa}\",\n  //     \"strict_min_version\": \"109.0\"\n  //   }\n  // },\n  \"action\": {\n    \"default_icon\": {\n      \"16\": \"icons/action_disabled_16.png\",\n      \"32\": \"icons/action_disabled_32.png\"\n    },\n    // Uncomment for Firefox.\n    // \"default_area\": \"navbar\",\n    \"default_popup\": \"pages/action.html\"\n  },\n  \"web_accessible_resources\": [\n    {\n      \"resources\": [\n        \"pages/vomnibar_page.html\",\n        \"content_scripts/vimium.css\",\n        \"pages/hud_page.html\",\n        \"pages/help_dialog_page.html\",\n        \"pages/doc_search_completion.html\",\n        \"pages/command_listing.html\",\n        \"resources/tlds.txt\",\n        // This allows one to script the reloading of Vimium.\n        // This should only be enabled in development.\n        // \"pages/reload.html\",\n        \"_favicon/*\"\n      ],\n      \"matches\": [\n        \"<all_urls>\"\n      ]\n    }\n  ]\n}\n"
  },
  {
    "path": "pages/action.css",
    "content": ":root {\n  --padding: 15px;\n}\n\nbody {\n  /* This will be the size of the toolbar action popup. */\n  width: 600px;\n  height: 300px;\n  display: flex;\n  flex-direction: column;\n}\n\nh1 {\n  font-size: 18px;\n}\n\n#dialog-body {\n  padding: var(--padding);\n  padding-right: 0;\n  display: flex;\n  flex-direction: column;\n  flex-grow: 1;\n}\n\n#not-enabled-error, #firefox-missing-permissions-error {\n  padding: var(--padding);\n}\n\n#dialog-body > * {\n  margin: 10px 0;\n}\n\n#dialog-body > *:first-child {\n  margin-top: 0;\n}\n\n#dialog-body > *:last-child {\n  margin-bottom: 0;\n}\n\n#how-many-enabled {\n  font-weight: bold;\n}\n\n#exclusion-scroll-box {\n  max-height: 140px;\n}\n\nfooter {\n  background-color: var(--vimium-foreground-color);\n  padding: var(--padding);\n  box-sizing: border-box;\n  display: flex;\n  align-items: center;\n  position: inherit;\n}\n\nfooter .options-message {\n  flex-grow: 1;\n}\n"
  },
  {
    "path": "pages/action.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\">\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"options.css\">\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"action.css\">\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"../content_scripts/vimium.css\">\n    <script src=\"action.js\" type=\"module\"></script>\n  </head>\n\n  <body class=\"vimium-body\">\n    <div id=\"not-enabled-error\" style=\"display: none\">\n      <h1>\n        Vimium is not allowed to run on this page.\n      </h1>\n      <p>\n        Your browser does not run web extensions like Vimium on certain pages, usually for security\n        reasons.\n      </p>\n    </div>\n\n    <div id=\"firefox-missing-permissions-error\" style=\"display: none\">\n      <h1>\n        Vimium is missing the \"all hosts\" permission.\n      </h1>\n      <p>\n        Firefox requires users to manually grant this permission to extensions. You can enable it\n        via the button below:\n      </p>\n\n      <p>\n        <button id=\"grant-hosts-permission\">Enable all hosts permission</button>\n      </p>\n\n      <p>\n        Or by navigating to:\n      </p>\n\n      <p>\n        about:addons > Vimium > Manage (click the 3 dot menu) > Permissions > <br> Enable the\n        \"Access your data for all websites\" permission.\n      </p>\n      <p>\n        <a href=\"https://github.com/philc/vimium/wiki/Permissions\">See here</a> for more info about\n        this permission.\n      </p>\n    </div>\n\n    <div id=\"dialog-body\">\n      <div>\n        <span id=\"how-many-enabled\">All</span> Vimium keys are enabled on this page.\n      </div>\n\n      <div id=\"add-first-rule-container\">\n        <button id=\"add-first-rule\">Exclude Vimium keys on this page</button>\n      </div>\n\n      <div id=\"exclusions-container\" style=\"display: none\">\n        <div id=\"exclusion-scroll-box\">\n          <table id=\"exclusion-rules\">\n            <thead>\n              <tr>\n                <td>\n                  <span class=\"exclusion-header-text\">Patterns matching the current page</span>\n                </td>\n                <td><span class=\"exclusion-header-text\">Keys to exclude</span></td>\n              </tr>\n            </thead>\n          </table>\n        </div>\n\n        <button id=\"exclusion-add-button\">Add rule</button>\n      </div>\n    </div>\n\n    <footer>\n      <div class=\"options-message\">\n        See all exclusion rules on the <a id=\"optionsLink\" target=\"_blank\">Options</a> page.\n      </div>\n      <div>\n        <button id=\"cancel\">Cancel</button>\n        <button id=\"save\" disabled=\"true\">No changes</button>\n      </div>\n    </footer>\n\n    <template id=\"exclusion-rule-template\">\n      <tr class=\"rule\">\n        <td>\n          <input type=\"text\" name=\"pattern\" spellcheck=\"false\" placeholder=\"URL pattern\" />\n          <div class=\"validationMessage\"></div>\n        </td>\n        <td>\n          <input type=\"text\" name=\"passKeys\" spellcheck=\"false\" placeholder=\"All\" />\n        </td>\n        <td>\n          <input type=\"button\" class=\"remove\" value=\"&#x2716;\" />\n        </td>\n      </tr>\n    </template>\n  </body>\n</html>\n"
  },
  {
    "path": "pages/action.js",
    "content": "import \"../lib/utils.js\";\nimport \"../lib/dom_utils.js\";\nimport \"../lib/settings.js\";\n\nimport * as bgUtils from \"../background_scripts/bg_utils.js\";\nimport { ExclusionRulesEditor } from \"./exclusion_rules_editor.js\";\n\nconst ActionPage = {\n  async init() {\n    // Is it possible for the current tab's URL to change while this action popup is open?\n    const tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n    const activeTab = tabs[0];\n    this.tabUrl = activeTab.url;\n\n    const hideUI = () => {\n      document.querySelector(\"#dialog-body\").style.display = \"none\";\n      document.querySelector(\"footer\").style.display = \"none\";\n    };\n\n    // In Firefox, prompt the user if they haven't enabled the \"all hosts\" permission. Vimium needs\n    // this permission to work correctly, and as of 2023-11-06, Firefox does not grant this\n    // permission without user consent, and doesn't make it clear that the user needs to do\n    // anything. See #4348 for discussion, and https://stackoverflow.com/q/76083327 for\n    // implementation notes.\n    const permission = { origins: [\"<all_urls>\"] };\n    if (bgUtils.isFirefox()) {\n      const hasAllHostsPermission = await browser.permissions.contains(permission);\n      if (!hasAllHostsPermission) {\n        hideUI();\n        document.querySelector(\"#grant-hosts-permission\").addEventListener(\"click\", async (e) => {\n          browser.permissions.request(permission);\n          // We close the action page because if the user clicks on this button once, clicks \"deny\"\n          // on the browser's permissions dialog, and then clicks on the button a second time, the\n          // browser permissions dialog will now be shown *under* the action page!\n          globalThis.close();\n        });\n        document.querySelector(\"#firefox-missing-permissions-error\").style.display = \"block\";\n        return;\n      }\n    }\n\n    if (!await this.isVimiumInstalledInTab(activeTab.id)) {\n      hideUI();\n      document.querySelector(\"#not-enabled-error\").style.display = \"block\";\n      return;\n    }\n\n    document.querySelector(\"#optionsLink\").href = chrome.runtime.getURL(\"pages/options.html\");\n\n    const saveButton = document.querySelector(\"#save\");\n    saveButton.addEventListener(\"click\", (e) => this.onSave());\n\n    document.querySelector(\"#cancel\").addEventListener(\"click\", () => globalThis.close());\n\n    const onUpdated = () => {\n      saveButton.disabled = false;\n      saveButton.textContent = \"Save changes\";\n      this.syncEnabledKeysCaption();\n      this.showValidationErrors();\n    };\n\n    const defaultPatternForNewRules = this.generateDefaultPattern(this.tabUrl);\n\n    document.querySelector(\"#add-first-rule\").addEventListener(\n      \"click\",\n      () => {\n        ExclusionRulesEditor.addRow(defaultPatternForNewRules);\n        this.showExclusionRulesEditor();\n        onUpdated();\n      },\n    );\n\n    ExclusionRulesEditor.defaultPatternForNewRules = defaultPatternForNewRules;\n    ExclusionRulesEditor.init();\n    ExclusionRulesEditor.addEventListener(\"input\", onUpdated);\n    const rules = Settings.get(\"exclusionRules\").filter((r) =>\n      this.tabUrl.match(this.getPatternRegExp(r.pattern))\n    );\n    ExclusionRulesEditor.setForm(rules);\n    this.syncEnabledKeysCaption();\n\n    if (rules.length > 0) this.showExclusionRulesEditor();\n  },\n\n  async isVimiumInstalledInTab(tabId) {\n    try {\n      // There is no handler in our content script for this message, but that's OK. We just want to\n      // see if sending any message triggers an error.\n      await chrome.tabs.sendMessage(tabId, { handler: \"isVimiumInstalledInTab\" });\n      return true;\n    } catch {\n      // If there's no content script running in the activeTab, we'll get a connection error.\n      return false;\n    }\n  },\n\n  showValidationErrors() {\n    const rows = document.querySelectorAll(\".rule\");\n    for (const row of rows) {\n      const pattern = row.querySelector(\"input[name=pattern]\").value;\n      const regExp = this.getPatternRegExp(pattern);\n      const validationEl = row.querySelector(\".validationMessage\");\n      const patternMatchesUrl = this.tabUrl.match(regExp);\n      if (patternMatchesUrl) {\n        row.classList.remove(\"validationError\");\n        validationEl.textContent = \"\";\n      } else {\n        row.classList.add(\"validationError\");\n        validationEl.textContent = \"Pattern does not match the current URL\";\n      }\n    }\n  },\n\n  showExclusionRulesEditor() {\n    document.querySelector(\"#exclusions-container\").style.display = \"block\";\n    document.querySelector(\"#add-first-rule-container\").style.display = \"none\";\n  },\n\n  syncEnabledKeysCaption() {\n    let caption = \"All\";\n    const rules = ExclusionRulesEditor.getRules();\n    if (rules.length > 0) {\n      const hasBlankPassKeysRule = rules.find((r) => r.passKeys.length == 0);\n      caption = hasBlankPassKeysRule ? \"No\" : \"Some\";\n    }\n    document.querySelector(\"#how-many-enabled\").textContent = caption;\n  },\n\n  async onSave() {\n    let rules = await Settings.get(\"exclusionRules\");\n    // Remove any rules which match the current URL, and replace them with the contents of this dialog.\n    rules = rules.filter((r) => !this.tabUrl.match(this.getPatternRegExp(r.pattern)));\n    rules = rules.concat(ExclusionRulesEditor.getRules());\n    Settings.set(\"exclusionRules\", rules);\n    const el = document.querySelector(\"#save\");\n    el.disabled = true;\n    el.textContent = \"Saved\";\n  },\n\n  getPatternRegExp(patternStr) {\n    return new RegExp(\"^\" + patternStr.replace(/\\*/g, \".*\") + \"$\");\n  },\n\n  // Returns an exclusion pattern which matches the domain of the given URL.\n  // This is used as the default starter pattern when the \"Add rule\" button is clicked.\n  generateDefaultPattern(url) {\n    if (/^https?:\\/\\/./.test(url)) {\n      // The common use case is to disable Vimium at the domain level.\n      // Generate \"https?://www.example.com/*\" from \"http://www.example.com/path/to/page.html\".\n      // Note: IPV6 host addresses will contain \"[\" and \"]\" (which must be escaped).\n      const hostname = url.split(\"/\", 3).slice(1).join(\"/\").replace(\"[\", \"\\\\[\").replace(\n        \"]\",\n        \"\\\\]\",\n      );\n      return \"https?:/\" + hostname + \"/*\";\n    } else if (/^[a-z]{3,}:\\/\\/./.test(url)) {\n      // Anything else which seems to be a URL.\n      return url.split(\"/\", 3).join(\"/\") + \"/*\";\n    } else {\n      return url + \"*\";\n    }\n  },\n};\n\ndocument.addEventListener(\"DOMContentLoaded\", async () => {\n  await Settings.onLoaded();\n  ActionPage.init();\n});\n"
  },
  {
    "path": "pages/all_content_scripts.js",
    "content": "// This is the set of all content scripts required to make Vimium's functionality work. This file is\n// imported by background pages that we want to work with Vimium's key mappings, e.g. the options\n// page. This should be the same list of files as in manifest.js's content_scripts section.\n\nimport \"../lib/types.js\";\nimport \"../lib/utils.js\";\nimport \"../lib/url_utils.js\";\nimport \"../lib/keyboard_utils.js\";\nimport \"../lib/dom_utils.js\";\nimport \"../lib/rect.js\";\nimport \"../lib/handler_stack.js\";\nimport \"../lib/settings.js\";\nimport \"../lib/find_mode_history.js\";\n\nimport \"../content_scripts/mode.js\";\nimport \"../content_scripts/ui_component.js\";\nimport \"../content_scripts/link_hints.js\";\nimport \"../content_scripts/vomnibar.js\";\nimport \"../content_scripts/scroller.js\";\nimport \"../content_scripts/marks.js\";\nimport \"../content_scripts/mode_insert.js\";\nimport \"../content_scripts/mode_find.js\";\nimport \"../content_scripts/mode_key_handler.js\";\nimport \"../content_scripts/mode_visual.js\";\nimport \"../content_scripts/hud.js\";\nimport \"../content_scripts/mode_normal.js\";\nimport \"../content_scripts/vimium_frontend.js\";\n"
  },
  {
    "path": "pages/command_listing.css",
    "content": ":root {\n  --border-color: #666;\n}\n\nhtml, body {\n  font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n  font-size: 14px;\n  margin: 0;\n  padding: 0;\n}\n\nbody {\n  display: grid;\n  grid-template-columns: 200px auto;\n}\n\nnav {\n  border-right: 1px solid var(--border-color);\n  padding-top: 20px;\n}\n\nnav ul {\n  position: fixed;\n  margin: 0;\n  padding-left: 20px;\n}\n\nnav ul li {\n  list-style-type: none;\n  margin: 8px 0;\n}\n\nheader {\n  font-size: 18px;\n  border-bottom: 1px solid var(--border-color);\n  padding: 20px 20px;\n  grid-column: span 2;\n  /* These styles are necessary to show the Github logo when this page is hosted externally. */\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n  justify-content: space-between;\n}\n\n#github-link {\n  display: none;\n}\n\nmain {\n  margin-top: 10px;\n}\n\nh2 {\n  font-size: 20px;\n  background-color: var(--vimium-foreground-color);\n  padding: 20px 20px;\n}\n\n.command {\n  max-width: 900px;\n  padding: 1px 10px;\n  margin: 10px 0;\n  box-sizing: border-box;\n}\n\nh3 {\n  font-size: 18px;\n  margin: 6px 0;\n  font-weight: normal;\n  display: flex;\n  justify-items: space-between;\n}\n\nh3 code {\n  background-color: rgb(243, 243, 243);\n  padding: 4px 6px;\n  /* This pushes the key bindings to the right of the container. */\n  margin-right: auto;\n}\n\np.desc, p.details {\n  margin-left: 1rem;\n}\n\n.options {\n  margin-left: 1rem;\n}\n\n.key {\n  font-size: 14px;\n}\n\n@media (prefers-color-scheme: dark) {\n  h3 code {\n    background-color: var(--vimium-foreground-color);\n  }\n}\n\n/*\n * There are two versions of this page: one served as an extension page, and one generated by the\n * build process and hosted on the vimium.github.io website. The link back to Github is not shown in\n * the version served as an extension page.\n */\n\n.hosted-version #github-link {\n  display: block;\n}\n\n.hosted-version #github-link img#github {\n  width: 40px;\n}\n\n@media (prefers-color-scheme: dark) {\n  .hosted-version #github-link img#github {\n    filter: invert(1);\n  }\n}\n"
  },
  {
    "path": "pages/command_listing.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"color-scheme\" content=\"light dark\">\n    <title>Vimium Commands</title>\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"../content_scripts/vimium.css\" />\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"key_mappings.css\" />\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"command_listing.css\" />\n\n    <script src=\"command_listing.js\" type=\"module\"></script>\n  </head>\n\n  <body class=\"vimium-body\">\n    <header>\n      Vimium Commands\n      <a id=\"github-link\" href=\"https://www.github.com/philc/vimium\">\n        <img id=\"github\" src=\"../github.svg\">\n      </a>\n    </header>\n    <nav>\n      <ul>\n        <li><a href=\"#navigation\">Navigating the page</a></li>\n        <li><a href=\"#find\">Using find</a></li>\n        <li><a href=\"#history\">Navigating history</a></li>\n        <li><a href=\"#tabs\">Manipulating tabs</a></li>\n        <li><a href=\"#misc\">Miscellaneous</a></li>\n      </ul>\n    </nav>\n\n    <main>\n      <h2 id=\"navigation\" data-group=\"navigation\">Navigating the page</h2>\n      <h2 id=\"vomnibar\" data-group=\"vomnibar\">Using the vomnibar</h2>\n      <h2 id=\"find\" data-group=\"find\">Using find</h2>\n      <h2 id=\"history\" data-group=\"history\">Navigating history</h2>\n      <h2 id=\"tabs\" data-group=\"tabs\">Manipulating tabs</h2>\n      <h2 id=\"misc\" data-group=\"misc\">Miscellaneous</h2>\n    </main>\n\n    <template id=\"command\">\n      <div class=\"command\" id=\"example-command-name\">\n        <h3>\n          <code>an-example-command</code>\n          <span class=\"key-bindings\"></span>\n        </h3>\n        <p class=\"desc\">Example description.</p>\n        <p class=\"details\"></p>\n        <div class=\"options\">\n          <p><strong>Options</strong></p>\n          <ul></ul>\n        </div>\n      </div>\n    </template>\n\n    <template id=\"keys\">\n      <div class=\"key-block\">\n        <span class=\"key\"></span><span class=\"comma\">,</span>\n      </div>\n    </template>\n  </body>\n</html>\n"
  },
  {
    "path": "pages/command_listing.js",
    "content": "import \"./all_content_scripts.js\";\nimport { allCommands } from \"../background_scripts/all_commands.js\";\n\n// The ordering we show key bindings is alphanumerical, except that special keys sort to the end.\nfunction compareKeys(a, b) {\n  a = a.replace(\"<\", \"~\");\n  b = b.replace(\"<\", \"~\");\n  if (a < b) {\n    return -1;\n  } else if (b < a) {\n    return 1;\n  } else {\n    return 0;\n  }\n}\n\nfunction replaceBackticksWithCodeTags(str) {\n  let count = 0;\n  return str.replace(/`/g, (match) => {\n    count++;\n    return count % 2 === 1 ? \"<code>\" : \"</code>\";\n  });\n}\n\nasync function populatePage() {\n  const h2s = document.querySelectorAll(\"h2\");\n  const byGroup = Object.groupBy(allCommands, (el) => el.group);\n  const commandToOptionsToKeys =\n    (await chrome.storage.session.get(\"commandToOptionsToKeys\")).commandToOptionsToKeys;\n\n  const commandTemplate = document.querySelector(\"template#command\").content;\n  const keysTemplate = document.querySelector(\"template#keys\").content;\n\n  for (const h2 of Array.from(h2s)) {\n    const group = h2.dataset[\"group\"];\n    let commands = byGroup[group];\n    // Display them in alphabetical order.\n    commands = commands.sort((a, b) => b.name.localeCompare(a.name));\n    for (const command of commands) {\n      // Here, we're going to list all of the keys bound to this command, and for now, we're not\n      // going to visually distinguish versions of the command with options and versions without.\n      const keys = Object.values(commandToOptionsToKeys[command.name] || {})\n        .flat(1);\n      const el = commandTemplate.cloneNode(true);\n      // Used for linking to commands using the URL fragment, and by the tests.\n      el.querySelector(\".command\").id = command.name;\n      el.querySelector(\"h3 code\").textContent = command.name;\n\n      const keysEl = el.querySelector(\".key-bindings\");\n      for (const key of keys.sort(compareKeys)) {\n        const node = keysTemplate.cloneNode(true);\n        node.querySelector(\".key\").textContent = key;\n        keysEl.appendChild(node);\n      }\n\n      el.querySelector(\".desc\").textContent = command.desc;\n      if (command.details) {\n        el.querySelector(\".details\").textContent = command.details;\n      }\n\n      if (command.options) {\n        const ul = el.querySelector(\".options ul\");\n        for (const [name, desc] of Object.entries(command.options)) {\n          const li = document.createElement(\"li\");\n          li.innerHTML = `<code>${name}</code>: ` + replaceBackticksWithCodeTags(desc);\n          ul.appendChild(li);\n        }\n      } else {\n        el.querySelector(\".options\").remove();\n      }\n      h2.after(el);\n    }\n  }\n}\n\nconst testEnv = globalThis.window == null;\nif (!testEnv) {\n  document.addEventListener(\"DOMContentLoaded\", async () => {\n    await Settings.onLoaded();\n    DomUtils.injectUserCss();\n    await populatePage();\n  });\n}\n\nexport { populatePage };\n"
  },
  {
    "path": "pages/doc.css",
    "content": "body.documentation {\n  max-width: 730px;\n  margin: 0 25px;\n}\n\nul {\n  padding-left: 20px;\n}\n\nli {\n  margin: 10px;\n}\n\np {\n  line-height: 1.5em;\n}\n"
  },
  {
    "path": "pages/doc_search_completion.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\">\n    <meta name=\"color-scheme\" content=\"light dark\">\n    <title>Vimium Search Completion</title>\n    <!-- We re-use some styling from the options page, so that the look and feel here is similar -->\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"options.css\">\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"doc.css\">\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"../content_scripts/vimium.css\" />\n    <style>\n      div.engine {\n        margin-left: 20px;\n      }\n    </style>\n    <script src=\"doc_search_completion.js\" type=\"module\"></script>\n  </head>\n\n  <body class=\"vimium-body documentation\">\n    <header>Vimium Search Completion</header>\n    <p>\n      Search completion is available for custom search engines whose search URL matches one of\n      Vimium's built-in completion engines; that is, the search URL matches one of the regular\n      expressions below. Search completion is not available for the default search engine.\n    </p>\n    <p>\n      Custom search engines can be configured on the <a href=\"options.html\" target=\"_blank\"\n      >options</a>\n      page. <br>\n      Further information is available on the <a\n        href=\"https://github.com/philc/vimium/wiki/Search-Completion\"\n        target=\"_blank\"\n      >wiki</a>.\n    </p>\n    <header>Available Completion Engines</header>\n    <p>\n      Search completion is available in this version of Vimium for the following custom search\n      engines.\n    </p>\n    <p>\n      <dl id=\"engine-list\"></dl>\n    </p>\n\n    <template id=\"engine-template\">\n      <h4 data-engine=\"the-engine\">Engine name</h4>\n      <div class=\"engine\">\n        <p class=\"explanation\"></p>\n        <div class=\"engine-example\">\n          <p>Example:</p>\n          <pre></pre>\n        </div>\n        <div class=\"regexps\">\n          <p>Regular expressions</p>\n          <pre></pre>\n        </div>\n      </div>\n    </template>\n  </body>\n</html>\n"
  },
  {
    "path": "pages/doc_search_completion.js",
    "content": "import \"./all_content_scripts.js\";\nimport * as completionEngines from \"../background_scripts/completion/search_engines.js\";\n\nfunction cleanUpRegexp(re) {\n  return re.toString()\n    .replace(/^\\//, \"\")\n    .replace(/\\/$/, \"\")\n    .replace(/\\\\\\//g, \"/\");\n}\n\nexport function populatePage() {\n  const template = document.querySelector(\"#engine-template\").content;\n  for (const engineClass of completionEngines.list) {\n    const el = template.cloneNode(true);\n    const engine = new engineClass();\n    const h4 = el.querySelector(\"h4\");\n    h4.textContent = engine.constructor.name;\n    // This data attribute is used in tests.\n    h4.dataset.engine = engine.constructor.name;\n    const explanationEl = el.querySelector(\".explanation\");\n    if (engine.example.explanation) {\n      explanationEl.textContent = engine.example.explanation;\n    } else {\n      explanationEl.remove();\n    }\n\n    const exampleEl = el.querySelector(\".engine-example\");\n    if (engine.example.searchUrl && engine.example.keyword) {\n      const desc = engine.example.description || engine.constructor.name;\n      exampleEl.querySelector(\"pre\").textContent =\n        `${engine.example.keyword}: ${engine.example.searchUrl} ${desc}`;\n    } else {\n      exampleEl.remove();\n    }\n\n    const regexpsEl = el.querySelector(\".regexps\");\n    if (engine.regexps) {\n      let content = \"\";\n      for (const re of engine.regexps) {\n        content += `${cleanUpRegexp(re)}\\n`;\n      }\n      regexpsEl.querySelector(\"pre\").textContent = content;\n    } else {\n      regexpsEl.remove();\n    }\n    document.querySelector(\"#engine-list\").appendChild(el);\n  }\n}\n\nconst testEnv = globalThis.window == null;\nif (!testEnv) {\n  document.addEventListener(\"DOMContentLoaded\", populatePage);\n}\n"
  },
  {
    "path": "pages/exclusion_rules_editor.js",
    "content": "// The table-editor used for exclusion rules.\nconst ExclusionRulesEditor = {\n  // When the Add rule button is clicked, use this as the pattern for the new rule. This is used by\n  // the action.html toolbar popup.\n  defaultPatternForNewRules: null,\n\n  init() {\n    document.querySelector(\"#exclusion-add-button\").addEventListener(\"click\", () => {\n      this.addRow(this.defaultPatternForNewRules);\n      this.dispatchEvent(\"input\");\n    });\n  },\n\n  // - exclusionRules: the value obtained from settings, with the shape [{pattern, passKeys}].\n  setForm(exclusionRules = []) {\n    const rulesTable = document.querySelector(\"#exclusion-rules\");\n    // Remove any previous rows.\n    const existingRuleEls = rulesTable.querySelectorAll(\".rule\");\n    for (const el of existingRuleEls) {\n      el.remove();\n    }\n\n    const rowTemplate = document.querySelector(\"#exclusion-rule-template\").content;\n    for (const rule of exclusionRules) {\n      this.addRow(rule.pattern, rule.passKeys);\n    }\n  },\n\n  // `pattern` and `passKeys` are optional.\n  addRow(pattern, passKeys) {\n    const rulesTable = document.querySelector(\"#exclusion-rules\");\n    const rowTemplate = document.querySelector(\"#exclusion-rule-template\").content;\n    const rowEl = rowTemplate.cloneNode(true);\n\n    const patternEl = rowEl.querySelector(\"[name=pattern]\");\n    patternEl.value = pattern ?? \"\";\n    patternEl.addEventListener(\"input\", () => this.dispatchEvent(\"input\"));\n\n    const keysEl = rowEl.querySelector(\"[name=passKeys]\");\n    keysEl.value = passKeys ?? \"\";\n    keysEl.addEventListener(\"input\", () => this.dispatchEvent(\"input\"));\n\n    rowEl.querySelector(\".remove\").addEventListener(\"click\", (e) => {\n      e.target.closest(\"tr\").remove();\n      this.dispatchEvent(\"input\");\n    });\n    rulesTable.appendChild(rowEl);\n  },\n\n  // Returns an array of rules, which can be stored in Settings.\n  getRules() {\n    const rows = Array.from(document.querySelectorAll(\"#exclusion-rules tr.rule\"));\n    const rules = rows\n      .map((el) => {\n        return {\n          // The ordering of these keys should match the order in defaultOptions in Settings.js.\n          passKeys: el.querySelector(\"[name=passKeys]\").value.trim(),\n          pattern: el.querySelector(\"[name=pattern]\").value.trim(),\n        };\n      })\n      // Exclude blank patterns.\n      .filter((rule) => rule.pattern);\n    return rules;\n  },\n};\n\nObject.assign(ExclusionRulesEditor, EventDispatcher);\n\nexport { ExclusionRulesEditor };\n"
  },
  {
    "path": "pages/help_dialog_page.css",
    "content": "body {\n  font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n}\n\n#container {\n  background-color: white;\n  border: 2px solid #b3b3b3;\n  border-radius: 6px;\n  width: 840px;\n  max-width: calc(100% - 100px);\n  max-height: calc(100% - 100px);\n  margin: 50px auto;\n  overflow-y: auto;\n  overflow-x: auto;\n}\n\n#dialog {\n  min-width: 600px;\n  padding: 8px 12px;\n}\n\na {\n  text-decoration: underline;\n  color: #2f508e;\n  cursor: pointer;\n}\n\nheader {\n  display: flex;\n  flex-direction: row;\n  align-items: center;\n}\n\na#close {\n  font-family: \"courier new\", monospace;\n  font-weight: bold;\n  color: #555;\n  text-decoration: none;\n  font-size: 24px;\n  position: relative;\n  top: 3px;\n  padding-left: 5px;\n  cursor: pointer;\n}\n\na#close:hover {\n  color: black;\n  -webkit-user-select: none;\n}\n\nh1 {\n  font-size: 20px;\n  white-space: nowrap;\n  flex-grow: 1;\n  font-weight: normal;\n  margin: 4px 0;\n}\n\nh1 .vim {\n  color: #2f508e;\n}\n\nheader a {\n  font-size: 14px;\n  padding-left: 5px;\n  padding-right: 5px;\n}\n\nheader a.close {\n  padding-right: 0;\n}\n\n#commands-section {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n}\n\n.column {\n  display: grid;\n  grid-template-columns: auto 1fr;\n  row-gap: 3px;\n}\n\nh2 {\n  margin-top: 3px;\n  margin-bottom: 4px;\n  font-size: 16px;\n  font-weight: bold;\n}\n\ndiv[data-group] {\n  display: contents;\n}\n\n.row {\n  display: contents;\n}\n\n.help-description {\n  font-size: 14px;\n}\n\ndiv.divider {\n  height: 1px;\n  width: 100%;\n  margin: 10px auto;\n  background-color: #9a9a9a;\n}\n\n/* Advanced commands are hidden by default until \"show advanced\" is clicked. */\n.row.advanced {\n  display: none;\n}\n\n#dialog.show-advanced .row.advanced {\n  display: contents;\n}\n\nfooter {\n  font-size: 10px;\n  display: flex;\n  justify-content: space-between;\n}\n\n.version-info {\n  text-align: right;\n}\n\n#toggle-advanced {\n  text-align: right;\n  font-size: 10px;\n}\n\n/* Dark Mode CSS for Help Dialog */\n@media (prefers-color-scheme: dark) {\n  #container {\n    border-color: rgba(255, 255, 255, 0.1);\n    background-color: #202124;\n  }\n\n  #dialog {\n    background-color: var(--vimium-background-color);\n    color: var(--vimium-background-text-color);\n  }\n\n  a {\n    color: var(--vimium-link-color);\n  }\n\n  h1,\n  h2 {\n    color: white;\n  }\n\n  h1 .vim {\n    color: var(--vimium-link-color);\n  }\n\n  div.divider {\n    background-color: rgba(255, 255, 255, 0.1);\n  }\n\n  .help-description {\n    /* Use a fainter color than --vimium-background-text-color, so the dialog text doesn't get\n       overwhelming. */\n    color: #c9cccf;\n  }\n}\n"
  },
  {
    "path": "pages/help_dialog_page.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <title>Vimium Help</title>\n    <meta name=\"color-scheme\" content=\"light dark\">\n    <meta charset=\"UTF-8\">\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"../content_scripts/vimium.css\" />\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"./key_mappings.css\" />\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"./help_dialog_page.css\" />\n\n    <script src=\"help_dialog_page.js\" type=\"module\"></script>\n  </head>\n\n  <body>\n    <!-- Note that the command placeholders (data-group=navigation) will be replaced by the\n         background page with the user's key bindings when the dialog is shown. -->\n    <div id=\"container\">\n      <div id=\"dialog\">\n        <header>\n          <h1>\n            <span class=\"vim\">Vim</span>ium Help\n            <span id=\"help-dialog-title\"></span>\n          </h1>\n\n          <!-- These buttons are wrapped in a div so they vertically center more evenly. -->\n          <div>\n            <a id=\"options-page\" href=\"#\">Options</a>\n            <a href=\"https://github.com/philc/vimium/wiki\" target=\"_blank\">Wiki</a>\n            <a id=\"close\" href=\"#\">&times;</a>\n          </div>\n        </header>\n\n        <div class=\"divider\"></div>\n\n        <div id=\"commands-section\">\n          <div class=\"column\">\n            <div></div>\n            <h2>Navigating the page</h2>\n            <div data-group=\"navigation\"></div>\n          </div>\n\n          <div class=\"column\">\n            <div></div>\n            <h2>Using the Vomnibar</h2>\n            <div data-group=\"vomnibar\"></div>\n\n            <div></div>\n            <h2>Using find</h2>\n            <div data-group=\"find\"></div>\n\n            <div></div>\n            <h2>Navigating history</h2>\n            <div data-group=\"history\"></div>\n\n            <div></div>\n            <h2>Manipulating tabs</h2>\n            <div data-group=\"tabs\"></div>\n\n            <div></div>\n            <h2>Miscellaneous</h2>\n            <div data-group=\"misc\"></div>\n          </div>\n        </div>\n\n        <div id=\"toggle-advanced\">\n          <a href=\"#\">Show advanced commands</a>\n        </div>\n\n        <div class=\"divider\"></div>\n\n        <footer>\n          <div>\n            Enjoying Vimium?\n            <a\n              target=\"_blank\"\n              href=\"https://chrome.google.com/webstore/detail/vimium/dbepggeogbaibhgnhhndojpepiihcmeb/reviews\"\n            >Leave us feedback</a>.<br>\n            Found a bug? <a\n              target=\"_blank\"\n              href=\"https://github.com/philc/vimium/issues\"\n            >Report it here</a>.\n          </div>\n\n          <div class=\"version-info\">\n            Version <span id=\"vimium-version\"></span>\n            <br>\n            <a\n              href=\"https://github.com/philc/vimium/blob/master/CHANGELOG.md\"\n              target=\"_blank\"\n            >What's new?</a>\n          </div>\n        </footer>\n      </div>\n    </div>\n\n    <template id=\"row\">\n      <div class=\"row\">\n        <div class=\"key-bindings\"></div>\n        <div class=\"help-description\"></div>\n      </div>\n    </template>\n\n    <template id=\"keys\">\n      <div class=\"key-block\">\n        <span class=\"key\"></span><span class=\"comma\">,</span>\n      </div>\n    </template>\n  </body>\n</html>\n"
  },
  {
    "path": "pages/help_dialog_page.js",
    "content": "import \"./all_content_scripts.js\";\nimport * as UIComponentMessenger from \"./ui_component_messenger.js\";\nimport { allCommands } from \"../background_scripts/all_commands.js\";\n\n// The ordering we show key bindings is alphanumerical, except that special keys sort to the end.\nfunction compareKeys(a, b) {\n  a = a.replace(\"<\", \"~\");\n  b = b.replace(\"<\", \"~\");\n  if (a < b) {\n    return -1;\n  } else if (b < a) {\n    return 1;\n  } else {\n    return 0;\n  }\n}\n\nconst ellipsis = \"...\";\n// Truncates `s` and appends an ellipsis if `s` is longer than maxLength.\nfunction ellipsize(s, maxLength) {\n  if (s.length <= maxLength) return s;\n  return s.substring(0, Math.max(0, maxLength - ellipsis.length)) + ellipsis;\n}\n\n// Returns true if the command should be labeled as \"advanced\" for UI purposes.\nfunction isAdvancedCommand(command, options) {\n  // Use some bespoke logic to label some command + option combos as advanced.\n  return command.advanced ||\n    (command.name == \"reload\" && options.includes(\"hard\"));\n}\n\nconst HelpDialogPage = {\n  dialogElement: null,\n\n  // This setting is pulled out of local storage. It's false by default.\n  getShowAdvancedCommands() {\n    return Settings.get(\"helpDialog_showAdvancedCommands\");\n  },\n\n  init() {\n    if (this.dialogElement != null) {\n      return;\n    }\n    this.dialogElement = document.querySelector(\"#dialog\");\n\n    const closeButton = this.dialogElement.querySelector(\"#close\");\n    closeButton.addEventListener(\"click\", (event) => {\n      event.preventDefault();\n      this.hide();\n    }, false);\n\n    // \"auxclick\" handles a click with the middle mouse button.\n    const optionsLink = document.querySelector(\"#options-page\");\n    for (const eventName of [\"click\", \"auxclick\"]) {\n      optionsLink.addEventListener(eventName, (event) => {\n        event.preventDefault();\n        chrome.runtime.sendMessage({ handler: \"openOptionsPageInNewTab\" });\n      }, false);\n    }\n\n    document.querySelector(\"#toggle-advanced a\").addEventListener(\n      \"click\",\n      HelpDialogPage.toggleAdvancedCommands.bind(HelpDialogPage),\n      false,\n    );\n\n    document.documentElement.addEventListener(\"click\", (event) => {\n      if (!this.dialogElement.contains(event.target)) {\n        this.hide();\n      }\n    }, false);\n  },\n\n  // Returns the rows to show in the help dialog, grouped by command group.\n  // Returns: { group: [[command, args, keys], ...], ... }\n  getRowsForDialog(commandToOptionsToKeys) {\n    const result = {};\n    const byGroup = Object.groupBy(allCommands, (o) => o.group);\n    for (const [group, commands] of Object.entries(byGroup)) {\n      const list = [];\n      for (const command of commands) {\n        // Note that commands which are unbound won't be present in this data structure, and that's\n        // desired; we don't want to show unbound commands in the help dialog.\n        const variations = commandToOptionsToKeys[command.name] || {};\n        for (const [options, keys] of Object.entries(variations)) {\n          list.push([command, options, keys]);\n        }\n      }\n      result[group] = list;\n    }\n    return result;\n  },\n\n  getRowEl(command, options, keys) {\n    const rowTemplate = document.querySelector(\"template#row\").content;\n    const keysTemplate = document.querySelector(\"template#keys\").content;\n\n    const rowEl = rowTemplate.cloneNode(true);\n    rowEl.querySelector(\".help-description\").textContent = command.desc;\n    if (isAdvancedCommand(command, options)) {\n      rowEl.querySelector(\".row\").classList.add(\"advanced\");\n    }\n    const keysEl = rowEl.querySelector(\".key-bindings\");\n    for (const key of keys.sort(compareKeys)) {\n      const node = keysTemplate.cloneNode(true);\n      node.querySelector(\".key\").textContent = key;\n      keysEl.appendChild(node);\n    }\n\n    const maxLength = 40;\n    const descEl = rowEl.querySelector(\".help-description\");\n    let desc = command.desc;\n    if (options != \"\") {\n      const optionsString = ellipsize(options, maxLength - command.desc.length);\n      desc += ` (${optionsString})`;\n      const isTruncated = optionsString != options;\n      if (isTruncated) {\n        // Show the full option string on hover.\n        descEl.title = `${command.desc} (${options})`;\n      }\n    }\n    descEl.textContent = desc;\n    return rowEl;\n  },\n\n  async show() {\n    document.getElementById(\"vimium-version\").textContent = Utils.getCurrentVersion();\n\n    const commandToOptionsToKeys =\n      (await chrome.storage.session.get(\"commandToOptionsToKeys\")).commandToOptionsToKeys;\n    const rowsByGroup = this.getRowsForDialog(commandToOptionsToKeys);\n\n    for (const [group, rows] of Object.entries(rowsByGroup)) {\n      const container = this.dialogElement.querySelector(`[data-group=\"${group}\"]`);\n      container.innerHTML = \"\";\n      for (const [command, options, keys] of rows) {\n        const el = this.getRowEl(command, options, keys);\n        container.appendChild(el);\n      }\n    }\n\n    this.showAdvancedCommands(this.getShowAdvancedCommands());\n\n    // \"Click\" the dialog element (so that it becomes scrollable).\n    DomUtils.simulateClick(this.dialogElement);\n  },\n\n  hide() {\n    UIComponentMessenger.postMessage({ name: \"hide\" });\n  },\n\n  //\n  // Advanced commands are hidden by default so they don't overwhelm new and casual users.\n  //\n  toggleAdvancedCommands(event) {\n    const container = document.querySelector(\"#container\");\n    const scrollHeightBefore = container.scrollHeight;\n    event.preventDefault();\n    const showAdvanced = HelpDialogPage.getShowAdvancedCommands();\n    HelpDialogPage.showAdvancedCommands(!showAdvanced);\n    Settings.set(\"helpDialog_showAdvancedCommands\", !showAdvanced);\n    // Try to keep the \"show advanced commands\" button in the same scroll position.\n    const scrollHeightDelta = container.scrollHeight - scrollHeightBefore;\n    if (scrollHeightDelta > 0) {\n      container.scrollTop += scrollHeightDelta;\n    }\n  },\n\n  showAdvancedCommands(visible) {\n    const caption = visible ? \"Hide advanced commands\" : \"Show advanced commands\";\n    document.querySelector(\"#toggle-advanced a\").textContent = caption;\n    if (visible) {\n      HelpDialogPage.dialogElement.classList.add(\"show-advanced\");\n    } else {\n      HelpDialogPage.dialogElement.classList.remove(\"show-advanced\");\n    }\n  },\n};\n\nfunction init() {\n  UIComponentMessenger.init();\n  UIComponentMessenger.registerHandler(async function (event) {\n    await Settings.onLoaded();\n    await Utils.populateBrowserInfo();\n    switch (event.data.name) {\n      case \"hide\":\n        HelpDialogPage.hide();\n        break;\n      case \"show\":\n        HelpDialogPage.init();\n        await HelpDialogPage.show(event.data);\n        // If we abandoned (see below) in a mode with a HUD indicator, then we have to reinstate it.\n        Mode.setIndicator();\n        break;\n      case \"hidden\":\n        // Abandon any HUD which might be showing within the help dialog.\n        HUD.abandon();\n        break;\n      default:\n        Utils.assert(false, \"Unrecognized message type.\", event.data);\n    }\n  });\n}\n\nglobalThis.HelpDialogPage = HelpDialogPage;\nglobalThis.isVimiumHelpDialogPage = true;\n\nconst testEnv = globalThis.window == null;\nif (!testEnv) {\n  document.addEventListener(\"DOMContentLoaded\", async () => {\n    await Settings.onLoaded();\n    DomUtils.injectUserCss(); // Manually inject custom user styles.\n  });\n  init();\n}\n\nexport { HelpDialogPage };\n"
  },
  {
    "path": "pages/hud_page.css",
    "content": "#hud-container {\n  display: block;\n  position: fixed;\n  width: calc(100% - 20px);\n  bottom: 8px;\n  left: 8px;\n  background-color: var(--vimium-foreground-color);\n  color: var(--vimium-foreground-text-color);\n  text-align: left;\n  border-radius: 4px;\n  box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8);\n  border: 1px solid #aaa;\n  z-index: 2147483647;\n  font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n}\n\n#search-area {\n  display: block;\n  padding: 3px;\n  color: var(--vimium-foreground-text-color);\n  border-radius: 4px 4px 0 0;\n}\n\n#hud {\n  font-size: 14px;\n  height: 30px;\n  margin-bottom: 0;\n  padding: 2px 4px;\n  border-radius: 3px;\n  width: 100%;\n  outline: none;\n  box-sizing: border-box;\n  line-height: 20px;\n}\n\nspan#hud-find-input, span#hud-match-count {\n  display: inline;\n  outline: none;\n  white-space: nowrap;\n  overflow-y: hidden;\n}\n\nspan#hud-find-input:before {\n  content: \"/\";\n}\n\nspan#hud-match-count {\n  color: #aaa;\n  font-size: 12px;\n}\n\nspan#hud-find-input br {\n  display: none;\n}\n\nspan#hud-find-input * {\n  display: inline;\n  white-space: nowrap;\n}\n\n@media (prefers-color-scheme: light) {\n  #hud-body {\n    background-color: #f1f1f1;\n  }\n\n  /* This creates a border around the area where you type, which is nice effect on the light color\n   * scheme. It's hard to make this effect visible in dark mode, so we don't use it. */\n  #hud.hud-find {\n    background-color: white;\n    border: 1px solid #ccc;\n  }\n}\n"
  },
  {
    "path": "pages/hud_page.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <title>HUD</title>\n    <meta name=\"color-scheme\" content=\"light dark\">\n    <meta charset=\"UTF-8\">\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"../content_scripts/vimium.css\" />\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"hud_page.css\" />\n    <script src=\"hud_page.js\" type=\"module\"></script>\n  </head>\n  <body>\n    <div id=\"hud-container\">\n      <div id=\"search-area\">\n        <div id=\"hud\"></div>\n      </div>\n    </div>\n  </body>\n</html>\n"
  },
  {
    "path": "pages/hud_page.js",
    "content": "import \"../lib/chrome_api_stubs.js\";\nimport \"../lib/utils.js\";\nimport \"../lib/dom_utils.js\";\nimport \"../lib/settings.js\";\nimport \"../lib/keyboard_utils.js\";\nimport \"../lib/find_mode_history.js\";\nimport * as UIComponentMessenger from \"./ui_component_messenger.js\";\n\nlet findMode = null;\n\n// Chrome creates a unique port for each MessageChannel, so there's a race condition between\n// JavaScript messages of Vimium and browser messages during style recomputation. This duration was\n// determined empirically. See https://github.com/philc/vimium/pull/3277#discussion_r283080348\nconst TIME_TO_WAIT_FOR_IPC_MESSAGES = 17;\n\n// Set the input element's text, and move the cursor to the end.\nfunction setTextInInputElement(inputEl, text) {\n  inputEl.textContent = text;\n  // Move the cursor to the end. Based on one of the solutions here:\n  // http://stackoverflow.com/questions/1125292/how-to-move-cursor-to-end-of-contenteditable-entity\n  const range = document.createRange();\n  range.selectNodeContents(inputEl);\n  range.collapse(false);\n  const selection = globalThis.getSelection();\n  selection.removeAllRanges();\n  selection.addRange(range);\n}\n\nexport function onKeyEvent(event) {\n  // Handle <Enter> on \"keypress\", and other events on \"keydown\"; this avoids interence with CJK\n  // translation (see #2915 and #2934).\n  let rawQuery;\n  if ((event.type === \"keypress\") && (event.key !== \"Enter\")) {\n    return null;\n  }\n  if ((event.type === \"keydown\") && (event.key === \"Enter\")) {\n    return null;\n  }\n\n  const inputEl = document.querySelector(\"#hud-find-input\");\n  // Don't do anything if we're not in find mode.\n  if (inputEl == null) return;\n\n  if (\n    (KeyboardUtils.isBackspace(event) && (inputEl.textContent.length === 0)) ||\n    (event.key === \"Enter\") || KeyboardUtils.isEscape(event)\n  ) {\n    inputEl.blur();\n    UIComponentMessenger.postMessage({\n      name: \"hideFindMode\",\n      exitEventIsEnter: event.key === \"Enter\",\n      exitEventIsEscape: KeyboardUtils.isEscape(event),\n    });\n  } else if (event.key === \"ArrowUp\") {\n    if (rawQuery = FindModeHistory.getQuery(findMode.historyIndex + 1)) {\n      findMode.historyIndex += 1;\n      if (findMode.historyIndex === 0) {\n        findMode.partialQuery = findMode.rawQuery;\n      }\n      setTextInInputElement(inputEl, rawQuery);\n      findMode.executeQuery();\n    }\n  } else if (event.key === \"ArrowDown\") {\n    findMode.historyIndex = Math.max(-1, findMode.historyIndex - 1);\n    rawQuery = 0 <= findMode.historyIndex\n      ? FindModeHistory.getQuery(findMode.historyIndex)\n      : findMode.partialQuery;\n    setTextInInputElement(inputEl, rawQuery);\n    findMode.executeQuery();\n  } else {\n    return;\n  }\n\n  DomUtils.suppressEvent(event);\n  return false;\n}\n\n// Navigator.clipboard is only available in secure contexts. Show a warning when clipboard actions\n// fail on non-HTTPS sites. See #4572.\nfunction ensureClipboardIsAvailable() {\n  if (!navigator.clipboard) {\n    UIComponentMessenger.postMessage({ name: \"showClipboardUnavailableMessage\" });\n    return false;\n  }\n  return true;\n}\n\n// Exported for unit tests.\nexport const handlers = {\n  show(data) {\n    const el = document.querySelector(\"#hud\");\n    el.textContent = data.text;\n    el.classList.add(\"vimium-ui-component-visible\");\n    el.classList.remove(\"vimium-ui-component-hidden\");\n    el.classList.remove(\"hud-find\");\n  },\n\n  hidden() {\n    const el = document.querySelector(\"#hud\");\n    // We get a flicker when the HUD later becomes visible again (with new text) unless we reset its\n    // contents here.\n    el.textContent = \"\";\n    el.classList.add(\"vimium-ui-component-hidden\");\n    el.classList.remove(\"vimium-ui-component-visible\");\n  },\n\n  showFindMode() {\n    let executeQuery;\n    const hudEl = document.querySelector(\"#hud\");\n    hudEl.classList.add(\"hud-find\");\n\n    const inputEl = document.createElement(\"span\");\n    // NOTE(mrmr1993): Chrome supports non-standard \"plaintext-only\", which is what we *really*\n    // want.\n    try {\n      inputEl.contentEditable = \"plaintext-only\";\n    } catch (error) { // Fallback to standard-compliant version.\n      inputEl.contentEditable = \"true\";\n    }\n    inputEl.id = \"hud-find-input\";\n    hudEl.appendChild(inputEl);\n\n    inputEl.addEventListener(\n      \"input\",\n      executeQuery = function (event) {\n        // On Chrome when IME is on, the order of events is:\n        //   keydown, input.isComposing=true, keydown, input.true, ..., keydown, input.true, compositionend;\n        // while on Firefox, the order is: keydown, input.true, ..., input.true, keydown, compositionend, input.false.\n        // Therefore, check event.isComposing here, to avoid window focus changes during typing with\n        // IME, since such changes will prevent normal typing on Firefox (see #3480)\n        if (Utils.isFirefox() && event.isComposing) {\n          return;\n        }\n        // Replace \\u00A0 (&nbsp;) with a normal space.\n        findMode.rawQuery = inputEl.textContent.replace(\"\\u00A0\", \" \");\n        UIComponentMessenger.postMessage({ name: \"search\", query: findMode.rawQuery });\n      },\n    );\n\n    const countEl = document.createElement(\"span\");\n    countEl.id = \"hud-match-count\";\n    countEl.style.float = \"right\";\n    hudEl.appendChild(countEl);\n    Utils.setTimeout(TIME_TO_WAIT_FOR_IPC_MESSAGES, function () {\n      // On Firefox, the page must first be focused before the HUD input element can be focused.\n      // #3460.\n      if (Utils.isFirefox()) {\n        globalThis.focus();\n      }\n      inputEl.focus();\n    });\n\n    findMode = {\n      historyIndex: -1,\n      partialQuery: \"\",\n      rawQuery: \"\",\n      executeQuery,\n    };\n  },\n\n  updateMatchesCount({ matchCount, showMatchText }) {\n    const countEl = document.querySelector(\"#hud-match-count\");\n    // Don't do anything if we're not in find mode.\n    if (countEl == null) return;\n\n    if (Utils.isFirefox()) {\n      document.querySelector(\"#hud-find-input\").focus();\n    }\n    const countText = matchCount > 0\n      ? ` (${matchCount} Match${matchCount === 1 ? \"\" : \"es\"})`\n      : \" (No matches)\";\n    countEl.textContent = showMatchText ? countText : \"\";\n  },\n\n  copyToClipboard(message) {\n    if (!ensureClipboardIsAvailable()) return;\n    Utils.setTimeout(TIME_TO_WAIT_FOR_IPC_MESSAGES, async function () {\n      const focusedElement = document.activeElement;\n      // In Chrome, if we do not focus the current window before invoking navigator.clipboard APIs,\n      // the error \"DOMException: Document is not focused.\" is thrown.\n      globalThis.focus();\n\n      // Replace nbsp; characters with space. See #2217.\n      const value = message.data.replace(/\\xa0/g, \" \");\n      await navigator.clipboard.writeText(value);\n\n      if (focusedElement != null) focusedElement.focus();\n      globalThis.parent.focus();\n      UIComponentMessenger.postMessage({ name: \"unfocusIfFocused\" });\n    });\n  },\n\n  pasteFromClipboard() {\n    if (!ensureClipboardIsAvailable()) return;\n    Utils.setTimeout(TIME_TO_WAIT_FOR_IPC_MESSAGES, async function () {\n      const focusedElement = document.activeElement;\n      // In Chrome, if we do not focus the current window before invoking navigator.clipboard APIs,\n      // the error \"DOMException: Document is not focused.\" is thrown.\n      globalThis.focus();\n\n      let value = await navigator.clipboard.readText();\n      // Replace nbsp; characters with space. See #2217.\n      value = value.replace(/\\xa0/g, \" \");\n\n      if (focusedElement != null) focusedElement.focus();\n      globalThis.parent.focus();\n      UIComponentMessenger.postMessage({ name: \"pasteResponse\", data: value });\n    });\n  },\n};\n\nfunction init() {\n  // Manually inject custom user styles.\n  document.addEventListener(\"DOMContentLoaded\", async () => {\n    await Settings.onLoaded();\n    DomUtils.injectUserCss();\n  });\n\n  document.addEventListener(\"keydown\", onKeyEvent);\n  document.addEventListener(\"keypress\", onKeyEvent);\n\n  UIComponentMessenger.init();\n  UIComponentMessenger.registerHandler(async function (event) {\n    await Utils.populateBrowserInfo();\n    const handler = handlers[event.data.name];\n    Utils.assert(handler != null, \"Unrecognized message type.\", event.data);\n    return handler(event.data);\n  });\n\n  FindModeHistory.init();\n}\n\nconst testEnv = globalThis.window == null;\nif (!testEnv) {\n  init();\n}\n"
  },
  {
    "path": "pages/key_mappings.css",
    "content": "/*\n * Styles for showing key bindings. Shared by help_dialog_page.html and command_listing.html.\n */\n\n.key-bindings {\n  max-width: 110px;\n  font-size: 14px;\n  text-align: right;\n  margin-right: 8px;\n  display: flex;\n  flex-wrap: wrap;\n  align-items: flex-end;\n  justify-content: flex-end;\n}\n\n/* A \"key block\" includes a key and a comma separator. */\n.key-block {\n  margin-bottom: 4px;\n}\n\n.key {\n  background-color: rgb(243, 243, 243);\n  color: rgb(33, 33, 33);\n  margin-left: 2px;\n  padding: 2px 6px;\n  border-radius: 3px;\n  border: solid 1px #ccc;\n  border-bottom-color: #bbb;\n  box-shadow: inset 0 -1px 0 #bbb;\n  font-family: monospace;\n  font-size: 11px;\n}\n\n.comma {\n  margin-right: 3px;\n}\n\n/* Hide the trailing comma after the last key binding. */\n.key-block:last-of-type .comma {\n  display: none;\n}\n\n@media (prefers-color-scheme: dark) {\n  .key {\n    /* We're using a color that pops more than --vimium-foreground-color because the squares\n       representing keys are small and hard to read otherwise. */\n    background-color: #393a3d;\n    border: solid 1px #101010;\n    box-shadow: none;\n    color: white;\n  }\n}\n"
  },
  {
    "path": "pages/options.css",
    "content": "/* This stylesheet is included in both options.html and action.html, so changes affect both. */\n:root {\n  --closeButtonWidth: 25px;\n  --validationErrorColor: #ff5300;\n}\n\nbody {\n  font: 14px \"DejaVu Sans\", \"Arial\", sans-serif;\n  color: #303942;\n  margin: 0;\n}\n\na,\na:visited {\n  color: #15c;\n}\n\na:active {\n  color: #052577;\n}\n\ndiv#wrapper,\n#footer-content {\n  max-width: 1050px;\n  margin: 0 25px;\n}\n\nheader {\n  font-size: 18px;\n  font-weight: normal;\n  border-bottom: 1px solid #ccc;\n  padding: 20px 0 15px 0;\n  width: 100%;\n}\n\nbutton {\n  -webkit-user-select: none;\n  -webkit-appearance: none;\n  font: inherit;\n  border-width: 1px;\n  border-radius: 3px;\n  padding: 2px 10px;\n}\n\ninput[type=\"checkbox\"] {\n  -webkit-user-select: none;\n  margin: 0;\n  margin-right: 8px;\n}\n\ninput[type=\"radio\"] {\n  margin: 0;\n  margin-left: 0;\n  margin-right: 8px;\n}\n\n.boolean-label {\n  display: flex;\n  align-items: center;\n  gap: 0;\n}\n\npre,\ncode,\n.code {\n  font-family: Consolas, \"Liberation Mono\", Courier, monospace;\n}\n\npre {\n  margin: 5px;\n  border-left: 1px solid #eee;\n  padding-left: 5px;\n}\n\ninput,\ntextarea {\n  box-sizing: border-box;\n}\n\ntextarea {\n  /* Horizontal resizing breaks the page's layout, so we just allow vertical. */\n  resize: vertical;\n}\n\nh2 {\n  margin: 12px 0;\n  font-size: 16px;\n  font-weight: normal;\n}\n\n#settings-grid-container {\n  display: grid;\n  font-size: 14px;\n  grid-template-columns: auto 320px;\n  /* This is required so the \"save changes\" panel at the bottom of the options page doesn't cover\n   * any settings content. */\n  margin-bottom: 100px;\n}\n\n/* Adds a blank line in the grid. */\n#settings-grid-container .spacer {\n  grid-column-start: 1;\n  grid-column-end: 3;\n  height: 10px;\n}\n\n/* These should span the full width of the grid. */\n#settings-grid-container h2,\n#settings-grid-container header {\n  grid-column-start: 1;\n  grid-column-end: 3;\n}\n\n.example {\n  font-size: 12px;\n  line-height: 16px;\n  color: #979ca0;\n  margin-left: 20px;\n}\n\n.reset-link {\n  margin-left: 0px;\n  text-align: right;\n}\n\n.reset-link a {\n  text-decoration: none;\n}\n\n.validation-message {\n  color: var(--validationErrorColor);\n  /* Render newlines in validation messages. When there are multiple errors, they are separated by a\n     newline. */\n  white-space: pre-line;\n}\n\n/* This longer selector is required to take precedence over our dark scheme textarea colors which\n * are defined in vimium.css. */\nbody.vimium-body textarea.validation-error, body.vimium-body input.validation-error {\n  border: 2px solid var(--validationErrorColor);\n}\n\ndiv#exampleKeyMapping {\n  margin-left: 10px;\n  margin-top: 5px;\n}\n\n#new-tab-url-container {\n  display: grid;\n  grid-template-columns: auto 100%;\n  grid-auto-rows: 1.5em;\n  row-gap: 10px;\n  align-items: center;\n}\n\ninput[name=\"newTabCustomUrl\"] {\n  grid-column-start: 2;\n}\n\n#openVomnibarContainer {\n  display: flex;\n  align-items: center;\n  grid-column-start: 2;\n}\n\ninput[name=\"newTabCustomUrl\"] {\n  width: 400px;\n}\n\n#link-hint-characters-container,\n#link-hint-numbers-container,\n#wait-for-enter {\n  display: contents;\n}\n\n.link-hint-characters-field {\n  width: 100%;\n  /* These text fields look strange when they're excessively long. */\n  max-width: 400px;\n}\n\n.link-hint-characters-field input {\n  width: 100%;\n}\n\ninput[name=\"scrollStepSize\"] {\n  width: 80px;\n  margin-right: 3px;\n  padding-left: 3px;\n}\n\ntextarea[name=\"userDefinedLinkHintCss\"],\ntextarea[name=\"keyMappings\"],\ntextarea[name=\"searchEngines\"] {\n  width: 100%;\n  min-height: 140px;\n  white-space: pre;\n}\n\ninput[name=\"previousPatterns\"],\ninput[name=\"nextPatterns\"] {\n  width: 100%;\n}\n\ninput#searchUrl {\n  width: 100%;\n}\n\n#status {\n  margin-left: 10px;\n  font-size: 80%;\n}\n\ninput[type=\"text\"]:read-only,\ninput[type=\"number\"]:read-only,\ntextarea:read-only {\n  background-color: #eee;\n  color: #666;\n  pointer-events: none;\n  -webkit-user-select: none;\n}\n\ninput[type=\"text\"],\ntextarea {\n  border: 1px solid #bfbfbf;\n  border-radius: 2px;\n  color: #444;\n  background-color: white;\n  font: inherit;\n  padding: 3px;\n}\n\nbutton:focus,\ninput[type=\"text\"]:focus,\ntextarea:focus {\n  -webkit-transition: border-color 200ms;\n  border-color: #4d90fe;\n  outline: none;\n}\n\n/*\n * CSS for exclusion rules.\n */\n\n#exclusion-scroll-box {\n  overflow: scroll;\n  overflow-x: hidden;\n  overflow-y: auto;\n  /* Each exclusion rule is about 30px tall, so this allows 7 rules before scrolling. */\n  max-height: 215px;\n  border-radius: 2px;\n  color: #444;\n  width: 100%;\n}\n\n#exclusion-rules {\n  width: 100%;\n  border-collapse: collapse;\n}\n\n#exclusion-rules td {\n  vertical-align: top;\n  border: 2px solid transparent;\n  padding: 0px;\n}\n\n#exclusion-rules td:nth-of-type(2) {\n  width: 33%;\n}\n\n#exclusion-rules td:nth-of-type(3) {\n  /* Make the close button td use only the minimum width needed. */\n  width: var(--closeButtonWidth);\n  padding-top: 3px;\n}\n\n#exclusion-rules tr.validationError .validationMessage {\n  display: block;\n}\n\n#exclusion-rules tr.validationError td:nth-of-type(1) input {\n  border-color: orange;\n}\n\n#exclusion-rules .validationMessage {\n  display: block;\n  color: orange;\n  margin-top: 2px;\n  margin-left: 2px;\n}\n\n#exclusion-rules .remove {\n  border: none;\n  color: #979ca0;\n}\n\n#exclusion-rules .remove:hover {\n  color: #444;\n}\n\ninput[name=\"pattern\"],\ninput[name=\"passKeys\"],\n.exclusion-header-text {\n  width: 100%;\n  font-family: Consolas, \"Liberation Mono\", Courier, monospace;\n  font-size: 14px;\n}\n\n.exclusion-header-text {\n  padding-left: 3px;\n  color: #979ca0;\n}\n\n#exclusion-add-button {\n  float: right;\n  /* Add the spacing between the table's cells to the right margin of this button. */\n  margin-right: calc(var(--closeButtonWidth) + 4px);\n  margin-top: 10px;\n}\n\nfooter {\n  background: #f5f5f5;\n  border-top: 1px solid #979ca0;\n  position: fixed;\n  bottom: 0px;\n  left: 0;\n  padding: 15px 0;\n  z-index: 10;\n  width: 100%;\n}\n\n#footer-content {\n  width: 100%;\n  display: flex;\n  align-items: center;\n}\n\n#footer-help-text {\n  flex-grow: 1;\n  font-size: 12px;\n}\n\n#footer-save-options {\n  flex-grow: 0;\n}\n\n#save,\n#exclusion-add-button {\n  white-space: nowrap;\n}\n\n#backupLink {\n  cursor: pointer;\n}\n\ninput#upload-backup {\n  max-width: 400px;\n}\n\n@media (prefers-color-scheme: light) {\n  button {\n    background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede);\n    border-color: rgba(0, 0, 0, 0.25);\n    box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75);\n    color: #444;\n    text-shadow: 0 1px 0 #f0f0f0;\n  }\n\n  button:hover {\n    background-image: -webkit-linear-gradient(#f0f0f0, #f0f0f0 38%, #e0e0e0);\n    border-color: rgba(0, 0, 0, 0.3);\n    box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12), inset 0 1px 2px rgba(255, 255, 255, 0.95);\n    color: black;\n  }\n\n  button:active {\n    background-image: -webkit-linear-gradient(#e7e7e7, #e7e7e7 38%, #d7d7d7);\n    box-shadow: none;\n    text-shadow: none;\n  }\n\n  button[disabled],\n  button[disabled]:hover,\n  button[disabled]:active {\n    background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede);\n    border-color: rgba(0, 0, 0, 0.25);\n    box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75);\n    text-shadow: 0 1px 0 #f0f0f0;\n    color: #888;\n  }\n}\n\n@media (prefers-color-scheme: dark) {\n  header {\n    border-bottom: 1px solid #999;\n  }\n\n  pre {\n    border-left: 1px solid #666;\n  }\n\n  #exclusion-rules .remove {\n    border: none;\n    color: var(--vimium-foreground-text-color);\n  }\n\n  #exclusion-rules .remove:hover {\n    color: #444;\n    color: #15c;\n    color: var(--vimium-link-color);\n  }\n\n  footer {\n    background-color: var(--vimium-foreground-color);\n    border-color: rgba(255, 255, 255, 0.1);\n  }\n\n  /* Our dark mode style for buttons matches MacOS Mojave's dark mode style for HTML buttons. MacOS\n     will automatically apply a dark mode style to unstyled buttons, but that doesn't happen on\n     other OSes, so to be cross-platform, we have to set these styles ourselves. */\n  button {\n    background: none;\n    background-color: #2b2a32;\n    border-color: #8f8f9c;\n    border-width: 1px;\n    color: var(--vimium-foreground-text-color);\n    text-shadow: none;\n  }\n  button:hover {}\n  button:active {}\n\n  button[disabled],\n  button[disabled]:hover,\n  button[disabled]:active {\n    color: #75747a;\n    border-color: #75747a;\n    background-color: #222127;\n  }\n}\n"
  },
  {
    "path": "pages/options.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <title>Vimium Options</title>\n    <meta charset=\"UTF-8\">\n    <meta name=\"color-scheme\" content=\"light dark\">\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"options.css\">\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"../content_scripts/vimium.css\" />\n    <script src=\"options.js\" type=\"module\"></script>\n  </head>\n\n  <body class=\"vimium-body\">\n    <div id=\"wrapper\">\n      <header>Vimium Options</header>\n      <input type=\"hidden\" name=\"settingsVersion\" />\n\n      <div id=\"settings-grid-container\">\n        <h2>Excluded URLs and keys</h2>\n        <div>\n          <div id=\"exclusion-scroll-box\">\n            <table id=\"exclusion-rules\">\n              <tr>\n                <td><span class=\"exclusion-header-text\">Patterns</span></td>\n                <td><span class=\"exclusion-header-text\">Keys to exclude</span></td>\n              </tr>\n            </table>\n          </div>\n          <button id=\"exclusion-add-button\">Add rule</button>\n        </div>\n        <div class=\"example\">\n          Disable Vimium on URLs.<br>\n          \"Patterns\" are URL regular expressions. <code>*</code> will match zero or more characters.\n          <br>\n          \"Keys\": Vimium will exclude these keys and pass them through to the page.\n        </div>\n\n        <h2>Custom key mappings</h2>\n        <textarea name=\"keyMappings\" type=\"text\" spellcheck=\"false\"></textarea>\n        <div class=\"example\">\n          Example syntax:<br>\n          <pre\n            id=\"exampleKeyMapping\"\n          >\nmap j scrollDown\nmap z2 setZoom level=2\nunmap j\nunmapAll\n\" this is a comment\n# this is also a comment</pre>\n          <a href=\"/pages/command_listing.html\" target=\"_blank\">See all available commands</a>.\n        </div>\n\n        <h2>Custom search engines</h2>\n        <textarea name=\"searchEngines\" spellcheck=\"false\"></textarea>\n        <div class=\"example\">\n          Add search-engine shortcuts to the Vomnibar. Format:<br>\n          <pre>\na: http://a.com/?q=%s\nb: http://b.com/?q=%s description\n\" this is a comment\n# this is also a comment</pre>\n          %s is replaced with the search terms. <br>\n          For search completion, see <a href=\"doc_search_completion.html\" target=\"_blank\">here</a>.\n        </div>\n\n        <h2>New tab URL</h2>\n        <div id=\"new-tab-url-container\">\n          <input\n            id=\"vimiumNewTabPage\"\n            type=\"radio\"\n            name=\"newTabDestination\"\n            value=\"vimiumNewTabPage\"\n          />\n          <label for=\"vimiumNewTabPage\">Vimium blank new tab page</label>\n          <div id=\"openVomnibarContainer\">\n            <input id=\"openVomnibarOnNewTabPage\" type=\"checkbox\" name=\"openVomnibarOnNewTabPage\">\n            <label for=\"openVomnibarOnNewTabPage\">Open the Vomnibar when the page loads</label>\n          </div>\n          <input\n            id=\"browserNewTabPage\"\n            type=\"radio\"\n            name=\"newTabDestination\"\n            value=\"browserNewTabPage\"\n          />\n          <label for=\"browserNewTabPage\">Browser's default new tab page</label>\n          <input id=\"customUrl\" type=\"radio\" name=\"newTabDestination\" value=\"customUrl\" />\n          <label for=\"customUrl\">Custom URL</label>\n          <input type=\"text\" name=\"newTabCustomUrl\" style=\"display: none\" />\n        </div>\n        <div class=\"example\">\n          The page to open when using Vimium's \"create new tab\" command. To have Vimium commands\n          work on <em>all</em> new tab pages opened by the browser, a separate Vimium new tab\n          extension is required. See the full details <a\n            href=\"https://github.com/philc/vimium/blob/master/README.md#how-to-allow-vimium-to-work-on-new-tab-pages\"\n            target=\"_blank\"\n          >here</a>.\n        </div>\n\n        <header>Advanced Options</header>\n\n        <h2>Scroll step size</h2>\n        <span>\n          <input name=\"scrollStepSize\" type=\"number\" />px\n        </span>\n        <div class=\"example\">\n          The size for basic movements (usually j/k/h/l).\n        </div>\n\n        <div id=\"link-hint-characters-container\">\n          <h2>Characters used for link hints</h2>\n          <div class=\"link-hint-characters-field\">\n            <input name=\"linkHintCharacters\" type=\"text\" />\n            <div class=\"example reset-link\"><a href=\"#\">Reset</a></div>\n          </div>\n          <div class=\"example\">\n            The characters placed next to each link after typing \"f\" to enter link-hint mode.\n          </div>\n        </div>\n\n        <div id=\"link-hint-numbers-container\">\n          <h2>Numbers used for link hints</h2>\n          <div class=\"link-hint-characters-field\">\n            <input name=\"linkHintNumbers\" type=\"text\" />\n            <div class=\"example reset-link\"><a href=\"#\">Reset</a></div>\n          </div>\n          <div class=\"example\">\n            The characters placed next to each link after typing \"f\" to enter link-hint mode.\n          </div>\n        </div>\n\n        <div class=\"spacer\"></div>\n\n        <label class=\"boolean-label\">\n          <input name=\"smoothScroll\" type=\"checkbox\" />\n          Use smooth scrolling</label>\n        <div class=\"example\"></div>\n\n        <h2></h2>\n        <label class=\"boolean-label\">\n          <input name=\"filterLinkHints\" type=\"checkbox\" />\n          Use the link's name and characters for link-hint filtering\n        </label>\n        <div class=\"example\">\n          In link-hint mode, this option lets you select a link by typing its text.\n        </div>\n\n        <div id=\"wait-for-enter\">\n          <h2></h2>\n          <label class=\"boolean-label\">\n            <input name=\"waitForEnterForFilteredHints\" type=\"checkbox\" />\n            Require <tt>Enter</tt> when filtering hints\n          </label>\n          <div class=\"example\">\n            You activate the link with <tt>Enter</tt>, <em>always</em>; so you never accidentally\n            type Vimium commands.\n          </div>\n        </div>\n\n        <h2></h2>\n        <label class=\"boolean-label\">\n          <input name=\"grabBackFocus\" type=\"checkbox\" />\n          Don't let pages steal the focus on load\n        </label>\n        <div class=\"example\">\n          Prevent pages from focusing an input on load (e.g. Google, Bing, etc.).\n        </div>\n\n        <h2></h2>\n        <label class=\"boolean-label\">\n          <input name=\"hideHud\" type=\"checkbox\" />\n          Hide the Heads Up Display (HUD) in insert mode\n        </label>\n        <div class=\"example\">\n          When enabled, the HUD will not be displayed in insert mode.\n        </div>\n\n        <h2></h2>\n        <label class=\"boolean-label\">\n          <input name=\"hideUpdateNotifications\" type=\"checkbox\" />\n          Hide update notifications\n        </label>\n        <div class=\"example\">\n          When enabled, \"Vimium has been updated\" notifications will not be shown.\n        </div>\n\n        <h2></h2>\n        <label class=\"boolean-label\">\n          <input name=\"regexFindMode\" type=\"checkbox\" />\n          Treat find queries as JavaScript regular expressions\n        </label>\n        <div class=\"example\">\n          Switch back to plain find mode by using the <code>\\R</code> escape sequence.\n        </div>\n\n        <h2></h2>\n        <label class=\"boolean-label\">\n          <input name=\"ignoreKeyboardLayout\" type=\"checkbox\" />\n          Ignore keyboard layout\n        </label>\n        <div class=\"example\">\n          This forces the use of <code>en-US</code> QWERTY layout and can be helpful for non-Latin\n          keyboards.\n        </div>\n\n        <h2>Previous patterns</h2>\n        <div>\n          <input name=\"previousPatterns\" type=\"text\" />\n          <div class=\"example reset-link\"><a href=\"#\">Reset</a></div>\n        </div>\n        <div class=\"example\">\n          The \"navigate to previous page\" command uses these patterns to find the link to follow.\n        </div>\n\n        <h2>Next patterns</h2>\n        <div>\n          <input name=\"nextPatterns\" type=\"text\" />\n          <div class=\"example reset-link\"><a href=\"#\">Reset</a></div>\n        </div>\n        <div class=\"example\">\n          The \"navigate to next page\" command uses these patterns to find the link to follow.\n        </div>\n\n        <h2>CSS for Vimium UI</h2>\n        <div>\n          <textarea\n            name=\"userDefinedLinkHintCss\"\n            class=\"code\"\n            type=\"text\"\n            spellcheck=\"false\"\n          ></textarea>\n          <div class=\"example reset-link\"><a href=\"#\">Reset</a></div>\n        </div>\n        <div class=\"example\">\n          These styles are applied to link hints, the Vomnibar, the help dialog, the exclusions\n          pop-up and the HUD.<br>\n          By default, this CSS is used to style the characters next to each link hint.<br>\n          <br>\n          These styles are used in addition to and take precedence over Vimium's default styles.\n        </div>\n\n        <header>Backup and Restore</header>\n\n        <h2>Backup</h2>\n        <a id=\"download-backup\" download=\"vimium-options.json\" href=\"#\">Download backup</a>\n        <div class=\"example\">Download a backup of your settings.</div>\n\n        <h2>Restore</h2>\n        <input id=\"upload-backup\" type=\"file\" accept=\".json\" />\n        <div class=\"example\">\n          Choose a backup file to restore.\n        </div>\n      </div>\n\n      <footer>\n        <div id=\"footer-content\">\n          <div id=\"footer-help-text\">\n            Type <strong>?</strong> to show the help dialog.\n            <br>\n            Type <strong id=\"shortcut-to-save-all\"></strong> to save all options.\n          </div>\n          <div id=\"footer-save-options\">\n            <button id=\"save\" disabled=\"disabled\">No changes</button>\n          </div>\n        </div>\n      </footer>\n    </div>\n\n    <template id=\"exclusion-rule-template\">\n      <tr class=\"rule\">\n        <td>\n          <input type=\"text\" name=\"pattern\" spellcheck=\"false\" placeholder=\"URL pattern\" />\n        </td>\n        <td>\n          <input type=\"text\" name=\"passKeys\" spellcheck=\"false\" placeholder=\"All\" />\n        </td>\n        <td>\n          <input type=\"button\" class=\"remove\" value=\"&#x2716;\" />\n        </td>\n      </tr>\n    </template>\n  </body>\n</html>\n"
  },
  {
    "path": "pages/options.js",
    "content": "import \"./all_content_scripts.js\";\nimport { ExclusionRulesEditor } from \"./exclusion_rules_editor.js\";\nimport { allCommands } from \"../background_scripts/all_commands.js\";\nimport { Commands, KeyMappingsParser } from \"../background_scripts/commands.js\";\nimport * as userSearchEngines from \"../background_scripts/user_search_engines.js\";\n\nconst options = {\n  filterLinkHints: \"boolean\",\n  grabBackFocus: \"boolean\",\n  hideHud: \"boolean\",\n  hideUpdateNotifications: \"boolean\",\n  ignoreKeyboardLayout: \"boolean\",\n  keyMappings: \"string\",\n  linkHintCharacters: \"string\",\n  linkHintNumbers: \"string\",\n  newTabCustomUrl: \"string\",\n  newTabDestination: \"option\",\n  nextPatterns: \"string\",\n  openVomnibarOnNewTabPage: \"boolean\",\n  previousPatterns: \"string\",\n  regexFindMode: \"boolean\",\n  scrollStepSize: \"number\",\n  searchEngines: \"string\",\n  settingsVersion: \"string\", // This is a hidden field.\n  smoothScroll: \"boolean\",\n  userDefinedLinkHintCss: \"string\",\n  waitForEnterForFilteredHints: \"boolean\",\n};\n\nexport async function init() {\n  await Settings.onLoaded();\n\n  const shortcutLabel = document.querySelector(\"#shortcut-to-save-all\");\n  shortcutLabel.textContent = KeyboardUtils.platform == \"Mac\" ? \"Cmd-Enter\" : \"Ctrl-Enter\";\n\n  const saveButton = document.querySelector(\"#save\");\n\n  const onUpdated = () => {\n    maintainNewTabUrlView();\n    saveButton.disabled = false;\n    saveButton.textContent = \"Save changes\";\n  };\n\n  for (const el of document.querySelectorAll(\"input, textarea\")) {\n    // We want to immediately enable the save button when a setting is changed, so we want to use\n    // the HTML element's \"input\" event here rather than the \"change\" event.\n    el.addEventListener(\"input\", () => onUpdated());\n    el.addEventListener(\"blur\", () => {\n      showValidationErrors();\n    });\n  }\n\n  saveButton.addEventListener(\"click\", () => saveOptions());\n\n  getOptionEl(\"filterLinkHints\").addEventListener(\n    \"click\",\n    () => maintainLinkHintsView(),\n  );\n\n  document.querySelector(\"#download-backup\").addEventListener(\n    \"mousedown\",\n    () => onDownloadBackupClicked(),\n    true,\n  );\n  document.querySelector(\"#upload-backup\").addEventListener(\n    \"change\",\n    () => onUploadBackupClicked(),\n  );\n\n  for (const el of document.querySelectorAll(\".reset-link a\")) {\n    el.addEventListener(\"click\", (event) => {\n      resetInputValue(event);\n      showValidationErrors();\n      onUpdated();\n    });\n  }\n\n  globalThis.onbeforeunload = () => {\n    if (!saveButton.disabled) {\n      return \"You have unsaved changes to options.\";\n    }\n  };\n\n  document.addEventListener(\"keydown\", (event) => {\n    // Firefox on Mac doesn't pass ctrl-enter to our page because MacOS Sequoia treats it as a\n    // shortcut for right click; typing it shows a context menu. So, we also allow cmd-enter to save\n    // all options. Note that ctrl-enter still works on Chrome for some reason.\n    const isCtrlEnter = event.ctrlKey && event.keyCode === 13;\n    const isCmdEnter = event.metaKey && event.keyCode === 13;\n    if (isCtrlEnter || isCmdEnter) {\n      saveOptions();\n    }\n  });\n\n  ExclusionRulesEditor.init();\n  ExclusionRulesEditor.addEventListener(\"input\", onUpdated);\n\n  const settings = Settings.getSettings();\n  setFormFromSettings(settings);\n}\n\nexport function getOptionEl(optionName) {\n  return document.querySelector(`*[name=\"${optionName}\"]`);\n}\n\n// Invoked when the user clicks the \"reset\" button next to an option's text field.\nfunction resetInputValue(event) {\n  const parentDiv = event.target.parentNode.parentNode;\n  console.assert(parentDiv?.tagName == \"DIV\", \"Expected parent to be a div\", event.target);\n  const input = parentDiv.querySelector(\"input\") || parentDiv.querySelector(\"textarea\");\n  const optionName = input.name;\n  const defaultValue = Settings.defaultOptions[optionName];\n  input.value = defaultValue;\n  event.preventDefault();\n}\n\nfunction setFormFromSettings(settings) {\n  for (const [optionName, optionType] of Object.entries(options)) {\n    const el = getOptionEl(optionName);\n    const value = settings[optionName];\n    switch (optionType) {\n      case \"boolean\":\n        el.checked = value;\n        break;\n      case \"number\":\n        el.value = value;\n        break;\n      case \"string\":\n        el.value = value;\n        break;\n      case \"option\":\n        const optionEl = document.querySelector(`input[name=\"${optionName}\"][value=\"${value}\"]`);\n        optionEl.checked = true;\n        break;\n      default:\n        throw new Error(`Unrecognized option type ${optionType}`);\n    }\n  }\n\n  ExclusionRulesEditor.setForm(settings[\"exclusionRules\"]);\n\n  document.querySelector(\"#upload-backup\").value = \"\";\n  maintainLinkHintsView();\n  maintainNewTabUrlView();\n}\n\nfunction getSettingsFromForm() {\n  const settings = {};\n  for (const [optionName, optionType] of Object.entries(options)) {\n    const el = getOptionEl(optionName);\n    let value;\n    switch (optionType) {\n      case \"boolean\":\n        value = el.checked;\n        break;\n      case \"number\":\n        value = parseFloat(el.value);\n        break;\n      case \"string\":\n        value = el.value.trim();\n        break;\n      case \"option\":\n        const optionEl = document.querySelector(`input[name=\"${optionName}\"]:checked`);\n        value = optionEl.value;\n        break;\n      default:\n        throw new Error(`Unrecognized option type ${optionType}`);\n    }\n    if (value !== null) {\n      settings[optionName] = value;\n    }\n  }\n  if (settings[\"linkHintCharacters\"] != null) {\n    settings[\"linkHintCharacters\"] = settings[\"linkHintCharacters\"].toLowerCase();\n  }\n  settings[\"exclusionRules\"] = ExclusionRulesEditor.getRules();\n  return settings;\n}\n\nfunction getValidationErrors() {\n  const results = {};\n  let text, parsed;\n\n  // keyMappings field.\n  text = getOptionEl(\"keyMappings\").value.trim();\n  parsed = KeyMappingsParser.parse(text);\n  if (parsed.validationErrors.length > 0) {\n    results[\"keyMappings\"] = parsed.validationErrors.join(\"\\n\");\n  }\n\n  // searchEngines field.\n  text = getOptionEl(\"searchEngines\").value.trim();\n  parsed = userSearchEngines.parseConfig(text);\n  if (parsed.validationErrors.length > 0) {\n    results[\"searchEngines\"] = parsed.validationErrors.join(\"\\n\");\n  }\n\n  // linkHintCharacters field.\n  text = getOptionEl(\"linkHintCharacters\").value.trim();\n  if (text != removeDuplicateChars(text)) {\n    results[\"linkHintCharacters\"] = \"This cannot contain duplicate characters.\";\n  } else if (text.length <= 1) {\n    results[\"linkHintCharacters\"] = \"This must be at least two characters long.\";\n  }\n\n  // linkHintNumbers field.\n  text = getOptionEl(\"linkHintNumbers\").value.trim();\n  if (text != removeDuplicateChars(text)) {\n    results[\"linkHintNumbers\"] = \"This cannot contain duplicate characters.\";\n  } else if (text.length <= 1) {\n    results[\"linkHintNumbers\"] = \"This must be at least two characters long.\";\n  }\n\n  return results;\n}\n\nfunction addValidationMessage(el, message) {\n  el.classList.add(\"validation-error\");\n  const exampleEl = el.nextElementSibling;\n  const messageEl = document.createElement(\"div\");\n  messageEl.classList.add(\"validation-message\");\n  messageEl.textContent = message;\n  exampleEl.after(messageEl);\n}\n\n// Returns true if there are errors, false otherwise.\nfunction showValidationErrors() {\n  // Remove all previous validation errors.\n  let els = document.querySelectorAll(\".validation-error\");\n  for (const el of els) {\n    el.classList.remove(\"validation-error\");\n  }\n  els = document.querySelectorAll(\".validation-message\");\n  for (const el of els) {\n    el.remove();\n  }\n\n  const errors = getValidationErrors();\n  for (const [optionName, message] of Object.entries(errors)) {\n    const el = getOptionEl(optionName);\n    addValidationMessage(el, message);\n  }\n  // Some options can be hidden in the UI. If they have validation errors, force them to be shown.\n  if (errors[\"linkHintCharacters\"]) {\n    showElement(document.querySelector(\"#link-hint-characters-container\"), true);\n  }\n  if (errors[\"linkHintNumbers\"]) {\n    showElement(document.querySelector(\"#link-hint-numbers-container\"), true);\n  }\n  const hasErrors = Object.keys(errors).length > 0;\n  return hasErrors;\n}\n\nfunction removeDuplicateChars(str) {\n  const seen = new Set();\n  let result = \"\";\n  for (let char of str) {\n    if (!seen.has(char)) {\n      result += char;\n      seen.add(char);\n    }\n  }\n  return result;\n}\n\nexport async function saveOptions() {\n  const hasErrors = showValidationErrors();\n  if (hasErrors) {\n    // TODO(philc): If no fields with validation errors are in view, scroll one of them into view\n    // so it's clear what the issue is.\n    return;\n  }\n\n  await Settings.setSettings(getSettingsFromForm());\n  const el = document.querySelector(\"#save\");\n  el.disabled = true;\n  el.textContent = \"Saved\";\n}\n\nfunction showElement(el, visible) {\n  el.style.display = visible ? null : \"none\";\n}\n\n// Hide or show extra form elements depending on which radio button is selected for\n// newTabDestination.\nfunction maintainNewTabUrlView() {\n  const destination = document.querySelector(\"[name=newTabDestination]:checked\").value;\n  showElement(\n    document.querySelector(\"#openVomnibarContainer\"),\n    destination == Settings.newTabDestinations.vimiumNewTabPage,\n  );\n  showElement(\n    document.querySelector(\"[name=newTabCustomUrl]\"),\n    destination == Settings.newTabDestinations.customUrl,\n  );\n}\n\n// Display the UI for link hint numbers vs. characters, depending upon the value of\n// \"filterLinkHints\".\nfunction maintainLinkHintsView() {\n  const errors = getValidationErrors();\n  const isFilteredLinkhints = getOptionEl(\"filterLinkHints\").checked;\n  showElement(\n    document.querySelector(\"#link-hint-characters-container\"),\n    !isFilteredLinkhints || errors[\"linkHintCharacters\"],\n  );\n  showElement(\n    document.querySelector(\"#link-hint-numbers-container\"),\n    isFilteredLinkhints || errors[\"linkHintNumbers\"],\n  );\n  showElement(\n    document.querySelector(\"#wait-for-enter\"),\n    isFilteredLinkhints,\n  );\n}\n\nexport function prepareBackupSettings() {\n  const settings = Settings.pruneOutDefaultValues(getSettingsFromForm());\n  // Serialize the JSON keys in order, so that they're stable across backups. See #4764.\n  const keys = Object.keys(settings).sort();\n  const sortedSettings = Object.fromEntries(keys.map((k) => [k, settings[k]]));\n  // Don't use an array replacer in JSON.stringify; it filters nested object keys too, which would\n  // drop nested fields inside exclusionRules (e.g. `pattern`, `passKeys`). See #4853.\n  return JSON.stringify(sortedSettings, null, 2) + \"\\n\";\n}\n\nfunction onDownloadBackupClicked() {\n  const settings = prepareBackupSettings();\n  const blob = new Blob([settings]);\n  document.querySelector(\"#download-backup\").href = URL.createObjectURL(blob);\n}\n\nfunction onUploadBackupClicked() {\n  if (document.activeElement) {\n    document.activeElement.blur();\n  }\n\n  const files = event.target.files;\n  if (files.length === 1) {\n    const file = files[0];\n    const reader = new FileReader();\n    reader.readAsText(file);\n    reader.onload = async () => {\n      let backup;\n      try {\n        backup = JSON.parse(reader.result);\n      } catch (error) {\n        console.log(\"parsing error:\", error);\n        alert(\"Failed to parse Vimium backup: \" + error);\n        return;\n      }\n\n      await Settings.setSettings(backup);\n      setFormFromSettings(Settings.getSettings());\n      const saveButton = document.querySelector(\"#save\");\n      saveButton.disabled = true;\n      saveButton.textContent = \"Saved\";\n      alert(\"Settings have been restored from the backup.\");\n    };\n  }\n}\n\nconst testEnv = globalThis.window == null ||\n  globalThis.window.location.search.includes(\"dom_tests=true\");\nif (!testEnv) {\n  document.addEventListener(\"DOMContentLoaded\", async () => {\n    await Settings.onLoaded();\n    DomUtils.injectUserCss();\n    await Commands.init();\n    await init();\n  });\n}\n"
  },
  {
    "path": "pages/reload.html",
    "content": "<!--\n    This page can be used during development to reload the Vimium extension, and all open tabs.\n  -->\n<!DOCTYPE html>\n<html>\n  <head>\n    <title></title>\n    <script src=\"../background_scripts/reload.js\" type=\"module\"></script>\n  </head>\n\n  <body></body>\n</html>\n"
  },
  {
    "path": "pages/ui_component_messenger.js",
    "content": "//\n// These are functions for a page in a UIComponent iframe to communicate to its parent frame.\n//\n\nlet ownerPagePort = null;\nlet handleMessage = null;\n\nexport async function registerPortWithOwnerPage(event) {\n  if (event.source !== globalThis.parent) return;\n  // The Vimium content script that's running on the parent page has access to this vimiumSecret\n  // fetched from session storage, so if it matches, then we know that event.ports came from the\n  // Vimium extension.\n  const secret = (await chrome.storage.session.get(\"vimiumSecret\")).vimiumSecret;\n  if (event.data !== secret) {\n    Utils.debugLog(\"ui_component_messenger.js: vimiumSecret is incorrect.\");\n    return;\n  }\n  openPort(event.ports[0]);\n  // Once we complete a handshake with the parent page hosting this page's iframe, stop listening\n  // for messages on the window object.\n  globalThis.removeEventListener(\"message\", registerPortWithOwnerPage);\n}\n\n// Used by unit tests.\nexport async function unregister() {\n  ownerPagePort = null;\n  handleMessage = null;\n}\n\nexport function init() {\n  globalThis.addEventListener(\"message\", registerPortWithOwnerPage);\n}\n\nfunction openPort(port) {\n  ownerPagePort = port;\n  ownerPagePort.onmessage = async (event) => {\n    if (handleMessage) {\n      return await handleMessage(event);\n    }\n  };\n  dispatchReadyEventWhenReady();\n}\n\nexport function registerHandler(messageHandlerFn) {\n  handleMessage = messageHandlerFn;\n}\n\nexport function postMessage(data) {\n  if (!ownerPagePort) return;\n  ownerPagePort.postMessage(data);\n}\n\n// We require both that the DOM is ready and that the port has been opened before the UIComponent\n// is ready. These events can happen in either order. We count them, and notify the content script\n// when we've seen both.\nlet hasDispatchedReadyEvent = false;\nfunction dispatchReadyEventWhenReady() {\n  if (hasDispatchedReadyEvent) return;\n\n  if (document.readyState === \"loading\") {\n    globalThis.addEventListener(\"DOMContentLoaded\", () => dispatchReadyEventWhenReady());\n    return;\n  }\n  if (!ownerPagePort) return;\n\n  if (globalThis.frameId != null) {\n    postMessage({ name: \"setIframeFrameId\", iframeFrameId: globalThis.frameId });\n  }\n  hasDispatchedReadyEvent = true;\n  postMessage({ name: \"uiComponentIsReady\" });\n}\n"
  },
  {
    "path": "pages/vomnibar_page.css",
    "content": "ul {\n  list-style: none;\n  display: none;\n  margin: 0;\n  padding: 0;\n}\n\n#vomnibar {\n  display: block;\n  position: fixed;\n  width: calc(100% - 20px); /* adjusted to keep border radius and box-shadow visible*/\n  top: 8px;\n  left: 8px;\n  font-family: \"Helvetica Neue\", Helvetica, Arial, sans-serif;\n\n  background: #f1f1f1;\n  text-align: left;\n  border-radius: 4px;\n  box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8);\n  border: 1px solid #aaa;\n  /* One less than hint markers and the help dialog (see ../content_scripts/vimium.css). */\n  z-index: 2139999999;\n}\n\n#vomnibar input {\n  font-size: 20px;\n  height: 34px;\n  margin-bottom: 0;\n  padding: 4px;\n  background-color: white;\n  color: black;\n  border-radius: 3px;\n  border: 1px solid #e8e8e8;\n  box-shadow: #444 0px 0px 1px;\n  width: 100%;\n  outline: none;\n  box-sizing: border-box;\n}\n\n#vomnibar-search-area {\n  display: block;\n  padding: 10px;\n  border-radius: 4px 4px 0 0;\n  border-bottom: 1px solid #c6c9ce;\n}\n\n#vomnibar ul {\n  border-radius: 0 0 4px 4px;\n}\n\n#vomnibar li {\n  border-bottom: 1px solid #ddd;\n  line-height: 1.1em;\n  padding: 7px 10px;\n  font-size: 16px;\n  color: black;\n  position: relative;\n  display: list-item;\n  margin: auto;\n}\n\n#vomnibar li:last-of-type {\n  border-bottom: none;\n}\n\n#vomnibar li .top-half, #vomnibar li .bottom-half {\n  display: block;\n  overflow: hidden;\n}\n\n#vomnibar li .bottom-half {\n  font-size: 15px;\n  margin-top: 3px;\n  padding: 2px 0;\n}\n\n#vomnibar li .icon {\n  padding: 0 13px 0 6px;\n  vertical-align: bottom;\n}\n\n#vomnibar li .source {\n  color: #777;\n  margin-right: 4px;\n}\n#vomnibar li .relevancy {\n  position: absolute;\n  right: 0;\n  top: 0;\n  padding: 5px;\n  color: black;\n  font-family: monospace;\n  width: 100px;\n  overflow: hidden;\n}\n\n#vomnibar li .url {\n  white-space: nowrap;\n  color: #224684;\n}\n\n#vomnibar li .match {\n  font-weight: bold;\n  color: black;\n}\n\n#vomnibar li em, #vomnibar li .title {\n  color: black;\n  margin-left: 4px;\n}\n#vomnibar li em {\n  font-style: italic;\n}\n#vomnibar li em .match, #vomnibar li .title .match {\n  color: #333;\n}\n\n#vomnibar li.selected {\n  background-color: #bbcee9;\n}\n\n#vomnibar input::selection {\n  /* This is the light grey color of the vomnibar border. */\n  /* background-color: #F1F1F1; */\n\n  /* This is the light blue color of the vomnibar selected item. */\n  /* background-color: #BBCEE9; */\n\n  /* This is a considerably lighter blue than Vimium blue, which seems softer\n   * on the eye for this purpose. */\n  background-color: #e6eefb;\n}\n\n.no-insert-text {\n  visibility: hidden;\n}\n\n/* Dark Vomnibar */\n\n@media (prefers-color-scheme: dark) {\n  #vomnibar {\n    background-color: var(--vimium-background-color);\n    color: var(--vimium-background-text-color);\n    border-radius: 6px;\n    border: 1px solid var(--vimium-foreground-color);\n  }\n\n  #vomnibar-search-area {\n    border-bottom: 1px solid var(--vimium-foreground-color);\n  }\n\n  #vomnibar input {\n    background-color: var(--vimium-foreground-color);\n    color: var(--vimium-foreground-text-color);\n    border: none;\n  }\n\n  /* Ensure selected text is visible in dark mode. */\n  #vomnibar input::selection {\n    color: var(--vimium-foreground-color);\n    background-color: var(--vimium-foreground-text-color);\n  }\n\n  #vomnibar li {\n    border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n  }\n\n  #vomnibar li.selected {\n    background-color: #37383a;\n  }\n\n  #vomnibar li .url {\n    white-space: nowrap;\n    color: #5ca1f7;\n  }\n\n  #vomnibar li em,\n  #vomnibar li .title {\n    color: white;\n  }\n\n  #vomnibar li .source {\n    color: #9aa0a6;\n  }\n\n  #vomnibar li .match {\n    color: white;\n  }\n\n  #vomnibar li em .match,\n  #vomnibar li .title .match {\n    color: white;\n  }\n}\n"
  },
  {
    "path": "pages/vomnibar_page.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <title>Vomnibar</title>\n    <meta name=\"color-scheme\" content=\"light dark\">\n    <meta charset=\"UTF-8\">\n    <script type=\"module\" src=\"vomnibar_page.js\"></script>\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"../content_scripts/vimium.css\" />\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"vomnibar_page.css\" />\n  </head>\n  <body>\n    <div id=\"vomnibar\">\n      <div id=\"vomnibar-search-area\">\n        <input type=\"text\" autocomplete=\"off\">\n      </div>\n      <ul></ul>\n    </div>\n  </body>\n</html>\n"
  },
  {
    "path": "pages/vomnibar_page.js",
    "content": "//\n// This controls the contents of the Vomnibar iframe. We use an iframe to avoid changing the\n// selection on the page (useful for bookmarklets), ensure that the Vomnibar style is unaffected by\n// the page, and simplify key handling in vimium_frontend.js\n//\n\nimport \"../lib/types.js\";\nimport \"../lib/utils.js\";\nimport \"../lib/url_utils.js\";\nimport \"../lib/settings.js\";\nimport \"../lib/keyboard_utils.js\";\nimport \"../lib/dom_utils.js\";\nimport \"../lib/handler_stack.js\";\nimport * as UIComponentMessenger from \"./ui_component_messenger.js\";\nimport * as userSearchEngines from \"../background_scripts/user_search_engines.js\";\n\nexport let ui; // An instance of VomnibarUI.\n\n// Used for tests.\nexport function reset() {\n  ui = null;\n}\n\nexport async function activate(options) {\n  Utils.assertType(VomnibarShowOptions, options || {});\n  await Settings.onLoaded();\n  userSearchEngines.set(Settings.get(\"searchEngines\"));\n\n  const defaults = {\n    completer: \"omni\",\n    query: \"\",\n    newTab: false,\n    selectFirst: false,\n    keyword: null,\n  };\n\n  options = Object.assign(defaults, options);\n\n  if (ui == null) {\n    ui = new VomnibarUI();\n  }\n  ui.setCompleterName(options.completer);\n  ui.refreshCompletions();\n  ui.setInitialSelectionValue(options.selectFirst ? 0 : -1);\n  ui.setForceNewTab(options.newTab);\n  ui.setQuery(options.query);\n  ui.setActiveUserSearchEngine(userSearchEngines.keywordToEngine[options.keyword]);\n  // Use await here for vomnibar_test.js, so that this page doesn't get unloaded while a test is\n  // running.\n  await ui.update();\n}\n\nclass VomnibarUI {\n  constructor() {\n    this.onKeyEvent = this.onKeyEvent.bind(this);\n    this.onInput = this.onInput.bind(this);\n    this.update = this.update.bind(this);\n    this.onHiddenCallback = null;\n    this.initDom();\n    // The user's custom search engine, if they have prefixed their query with the keyword for one\n    // of their search engines.\n    this.activeUserSearchEngine = null;\n    // Used for synchronizing requests and responses to the background page.\n    this.lastRequestId = null;\n  }\n\n  setQuery(query) {\n    this.input.value = query;\n  }\n  setActiveUserSearchEngine(userSearchEngine) {\n    this.activeUserSearchEngine = userSearchEngine;\n  }\n\n  setInitialSelectionValue(initialSelectionValue) {\n    this.initialSelectionValue = initialSelectionValue;\n  }\n  setForceNewTab(forceNewTab) {\n    this.forceNewTab = forceNewTab;\n  }\n  setCompleterName(name) {\n    this.completerName = name;\n    this.reset();\n  }\n\n  // True if the user has entered the keyword of one of their custom search engines.\n  isUserSearchEngineActive() {\n    return this.activeUserSearchEngine != null;\n  }\n\n  // The sequence of events when the vomnibar is hidden:\n  // 1. Post a \"hide\" message to the host page.\n  // 2. The host page hides the vomnibar.\n  // 3. When that page receives the focus, it posts back a \"hidden\" message.\n  // 4. Only once the \"hidden\" message is received here is onHiddenCallback called.\n  //\n  // This ensures that the vomnibar is actually hidden before any new tab is created, and avoids\n  // flicker after opening a link in a new tab then returning to the original tab. See #1485.\n  hide(onHiddenCallback = null) {\n    this.onHiddenCallback = onHiddenCallback;\n    this.input.blur();\n    this.reset();\n    // Wait until this iframe's DOM has been rendered before hiding the iframe. This is to prevent\n    // Chrome caching the previous visual state of the vomnibar iframe. See #4708.\n    setTimeout(() => {\n      UIComponentMessenger.postMessage({ name: \"hide\" });\n    }, 0);\n  }\n\n  onHidden() {\n    this.onHiddenCallback?.();\n    this.onHiddenCallback = null;\n    this.reset();\n  }\n\n  reset() {\n    this.input.value = \"\";\n    this.completions = [];\n    this.renderCompletions(this.completions);\n    this.previousInputValue = null;\n    this.activeUserSearchEngine = null;\n    this.selection = this.initialSelectionValue;\n    this.seenTabToOpenCompletionList = false;\n    this.lastRequestId = null;\n  }\n\n  updateSelection() {\n    // For suggestions from custom search engines, we copy the suggestion's text into the input when\n    // the suggestion is selected, and revert when it is not. This allows the user to select a\n    // suggestion and then continue typing.\n    const completion = this.completions[this.selection];\n    const shouldReplaceInputWithSuggestion = this.selection >= 0 &&\n      completion.insertText != null;\n    if (shouldReplaceInputWithSuggestion) {\n      if (this.previousInputValue == null) {\n        this.previousInputValue = this.input.value;\n      }\n      this.input.value = completion.insertText;\n    } else if (this.previousInputValue != null) {\n      this.input.value = this.previousInputValue;\n      this.previousInputValue = null;\n    }\n\n    // Highlight the selected entry.\n    for (const [i, el] of Object.entries(this.completionList.children)) {\n      el.className = i == this.selection ? \"selected\" : \"\";\n    }\n  }\n\n  // Returns the user's action (\"up\", \"down\", \"tab\", etc, or null) based on their keypress. We\n  // support the arrow keys and various other shortcuts, and this function hides the event-decoding\n  // complexity.\n  actionFromKeyEvent(event) {\n    const key = KeyboardUtils.getKeyChar(event);\n    // Handle <Enter> on \"keypress\", and other events on \"keydown\". This avoids interence with CJK\n    // translation (see #2915 and #2934).\n    if ((event.type === \"keypress\") && (key !== \"enter\")) return null;\n    if ((event.type === \"keydown\") && (key === \"enter\")) return null;\n    if (KeyboardUtils.isEscape(event)) {\n      return \"dismiss\";\n    } else if (\n      (key === \"up\") ||\n      (event.shiftKey && (event.key === \"Tab\")) ||\n      (event.ctrlKey && ((key === \"k\") || (key === \"p\")))\n    ) {\n      return \"up\";\n    } else if ((event.key === \"Tab\") && !event.shiftKey) {\n      return \"tab\";\n    } else if (\n      (key === \"down\") ||\n      (event.ctrlKey && ((key === \"j\") || (key === \"n\")))\n    ) {\n      return \"down\";\n    } else if (event.ctrlKey && (key === \"enter\")) {\n      return \"ctrl-enter\";\n    } else if (event.key === \"Enter\") {\n      return \"enter\";\n    } else if ((event.key === \"Delete\") && event.shiftKey && !event.ctrlKey && !event.altKey) {\n      return \"remove\";\n    } else if (KeyboardUtils.isBackspace(event)) {\n      return \"delete\";\n    }\n\n    return null;\n  }\n\n  async onKeyEvent(event) {\n    const action = this.actionFromKeyEvent(event);\n    if (!action) {\n      return;\n    }\n\n    if (action === \"dismiss\") {\n      this.hide();\n    } else if ([\"tab\", \"down\"].includes(action)) {\n      if (\n        (action === \"tab\") &&\n        (this.completerName === \"omni\") &&\n        !this.seenTabToOpenCompletionList &&\n        (this.input.value.trim().length === 0)\n      ) {\n        this.seenTabToOpenCompletionList = true;\n        this.update();\n      } else if (this.completions.length > 0) {\n        this.selection += 1;\n        if (this.selection === this.completions.length) {\n          this.selection = this.initialSelectionValue;\n        }\n        this.updateSelection();\n      }\n    } else if (action === \"up\") {\n      this.selection -= 1;\n      if (this.selection < this.initialSelectionValue) {\n        this.selection = this.completions.length - 1;\n      }\n      this.updateSelection();\n    } else if (action === \"enter\") {\n      await this.handleEnterKey(event);\n    } else if (action === \"ctrl-enter\") {\n      // Populate the vomnibar with the current selection's URL.\n      if (!this.isUserSearchEngineActive() && (this.selection >= 0)) {\n        if (this.previousInputValue == null) {\n          this.previousInputValue = this.input.value;\n        }\n        this.input.value = this.completions[this.selection]?.url;\n        this.input.scrollLeft = this.input.scrollWidth;\n      }\n    } else if (action === \"delete\") {\n      if (this.isUserSearchEngineActive() && (this.input.selectionEnd === 0)) {\n        // Normally, with custom search engines, the keyword (e.g. the \"w\" of \"w query terms\") is\n        // suppressed. If the cursor is at the start of the input, then reinstate the keyword (the\n        // \"w\").\n        const keyword = this.activeUserSearchEngine.keyword;\n        this.input.value = keyword + this.input.value.trimStart();\n        this.input.selectionStart = this.input.selectionEnd = keyword.length;\n        this.activeUserSearchEngine = null;\n        this.update();\n      } else if (this.seenTabToOpenCompletionList && (this.input.value.trim().length === 0)) {\n        this.seenTabToOpenCompletionList = false;\n        this.update();\n      } else {\n        return; // Do not suppress event.\n      }\n    } else if ((action === \"remove\") && (this.selection >= 0)) {\n      const completion = this.completions[this.selection];\n      console.log(completion);\n    }\n\n    event.stopImmediatePropagation();\n    event.preventDefault();\n  }\n\n  async handleEnterKey(event) {\n    const isPrimarySearchSuggestion = (c) => c?.isPrimarySuggestion && c?.isCustomSearch;\n    let query = this.input.value.trim();\n\n    // Note that it's possible that this.completions is empty. This can happen in practice if the\n    // user hits enter quickly after loading the vomnibar, before the filterCompletions request to\n    // the background page finishes.\n    const waitingOnCompletions = this.completions.length == 0;\n    const completion = this.completions[this.selection];\n\n    const openInNewTab = this.forceNewTab || event.shiftKey || event.ctrlKey || event.altKey ||\n      event.metaKey;\n\n    // If the user types something and hits enter without selecting a completion from the list,\n    // then:\n    //   - If they've activated a custom search engine in the Vomnibar, then launch that search\n    //     using the typed-in query.\n    //   - Otherwise, open the query as a URL or create a default search as appropriate.\n    //\n    //  When launching a query in a custom search engine, the user may have typed more text than\n    //  that which is included in the URL associated with the primary suggestion, because the\n    //  suggestions are updated asynchronously. Therefore, to avoid a race condition, we construct\n    //  the search URL from the actual contents of the input (query).\n    if (waitingOnCompletions || this.selection == -1) {\n      // <Enter> on an empty query is a no-op.\n      if (query.length == 0) return;\n      const firstCompletion = this.completions[0];\n      const isPrimary = isPrimarySearchSuggestion(firstCompletion);\n      if (isPrimary) {\n        query = UrlUtils.createSearchUrl(query, firstCompletion.searchUrl);\n        await this.launchUrl(query);\n      } else {\n        // If the query looks like a URL, try to open it directly. Otherwise, pass the query to\n        // the user's default search engine.\n        // TODO(philc):\n        const isUrl = await UrlUtils.isUrl(query);\n        if (isUrl) {\n          this.hide(() => this.launchUrl(query, openInNewTab));\n        } else {\n          this.hide(() =>\n            chrome.runtime.sendMessage({\n              handler: \"launchSearchQuery\",\n              query,\n              openInNewTab,\n            })\n          );\n        }\n      }\n    } else if (isPrimarySearchSuggestion(completion)) {\n      query = UrlUtils.createSearchUrl(query, completion.searchUrl);\n      this.hide(() => this.launchUrl(query, openInNewTab));\n    } else {\n      this.hide(() => this.openCompletion(completion, openInNewTab));\n    }\n  }\n\n  // Return the background-page query corresponding to the current input state. In other words,\n  // reinstate any search engine keyword which is currently being suppressed, and strip any prompted\n  // text.\n  getInputValueAsQuery() {\n    const prefix = this.isUserSearchEngineActive() ? this.activeUserSearchEngine.keyword + \" \" : \"\";\n    return prefix + this.input.value;\n  }\n\n  async updateCompletions() {\n    const requestId = Utils.createUniqueId();\n    this.lastRequestId = requestId;\n    const query = this.getInputValueAsQuery();\n    const queryTerms = query.trim().split(/\\s+/).filter((s) => s.length > 0);\n\n    const results = await chrome.runtime.sendMessage({\n      handler: \"filterCompletions\",\n      completerName: this.completerName,\n      queryTerms,\n      query,\n      seenTabToOpenCompletionList: this.seenTabToOpenCompletionList,\n    });\n\n    // Ensure that no new filter requests have gone out while waiting for this result.\n    if (this.lastRequestId != requestId) return;\n\n    this.completions = results;\n    this.selection = this.completions[0]?.autoSelect ? 0 : this.initialSelectionValue;\n    this.renderCompletions(this.completions);\n    this.selection = Math.min(\n      this.completions.length - 1,\n      Math.max(this.initialSelectionValue, this.selection),\n    );\n    this.updateSelection();\n  }\n\n  renderCompletions(completions) {\n    this.completionList.innerHTML = completions.map((c) => `<li>${c.html}</li>`).join(\"\");\n    this.completionList.style.display = completions.length > 0 ? \"block\" : \"\";\n  }\n\n  refreshCompletions() {\n    chrome.runtime.sendMessage({\n      handler: \"refreshCompletions\",\n      completerName: this.completerName,\n    });\n  }\n\n  cancelCompletions() {\n    // Let the background page's completer optionally abandon any pending query, because the user is\n    // typing and another query will arrive soon.\n    chrome.runtime.sendMessage({\n      handler: \"cancelCompletions\",\n      completerName: this.completerName,\n    });\n  }\n\n  onInput() {\n    this.seenTabToOpenCompletionList = false;\n    this.cancelCompletions();\n\n    // For custom search engines, we suppress the leading prefix (e.g. the \"w\" of \"w query terms\")\n    // within the vomnibar input.\n    if (!this.isUserSearchEngineActive() && this.getUserSearchEngineForQuery() != null) {\n      this.activeUserSearchEngine = this.getUserSearchEngineForQuery();\n      const queryTerms = this.input.value.trim().split(/\\s+/);\n      this.input.value = queryTerms.slice(1).join(\" \");\n    }\n\n    // If the user types, then don't reset any previous text, and reset the selection.\n    if (this.previousInputValue != null) {\n      this.previousInputValue = null;\n      this.selection = -1;\n    }\n    this.update();\n  }\n\n  // Returns the UserSearchEngine for the given query. Returns null if the query does not begin with\n  // a keyword from one of the user's search engines.\n  getUserSearchEngineForQuery() {\n    // This logic is duplicated from SearchEngineCompleter.getEngineForQueryPrefix\n    const parts = this.input.value.trimStart().split(/\\s+/);\n    // For a keyword \"w\", we match \"w search terms\" and \"w \", but not \"w\" on its own.\n    const keyword = parts[0];\n    if (parts.length <= 1) return null;\n    // Don't match queries for built-in properties like \"constructor\". See #4396.\n    if (Object.hasOwn(userSearchEngines.keywordToEngine, keyword)) {\n      return userSearchEngines.keywordToEngine[keyword];\n    }\n    return null;\n  }\n\n  async update() {\n    await this.updateCompletions();\n    this.input.focus();\n  }\n\n  openCompletion(completion, openInNewTab) {\n    if (completion.description == \"tab\") {\n      chrome.runtime.sendMessage({ handler: \"selectSpecificTab\", id: completion.tabId });\n    } else {\n      this.launchUrl(completion.url, openInNewTab);\n    }\n  }\n\n  async launchUrl(url, openInNewTab) {\n    // If the URL is a bookmarklet (so, prefixed with \"javascript:\"), then always open it in the\n    // current tab.\n    if (openInNewTab && UrlUtils.hasJavascriptProtocol(url)) {\n      openInNewTab = false;\n    }\n    await chrome.runtime.sendMessage({\n      handler: openInNewTab ? \"openUrlInNewTab\" : \"openUrlInCurrentTab\",\n      url,\n    });\n  }\n\n  initDom() {\n    this.box = document.getElementById(\"vomnibar\");\n\n    this.input = this.box.querySelector(\"input\");\n    this.input.addEventListener(\"input\", this.onInput);\n    this.input.addEventListener(\"keydown\", this.onKeyEvent);\n    this.input.addEventListener(\"keypress\", this.onKeyEvent);\n    this.completionList = this.box.querySelector(\"ul\");\n    this.completionList.style.display = \"\";\n\n    window.addEventListener(\"focus\", () => this.input.focus());\n    // A click in the vomnibar itself refocuses the input.\n    this.box.addEventListener(\"click\", (event) => {\n      this.input.focus();\n      return event.stopImmediatePropagation();\n    });\n    // A click anywhere else hides the vomnibar.\n    document.addEventListener(\"click\", () => this.hide());\n  }\n}\n\nlet vomnibarInstance;\n\nfunction init() {\n  UIComponentMessenger.init();\n  UIComponentMessenger.registerHandler(function (event) {\n    switch (event.data.name) {\n      case \"hide\":\n        ui?.hide();\n        break;\n      case \"hidden\":\n        ui?.onHidden();\n        break;\n      case \"activate\":\n        const options = Object.assign({}, event.data);\n        delete options.name;\n        activate(options);\n        break;\n      default:\n        Utils.assert(false, \"Unrecognized message type.\", event.data);\n    }\n  });\n}\n\nconst testEnv = globalThis.window == null ||\n  globalThis.window.location.search.includes(\"dom_tests=true\");\nif (!testEnv) {\n  document.addEventListener(\"DOMContentLoaded\", async () => {\n    await Settings.onLoaded();\n    DomUtils.injectUserCss(); // Manually inject custom user styles.\n  });\n  init();\n}\n"
  },
  {
    "path": "resources/tlds.txt",
    "content": "aaa\naarp\nabarth\nabb\nabbott\nabbvie\nabc\nable\nabogado\nabudhabi\nac\nacademy\naccenture\naccountant\naccountants\naco\nactive\nactor\nad\nadac\nads\nadult\nae\naeg\naero\naetna\naf\nafamilycompany\nafl\nafrica\nag\nagakhan\nagency\nai\naig\naigo\nairbus\nairforce\nairtel\nakdn\nal\nalfaromeo\nalibaba\nalipay\nallfinanz\nallstate\nally\nalsace\nalstom\nam\namazon\namericanexpress\namericanfamily\namex\namfam\namica\namsterdam\nan\nanalytics\nandroid\nanquan\nanz\nao\naol\napartments\napp\napple\naq\naquarelle\nar\narab\naramco\narchi\narmy\narpa\nart\narte\nas\nasda\nasia\nassociates\nat\nathleta\nattorney\nau\nauction\naudi\naudible\naudio\nauspost\nauthor\nauto\nautos\navianca\naw\naws\nax\naxa\naz\nazure\nba\nbaby\nbaidu\nbanamex\nbananarepublic\nband\nbank\nbar\nbarcelona\nbarclaycard\nbarclays\nbarefoot\nbargains\nbaseball\nbasketball\nbauhaus\nbayern\nbb\nbbc\nbbt\nbbva\nbcg\nbcn\nbd\nbe\nbeats\nbeauty\nbeer\nbentley\nberlin\nbest\nbestbuy\nbet\nbf\nbg\nbh\nbharti\nbi\nbible\nbid\nbike\nbing\nbingo\nbio\nbiz\nbj\nbl\nblack\nblackfriday\nblanco\nblockbuster\nblog\nbloomberg\nblue\nbm\nbms\nbmw\nbn\nbnl\nbnpparibas\nbo\nboats\nboehringer\nbofa\nbom\nbond\nboo\nbook\nbooking\nboots\nbosch\nbostik\nboston\nbot\nboutique\nbox\nbq\nbr\nbradesco\nbridgestone\nbroadway\nbroker\nbrother\nbrussels\nbs\nbt\nbudapest\nbugatti\nbuild\nbuilders\nbusiness\nbuy\nbuzz\nbv\nbw\nby\nbz\nbzh\nca\ncab\ncafe\ncal\ncall\ncalvinklein\ncam\ncamera\ncamp\ncancerresearch\ncanon\ncapetown\ncapital\ncapitalone\ncar\ncaravan\ncards\ncare\ncareer\ncareers\ncars\ncartier\ncasa\ncase\ncaseih\ncash\ncasino\ncat\ncatering\ncatholic\ncba\ncbn\ncbre\ncbs\ncc\ncd\nceb\ncenter\nceo\ncern\ncf\ncfa\ncfd\ncg\nch\nchanel\nchannel\ncharity\nchase\nchat\ncheap\nchintai\nchloe\nchristmas\nchrome\nchrysler\nchurch\nci\ncipriani\ncircle\ncisco\ncitadel\nciti\ncitic\ncity\ncityeats\nck\ncl\nclaims\ncleaning\nclick\nclinic\nclinique\nclothing\ncloud\nclub\nclubmed\ncm\ncn\nco\ncoach\ncodes\ncoffee\ncollege\ncologne\ncom\ncomcast\ncommbank\ncommunity\ncompany\ncompare\ncomputer\ncomsec\ncondos\nconstruction\nconsulting\ncontact\ncontractors\ncooking\ncookingchannel\ncool\ncoop\ncorsica\ncountry\ncoupon\ncoupons\ncourses\ncpa\ncr\ncredit\ncreditcard\ncreditunion\ncricket\ncrown\ncrs\ncruise\ncruises\ncsc\ncu\ncuisinella\ncv\ncw\ncx\ncy\ncymru\ncyou\ncz\ndabur\ndad\ndance\ndata\ndate\ndating\ndatsun\nday\ndclk\ndds\nde\ndeal\ndealer\ndeals\ndegree\ndelivery\ndell\ndeloitte\ndelta\ndemocrat\ndental\ndentist\ndesi\ndesign\ndev\ndhl\ndiamonds\ndiet\ndigital\ndirect\ndirectory\ndiscount\ndiscover\ndish\ndiy\ndj\ndk\ndm\ndnp\ndo\ndocs\ndoctor\ndodge\ndog\ndoha\ndomains\ndoosan\ndot\ndownload\ndrive\ndtv\ndubai\nduck\ndunlop\nduns\ndupont\ndurban\ndvag\ndvr\ndz\nearth\neat\nec\neco\nedeka\nedu\neducation\nee\neg\neh\nemail\nemerck\nenergy\nengineer\nengineering\nenterprises\nepost\nepson\nequipment\ner\nericsson\nerni\nes\nesq\nestate\nesurance\net\netisalat\neu\neurovision\neus\nevents\neverbank\nexchange\nexpert\nexposed\nexpress\nextraspace\nfage\nfail\nfairwinds\nfaith\nfamily\nfan\nfans\nfarm\nfarmers\nfashion\nfast\nfedex\nfeedback\nferrari\nferrero\nfi\nfiat\nfidelity\nfido\nfilm\nfinal\nfinance\nfinancial\nfire\nfirestone\nfirmdale\nfish\nfishing\nfit\nfitness\nfj\nfk\nflickr\nflights\nflir\nflorist\nflowers\nflsmidth\nfly\nfm\nfo\nfoo\nfood\nfoodnetwork\nfootball\nford\nforex\nforsale\nforum\nfoundation\nfox\nfr\nfree\nfresenius\nfrl\nfrogans\nfrontdoor\nfrontier\nftr\nfujitsu\nfujixerox\nfun\nfund\nfurniture\nfutbol\nfyi\nga\ngal\ngallery\ngallo\ngallup\ngame\ngames\ngap\ngarden\ngay\ngb\ngbiz\ngd\ngdn\nge\ngea\ngent\ngenting\ngeorge\ngf\ngg\nggee\ngh\ngi\ngift\ngifts\ngives\ngiving\ngl\nglade\nglass\ngle\nglobal\nglobo\ngm\ngmail\ngmbh\ngmo\ngmx\ngn\ngodaddy\ngold\ngoldpoint\ngolf\ngoo\ngoodhands\ngoodyear\ngoog\ngoogle\ngop\ngot\ngov\ngp\ngq\ngr\ngrainger\ngraphics\ngratis\ngreen\ngripe\ngrocery\ngroup\ngs\ngt\ngu\nguardian\ngucci\nguge\nguide\nguitars\nguru\ngw\ngy\nhair\nhamburg\nhangout\nhaus\nhbo\nhdfc\nhdfcbank\nhealth\nhealthcare\nhelp\nhelsinki\nhere\nhermes\nhgtv\nhiphop\nhisamitsu\nhitachi\nhiv\nhk\nhkt\nhm\nhn\nhockey\nholdings\nholiday\nhomedepot\nhomegoods\nhomes\nhomesense\nhonda\nhoneywell\nhorse\nhospital\nhost\nhosting\nhot\nhoteles\nhotels\nhotmail\nhouse\nhow\nhr\nhsbc\nht\nhtc\nhu\nhughes\nhyatt\nhyundai\nibm\nicbc\nice\nicu\nid\nie\nieee\nifm\niinet\nikano\nil\nim\nimamat\nimdb\nimmo\nimmobilien\nin\ninc\nindustries\ninfiniti\ninfo\ning\nink\ninstitute\ninsurance\ninsure\nint\nintel\ninternational\nintuit\ninvestments\nio\nipiranga\niq\nir\nirish\nis\niselect\nismaili\nist\nistanbul\nit\nitau\nitv\niveco\niwc\njaguar\njava\njcb\njcp\nje\njeep\njetzt\njewelry\njio\njlc\njll\njm\njmp\njnj\njo\njobs\njoburg\njot\njoy\njp\njpmorgan\njprs\njuegos\njuniper\nkaufen\nkddi\nke\nkerryhotels\nkerrylogistics\nkerryproperties\nkfh\nkg\nkh\nki\nkia\nkids\nkim\nkinder\nkindle\nkitchen\nkiwi\nkm\nkn\nkoeln\nkomatsu\nkosher\nkp\nkpmg\nkpn\nkr\nkrd\nkred\nkuokgroup\nkw\nky\nkyoto\nkz\nla\nlacaixa\nladbrokes\nlamborghini\nlamer\nlancaster\nlancia\nlancome\nland\nlandrover\nlanxess\nlasalle\nlat\nlatino\nlatrobe\nlaw\nlawyer\nlb\nlc\nlds\nlease\nleclerc\nlefrak\nlegal\nlego\nlexus\nlgbt\nli\nliaison\nlidl\nlife\nlifeinsurance\nlifestyle\nlighting\nlike\nlilly\nlimited\nlimo\nlincoln\nlinde\nlink\nlipsy\nlive\nliving\nlixil\nlk\nllc\nllp\nloan\nloans\nlocker\nlocus\nloft\nlol\nlondon\nlotte\nlotto\nlove\nlpl\nlplfinancial\nlr\nls\nlt\nltd\nltda\nlu\nlundbeck\nlupin\nluxe\nluxury\nlv\nly\nma\nmacys\nmadrid\nmaif\nmaison\nmakeup\nman\nmanagement\nmango\nmap\nmarket\nmarketing\nmarkets\nmarriott\nmarshalls\nmaserati\nmattel\nmba\nmc\nmcd\nmcdonalds\nmckinsey\nmd\nme\nmed\nmedia\nmeet\nmelbourne\nmeme\nmemorial\nmen\nmenu\nmeo\nmerckmsd\nmetlife\nmf\nmg\nmh\nmiami\nmicrosoft\nmil\nmini\nmint\nmit\nmitsubishi\nmk\nml\nmlb\nmls\nmm\nmma\nmn\nmo\nmobi\nmobile\nmobily\nmoda\nmoe\nmoi\nmom\nmonash\nmoney\nmonster\nmontblanc\nmopar\nmormon\nmortgage\nmoscow\nmoto\nmotorcycles\nmov\nmovie\nmovistar\nmp\nmq\nmr\nms\nmsd\nmt\nmtn\nmtpc\nmtr\nmu\nmuseum\nmusic\nmutual\nmutuelle\nmv\nmw\nmx\nmy\nmz\nna\nnab\nnadex\nnagoya\nname\nnationwide\nnatura\nnavy\nnba\nnc\nne\nnec\nnet\nnetbank\nnetflix\nnetwork\nneustar\nnew\nnewholland\nnews\nnext\nnextdirect\nnexus\nnf\nnfl\nng\nngo\nnhk\nni\nnico\nnike\nnikon\nninja\nnissan\nnissay\nnl\nno\nnokia\nnorthwesternmutual\nnorton\nnow\nnowruz\nnowtv\nnp\nnr\nnra\nnrw\nntt\nnu\nnyc\nnz\nobi\nobserver\noff\noffice\nokinawa\nolayan\nolayangroup\noldnavy\nollo\nom\nomega\none\nong\nonl\nonline\nonyourside\nooo\nopen\noracle\norange\norg\norganic\norientexpress\norigins\nosaka\notsuka\nott\novh\npa\npage\npamperedchef\npanasonic\npanerai\nparis\npars\npartners\nparts\nparty\npassagens\npay\npccw\npe\npet\npf\npfizer\npg\nph\npharmacy\nphd\nphilips\nphone\nphoto\nphotography\nphotos\nphysio\npiaget\npics\npictet\npictures\npid\npin\nping\npink\npioneer\npizza\npk\npl\nplace\nplay\nplaystation\nplumbing\nplus\npm\npn\npnc\npohl\npoker\npolitie\nporn\npost\npr\npramerica\npraxi\npress\nprime\npro\nprod\nproductions\nprof\nprogressive\npromo\nproperties\nproperty\nprotection\npru\nprudential\nps\npt\npub\npw\npwc\npy\nqa\nqpon\nquebec\nquest\nqvc\nracing\nradio\nraid\nre\nread\nrealestate\nrealtor\nrealty\nrecipes\nred\nredstone\nredumbrella\nrehab\nreise\nreisen\nreit\nreliance\nren\nrent\nrentals\nrepair\nreport\nrepublican\nrest\nrestaurant\nreview\nreviews\nrexroth\nrich\nrichardli\nricoh\nrightathome\nril\nrio\nrip\nrmit\nro\nrocher\nrocks\nrodeo\nrogers\nroom\nrs\nrsvp\nru\nrugby\nruhr\nrun\nrw\nrwe\nryukyu\nsa\nsaarland\nsafe\nsafety\nsakura\nsale\nsalon\nsamsclub\nsamsung\nsandvik\nsandvikcoromant\nsanofi\nsap\nsapo\nsarl\nsas\nsave\nsaxo\nsb\nsbi\nsbs\nsc\nsca\nscb\nschaeffler\nschmidt\nscholarships\nschool\nschule\nschwarz\nscience\nscjohnson\nscor\nscot\nsd\nse\nsearch\nseat\nsecure\nsecurity\nseek\nselect\nsener\nservices\nses\nseven\nsew\nsex\nsexy\nsfr\nsg\nsh\nshangrila\nsharp\nshaw\nshell\nshia\nshiksha\nshoes\nshop\nshopping\nshouji\nshow\nshowtime\nshriram\nsi\nsilk\nsina\nsingles\nsite\nsj\nsk\nski\nskin\nsky\nskype\nsl\nsling\nsm\nsmart\nsmile\nsn\nsncf\nso\nsoccer\nsocial\nsoftbank\nsoftware\nsohu\nsolar\nsolutions\nsong\nsony\nsoy\nspa\nspace\nspiegel\nsport\nspot\nspreadbetting\nsr\nsrl\nsrt\nss\nst\nstada\nstaples\nstar\nstarhub\nstatebank\nstatefarm\nstatoil\nstc\nstcgroup\nstockholm\nstorage\nstore\nstream\nstudio\nstudy\nstyle\nsu\nsucks\nsupplies\nsupply\nsupport\nsurf\nsurgery\nsuzuki\nsv\nswatch\nswiftcover\nswiss\nsx\nsy\nsydney\nsymantec\nsystems\nsz\ntab\ntaipei\ntalk\ntaobao\ntarget\ntatamotors\ntatar\ntattoo\ntax\ntaxi\ntc\ntci\ntd\ntdk\nteam\ntech\ntechnology\ntel\ntelecity\ntelefonica\ntemasek\ntennis\nteva\ntf\ntg\nth\nthd\ntheater\ntheatre\ntiaa\ntickets\ntienda\ntiffany\ntips\ntires\ntirol\ntj\ntjmaxx\ntjx\ntk\ntkmaxx\ntl\ntm\ntmall\ntn\nto\ntoday\ntokyo\ntools\ntop\ntoray\ntoshiba\ntotal\ntours\ntown\ntoyota\ntoys\ntp\ntr\ntrade\ntrading\ntraining\ntravel\ntravelchannel\ntravelers\ntravelersinsurance\ntrust\ntrv\ntt\ntube\ntui\ntunes\ntushu\ntv\ntvs\ntw\ntz\nua\nubank\nubs\nuconnect\nug\nuk\num\nunicom\nuniversity\nuno\nuol\nups\nus\nuy\nuz\nva\nvacations\nvana\nvanguard\nvc\nve\nvegas\nventures\nverisign\nversicherung\nvet\nvg\nvi\nviajes\nvideo\nvig\nviking\nvillas\nvin\nvip\nvirgin\nvisa\nvision\nvista\nvistaprint\nviva\nvivo\nvlaanderen\nvn\nvodka\nvolkswagen\nvolvo\nvote\nvoting\nvoto\nvoyage\nvu\nvuelos\nwales\nwalmart\nwalter\nwang\nwanggou\nwarman\nwatch\nwatches\nweather\nweatherchannel\nwebcam\nweber\nwebsite\nwed\nwedding\nweibo\nweir\nwf\nwhoswho\nwien\nwiki\nwilliamhill\nwin\nwindows\nwine\nwinners\nwme\nwolterskluwer\nwoodside\nwork\nworks\nworld\nwow\nws\nwtc\nwtf\nxbox\nxerox\nxfinity\nxihuan\nxin\n测试\nकॉम\nपरीक्षा\nセール\n佛山\nಭಾರತ\n慈善\n集团\n在线\n한국\nଭାରତ\n大众汽车\n点看\nคอม\nভাৰত\nভারত\n八卦\n.ישראל‎\n.موقع‎\nবাংলা\n公益\n公司\n香格里拉\n网站\n移动\n我爱你\nмосква\nиспытание\nқаз\nкатолик\nонлайн\nсайт\n联通\nсрб\nбг\nбел\n.קום‎\n时尚\n微博\n테스트\n淡马锡\nファッション\nорг\nनेट\nストア\nアマゾン\n삼성\nசிங்கப்பூர்\n商标\n商店\n商城\nдети\nмкд\n.טעסט‎\nею\nポイント\n新闻\n工行\n家電\n.كوم‎\n中文网\n中信\n中国\n中國\n娱乐\n谷歌\nభారత్\nලංකා\n電訊盈科\n购物\n測試\nクラウド\nભારત\n通販\nभारतम्\nभारत\nभारोत\n.آزمایشی‎\nபரிட்சை\n网店\nसंगठन\n餐厅\n网络\nком\nукр\n香港\n亚马逊\n诺基亚\n食品\nδοκιμή\n飞利浦\n.إختبار‎\n台湾\n台灣\n手表\n手机\nмон\n.الجزائر‎\n.عمان‎\n.ارامكو‎\n.ایران‎\n.العليان‎\n.اتصالات‎\n.امارات‎\n.بازار‎\n.موريتانيا‎\n.پاکستان‎\n.الاردن‎\n.موبايلي‎\n.بارت‎\n.بھارت‎\n.المغرب‎\n.ابوظبي‎\n.البحرين‎\n.السعودية‎\n.ڀارت‎\n.كاثوليك‎\n.سودان‎\n.همراه‎\n.عراق‎\n.مليسيا‎\n澳門\n닷컴\n政府\n.شبكة‎\n.بيتك‎\n.عرب‎\nგე\n机构\n组织机构\n健康\nไทย\n.سورية‎\n招聘\nрус\nрф\n珠宝\n.تونس‎\n大拿\nລາວ\nみんな\nグーグル\nευ\nελ\n世界\n書籍\nഭാരതം\nਭਾਰਤ\n网址\n닷넷\nコム\n天主教\n游戏\nvermögensberater\nvermögensberatung\n企业\n信息\n嘉里大酒店\n嘉里\n.مصر‎\n.قطر‎\n广东\nஇலங்கை\nஇந்தியா\nհայ\n新加坡\n.فلسطين‎\nテスト\n政务\nxperia\nxxx\nxyz\nyachts\nyahoo\nyamaxun\nyandex\nye\nyodobashi\nyoga\nyokohama\nyou\nyoutube\nyt\nyun\nza\nzappos\nzara\nzero\nzip\nzippo\nzm\nzone\nzuerich\nzw"
  },
  {
    "path": "test_harnesses/cross_origin_iframe.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"UTF-8\">\n    <link rel=\"icon\" href=\"data:;base64,iVBORw0KGgo=\">\n    <title>Cross origin iFrame</title>\n    <style type=\"text/css\" media=\"screen\">\n      body {\n        background-color: black;\n        color: #999;\n        height: 4000px;\n      }\n\n      iframe {\n        width: 800px;\n        height: 400px;\n        border: 2px solid green;\n      }\n    </style>\n  </head>\n\n  <body>\n    Iframe from a different origin as its parent:<br>\n    <iframe src=\"https://example.com\" />\n  </body>\n</html>\n"
  },
  {
    "path": "test_harnesses/event_capture.html",
    "content": "<!--\n  This is a test harness to determine whether a page can install event listeners before Vimium's\n  content scripts can.\n-->\n<!DOCTYPE html>\n<html>\n  <head>\n    <title>Event capture</title>\n    <link rel=\"icon\" href=\"data:;base64,iVBORw0KGgo=\">\n    <script>\n      window.addEventListener(\"keydown\", (event) => {\n        console.log(\n          \"Page's received keydown. Vimium in normal mode should not allow this.\",\n        );\n        event.preventDefault();\n        event.stopPropagation();\n      }, true);\n    </script>\n    <style type=\"text/css\" media=\"screen\">\n      body {\n        background-color: black;\n      }\n    </style>\n  </head>\n\n  <body></body>\n</html>\n"
  },
  {
    "path": "test_harnesses/form.html",
    "content": "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\"\n  \"http://www.w3.org/TR/html4/strict.dtd\">\n<html>\n  <head>\n    <title>Page with forms</title>\n    <link rel=\"icon\" href=\"data:;base64,iVBORw0KGgo=\">\n  </head>\n\n  <body>\n    <form action=\"#\">\n      <p>\n        Text: <input type=\"text\" name=\"text\" value=\"\" />\n        Text 2: <input type=\"text\" name=\"text\" value=\"\" />\n        Email: <input type=\"email\" name=\"text\" value=\"\" />\n        No Type: <input name=\"text\" value=\"\" />\n      </p>\n      <p>\n        Search: <input type=\"search\" />\n        Search 2: <input type=\"search\" />\n      </p>\n      <p>\n        Radio:<br>\n        Maryland<input type=\"radio\" name=\"group1\" value=\"Maryland\" />\n        <br>\n        California<input type=\"radio\" name=\"group1\" value=\"California\" />\n      </p>\n      <p>\n        <select>\n          <option value=\"Maryland\">Maryland</option>\n          <option value=\"California\">California</option>\n        </select>\n      </p>\n      <p>\n        Button: <input type=\"button\" value=\"button\" />\n      </p>\n      <p>\n        Submit: <input type=\"submit\" />\n      </p>\n    </form>\n  </body>\n</html>\n"
  },
  {
    "path": "test_harnesses/has_popup_and_link_hud.html",
    "content": "<!--\n  This page when loaded causes Chrome to show both its \"link\" and \"popup blocked\" HUDs.\n  (mouse over the giant link to see the link HUD)\n  -->\n<!DOCTYPE html>\n<html>\n  <head>\n    <title>Link and popup HUD</title>\n    <link rel=\"icon\" href=\"data:;base64,iVBORw0KGgo=\">\n    <style type=\"text/css\" media=\"screen\">\n      body {\n        height: 900px;\n        width: 800px;\n      }\n\n      #biglink {\n        display: block;\n        width: 400px;\n        height: 400px;\n        background-color: #123456;\n        float: left;\n      }\n\n      #hud {\n        position: fixed;\n        bottom: 0px;\n        color: black;\n        /*      right:150px;*/\n        right: 315px;\n        height: 13px;\n        max-width: 400px;\n        min-width: 150px;\n        text-align: left;\n        background-color: #ebebeb;\n        font-weight: normal;\n        font-size: 11px;\n        padding: 3px 3px 2px 3px;\n        border: 1px solid #b3b3b3;\n        font-family: \"Lucida Grande\", Arial, Sans;\n        z-index: 99999999999;\n        text-shadow: 0px 1px 2px #fff;\n        line-height: 1.0;\n\n        border-radius: 4px 4px 0 0;\n      }\n      #hud a, #hud a:hover {\n        color: blue;\n      }\n      #hud .close-button {\n        font-family: \"courier new\";\n        font-weight: bold;\n        color: #9c9a9a;\n        text-decoration: none;\n        padding-left: 10px;\n        font-size: 14px;\n      }\n      #hud .close-button:hover {\n        color: #333333;\n        cursor: default;\n        -webkit-user-select: none;\n      }\n    </style>\n\n    <script>\n      // Trigger Chrome's popup HUD. It's inside the URL bar.\n      window.open(\"https://www.google.com\");\n    </script>\n  </head>\n  <body>\n    <h2>Loading and popup HUD</h2>\n    <a\n      id=\"biglink\"\n      href=\"http://ninjawords.com/thisShouldEventuallyGetTruncatedBecauseItsAReallyLongLink,very,long,indeed\"\n    >Big link</a>\n\n    <input type=\"text\" name=\"some_name\" value=\"\" id=\"some_name\" />\n    <div id=\"hud\">\n      Vimium has been updated to 1.14.\n      <a href=\"#\">See the changes</a>.\n      <span class=\"close-button\" href=\"#\">x</span>\n    </div>\n  </body>\n</html>\n"
  },
  {
    "path": "test_harnesses/iframe.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <title>IFrame test harness</title>\n    <link rel=\"icon\" href=\"data:;base64,iVBORw0KGgo=\">\n    <style type=\"text/css\" media=\"screen\">\n      body {\n        background-color: black;\n        color: #999;\n        height: 4000px;\n      }\n      a {\n        color: white;\n      }\n\n      iframe {\n        border: 2px solid orange;\n        width: 800px;\n        height: 800px;\n        margin-left: 10px;\n      }\n\n      h2 {\n        margin: 4px;\n      }\n    </style>\n  </head>\n\n  <body>\n    <h2>IFrame test page</h2>\n    <input type=\"text\" />\n    <a href=\"https://google.com\">Sample link</a>\n    <br>\n    <iframe src=\"./page_with_links.html\"></iframe>\n    <iframe src=\"./page_with_links.html\"></iframe>\n  </body>\n</html>\n"
  },
  {
    "path": "test_harnesses/page_with_links.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <title>Page with many links</title>\n    <meta http-equiv=\"Content-type\" content=\"text/html;charset=UTF-8\">\n    <link rel=\"icon\" href=\"data:;base64,iVBORw0KGgo=\">\n    <style type=\"text/css\" media=\"screen\">\n      body {\n        background-color: black;\n        color: #eee;\n      }\n      a {\n        background-color: #666;\n        color: #eee;\n      }\n\n      a:hover {\n        background-color: #123456;\n      }\n\n      a#paddingLink {\n        padding: 30px;\n      }\n\n      a#paddingLinkTop {\n        padding-top: 50px;\n      }\n      #div-a {\n        border: 1px solid #333;\n        padding: 5px;\n      }\n    </style>\n  </head>\n  <body>\n    <a href=\"#multilineLink\" id=\"multilineLink\">This will be a link spanning two<br> lines</a>\n\n    <br>\n    <br>\n    <br>\n    <br>\n\n    <a href=\"#paddingLink\" id=\"paddingLink\">This link has a lot of vertical padding</a>\n\n    <br>\n    <br>\n    <br>\n    <br>\n    <br>\n    <br>\n    <br>\n    <br>\n\n    <a href=\"#paddingLinkTop\" id=\"paddingLinkTop\"\n    >This link has a lot of vertical padding on the top</a>\n\n    <br>\n    <br>\n    <div id=\"div-a\" onclick=\"alert('hi')\">div with an onclick attribute</div>\n\n    <br>\n    <br>\n\n    <a name=\"anchorSpot\">An anchor with just a name</a>\n\n    <br>\n    <br>\n    <a href=\"https://www.google.com\">Next</a> and <a href=\"https://www.ninjawords.com\">previous</a>\n    links.\n\n    <br>\n    <br>\n\n    Below is an image map:<br>\n    <img src=\"./image_map.png\" usemap=\"#the-image-map\">\n\n    <map name=\"the-image-map\">\n      <area shape=\"rect\" coords=\"0,0,100,50\" alt=\"Section A\" href=\"#section-A\">\n      <area shape=\"rect\" coords=\"200,0,300,50\" alt=\"Section B\" href=\"http://google.com\">\n    </map>\n  </body>\n</html>\n"
  },
  {
    "path": "test_harnesses/visibility_test.html",
    "content": "<!--\n  This demonstrates the effectiveness a method for testing for visibility elements on various\n  conditions.\n-->\n<!DOCTYPE html>\n<html>\n  <head>\n    <title>Visibility test</title>\n    <link rel=\"icon\" href=\"data:;base64,iVBORw0KGgo=\">\n    <style type=\"text/css\" media=\"screen\">\n      * {\n        font-family: sans;\n        font-size: 14px;\n      }\n      span {\n        display: block;\n        width: 30px;\n        height: 20px;\n        background-color: blue;\n      }\n      table {\n        border-collapse: separate;\n      }\n      tr, td {\n        border: 2px solid black;\n        margin: 0;\n        padding: 2px;\n      }\n    </style>\n\n    <script charset=\"utf-8\">\n      window.addEventListener(\"load\", displayTests, false);\n      window.addEventListener(\"load\", checkDivsForVisibility, false);\n\n      var visibilityTests = [\n        {\n          \"description\": \"BoundingClientRect is inside viewport\",\n          \"test\": function (element) {\n            var rect = element.getBoundingClientRect();\n            if (\n              !rect || rect.top > window.innerHeight || rect.bottom < 0 ||\n              rect.left > window.innerWidth || rect.right < 0\n            ) {\n              return false;\n            }\n            return true;\n          },\n        },\n        {\n          \"description\": \"BoundingClientRect has nonzero dimensions\",\n          \"test\": function (element) {\n            var rect = element.getBoundingClientRect();\n            if (!rect || rect.width == 0 || rect.height == 0) {\n              return false;\n            }\n            return true;\n          },\n        },\n        {\n          \"description\": \"Is visible and displayed\",\n          \"test\": function (element) {\n            var computedStyle = window.getComputedStyle(element, null);\n            if (\n              computedStyle.getPropertyValue(\"visibility\") != \"visible\" ||\n              computedStyle.getPropertyValue(\"display\") == \"none\"\n            ) {\n              return false;\n            }\n            return true;\n          },\n        },\n        {\n          \"description\": \"Has ClientRect\",\n          \"test\": function (element) {\n            var clientRect = element.getClientRects()[0];\n            if (!clientRect) {\n              return false;\n            }\n            return true;\n          },\n        },\n        {\n          \"description\": \"ClientRect has nonzero dimensions\",\n          \"test\": function (element) {\n            var clientRect = element.getClientRects()[0];\n            if (!clientRect || clientRect.width == 0 || clientRect.height == 0) {\n              return false;\n            }\n            return true;\n          },\n        },\n      ];\n\n      function displayTests() {\n        for (var i = 0; i < visibilityTests.length; i++) {\n          document.getElementById(\"testDescriptions\").innerHTML += \"<b>Test \" + (i + 1) +\n            \": </b>\" + visibilityTests[i].description + \"<br/>\";\n        }\n      }\n\n      function makeBoolTd(bool) {\n        var td = document.createElement(\"td\");\n        td.style.width = \"15px\";\n        td.style.background = bool ? \"#0f0\" : \"#f00\";\n        return td;\n      }\n\n      function makeTag(tag, text) {\n        var td = document.createElement(tag);\n        td.innerHTML = text;\n        return td;\n      }\n\n      function checkDivsForVisibility() {\n        var table = document.getElementById(\"resultsDisplay\");\n        var tr = document.getElementsByTagName(\"tr\")[0];\n        for (var i = 0; i < visibilityTests.length; i++) {\n          tr.appendChild(makeTag(\"th\", i + 1));\n        }\n        tr.appendChild(makeTag(\"th\", \"Expected Result\"));\n        tr.appendChild(makeTag(\"th\", \"Comments\"));\n\n        var divs = document.getElementsByClassName(\"testElement\");\n        for (var i = 0; i < divs.length; i++) {\n          var tr = document.createElement(\"tr\");\n          table.appendChild(tr);\n          table.appendChild(makeTag(\"td\", i + 1));\n          var netResult = true;\n          for (var j = 0; j < visibilityTests.length; j++) {\n            result = visibilityTests[j].test(divs[i]);\n            table.appendChild(makeBoolTd(result));\n            netResult = netResult && result;\n          }\n          var expectedResult = divs[i].getAttribute(\"data-expectedresult\") == 1;\n          var td = makeTag(\"td\", expectedResult);\n          td.style.background = netResult === expectedResult ? \"#fff\" : \"#ccf\";\n          table.appendChild(td);\n          table.appendChild(makeTag(\"td\", divs[i].getAttribute(\"data-comment\")));\n\n          // hide the test cases once we're done with them\n          divs[i].style.visibility = \"hidden\";\n        }\n      }\n    </script>\n  </head>\n\n  <body>\n    <div id=\"testDescriptions\" style=\"margin: 6px 0 6px 0\"></div>\n    <table id=\"resultsDisplay\">\n      <tr>\n        <th>Node/Test</th>\n      </tr>\n    </table>\n\n    <div id=\"testContainer\" style=\"position: absolute; top: 0; left: 0\">\n      <span class=\"testElement\" data-expectedresult=\"1\" data-comment=\"default\">test</span>\n\n      <span\n        class=\"testElement\"\n        data-expectedresult=\"0\"\n        style=\"visibility: hidden\"\n        data-comment=\"visibility: hidden\"\n      >test</span>\n\n      <div style=\"visibility: hidden\">\n        <span\n          class=\"testElement\"\n          data-expectedresult=\"0\"\n          data-comment=\"nested in an element that has visibility: hidden\"\n        >test</span>\n      </div>\n\n      <span\n        class=\"testElement\"\n        data-expectedresult=\"0\"\n        style=\"display: none\"\n        data-comment=\"display: none\"\n      >test</span>\n\n      <div style=\"display: none\">\n        <span\n          class=\"testElement\"\n          data-expectedresult=\"0\"\n          data-comment=\"nested in an element that has display: none\"\n        >test</span>\n      </div>\n\n      <span\n        class=\"testElement\"\n        data-expectedresult=\"0\"\n        style=\"position: absolute; top: 2000px\"\n        data-comment=\"outside viewport\"\n      >test</span>\n\n      <div style=\"opacity: 0\">\n        <span\n          class=\"testElement\"\n          data-expectedresult=\"1\"\n          data-comment=\"nested in an element that has opacity:0\"\n        >test</span>\n      </div>\n      <div\n        class=\"testElement\"\n        data-expectedresult=\"1\"\n        data-comment=\"Contains only a floated span. We must recurse into the div to find it.\"\n      >\n        <span style=\"float: left\">test</span>\n      </div>\n      <svg>\n        <a\n          class=\"testElement\"\n          data-expectedresult=\"1\"\n          xlink:href=\"http://www.example.com/\"\n          data-comment=\"This link is contained within an SVG.\"\n        >\n          <text x=\"0\" y=\"68\">test</text>\n        </a>\n      </svg>\n    </div>\n  </body>\n</html>\n"
  },
  {
    "path": "test_harnesses/vomnibar_harness.html",
    "content": "<!--\n  This harness is used to show our vomnibar with some sample suggestions. It's a convenient way to\n  restyle the Vomnibar without having to make changes, reload the extension, refresh the page, open\n  it and type something.\n -->\n<!DOCTYPE html>\n<html>\n  <head>\n    <title>Vomnibar harness</title>\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"../content_scripts/vimium.css\" />\n    <link rel=\"icon\" href=\"data:,\">\n    <script src=\"./vomnibar_harness.js\" type=\"module\"></script>\n  </head>\n\n  <body class=\"vimium-body\">\n    Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut\n    labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco\n    laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\n    voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat\n    non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n\n    <br>\n    <br>\n\n    Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut\n    labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco\n    laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in\n    voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat\n    non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n  </body>\n</html>\n"
  },
  {
    "path": "test_harnesses/vomnibar_harness.js",
    "content": "import \"../pages/all_content_scripts.js\";\nimport \"../pages/vomnibar_page.js\";\n\nfunction setup() {\n  Vomnibar.activate(0, {});\n}\n\ndocument.addEventListener(\"DOMContentLoaded\", setup, false);\n"
  },
  {
    "path": "tests/dom_tests/dom_test_setup.js",
    "content": "globalThis.vimiumDomTestsAreRunning = true;\n\nimport * as shoulda from \"../vendor/shoulda.js\";\n\n// Attach shoulda's functions -- like setup, context, should -- to the global namespace.\nObject.assign(globalThis, shoulda);\nglobalThis.shoulda = shoulda;\n\ndocument.addEventListener(\"DOMContentLoaded\", async () => {\n  isEnabledForUrl = true;\n  await Settings.onLoaded();\n  await HUD.init();\n});\n"
  },
  {
    "path": "tests/dom_tests/dom_tests.html",
    "content": "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.01//EN\"\n  \"http://www.w3.org/TR/html4/strict.dtd\">\n<html>\n  <head>\n    <link rel=\"stylesheet\" type=\"text/css\" href=\"../../content_scripts/vimium.css\" />\n    <link rel=\"icon\" href=\"data:,\">\n    <script src=\"../../lib/chrome_api_stubs.js\"></script>\n    <script src=\"../../lib/utils.js\"></script>\n    <script src=\"../../lib/keyboard_utils.js\"></script>\n    <script src=\"../../lib/dom_utils.js\"></script>\n    <script src=\"../../lib/rect.js\"></script>\n    <script src=\"../../lib/handler_stack.js\"></script>\n    <script src=\"../../lib/settings.js\"></script>\n    <script src=\"../../lib/find_mode_history.js\"></script>\n    <script src=\"../../content_scripts/mode.js\"></script>\n    <script src=\"../../content_scripts/marks.js\"></script>\n    <script src=\"../../content_scripts/ui_component.js\"></script>\n    <script src=\"../../content_scripts/link_hints.js\"></script>\n    <script src=\"../../content_scripts/vomnibar.js\"></script>\n    <script src=\"../../content_scripts/scroller.js\"></script>\n    <script src=\"../../content_scripts/mode_insert.js\"></script>\n    <script src=\"../../content_scripts/mode_find.js\"></script>\n    <script src=\"../../content_scripts/mode_key_handler.js\"></script>\n    <script src=\"../../content_scripts/mode_visual.js\"></script>\n    <script src=\"../../content_scripts/hud.js\"></script>\n    <script src=\"../../content_scripts/mode_normal.js\"></script>\n    <script src=\"../../content_scripts/vimium_frontend.js\"></script>\n\n    <script type=\"module\" src=\"dom_test_setup.js\"></script>\n    <script type=\"module\" src=\"dom_tests.js\"></script>\n    <script type=\"module\" src=\"dom_utils_test.js\"></script>\n  </head>\n  <body>\n    <!-- should always be the first element on the page -->\n    <div id=\"test-div\"></div>\n\n    <h1>Vimium Tests</h1>\n  </body>\n</html>\n"
  },
  {
    "path": "tests/dom_tests/dom_tests.js",
    "content": "let commandCount = null;\nlet commandName = null;\n\n// Some tests have side effects on the handler stack and the active mode, so these are reset on\n// setup. Also, some tests affect the focus (e.g. Vomnibar tests), so we make sure the window has\n// the focus.\nconst initializeModeState = () => {\n  globalThis.focus();\n  Mode.reset();\n  handlerStack.reset();\n  const normalMode = installModes();\n  normalMode.setPassKeys(\"p\");\n  normalMode.setKeyMapping({\n    m: { options: {}, command: \"m\" }, // A mapped key.\n    p: { options: {}, command: \"p\" }, // A pass key.\n    z: { p: { options: {}, command: \"zp\" } }, // Not a pass key.\n  });\n  normalMode.setCommandHandler(({ command, count }) => {\n    [commandName, commandCount] = [command.command, count];\n  });\n  commandName = null;\n  commandCount = null;\n  return normalMode;\n};\n\n//\n// Retrieve the hint markers as an array object.\n//\nconst getHintMarkerEls = () => Array.from(document.querySelectorAll(\".vimiumHintMarker\"));\n\nconst stubSettings = (key, value) => stub(Settings._settings, key, value);\n\nHintCoordinator.sendMessage = (name, request) => {\n  if (request == null) {\n    request = {};\n  }\n  if (HintCoordinator[name]) {\n    HintCoordinator[name](request);\n  }\n  return request;\n};\n\nconst activateLinkHintsMode = () => {\n  HintCoordinator.getHintDescriptors({ modeIndex: 0 }, {}, () => {});\n  HintCoordinator.activateMode({\n    frameIdToHintDescriptors: {},\n    modeIndex: 0,\n    originatingFrameId: frameId,\n  });\n  return HintCoordinator.linkHintsMode;\n};\n\n//\n// Generate tests that are common to both default and filtered\n// link hinting modes.\n//\nconst createGeneralHintTests = (isFilteredMode) => {\n  globalThis.vimiumOnClickAttributeName = \"does-not-matter\";\n\n  context(\"Link hints\", () => {\n    setup(() => {\n      initializeModeState();\n      const testContent = \"<a>test</a><a>tress</a>\";\n      document.getElementById(\"test-div\").innerHTML = testContent;\n      stubSettings(\"filterLinkHints\", isFilteredMode);\n      stubSettings(\"linkHintCharacters\", \"ab\");\n      stubSettings(\"linkHintNumbers\", \"12\");\n      stub(globalThis, \"windowIsFocused\", () => true);\n    });\n\n    teardown(() => document.getElementById(\"test-div\").innerHTML = \"\");\n\n    should(\"create hints when activated, discard them when deactivated\", () => {\n      const mode = activateLinkHintsMode();\n      assert.isFalse(mode.containerEl == null);\n      mode.deactivateMode();\n      assert.isTrue(mode.containerEl == null);\n    });\n\n    should(\"position items correctly\", () => {\n      const assertStartPosition = (element1, element2) => {\n        assert.equal(element1.getClientRects()[0].left, element2.getClientRects()[0].left);\n        assert.equal(element1.getClientRects()[0].top, element2.getClientRects()[0].top);\n      };\n      stub(document.body.style, \"position\", \"static\");\n      let mode = activateLinkHintsMode();\n      let markerEls = getHintMarkerEls();\n      assertStartPosition(document.getElementsByTagName(\"a\")[0], markerEls[0]);\n      assertStartPosition(document.getElementsByTagName(\"a\")[1], markerEls[1]);\n      mode.deactivateMode();\n      stub(document.body.style, \"position\", \"relative\");\n      mode = activateLinkHintsMode();\n      markerEls = getHintMarkerEls();\n      assertStartPosition(document.getElementsByTagName(\"a\")[0], markerEls[0]);\n      assertStartPosition(document.getElementsByTagName(\"a\")[1], markerEls[1]);\n      mode.deactivateMode();\n    });\n  });\n};\n\ncreateGeneralHintTests(false);\ncreateGeneralHintTests(true);\n\ncontext(\"False positives in link-hint\", () => {\n  setup(() => {\n    const testContent = '<span class=\"buttonWrapper\">false positive<a>clickable</a></span>' +\n      '<span class=\"buttonWrapper\">clickable</span>';\n    document.getElementById(\"test-div\").innerHTML = testContent;\n    stubSettings(\"filterLinkHints\", true);\n    stubSettings(\"linkHintNumbers\", \"12\");\n    stub(globalThis, \"windowIsFocused\", () => true);\n  });\n\n  teardown(() => document.getElementById(\"test-div\").innerHTML = \"\");\n\n  should(\"handle false positives\", () => {\n    const mode = activateLinkHintsMode();\n    mode.deactivateMode();\n    assert.equal([\"clickable\", \"clickable\"], mode.hintMarkers.map((m) => m.linkText));\n  });\n});\n\ncontext(\"jsaction matching\", () => {\n  let element;\n\n  setup(() => {\n    stubSettings(\"filterLinkHints\", true);\n    const testContent = '<p id=\"test-paragraph\">clickable</p>';\n    document.getElementById(\"test-div\").innerHTML = testContent;\n    element = document.getElementById(\"test-paragraph\");\n  });\n\n  teardown(() => document.getElementById(\"test-div\").innerHTML = \"\");\n\n  should(\"select jsaction elements\", () => {\n    for (const text of [\"click:namespace.actionName\", \"namespace.actionName\"]) {\n      element.setAttribute(\"jsaction\", text);\n      const mode = activateLinkHintsMode();\n      mode.deactivateMode();\n      assert.equal(1, mode.hintMarkers.length);\n      assert.equal(\"clickable\", mode.hintMarkers[0].linkText);\n      assert.equal(element, mode.hintMarkers[0].localHint.element);\n    }\n  });\n\n  should(\"not select inactive jsaction elements\", () => {\n    const attributes = [\n      \"mousedown:namespace.actionName\",\n      \"click:namespace._\",\n      \"none\",\n      \"namespace:_\",\n    ];\n    for (const attribute of attributes) {\n      element.setAttribute(\"jsaction\", attribute);\n      const linkHints = activateLinkHintsMode();\n      const hintMarkers = getHintMarkerEls().filter((marker) => marker.linkText !== \"Frame.\");\n      linkHints.deactivateMode();\n      assert.equal(0, hintMarkers.length);\n    }\n  });\n});\n\ncontext(\"link hints for image maps\", () => {\n  setup(() => {\n    const testContent = '<img usemap=\"#the-map\" style=\"width: 50px; height: 50px\">' +\n      '<map name=\"the-map\">' +\n      '<area shape=\"rect\" coords=\"0,0,20,50\" href=\"#\">' +\n      '<area shape=\"rect\" coords=\"0,30,30,50\" href=\"#\">' +\n      \"</area>\";\n    document.getElementById(\"test-div\").innerHTML = testContent;\n  });\n\n  teardown(() => document.getElementById(\"test-div\").innerHTML = \"\");\n\n  should(\"generate a hint for each area in the image map\", () => {\n    const mode = activateLinkHintsMode();\n    const markerEls = getHintMarkerEls();\n    assert.equal(2, markerEls.length);\n    mode.deactivateMode();\n  });\n});\n\nconst sendKeyboardEvent = (key, type, extra) => {\n  if (type == null) type = \"keydown\";\n  if (extra == null) extra = {};\n  handlerStack.bubbleEvent(\n    type,\n    Object.assign(extra, {\n      type,\n      key,\n      preventDefault() {},\n      stopImmediatePropagation() {},\n    }),\n  );\n};\n\nconst sendKeyboardEvents = (keys) => {\n  for (const key of keys.split(\"\")) {\n    sendKeyboardEvent(key);\n  }\n};\n\n// TODO(philc): For some reason, this test corrupts the state linkhints state for other tests, in particular,\n// the alphabet hints tests. I haven't yet dug into why.\n// const inputs = [];\n// context(\"Test link hints for focusing input elements correctly\", () => {\n//   let linkHintsMode;\n\n//   setup(() => {\n//     let input;\n//     initializeModeState();\n//     const testDiv = document.getElementById(\"test-div\");\n//     testDiv.innerHTML = \"\";\n\n//     stubSettings(\"filterLinkHints\", false);\n//     stubSettings(\"linkHintCharacters\", \"ab\");\n\n//     // Every HTML5 input type except for hidden. We should be able to activate all of them with link hints.\n//     // NOTE(philc): I'm not sure why, but \"image\" doesn't get a link hint in Puppeteer, so I've omitted it.\n//     const inputTypes = [\"button\", \"checkbox\", \"color\", \"date\", \"datetime\", \"datetime-local\", \"email\", \"file\",\n//       \"month\", \"number\", \"password\", \"radio\", \"range\", \"reset\", \"search\", \"submit\", \"tel\", \"text\",\n//       \"time\", \"url\", \"week\"];\n\n//     for (let type of inputTypes) {\n//       input = document.createElement(\"input\");\n//       input.type = type;\n//       testDiv.appendChild(input);\n//       inputs.push(input);\n//     }\n\n//     // Manually add also a select element to test focus.\n//     input = document.createElement(\"select\");\n//     testDiv.appendChild(input);\n//     inputs.push(input);\n//   });\n\n//   teardown(() => {\n//     document.getElementById(\"test-div\").innerHTML = \"\";\n//     // linkHintsMode.deactivateMode(); // TODO(philc): I don't think this should be necessary.\n//   });\n\n//   should(\"Focus each input when its hint text is typed\", () => {\n//     for (var input of inputs) {\n//       input.scrollIntoView(); // Ensure the element is visible so we create a link hint for it.\n\n//       const activeListener = ensureCalled(function(event) {\n//         if (event.type === \"focus\") { return input.blur(); }\n//       });\n//       input.addEventListener(\"focus\", activeListener, false);\n//       input.addEventListener(\"click\", activeListener, false);\n\n//       linkHintsMode = activateLinkHintsMode();\n//       const [hint] = getHintMarkerEls().\n//             filter(hint => input === HintCoordinator.getLocalHint(hint.hintDescriptor).element);\n\n//       for (let char of hint.hintString)\n//         sendKeyboardEvent(char);\n//       linkHintsMode.deactivateMode();\n\n//       input.removeEventListener(\"focus\", activeListener, false);\n//       input.removeEventListener(\"click\", activeListener, false);\n//     }\n//   });\n// });\n\ncontext(\"Test link hints for changing mode\", () => {\n  let linkHints;\n\n  setup(() => {\n    initializeModeState();\n    const testDiv = document.getElementById(\"test-div\");\n    testDiv.innerHTML = \"<a>link</a>\";\n    linkHints = activateLinkHintsMode();\n  });\n\n  teardown(() => {\n    document.getElementById(\"test-div\").innerHTML = \"\";\n    linkHints.deactivateMode();\n  });\n\n  should(\"change mode on shift\", () => {\n    assert.equal(\"curr-tab\", linkHints.mode.name);\n    sendKeyboardEvent(\"Shift\", \"keydown\");\n    assert.equal(\"bg-tab\", linkHints.mode.name);\n    sendKeyboardEvent(\"Shift\", \"keyup\");\n    assert.equal(\"curr-tab\", linkHints.mode.name);\n  });\n\n  should(\"change mode on ctrl\", () => {\n    assert.equal(\"curr-tab\", linkHints.mode.name);\n    sendKeyboardEvent(\"Control\", \"keydown\");\n    assert.equal(\"fg-tab\", linkHints.mode.name);\n    sendKeyboardEvent(\"Control\", \"keyup\");\n    assert.equal(\"curr-tab\", linkHints.mode.name);\n  });\n});\n\nconst createLinks = function (n) {\n  for (let i = 0, end = n; i < end; i++) {\n    const link = document.createElement(\"a\");\n    link.textContent = \"test\";\n    document.getElementById(\"test-div\").appendChild(link);\n  }\n};\n\ncontext(\"Alphabet link hints\", () => {\n  let mode;\n  setup(() => {\n    initializeModeState();\n    stubSettings(\"filterLinkHints\", false);\n    stubSettings(\"linkHintCharacters\", \"ab\");\n    stub(globalThis, \"windowIsFocused\", () => true);\n\n    document.getElementById(\"test-div\").innerHTML = \"\";\n    // Three hints will trigger double hint chars.\n    createLinks(3);\n    mode = activateLinkHintsMode();\n  });\n\n  teardown(() => {\n    mode.deactivateMode();\n    document.getElementById(\"test-div\").innerHTML = \"\";\n  });\n\n  should(\"label the hints correctly\", () => {\n    assert.equal(\n      [\"aa\", \"b\", \"ab\"],\n      mode.hintMarkers.map((m) => m.hintString),\n    );\n  });\n\n  should(\"narrow the hints\", () => {\n    sendKeyboardEvent(\"a\");\n    assert.equal(\n      [\"\", \"none\", \"\"],\n      mode.hintMarkers.map((m) => m.element.style.display),\n    );\n  });\n\n  should(\"generate the correct number of alphabet hints\", () => {\n    const alphabetHints = new AlphabetHints();\n    for (const n of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {\n      const hintStrings = alphabetHints.hintStrings(n);\n      assert.equal(n, hintStrings.length);\n    }\n  });\n\n  should(\"generate non-overlapping alphabet hints\", () => {\n    const alphabetHints = new AlphabetHints();\n    for (const n of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) {\n      const hintStrings = alphabetHints.hintStrings(n);\n      for (const h1 of hintStrings) {\n        for (const h2 of hintStrings) {\n          if (h1 !== h2) {\n            assert.isFalse(0 === h1.indexOf(h2));\n          }\n        }\n      }\n    }\n  });\n});\n\ncontext(\"Filtered link hints\", () => {\n  // In all of these tests, the order of the elements returned by getHintMarkerEls() may be\n  // different from the order they are listed in the test HTML content. This is because\n  // LinkHints.activateMode() sorts the elements.\n\n  let mode;\n\n  setup(() => {\n    stubSettings(\"filterLinkHints\", true);\n    stubSettings(\"linkHintNumbers\", \"0123456789\");\n    stub(globalThis, \"windowIsFocused\", () => true);\n  });\n\n  context(\"Text hints\", () => {\n    setup(() => {\n      initializeModeState();\n      const testContent = \"<a>test</a><a>tress</a><a>trait</a><a>track<img alt='alt text'/></a>\";\n      document.getElementById(\"test-div\").innerHTML = testContent;\n      mode = activateLinkHintsMode();\n    });\n\n    teardown(() => {\n      document.getElementById(\"test-div\").innerHTML = \"\";\n      mode.deactivateMode();\n    });\n\n    should(\"label the hints\", () => {\n      const hintMarkers = getHintMarkerEls();\n      const expectedMarkers = [1, 2, 3, 4].map((m) => m.toString());\n      const actualMarkers = [0, 1, 2, 3].map((i) => hintMarkers[i].textContent.toLowerCase());\n      assert.equal(expectedMarkers.length, actualMarkers.length);\n      for (const marker of expectedMarkers) {\n        assert.isTrue(actualMarkers.includes(marker));\n      }\n    });\n\n    should(\"narrow the hints\", () => {\n      sendKeyboardEvent(\"t\");\n      sendKeyboardEvent(\"r\");\n      assert.equal(\n        [\"none\", \"\", \"\", \"\"],\n        mode.hintMarkers.map((m) => m.element.style.display),\n      );\n      assert.equal(\"3\", mode.hintMarkers[1].hintString);\n      sendKeyboardEvent(\"a\");\n      assert.equal(\"1\", mode.hintMarkers[3].hintString);\n    });\n\n    // This test is the same as above, but with an extra non-matching character. The effect should\n    // be the same.\n    should(\"narrow the hints and ignore typing mistakes\", () => {\n      sendKeyboardEvent(\"t\");\n      sendKeyboardEvent(\"r\");\n      sendKeyboardEvent(\"x\");\n      assert.equal(\n        [\"none\", \"\", \"\", \"\"],\n        mode.hintMarkers.map((m) => m.element.style.display),\n      );\n      assert.equal(\"3\", mode.hintMarkers[1].hintString);\n      sendKeyboardEvent(\"a\");\n      assert.equal(\"1\", mode.hintMarkers[3].hintString);\n    });\n  });\n\n  context(\"Image hints\", () => {\n    setup(() => {\n      initializeModeState();\n      const testContent = \"<a><img alt='alt text' width='10px' height='10px'/></a>\" +\n        \"<a><img alt='alt text' title='some title' width='10px' height='10px'/></a>\" +\n        \"<a><img title='some title' width='10px' height='10px'/></a>\" +\n        \"<a><img src='' width='320px' height='100px'/></a>\";\n      document.getElementById(\"test-div\").innerHTML = testContent;\n      mode = activateLinkHintsMode();\n    });\n\n    teardown(() => {\n      document.getElementById(\"test-div\").innerHTML = \"\";\n      mode.deactivateMode();\n    });\n\n    should(\"label the images\", () => {\n      let hintMarkers = getHintMarkerEls().map((m) => m.textContent.toLowerCase());\n      // We don't know the actual hint numbers which will be assigned, so we replace them with \"N\".\n      hintMarkers = hintMarkers.map((str) => str.replace(/^[1-4]/, \"N\"));\n      assert.equal(4, hintMarkers.length);\n      assert.isTrue(hintMarkers.includes(\"N: alt text\"));\n      assert.isTrue(hintMarkers.includes(\"N: some title\"));\n      assert.isTrue(hintMarkers.includes(\"N: alt text\"));\n      assert.isTrue(hintMarkers.includes(\"N\"));\n    });\n  });\n\n  context(\"Input hints\", () => {\n    setup(() => {\n      initializeModeState();\n      const testContent =\n        `<input type='text' value='some value'/><input type='password' value='some value'/> \\\n<textarea>some text</textarea><label for='test-input'/>a label</label> \\\n<input type='text' id='test-input' value='some value'/> \\\n<label for='test-input-2'/>a label: </label><input type='text' id='test-input-2' value='some value'/>`;\n      document.getElementById(\"test-div\").innerHTML = testContent;\n      mode = activateLinkHintsMode();\n    });\n\n    teardown(() => {\n      document.getElementById(\"test-div\").innerHTML = \"\";\n      mode.deactivateMode();\n    });\n\n    should(\"label the input elements\", () => {\n      let hintMarkers = getHintMarkerEls();\n      hintMarkers = getHintMarkerEls().map((m) => m.textContent.toLowerCase());\n      // We don't know the actual hint numbers which will be assigned, so we replace them with \"N\".\n      hintMarkers = hintMarkers.map((str) => str.replace(/^[0-9]+/, \"N\"));\n      assert.equal(5, hintMarkers.length);\n      assert.isTrue(hintMarkers.includes(\"N\"));\n      assert.isTrue(hintMarkers.includes(\"N\"));\n      assert.isTrue(hintMarkers.includes(\"N: a label\"));\n      assert.isTrue(hintMarkers.includes(\"N: a label\"));\n      assert.isTrue(hintMarkers.includes(\"N\"));\n    });\n  });\n\n  context(\"Text hint scoring\", () => {\n    let getActiveHintMarker;\n\n    setup(() => {\n      initializeModeState();\n      const testContent = [\n        { id: 0, text: \"the xboy stood on the xburning deck\" }, // Noise.\n        { id: 1, text: \"the boy stood on the xburning deck\" }, // Whole word (boy).\n        { id: 2, text: \"on the xboy stood the xburning deck\" }, // Start of text (on).\n        { id: 3, text: \"the xboy stood on the xburning deck\" }, // Noise.\n        { id: 4, text: \"the xboy stood on the xburning deck\" }, // Noise.\n        { id: 5, text: \"the xboy stood on the xburning\" }, // Shortest text..\n        { id: 6, text: \"the xboy stood on the burning xdeck\" }, // Start of word (bu)\n        { id: 7, text: \"test abc one - longer\" }, // For tab test - 2.\n        { id: 8, text: \"test abc one\" }, // For tab test - 1.\n        { id: 9, text: \"test abc one - longer still\" }, // For tab test - 3.\n      ].map(({ id, text }) => `<a id=\\\"${id}\\\">${text}</a>`).join(\" \");\n      document.getElementById(\"test-div\").innerHTML = testContent;\n      mode = activateLinkHintsMode();\n\n      getActiveHintMarker = () => {\n        return HintCoordinator.getLocalHint(\n          mode.markerMatcher.activeHintMarker.hintDescriptor,\n        ).element.id;\n      };\n    });\n\n    teardown(() => {\n      document.getElementById(\"test-div\").innerHTML = \"\";\n      mode.deactivateMode();\n    });\n\n    should(\"score start-of-word matches highly\", () => {\n      sendKeyboardEvents(\"bu\");\n      assert.equal(\"6\", getActiveHintMarker());\n    });\n\n    should(\"score start-of-text matches highly (br)\", () => {\n      sendKeyboardEvents(\"on\");\n      assert.equal(\"2\", getActiveHintMarker());\n    });\n\n    should(\"score whole-word matches highly\", () => {\n      sendKeyboardEvents(\"boy\");\n      assert.equal(\"1\", getActiveHintMarker());\n    });\n\n    should(\"score shorter texts more highly\", () => {\n      sendKeyboardEvents(\"stood\");\n      assert.equal(\"5\", getActiveHintMarker());\n    });\n\n    should(\"use tab to select the active hint\", () => {\n      sendKeyboardEvents(\"abc\");\n      assert.equal(\"8\", getActiveHintMarker());\n      sendKeyboardEvent(\"Tab\", \"keydown\");\n      assert.equal(\"7\", getActiveHintMarker());\n      sendKeyboardEvent(\"Tab\", \"keydown\");\n      assert.equal(\"9\", getActiveHintMarker());\n    });\n  });\n});\n\ncontext(\"Input focus\", () => {\n  setup(() => {\n    initializeModeState();\n    const testContent = `<input type='text' id='first'/><input style='display:none;' id='second'/> \\\n<input type='password' id='third' value='some value'/>`;\n    document.getElementById(\"test-div\").innerHTML = testContent;\n  });\n\n  teardown(() => document.getElementById(\"test-div\").innerHTML = \"\"),\n    should(\"focus the first element\", () => {\n      NormalModeCommands.focusInput(1);\n      assert.equal(\"first\", document.activeElement.id);\n    });\n\n  should(\"focus the nth element\", () => {\n    NormalModeCommands.focusInput(100);\n    assert.equal(\"third\", document.activeElement.id);\n  });\n\n  should(\"activate insert mode on the first element\", () => {\n    NormalModeCommands.focusInput(1);\n    assert.isTrue(InsertMode.permanentInstance.isActive());\n  });\n\n  should(\"activate insert mode on the first element\", () => {\n    NormalModeCommands.focusInput(100);\n    assert.isTrue(InsertMode.permanentInstance.isActive());\n  });\n\n  should(\"activate the most recently-selected input if the count is 1\", () => {\n    NormalModeCommands.focusInput(3);\n    NormalModeCommands.focusInput(1);\n    assert.equal(\"third\", document.activeElement.id);\n  });\n\n  should(\"not trigger insert if there are no inputs\", () => {\n    document.getElementById(\"test-div\").innerHTML = \"\";\n    NormalModeCommands.focusInput(1);\n    assert.isFalse(InsertMode.permanentInstance.isActive());\n  });\n});\n\n// TODO: these find prev/next link tests could be refactored into unit tests which invoke a function\n// which has a tighter contract than goNext(), since they test minor aspects of goNext()'s link\n// matching behavior, and we don't need to construct external state many times over just to test\n// that. i.e. these tests should look something like:\n// assert.equal(findLink(html(\"<a href=...\">))[0].href, \"first\")\n// These could then move outside of the dom_tests file.\ncontext(\"Find prev / next links\", () => {\n  setup(() => {\n    initializeModeState();\n    globalThis.location.hash = \"\";\n  });\n\n  should(\"find exact matches\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n<a href='#first'>nextcorrupted</a>\n<a href='#second'>next page</a>\\\n`;\n    stubSettings(\"nextPatterns\", \"next\");\n    NormalModeCommands.goNext();\n    assert.equal(\"#second\", globalThis.location.hash);\n  });\n\n  should(\"match against non-word patterns\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n<a href='#first'>&gt;&gt;</a>\\\n`;\n    stubSettings(\"nextPatterns\", \">>\");\n    NormalModeCommands.goNext();\n    assert.equal(\"#first\", globalThis.location.hash);\n  });\n\n  should(\"favor matches with fewer words\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n<a href='#first'>lorem ipsum next</a>\n<a href='#second'>next!</a>\\\n`;\n    stubSettings(\"nextPatterns\", \"next\");\n    NormalModeCommands.goNext();\n    assert.equal(\"#second\", globalThis.location.hash);\n  });\n\n  should(\"find link relation in header\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n<link rel='next' href='#first'>\\\n`;\n    NormalModeCommands.goNext();\n    assert.equal(\"#first\", globalThis.location.hash);\n  });\n\n  should(\"favor link relation to text matching\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n<link rel='next' href='#first'>\n<a href='#second'>next</a>\\\n`;\n    NormalModeCommands.goNext();\n    assert.equal(\"#first\", globalThis.location.hash);\n  });\n\n  should(\"match mixed case link relation\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n<link rel='Next' href='#first'>\\\n`;\n    NormalModeCommands.goNext();\n    assert.equal(\"#first\", globalThis.location.hash);\n  });\n\n  should(\"match against the title attribute\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n<a title='Next page' href='#first'>unhelpful text</a>\\\n`;\n    NormalModeCommands.goNext();\n    assert.equal(\"#first\", globalThis.location.hash);\n  });\n\n  should(\"match against the aria-label attribute\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n<a aria-label='Next page' href='#first'>unhelpful text</a>\\\n`;\n    NormalModeCommands.goNext();\n    assert.equal(\"#first\", globalThis.location.hash);\n  });\n});\n\ncontext(\"Key mapping\", () => {\n  let normalMode, handlerCalled, handlerCalledCount;\n\n  setup(() => {\n    normalMode = initializeModeState();\n    handlerCalled = false;\n    handlerCalledCount = 0;\n    normalMode.setCommandHandler(({ count }) => {\n      handlerCalled = true;\n      handlerCalledCount = count;\n    });\n  });\n\n  should(\"recognize first mapped key\", () => {\n    assert.isTrue(normalMode.isMappedKey(\"m\"));\n  });\n\n  should(\"recognize second mapped key\", () => {\n    assert.isFalse(normalMode.isMappedKey(\"p\"));\n    sendKeyboardEvent(\"z\");\n    assert.isTrue(normalMode.isMappedKey(\"p\"));\n  });\n\n  should(\"recognize pass keys\", () => {\n    assert.isTrue(normalMode.isPassKey(\"p\"));\n  });\n\n  should(\"not mis-recognize pass keys\", () => {\n    assert.isFalse(normalMode.isMappedKey(\"p\"));\n    sendKeyboardEvent(\"z\");\n    assert.isTrue(normalMode.isMappedKey(\"p\"));\n  });\n\n  should(\"recognize initial count keys\", () => {\n    assert.isTrue(normalMode.isCountKey(\"1\"));\n    assert.isTrue(normalMode.isCountKey(\"9\"));\n  });\n\n  should(\"not recognize '0' as initial count key\", () => {\n    assert.isFalse(normalMode.isCountKey(\"0\"));\n  });\n\n  should(\"recognize subsequent count keys\", () => {\n    sendKeyboardEvent(\"1\");\n    assert.isTrue(normalMode.isCountKey(\"0\"));\n    assert.isTrue(normalMode.isCountKey(\"9\"));\n  });\n\n  should(\"set and call command handler\", () => {\n    sendKeyboardEvent(\"m\");\n    assert.isTrue(handlerCalled);\n  });\n\n  should(\"not call command handler for pass keys\", () => {\n    sendKeyboardEvent(\"p\");\n    assert.isFalse(handlerCalled);\n  });\n\n  should(\"accept a count prefix with a single digit\", () => {\n    sendKeyboardEvent(\"2\");\n    sendKeyboardEvent(\"m\");\n    assert.equal(2, handlerCalledCount);\n  });\n\n  should(\"accept a count prefix with multiple digits\", () => {\n    sendKeyboardEvent(\"2\");\n    sendKeyboardEvent(\"0\");\n    sendKeyboardEvent(\"m\");\n    assert.equal(20, handlerCalledCount);\n  });\n\n  should(\"cancel a count prefix\", () => {\n    sendKeyboardEvent(\"2\");\n    sendKeyboardEvent(\"z\");\n    sendKeyboardEvent(\"m\");\n    assert.equal(true, handlerCalled);\n    assert.equal(null, handlerCalledCount);\n  });\n\n  should(\"accept a count prefix for multi-key command mappings\", () => {\n    sendKeyboardEvent(\"5\");\n    sendKeyboardEvent(\"z\");\n    sendKeyboardEvent(\"p\");\n    assert.equal(5, handlerCalledCount);\n  });\n\n  should(\"cancel a key prefix\", () => {\n    sendKeyboardEvent(\"z\");\n    assert.equal(false, handlerCalled);\n    sendKeyboardEvent(\"m\");\n    assert.equal(true, handlerCalled);\n  });\n\n  should(\"cancel a count prefix after a prefix key\", () => {\n    sendKeyboardEvent(\"2\");\n    sendKeyboardEvent(\"z\");\n    sendKeyboardEvent(\"m\");\n    assert.equal(null, handlerCalledCount);\n  });\n\n  should(\"cancel a prefix key on escape\", () => {\n    sendKeyboardEvent(\"z\");\n    sendKeyboardEvent(\"Escape\", \"keydown\");\n    sendKeyboardEvent(\"p\");\n    assert.equal(0, handlerCalledCount);\n  });\n});\n\ncontext(\"Normal mode\", () => {\n  setup(() => initializeModeState());\n\n  should(\"invoke commands for mapped keys\", () => {\n    sendKeyboardEvent(\"m\");\n    assert.equal(\"m\", commandName);\n  });\n\n  should(\"invoke commands for mapped keys with a mapped prefix\", () => {\n    sendKeyboardEvent(\"z\");\n    sendKeyboardEvent(\"m\");\n    assert.equal(\"m\", commandName);\n  });\n\n  should(\"invoke commands for mapped keys with an unmapped prefix\", () => {\n    sendKeyboardEvent(\"a\");\n    sendKeyboardEvent(\"m\");\n    assert.equal(\"m\", commandName);\n  });\n\n  should(\"not invoke commands for pass keys\", () => {\n    sendKeyboardEvent(\"p\");\n    assert.equal(null, commandName);\n  });\n\n  should(\"not invoke commands for pass keys with an unmapped prefix\", () => {\n    sendKeyboardEvent(\"a\");\n    sendKeyboardEvent(\"p\");\n    assert.equal(null, commandName);\n  });\n\n  should(\"invoke commands for pass keys with a count\", () => {\n    sendKeyboardEvent(\"1\");\n    sendKeyboardEvent(\"p\");\n    assert.equal(\"p\", commandName);\n  });\n\n  should(\"invoke commands for pass keys with a key queue\", () => {\n    sendKeyboardEvent(\"z\");\n    sendKeyboardEvent(\"p\");\n    assert.equal(\"zp\", commandName);\n  });\n\n  should(\"accept count prefixes of length 1\", () => {\n    sendKeyboardEvent(\"2\");\n    sendKeyboardEvent(\"m\");\n    assert.equal(2, commandCount);\n  });\n\n  should(\"accept count prefixes of length 2\", () => {\n    sendKeyboardEvents(\"12\");\n    sendKeyboardEvent(\"m\");\n    assert.equal(12, commandCount);\n  });\n\n  should(\"get the correct count for mixed inputs (single key)\", () => {\n    sendKeyboardEvent(\"2\");\n    sendKeyboardEvent(\"z\");\n    sendKeyboardEvent(\"m\");\n    assert.equal(null, commandCount);\n  });\n\n  should(\"get the correct count for mixed inputs (multi key)\", () => {\n    sendKeyboardEvent(\"2\");\n    sendKeyboardEvent(\"z\");\n    sendKeyboardEvent(\"p\");\n    assert.equal(2, commandCount);\n  });\n\n  should(\"get the correct count for mixed inputs (multi key, duplicates)\", () => {\n    sendKeyboardEvent(\"2\");\n    sendKeyboardEvent(\"z\");\n    sendKeyboardEvent(\"z\");\n    sendKeyboardEvent(\"p\");\n    assert.equal(null, commandCount);\n  });\n\n  should(\"get the correct count for mixed inputs (with leading mapped keys)\", () => {\n    sendKeyboardEvent(\"z\");\n    sendKeyboardEvent(\"2\");\n    sendKeyboardEvent(\"m\");\n    assert.equal(2, commandCount);\n  });\n\n  should(\"get the correct count for mixed inputs (with leading unmapped keys)\", () => {\n    sendKeyboardEvent(\"a\");\n    sendKeyboardEvent(\"2\");\n    sendKeyboardEvent(\"m\");\n    assert.equal(2, commandCount);\n  });\n\n  should(\"not get a count after unmapped keys\", () => {\n    sendKeyboardEvent(\"2\");\n    sendKeyboardEvent(\"a\");\n    sendKeyboardEvent(\"m\");\n    assert.equal(null, commandCount);\n  });\n\n  should(\"get the correct count after unmapped keys\", () => {\n    sendKeyboardEvent(\"2\");\n    sendKeyboardEvent(\"a\");\n    sendKeyboardEvent(\"3\");\n    sendKeyboardEvent(\"m\");\n    assert.equal(3, commandCount);\n  });\n\n  should(\"not handle unmapped keys\", () => {\n    sendKeyboardEvent(\"u\");\n    assert.equal(null, commandCount);\n  });\n});\n\ncontext(\"Insert mode\", () => {\n  let insertMode;\n\n  setup(() => {\n    initializeModeState();\n    insertMode = new InsertMode({ global: true });\n  });\n\n  should(\"exit on escape\", () => {\n    assert.isTrue(insertMode.modeIsActive);\n    sendKeyboardEvent(\"Escape\", \"keydown\");\n    assert.isFalse(insertMode.modeIsActive);\n  });\n\n  should(\"resume normal mode after leaving insert mode\", () => {\n    assert.equal(null, commandName);\n    insertMode.exit();\n    sendKeyboardEvent(\"m\");\n    assert.equal(\"m\", commandName);\n  });\n});\n\ncontext(\"Triggering insert mode\", () => {\n  setup(() => {\n    initializeModeState();\n\n    const testContent = `<input type='text' id='first'/> \\\n<input style='display:none;' id='second'/> \\\n<input type='password' id='third' value='some value'/> \\\n<p id='fourth' contenteditable='true'/> \\\n<p id='fifth'/>`;\n    document.getElementById(\"test-div\").innerHTML = testContent;\n  });\n\n  teardown(() => {\n    if (document.activeElement != null) {\n      document.activeElement.blur();\n    }\n    document.getElementById(\"test-div\").innerHTML = \"\";\n  });\n\n  should(\"trigger insert mode on focus of text input\", () => {\n    assert.isFalse(InsertMode.permanentInstance.isActive());\n    document.getElementById(\"first\").focus();\n    assert.isTrue(InsertMode.permanentInstance.isActive());\n  });\n\n  should(\"trigger insert mode on focus of password input\", () => {\n    assert.isFalse(InsertMode.permanentInstance.isActive());\n    document.getElementById(\"third\").focus();\n    assert.isTrue(InsertMode.permanentInstance.isActive());\n  });\n\n  should(\"trigger insert mode on focus of contentEditable elements\", () => {\n    assert.isFalse(InsertMode.permanentInstance.isActive());\n    document.getElementById(\"fourth\").focus();\n    assert.isTrue(InsertMode.permanentInstance.isActive());\n  });\n\n  should(\"not trigger insert mode on other elements\", () => {\n    assert.isFalse(InsertMode.permanentInstance.isActive());\n    document.getElementById(\"fifth\").focus();\n    assert.isFalse(InsertMode.permanentInstance.isActive());\n  });\n});\n\n// NOTE(philc): I'm disabling the caret and visual mode tests because I think they're fallen into\n// disrepair, or we merged changes to master and neglected to update the tests. We should return to\n// these and fix+re-enable them.\n\n// context(\"Caret mode\",\n//   setup(() => {\n//     document.getElementById(\"test-div\").innerHTML = `\\\n// <p><pre>\n//   It is an ancient Mariner,\n//   And he stoppeth one of three.\n//   By thy long grey beard and glittering eye,\n//   Now wherefore stopp'st thou me?\n// </pre></p>\\\n// `;\n//     initializeModeState();\n//     this.initialVisualMode = new VisualMode;\n//   });\n\n//   teardown(() => document.getElementById(\"test-div\").innerHTML = \"\"),\n\n//   should(\"enter caret mode\", () => {\n//     assert.isFalse(this.initialVisualMode.modeIsActive);\n//     assert.equal(\"I\", getSelection());\n//   });\n\n//   should(\"exit caret mode on escape\", () => {\n//     sendKeyboardEvent(\"Escape\", \"keydown\");\n//     assert.equal(\"\", getSelection());\n//   });\n\n//   should(\"move caret with l and h\", () => {\n//     assert.equal(\"I\", getSelection());\n//     sendKeyboardEvent(\"l\");\n//     assert.equal(\"t\", getSelection());\n//     sendKeyboardEvent(\"h\");\n//     assert.equal(\"I\", getSelection());\n//   });\n\n//   should(\"move caret with w and b\", () => {\n//     assert.equal(\"I\", getSelection());\n//     sendKeyboardEvent(\"w\");\n//     assert.equal(\"i\", getSelection());\n//     sendKeyboardEvent(\"b\");\n//     assert.equal(\"I\", getSelection());\n//   });\n\n//   should(\"move caret with e\", () => {\n//     assert.equal(\"I\", getSelection());\n//     sendKeyboardEvent(\"e\");\n//     assert.equal(\" \", getSelection());\n//     sendKeyboardEvent(\"e\");\n//     assert.equal(\" \", getSelection());\n//   });\n\n//   should(\"move caret with j and k\", () => {\n//     assert.equal(\"I\", getSelection());\n//     sendKeyboardEvent(\"j\");\n//     assert.equal(\"A\", getSelection());\n//     sendKeyboardEvent(\"k\");\n//     assert.equal(\"I\", getSelection());\n//   });\n\n//   should(\"re-use an existing selection\", () => {\n//     assert.equal(\"I\", getSelection());\n//     sendKeyboardEvents(\"ww\");\n//     assert.equal(\"a\", getSelection());\n//     sendKeyboardEvent(\"Escape\", \"keydown\");\n//     new VisualMode;\n//     assert.equal(\"a\", getSelection());\n//   });\n\n//   should(\"not move the selection on caret/visual mode toggle\", () => {\n//     sendKeyboardEvents(\"ww\");\n//     assert.equal(\"a\", getSelection());\n//     for (let key of \"vcvcvc\".split()) {\n//       sendKeyboardEvent(key);\n//       assert.equal(\"a\", getSelection());\n//     }\n//   })\n// );\n\n// // TODO(philc): Re-enable\n// context(\"Visual mode\",\n//   setup(() => {\n//     document.getElementById(\"test-div\").innerHTML = `\\\n// <p><pre>\n//   It is an ancient Mariner,\n//   And he stoppeth one of three.\n//   By thy long grey beard and glittering eye,\n//   Now wherefore stopp'st thou me?\n// </pre></p>\\\n// `;\n//     initializeModeState();\n//     this.initialVisualMode = new VisualMode;\n//     sendKeyboardEvent(\"w\");\n//     sendKeyboardEvent(\"w\");\n//     // We should now be at the \"a\" of \"an\".\n//     sendKeyboardEvent(\"v\");\n//   });\n\n//   teardown(() => document.getElementById(\"test-div\").innerHTML = \"\"),\n\n//   should(\"select word with e\", () => {\n//     assert.equal(\"a\", getSelection());\n//     sendKeyboardEvent(\"e\");\n//     assert.equal(\"an\", getSelection());\n//     sendKeyboardEvent(\"e\");\n//     assert.equal(\"an ancient\", getSelection());\n//   });\n\n//   should(\"select opposite end of the selection with o\", () => {\n//     assert.equal(\"a\", getSelection());\n//     sendKeyboardEvent(\"e\");\n//     assert.equal(\"an\", getSelection());\n//     sendKeyboardEvent(\"e\");\n//     assert.equal(\"an ancient\", getSelection());\n//     sendKeyboardEvents(\"ow\");\n//     assert.equal(\"ancient\", getSelection());\n//     sendKeyboardEvents(\"oe\");\n//     assert.equal(\"ancient Mariner\", getSelection());\n//   });\n\n//   should(\"accept a count\", () => {\n//     assert.equal(\"a\", getSelection());\n//     sendKeyboardEvents(\"2e\");\n//     assert.equal(\"an ancient\", getSelection());\n//   });\n\n//   should(\"select a word\", () => {\n//     assert.equal(\"a\", getSelection());\n//     sendKeyboardEvents(\"aw\");\n//     assert.equal(\"an\", getSelection());\n//   });\n\n//   should(\"select a word with a count\", () => {\n//     assert.equal(\"a\", getSelection());\n//     sendKeyboardEvents(\"2aw\");\n//     assert.equal(\"an ancient\", getSelection());\n//   });\n\n//   should(\"select a word with a count\", () => {\n//     assert.equal(\"a\", getSelection());\n//     sendKeyboardEvents(\"2aw\");\n//     assert.equal(\"an ancient\", getSelection());\n//   });\n\n//   should(\"select to start of line\", () => {\n//     assert.equal(\"a\", getSelection());\n//     sendKeyboardEvents(\"0\");\n//     assert.equal(\"It is\", getSelection().trim());\n//   });\n\n//   should(\"select to end of line\", () => {\n//     assert.equal(\"a\", getSelection());\n//     sendKeyboardEvents(\"$\");\n//     assert.equal(\"an ancient Mariner,\", getSelection());\n//   });\n\n//   should(\"re-enter caret mode\", () => {\n//     assert.equal(\"a\", getSelection());\n//     sendKeyboardEvents(\"cww\");\n//     assert.equal(\"M\", getSelection());\n//   })\n// );\n\nconst createMode = (options) => {\n  const mode = new Mode();\n  mode.init(options);\n  return mode;\n};\n\ncontext(\"Mode utilities\", () => {\n  setup(() => {\n    initializeModeState();\n\n    const testContent = `<input type='text' id='first'/> \\\n<input style='display:none;' id='second'/> \\\n<input type='password' id='third' value='some value'/>`;\n    document.getElementById(\"test-div\").innerHTML = testContent;\n  });\n\n  teardown(() => document.getElementById(\"test-div\").innerHTML = \"\");\n\n  should(\"not have duplicate singletons\", () => {\n    let mode;\n    let count = 0;\n    class Test extends Mode {\n      constructor() {\n        count += 1;\n        super();\n        super.init({ singleton: \"test\" });\n      }\n      exit() {\n        count -= 1;\n        return super.exit();\n      }\n    }\n    assert.isTrue(count === 0);\n    for (let i = 1; i <= 10; i++) {\n      mode = new Test();\n      assert.isTrue(count === 1);\n    }\n    mode.exit();\n    assert.isTrue(count === 0);\n  });\n\n  should(\"exit on escape\", () => {\n    const test = createMode({ exitOnEscape: true });\n    assert.isTrue(test.modeIsActive);\n    sendKeyboardEvent(\"Escape\", \"keydown\");\n    assert.isFalse(test.modeIsActive);\n  });\n\n  should(\"not exit on escape if not enabled\", () => {\n    const test = createMode({ exitOnEscape: false });\n    assert.isTrue(test.modeIsActive);\n    sendKeyboardEvent(\"Escape\", \"keydown\");\n    assert.isTrue(test.modeIsActive);\n  });\n\n  should(\"exit on blur\", () => {\n    const element = document.getElementById(\"first\");\n    element.focus();\n    const test = createMode({ exitOnBlur: element });\n    assert.isTrue(test.modeIsActive);\n    element.blur();\n    assert.isFalse(test.modeIsActive);\n  });\n\n  should(\"not exit on blur if not enabled\", () => {\n    const element = document.getElementById(\"first\");\n    element.focus();\n    const test = createMode({ exitOnBlur: false });\n    assert.isTrue(test.modeIsActive);\n    element.blur();\n    assert.isTrue(test.modeIsActive);\n  });\n});\n\ncontext(\"PostFindMode\", () => {\n  let postFindMode;\n\n  setup(() => {\n    initializeModeState();\n    const testContent = \"<input type='text' id='first'/>\";\n    document.getElementById(\"test-div\").innerHTML = testContent;\n    document.getElementById(\"first\").focus();\n    postFindMode = new PostFindMode();\n  });\n\n  teardown(() => document.getElementById(\"test-div\").innerHTML = \"\"),\n    should(\"be a singleton\", () => {\n      assert.isTrue(postFindMode.modeIsActive);\n      new PostFindMode();\n      assert.isFalse(postFindMode.modeIsActive);\n    });\n\n  should(\"suppress unmapped printable keys\", () => {\n    sendKeyboardEvent(\"a\");\n    assert.equal(null, commandCount);\n  });\n\n  should(\"be deactivated on click events\", () => {\n    handlerStack.bubbleEvent(\"click\", { target: document.activeElement });\n    assert.isFalse(postFindMode.modeIsActive);\n  });\n\n  should(\"enter insert mode on immediate escape\", () => {\n    sendKeyboardEvent(\"Escape\", \"keydown\");\n    assert.equal(null, commandCount);\n    assert.isFalse(postFindMode.modeIsActive);\n  });\n\n  should(\"not enter insert mode on subsequent escapes\", () => {\n    sendKeyboardEvent(\"a\");\n    sendKeyboardEvent(\"Escape\", \"keydown\");\n    assert.isTrue(postFindMode.modeIsActive);\n  });\n});\n\ncontext(\"WaitForEnter\", () => {\n  let isSuccess, waitForEnter;\n\n  setup(() => {\n    initializeModeState();\n    isSuccess = null;\n    waitForEnter = new WaitForEnter((value) => {\n      isSuccess = value;\n    });\n  });\n\n  should(\"exit with success on Enter\", () => {\n    assert.isTrue(waitForEnter.modeIsActive);\n    assert.isFalse(isSuccess != null);\n    sendKeyboardEvent(\"Enter\", \"keydown\");\n    assert.isFalse(waitForEnter.modeIsActive);\n    assert.isTrue((isSuccess != null) && (isSuccess === true));\n  });\n\n  should(\"exit without success on Escape\", () => {\n    assert.isTrue(waitForEnter.modeIsActive);\n    assert.isFalse(isSuccess != null);\n    sendKeyboardEvent(\"Escape\", \"keydown\");\n    assert.isFalse(waitForEnter.modeIsActive);\n    assert.isTrue((isSuccess != null) && (isSuccess === false));\n  });\n\n  should(\"not exit on other keyboard events\", () => {\n    assert.isTrue(waitForEnter.modeIsActive);\n    assert.isFalse(isSuccess != null);\n    sendKeyboardEvents(\"abc\");\n    assert.isTrue(waitForEnter.modeIsActive);\n    assert.isFalse(isSuccess != null);\n  });\n});\n\ncontext(\"GrabBackFocus\", () => {\n  setup(() => {\n    const testContent = \"<input type='text' value='some value' id='input'/>\";\n    document.getElementById(\"test-div\").innerHTML = testContent;\n    stubSettings(\"grabBackFocus\", true);\n  });\n\n  teardown(() => document.getElementById(\"test-div\").innerHTML = \"\"),\n    should(\"blur an already focused input\", () => {\n      document.getElementById(\"input\").focus();\n      assert.isTrue(document.activeElement);\n      assert.isTrue(DomUtils.isEditable(document.activeElement));\n      initializeModeState();\n      assert.isTrue(document.activeElement);\n      assert.isFalse(DomUtils.isEditable(document.activeElement));\n    });\n\n  should(\"blur a newly focused input\", () => {\n    initializeModeState();\n    document.getElementById(\"input\").focus();\n    assert.isTrue(document.activeElement);\n    assert.isFalse(DomUtils.isEditable(document.activeElement));\n  });\n\n  should(\"exit on a key event\", () => {\n    initializeModeState();\n    sendKeyboardEvent(\"a\");\n    document.getElementById(\"input\").focus();\n    assert.isTrue(document.activeElement);\n    assert.isTrue(DomUtils.isEditable(document.activeElement));\n  });\n\n  should(\"exit on a mousedown event\", () => {\n    initializeModeState();\n    handlerStack.bubbleEvent(\"mousedown\", { target: document.body });\n    document.getElementById(\"input\").focus();\n    assert.isTrue(document.activeElement);\n    assert.isTrue(DomUtils.isEditable(document.activeElement));\n  });\n});\n"
  },
  {
    "path": "tests/dom_tests/dom_utils_test.js",
    "content": "context(\"Check visibility\", () => {\n  should(\"detect visible elements as visible\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n      <div id='foo'>test</div>`;\n    assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById(\"foo\"), true)) !== null);\n  });\n\n  should(\"detect display:none links as hidden\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n      <a id='foo' style='display:none'>test</a>`;\n    assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById(\"foo\"), true));\n  });\n\n  should(\"detect visibility:hidden links as hidden\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n      <a id='foo' style='visibility:hidden'>test</a>`;\n    assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById(\"foo\"), true));\n  });\n\n  should(\"detect elements nested in display:none elements as hidden\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n      <div style='display:none'>\n        <a id='foo'>test</a>\n      </div>`;\n    assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById(\"foo\"), true));\n  });\n\n  should(\"detect links nested in visibility:hidden elements as hidden\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n      <div style='visibility:hidden'>\n        <a id='foo'>test</a>\n      </div>`;\n    assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById(\"foo\"), true));\n  });\n\n  should(\"detect links outside viewport as hidden\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n      <a id='foo' style='position:absolute;top:-2000px'>test</a>\n      <a id='bar' style='position:absolute;left:2000px'>test</a>`;\n    assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById(\"foo\"), true));\n    assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById(\"bar\"), true));\n  });\n\n  should(\"detect links only partially outside viewport as visible\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n      <a id='foo' style='position:absolute;top:-10px'>test</a>\n      <a id='bar' style='position:absolute;left:-10px'>test</a>`;\n    assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById(\"foo\"), true)) !== null);\n    assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById(\"bar\"), true)) !== null);\n  });\n\n  should(\"detect links that contain only floated / absolutely-positioned divs as visible\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n      <a id='foo'>\n        <div style='float:left'>test</div>\n      </a>`;\n    assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById(\"foo\"), true)) !== null);\n\n    document.getElementById(\"test-div\").innerHTML = `\\\n      <a id='foo'>\n        <div style='position:absolute;top:0;left:0'>test</div>\n      </a>`;\n    assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById(\"foo\"), true)) !== null);\n  });\n\n  should(\"detect links that contain only invisible floated divs as invisible\", () => {\n    document.getElementById(\"test-div\").innerHTML = `\\\n      <a id='foo'>\n        <div style='float:left;visibility:hidden'>test</div>\n      </a>`;\n    assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById(\"foo\"), true));\n  });\n\n  should(\n    \"detect font-size: 0; and display: inline; links when their children are display: inline\",\n    () => {\n      // This test represents the minimal test case covering issue #1554.\n      document.getElementById(\"test-div\").innerHTML = `\\\n        <a id='foo' style='display: inline; font-size: 0px;'>\n          <div style='display: inline; font-size: 16px;'>test</div>\n        </a>`;\n      assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById(\"foo\"), true)) !== null);\n    },\n  );\n\n  should(\"detect links inside opacity:0 elements as visible\", () => {\n    // XXX This is an expected failure. See issue #16.\n    document.getElementById(\"test-div\").innerHTML = `\\\n      <div style='opacity:0'>\n        <a id='foo'>test</a>\n      </div>`;\n    assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById(\"foo\"), true)) !== null);\n  });\n});\n\ncontext(\"getClientRectsForAreas\", () => {\n  let img, area;\n  setup(() => {\n    img = document.createElement(\"img\");\n    area = document.createElement(\"area\");\n  });\n\n  should(\"return the associated rect for an image map\", () => {\n    area.setAttribute(\"coords\", \"1,2,3,4\");\n    const result = DomUtils.getClientRectsForAreas(img, [area]);\n    assert.equal([{ element: area, rect: Rect.create(1, 2, 3, 4) }], result);\n  });\n\n  should(\"skip when a map's coords are malformed\", () => {\n    area.setAttribute(\"coords\", \"1,2,3\"); // This is only 3 coords rather than 4.\n    assert.equal([], DomUtils.getClientRectsForAreas(img, [area]));\n    area.setAttribute(\"coords\", \"1,2,3,junk-value\");\n    assert.equal([], DomUtils.getClientRectsForAreas(img, [area]));\n  });\n});\n\n// NOTE(philc): This test doesn't pass on puppeteer. It's unclear from the XXX comment if it's\n// supposed to.\n// should(\"Detect links within SVGs as visible\"), () => {\n//   # XXX this is an expected failure\n//   document.getElementById(\"test-div\").innerHTML = \"\"\"\n//   <svg>\n//     <a id='foo' xlink:href='http://www.example.com/'>\n//       <text x='0' y='68'>test</text>\n//     </a>\n//   </svg>\n//   \"\"\"\n//   assert.equal(null, (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true));\n// }\n"
  },
  {
    "path": "tests/unit_tests/bg_utils_test.js",
    "content": "import \"./test_helper.js\";\nimport \"../../lib/url_utils.js\";\nimport \"../../background_scripts/tab_recency.js\";\nimport \"../../background_scripts/bg_utils.js\";\n"
  },
  {
    "path": "tests/unit_tests/command_listing_test.js",
    "content": "import * as testHelper from \"./test_helper.js\";\nimport \"../../tests/unit_tests/test_chrome_stubs.js\";\nimport \"../../lib/utils.js\";\nimport \"../../lib/settings.js\";\nimport { allCommands } from \"../../background_scripts/all_commands.js\";\nimport * as commandListing from \"../../pages/command_listing.js\";\n\ncontext(\"command listing\", () => {\n  setup(async () => {\n    await testHelper.jsdomStub(\"pages/command_listing.html\");\n    await Settings.onLoaded();\n    stub(chrome.storage.session, \"get\", async (key) => {\n      if (key == \"commandToOptionsToKeys\") {\n        const data = {\n          \"reload\": {\n            \"\": [\"a\"],\n            \"hard\": [\"b\"],\n          },\n        };\n        return { commandToOptionsToKeys: data };\n      }\n    });\n  });\n\n  should(\"have a section in the html for every group\", async () => {\n    // This is to prevent editing errors, where a new command group is added, and we forget to add a\n    // corresponding group to the command listing.\n    await commandListing.populatePage();\n    const groups = Array.from(new Set(allCommands.map((c) => c.group))).sort();\n    const groupsInPage = Array.from(globalThis.document.querySelectorAll(\"h2[data-group]\"))\n      .map((e) => e.dataset.group)\n      .sort();\n    assert.equal(groups, groupsInPage);\n  });\n\n  should(\"have one entry per command\", async () => {\n    await commandListing.populatePage();\n    const rows = globalThis.document.querySelectorAll(\".command\");\n    assert.equal(allCommands.length, rows.length);\n  });\n\n  should(\"show key mappings for mapped commands\", async () => {\n    const getKeys = (commandName) => {\n      const el = globalThis.document.querySelector(`.command#${commandName}`);\n      if (!el) throw new Error(`${commandName} el not found.`);\n      const keys = Array.from(el.querySelectorAll(\".key\")).map((el) => el.textContent);\n      return keys;\n    };\n    await commandListing.populatePage();\n    assert.equal([\"a\", \"b\"], getKeys(\"reload\"));\n    // This command isn't bound in our stubbed test environment:\n    assert.equal([], getKeys(\"scrollDown\"));\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/commands_test.js",
    "content": "import \"./test_helper.js\";\nimport \"../../lib/settings.js\";\nimport \"../../lib/keyboard_utils.js\";\nimport { allCommands } from \"../../background_scripts/all_commands.js\";\nimport {\n  Commands,\n  defaultKeyMappings,\n  KeyMappingsParser,\n  parseLines,\n} from \"../../background_scripts/commands.js\";\nimport \"../../content_scripts/mode.js\";\nimport \"../../content_scripts/mode_key_handler.js\";\nimport \"../../content_scripts/marks.js\";\nimport \"../../content_scripts/link_hints.js\";\nimport \"../../content_scripts/vomnibar.js\";\n// Include mode_normal to check that all commands have been implemented.\nimport \"../../content_scripts/mode_normal.js\";\nimport \"../../content_scripts/link_hints.js\";\nimport \"../../content_scripts/marks.js\";\nimport \"../../content_scripts/vomnibar.js\";\n\nawait Commands.init();\n\ncontext(\"KeyMappingsParser\", () => {\n  const getErrors = (config) => KeyMappingsParser.parse(config).validationErrors;\n\n  should(\"handle map statements\", () => {\n    const { keyToRegistryEntry } = KeyMappingsParser.parse(\"map a scrollDown\");\n    assert.equal(\"scrollDown\", keyToRegistryEntry[\"a\"]?.command);\n  });\n\n  should(\"ignore mappings for unknown commands\", () => {\n    assert.equal({}, KeyMappingsParser.parse(\"map a unknownCommand\").keyToRegistryEntry);\n  });\n\n  should(\"handle mapkey statements\", () => {\n    const { keyToMappedKey } = KeyMappingsParser.parse(\"mapkey a b\");\n    assert.equal({ \"a\": \"b\" }, keyToMappedKey);\n  });\n\n  should(\"handle unmap statements\", () => {\n    const input = \"mapkey a b \\n unmap a\";\n    const { keyToMappedKey } = KeyMappingsParser.parse(input);\n    assert.equal({}, keyToMappedKey);\n  });\n\n  should(\"handle unmapall statements\", () => {\n    const input = \"mapkey a b \\n unmapall \\n mapkey b c\";\n    const { keyToMappedKey } = KeyMappingsParser.parse(input);\n    assert.equal({ \"b\": \"c\" }, keyToMappedKey);\n  });\n\n  should(\"ignore commands with the wrong number of tokens\", () => {\n    assert.equal({}, KeyMappingsParser.parse(\"mapkey a b c\").keyToMappedKey);\n    assert.equal({}, KeyMappingsParser.parse(\"map a\").keyToRegistryEntry);\n    assert.equal(\n      { \"a\": \"b\" },\n      KeyMappingsParser.parse(\"mapkey a b \\n unmap a a\").keyToMappedKey,\n    );\n  });\n\n  should(\"parse option values surrounded by quotes\", () => {\n    const { keyToRegistryEntry } = KeyMappingsParser.parse('map v Vomnibar.activate query=\"a b\"');\n    const entry = keyToRegistryEntry[\"v\"];\n    assert.equal({ query: \"a b\" }, entry.options);\n  });\n\n  should(\"parse options using all 3 syntaxes\", () => {\n    // This test exercises some of the edge cases of the underlying regular expressions.\n    const result = KeyMappingsParser.parseCommandOptions('keyA  keyB=\"a b=c\"  keyC=\" ');\n    assert.equal({ keyA: true, keyB: \"a b=c\", keyC: '\"' }, result);\n  });\n\n  should(\"parse a URL parameter alongside an option value\", () => {\n    // URLs alongside the \"position\" option occurs in the createTab command.\n    const result = KeyMappingsParser.parseCommandOptions('abc.com/?param=val position=\"end\"');\n    assert.equal({ \"abc.com/?param=val\": true, position: \"end\" }, result);\n  });\n\n  should(\"return parsing validation errors\", () => {\n    assert.equal(0, getErrors(\"map a scrollDown\").length);\n    // Missing an action (e.g. map).\n    assert.equal(1, getErrors(\"a scrollDown\").length);\n    // Invalid action.\n    assert.equal(1, getErrors(\"invalidAction a scrollDown\").length);\n    // Map requires at least two arguments\n    assert.equal(0, getErrors(\"map a scrollDown\").length);\n    assert.equal(1, getErrors(\"map a\").length);\n    // Unmap allows only 1 argument.\n    assert.equal(0, getErrors(\"unmap a\").length);\n    assert.equal(1, getErrors(\"unmap a b\").length);\n    // Mapkey requires 2 arguments.\n    assert.equal(0, getErrors(\"mapkey a b\").length);\n    assert.equal(1, getErrors(\"mapkey a\").length);\n    // Reject unknown modifiers.\n    assert.equal(0, getErrors(\"map <a-f> scrollDown\").length);\n    assert.equal(1, getErrors(\"map <b-f> scrollDown\").length);\n  });\n\n  should(\"reject unknown commands on map statements\", () => {\n    // Reject unknown commands.\n    assert.equal(1, getErrors(\"map a example-command\").length);\n  });\n\n  should(\"reject unknown options on map statements\", () => {\n    assert.equal(0, getErrors(\"map j LinkHints.activateMode action=focus\").length);\n    assert.equal(1, getErrors(\"map j LinkHints.activateMode unknownOption=a\").length);\n  });\n\n  should(\"reject count option on commands with noRepeat=true\", () => {\n    assert.equal(0, getErrors(\"map j scrollLeft count=1\").length);\n    assert.equal(1, getErrors(\"map j copyCurrentUrl count=1\").length);\n  });\n\n  should(\"allow arbitrary URLs as arguments to commands with (any url) as an option\", () => {\n    assert.equal(0, getErrors(\"map j createTab http://example.com\").length);\n    assert.equal(1, getErrors(\"map j createTab invalid-url\").length);\n  });\n\n  context(\"parseLines\", () => {\n    should(\"omit whitespace\", () => {\n      assert.equal(0, parseLines(\"    \\n    \\n   \").length);\n    });\n\n    should(\"omit comments\", () => {\n      assert.equal(0, parseLines(' # comment   \\n \" comment   \\n   ').length);\n    });\n\n    should(\"join lines\", () => {\n      assert.equal(1, parseLines(\"a\\\\\\nb\").length);\n      assert.equal(\"ab\", parseLines(\"a\\\\\\nb\")[0]);\n    });\n\n    should(\"trim lines\", () => {\n      assert.equal(2, parseLines(\"  a  \\n  b\").length);\n      assert.equal(\"a\", parseLines(\"  a  \\n  b\")[0]);\n      assert.equal(\"b\", parseLines(\"  a  \\n  b\")[1]);\n    });\n  });\n\n  context(\"parseKeySequence\", () => {\n    const testKeySequence = (key, expectedKeyText, expectedKeyLength) => {\n      const keySequence = KeyMappingsParser.parseKeySequence(key);\n      assert.equal(expectedKeyText, keySequence.join(\"/\"));\n      assert.equal(expectedKeyLength, keySequence.length);\n    };\n\n    should(\"lowercase keys correctly\", () => {\n      testKeySequence(\"a\", \"a\", 1);\n      testKeySequence(\"A\", \"A\", 1);\n      testKeySequence(\"ab\", \"a/b\", 2);\n    });\n\n    should(\"recognise non-alphabetic keys\", () => {\n      testKeySequence(\"#\", \"#\", 1);\n      testKeySequence(\".\", \".\", 1);\n      testKeySequence(\"##\", \"#/#\", 2);\n      testKeySequence(\"..\", \"./.\", 2);\n    });\n\n    should(\"parse keys with modifiers\", () => {\n      testKeySequence(\"<c-a>\", \"<c-a>\", 1);\n      testKeySequence(\"<c-A>\", \"<c-A>\", 1);\n      testKeySequence(\"<C-A>\", \"<c-A>\", 1);\n      testKeySequence(\"<c-a><a-b>\", \"<c-a>/<a-b>\", 2);\n      testKeySequence(\"<m-a>\", \"<m-a>\", 1);\n      testKeySequence(\"z<m-a>\", \"z/<m-a>\", 2);\n    });\n\n    should(\"normalize with modifiers\", () => {\n      // Modifiers should be in alphabetical order.\n      testKeySequence(\"<m-c-a-A>\", \"<a-c-m-A>\", 1);\n    });\n\n    should(\"parse and normalize named keys\", () => {\n      testKeySequence(\"<space>\", \"<space>\", 1);\n      testKeySequence(\"<Space>\", \"<space>\", 1);\n      testKeySequence(\"<C-Space>\", \"<c-space>\", 1);\n      testKeySequence(\"<f12>\", \"<f12>\", 1);\n      testKeySequence(\"<F12>\", \"<f12>\", 1);\n    });\n\n    should(\"handle angle brackets which are part of not modifiers\", () => {\n      testKeySequence(\"<\", \"<\", 1);\n      testKeySequence(\">\", \">\", 1);\n\n      testKeySequence(\"<<\", \"</<\", 2);\n      testKeySequence(\">>\", \">/>\", 2);\n\n      testKeySequence(\"<>\", \"</>\", 2);\n      testKeySequence(\"<>\", \"</>\", 2);\n\n      testKeySequence(\"<<space>\", \"</<space>\", 2);\n      testKeySequence(\"<C->>\", \"<c->>\", 1);\n\n      testKeySequence(\"<a>\", \"</a/>\", 3);\n    });\n\n    should(\"negative tests\", () => {\n      // This should not be parsed as modifiers.\n      testKeySequence(\"<c-@@>\", \"</c/-/@/@/>\", 6);\n    });\n  });\n});\n\ncontext(\"Validate commands and options data structures\", () => {\n  should(\"have either noRepeat or repeatLimit, but not both\", () => {\n    for (const command of allCommands) {\n      const validProperties = !(command.noRepeat && command.repeatLimit);\n      if (!validProperties) {\n        assert.fail(`${command.name} has incorrect noRepeat and/or repeatLimit config.`);\n      }\n    }\n  });\n\n  should(\"have required properties\", () => {\n    for (const command of allCommands) {\n      const hasRequired = command.desc.length > 0 && command.group.length > 0;\n      if (!hasRequired) {\n        assert.fail(`${command.name} is missing required properties.`);\n      }\n    }\n  });\n\n  should(\"have valid commands for each default key mapping\", () => {\n    const commandsByName = Utils.keyBy(allCommands, \"name\");\n    for (const [key, commandString] of Object.entries(defaultKeyMappings)) {\n      // The comamnd string might be command name + an option string. Ignore the options.\n      const name = commandString.split(\" \")[0];\n      if (commandsByName[name] == null) {\n        assert.fail(`The default mapping for ${key} is bound to non-existant command ${name}.`);\n      }\n    }\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/completion/completers_test.js",
    "content": "import \"../test_helper.js\";\nimport \"../../../background_scripts/tab_recency.js\";\nimport \"../../../background_scripts/bg_utils.js\";\nimport \"../../../background_scripts/completion/search_engines.js\";\nimport \"../../../background_scripts/completion/search_wrapper.js\";\nimport * as userSearchEngines from \"../../../background_scripts/user_search_engines.js\";\nimport {\n  BookmarkCompleter,\n  DomainCompleter,\n  HistoryCache,\n  HistoryCompleter,\n  MultiCompleter,\n  SearchEngineCompleter,\n  Suggestion,\n  TabCompleter,\n} from \"../../../background_scripts/completion/completers.js\";\nimport * as ranking from \"../../../background_scripts/completion/ranking.js\";\nimport { RegexpCache } from \"../../../background_scripts/completion/ranking.js\";\nimport \"../../../lib/url_utils.js\";\n\nconst hours = (n) => 1000 * 60 * 60 * n;\n\n// A convenience wrapper around completer.filter() so it can be called synchronously in tests.\nconst filterCompleter = async (completer, queryTerms) => {\n  return await completer.filter({\n    queryTerms,\n    query: queryTerms.join(\" \"),\n  });\n};\n\ncontext(\"bookmark completer\", () => {\n  const bookmark3 = { title: \"bookmark3\", url: \"bookmark3.com\" };\n  const bookmark2 = { title: \"bookmark2\", url: \"bookmark2.com\" };\n  const bookmark1 = { title: \"bookmark1\", url: \"bookmark1.com\", children: [bookmark2] };\n  let completer;\n\n  setup(() => {\n    stub(globalThis.chrome.bookmarks, \"getTree\", () => [bookmark1]);\n    completer = new BookmarkCompleter();\n  });\n\n  should(\"flatten a list of bookmarks with inorder traversal\", async () => {\n    const result = await completer.traverseBookmarks([bookmark1, bookmark3]);\n    assert.equal([bookmark1, bookmark2, bookmark3], result);\n  });\n\n  should(\"return matching bookmarks when searching\", async () => {\n    completer.refresh();\n    const results = await filterCompleter(completer, [\"mark2\"]);\n    assert.equal([bookmark2.url], results.map((suggestion) => suggestion.url));\n  });\n\n  should(\"return *no* matching bookmarks when there is no match\", async () => {\n    completer.refresh();\n    const results = await filterCompleter(completer, [\"does-not-match\"]);\n    assert.equal([], results.map((suggestion) => suggestion.url));\n  });\n\n  should(\"construct bookmark paths correctly\", async () => {\n    completer.refresh();\n    await filterCompleter(completer, [\"mark2\"]);\n    assert.equal(\"/bookmark1/bookmark2\", bookmark2.pathAndTitle);\n  });\n\n  should(\n    \"return matching bookmark *titles* when searching *without* the folder separator character\",\n    async () => {\n      completer.refresh();\n      const results = await filterCompleter(completer, [\"mark2\"]);\n      assert.equal([\"bookmark2\"], results.map((suggestion) => suggestion.title));\n    },\n  );\n\n  should(\n    \"return matching bookmark *paths* when searching with the folder separator character\",\n    async () => {\n      completer.refresh();\n      const results = await filterCompleter(completer, [\"/bookmark1\", \"mark2\"]);\n      assert.equal([\"/bookmark1/bookmark2\"], results.map((suggestion) => suggestion.title));\n    },\n  );\n});\n\ncontext(\"HistoryCache\", () => {\n  const compare = (a, b) => a - b;\n  context(\"binary search\", () => {\n    should(\"find elements to the left of the middle\", () => {\n      assert.equal(0, HistoryCache.binarySearch(3, [3, 5, 8], compare));\n    });\n\n    should(\"find elements to the right of the middle\", () => {\n      assert.equal(2, HistoryCache.binarySearch(8, [3, 5, 8], compare));\n    });\n\n    context(\"unfound elements\", () => {\n      should(\"return 0 if it should be the head of the list\", () => {\n        assert.equal(0, HistoryCache.binarySearch(1, [3, 5, 8], compare));\n      });\n      should(\"return length - 1 if it should be at the end of the list\", () => {\n        assert.equal(0, HistoryCache.binarySearch(3, [3, 5, 8], compare));\n      });\n      should(\n        \"return one passed end of array (so: array.length) if greater than last element in array\",\n        () => {\n          assert.equal(3, HistoryCache.binarySearch(10, [3, 5, 8], compare));\n        },\n      );\n      should(\"found return the position if it's between two elements\", () => {\n        assert.equal(1, HistoryCache.binarySearch(4, [3, 5, 8], compare));\n        assert.equal(2, HistoryCache.binarySearch(7, [3, 5, 8], compare));\n      });\n    });\n  });\n\n  context(\"fetchHistory\", () => {\n    const history1 = { url: \"b.com\", lastVisitTime: 5 };\n    const history2 = { url: \"a.com\", lastVisitTime: 10 };\n    let onVisitedListener, onVisitRemovedListener;\n\n    setup(async () => {\n      const history = [history1, history2];\n      // const history = [history2, history1];\n      onVisitedListener = null;\n      onVisitRemovedListener = null;\n\n      stub(globalThis.chrome, \"history\", {\n        search: (_options) => history,\n        onVisited: {\n          addListener(listener) {\n            onVisitedListener = listener;\n          },\n          removeListener() {},\n        },\n        onVisitRemoved: {\n          addListener(listener) {\n            onVisitRemovedListener = listener;\n          },\n          removeListener() {},\n        },\n      });\n\n      HistoryCache.reset();\n      await HistoryCache.fetchHistory();\n    });\n\n    should(\"store visits sorted by url ascending\", () => {\n      assert.equal([history2, history1], HistoryCache.history);\n    });\n\n    should(\"add new visits to the history\", () => {\n      const newSite = { url: \"ab.com\" };\n      onVisitedListener(newSite);\n      assert.equal([history2, newSite, history1], HistoryCache.history);\n    });\n\n    should(\"replace new visits in the history\", () => {\n      assert.equal([history2, history1], HistoryCache.history);\n      const newSite = { url: \"a.com\", lastVisitTime: 15 };\n      onVisitedListener(newSite);\n      assert.equal([newSite, history1], HistoryCache.history);\n    });\n\n    should(\n      \"(not) remove page from the history, when page is not in history (it should be a no-op)\",\n      () => {\n        assert.equal([history2, history1], HistoryCache.history);\n        const toRemove = { urls: [\"x.com\"], allHistory: false };\n        onVisitRemovedListener(toRemove);\n        assert.equal([history2, history1], HistoryCache.history);\n      },\n    );\n\n    should(\"remove pages from the history\", () => {\n      assert.equal([history2, history1], HistoryCache.history);\n      const toRemove = { urls: [\"a.com\"], allHistory: false };\n      onVisitRemovedListener(toRemove);\n      assert.equal([history1], HistoryCache.history);\n    });\n\n    should(\"remove all pages from the history\", () => {\n      assert.equal([history2, history1], HistoryCache.history);\n      const toRemove = { allHistory: true };\n      onVisitRemovedListener(toRemove);\n      assert.equal([], HistoryCache.history);\n    });\n  });\n});\n\ncontext(\"history completer\", () => {\n  const history1 = { title: \"history1\", url: \"history1.com\", lastVisitTime: hours(1) };\n  const history2 = { title: \"history2\", url: \"history2.com\", lastVisitTime: hours(5) };\n  let completer;\n\n  setup(() => {\n    completer = new HistoryCompleter();\n    stub(globalThis.chrome, \"history\", {\n      search: (_options) => [history1, history2],\n      onVisited: { addListener() {}, removeListener() {} },\n      onVisitRemoved: { addListener() {}, removeListener() {} },\n    });\n    HistoryCache.reset();\n  });\n\n  should(\"return matching history entries when searching\", async () => {\n    const results = await filterCompleter(completer, [\"story1\"]);\n    assert.equal([history1.url], results.map((s) => s.url));\n  });\n\n  should(\"rank recent results higher than nonrecent results\", async () => {\n    stub(Date, \"now\", returns(hours(24)));\n    const results = await filterCompleter(completer, [\"hist\"]);\n    results.forEach((result) => result.computeRelevancy());\n    results.sort((a, b) => b.relevancy - a.relevancy);\n    assert.equal([history2.url, history1.url], results.map((result) => result.url));\n  });\n});\n\ncontext(\"domain completer\", () => {\n  const history1 = { title: \"history1\", url: \"http://history1.com\", lastVisitTime: hours(1) };\n  const history2 = { title: \"history2\", url: \"http://history2.com\", lastVisitTime: hours(1) };\n  const undef = { title: \"history2\", url: \"http://undefined.net\", lastVisitTime: hours(1) };\n  let completer = null;\n\n  setup(() => {\n    stub(globalThis.chrome, \"history\", {\n      search: (_options) => [history1, history2, undef],\n      onVisited: { addListener() {}, removeListener() {} },\n      onVisitRemoved: { addListener() {}, removeListener() {} },\n    });\n    stub(Date, \"now\", returns(hours(24)));\n\n    completer = new DomainCompleter();\n    HistoryCache.reset();\n  });\n\n  should(\"return only a single matching domain\", async () => {\n    const results = await filterCompleter(completer, [\"story\"]);\n    assert.equal([\"http://history1.com\"], results.map((r) => r.url));\n  });\n\n  should(\"pick domains which are more recent\", async () => {\n    // These domains are the same except for their last visited time.\n    let result = await filterCompleter(completer, [\"story\"]);\n    assert.equal(\"http://history1.com\", result[0].url);\n\n    history2.lastVisitTime = hours(3);\n    result = await filterCompleter(completer, [\"story\"]);\n    assert.equal(\"http://history2.com\", result[0].url);\n  });\n\n  should(\n    \"returns no results when there's more than one query term, because clearly it's not a domain\",\n    async () => {\n      assert.equal([], await filterCompleter(completer, [\"his\", \"tory\"]));\n    },\n  );\n\n  should(\"not return any results for empty queries\", async () => {\n    assert.equal([], await filterCompleter(completer, []));\n  });\n});\n\ncontext(\"domain completer (removing entries)\", () => {\n  const history1 = { title: \"history1\", url: \"http://history1.com\", lastVisitTime: hours(2) };\n  const history2 = { title: \"history2\", url: \"http://history2.com\", lastVisitTime: hours(1) };\n  const history3 = {\n    title: \"history2something\",\n    url: \"http://history2.com/something\",\n    lastVisitTime: hours(0),\n  };\n\n  let onVisitRemovedListener, completer;\n\n  setup(async () => {\n    onVisitRemovedListener = null;\n    stub(globalThis.chrome, \"history\", {\n      search: (_options) => [history1, history2, history3],\n      onVisited: {\n        addListener(_listener) {\n        },\n      },\n      onVisitRemoved: {\n        addListener(listener) {\n          onVisitRemovedListener = listener;\n        },\n      },\n    });\n\n    stub(Date, \"now\", returns(hours(24)));\n\n    completer = new DomainCompleter();\n    // Force installation of listeners.\n    await filterCompleter(completer, [\"story\"]);\n  });\n\n  should(\"remove 1 entry for domain with reference count of 1\", async () => {\n    onVisitRemovedListener({ allHistory: false, urls: [history1.url] });\n    let result = await filterCompleter(completer, [\"story\"]);\n    assert.equal(\"http://history2.com\", result[0].url);\n    result = await filterCompleter(completer, [\"story1\"]);\n    assert.equal(0, result.length);\n  });\n\n  should(\"remove 2 entries for domain with reference count of 2\", async () => {\n    onVisitRemovedListener({ allHistory: false, urls: [history2.url] });\n    let result = await filterCompleter(completer, [\"story2\"]);\n    assert.equal(\"http://history2.com\", result[0].url);\n    onVisitRemovedListener({ allHistory: false, urls: [history3.url] });\n    result = await filterCompleter(completer, [\"story2\"]);\n    assert.equal(0, result.length);\n    result = await filterCompleter(completer, [\"story\"]);\n    assert.equal(\"http://history1.com\", result[0].url);\n  });\n\n  should(\"remove 3 (all) matching domain entries\", async () => {\n    onVisitRemovedListener({ allHistory: false, urls: [history2.url] });\n    onVisitRemovedListener({ allHistory: false, urls: [history1.url] });\n    onVisitRemovedListener({ allHistory: false, urls: [history3.url] });\n    const result = await filterCompleter(completer, [\"story\"]);\n    assert.equal(0, result.length);\n  });\n\n  should(\"remove 3 (all) matching domain entries, and do it all at once\", async () => {\n    onVisitRemovedListener({ allHistory: false, urls: [history2.url, history1.url, history3.url] });\n    const result = await filterCompleter(completer, [\"story\"]);\n    assert.equal(0, result.length);\n  });\n\n  should(\"remove *all* domain entries\", async () => {\n    onVisitRemovedListener({ allHistory: true });\n    const result = await filterCompleter(completer, [\"story\"]);\n    assert.equal(0, result.length);\n  });\n});\n\ncontext(\"multi completer\", () => {\n  const tabs = [{ url: \"tab1.com\", title: \"tab1\", id: 1 }];\n  const tabCompleter = new TabCompleter();\n  let multiCompleter;\n\n  setup(() => {\n    stub(chrome.tabs, \"query\", () => tabs);\n    multiCompleter = new MultiCompleter([tabCompleter, new DomainCompleter()]);\n  });\n\n  should(\"return an empty list when the query is empty\", async () => {\n    // Even though a TabCompleter returns results when the query is empty, a MultiCompleter which\n    // wraps a TabCompleter should not.\n    assert.equal(1, (await filterCompleter(tabCompleter, [])).length);\n    assert.equal([], await filterCompleter(multiCompleter, []));\n  });\n});\n\ncontext(\"tab completer\", () => {\n  const tabs = [\n    { url: \"tab1.com\", title: \"tab1\", id: 1 },\n    { url: \"tab2.com\", title: \"tab2\", id: 2 },\n  ];\n  let completer;\n\n  setup(() => {\n    stub(chrome.tabs, \"query\", () => tabs);\n    completer = new TabCompleter();\n  });\n\n  should(\"return tabs by recency when query is empty\", async () => {\n    const results = await filterCompleter(completer, []);\n    assert.equal([\"tab1.com\", \"tab2.com\"], results.map((tab) => tab.url));\n  });\n\n  should(\"return matching tabs\", async () => {\n    const results = await filterCompleter(completer, [\"tab2\"]);\n    assert.equal([\"tab2.com\"], results.map((tab) => tab.url));\n    assert.equal([2], results.map((tab) => tab.tabId));\n  });\n});\n\ncontext(\"SearchEngineCompleter\", () => {\n  const googleSearchUrl = \"http://www.google.com/search?q=\";\n  let completer;\n\n  const createResponse = (responseText) => {\n    return { text: () => responseText };\n  };\n\n  setup(() => {\n    completer = new SearchEngineCompleter();\n    const searchEngineConfig = `g: ${googleSearchUrl}%s`;\n    userSearchEngines.set(searchEngineConfig);\n  });\n\n  should(\"complete search results using the given completer\", async () => {\n    const googleResults = [\"blue\", [\"blue1\", \"blue2\"]];\n    stub(globalThis, \"fetch\", () => createResponse(JSON.stringify(googleResults)));\n    const results = await filterCompleter(completer, [\"g\", \"blue\"]);\n    assert.equal(\n      [googleSearchUrl + \"blue\", googleSearchUrl + \"blue1\", googleSearchUrl + \"blue2\"],\n      results.map((suggestion) => suggestion.url),\n    );\n  });\n});\n\ncontext(\"suggestions\", () => {\n  setup(() => {\n    stub(chrome.runtime, \"getURL\", returns(\"https://test/\"));\n  });\n\n  should(\"escape html in page titles\", () => {\n    const suggestion = new Suggestion({\n      queryTerms: [\"queryterm\"],\n      description: \"tab\",\n      url: \"url\",\n      title: \"title <span>\",\n      relevancyFunction: returns(1),\n    });\n    assert.isTrue(suggestion.generateHtml({}).indexOf(\"title &lt;span&gt;\") >= 0);\n  });\n\n  should(\"highlight query words\", () => {\n    const suggestion = new Suggestion({\n      queryTerms: [\"ninj\", \"words\"],\n      description: \"tab\",\n      url: \"url\",\n      title: \"ninjawords\",\n      relevancyFunction: returns(1),\n    });\n    const expected = \"<span class='match'>ninj</span>a<span class='match'>words</span>\";\n    assert.isTrue(suggestion.generateHtml({}).indexOf(expected) >= 0);\n  });\n\n  should(\"highlight query words correctly when whey they overlap\", () => {\n    const suggestion = new Suggestion({\n      queryTerms: [\"ninj\", \"jaword\"],\n      description: \"tab\",\n      url: \"url\",\n      title: \"ninjawords\",\n      relevancyFunction: returns(1),\n    });\n    const expected = \"<span class='match'>ninjaword</span>s\";\n    assert.isTrue(suggestion.generateHtml({}).indexOf(expected) >= 0);\n  });\n\n  should(\"shorten urls\", () => {\n    const suggestion = new Suggestion({\n      queryTerms: [\"queryterm\"],\n      description: \"history\",\n      url: \"http://ninjawords.com\",\n      title: \"ninjawords\",\n      relevancyFunction: returns(1),\n    });\n    assert.equal(-1, suggestion.generateHtml({}).indexOf(\"http://ninjawords.com\"));\n  });\n});\n\n// TODO: (smblott)\n// Word relevancy should take into account the number of matches (it doesn't currently). should\n// \"score higher for multiple matches (in a URL)\", ->\n//   lowScore  = ranking.wordRelevancy([\"stack\"], \"http://stackoverflow.com/Xxxxxx\", \"a-title\")\n//   highScore = ranking.wordRelevancy([\"stack\"], \"http://stackoverflow.com/Xstack\", \"a-title\")\n//   assert.isTrue highScore > lowScore\n\n// should \"score higher for multiple matches (in a title)\", ->\n//   lowScore  = ranking.wordRelevancy([\"bbc\"], \"http://stackoverflow.com/same\", \"BBC Radio 4 (XBCr4)\")\n//   highScore = ranking.wordRelevancy([\"bbc\"], \"http://stackoverflow.com/same\", \"BBC Radio 4 (BBCr4)\")\n//   assert.isTrue highScore > lowScore\n\ncontext(\"Suggestion.pushMatchingRanges\", () => {\n  should(\"extract ranges matching term (simple case, two matches)\", () => {\n    const ranges = [];\n    const [one, two, three] = [\"one\", \"two\", \"three\"];\n    const suggestion = new Suggestion([], \"\", \"\", \"\", returns(1));\n    suggestion.pushMatchingRanges(`${one}${two}${three}${two}${one}`, two, ranges);\n    assert.equal(\n      2,\n      Utils.zip([ranges, [[3, 6], [11, 14]]]).filter((pair) =>\n        (pair[0][0] === pair[1][0]) && (pair[0][1] === pair[1][1])\n      ).length,\n    );\n  });\n\n  should(\"extract ranges matching term (two matches, one at start of string)\", () => {\n    const ranges = [];\n    const [one, two, three] = [\"one\", \"two\", \"three\"];\n    const suggestion = new Suggestion([], \"\", \"\", \"\", returns(1));\n    suggestion.pushMatchingRanges(`${two}${three}${two}${one}`, two, ranges);\n    assert.equal(\n      2,\n      Utils.zip([ranges, [[0, 3], [8, 11]]]).filter((pair) =>\n        (pair[0][0] === pair[1][0]) && (pair[0][1] === pair[1][1])\n      ).length,\n    );\n  });\n\n  should(\"extract ranges matching term (two matches, one at end of string)\", () => {\n    const ranges = [];\n    const [one, two, three] = [\"one\", \"two\", \"three\"];\n    const suggestion = new Suggestion([], \"\", \"\", \"\", returns(1));\n    suggestion.pushMatchingRanges(`${one}${two}${three}${two}`, two, ranges);\n    assert.equal(\n      2,\n      Utils.zip([ranges, [[3, 6], [11, 14]]]).filter((pair) =>\n        (pair[0][0] === pair[1][0]) && (pair[0][1] === pair[1][1])\n      ).length,\n    );\n  });\n\n  should(\"extract ranges matching term (no matches)\", () => {\n    const ranges = [];\n    const [one, two, three] = [\"one\", \"two\", \"three\"];\n    const suggestion = new Suggestion([], \"\", \"\", \"\", returns(1));\n    suggestion.pushMatchingRanges(`${one}${two}${three}${two}${one}`, \"does-not-match\", ranges);\n    assert.equal(0, ranges.length);\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/completion/ranking_test.js",
    "content": "import \"../test_helper.js\";\nimport * as ranking from \"../../../background_scripts/completion/ranking.js\";\nimport { RegexpCache } from \"../../../background_scripts/completion/ranking.js\";\nimport \"../../../lib/url_utils.js\";\n\ncontext(\"wordRelevancy\", () => {\n  should(\"score higher in shorter URLs\", () => {\n    const highScore = ranking.wordRelevancy(\n      [\"stack\"],\n      \"http://stackoverflow.com/short\",\n      \"a-title\",\n    );\n    const lowScore = ranking.wordRelevancy(\n      [\"stack\"],\n      \"http://stackoverflow.com/longer\",\n      \"a-title\",\n    );\n    assert.isTrue(highScore > lowScore);\n  });\n\n  should(\"score higher in shorter titles\", () => {\n    const highScore = ranking.wordRelevancy([\"milk\"], \"a-url\", \"Milkshakes\");\n    const lowScore = ranking.wordRelevancy([\"milk\"], \"a-url\", \"Milkshakes rocks\");\n    assert.isTrue(highScore > lowScore);\n  });\n\n  should(\"score higher for matching the start of a word (in a URL)\", () => {\n    const lowScore = ranking.wordRelevancy(\n      [\"stack\"],\n      \"http://Xstackoverflow.com/same\",\n      \"a-title\",\n    );\n    const highScore = ranking.wordRelevancy(\n      [\"stack\"],\n      \"http://stackoverflowX.com/same\",\n      \"a-title\",\n    );\n    assert.isTrue(highScore > lowScore);\n  });\n\n  should(\"score higher for matching the start of a word (in a title)\", () => {\n    const lowScore = ranking.wordRelevancy([\"te\"], \"a-url\", \"Dist racted\");\n    const highScore = ranking.wordRelevancy([\"te\"], \"a-url\", \"Distrac ted\");\n    assert.isTrue(highScore > lowScore);\n  });\n\n  should(\"score higher for matching a whole word (in a URL)\", () => {\n    const lowScore = ranking.wordRelevancy(\n      [\"com\"],\n      \"http://stackoverflow.comX/same\",\n      \"a-title\",\n    );\n    const highScore = ranking.wordRelevancy(\n      [\"com\"],\n      \"http://stackoverflowX.com/same\",\n      \"a-title\",\n    );\n    assert.isTrue(highScore > lowScore);\n  });\n\n  should(\"score higher for matching a whole word (in a title)\", () => {\n    const lowScore = ranking.wordRelevancy([\"com\"], \"a-url\", \"abc comX\");\n    const highScore = ranking.wordRelevancy([\"com\"], \"a-url\", \"abcX com\");\n    assert.isTrue(highScore > lowScore);\n  });\n});\n\ncontext(\"matches\", () => {\n  should(\"do a case insensitive match\", () => {\n    assert.isTrue(ranking.matches([\"ari\"], \"maRio\"));\n  });\n\n  should(\"do a case insensitive match on full term\", () => {\n    assert.isTrue(ranking.matches([\"mario\"], \"MARio\"));\n  });\n\n  should(\"do a case insensitive match on several terms\", () => {\n    assert.isTrue(\n      ranking.matches([\"ari\"], \"DOES_NOT_MATCH\", \"DOES_NOT_MATCH_EITHER\", \"MARio\"),\n    );\n  });\n\n  should(\"do a smartcase match (positive)\", () => {\n    assert.isTrue(ranking.matches([\"Mar\"], \"Mario\"));\n  });\n\n  should(\"do a smartcase match (negative)\", () => {\n    assert.isFalse(ranking.matches([\"Mar\"], \"mario\"));\n  });\n\n  should(\"do a match with regexp meta-characters (positive)\", () => {\n    assert.isTrue(ranking.matches([\"ma.io\"], \"ma.io\"));\n  });\n\n  should(\"do a match with regexp meta-characters (negative)\", () => {\n    assert.isFalse(ranking.matches([\"ma.io\"], \"mario\"));\n  });\n\n  should(\"do a smartcase match on full term\", () => {\n    assert.isTrue(ranking.matches([\"Mario\"], \"Mario\"));\n    assert.isFalse(ranking.matches([\"Mario\"], \"mario\"));\n  });\n\n  should(\"do case insensitive word relevancy (matching)\", () => {\n    assert.isTrue(ranking.wordRelevancy([\"ari\"], \"MARIO\", \"MARio\") > 0.0);\n  });\n\n  should(\"do case insensitive word relevancy (not matching)\", () => {\n    assert.isTrue(ranking.wordRelevancy([\"DOES_NOT_MATCH\"], \"MARIO\", \"MARio\") === 0.0);\n  });\n\n  should(\"every query term must match at least one thing (matching)\", () => {\n    assert.isTrue(ranking.matches([\"cat\", \"dog\"], \"catapult\", \"hound dog\"));\n  });\n\n  should(\"every query term must match at least one thing (not matching)\", () => {\n    assert.isTrue(!ranking.matches([\"cat\", \"dog\", \"wolf\"], \"catapult\", \"hound dog\"));\n  });\n});\n\ncontext(\"RegexpCache\", () => {\n  should(\"RegexpCache is in fact caching (positive case)\", () => {\n    assert.isTrue(RegexpCache.get(\"this\") === RegexpCache.get(\"this\"));\n  });\n\n  should(\"RegexpCache is in fact caching (negative case)\", () => {\n    assert.isTrue(RegexpCache.get(\"this\") !== RegexpCache.get(\"that\"));\n  });\n\n  should(\"RegexpCache prefix/suffix wrapping is working (positive case)\", () => {\n    assert.isTrue(RegexpCache.get(\"this\", \"(\", \")\") === RegexpCache.get(\"this\", \"(\", \")\"));\n  });\n\n  should(\"RegexpCache prefix/suffix wrapping is working (negative case)\", () => {\n    assert.isTrue(RegexpCache.get(\"this\", \"(\", \")\") !== RegexpCache.get(\"this\"));\n  });\n\n  should(\"search for a string\", () => {\n    assert.isTrue(\"hound dog\".search(RegexpCache.get(\"dog\")) === 6);\n  });\n\n  should(\"search for a string which isn't there\", () => {\n    assert.isTrue(\"hound dog\".search(RegexpCache.get(\"cat\")) === -1);\n  });\n\n  should(\"search for a string with a prefix/suffix (positive case)\", () => {\n    assert.isTrue(\"hound dog\".search(RegexpCache.get(\"dog\", \"\\\\b\", \"\\\\b\")) === 6);\n  });\n\n  should(\"search for a string with a prefix/suffix (negative case)\", () => {\n    assert.isTrue(\"hound dog\".search(RegexpCache.get(\"do\", \"\\\\b\", \"\\\\b\")) === -1);\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/completion/search_engines_test.js",
    "content": "import \"../test_helper.js\";\nimport \"../../../background_scripts/bg_utils.js\";\nimport * as Engines from \"../../../background_scripts/completion/search_engines.js\";\nimport \"../../../background_scripts/completion/completers.js\";\n\ncontext(\"Amazon completion\", () => {\n  should(\"parses results\", () => {\n    const response = JSON.stringify({\n      \"suggestions\": [\n        { \"value\": \"one\" },\n        { \"value\": \"two\" },\n      ],\n    });\n    const results = new Engines.Amazon().parse(response);\n    assert.equal([\"one\", \"two\"], results);\n  });\n});\n\ncontext(\"Brave completion\", () => {\n  should(\"parses results\", () => {\n    const response = JSON.stringify([\"the-query\", [\"one\", \"two\"]]);\n    const results = new Engines.Brave().parse(response);\n    assert.equal([\"one\", \"two\"], results);\n  });\n});\n\ncontext(\"Kagi completion\", () => {\n  should(\"parses results\", () => {\n    const response = JSON.stringify([{ t: \"one\" }, { t: \"two\" }]);\n    const results = new Engines.Kagi().parse(response);\n    assert.equal([\"one\", \"two\"], results);\n  });\n});\n\ncontext(\"DuckDuckGo completion\", () => {\n  should(\"parses results\", () => {\n    const response = JSON.stringify([\n      { \"phrase\": \"one\" },\n      { \"phrase\": \"two\" },\n    ]);\n    const results = new Engines.DuckDuckGo().parse(response);\n    assert.equal([\"one\", \"two\"], results);\n  });\n});\n\ncontext(\"Qwant completion\", () => {\n  should(\"parses results\", () => {\n    const response = JSON.stringify({\n      \"data\": {\n        \"items\": [\n          { \"value\": \"one\" },\n          { \"value\": \"two\" },\n        ],\n      },\n    });\n    const results = new Engines.Qwant().parse(response);\n    assert.equal([\"one\", \"two\"], results);\n  });\n});\n\n// Engines which have trivial parsers are omitted from these tests.\ncontext(\"Webster completion\", () => {\n  should(\"parses results\", () => {\n    const response = JSON.stringify({\n      \"docs\": [\n        { \"word\": \"one\" },\n        { \"word\": \"two\" },\n      ],\n    });\n    const results = new Engines.Webster().parse(response);\n    assert.equal([\"one\", \"two\"], results);\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/doc_search_completion_test.js",
    "content": "import * as testHelper from \"./test_helper.js\";\nimport \"../../tests/unit_tests/test_chrome_stubs.js\";\nimport \"../../lib/utils.js\";\nimport \"../../lib/settings.js\";\nimport * as completionEngines from \"../../background_scripts/completion/search_engines.js\";\nimport * as page from \"../../pages/doc_search_completion.js\";\n\ncontext(\"completion engines page\", () => {\n  setup(async () => {\n    await testHelper.jsdomStub(\"pages/doc_search_completion.html\");\n  });\n\n  should(\"have a section in the html for every engine\", () => {\n    // This is to prevent editing errors, where a new command group is added, and we forget to add a\n    // corresponding group to the command listing.\n    page.populatePage();\n    const engines = completionEngines.list.map((e) => e.name);\n    const enginesInPage = Array.from(globalThis.document.querySelectorAll(\"h4[data-engine]\"))\n      .map((e) => e.dataset.engine);\n    assert.equal(engines, enginesInPage);\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/exclusion_test.js",
    "content": "import \"./test_helper.js\";\nimport \"../../lib/settings.js\";\nimport \"../../background_scripts/bg_utils.js\";\nimport * as exclusions from \"../../background_scripts/exclusions.js\";\nimport \"../../background_scripts/commands.js\";\n\nconst isEnabledForUrl = (request) => exclusions.isEnabledForUrl(request.url);\n\n// These tests cover only the most basic aspects of excluded URLs and passKeys.\ncontext(\"Excluded URLs and pass keys\", () => {\n  setup(async () => {\n    await Settings.onLoaded();\n    await Settings.set(\"exclusionRules\", [\n      { pattern: \"http*://mail.google.com/*\", passKeys: \"\" },\n      { pattern: \"http*://www.facebook.com/*\", passKeys: \"abab\" },\n      { pattern: \"http*://www.facebook.com/*\", passKeys: \"cdcd\" },\n      { pattern: \"http*://www.bbc.com/*\", passKeys: \"\" },\n      { pattern: \"http*://www.bbc.com/*\", passKeys: \"ab\" },\n      { pattern: \"http*://www.example.com/*\", passKeys: \"a bb c bba a\" },\n      { pattern: \"http*://www.duplicate.com/*\", passKeys: \"ace\" },\n      { pattern: \"http*://www.duplicate.com/*\", passKeys: \"bdf\" },\n    ]);\n  });\n\n  teardown(async () => {\n    await Settings.clear();\n  });\n\n  should(\"be disabled for excluded sites\", () => {\n    const rule = isEnabledForUrl({ url: \"http://mail.google.com/calendar/page\" });\n    assert.isFalse(rule.isEnabledForUrl);\n    assert.isFalse(rule.passKeys);\n  });\n\n  should(\"be disabled for excluded sites, one exclusion\", () => {\n    const rule = isEnabledForUrl({ url: \"http://www.bbc.com/calendar/page\" });\n    assert.isFalse(rule.isEnabledForUrl);\n    assert.isFalse(rule.passKeys);\n  });\n\n  should(\"be enabled, but with pass keys\", () => {\n    const rule = isEnabledForUrl({ url: \"https://www.facebook.com/something\" });\n    assert.isTrue(rule.isEnabledForUrl);\n    assert.equal(rule.passKeys, \"abcd\");\n  });\n\n  should(\"be enabled\", () => {\n    const rule = isEnabledForUrl({ url: \"http://www.twitter.com/pages\" });\n    assert.isTrue(rule.isEnabledForUrl);\n    assert.isFalse(rule.passKeys);\n  });\n\n  should(\"handle spaces and duplicates in passkeys\", () => {\n    const rule = isEnabledForUrl({ url: \"http://www.example.com/pages\" });\n    assert.isTrue(rule.isEnabledForUrl);\n    assert.equal(\"abc\", rule.passKeys);\n  });\n\n  should(\"handle multiple passkeys rules\", () => {\n    const rule = isEnabledForUrl({ url: \"http://www.duplicate.com/pages\" });\n    assert.isTrue(rule.isEnabledForUrl);\n    assert.equal(\"abcdef\", rule.passKeys);\n  });\n\n  should(\"be enabled when given malformed regular expressions\", async () => {\n    await Settings.set(\"exclusionRules\", [\n      { pattern: \"http*://www.bad-regexp.com/*[a-\", passKeys: \"\" },\n    ]);\n    const rule = isEnabledForUrl({ url: \"http://www.bad-regexp.com/pages\" });\n    assert.isTrue(rule.isEnabledForUrl);\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/handler_stack_test.js",
    "content": "import \"./test_helper.js\";\nimport \"../../lib/handler_stack.js\";\n\ncontext(\"handlerStack\", () => {\n  let handlerStack, handler1Called, handler2Called;\n\n  setup(() => {\n    stub(globalThis, \"DomUtils\", {});\n    stub(DomUtils, \"consumeKeyup\", () => {});\n    stub(DomUtils, \"suppressEvent\", () => {});\n    stub(DomUtils, \"suppressPropagation\", () => {});\n    handlerStack = new HandlerStack();\n    handler1Called = false;\n    handler2Called = false;\n  });\n\n  should(\"bubble events\", () => {\n    handlerStack.push({\n      keydown: () => {\n        return handler1Called = true;\n      },\n    });\n    handlerStack.push({\n      keydown: () => {\n        return handler2Called = true;\n      },\n    });\n    handlerStack.bubbleEvent(\"keydown\", {});\n    assert.isTrue(handler2Called);\n    assert.isTrue(handler1Called);\n  });\n\n  should(\"terminate bubbling on falsy return value\", () => {\n    handlerStack.push({\n      keydown: () => {\n        return handler1Called = true;\n      },\n    });\n    handlerStack.push({\n      keydown: () => {\n        handler2Called = true;\n        return false;\n      },\n    });\n    handlerStack.bubbleEvent(\"keydown\", {});\n    assert.isTrue(handler2Called);\n    assert.isFalse(handler1Called);\n  });\n\n  should(\"terminate bubbling on passEventToPage, and be true\", () => {\n    handlerStack.push({\n      keydown: () => {\n        return handler1Called = true;\n      },\n    });\n    handlerStack.push({\n      keydown: () => {\n        handler2Called = true;\n        return handlerStack.passEventToPage;\n      },\n    });\n    assert.isTrue(handlerStack.bubbleEvent(\"keydown\", {}));\n    assert.isTrue(handler2Called);\n    assert.isFalse(handler1Called);\n  });\n\n  should(\"terminate bubbling on passEventToPage, and be false\", () => {\n    handlerStack.push({\n      keydown: () => {\n        return handler1Called = true;\n      },\n    });\n    handlerStack.push({\n      keydown: () => {\n        handler2Called = true;\n        return handlerStack.suppressPropagation;\n      },\n    });\n    assert.isFalse(handlerStack.bubbleEvent(\"keydown\", {}));\n    assert.isTrue(handler2Called);\n    assert.isFalse(handler1Called);\n  });\n\n  should(\"restart bubbling on restartBubbling\", () => {\n    handler1Called = 0;\n    handler2Called = 0;\n    const id = handlerStack.push({\n      keydown: () => {\n        handler1Called++;\n        handlerStack.remove(id);\n        return handlerStack.restartBubbling;\n      },\n    });\n    handlerStack.push({\n      keydown: () => {\n        handler2Called++;\n        return true;\n      },\n    });\n    assert.isTrue(handlerStack.bubbleEvent(\"keydown\", {}));\n    assert.isTrue(handler1Called === 1);\n    assert.isTrue(handler2Called === 2);\n  });\n\n  should(\"remove handlers correctly\", () => {\n    handlerStack.push({\n      keydown: () => {\n        handler1Called = true;\n      },\n    });\n    const handlerId = handlerStack.push({\n      keydown: () => {\n        handler2Called = true;\n      },\n    });\n    handlerStack.remove(handlerId);\n    handlerStack.bubbleEvent(\"keydown\", {});\n    assert.isFalse(handler2Called);\n    assert.isTrue(handler1Called);\n  });\n\n  should(\"remove handlers correctly\", () => {\n    const handlerId = handlerStack.push({\n      keydown: () => {\n        handler1Called = true;\n      },\n    });\n    handlerStack.push({\n      keydown: () => {\n        handler2Called = true;\n      },\n    });\n    handlerStack.remove(handlerId);\n    handlerStack.bubbleEvent(\"keydown\", {});\n    assert.isTrue(handler2Called);\n    assert.isFalse(handler1Called);\n  });\n\n  should(\"handle self-removing handlers correctly\", () => {\n    handlerStack.push({\n      keydown: () => {\n        handler1Called = true;\n      },\n    });\n    handlerStack.push({\n      keydown() {\n        handler2Called = true;\n        this.remove();\n        return true;\n      },\n    });\n    handlerStack.bubbleEvent(\"keydown\", {});\n    assert.isTrue(handler2Called);\n    assert.isTrue(handler1Called);\n    assert.equal(handlerStack.stack.length, 1);\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/help_dialog_test.js",
    "content": "import * as testHelper from \"./test_helper.js\";\nimport \"../../tests/unit_tests/test_chrome_stubs.js\";\nimport \"../../background_scripts/completion/completers.js\";\nimport { allCommands } from \"../../background_scripts/all_commands.js\";\nimport { HelpDialogPage } from \"../../pages/help_dialog_page.js\";\n\ncontext(\"help dialog\", () => {\n  setup(async () => {\n    await testHelper.jsdomStub(\"pages/help_dialog_page.html\");\n    await Settings.onLoaded();\n    stub(chrome.storage.session, \"get\", async (key) => {\n      if (key == \"commandToOptionsToKeys\") {\n        const data = {\n          \"reload\": {\n            \"\": [\"a\"],\n            \"hard\": [\"b\"],\n          },\n        };\n        return { commandToOptionsToKeys: data };\n      }\n    });\n  });\n\n  should(\"getRowsForDialog includes one row per command-options pair\", () => {\n    const config = {\n      \"reload\": {\n        \"\": [\"a\"],\n        \"hard\": [\"b\", \"c\"],\n      },\n    };\n    const result = HelpDialogPage.getRowsForDialog(config);\n    const rows = result[\"navigation\"]\n      .filter((row) => row[0].name == \"reload\");\n    assert.equal(2, rows.length);\n    assert.equal([\"reload\", \"\", [\"a\"]], [rows[0][0].name, rows[0][1], rows[0][2]]);\n    assert.equal([\"reload\", \"hard\", [\"b\", \"c\"]], [rows[1][0].name, rows[1][1], rows[1][2]]);\n  });\n\n  should(\"have a section in the help dialog for every group\", async () => {\n    // This test is to prevent code editing errors, where a command is added but doesn't have a\n    // corresponding group in the help dialog.\n    HelpDialogPage.init();\n    await HelpDialogPage.show();\n    const groups = Array.from(new Set(allCommands.map((c) => c.group))).sort();\n    const groupsInDialog = Array.from(\n      HelpDialogPage.dialogElement.querySelectorAll(\"div[data-group]\"),\n    )\n      .map((e) => e.dataset.group)\n      .sort();\n    assert.equal(groups, groupsInDialog);\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/hud_page_test.js",
    "content": "import * as testHelper from \"./test_helper.js\";\nimport \"../../tests/unit_tests/test_chrome_stubs.js\";\nimport * as hudPage from \"../../pages/hud_page.js\";\nimport * as UIComponentMessenger from \"../../pages/ui_component_messenger.js\";\n\nfunction newKeyEvent(properties) {\n  return Object.assign(\n    {\n      type: \"keydown\",\n      key: \"a\",\n      ctrlKey: false,\n      shiftKey: false,\n      altKey: false,\n      metaKey: false,\n      stopImmediatePropagation: function () {},\n      preventDefault: function () {},\n    },\n    properties,\n  );\n}\n\ncontext(\"hud page\", () => {\n  let ui;\n  setup(async () => {\n    stub(Utils, \"isFirefox\", () => false);\n    await testHelper.jsdomStub(\"pages/hud_page.html\");\n    // Make Utils.setTimeout synchronous so that the tests easier to deal with.\n    stub(Utils, \"setTimeout\", (timeout, fn) => {\n      fn();\n    });\n  });\n\n  teardown(() => {\n    UIComponentMessenger.unregister();\n  });\n\n  should(\"find mode hides when escape is pressed\", async () => {\n    let message;\n    const stubPort = {\n      postMessage: (event) => {\n        message = event;\n      },\n    };\n    await UIComponentMessenger.registerPortWithOwnerPage({\n      data: (await chrome.storage.session.get(\"vimiumSecret\")).vimiumSecret,\n      ports: [stubPort],\n    });\n    hudPage.handlers.showFindMode();\n    await hudPage.onKeyEvent(newKeyEvent({ key: \"Escape\" }));\n    assert.equal(\"hideFindMode\", message.name);\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/link_hints_test.js",
    "content": "import \"./test_helper.js\";\nimport \"../../lib/keyboard_utils.js\";\nimport \"../../lib/settings.js\";\nimport \"../../content_scripts/mode.js\";\nimport \"../../content_scripts/link_hints.js\";\n\ncontext(\"With insufficient link characters\", () => {\n  setup(async () => {\n    await Settings.onLoaded();\n  });\n\n  teardown(async () => {\n    await Settings.clear();\n  });\n\n  should(\"throw error in AlphabetHints\", async () => {\n    await Settings.set(\"linkHintCharacters\", \"ab\");\n    new AlphabetHints();\n    await Settings.set(\"linkHintCharacters\", \"a\");\n    assert.throwsError(() => new AlphabetHints(), \"Error\");\n  });\n\n  should(\"throw error in FilterHints\", async () => {\n    await Settings.set(\"linkHintNumbers\", \"12\");\n    new FilterHints();\n    await Settings.set(\"linkHintNumbers\", \"1\");\n    assert.throwsError(() => new FilterHints(), \"Error\");\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/main_test.js",
    "content": "import \"./test_helper.js\";\nimport \"../../lib/settings.js\";\nimport \"../../background_scripts/main.js\";\nimport { RegistryEntry } from \"../../background_scripts/commands.js\";\n\ncontext(\"HintCoordinator\", () => {\n  should(\"prepareToActivateLinkHintsMode\", async () => {\n    let receivedMessages = [];\n    const frameIdToHintDescriptors = {\n      \"0\": { frameId: 0, localIndex: 123, linkText: null },\n      \"1\": { frameId: 1, localIndex: 456, linkText: null },\n    };\n\n    stub(chrome.webNavigation, \"getAllFrames\", () => [{ frameId: 0 }, { frameId: 1 }]);\n\n    stub(chrome.tabs, \"sendMessage\", async (_tabId, message, options) => {\n      if (message.messageType == \"getHintDescriptors\") {\n        return frameIdToHintDescriptors[options.frameId];\n      } else if (message.messageType == \"activateMode\") {\n        receivedMessages.push(message);\n      }\n    });\n\n    await HintCoordinator.prepareToActivateLinkHintsMode(0, 0, {\n      modeIndex: 0,\n      requestedByHelpDialog: false,\n    });\n\n    receivedMessages = receivedMessages.map(\n      (m) => Utils.pick(m, [\"frameId\", \"frameIdToHintDescriptors\"]),\n    );\n\n    // Each frame should receive only the hint descriptors from the other frames.\n    assert.equal([\n      { frameId: 0, frameIdToHintDescriptors: { \"1\": frameIdToHintDescriptors[1] } },\n      { frameId: 1, frameIdToHintDescriptors: { \"0\": frameIdToHintDescriptors[0] } },\n    ], receivedMessages);\n  });\n});\n\ncontext(\"createTab command\", () => {\n  let tabCreated;\n  let requestStub;\n\n  setup(async () => {\n    stub(chrome.tabs, \"create\", (args) => {\n      tabCreated = args;\n    });\n    requestStub = {\n      registryEntry: new RegistryEntry({ options: {} }),\n      tab: {},\n      count: 1,\n    };\n    await Settings.load();\n  });\n\n  should(\"open the provided URL\", async () => {\n    requestStub.url = \"https://example.com\";\n    await BackgroundCommands.createTab(requestStub);\n    assert.equal(\"https://example.com\", tabCreated.url);\n  });\n\n  should(\"open the vimium new tab page\", async () => {\n    await Settings.set(\"newTabDestination\", Settings.newTabDestinations.vimiumNewTabPage);\n    await BackgroundCommands.createTab(requestStub);\n    assert.equal(Settings.vimiumNewTabPageUrl, tabCreated.url);\n  });\n\n  should(\"open the browser's new tab page\", async () => {\n    await Settings.set(\"newTabDestination\", Settings.newTabDestinations.browserNewTabPage);\n    await BackgroundCommands.createTab(requestStub);\n    // The URL argument to chrome.tabs.create is omitted when we want to use the browser's NTP.\n    assert.isTrue(tabCreated != null);\n    assert.equal(undefined, tabCreated.url);\n  });\n\n  should(\"open custom URL\", async () => {\n    await Settings.set(\"newTabDestination\", Settings.newTabDestinations.customUrl);\n    await BackgroundCommands.createTab(requestStub);\n    // If a specific custom URL isn't provided, the browser's new tab page will be used.\n    // The URL argument to chrome.tabs.create is omitted when we want to use the browser's NTP.\n    assert.isTrue(tabCreated != null);\n    assert.equal(undefined, tabCreated.url);\n\n    await Settings.set(\"newTabCustomUrl\", \"http://example.com\");\n    await BackgroundCommands.createTab(requestStub);\n    assert.equal(\"http://example.com\", tabCreated.url);\n  });\n\n  teardown(() => {\n    tabCreated = null;\n    Settings.clear();\n  });\n});\n\ncontext(\"Next zoom level\", () => {\n  // All these tests use the Chrome zoom levels, which are the default.\n  should(\"Zoom in 0 times\", async () => {\n    const zoom = await nextZoomLevel(1.00, 0);\n    assert.equal(1.00, zoom);\n  });\n\n  should(\"Zoom in 1\", async () => {\n    const zoom = await nextZoomLevel(1.00, 1);\n    assert.equal(1.10, zoom);\n  });\n\n  should(\"Zoom out 1\", async () => {\n    const zoom = await nextZoomLevel(1.00, -1);\n    assert.equal(0.90, zoom);\n  });\n\n  should(\"Zoom in 2\", async () => {\n    const zoom = await nextZoomLevel(1.00, 2);\n    assert.equal(1.25, zoom);\n  });\n\n  should(\"Zoom out 2\", async () => {\n    const zoom = await nextZoomLevel(1.00, -2);\n    assert.equal(0.80, zoom);\n  });\n\n  should(\"Zoom in from between values\", async () => {\n    const zoom = await nextZoomLevel(1.05, 1);\n    assert.equal(1.10, zoom);\n  });\n\n  should(\"Zoom out from between values\", async () => {\n    const zoom = await nextZoomLevel(1.05, -1);\n    assert.equal(1.00, zoom);\n  });\n\n  should(\"Zoom in past the maximum\", async () => {\n    const zoom = await nextZoomLevel(1.00, 15);\n    assert.equal(5.00, zoom);\n  });\n\n  should(\"Zoom out past the minimum\", async () => {\n    const zoom = await nextZoomLevel(1.00, -15);\n    assert.equal(0.25, zoom);\n  });\n\n  should(\"Zoom in from below the minimum\", async () => {\n    const lowZoom = 0.01; // Lowest non-broken Chrome zoom level\n    const zoom = await nextZoomLevel(lowZoom, 1);\n    assert.equal(0.25, zoom);\n  });\n\n  should(\"Zoom out from above the maximum\", async () => {\n    const highZoom = 9.99; // highest non-broken Chrome zoom level\n    const zoom = await nextZoomLevel(highZoom, -1);\n    assert.equal(5.00, zoom);\n  });\n\n  should(\"Zoom in from above the maximum\", async () => {\n    const highZoom = 9.99; // highest non-broken Chrome zoom level\n    const zoom = await nextZoomLevel(highZoom, 1);\n    assert.equal(5.00, zoom);\n  });\n\n  should(\"Zoom out from below the minimum\", async () => {\n    const lowZoom = 0.01; // lowest non-broken Chrome zoom level\n    const zoom = await nextZoomLevel(lowZoom, -1);\n    assert.equal(0.25, zoom);\n  });\n\n  should(\"Test Chrome 33% zoom in with float error\", async () => {\n    const floatZoom = 0.32999999999999996; // The value chrome actually gives for 33%.\n    const zoom = await nextZoomLevel(floatZoom, 1);\n    assert.equal(0.50, zoom);\n  });\n\n  should(\"Test Chrome 175% zoom in with float error\", async () => {\n    const floatZoom = 1.7499999999999998; // The value chrome actually gives for 175%.\n    const zoom = await nextZoomLevel(floatZoom, 1);\n    assert.equal(2.00, zoom);\n  });\n});\n\ncontext(\"Selecting frames\", () => {\n  should(\"nextFrame\", async () => {\n    const focusedFrames = [];\n    stub(chrome.webNavigation, \"getAllFrames\", () => [{ frameId: 1 }, { frameId: 2 }]);\n    stub(chrome.tabs, \"sendMessage\", async (_tabId, message, options) => {\n      if (message.handler == \"getFocusStatus\") {\n        return { focused: options.frameId == 2, focusable: true };\n      } else if (message.handler == \"focusFrame\") {\n        focusedFrames.push(options.frameId);\n      }\n    });\n\n    await BackgroundCommands.nextFrame(1, 0);\n    assert.equal([1], focusedFrames);\n  });\n});\n\ncontext(\"majorVersionHasIncreased\", () => {\n  should(\"return whether the major version has changed\", () => {\n    assert.equal(false, majorVersionHasIncreased(null));\n    shoulda.stub(Utils, \"getCurrentVersion\", () => \"2.0.1\");\n    assert.equal(false, majorVersionHasIncreased(\"2.0.0\"));\n    shoulda.stub(Utils, \"getCurrentVersion\", () => \"2.1.0\");\n    assert.equal(true, majorVersionHasIncreased(\"2.0.0\"));\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/marks_test.js",
    "content": "import \"./test_helper.js\";\nimport * as marks from \"../../background_scripts/marks.js\";\n\ncontext(\"marks\", () => {\n  const createMark = async (markProperties, tabProperties) => {\n    const mark = Object.assign({ scrollX: 0, scrollY: 0 }, markProperties);\n    const tab = Object.assign({ url: \"http://example.com\" }, tabProperties);\n    const sender = { tab: tab };\n    await marks.create(mark, sender);\n  };\n\n  setup(() => {\n    chrome.storage.session.clear();\n    chrome.storage.session.set({ vimiumSecret: \"secret\" });\n  });\n\n  teardown(() => {\n    chrome.storage.session.clear();\n    chrome.storage.local.clear();\n  });\n\n  should(\"record the vimium secret in the mark's info\", async () => {\n    await createMark({ markName: \"a\" });\n    const key = marks.getLocationKey(\"a\");\n    const savedMark = (await chrome.storage.local.get(key))[key];\n    assert.equal(\"secret\", savedMark.vimiumSecret);\n  });\n\n  should(\"goto a mark when its tab exists\", async () => {\n    await createMark({ markName: \"A\" }, { id: 1 });\n    const tab = { url: \"http://example.com\" };\n    stub(globalThis.chrome.tabs, \"get\", (id) => id == 1 ? tab : null);\n    const updatedTabs = [];\n    stub(globalThis.chrome.tabs, \"update\", (id, properties) => updatedTabs[id] = properties);\n    await marks.goto({ markName: \"A\" });\n    assert.isTrue(updatedTabs[1] && updatedTabs[1].active);\n  });\n\n  should(\"find a new tab if a mark's tab no longer exists\", async () => {\n    await createMark({ markName: \"A\" }, { id: 1 });\n    const tab = { url: \"http://example.com\", id: 2 };\n    stub(globalThis.chrome.tabs, \"get\", (_id) => {\n      throw new Error();\n    });\n    stub(globalThis.chrome.tabs, \"query\", (_) => [tab]);\n    const updatedTabs = [];\n    stub(globalThis.chrome.tabs, \"update\", (id, properties) => updatedTabs[id] = properties);\n    await marks.goto({ markName: \"A\" });\n    assert.isTrue(updatedTabs[2] && updatedTabs[2].active);\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/options_page_test.js",
    "content": "import * as testHelper from \"./test_helper.js\";\nimport \"../../tests/unit_tests/test_chrome_stubs.js\";\nimport * as optionsPage from \"../../pages/options.js\";\n\ncontext(\"options page\", () => {\n  setup(async () => {\n    await testHelper.jsdomStub(\"pages/options.html\");\n    await optionsPage.init();\n  });\n\n  teardown(async () => {\n    await Settings.clear();\n  });\n\n  should(\"populate the form fields with the settings\", () => {\n    const settings = Settings.getSettings();\n    const field = optionsPage.getOptionEl(\"keyMappings\");\n    assert.isTrue(Settings.defaultOptions.keyMappings.length > 0);\n    assert.equal(Settings.defaultOptions.keyMappings, settings.keyMappings);\n    assert.equal(settings.keyMappings, field.value);\n  });\n\n  should(\"show validation errors for invalid fields on save\", async () => {\n    const el = optionsPage.getOptionEl(\"keyMappings\");\n    assert.isFalse(el.classList.contains(\"validation-error\"));\n    assert.equal(0, document.querySelectorAll(\".validation-message\").length);\n\n    el.value = \"invalid-mapping-statement\";\n    await optionsPage.saveOptions();\n    assert.isTrue(el.classList.contains(\"validation-error\"));\n\n    const messageEls = document.querySelectorAll(\".validation-message\");\n    assert.equal(1, messageEls.length);\n    assert.isTrue(messageEls[0].innerHTML.includes(el.value));\n  });\n\n  should(\"show exclusion rule editor for exclusion rules\", async () => {\n    const rule = {\n      passKeys: \"\",\n      pattern: \"example.com\",\n    };\n    await Settings.set(\"exclusionRules\", [rule]);\n    await optionsPage.init();\n    const el = document.querySelector(\"#exclusion-rules input[name=pattern]\");\n    assert.equal(\"example.com\", el.value);\n  });\n\n  context(\"backup\", () => {\n    should(\"exclude settings which are default values\", () => {\n      const settings = JSON.parse(optionsPage.prepareBackupSettings());\n      // This should exclude all values which are defaults.\n      assert.equal([\"settingsVersion\"], Object.keys(settings));\n    });\n\n    should(\"include settings which have changed from the default\", () => {\n      optionsPage.getOptionEl(\"keyMappings\").value = \"map a scrollUp\";\n      const settings = JSON.parse(optionsPage.prepareBackupSettings());\n      assert.equal([\"keyMappings\", \"settingsVersion\"], Object.keys(settings));\n      assert.equal(\"map a scrollUp\", settings.keyMappings);\n    });\n\n    should(\"export settings with sorted keys\", () => {\n      optionsPage.getOptionEl(\"linkHintCharacters\").value = \"abcd\";\n      optionsPage.getOptionEl(\"keyMappings\").value = \"map a scrollUp\";\n      const settings = JSON.parse(optionsPage.prepareBackupSettings());\n      assert.equal([\"keyMappings\", \"linkHintCharacters\", \"settingsVersion\"], Object.keys(settings));\n    });\n\n    should(\"include exclusion rules\", async () => {\n      const rule = {\n        passKeys: \"\",\n        pattern: \"example.com\",\n      };\n      await Settings.set(\"exclusionRules\", [rule]);\n      await optionsPage.init();\n      const settings = JSON.parse(optionsPage.prepareBackupSettings());\n      assert.equal([rule], settings[\"exclusionRules\"]);\n    });\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/rect_test.js",
    "content": "import \"./test_helper.js\";\nimport \"../../lib/rect.js\";\n\ncontext(\"Rect\", () => {\n  should(\"set rect properties correctly\", () => {\n    const [x1, y1, x2, y2] = [1, 2, 3, 4];\n    const rect = Rect.create(x1, y1, x2, y2);\n    assert.equal(rect.left, x1);\n    assert.equal(rect.top, y1);\n    assert.equal(rect.right, x2);\n    assert.equal(rect.bottom, y2);\n    assert.equal(rect.width, x2 - x1);\n    assert.equal(rect.height, y2 - y1);\n  });\n\n  should(\"translate rect horizontally\", () => {\n    const [x1, y1, x2, y2] = [1, 2, 3, 4];\n    const x = 5;\n    const rect1 = Rect.create(x1, y1, x2, y2);\n    const rect2 = Rect.translate(rect1, x);\n\n    assert.equal(rect1.left + x, rect2.left);\n    assert.equal(rect1.right + x, rect2.right);\n\n    assert.equal(rect1.width, rect2.width);\n    assert.equal(rect1.height, rect2.height);\n    assert.equal(rect1.top, rect2.top);\n    assert.equal(rect1.bottom, rect2.bottom);\n  });\n\n  should(\"translate rect vertically\", () => {\n    const [x1, y1, x2, y2] = [1, 2, 3, 4];\n    const y = 5;\n    const rect1 = Rect.create(x1, y1, x2, y2);\n    const rect2 = Rect.translate(rect1, undefined, y);\n\n    assert.equal(rect1.top + y, rect2.top);\n    assert.equal(rect1.bottom + y, rect2.bottom);\n\n    assert.equal(rect1.width, rect2.width);\n    assert.equal(rect1.height, rect2.height);\n    assert.equal(rect1.left, rect2.left);\n    assert.equal(rect1.right, rect2.right);\n  });\n});\n\ncontext(\"Rect subtraction\", () => {\n  context(\"unchanged by rects outside\", () => {\n    should(\"left, above\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(-2, -2, -1, -1);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"left\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(-2, 0, -1, 1);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"left, below\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(-2, 2, -1, 3);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"right, above\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(2, -2, 3, -1);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"right\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(2, 0, 3, 1);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"right, below\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(2, 2, 3, 3);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"above\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(0, -2, 1, -1);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"below\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(0, 2, 1, 3);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n  });\n\n  context(\"unchanged by rects touching\", () => {\n    should(\"left, above\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(-1, -1, 0, 0);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"left\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(-1, 0, 0, 1);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"left, below\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(-1, 1, 0, 2);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"right, above\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(1, -1, 2, 0);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"right\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(1, 0, 2, 1);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"right, below\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(1, 1, 2, 2);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"above\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(0, -1, 1, 0);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n\n    should(\"below\", () => {\n      const rect1 = Rect.create(0, 0, 1, 1);\n      const rect2 = Rect.create(0, 1, 1, 2);\n\n      const rects = Rect.subtract(rect1, rect2);\n      assert.equal(rects.length, 1);\n      const rect = rects[0];\n      assert.isTrue(Rect.equals(rect1, rect));\n    });\n  });\n\n  should(\"have nothing when subtracting itself\", () => {\n    const rect = Rect.create(0, 0, 1, 1);\n    const rects = Rect.subtract(rect, rect);\n    assert.equal(rects.length, 0);\n  });\n\n  should(\"not overlap subtracted rect\", () => {\n    const rect = Rect.create(0, 0, 3, 3);\n    for (let x = -2; x <= 2; x++) {\n      for (let y = -2; y <= 2; y++) {\n        for (let width = 1; width <= 3; width++) {\n          for (let height = 1; height <= 3; height++) {\n            const subtractRect = Rect.create(x, y, x + width, y + height);\n            const resultRects = Rect.subtract(rect, subtractRect);\n            for (const resultRect of resultRects) {\n              assert.isFalse(Rect.intersects(subtractRect, resultRect));\n            }\n          }\n        }\n      }\n    }\n  });\n\n  should(\"be contained in original rect\", () => {\n    const rect = Rect.create(0, 0, 3, 3);\n    for (let x = -2; x <= 2; x++) {\n      for (let y = -2; y <= 2; y++) {\n        for (let width = 1; width <= 3; width++) {\n          for (let height = 1; height <= 3; height++) {\n            const subtractRect = Rect.create(x, y, x + width, y + height);\n            const resultRects = Rect.subtract(rect, subtractRect);\n            for (const resultRect of resultRects) {\n              assert.isTrue(Rect.intersects(rect, resultRect));\n            }\n          }\n        }\n      }\n    }\n  });\n\n  should(\"contain the subtracted rect in the original minus the results\", () => {\n    const rect = Rect.create(0, 0, 3, 3);\n    for (let x = -2; x <= 2; x++) {\n      for (let y = -2; y <= 2; y++) {\n        for (let width = 1; width <= 3; width++) {\n          for (let height = 1; height <= 3; height++) {\n            const subtractRect = Rect.create(x, y, x + width, y + height);\n            const resultRects = Rect.subtract(rect, subtractRect);\n            let resultComplement = [Rect.copy(rect)];\n            for (const resultRect of resultRects) {\n              resultComplement = Array.prototype.concat.apply(\n                [],\n                resultComplement.map((rect) => Rect.subtract(rect, resultRect)),\n              );\n            }\n            assert.isTrue((resultComplement.length === 0) || (resultComplement.length === 1));\n            if (resultComplement.length === 1) {\n              const complementRect = resultComplement[0];\n              assert.isTrue(Rect.intersects(subtractRect, complementRect));\n            }\n          }\n        }\n      }\n    }\n  });\n});\n\ncontext(\"Rect overlaps\", () => {\n  should(\"detect that a rect overlaps itself\", () => {\n    const rect = Rect.create(2, 2, 4, 4);\n    assert.isTrue(Rect.intersectsStrict(rect, rect));\n  });\n\n  should(\"detect that non-overlapping rectangles do not overlap on the left\", () => {\n    const rect1 = Rect.create(2, 2, 4, 4);\n    const rect2 = Rect.create(0, 2, 1, 4);\n    assert.isFalse(Rect.intersectsStrict(rect1, rect2));\n  });\n\n  should(\"detect that non-overlapping rectangles do not overlap on the right\", () => {\n    const rect1 = Rect.create(2, 2, 4, 4);\n    const rect2 = Rect.create(5, 2, 6, 4);\n    assert.isFalse(Rect.intersectsStrict(rect1, rect2));\n  });\n\n  should(\"detect that non-overlapping rectangles do not overlap on the top\", () => {\n    const rect1 = Rect.create(2, 2, 4, 4);\n    const rect2 = Rect.create(2, 0, 2, 1);\n    assert.isFalse(Rect.intersectsStrict(rect1, rect2));\n  });\n\n  should(\"detect that non-overlapping rectangles do not overlap on the bottom\", () => {\n    const rect1 = Rect.create(2, 2, 4, 4);\n    const rect2 = Rect.create(2, 5, 2, 6);\n    assert.isFalse(Rect.intersectsStrict(rect1, rect2));\n  });\n\n  should(\"detect overlapping rectangles on the left\", () => {\n    const rect1 = Rect.create(2, 2, 4, 4);\n    const rect2 = Rect.create(0, 2, 2, 4);\n    assert.isTrue(Rect.intersectsStrict(rect1, rect2));\n  });\n\n  should(\"detect overlapping rectangles on the right\", () => {\n    const rect1 = Rect.create(2, 2, 4, 4);\n    const rect2 = Rect.create(4, 2, 5, 4);\n    assert.isTrue(Rect.intersectsStrict(rect1, rect2));\n  });\n\n  should(\"detect overlapping rectangles on the top\", () => {\n    const rect1 = Rect.create(2, 2, 4, 4);\n    const rect2 = Rect.create(2, 4, 4, 5);\n    assert.isTrue(Rect.intersectsStrict(rect1, rect2));\n  });\n\n  should(\"detect overlapping rectangles on the bottom\", () => {\n    const rect1 = Rect.create(2, 2, 4, 4);\n    const rect2 = Rect.create(2, 0, 4, 2);\n    assert.isTrue(Rect.intersectsStrict(rect1, rect2));\n  });\n\n  should(\"detect overlapping rectangles when second rectangle is contained in first\", () => {\n    const rect1 = Rect.create(1, 1, 4, 4);\n    const rect2 = Rect.create(2, 2, 3, 3);\n    assert.isTrue(Rect.intersectsStrict(rect1, rect2));\n  });\n\n  should(\"detect overlapping rectangles when first rectangle is contained in second\", () => {\n    const rect1 = Rect.create(1, 1, 4, 4);\n    const rect2 = Rect.create(2, 2, 3, 3);\n    assert.isTrue(Rect.intersectsStrict(rect2, rect1));\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/settings_test.js",
    "content": "import \"./test_helper.js\";\nimport \"../../lib/settings.js\";\n\ncontext(\"settings\", () => {\n  context(\"v2.0 migration\", () => {\n    setup(async () => {\n      // Prior to Vimium 2.0.0, the settings values were encoded as JSON strings.\n      await chrome.storage.sync.set({ scrollStepSize: JSON.stringify(123) });\n    });\n\n    teardown(async () => {\n      await Settings.clear();\n    });\n\n    should(\"Run v2.0.0 migration when loading settings\", async () => {\n      let storage = await chrome.storage.sync.get(null);\n      assert.equal(\"123\", storage.scrollStepSize);\n      // The JSON value should've been migrated to an int when loading settings.\n      await Settings.load();\n      const settings = Settings.getSettings();\n      assert.equal(123, settings[\"scrollStepSize\"]);\n      // When writing settings, the JSON value should be persisted back to storage.\n      await Settings.set(settings);\n      storage = await chrome.storage.sync.get(null);\n      assert.equal(123, storage.scrollStepSize);\n    });\n  });\n\n  context(\"v2.4 migration\", () => {\n    setup(async () => {\n      await chrome.storage.sync.set({\n        settingsVersion: \"2.3\",\n      });\n    });\n\n    teardown(async () => {\n      await Settings.clear();\n    });\n\n    should(\"Handle null newTabUrl\", async () => {\n      // Users who never changed newTabUrl from its old default (\"about:newtab\") won't have it\n      // stored, because Settings.pruneOutDefaultValues removes keys equal to the default. The\n      // migration should still set browserNewTabPage as the destination.\n      await Settings.load();\n      const settings = Settings.getSettings();\n      assert.equal(Settings.newTabDestinations.browserNewTabPage, settings.newTabDestination);\n    });\n\n    should(\"Remove deprecated option\", async () => {\n      await chrome.storage.sync.set({ newTabUrl: \"pages/blank.html\" });\n      await Settings.load();\n      const settings = Settings.getSettings();\n      assert.isFalse(Object.hasOwn(settings, \"newTabUrl\"));\n    });\n\n    should(\"Handle pages/blank.html new tab URL\", async () => {\n      await chrome.storage.sync.set({ newTabUrl: \"pages/blank.html\" });\n      await Settings.load();\n      const settings = Settings.getSettings();\n      assert.equal(Settings.newTabDestinations.vimiumNewTabPage, settings.newTabDestination);\n    });\n\n    should(\"Handle https://example.com new tab URL\", async () => {\n      await chrome.storage.sync.set({ newTabUrl: \"https://example.com\" });\n      await Settings.load();\n      const settings = Settings.getSettings();\n      assert.equal(Settings.newTabDestinations.customUrl, settings.newTabDestination);\n      assert.equal(\"https://example.com\", settings.newTabCustomUrl);\n    });\n  });\n\n  context(\"v2.4.1 migration\", () => {\n    setup(async () => {\n      await chrome.storage.sync.set({ settingsVersion: \"2.4.0\" });\n    });\n\n    teardown(async () => {\n      await Settings.clear();\n    });\n\n    should(\"Sets default/missing newTabDestination to browserNewTabPage\", async () => {\n      await Settings.load();\n      const settings = Settings.getSettings();\n      assert.equal(Settings.newTabDestinations.browserNewTabPage, settings.newTabDestination);\n    });\n\n    should(\"Preserve customUrl destination\", async () => {\n      await chrome.storage.sync.set({ newTabDestination: Settings.newTabDestinations.customUrl });\n      await Settings.load();\n      const settings = Settings.getSettings();\n      assert.equal(Settings.newTabDestinations.customUrl, settings.newTabDestination);\n    });\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/tab_operations_test.js",
    "content": "import \"./test_helper.js\";\nimport \"../../lib/settings.js\";\nimport * as to from \"../../background_scripts/tab_operations.js\";\n\ncontext(\"TabOperations openurlInCurrentTab\", () => {\n  should(\"open a regular URL\", async () => {\n    let url = null;\n    stub(chrome.tabs, \"update\", (id, args) => {\n      url = args.url;\n    });\n    const expected = \"http://example.com\";\n    await to.openUrlInCurrentTab({ url: expected });\n    assert.equal(expected, url);\n  });\n\n  should(\"open a non-URL in the default search engine\", async () => {\n    let searchQuery = null;\n    stub(chrome.search, \"query\", (queryInfo) => {\n      searchQuery = queryInfo.text;\n    });\n    const expected = \"example query\";\n    await to.openUrlInCurrentTab({ url: expected });\n    assert.equal(expected, searchQuery);\n  });\n\n  should(\"open a javascript URL\", async () => {\n    let details = null;\n    // NOTE(philc): This is a shallow test.\n    stub(chrome.scripting, \"executeScript\", (_details) => {\n      details = _details;\n    });\n    const expected = \"javascript:console.log('hello')\";\n    await to.openUrlInCurrentTab({ url: expected });\n    assert.equal(expected, details.args[0]);\n  });\n});\n\ncontext(\"TabOperations openUrlInNewTab\", () => {\n  should(\"open a regular URL\", async () => {\n    let config = null;\n    stub(chrome.tabs, \"create\", (_config) => {\n      config = _config;\n      const newTab = { url: config.url };\n      return newTab;\n    });\n    const expected = \"http://example.com\";\n    const tab = await to.openUrlInNewTab({\n      tab: { index: 1 },\n      position: \"after\",\n      url: expected,\n    });\n    assert.equal(2, config.index);\n    assert.equal(expected, tab.url);\n  });\n\n  should(\"open a non-URL in the default search engine\", async () => {\n    let createConfig, queryInfo;\n    stub(chrome.tabs, \"create\", (config) => {\n      createConfig = config;\n      const newTab = { id: config.index };\n      return newTab;\n    });\n    stub(chrome.search, \"query\", (info) => {\n      queryInfo = info;\n    });\n    await to.openUrlInNewTab({\n      tab: { index: 1 },\n      position: \"after\",\n      url: \"example query\",\n    });\n    assert.equal(\"data:text/html,<html></html>\", createConfig.url);\n    assert.equal(2, createConfig.index);\n    assert.equal(\"example query\", queryInfo.text);\n    assert.equal(2, queryInfo.tabId);\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/tab_recency_test.js",
    "content": "import \"./test_helper.js\";\nimport { TabRecency } from \"../../background_scripts/tab_recency.js\";\n\ncontext(\"TabRecency\", () => {\n  let tabRecency;\n\n  setup(() => tabRecency = new TabRecency());\n\n  context(\"order\", () => {\n    setup(async () => {\n      stub(chrome.tabs, \"query\", () => Promise.resolve([]));\n      await tabRecency.init();\n      tabRecency.queueAction(\"register\", 1);\n      tabRecency.queueAction(\"register\", 2);\n      tabRecency.queueAction(\"register\", 3);\n      tabRecency.queueAction(\"register\", 4);\n      tabRecency.queueAction(\"deregister\", 4);\n      tabRecency.queueAction(\"register\", 2);\n    });\n\n    should(\"have the correct entries in the correct order\", () => {\n      const expected = [2, 3, 1];\n      assert.equal(expected, tabRecency.getTabsByRecency());\n    });\n\n    should(\"score tabs by recency; current tab should be last\", () => {\n      const score = (id) => tabRecency.recencyScore(id);\n      assert.equal(0, score(2));\n      assert.isTrue(score(2) < score(1));\n      assert.isTrue(score(1) < score(3));\n    });\n  });\n\n  should(\"navigate actions are queued until state from storage is loaded\", async () => {\n    let onActivated;\n    stub(chrome.tabs.onActivated, \"addListener\", (fn) => {\n      onActivated = fn;\n    });\n    let resolveStorage;\n    const storagePromise = new Promise((resolve, _) => resolveStorage = resolve);\n    stub(chrome.storage.session, \"get\", () => storagePromise);\n    tabRecency.init();\n    // Here, chrome.tabs.onActivated listeners have been added by tabrecency, but the\n    // chrome.storage.session data hasn't yet loaded.\n    onActivated({ tabId: 5 });\n    resolveStorage({});\n    await tabRecency.init();\n    assert.equal([5], tabRecency.getTabsByRecency());\n  });\n\n  should(\"loadFromStorage handles empty values\", async () => {\n    stub(chrome.tabs, \"query\", () => Promise.resolve([{ id: 1 }]));\n\n    stub(chrome.storage.session, \"get\", () => Promise.resolve({}));\n    await tabRecency.init();\n    assert.equal([], tabRecency.getTabsByRecency());\n\n    stub(chrome.storage.session, \"get\", () => Promise.resolve({ tabRecency: {} }));\n    await tabRecency.loadFromStorage();\n    assert.equal([], tabRecency.getTabsByRecency());\n  });\n\n  should(\"loadFromStorage works\", async () => {\n    const tabs = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];\n    stub(chrome.tabs, \"query\", () => Promise.resolve(tabs));\n\n    const storage = { tabRecency: { 1: 5, 2: 6 } };\n    stub(chrome.storage.session, \"get\", () => Promise.resolve(storage));\n\n    // Even though the in-storage tab counters are higher than the in-memory tabs, during\n    // loading, the in-memory tab counters are adjusted to be the most recent.\n    await tabRecency.init();\n\n    assert.equal([2, 1], tabRecency.getTabsByRecency());\n\n    tabRecency.queueAction(\"register\", 3);\n    tabRecency.queueAction(\"register\", 1);\n\n    assert.equal([1, 3, 2], tabRecency.getTabsByRecency());\n  });\n\n  should(\"loadFromStorage prunes out tabs which are no longer active\", async () => {\n    const tabs = [{ id: 1 }];\n    stub(chrome.tabs, \"query\", () => Promise.resolve(tabs));\n\n    const storage = { tabRecency: { 1: 5, 2: 6 } };\n    stub(chrome.storage.session, \"get\", () => Promise.resolve(storage));\n    await tabRecency.init();\n    assert.equal([1], tabRecency.getTabsByRecency());\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/test_chrome_stubs.js",
    "content": "// This file contains stubs for a number of browser and chrome APIs which are missing in Deno.\nimport JSON5 from \"npm:json5\";\n\n// There are 3 chrome.storage.* objects with identical APIs.\n// - areaName: one of \"local\", \"sync\", \"session\".\nconst createStorageAPI = (areaName) => {\n  const storage = {\n    store: {},\n\n    async set(items) {\n      let key, value;\n      chrome.runtime.lastError = undefined;\n      for (key of Object.keys(items)) {\n        value = items[key];\n        this.store[key] = value;\n      }\n      for (key of Object.keys(items)) {\n        value = items[key];\n        globalThis.chrome.storage.onChanged.call(key, value, areaName);\n      }\n    },\n\n    async get(keysArg) {\n      chrome.runtime.lastError = undefined;\n      if (keysArg == null) {\n        return globalThis.structuredClone(this.store);\n      } else if (typeof keysArg == \"string\") {\n        const result = {};\n        result[keysArg] = globalThis.structuredClone(this.store[keysArg]);\n        return result;\n      } else {\n        const result = {};\n        for (key of keysArg) {\n          result[key] = globalThis.structuredClone(this.store[key]);\n        }\n        return result;\n      }\n    },\n\n    async remove(key) {\n      chrome.runtime.lastError = undefined;\n      if (key in this.store) {\n        delete this.store[key];\n      }\n      globalThis.chrome.storage.onChanged.callEmpty(key);\n    },\n\n    async clear() {\n      // TODO: Consider firing the change listener if Chrome's API implementation does.\n      this.store = {};\n    },\n  };\n\n  // The \"session\" storage has one API that the others don't.\n  if (areaName == \"session\") storage.setAccessLevel = () => {};\n  return storage;\n};\n\nglobalThis.chrome = {\n  areRunningVimiumTests: true,\n\n  _manifest: null,\n\n  _loadManifest: async function () {\n    this._manifest = JSON5.parse(await Deno.readTextFile(\"./manifest.json\"));\n  },\n\n  _getManifest: function () {\n    if (!this._manifest) {\n      throw new Error(\"manifest.json has not yet been read.\");\n    }\n    return this._manifest;\n  },\n\n  runtime: {\n    getURL() {\n      return \"\";\n    },\n    getManifest() {\n      return chrome._getManifest();\n    },\n    onConnect: {\n      addListener() {\n        return true;\n      },\n    },\n    onMessage: {\n      addListener() {\n        return true;\n      },\n    },\n    onInstalled: {\n      addListener() {},\n    },\n    onStartup: {\n      addListener() {},\n    },\n  },\n\n  extension: {\n    getURL(path) {\n      return path;\n    },\n    getBackgroundPage() {\n      return {};\n    },\n    getViews() {\n      return [];\n    },\n  },\n\n  scripting: {\n    executeScript() {},\n  },\n\n  search: {\n    query() {},\n  },\n\n  tabs: {\n    get(_id) {},\n    onUpdated: {\n      addListener() {\n        return true;\n      },\n    },\n    onAttached: {\n      addListener() {\n        return true;\n      },\n    },\n    onMoved: {\n      addListener() {\n        return true;\n      },\n    },\n    onRemoved: {\n      addListener() {\n        return true;\n      },\n    },\n    onActivated: {\n      addListener() {\n        return true;\n      },\n    },\n    onReplaced: {\n      addListener() {\n        return true;\n      },\n    },\n    query() {\n      return true;\n    },\n    sendMessage(_id, _properties) {},\n    update(_id, _properties) {},\n  },\n\n  webNavigation: {\n    onHistoryStateUpdated: {\n      addListener() {},\n    },\n    onReferenceFragmentUpdated: {\n      addListener() {},\n    },\n    onCommitted: {\n      addListener() {},\n    },\n  },\n\n  windows: {\n    onRemoved: {\n      addListener() {\n        return true;\n      },\n    },\n    getAll() {\n      return true;\n    },\n    getCurrent() {\n      return {};\n    },\n    onFocusChanged: {\n      addListener() {\n        return true;\n      },\n    },\n    update(_id, _properties) {},\n  },\n\n  browserAction: {\n    setBadgeBackgroundColor() {},\n  },\n\n  sessions: {\n    MAX_SESSION_RESULTS: 25,\n  },\n\n  storage: {\n    onChanged: {\n      addListener(func) {\n        this.func = func;\n      },\n\n      // Fake a callback from chrome.storage.sync.\n      call(key, value, area) {\n        chrome.runtime.lastError = undefined;\n        const key_value = {};\n        key_value[key] = { newValue: value };\n        if (this.func) return this.func(key_value, area);\n      },\n\n      callEmpty(key) {\n        chrome.runtime.lastError = undefined;\n        if (this.func) {\n          const items = {};\n          items[key] = {};\n          this.func(items, \"sync\");\n        }\n      },\n    },\n\n    local: createStorageAPI(\"sync\"),\n    sync: createStorageAPI(\"sync\"),\n    session: createStorageAPI(\"session\"),\n  },\n\n  bookmarks: {\n    getTree: () => [],\n  },\n};\n\nawait chrome._loadManifest();\n"
  },
  {
    "path": "tests/unit_tests/test_helper.js",
    "content": "import * as shoulda from \"../vendor/shoulda.js\";\nimport * as jsdom from \"jsdom\";\nimport \"./test_chrome_stubs.js\";\nimport \"../../lib/utils.js\";\n\nconst shouldaSubset = {\n  assert: shoulda.assert,\n  context: shoulda.context,\n  ensureCalled: shoulda.ensureCalled,\n  setup: shoulda.setup,\n  should: shoulda.should,\n  shoulda: shoulda,\n  stub: shoulda.stub,\n  returns: shoulda.returns,\n  teardown: shoulda.teardown,\n};\n\nglobalThis.isUnitTests = true;\n\n// Attach shoulda's functions, like setup, context, should, to the global namespace.\nObject.assign(globalThis, shouldaSubset);\n\nexport async function jsdomStub(htmlFile) {\n  const html = await Deno.readTextFile(htmlFile);\n  const w = new jsdom.JSDOM(html).window;\n  stub(globalThis, \"window\", w);\n  stub(globalThis, \"document\", w.document);\n  stub(globalThis, \"MouseEvent\", w.MouseEvent);\n  stub(globalThis, \"MutationObserver\", w.MutationObserver);\n  // We might not need to stub HTMLElement once we resolve the TODO on DomUtils.createElement\n  stub(globalThis, \"HTMLElement\", w.HTMLElement);\n}\n"
  },
  {
    "path": "tests/unit_tests/ui_component_test.js",
    "content": "import * as testHelper from \"./test_helper.js\";\nimport \"../../lib/utils.js\";\nimport \"../../lib/dom_utils.js\";\nimport \"../../content_scripts/ui_component.js\";\n\nfunction stubPostMessage(iframeEl, fn) {\n  if (!iframeEl || !fn) throw new Error(\"iframeEl and fn are required.\");\n  Object.defineProperty(iframeEl, \"contentWindow\", {\n    value: { postMessage: fn },\n    writable: false,\n    configurable: true,\n  });\n}\n\ncontext(\"UIComponent\", () => {\n  let c;\n\n  setup(async () => {\n    // Which page we load doesn't matter; we just need any DOM.\n    await testHelper.jsdomStub(\"pages/help_dialog_page.html\");\n    stub(Utils, \"isFirefox\", () => false);\n  });\n\n  teardown(() => {\n    // MessageChannel ports must be closed, or our test process will never terminate. See\n    // https://github.com/facebook/react/issues/26608\n    for (const port of c?.messageChannelPorts) {\n      port.close();\n    }\n  });\n\n  should(\"focus the frame when showing\", async () => {\n    c = new UIComponent(\"testing.html\", \"example-class\");\n    await c.load(\"example.html\", \"example-class\");\n    stubPostMessage(c.iframeElement, function () {});\n    c.iframeElement.dispatchEvent(new window.Event(\"load\"));\n    assert.equal(document.body, document.activeElement);\n\n    // The shadow root element containing the iframe should be focused.\n    c.show();\n    assert.equal(c.iframeElement.getRootNode().host, document.activeElement);\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/url_utils_test.js",
    "content": "import \"./test_helper.js\";\nimport \"../../lib/settings.js\";\nimport \"../../lib/url_utils.js\";\n\ncontext(\"isUrl\", () => {\n  should(\"accept valid URLs\", async () => {\n    assert.isTrue(await UrlUtils.isUrl(\"www.google.com\"));\n    assert.isTrue(await UrlUtils.isUrl(\"www.bbc.co.uk\"));\n    assert.isTrue(await UrlUtils.isUrl(\"yahoo.com\"));\n    assert.isTrue(await UrlUtils.isUrl(\"nunames.nu\"));\n    assert.isTrue(await UrlUtils.isUrl(\"user:pass@ftp.xyz.com/test\"));\n\n    assert.isTrue(await UrlUtils.isUrl(\"localhost/index.html\"));\n    assert.isTrue(await UrlUtils.isUrl(\"127.0.0.1:8192/test.php\"));\n\n    // IPv6\n    assert.isTrue(await UrlUtils.isUrl(\"[::]:9000\"));\n\n    // Long TLDs\n    assert.isTrue(await UrlUtils.isUrl(\"testing.social\"));\n    assert.isTrue(await UrlUtils.isUrl(\"testing.onion\"));\n\n    // // Internal URLs.\n    assert.isTrue(\n      await UrlUtils.isUrl(\n        \"moz-extension://c66906b4-3785-4a60-97bc-094a6366017e/pages/options.html\",\n      ),\n    );\n  });\n\n  should(\"reject invalid URLs\", async () => {\n    assert.isFalse(await UrlUtils.isUrl(\"a.x\"));\n    assert.isFalse(await UrlUtils.isUrl(\"www-domain-tld\"));\n    assert.isFalse(await UrlUtils.isUrl(\"http://www.example.com/ has-space\"));\n  });\n});\n\ncontext(\"convertToUrl\", async () => {\n  should(\"detect and clean up valid URLs\", async () => {\n    assert.equal(\"http://www.google.com/\", await UrlUtils.convertToUrl(\"http://www.google.com/\"));\n    assert.equal(\n      \"http://www.google.com/\",\n      await UrlUtils.convertToUrl(\"    http://www.google.com/     \"),\n    );\n    assert.equal(\"http://www.google.com\", await UrlUtils.convertToUrl(\"www.google.com\"));\n    assert.equal(\"http://google.com\", await UrlUtils.convertToUrl(\"google.com\"));\n    assert.equal(\"http://localhost\", await UrlUtils.convertToUrl(\"localhost\"));\n    assert.equal(\"http://xyz.museum\", await UrlUtils.convertToUrl(\"xyz.museum\"));\n    assert.equal(\"chrome://extensions\", await UrlUtils.convertToUrl(\"chrome://extensions\"));\n    assert.equal(\n      \"http://user:pass@ftp.xyz.com/test\",\n      await UrlUtils.convertToUrl(\"user:pass@ftp.xyz.com/test\"),\n    );\n    assert.equal(\"http://127.0.0.1\", await UrlUtils.convertToUrl(\"127.0.0.1\"));\n    assert.equal(\"http://127.0.0.1:8080\", await UrlUtils.convertToUrl(\"127.0.0.1:8080\"));\n    assert.equal(\"http://[::]:8080\", await UrlUtils.convertToUrl(\"[::]:8080\"));\n    assert.equal(\"view-source:    0.0.0.0\", await UrlUtils.convertToUrl(\"view-source:    0.0.0.0\"));\n    assert.equal(\n      \"javascript:alert('25 % 20 * 25%20');\",\n      await UrlUtils.convertToUrl(\"javascript:alert('25 % 20 * 25%20');\"),\n    );\n  });\n});\n\ncontext(\"createSearchUrl\", () => {\n  should(\"replace %S without encoding\", () => {\n    assert.equal(\n      \"https://www.github.com/philc/vimium/pulls\",\n      UrlUtils.createSearchUrl(\"vimium/pulls\", \"https://www.github.com/philc/%S\"),\n    );\n  });\n});\n\ncontext(\"hasChromeProtocol\", () => {\n  should(\"detect chrome prefixes of URLs\", () => {\n    assert.isTrue(UrlUtils.hasChromeProtocol(\"about:foobar\"));\n    assert.isTrue(UrlUtils.hasChromeProtocol(\"view-source:foobar\"));\n    assert.isTrue(UrlUtils.hasChromeProtocol(\"chrome-extension:foobar\"));\n    assert.isTrue(UrlUtils.hasChromeProtocol(\"data:foobar\"));\n    assert.isTrue(UrlUtils.hasChromeProtocol(\"data:\"));\n    assert.isFalse(UrlUtils.hasChromeProtocol(\"\"));\n    assert.isFalse(UrlUtils.hasChromeProtocol(\"about\"));\n    assert.isFalse(UrlUtils.hasChromeProtocol(\"view-source\"));\n    assert.isFalse(UrlUtils.hasChromeProtocol(\"chrome-extension\"));\n    assert.isFalse(UrlUtils.hasChromeProtocol(\"data\"));\n    assert.isFalse(UrlUtils.hasChromeProtocol(\"data :foobar\"));\n  });\n});\n\ncontext(\"hasJavascriptProtocol\", () => {\n  should(\"detect javascript: URLs\", () => {\n    assert.isTrue(UrlUtils.hasJavascriptProtocol(\"javascript:foobar\"));\n    assert.isFalse(UrlUtils.hasJavascriptProtocol(\"http:foobar\"));\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/user_search_engines_test.js",
    "content": "import \"./test_helper.js\";\n\nimport * as userSearchEngines from \"../../background_scripts/user_search_engines.js\";\nimport { UserSearchEngine } from \"../../background_scripts/user_search_engines.js\";\n\ncontext(\"UserSearchEngines\", () => {\n  should(\"parse out search engine text\", () => {\n    const config = [\n      \"g: http://google.com/%s Google Search\",\n      \"random line\",\n      \"# comment\",\n      \" w: http://wikipedia.org/%s\",\n    ].join(\"\\n\");\n\n    const results = userSearchEngines.parseConfig(config).keywordToEngine;\n\n    assert.equal(\n      {\n        g: new UserSearchEngine({\n          keyword: \"g\",\n          url: \"http://google.com/%s\",\n          description: \"Google Search\",\n        }),\n        w: new UserSearchEngine({\n          keyword: \"w\",\n          url: \"http://wikipedia.org/%s\",\n          description: \"search (w)\",\n        }),\n      },\n      results,\n    );\n  });\n\n  should(\"return validation errors\", () => {\n    const getErrors = (config) => userSearchEngines.parseConfig(config).validationErrors;\n    assert.equal(0, getErrors(\"g: http://google.com\").length);\n    // Missing colon.\n    assert.equal(1, getErrors(\"g http://google.com\").length);\n    // Not enough tokens.\n    assert.equal(1, getErrors(\"g:\").length);\n    // Invalid search engine URL.\n    assert.equal(1, getErrors(\"g: invalid-url\").length);\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/utils_test.js",
    "content": "import \"./test_helper.js\";\nimport \"../../lib/settings.js\";\nimport \"../../lib/url_utils.js\";\n\ncontext(\"forTrusted\", () => {\n  should(\"invoke an event handler if the event is trusted\", () => {\n    let called = false;\n    const f = forTrusted(() => called = true);\n    const event = { isTrusted: true };\n    f(event);\n    assert.equal(true, called);\n  });\n\n  should(\"not invoke an event handler if the event is untrusted\", () => {\n    let called = false;\n    const f = forTrusted(() => called = true);\n    const event = { isTrusted: false };\n    f(event);\n    assert.equal(false, called);\n    f(null);\n    assert.equal(false, called);\n  });\n});\n\ncontext(\"extractQuery\", () => {\n  should(\"extract queries from search URLs\", () => {\n    assert.equal(\n      \"bbc sport 1\",\n      Utils.extractQuery(\n        \"https://www.google.ie/search?q=%s\",\n        \"https://www.google.ie/search?q=bbc+sport+1\",\n      ),\n    );\n    assert.equal(\n      \"bbc sport 2\",\n      Utils.extractQuery(\n        \"http://www.google.ie/search?q=%s\",\n        \"https://www.google.ie/search?q=bbc+sport+2\",\n      ),\n    );\n    assert.equal(\n      \"bbc sport 3\",\n      Utils.extractQuery(\n        \"https://www.google.ie/search?q=%s\",\n        \"http://www.google.ie/search?q=bbc+sport+3\",\n      ),\n    );\n    assert.equal(\n      \"bbc sport 4\",\n      Utils.extractQuery(\n        \"https://www.google.ie/search?q=%s\",\n        \"http://www.google.ie/search?q=bbc+sport+4&blah\",\n      ),\n    );\n  });\n});\n\ncontext(\"decodeURIByParts\", () => {\n  should(\"decode javascript: URLs\", () => {\n    assert.equal(\"foobar\", Utils.decodeURIByParts(\"foobar\"));\n    assert.equal(\" \", Utils.decodeURIByParts(\"%20\"));\n    assert.equal(\"25 % 20 25 \", Utils.decodeURIByParts(\"25 % 20 25%20\"));\n  });\n});\n\ncontext(\"compare versions\", () => {\n  should(\"compare correctly\", () => {\n    assert.equal(0, Utils.compareVersions(\"1.40.1\", \"1.40.1\"));\n    assert.equal(0, Utils.compareVersions(\"1.40\", \"1.40.0\"));\n    assert.equal(0, Utils.compareVersions(\"1.40.0\", \"1.40\"));\n    assert.equal(-1, Utils.compareVersions(\"1.40.1\", \"1.40.2\"));\n    assert.equal(-1, Utils.compareVersions(\"1.40.1\", \"1.41\"));\n    assert.equal(-1, Utils.compareVersions(\"1.40\", \"1.40.1\"));\n    assert.equal(1, Utils.compareVersions(\"1.41\", \"1.40\"));\n    assert.equal(1, Utils.compareVersions(\"1.41.0\", \"1.40\"));\n    assert.equal(1, Utils.compareVersions(\"1.41.1\", \"1.41\"));\n  });\n});\n\ncontext(\"makeIdempotent\", () => {\n  let func;\n  let count = 0;\n\n  setup(() => {\n    count = 0;\n    func = Utils.makeIdempotent((n) => {\n      if (n == null) {\n        n = 1;\n      }\n      count += n;\n    });\n  });\n\n  should(\"call a function once\", () => {\n    func();\n    assert.equal(1, count);\n  });\n\n  should(\"call a function once with an argument\", () => {\n    func(2);\n    assert.equal(2, count);\n  });\n\n  should(\"not call a function a second time\", () => {\n    func();\n    assert.equal(1, count);\n  });\n\n  should(\"not call a function a second time\", () => {\n    func();\n    assert.equal(1, count);\n    func();\n    assert.equal(1, count);\n  });\n});\n\ncontext(\"distinctCharacters\", () => {\n  should(\n    \"eliminate duplicate characters\",\n    () => assert.equal(\"abc\", Utils.distinctCharacters(\"bbabaabbacabbbab\")),\n  );\n});\n\ncontext(\"escapeRegexSpecialCharacters\", () => {\n  should(\"escape regexp special characters\", () => {\n    const str = \"-[]/{}()*+?.^$|\";\n    const regexp = new RegExp(Utils.escapeRegexSpecialCharacters(str));\n    assert.isTrue(regexp.test(str));\n  });\n});\n\ncontext(\"extractQuery\", () => {\n  should(\"extract the query terms from a URL\", () => {\n    const url = \"https://www.google.ie/search?q=star+wars&foo&bar\";\n    const searchUrl = \"https://www.google.ie/search?q=%s\";\n    assert.equal(\"star wars\", Utils.extractQuery(searchUrl, url));\n  });\n\n  should(\"require trailing URL components\", () => {\n    const url = \"https://www.google.ie/search?q=star+wars&foo&bar\";\n    const searchUrl = \"https://www.google.ie/search?q=%s&foobar=x\";\n    assert.equal(null, Utils.extractQuery(searchUrl, url));\n  });\n\n  should(\"accept trailing URL components\", () => {\n    const url = \"https://www.google.ie/search?q=star+wars&foo&bar&foobar=x\";\n    const searchUrl = \"https://www.google.ie/search?q=%s&foobar=x\";\n    assert.equal(\"star wars\", Utils.extractQuery(searchUrl, url));\n  });\n});\n\ncontext(\"pick\", () => {\n  should(\"omit properties\", () => {\n    assert.equal({ a: 1, b: 2 }, Utils.pick({ a: 1, b: 2, c: 3 }, [\"a\", \"b\", \"d\"]));\n  });\n});\n\ncontext(\"keyBy\", () => {\n  const array = [\n    { key: \"a\" },\n    { key: \"b\" },\n  ];\n\n  should(\"group by string key\", () => {\n    assert.equal(\n      { a: array[0], b: array[1] },\n      Utils.keyBy(array, \"key\"),\n    );\n  });\n\n  should(\"group by key function\", () => {\n    assert.equal(\n      { a: array[0], b: array[1] },\n      Utils.keyBy(array, (el) => el.key),\n    );\n  });\n});\n\ncontext(\"assertType\", () => {\n  should(\"fail if schema or object is null\", () => {\n    assert.throwsError(() => Utils.assertType(null, { a: 1 }));\n    assert.throwsError(() => Utils.assertType({ a: null }, null));\n  });\n\n  should(\"not allow unknown fields\", () => {\n    const schema = { a: null };\n    Utils.assertType(schema, { a: 1 });\n    assert.throwsError(() => Utils.assertType(schema, { b: 1 }));\n  });\n\n  should(\"type check fields with types\", () => {\n    const schema = {\n      bool: \"boolean\",\n      num: \"number\",\n      string: \"string\",\n    };\n    Utils.assertType(schema, {\n      bool: true,\n      num: 1,\n      string: \"example\",\n    });\n    assert.throwsError(() => Utils.assertType(schema, { bool: 1 }));\n    assert.throwsError(() => Utils.assertType(schema, { num: \"example\" }));\n    assert.throwsError(() => Utils.assertType(schema, { string: 1 }));\n  });\n\n  should(\"allow null values for typed fields\", () => {\n    Utils.assertType({ bool: \"boolean\" }, { bool: null });\n  });\n});\n"
  },
  {
    "path": "tests/unit_tests/vomnibar_page_test.js",
    "content": "import * as testHelper from \"./test_helper.js\";\nimport \"../../tests/unit_tests/test_chrome_stubs.js\";\nimport { Suggestion } from \"../../background_scripts/completion/completers.js\";\nimport * as vomnibarPage from \"../../pages/vomnibar_page.js\";\n\nfunction newKeyEvent(properties) {\n  return Object.assign(\n    {\n      type: \"keydown\",\n      key: \"a\",\n      ctrlKey: false,\n      shiftKey: false,\n      altKey: false,\n      metaKey: false,\n      stopImmediatePropagation: function () {},\n      preventDefault: function () {},\n    },\n    properties,\n  );\n}\n\ncontext(\"vomnibar page\", () => {\n  let ui;\n  setup(async () => {\n    await testHelper.jsdomStub(\"pages/vomnibar_page.html\");\n    stub(chrome.runtime, \"sendMessage\", async (message) => {\n      if (message.handler == \"filterCompletions\") {\n        return [];\n      }\n    });\n    vomnibarPage.reset();\n    await vomnibarPage.activate();\n    ui = vomnibarPage.ui;\n  });\n\n  should(\"hide when escape is pressed\", async () => {\n    ui.setQuery(\"www.example.com\");\n    // Here we assert that the dialog has been reset when esc is pressed, which happens as part of\n    // hiding the dialog. It would be better to check more directly that the dialog was hidden, but\n    // jacking into the channels for this are not worthwhile for this test.\n    await ui.onKeyEvent(newKeyEvent({ key: \"Escape\" }));\n    assert.equal(\"\", ui.input.value);\n  });\n\n  should(\"edit a completion's URL when ctrl-enter is pressed\", async () => {\n    stub(chrome.runtime, \"sendMessage\", async (message) => {\n      if (message.handler == \"filterCompletions\") {\n        const s = new Suggestion({ url: \"http://hello.com\" });\n        return [s];\n      }\n    });\n    await ui.update();\n    await ui.onKeyEvent(newKeyEvent({ type: \"keydown\", key: \"up\" }));\n    // TODO(philc): Why does this need to be lowercase enter?\n    await ui.onKeyEvent(newKeyEvent({ type: \"keypress\", ctrlKey: true, key: \"enter\" }));\n    assert.equal(\"http://hello.com\", ui.input.value);\n  });\n\n  should(\"open a URL-like query when enter is pressed\", async () => {\n    ui.setQuery(\"www.example.com\");\n    let handler = null;\n    let url = null;\n    stub(chrome.runtime, \"sendMessage\", async (message) => {\n      handler = message.handler;\n      url = message.url;\n    });\n    await ui.onKeyEvent(newKeyEvent({ type: \"keypress\", key: \"Enter\" }));\n    ui.onHidden();\n    assert.equal(\"openUrlInCurrentTab\", handler);\n    assert.equal(\"www.example.com\", url);\n  });\n\n  should(\"search for a non-URL query when enter is pressed\", async () => {\n    ui.setQuery(\"example\");\n    let handler = null;\n    let query = null;\n    stub(chrome.runtime, \"sendMessage\", async (message) => {\n      handler = message.handler;\n      query = message.query;\n    });\n    await ui.onKeyEvent(newKeyEvent({ type: \"keypress\", key: \"Enter\" }));\n    ui.onHidden();\n    assert.equal(\"launchSearchQuery\", handler);\n    assert.equal(\"example\", query);\n  });\n\n  // This test covers #4396.\n  should(\"not treat javascript keywords as user-defined search engines\", async () => {\n    ui.setQuery(\"constructor \"); // \"constructor\" is a built-in JS property\n    ui.onInput();\n    // The query should not be treated as a user search engine.\n    assert.equal(\"constructor \", ui.input.value);\n  });\n});\n"
  },
  {
    "path": "tests/vendor/shoulda.js",
    "content": "/*\n * A unit testing micro framework. Tests are grouped into \"contexts\", each of which can share common\n * setup and teardown functions.\n */\n\n/*\n * Assertions.\n */\nconst assert = {\n  isTrue(value) {\n    if (!value) {\n      this.fail(\"Expected true, but got \" + value);\n    }\n  },\n\n  isFalse(value) {\n    if (value) {\n      this.fail(\"Expected false, but got \" + value);\n    }\n  },\n\n  // Does a deep-equal check on complex objects.\n  equal(expected, actual) {\n    const areEqual = typeof expected === \"object\"\n      ? JSON.stringify(expected) === JSON.stringify(actual)\n      : expected === actual;\n    if (!areEqual) {\n      this.fail(`Expected:\\n${this._print(expected)}\\nGot:\\n${this._print(actual)}`);\n    }\n  },\n\n  // We cannot name this function simply \"throws\", because it's a reserved JavaScript keyword.\n  throwsError(expression, errorName) {\n    try {\n      expression();\n    } catch (error) {\n      if (errorName) {\n        if (error.name == errorName) {\n          return;\n        } else {\n          assert.fail(\n            `Expected error ${errorName} to be thrown but error ${error.name} was thrown instead.`,\n          );\n        }\n      } else {\n        return;\n      }\n    }\n    if (errorName) {\n      assert.fail(`Expected error ${errorName} but no error was thrown.`);\n    } else {\n      assert.fail(\"Expected error but none was thrown.\");\n    }\n  },\n\n  fail(message) {\n    throw new AssertionError(message);\n  },\n\n  // Used for printing the arguments passed to assertions.\n  _print(object) {\n    if (object === null) return \"null\";\n    else if (object === undefined) return \"undefined\";\n    else if (typeof object === \"string\") return '\"' + object + '\"';\n    else {\n      try {\n        // Pretty-print with indentation.\n        return JSON.stringify(object, undefined, 2);\n      } catch (_) {\n        // `object` might not be stringifiable (e.g. DOM nodes), or JSON.stringify may not exist.\n        return object.toString();\n      }\n    }\n  },\n};\n\n/*\n * ensureCalled ensures the given function is called by the end of the test case. This is useful\n * when testing APIs that use callbacks.\n */\nfunction ensureCalled(fn) {\n  const wrappedFunction = function () {\n    const i = Tests.requiredCallbacks.indexOf(wrappedFunction);\n    if (i >= 0) {\n      Tests.requiredCallbacks.splice(i, 1); // Delete.\n    }\n    return fn?.apply(null, arguments);\n  };\n  Tests.requiredCallbacks.push(wrappedFunction);\n  return wrappedFunction;\n}\n\nclass AssertionError extends Error {\n  constructor(message) {\n    super(message);\n    this.name = \"AssertionError\";\n    // Omit this constructor from the error's backtrace.\n    Error.captureStackTrace?.(this, AssertionError);\n  }\n}\n\n/*\n * A Context is a named set of test methods and nested contexts, with optional setup and teardown\n * methods.\n */\nfunction Context(name) {\n  this.name = name;\n  this.setupMethod = null;\n  this.teardownMethod = null;\n  this.contexts = [];\n  this.tests = [];\n}\n\nconst contextStack = [];\n\n/*\n * See the usage documentation for details on how to use the \"context\" and \"should\" functions.\n */\nfunction context(name, fn) {\n  if (typeof fn != \"function\") {\n    throw new Error(\"context() requires a function argument.\");\n  }\n  const newContext = new Context(name);\n  if (contextStack.length > 0) {\n    contextStack[contextStack.length - 1].tests.push(newContext);\n  } else {\n    Tests.topLevelContexts.push(newContext);\n  }\n  contextStack.push(newContext);\n  fn();\n  contextStack.pop();\n  return newContext;\n}\n\ncontext.only = (name, fn) => {\n  const c = context(name, fn);\n  c.isFocused = true;\n  Tests.focusIsUsed = true;\n};\n\nfunction setup(fn) {\n  contextStack[contextStack.length - 1].setupMethod = fn;\n}\n\nfunction teardown(fn) {\n  contextStack[contextStack.length - 1].teardownMethod = fn;\n}\n\nfunction should(name, fn) {\n  const test = { name, fn };\n  contextStack[contextStack.length - 1].tests.push(test);\n  return test;\n}\n\nshould.only = (name, fn) => {\n  const test = should(name, fn);\n  test.isFocused = true;\n  Tests.focusIsUsed = true;\n};\n\n/*\n * Tests is used to run tests and keep track of the count of successes and failures.\n */\nconst Tests = {\n  topLevelContexts: [],\n  testsRun: 0,\n  testsFailed: 0,\n\n  // The list of callbacks created by `ensureCalled` which must be called by the end of the test.\n  requiredCallbacks: [],\n\n  // True if, during the collection phase, should.only or context.only was used.\n  focusIsUsed: false,\n\n  /*\n   * Run all contexts which have been defined.\n   * - testNameFilter: a String. If provided, only run tests which match testNameFilter will be run.\n   */\n  async run(testNameFilter) {\n    // Run every top level context (i.e. those not defined within another context). These will in\n    // turn run any nested contexts. The very last context ever added to Tests.testContexts is a top\n    // level context. Note that any contexts which have not already been run by a previous top level\n    // context must themselves be top level contexts.\n    this.testsRun = 0;\n    this.testsFailed = 0;\n    for (const context of this.topLevelContexts) {\n      await this.runContext(context, [], testNameFilter);\n    }\n    this.printTestSummary();\n    return this.testsFailed == 0;\n  },\n\n  /*\n   * This resets (clears) the state of shoulda, including the tests which have been defined. This is\n   * useful when running shoulda tests in a REPL environment, to prevent tests from getting defined\n   * multiple times when a file is re-evaluated.\n   */\n  reset() {\n    this.topLevelContexts = [];\n    this.focusedTests = [];\n    this.focusIsUsed = false;\n  },\n\n  /*\n   * Run a context. This runs the test methods defined in the context first, and then any nested\n   * contexts.\n   */\n  async runContext(context, parentContexts, testNameFilter) {\n    parentContexts = parentContexts.concat([context]);\n    for (const test of context.tests) {\n      if (test instanceof Context) {\n        await this.runContext(test, parentContexts, testNameFilter);\n      } else {\n        await this.runTest(test, parentContexts, testNameFilter);\n      }\n    }\n  },\n\n  /*\n   * Run a test. This will run all setup methods in all contexts, and then all teardown methods.\n   * - testMethod: an object with keys name, fn.\n   * - contexts: an array of Contexts, ordered outer to inner.\n   * - testNameFilter: A String. If provided, only run the test if it matches testNameFilter.\n   */\n  async runTest(testMethod, contexts, testNameFilter) {\n    const shouldSkip = this.focusIsUsed && !testMethod.isFocused &&\n      !contexts.some((c) => c.isFocused);\n    if (shouldSkip) return;\n\n    const fullTestName = this.fullyQualifiedName(testMethod.name, contexts);\n    if (testNameFilter && !fullTestName.includes(testNameFilter)) {\n      return;\n    }\n\n    this.testsRun++;\n    let failureMessage = null;\n    // This is the scope which all references to \"this\" in the setup and test methods resolve to.\n    const testScope = {};\n\n    const errors = [];\n\n    for (const context of contexts.filter((c) => c.setupMethod)) {\n      try {\n        await context.setupMethod.call(testScope, testScope);\n      } catch (error) {\n        errors.push(error);\n        break;\n      }\n    }\n\n    if (errors.length == 0) {\n      try {\n        await testMethod.fn.call(testScope, testScope);\n      } catch (error) {\n        errors.push(error);\n      }\n    }\n\n    for (const context of contexts.filter((c) => c.teardownMethod)) {\n      try {\n        await context.teardownMethod.call(testScope, testScope);\n      } catch (error) {\n        errors.push(error);\n        break;\n      }\n    }\n\n    if (this.requiredCallbacks.length > 0) {\n      errors.push(\"A callback function should have been called during this test, but wasn't.\");\n    }\n\n    if (errors.length > 0) {\n      Tests.testsFailed++;\n    }\n\n    // Print the errors in the order they occurred in the setup, test, teardown chain.\n    for (const [i, error] of Object.entries(errors)) {\n      // Note that in JavaScript, any object can be thrown, even a string or null.\n      let message;\n      if (Error.isError(error)) {\n        if (error instanceof AssertionError) {\n          message = error.message;\n        } else {\n          // In Deno and Chrome, error.stack also includes the error's message.\n          message = error.stack;\n        }\n      } else {\n        // Thrown types which are not Errors will not have a backtrace.\n        message = String(error);\n      }\n\n      // For the first failure only, print the failed test header message.\n      if (i == 0) {\n        Tests.printFailure(fullTestName, message);\n      } else {\n        console.log(\"---\"); // Add a visual separator between backtraces when there are many.\n        console.log(message);\n      }\n    }\n\n    this.requiredCallbacks = [];\n    clearStubs();\n  },\n\n  // The fully-qualified name of the test or context, e.g. \"context1: context2: testName\".\n  fullyQualifiedName(testName, contexts) {\n    return contexts.map((c) => c.name).concat(testName).join(\": \");\n  },\n\n  printTestSummary() {\n    if (this.testsFailed > 0) {\n      console.log(`Fail (${Tests.testsFailed}/${Tests.testsRun})`);\n    } else {\n      console.log(`Pass (${Tests.testsRun}/${Tests.testsRun})`);\n    }\n  },\n\n  printFailure(testName, failureMessage) {\n    console.log(`Fail \"${testName}\"\\n${failureMessage}`);\n  },\n};\n\nfunction run(testNameFilter) {\n  return Tests.run(testNameFilter);\n}\n\nfunction reset() {\n  Tests.reset();\n}\n\n/*\n * Stats of the latest test run.\n */\nfunction getStats() {\n  return {\n    failed: Tests.testsFailed,\n    run: Tests.testsRun,\n  };\n}\n\n/*\n * Stubs\n */\nconst stubbedObjects = [];\n\nfunction stub(object, propertyName, returnValue) {\n  stubbedObjects.push({\n    object: object,\n    propertyName: propertyName,\n    original: object[propertyName],\n  });\n  object[propertyName] = returnValue;\n}\n\n/*\n * returns creates a function which returns the given value. This is useful for stubbing functions\n * to return a hardcoded value.\n */\nfunction returns(value) {\n  return () => value;\n}\n\nfunction clearStubs() {\n  // Restore stubs in the reverse order they were defined in, in case the same property was stubbed\n  // twice.\n  for (let i = stubbedObjects.length - 1; i >= 0; i--) {\n    const stubProperties = stubbedObjects[i];\n    stubProperties.object[stubProperties.propertyName] = stubProperties.original;\n  }\n}\n\nexport {\n  assert,\n  context,\n  ensureCalled,\n  getStats,\n  reset,\n  returns,\n  run,\n  setup,\n  should,\n  stub,\n  teardown,\n};\n"
  }
]