Repository: philc/vimium
Branch: master
Commit: e5163eaa32d4
Files: 118
Total size: 749.5 KB
Directory structure:
gitextract_bk_2yr_t/
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ └── bug_report.md
│ └── pull_request_template.md
├── .gitignore
├── CHANGELOG.md
├── CONTRIBUTING.md
├── CREDITS
├── MIT-LICENSE.txt
├── README.md
├── background_scripts/
│ ├── all_commands.js
│ ├── bg_utils.js
│ ├── commands.js
│ ├── completion/
│ │ ├── completers.js
│ │ ├── ranking.js
│ │ ├── search_engines.js
│ │ └── search_wrapper.js
│ ├── exclusions.js
│ ├── main.js
│ ├── marks.js
│ ├── reload.js
│ ├── tab_operations.js
│ ├── tab_recency.js
│ └── user_search_engines.js
├── build_scripts/
│ └── write_command_listing_page.js
├── content_scripts/
│ ├── file_urls.css
│ ├── hud.js
│ ├── link_hints.js
│ ├── marks.js
│ ├── mode.js
│ ├── mode_find.js
│ ├── mode_insert.js
│ ├── mode_key_handler.js
│ ├── mode_normal.js
│ ├── mode_visual.js
│ ├── scroller.js
│ ├── ui_component.js
│ ├── vimium.css
│ ├── vimium_frontend.js
│ └── vomnibar.js
├── deno.json
├── lib/
│ ├── chrome_api_stubs.js
│ ├── dom_utils.js
│ ├── find_mode_history.js
│ ├── handler_stack.js
│ ├── keyboard_utils.js
│ ├── rect.js
│ ├── settings.js
│ ├── types.js
│ ├── url_utils.js
│ └── utils.js
├── make.js
├── manifest.json
├── pages/
│ ├── action.css
│ ├── action.html
│ ├── action.js
│ ├── all_content_scripts.js
│ ├── command_listing.css
│ ├── command_listing.html
│ ├── command_listing.js
│ ├── doc.css
│ ├── doc_search_completion.html
│ ├── doc_search_completion.js
│ ├── exclusion_rules_editor.js
│ ├── help_dialog_page.css
│ ├── help_dialog_page.html
│ ├── help_dialog_page.js
│ ├── hud_page.css
│ ├── hud_page.html
│ ├── hud_page.js
│ ├── key_mappings.css
│ ├── options.css
│ ├── options.html
│ ├── options.js
│ ├── reload.html
│ ├── ui_component_messenger.js
│ ├── vomnibar_page.css
│ ├── vomnibar_page.html
│ └── vomnibar_page.js
├── resources/
│ └── tlds.txt
├── test_harnesses/
│ ├── cross_origin_iframe.html
│ ├── event_capture.html
│ ├── form.html
│ ├── has_popup_and_link_hud.html
│ ├── iframe.html
│ ├── page_with_links.html
│ ├── visibility_test.html
│ ├── vomnibar_harness.html
│ └── vomnibar_harness.js
└── tests/
├── dom_tests/
│ ├── dom_test_setup.js
│ ├── dom_tests.html
│ ├── dom_tests.js
│ └── dom_utils_test.js
├── unit_tests/
│ ├── bg_utils_test.js
│ ├── command_listing_test.js
│ ├── commands_test.js
│ ├── completion/
│ │ ├── completers_test.js
│ │ ├── ranking_test.js
│ │ └── search_engines_test.js
│ ├── doc_search_completion_test.js
│ ├── exclusion_test.js
│ ├── handler_stack_test.js
│ ├── help_dialog_test.js
│ ├── hud_page_test.js
│ ├── link_hints_test.js
│ ├── main_test.js
│ ├── marks_test.js
│ ├── options_page_test.js
│ ├── rect_test.js
│ ├── settings_test.js
│ ├── tab_operations_test.js
│ ├── tab_recency_test.js
│ ├── test_chrome_stubs.js
│ ├── test_helper.js
│ ├── ui_component_test.js
│ ├── url_utils_test.js
│ ├── user_search_engines_test.js
│ ├── utils_test.js
│ └── vomnibar_page_test.js
└── vendor/
└── shoulda.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: File a bug
title: ""
labels: ""
assignees: ""
---
**Describe the bug**
Include a clear bug description.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to URL '...'
2. Click on '....'
Include a screenshot if applicable.
**Browser and Vimium version**
If you're using Chrome, include the Chrome and OS version found at chrome://version. Also include
the Vimium version found at chrome://extensions.
If you're using Firefox, report the Firefox and OS version found at about:support. Also include the
Vimium version found at about:addons.
================================================
FILE: .github/pull_request_template.md
================================================
## Description
Provide a rationale for this PR, and a reference to the corresponding issue, if there is one.
Please review the "Which pull requests get merged?" section in `CONTRIBUTING.md`.
================================================
FILE: .gitignore
================================================
dist
================================================
FILE: CHANGELOG.md
================================================
2.4.1, 2.4.2 (2026-03-07)
- Fix issue where existing users were mistakenly opted-in to
[Vimium's new tab page](https://github.com/philc/vimium/pull/4795) for the `createTab` command.
(https://github.com/philc/vimium/issues/4859)
- Fix exclusion rules is empty in downloaded backups. (https://github.com/philc/vimium/issues/4839)
2.4.0 (2026-01-27)
- Support a Vimium new tab experience: the browser can be configured to open a blank Vimium page as
the new tab page. In Vimium's settings, the Vomnibar can configured to open on new tabs. See
[instructions](https://github.com/philc/vimium?tab=readme-ov-file#how-to-allow-vimium-to-work-on-new-tab-pages).
(https://github.com/philc/vimium/pull/4795)
- Make Google search result links work on sub-tabs like "Web".
(https://github.com/philc/vimium/issues/4750)
2.3.1 (2025-11-12)
- Fix Vimium to work with Chrome 144. (https://github.com/philc/vimium/issues/4785)
2.3.0 (2025-06-30)
- Add a command listing page, which documents all commands and their options. Access it
[on the web](https://vimium.github.io/commands/), or from the Vimium Options page > Show available
commands.
- Some internal CSS classes were changed for Vimium's UI. This may affect those who have customized
Vimium's CSS via the options page. (https://github.com/philc/vimium/issues/4668)
- Breaking change: when creating a mapping for `setZoom`, a `level` argument is now required. E.g.:
`map z2 setZoom level=2.0`.
- Make `Vomnibar.activateBookmark` accept a `query` option.
(https://github.com/philc/vimium/pull/4591)
- Fix `openCopiedUrlInCurrentTab` doesn't launch search queries.
(https://github.com/philc/vimium/issues/4657)
- Make `openCopiedUrlInCurrentTab` accept a `position` option.
- Update `goPrevious` and `goNext` commands to handle google.com's new layout.
(https://github.com/philc/vimium/issues/4650)
- Add a "hide update notifications" option for silencing "Vimium has been updated" notifications.
(https://github.com/philc/vimium/issues/4346)
- Use dark mode styles in the HUD when the browser is in dark mode.
- Bug fixes.
2.2.1 (2025-03-20)
- Fix findSelected and findSelectedBackwards commands (https://github.com/philc/vimium/issues/4655)
- Fix openCopiedUrlInCurrentTab (https://github.com/philc/vimium/issues/4654)
2.2.0 (2025-03-08)
- Use the browser's default search engine. [(#2598)](https://github.com/philc/vimium/issues/2598)
- Add "reload hard" command (R). ([#4445](https://github.com/philc/vimium/pull/4445)).
- Add zoomIn (zi), zoomOut (zo), zoomReset (z0), and setZoom commands.
([#4488](https://github.com/philc/vimium/pull/4488))
- Add findSelected and findSelectedBackwards commands.
([#4502](https://github.com/philc/vimium/pull/4502))
- Options page: improve UI, add error validation.
- Make tab commands handle Firefox hidden tabs.
- Bug fixes.
2.1.2 (2024-04-03)
- Better fix for Vomnibar doesn't always list tabs by recency.
([#4368](https://github.com/philc/vimium/issues/4368))
- Add a workaround to make link hints work on Github Enterprise.
([#4446](https://github.com/philc/vimium/issues/4446))
- Fix position=end is ignored in createTab command
([#4450](https://github.com/philc/vimium/issues/4450))
2.1.1 (2024-03-29)
- Fix exclusion rule popup not working. ([#4447](https://github.com/philc/vimium/issues/4447))
2.1.0 (2024-03-27)
- Fix Vomnibar doesn't always list tabs by recency.
([#4368](https://github.com/philc/vimium/issues/4368))
- Better domain detection in the Vomnibar ([#3268](https://github.com/philc/vimium/issues/3268))
- Exclude keys based on the top frame URL, not a subframe's URL. This fixes many cases where the
excluded keys feature didn't seem to work. ([#4402](https://github.com/philc/vimium/issues/4402))
- After selecting a link, if ESC is pressed, mouse out of the link. With this, Wikipedia's and
Github's link preview popups can be dismissed after following a link.
([#3073](https://github.com/philc/vimium/issues/3073))
- Fix link hints do not appear for links inside of github's popups. This fix is available on Chrome
114+, and soon Firefox. ([#4408](https://github.com/philc/vimium/issues/4408))
2.0.5, 2.0.6 (2023-11-06)
- Fix bug where "esc" wouldn't unfocus a textarea like it should.
([#4336](https://github.com/philc/vimium/issues/4336))
- Fix passNextKey command.
2.0.4 (2023-10-19)
- Bug fixes: ([#4340](https://github.com/philc/vimium/issues/4340)),
([#4341](https://github.com/philc/vimium/issues/4341)),
([#4342](https://github.com/philc/vimium/issues/4342)).
2.0.2, 2.0.3 (2023-10-11)
- Fix Vomnibar tab search doesn't get pre-populated with recently visited tabs.
([#4326](https://github.com/philc/vimium/issues/4326))
- Fix bookmarklets not working when opened from the Vomnibar. This is a partial fix; a full fix is
waiting on a new extensions API. See [#4329](https://github.com/philc/vimium/issues/4329) for
discussion.
2.0.1 (2023-10-04)
- Fix exception when migrating some pre-v2.0 settings.
([#4323](https://github.com/philc/vimium/issues/4323))
2.0.0 (2023-09-28)
- Support manifest v3, as now required by Chrome. This involved a partial rewrite and many changes.
Please report any new issues [here](https://github.com/philc/vimium/issues).
- The storage format for Vimium's options has changed in v2.x. That means an options backup from
Vimium v2.x cannot be loaded on Vimium v1.x installations.
- Revamp the action bar UI, which configures which keys Vimium ignores on a particular site.
- Improve Vimium's options UI.
- Show link hints for image maps. ([#3493](https://github.com/philc/vimium/issues/3493))
- Remove the use of window.unload handlers, in preparation for Chrome's bfcache.
([#4265](https://github.com/philc/vimium/issues/4265))
- Allow find mode to work when using only private windows.
([#3614](https://github.com/philc/vimium/issues/3614))
- Add a count option to closeTabsOnLeft and closeTabsOnRight commands, to allow binding a key to
"close just 1 tab on the left/right" rather than closing all tabs, as is the default. E.g.
`map cl
closeTabsOnLeft count=1`. ([#4296](https://github.com/philc/vimium/pull/4296))
- Add search completions for Brave Search. ([#3851](https://github.com/philc/vimium/pull/3851))
- Make regular expressions in find mode work again; other find mode improvements.
([#4261](https://github.com/philc/vimium/issues/4261))
- Bug fixes. ([#3944](https://github.com/philc/vimium/pull/3944),
[#3752](https://github.com/philc/vimium/pull/3752),
[#3675](https://github.com/philc/vimium/pull/3675))
1.67.7 (2023-07-12)
- Fix an issue where focusing the google search box puts the cursor at the start, rather than end,
of the search box. ([#4247](https://github.com/philc/vimium/issues/4247))
1.67.6 (2022-12-19)
- Fix a spurious issue preventing approval on the Mozilla addons site
([#4195](https://github.com/philc/vimium/issues/4195))
1.67.5 (2022-12-17)
- For Firefox only, add back the clipboard read and write permissions. This fixes the Vimium
commands which use the clipboard in Firefox ([#4186](https://github.com/philc/vimium/pull/4186))
1.67.4 (2022-12-01)
- Remove clipboard read/write permissions. We no longer need them since 1.67.2 (see #4120).
- Fix Vimium's dark mode styling, take 2 (see [#4156](https://github.com/philc/vimium/issues/4156),
[#4159](https://github.com/philc/vimium/pull/4159))
1.67.3 (2022-10-29)
- Fix copy-to-clipboard issue ([#4147](https://github.com/philc/vimium/issues/4147)) in visual mode.
- Fix Vimium's dark mode styling in latest Firefox.
([#4148](https://github.com/philc/vimium/issues/4148))
1.67.2 (2022-10-17)
- In Firefox, remove use of deprecated InstallTrigger, which was issuing a console warning
([#4033](https://github.com/philc/vimium/issues/4033))
- Fix the Vimium toolbar icon to accurately reflect whether keys are excluded
([#4118](https://github.com/philc/vimium/pull/4118))
- Fix usage of deprecated clipboard APIs, which affected commands using copy and paste
([#4120](https://github.com/philc/vimium/issues/4120))
- Fix bug preventing going into caret mode ([#3877](https://github.com/philc/vimium/pull/3877))
1.67.1 (2022-01-19)
- In Firefox 96+, make link hints open one tab, not two
([#3985](https://github.com/philc/vimium/pull/3985))
1.67 (2021-07-09)
- Dark mode: Vimium's UI (URL bar, help dialog, option page, etc.) are dark if the browser is
configured for dark mode. Vimium's dark mode is also compatible when using the popular
[DarkReader extension](https://github.com/darkreader/darkreader).
- Convert the code base from Coffeescript to Javascript, to simplify the dev experience and allow
more developers to work on Vimium.
- Make search mode work in newer versions of Firefox (#3801)
- Make buttons on the Vimium options page work again in newer versions of Firefox (#3624)
- Allow Vimium to work in LibreWolf (a Firefox fork)
- Fixes to visual mode (#3568, #3779)
1.66 (2020-03-02)
- Show tabs in the Vomnibar bar search results ('o')
([#2656](https://github.com/philc/vimium/pull/2656))
- Add commands to hover or focus a link ([#3097](https://github.com/philc/vimium/pull/3097)) (see
[wiki)](https://github.com/philc/vimium/wiki/Tips-and-Tricks#hovering-over-links-using-linkhints)
- Allow shift as a modifier for keybindings (e.g. ``)
([#2388](https://github.com/philc/vimium/pull/2388))
- Fix some issues with link hints [(#3499](https://github.com/philc/vimium/pull/3499),
[#3505](https://github.com/philc/vimium/pull/3505),
[#3509](https://github.com/philc/vimium/pull/3509))
- Other fixes.
1.65.2 (2020-02-10)
- No code changes; trying to debug a permissions issue as shown in the chrome store
([#3489](https://github.com/philc/vimium/issues/3489)).
1.65.1 (2020-02-09)
- Fix an issue with the HUD preventing some link hints from being shown
([#3486](https://github.com/philc/vimium/issues/3486)).
1.65 (2020-02-08)
- Many fixes for Firefox ([#3483](https://github.com/philc/vimium/pull/3483),
[#2893](https://github.com/philc/vimium/issues/2893),
[#3106](https://github.com/philc/vimium/issues/3106),
[#3409](https://github.com/philc/vimium/pull/3409),
[#3288](https://github.com/philc/vimium/pull/3288))
- Fix javascript bookmarks, broken by Chrome 71+
[(#3473)](https://github.com/philc/vimium/pull/3437)
- Improved link hints: show hints on sites with shadow DOM
[(#3406)](https://github.com/philc/vimium/pull/3406), don't show hints for obstructed/invisible
links ([#2251](https://github.com/philc/vimium/pull/2251))
- Fix scrolling on Reddit.com ([#3327](https://github.com/philc/vimium/pull/3327))
- Show favicons when using the tab switcher ([#2878](https://github.com/philc/vimium/pull/2878))
- The createTab command can now take arguments (start, end, before, after)
([#2895](https://github.com/philc/vimium/pull/2895))
- When using the Vomnibar, you can manually edit the suggested URL by typing ctrl-enter
[(#2464)](https://github.com/philc/vimium/pull/2914)
- Other fixes
1.64.6 (2019-05-12)
- Fix the find mode, and copying the page's URL to the clipboard, which were broken by Chrome 74+.
([#3260](https://github.com/philc/vimium/issues/3260))
1.64.5 (2019-02-16)
- Fix error in Chrome Store distribution.
1.64.4 (2019-02-16)
- Fix [Vomnibar focus issue](https://github.com/philc/vimium/issues/3242).
1.64.3 (2018-12-26)
- When yanking email addresses with `yf`, Vimium now strips the leading `mailto:`.
- For custom search engines, if you use `%S` (instead of `%s`), then your search terms are not URI
encoded.
- Bug fixes (including horizontal scrolling broken).
1.64.2 (2018-12-16)
- Better scrolling on new Reddit ~~and GMail~~.
1.64 (2018-08-30)
- Custom search engines can now be `javascript:` URLs (eg., search the current
[site](https://github.com/philc/vimium/issues/2956#issuecomment-366509915)).
- You can now using local marks to mark a hash/anchor. This is particularly useful for marking
labels on GMail.
- For filtered hints, you can now start typing the link text before the hints have been generated.
- On Twitter, expanded tweets are now scrollable.
- Fix bug whereby `` wasn't recognised in the Vomnibar in some circumstances.
- Various minor bug fixes.
1.63 (2018-02-16)
- The `reload` command now accepts a count prefix; so `999r` reloads all tabs (in the current
window).
- Better detection of click listeners for link hints.
- Display version number in page popup.
- The Vomnibar is now loaded on demand (not preloaded). This should fix some issues with the dev
console.
- The `\I` control (case sensitivity) for find mode has been removed. Find mode uses smartcase.
- Various bug fixes.
- 1.63.1 (Firefox only):
- Fix [#2958](https://github.com/philc/vimium/issues/2958#issuecomment-366488659), link hints
broken for `target="_blank"` links.
- 1.63.2 (Firefox only):
- Fix [#2962](https://github.com/philc/vimium/issues/2962), find mode broken on Firefox Quantum.
- 1.63.3:
- Fix [#2997](https://github.com/philc/vimium/issues/2997), Vimium's DOM injection breaks Google
Pay site.
1.62 (2017-12-09)
- Backup and restore Vimium options (see the very bottom of the options page, below _Advanced
Options_).
- It is now possible to map ``, ``, ``, ``, `` and ``.
- New command options for `createTab` to create new normal and incognito windows
([examples](https://github.com/philc/vimium/wiki/Tips-and-Tricks#creating-tabs-with-urls-and-windows)).
- Firefox only:
- Fix copy and paste commands.
- When upgrading, you will be asked to re-validate permissions. The only new permission is "copy
and paste to/from clipboard" (the `clipboardWrite` permission). This is necessary to support
copy/paste on Firefox.
- Various bug fixes.
- 1.62.1: Swap global and local marks (1.62.1). In a browser, some people find global marks more
useful than local marks. Example:
```
map X Marks.activateCreateMode swap
map Y Marks.activateGotoMode swap
```
- Other minor versions:
- 1.62.2: Fixes [#2868](https://github.com/philc/vimium/issues/2868) (`createTab` with multiple
URLs).
- 1.62.4: Fixes bug affecting the enabled state, and really fix `createTab`.
1.61 (2017-10-27)
- For _filtered hints_, you can now use alphabetical hint characters instead of digits; use
`` for hint characters.
- With `map R reload hard`, the reload command now asks Chrome to bypass its cache.
- You can now map `` to a command (in which case it will not be treated as `Escape`).
- Various bug fixes, particularly for Firefox.
- Minor versions:
- 1.61.1: Fix `map R reload hard`.
1.60 (2017-09-14)
- Features:
- There's a new (advanced) option to ignore the keyboard layout; this can be helpful for users of
non-Latin keyboards.
- Firefox support. This is a work in progress; please report any issues
[here](https://github.com/philc/vimium/issues?q=is%3Aopen+sort%3Aupdated-desc); see the
[add on](https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/).
- Bug fixes:
- Fixed issue affecting hint placement when the display is zoomed.
- Fixed search completion for Firefox (released as 1.59.1, Firefox only).
- Minor versions:
- 1.60.1: fix [#2642](https://github.com/philc/vimium/issues/2642).
- 1.60.2: revert previous fix for HiDPI screens. This was breaking link-hint positioning for some
users.
- 1.60.3: [fix](https://github.com/philc/vimium/pull/2649) link-hint positioning.
- 1.60.4: [fix](https://github.com/philc/vimium/pull/2602) hints opening in new tab (Firefox
only).
1.59 (2017-04-07)
- Features:
- Some commands now work on PDF tabs (`J`, `K`, `o`, `b`, etc.). Scrolling and other
content-related commands still do not work.
1.58 (2017-03-08)
- Features:
- The `createTab` command can now open specific URLs (e.g,
`map X createTab http://www.bbc.com/news`).
- With pass keys defined for a site (such as GMail), you can now use Vimium's bindings again with,
for example, `map \ passNextKey normal`; this reactivates normal mode temporarily, but _without
any pass keys_.
- You can now map multi-modifier keys, for example: ``.
- Vimium can now do simple key mapping in some modes; see
[here](https://github.com/philc/vimium/wiki/Tips-and-Tricks#key-mapping). This can be helpful
with some non-English keyboards (and can also be used to remap `Escape`).
- For _Custom key mappings_ on the options page, lines which end with `\` are now continued on the
following line.
- Process:
- In order to provide faster bug fixes, we may in future push new releases without the noisy
notification.
- Post-release minor fixes:
- 1.58.1 (2017-03-09) fix bug in `LinkHints.activateModeWithQueue` (#2445).
- 1.58.2 (2017-03-19) fix key handling bug (#2453).
1.57 (2016-10-01)
- New commands:
- `toggleMuteTab` - mute or unmute the current tab (default binding ``), see also
[advanced usage](https://github.com/philc/vimium/wiki/Tips-and-Tricks#muting-tabs).
- Other new features:
- You can now map `` to a Vimium command (e.g. `map goBack`).
- For link hints, when one hint marker is covered by another, `` now rotates the stacking
order. If you use filtered hints, you'll need to use a modifier (e.g. ``).
- Changes:
- Global marks now search for an existing matching tab by prefix (rather than exact match). This
allows global marks to be used as quick bookmarks on sites (like Facebook, Gmail, etc) where the
URL changes as you navigate around.
- Bug fixes:
- `/i` can no longer hang Vimium while the page is loading.
- `` is no longer handled (incorrectly) as `Escape`. This also affects ``.
- If `goX` is mapped, then `go` no longer launches the vomnibar. This only affects three-key (or
longer) bindings.
1.56 (2016-06-11)
- Vimium now works around a Chromium bug affecting users with non-standard keyboard layouts (see
#2147).
- Fixed a bug preventing visual line mode (`V`) from working.
1.55 (2016-05-26)
- New commands:
- `visitPreviousTab` - visit the previous tab (by recency) with `^`, or the tab before that with
`2^`.
- `passNextKey` - pass the next key to the page. For example, using `map passNextKey`, you
can close Facebook's messenger popups with ``.
- Link hints:
- Now work across all frames in the tab.
- Now select frames and scrollable elements.
- Now accept a count prefix; `3F` opens three new background tabs, `999F` opens many tabs.
- For filtered link hints, a new option on the settings page requires you to press `Enter` to
activate a link; this prevents unintentionally triggering Vimium commands with trailing
keystrokes.
- Miscellaneous:
- `gg` now accepts a `count` prefix.
- `W` now accepts a count prefix; `3W` moves three tabs to a new window.
- With smooth scrolling, `2j`-and-hold now gives a faster scroll than `j`-and-hold.
- You can now bind keys to a command with a defined count prefix; for example,
`map d scrollDown count=4`.
- You can now bind three-key (or longer) sequences; for example, `map abc enterInsertMode`.
- `c-y` and `c-e` now scroll in visual mode.
- The Vimium help dialog has been re-styled.
- Bug fixes:
- `` is no longer treated as escape.
- Fix icon display and memory leak due to a regression in recent Chrome versions (49+).
- For web-devs only:
- When disabled on a tab, Vimium no longer pollutes the dev console with network requests.
1.54 (2016-01-30)
- Fix occasional endless scrolling (#1911).
1.53 (2015-09-25)
- Vimium now works on the new-tab page for Chrome 47.
- `g0` and `g$` now accept count prefixes; so `2g0` selects the second tab, and so on.
- Bug fixes:
- Fix `moveTabLeft` and `moveTabRight` for pinned tabs (#1814 and #1815).
1.52 (2015-09-09)
- Search completion for selected custom search engines (details on the
[wiki](https://github.com/philc/vimium/wiki/Search-Completion)).
- Use `Tab` on an empty Vomnibar to repeat or edit recent queries (details on the
[wiki](https://github.com/philc/vimium/wiki/Tips-and-Tricks#repeat-recent-vomnibar-queries)).
- Marks:
- Use \`\` to jump back to the previous position after jump-like movements: (`gg`,
`G`, `n`, `N`, `/` and local mark movements).
- Global marks are now persistent and synced.
- For numeric link hints, you can now use `Tab` and `Enter` to select hints, and hints are ordered
by the best match.
- The Find Mode text entry box now supports editing, pasting, and better handles non-latin
characters.
- Vimium now works on XML pages.
- Bug fixes.
1.51 (2015-05-02)
- Bug
[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).
1.50 (2015-04-26)
- Visual mode (in beta): use `v` and then vim-like keystrokes to select text on the page. Use `y` to
yank or `p` and `P` to search with your default search engine.. Please provide feedback on Github.
- Added the option to prevent pages from stealing focus from Vimium when loaded.
- Many bugfixes for custom search engines, and search engines can now have a description.
- Better support for frames: key exclusion rules are much improved and work within frames; the
Vomnibar is always activated in the main frame; and a new command (`gF`) focuses the main frame.
- Find mode now has history. Use the up arrow to select previous searches.
- Ctrl and Shift when using link hints changes the tab in which links are opened in (reinstated
feature).
- Focus input (`gi`) remembers previously-visited inputs.
- Bug fixes.
1.49 (2014-12-16)
- An option to toggle smooth scrolling.
- Make Vimium work on older versions of Chrome.
1.46, 1.47, 1.48 (2014-12-15)
- Site-specific excluded keys: you can disable some Vimium key bindings on sites like gmail.com, so
you can use the key bindings provided by the site itself.
- Smooth scrolling.
- The Vomnibar now orders tabs by recency. Use this to quickly switch between your most
recently-used tabs.
- New commands: "close tabs to the left", "close tabs to the right", "close all other tabs".
- Usability improvements.
- Bug fixes.
1.45 (2014-07-20)
- Vimium's settings are now synced across computers.
- New commands: "open link in new tab and focus", "move tab left", "move tab right", "pin/unpin
tab".
- Vomnibar can now use
[search engine shortcuts](https://github.com/philc/vimium/wiki/Search-Engines), similar to
Chrome's Omnibar.
- Due to significant ranking improvements, Vomnibar's search results are now even more helpful.
- When reopening a closed tab, its history is now preserved.
- Bug fixes.
1.44 (2013-11-06)
- Add support for recent versions of Chromium.
- Bug fixes.
1.43 (2013-05-18)
- Relevancy improvements to the Vomnibar's domain & history search.
- Added `gU`, which goes to the root of the current URL.
- Added `yt`, which duplicates the current tab.
- Added `W`, which moves the current tab to a new window.
- Added marks for saving and jumping to sections of a page. `mX` to set a mark and `` `X `` to
return to it.
- Added "LinkHints.activateModeToOpenIncognito", currently an advanced, unbound command.
- Disallowed repeat tab closings, since this causes trouble for many people.
- Update our Chrome APIs so Vimium works on Chrome 28+.
- Bug fixes.
1.42 (2012-11-03)
- Bug fixes.
1.41 (2012-10-27)
- Bug fixes.
1.40 (2012-10-27)
- Bug fixes.
- Added options for search engines and regex find.
- Pressing unmapped keys in hints mode now deactivates the mode.
1.39 (2012-09-09)
- Bug fixes.
1.38 (2012-09-08)
- `O` now opens Vomnibar results in a new tab. `B` does the same for bookmarks only.
- Add a browser icon to quickly add sites to Vimium's exclude list.
- Restyle options page.
- `gi` now launches a new mode that allows the user to tab through the input elements on the page.
- Bug fixes.
1.37 (2012-07-07)
- Select the first result by default in Vomnibar tab and bookmark modes.
1.36 (2012-07-07)
- `b` brings up a bookmark-only Vomnibar.
- Better support for some bookmarklets.
1.35 (2012-07-05)
- Bug fixes.
1.34 (2012-07-03)
- A bug fix for bookmarklets in Vomnibar.
1.33 (2012-07-02)
- A Vomnibar, which allows you to open sites from history, bookmarks, and tabs using Vimium's UI.
Type `o` to try it.
1.32 (2012-03-05)
- More tweaks to the next / previous link-detection algorithm.
- Minor bug fixes.
1.31 (2012-02-28)
- Improve style of link hints, and use fewer characters for hints.
- Add an option to hide the heads up display (HUD). Notably, the HUD obscures Facebook Chat's
textbox.
- Detection and following of next / previous links has been improved.
- Addition of `g0` and `g$` commands, for switching tabs.
- Addition of `p`/`P` commands for URL pasting.
- A new find mode which optionally supports case sensitivity and regular expressions.
- Bug fixes.
1.30 (2011-12-04)
- Support for image maps in link hints.
- Counts now work with forward & backward navigation.
- `Tab` & `shift-tab` to navigate bookmarks dialog.
- An alternate link hints mode: type the title of a link to select it. You can enable it in Vimium's
Advanced Preferences.
- Bug fixes.
1.29 (2012-07-30)
- `yf` to copy a link hint url to the clipboard.
- Scatter link hints to prevent clustering on dense sites.
- Don't show insert mode notification unless you specifically hit `i`.
- Remove zooming functionality now that Chrome does it all natively.
1.28 (2011-06-29)
- Support for opening bookmarks (`b` and `B`).
- Support for contenteditable text boxes.
- Speed improvements and bug fixes.
1.27 (2011-03-24)
- Improvements and bug fixes.
1.26 (2011-02-17)
- ``, `` and related are no longer bound by default. You can rebind them on the options
page.
- Faster link hinting.
1.22, 1.23, 1.24, 1.25 (2011-02-10)
- Some sites are now excluded by default.
- View source (`gs`) now opens in a new tab.
- Support for browsing paginated sites using `]]` and `[[` to go forward and backward respectively.
- Many of the less-used commands are now marked as "advanced" and hidden in the help dialog by
default, so that the core command set is more focused and approachable.
- Improvements to link hinting.
- Bug fixes.
1.21 (2010-10-24)
- Critical bug fix for an excluded URLs regression due to frame support.
1.20 (2010-10-24)
- In link hints mode, holding down the shift key will now toggle between opening in the current tab
and opening in a new tab.
- Two new commands (`zH` and `zL`) to scroll to the left and right edges of the page.
- A new command (`gi`) to focus the first (or n-th) visible text input.
- A new command (``) to open up multiple links at a time in new tabs.
- Frame support.
- More robust support for non-US keyboard layouts.
- Numerous bug fixes.
1.19 (2010-06-29)
- A critical bug fix for development channel Chromium.
- Vimium icons for the Chrome extensions panel and other places.
1.18 (2010-06-22)
- Vimium now runs on pages with file:/// and ftp:///
- The Options page is now linked from the Help dialog.
- Arrow keys and function keys can now be mapped using <left>, <right>, <up>,
<down>, <f1>, <f2>, etc. in the mappings interface.
- There is a new command `goUp` (mapped to `gu` by default) that will go up one level in the URL
hierarchy. For example: from https://vimium.github.io/foo/bar to https://vimium.github.io/foo. At
the moment, `goUp` does not support command repetition.
- Bug fixes and optimizations.
1.17 (2010-04-18)
- `u` now restores tabs that were closed by the mouse or with native shortcuts. Tabs are also
restored in their prior position.
- New `unmapAll` command in the key mappings interface to remove all default mappings.
- Link hints are now faster and more reliable.
- Bug fixes.
1.16 (2010-03-09)
- Add support for configurable key mappings under Advanced Options.
- A help dialog which shows all currently bound keyboard shortcuts. Type `?` to see it.
- Bug fixes related to key stroke handling.
1.15 (2010-01-31)
- Make the CSS used by the link hints configurable. It's under Advanced Options.
- Add a notification linking to the changelog when Vimium is updated in the background.
- Link-hinting performance improvements and bug fixes.
- `Ctrl+D` and `Ctrl+U` now scroll by 1/2 page instead of a fixed amount, to mirror Vim's behavior.
1.14 (2010-01-21)
- Fixed a bug introduced in 1.13 that prevented excluded URLs from being saved.
1.13 (2010-01-21)
- `` and `` are now mapped to scroll a full page up or down respectively.
- Bug fixes related to entering insert mode when the page first loads, and when focusing Flash
embeds.
- Added command listing to the Options page for easy reference.
- `J` & `K` have reversed for tab switching: `J` goes left and `K` goes right.
- `` is now equivalent to `Esc`, to match the behavior of VIM.
- `` and `` are now mapped to scroll down and up respectively.
- The characters used for link hints are now configurable under Advanced Options.
1.11, 1.12 (2010-01-08)
- Commands `gt` & `gT` to move to the next & previous tab.
- Command `yy` to yank (copy) the current tab's url to the clipboard.
- Better Linux support.
- Fix for `Shift+F` link hints.
- `Esc` now clears the keyQueue. So, for example, hitting `g`, `Esc`, `g` will no longer scroll the
page.
1.1 (2010-01-03)
- A nicer looking settings page.
- An exclusion list that allows you to define URL patterns for which Vimium will be disabled (e.g.
http\*://mail.google.com/\*).
- Vimium-interpreted keystrokes are no longer sent to the page.
- Better Windows support.
- Various miscellaneous bug fixes and UI improvements.
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to Vimium
## Reporting a bug
File the issue [here](https://github.com/philc/vimium/issues).
## Contributing code
You'd like to fix a bug or implement a feature? Great! Before getting started, understand Vimium's
design principles and the goals of the maintainers.
### Vimium design principles
When people first start using Vimium, it provides an incredibly powerful workflow improvement and it
makes them feel awesome. Surprisingly, Vimium is applicable to a huge, broad population of people,
not just users of Vim.
In addition to power, a secondary goal of Vimium is approachability: minimizing the barriers which
prevent a new user from feeling awesome. Many of Vimium's users haven't used Vim before -- about 1
in 5 Chrome Store reviews say this -- and most people have strong web browsing habits forged from
years of browsing. Given that, it's a great experience when Vimium feels like a natural addition to
Chrome which augments, but doesn't break, the user's current browsing habits.
**Principles:**
1. **Easy to understand**. Even if you're not very familiar with Vim. The Vimium video shows you all
you need to know to start using Vimium and feel awesome.
2. **Reliable**. The core feature set works on most sites on the web.
3. **Immediately useful**. Vimium doesn't require any configuration or doc-reading before it's
useful. Just watch the video or hit `?`. You can transition into using Vimium piecemeal; you
don't need to jump in whole-hog from the start.
4. **Feels native**. Vimium doesn't drastically change the way Chrome looks or behaves.
5. **Simple**. The core feature set isn't overwhelming. This principle is particularly vulnerable as
we add to Vimium, so it requires our active effort to maintain this simplicity.
6. **Code simplicity**. Developers find the Vimium codebase relatively simple and easy to jump into.
This allows more people to fix bugs and implement features.
### Which pull requests get merged?
**Goals of the maintainers**
The maintainers of Vimium have limited bandwidth, which influences which PRs we can review and
merge.
Our goals are generally to keep Vimium small, maintainable, and really nail the broad appeal use
cases. This is in contrast to adding and maintaining an increasing number of complex or niche
features. We recommend those live in forked repos rather than the mainline Vimium repo.
PRs we'll likely merge:
- Reflect all of the Vimium design principles.
- Are useful for lots of Vimium users.
- Have simple implementations (straightforward code, few lines of code).
PRs we likely won't:
- Violate one or more of our design principles.
- Are niche.
- Have complex implementations -- more code than they're worth.
Tips for preparing a PR:
- If you want to check with us first before implementing something big, open an issue proposing the
idea. You'll get feedback from the maintainers as to whether it's something we'll likely merge.
- Try to keep PRs around 50 LOC or less. Bigger PRs create inertia for review.
Here's the rationale behind this policy:
- Vimium is a volunteer effort. To make it possible to keep the project up-to-date as the web and
browsers evolve, the codebase has to remain small and maintainable.
- If the maintainers don't use a feature, and most other users don't, then the feature will likely
get neglected.
- Every feature, particularly neglected ones, increase the complexity of the codebase and makes it
more difficult and less pleasant to work on.
- Adding a new feature is only part of the work. Once it's added, a feature must be maintained
forever.
- Vimium is a project which suffers from the
[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):
there are many users but unfortunately few maintainers. As a result, there is bandwidth to
maintain only a limited number of features in the main repo.
### Installing From Source
Vimium is written in Javascript. To install Vimium from source:
**On Chrome/Chromium:**
1. Navigate to `chrome://extensions`
1. Toggle into Developer Mode
1. Click on "Load Unpacked Extension..."
1. Select the Vimium directory you've cloned from Github.
**On Firefox:**
Firefox needs a modified version of the manifest.json that's used for Chrome. To generate this, run
`./make.js write-firefox-manifest`
After that:
1. Open Firefox
1. Enter "about:debugging" in the URL bar
1. Click "This Firefox" on the left side
1. Click "Load Temporary Add-on"
1. Open the Vimium directory you've cloned from Github, and select any file inside.
### Running the tests
Our tests use [shoulda.js](https://github.com/philc/shoulda.js) and
[Puppeteer](https://github.com/puppeteer/puppeteer). To run the tests:
1. Install [Deno](https://deno.land/) if you don't have it already.
2. `deno run -A npm:puppeteer browsers install chrome` to install puppeteer
3. `./make.js test` to build the code and run the tests.
### Coding Style
- Run `deno fmt` at the root of the Vimium project to format your code.
- We generally follow the recommendations from the
[Airbnb Javascript style guide](https://github.com/airbnb/javascript).
- We wrap lines at 100 characters.
- When writing comments, uppercase the first letter of your sentence, and put a period at the end.
- We're currently using JavaScript language features from ES2018 or earlier. If we desire to use
something introduced in a later version of JavaScript, we need to remember to update the minimum
Chrome and Firefox versions required.
================================================
FILE: CREDITS
================================================
Authors & Maintainers:
Ilya Sukhar (github: ilya)
Phil Crosby (github: philc)
Contributors:
acrollet
Adam Lindberg (github: eproxus)
akhilman
Ângelo Otávio Nuffer Nunes (github: angelonuffer)
Bernardo B. Marques (github: bernardofire)
Bill Casarin (github: jb55)
Bill Mill (github: llimllib)
Branden Rolston (github: branden)
Caleb Spare (github: cespare)
Carl Helmertz (github: chelmertz)
Christian Stefanescu (github: stchris)
ConradIrwin
Daniel MacDougall (github: dmacdougall)
drizzd
gpurkins
hogelog
int3
Johannes Emerich (github: knuton)
Julian Naydichev (github: naydichev)
Justin Blake (github: blaix)
Knorkebrot
lack
markstos
Matthew Cline
Matt Garriott (github: mgarriott)
Matthew Ryan (github: mrmr1993)
Michael Hauser-Raspe (github: mijoharas)
Murph (github: pandeiro)
Niklas Baumstark (github: niklasb)
rodimius
Stephen Blott (github: smblott-github)
Svein-Erik Larsen (github: feinom)
Tim Morgan (github: seven1m)
tsigo
R.T. Lechow (github: rtlechow)
Wang Ning (github:daning)
Werner Laurensse (github: ab3)
Timo Sand (github: deiga)
Shiyong Chen (github: UncleBill)
Utkarsh Upadhyay (github: PrestanceDesign)
Dahan Gong (github: gdh1995)
Scott Pinkelman (github: sco-tt)
Darryl Pogue (github: dpogue)
tobimensch
Ramiro Araujo (github: ramiroaraujo)
Daniel Skogly (github: poacher2k)
Matt Wanchap (github: mwanchap)
Leo Solidum (github: leosolid)
Feel free to add real names in addition to GitHub usernames.
================================================
FILE: MIT-LICENSE.txt
================================================
Copyright (c) 2010 Phil Crosby, Ilya Sukhar.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
================================================
FILE: README.md
================================================
# Vimium - The Hacker's Browser
Vimium is a browser extension that provides keyboard-based navigation and control of the web in the
spirit of the Vim editor.
[Watch the demo video](https://www.youtube.com/watch?v=t67Sn0RGK54).
**Installation instructions:**
- Chrome:
[Chrome web store](https://chromewebstore.google.com/detail/vimium/dbepggeogbaibhgnhhndojpepiihcmeb)
- Edge:
[Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/vimium/djmieaghokpkpjfbpelnlkfgfjapaopa)
- Firefox: [Firefox Add-ons](https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/)
To install from source, see [here](CONTRIBUTING.md#installing-from-source).
Vimium's Options page can be reached via a link on the help dialog (type `?`) or via the button next
to Vimium on the extension pages of Chrome and Edge (`chrome://extensions`), or Firefox
(`about:addons`).
## Keyboard Bindings
Modifier keys are specified as ``, ``, and `` for ctrl+x, meta+x, and alt+x
respectively. For shift+x and ctrl-shift-x, just type `X` and ``. See the next section for how
to customize these bindings.
Once you have Vimium installed, you can see this list of key bindings at any time by typing `?`.
Navigating the current page:
? show the help dialog for a list of all available keys
h scroll left
j scroll down
k scroll up
l scroll right
gg scroll to top of the page
G scroll to bottom of the page
d scroll down half a page
u scroll up half a page
f open a link in the current tab
F open a link in a new tab
r reload
gs view source
i enter insert mode -- all commands will be ignored until you hit Esc to exit
yy copy the current url to the clipboard
yf copy a link url to the clipboard
gf cycle forward to the next frame
gF focus the main/top frame
Navigating to new pages:
o Open URL, bookmark, or history entry
O Open URL, bookmark, history entry in a new tab
b Open bookmark
B Open bookmark in a new tab
Using find:
/ enter find mode
-- type your search query and hit enter to search, or Esc to cancel
n cycle forward to the next find match
N cycle backward to the previous find match
For advanced usage, see [regular expressions](https://github.com/philc/vimium/wiki/Find-Mode) on the
wiki.
Navigating your history:
H go back in history
L go forward in history
Manipulating tabs:
J, gT go one tab left
K, gt go one tab right
g0 go to the first tab. Use ng0 to go to n-th tab
g$ go to the last tab
^ visit the previously-visited tab
t create tab
yt duplicate current tab
x close current tab
X restore closed tab (i.e. unwind the 'x' command)
T search through your open tabs
W move current tab to new window
pin/unpin current tab
Using marks:
ma, mA set local mark "a" (global mark "A")
`a, `A jump to local mark "a" (global mark "A")
`` jump back to the position before the previous jump
-- that is, before the previous gg, G, n, N, / or `a
Additional advanced browsing commands:
]], [[ Follow the link labeled 'next' or '>' ('previous' or '<')
- helpful for browsing paginated sites
open multiple links in a new tab
gi focus the first (or n-th) text input box on the page. Use to cycle through options.
gu go up one level in the URL hierarchy
gU go up to root of the URL hierarchy
ge edit the current URL
gE edit the current URL and open in a new tab
zH scroll all the way left
zL scroll all the way right
v enter visual mode; use p/P to paste-and-go, use y to yank
V enter visual line mode
R Hard reload the page (skip the cache)
Vimium supports command repetition so, for example, hitting `5t` will open 5 tabs in rapid
succession. `` (or ``) will clear any partial commands in the queue and will also exit
insert and find modes.
There are additional commands which aren't included in this README; refer to the help dialog (type
`?`) for a full list.
## Custom Key Mappings
You may remap or unmap any of the default key bindings in the "Custom key mappings" on the options
page.
Enter one of the following key mapping statements per line:
- `map key command`: Maps a key to a Vimium command. Overrides Chrome's default behavior for that
key, if any.
- `unmap key`: Unmaps a key and restores Chrome's default behavior (if any).
- `unmapAll`: Unmaps all bindings. This is useful if you want to completely wipe Vimium's defaults
and start from scratch with your own setup.
Examples:
- `map scrollPageDown` maps ctrl+d to scrolling the page down. Chrome's default behavior of
showing a bookmark dialog is suppressed.
- `map r reload hard` maps the r key to reloading the page, and also includes the "hard" option to
hard-reload the page.
- `unmap ` removes any mapping for ctrl+d and restores Chrome's default behavior.
- `unmap r` removes any mapping for the r key.
See the [docs](https://vimium.github.io/commands/) for every Vimium command and its options.
You can add comments to key mappings by starting a line with `"` or `#`.
The following special keys are available for mapping:
- ``, ``, ``, `` for ctrl, alt, shift, and meta (command on Mac) respectively
with any key. Replace `*` with the key of choice.
- ``, ``, ``, `` for the arrow keys.
- `` through `` for the function keys.
- `` for the space key.
- ``, ``, ``, ``, ``, `` and `` for the
corresponding non-printable keys.
Shifts are automatically detected so, for example, `` corresponds to ctrl+shift+7 on an English
keyboard.
## How to allow Vimium to work on new tab pages
- Vimium will work on new tab pages which are opened with Vimium's `createTab` command (mapped to
`t` by default).
- To have Vimium work on all new tab pages opened by the browser (e.g. via `cmd-t` or
`ctrl-t` shortcuts), a companion
[Vimium New Tab Page extension](https://github.com/philc/vimium-new-tab/) is required.
- Once that is installed, all new tabs will open a blank Vimium new tab page.
## More documentation
- [FAQ](https://github.com/philc/vimium/wiki/FAQ)
- [Command listing](https://vimium.github.io/commands/)
- [Vimium's GitHub wiki](https://github.com/philc/vimium/wiki): documentation for the more advanced
features.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
## Release Notes
See [CHANGELOG](CHANGELOG.md) for the major changes in each release.
## License
Copyright (c) Phil Crosby, Ilya Sukhar. See [MIT-LICENSE.txt](MIT-LICENSE.txt) for details.
================================================
FILE: background_scripts/all_commands.js
================================================
// This is the order they will be shown in the help dialog.
//
// Properties:
// - advanced: advanced commands are not shown in the help dialog by default.
// - background: whether this command has to be run by the background page.
// - desc: shown in the help dialog and command listing page.
// - details: extra help information that will only be shown on the command listing page.
// - group: commands are displayed in groups in the help dialog and command listing.
// - noRepeat: whether this command can be used with a count key prefix.
// - repeatLimit: the number of allowed repetitions of this command before the user is prompted for
// confirmation.
// - topFrame: whether this command must be run only in the top frame of a page.
//
const allCommands = [
//
// Navigation
//
{
name: "scrollDown",
desc: "Scroll down",
group: "navigation",
},
{
name: "scrollUp",
desc: "Scroll up",
group: "navigation",
},
{
name: "scrollToTop",
desc: "Scroll to the top of the page",
group: "navigation",
},
{
name: "scrollToBottom",
desc: "Scroll to the bottom of the page",
group: "navigation",
},
{
name: "scrollPageDown",
desc: "Scroll a half page down",
group: "navigation",
},
{
name: "scrollPageUp",
desc: "Scroll a half page up",
group: "navigation",
},
{
name: "scrollFullPageDown",
desc: "Scroll a full page down",
group: "navigation",
},
{
name: "scrollFullPageUp",
desc: "Scroll a full page up",
group: "navigation",
},
{
name: "scrollLeft",
desc: "Scroll left",
group: "navigation",
},
{
name: "scrollRight",
desc: "Scroll right",
group: "navigation",
advanced: true,
},
{
name: "scrollToLeft",
desc: "Scroll all the way to the left",
group: "navigation",
advanced: true,
},
{
name: "scrollToRight",
desc: "Scroll all the way to the right",
group: "navigation",
},
{
name: "reload",
desc: "Reload the page",
group: "navigation",
background: true,
options: {
hard: "Perform a hard reload, forcing the browser to bypass its cache.",
},
},
{
name: "copyCurrentUrl",
desc: "Copy the current URL to the clipboard",
group: "navigation",
noRepeat: true,
},
{
name: "openCopiedUrlInCurrentTab",
desc: "Open the clipboard's URL in the current tab",
group: "navigation",
noRepeat: true,
},
{
name: "openCopiedUrlInNewTab",
desc: "Open the clipboard's URL in a new tab",
group: "navigation",
noRepeat: true,
options: {
position: "Where to place the tab in the tab bar. " +
"One of `start`, `before`, `after`, `end`. `after` is the default.",
},
},
{
name: "goUp",
desc: "Go up the URL hierarchy",
group: "navigation",
advanced: true,
},
{
name: "goToRoot",
desc: "Go to the root of current URL hierarchy",
group: "navigation",
advanced: true,
},
{
name: "enterInsertMode",
desc: "Enter insert mode",
group: "navigation",
noRepeat: true,
},
{
name: "enterVisualMode",
desc: "Enter visual mode",
group: "navigation",
noRepeat: true,
},
{
name: "enterVisualLineMode",
desc: "Enter visual line mode",
group: "navigation",
advanced: true,
noRepeat: true,
},
{
name: "passNextKey",
desc: "Pass the next key to the page",
options: {
normal: "Optional. Enter Vimium's normal mode, and ignore any defined pass keys.",
},
group: "navigation",
advanced: true,
},
{
name: "focusInput",
desc: "Focus the first text input on the page",
group: "navigation",
},
{
name: "LinkHints.activateMode",
desc: "Open a link in the current tab",
options: {
action: "one of `hover`, `focus`, `copy-text`. When a link is selected, " +
"instead of clicking on the link, perform the specified action.",
},
group: "navigation",
advanced: true,
},
{
name: "LinkHints.activateModeToOpenInNewTab",
desc: "Open a link in a new tab",
group: "navigation",
},
{
name: "LinkHints.activateModeToOpenInNewForegroundTab",
desc: "Open a link in a new tab & switch to it",
group: "navigation",
},
{
name: "LinkHints.activateModeWithQueue",
desc: "Open multiple links in a new tab",
group: "navigation",
advanced: true,
noRepeat: true,
},
{
name: "LinkHints.activateModeToDownloadLink",
desc: "Download link url",
group: "navigation",
advanced: true,
},
{
name: "LinkHints.activateModeToOpenIncognito",
desc: "Open a link in incognito window",
group: "navigation",
advanced: true,
},
{
name: "LinkHints.activateModeToCopyLinkUrl",
desc: "Copy a link URL to the clipboard",
group: "navigation",
advanced: true,
},
{
name: "goPrevious",
desc: "Follow the link labeled previous or <",
group: "navigation",
advanced: true,
noRepeat: true,
},
{
name: "goNext",
desc: "Follow the link labeled next or >",
group: "navigation",
advanced: true,
noRepeat: true,
},
{
name: "nextFrame",
desc: "Select the next frame on the page",
group: "navigation",
background: true,
},
{
name: "mainFrame",
desc: "Select the page's main/top frame",
group: "navigation",
topFrame: true,
noRepeat: true,
},
{
name: "Marks.activateCreateMode",
desc: "Create a new mark",
details: "Do this by typing the key bound to this command, and then a letter. " +
"This will set a mark bound to that letter. Lowercase letters are local marks and uppercase " +
"letters are global marks.",
options: {
swap: "Swap global and local marks. This option exists because in a browser, global marks " +
"are generally more useful than local marks, and so it may be desirable to make lowercase " +
"letters represent global marks rather than local marks.",
},
group: "navigation",
advanced: true,
noRepeat: true,
},
{
name: "Marks.activateGotoMode",
desc: "Jump to a mark",
options: {
swap: "Swap global and local marks. This option exists because in a browser, global marks " +
"are generally more useful than local marks, and so it may be desirable to make lowercase " +
"letters represent global marks rather than local marks.",
},
group: "navigation",
advanced: true,
noRepeat: true,
},
//
// Vomnibar
//
{
name: "Vomnibar.activate",
desc: "Open URL, bookmark or history entry",
options: {
query: "The text to prefill the Vomnibar with.",
keyword: 'The keyword of a search engine defined in the "Custom search engines" ' +
"section of the Vimium Options page. The Vomnibar will be scoped to use that search engine.",
},
group: "vomnibar",
topFrame: true,
},
{
name: "Vomnibar.activateInNewTab",
desc: "Open URL, bookmark or history entry in a new tab",
group: "vomnibar",
options: {
query: "The text to prefill the Vomnibar with.",
keyword: 'The keyword of a search engine defined in the "Custom search engines" ' +
"section of the Vimium Options page. The Vomnibar will be scoped to use that search engine.",
},
topFrame: true,
},
{
name: "Vomnibar.activateBookmarks",
desc: "Open a bookmark",
group: "vomnibar",
options: {
query: "The text to prefill the Vomnibar with.",
},
topFrame: true,
},
{
name: "Vomnibar.activateBookmarksInNewTab",
desc: "Open a bookmark in a new tab",
group: "vomnibar",
options: {
query: "The text to prefill the Vomnibar with.",
},
topFrame: true,
},
{
name: "Vomnibar.activateTabSelection",
desc: "Search through your open tabs",
group: "vomnibar",
topFrame: true,
},
{
name: "Vomnibar.activateEditUrl",
desc: "Edit the current URL",
group: "vomnibar",
topFrame: true,
},
{
name: "Vomnibar.activateEditUrlInNewTab",
desc: "Edit the current URL and open in a new tab",
group: "vomnibar",
topFrame: true,
},
//
// Find
//
{
name: "enterFindMode",
desc: "Enter find mode.",
group: "find",
noRepeat: true,
},
{
name: "performFind",
desc: "Cycle forward to the next find match",
group: "find",
},
{
name: "performBackwardsFind",
desc: "Cycle backward to the previous find match",
group: "find",
},
{
name: "findSelected",
desc: "Find the selected text",
group: "find",
advanced: true,
},
{
name: "findSelectedBackwards",
desc: "Find the selected text, searching backwards",
group: "find",
advanced: true,
},
//
// History
//
{
name: "goBack",
desc: "Go back in history",
group: "history",
},
{
name: "goForward",
desc: "Go forward in history",
group: "history",
},
//
// Tabs
//
{
name: "createTab",
desc: "Create new tab",
options: {
"(any url)": "Open this URL, rather than the browser's new tab page. " +
"E.g.: `map X createTab https://example.com`",
window: "Create the tab in a new window",
incognito: "Create the tab in an incognito window",
position: "Where to place the tab in the tab bar. " +
"One of `start`, `before`, `after`, `end`. `after` is the default.",
},
group: "tabs",
background: true,
repeatLimit: 20,
},
{
name: "previousTab",
desc: "Go one tab left",
group: "tabs",
background: true,
},
{
name: "nextTab",
desc: "Go one tab right",
group: "tabs",
background: true,
},
{
name: "visitPreviousTab",
desc: "Go to previously-visited tab",
group: "tabs",
background: true,
},
{
name: "firstTab",
desc: "Go to the first tab",
group: "tabs",
background: true,
},
{
name: "lastTab",
desc: "Go to the last tab",
group: "tabs",
background: true,
},
{
name: "duplicateTab",
desc: "Duplicate current tab",
group: "tabs",
background: true,
repeatLimit: 20,
},
{
name: "togglePinTab",
desc: "Pin or unpin current tab",
group: "tabs",
background: true,
},
{
name: "toggleMuteTab",
desc: "Mute or unmute current tab",
options: {
all: "Mute all tabs.",
other: "Mute every tab except the current one.",
},
group: "tabs",
background: true,
noRepeat: true,
},
{
name: "removeTab",
desc: "Close current tab",
group: "tabs",
background: true,
// Don't close (in one command invocation) more than the number of tabs that can be re-opened by
// the browser.
repeatLimit: chrome.sessions?.MAX_SESSION_RESULTS || 25,
},
{
name: "restoreTab",
desc: "Restore closed tab",
group: "tabs",
background: true,
repeatLimit: 20,
},
{
name: "moveTabToNewWindow",
desc: "Move tab to new window",
group: "tabs",
advanced: true,
background: true,
},
{
name: "closeTabsOnLeft",
desc: "Close tabs on the left",
group: "tabs",
advanced: true,
background: true,
},
{
name: "closeTabsOnRight",
desc: "Close tabs on the right",
group: "tabs",
advanced: true,
background: true,
},
{
name: "closeOtherTabs",
desc: "Close all other tabs",
group: "tabs",
advanced: true,
background: true,
noRepeat: true,
},
{
name: "moveTabLeft",
desc: "Move tab to the left",
group: "tabs",
advanced: true,
background: true,
},
{
name: "moveTabRight",
desc: "Move tab to the right",
group: "tabs",
advanced: true,
background: true,
},
{
name: "setZoom",
desc: "Set zoom",
group: "tabs",
advanced: true,
background: true,
options: {
level: "The zoom level. This can be a range of [0.25, 5.0]. 1.0 is the default.",
},
},
{
name: "zoomIn",
desc: "Zoom in",
group: "tabs",
advanced: true,
background: true,
},
{
name: "zoomOut",
desc: "Zoom out",
group: "tabs",
advanced: true,
background: true,
},
{
name: "zoomReset",
desc: "Reset zoom",
group: "tabs",
advanced: true,
background: true,
},
//
// Misc
//
{
name: "toggleViewSource",
desc: "View page source",
group: "misc",
advanced: true,
noRepeat: true,
},
{
name: "showHelp",
desc: "Show help",
group: "misc",
noRepeat: true,
topFrame: true,
},
];
export { allCommands };
================================================
FILE: background_scripts/bg_utils.js
================================================
import { TabRecency } from "./tab_recency.js";
// We're using browser.runtime to determine the browser name and version for Firefox. That API is
// only available on the background page. We're not using window.navigator because it's unreliable.
// Sometimes browser vendors will provide fake values, like when `privacy.resistFingerprinting` is
// enabled on `about:config` of Firefox.
export function isFirefox() {
// We want this browser check to also cover Firefox variants, like LibreWolf. See #3773.
// We could also just check browserInfo.name against Firefox and Librewolf.
return globalThis.browser?.runtime.getURL("").startsWith("moz") ?? false;
}
export async function getFirefoxVersion() {
return isFirefox() ? (await browser.runtime.getBrowserInfo()).version : null;
}
// TODO(philc): tabRecency imports bg_utils. We should resovle the cycle for the sake of clarity.
export const tabRecency = new TabRecency();
tabRecency.init();
================================================
FILE: background_scripts/commands.js
================================================
import { allCommands } from "./all_commands.js";
// A specification for a command that's currently bound to a key sequence, as defined by the default
// key bindings, or as it appears in the user's keymapping settings.
export class RegistryEntry {
// Array of keys.
keySequence;
// Name of the command.
command;
// Whether this command can be used with a count key prefix.
noRepeat;
// The number of allowed repetitions of this command before the user is prompted for confirmation.
repeatLimit;
// Whether this command has to be run by the background page.
background;
// Whether this command must be run only in the top frame of a page.
topFrame;
// The map of options for this command. This is a parsed, sanitized version of the user's options
// for this command.
options;
constructor(o) {
Object.seal(this);
if (o) Object.assign(this, o);
}
}
// This is intentionally a superset of valid modifiers (a, c, m, s).
const modifier = "(?:[a-zA-Z]-)";
const namedKey = "(?:[a-z][a-z0-9]+)"; // E.g. "left" or "f12" (always two characters or more).
const modifiedKey = `(?:${modifier}+(?:.|${namedKey}))`; // E.g. "c-*" or "c-left".
const specialKeyRegexp = new RegExp(`^<(${namedKey}|${modifiedKey})>(.*)`, "i");
// Remove comments and leading/trailing whitespace from a list of lines, and merge lines where the
// last character on the preceding line is "\".
function parseLines(text) {
return text.replace(/\\\n/g, "")
.split("\n")
.map((line) => line.trim())
.filter((line) => (line.length > 0) && !(Array.from('#"').includes(line[0])));
}
// Returns the index of the nth occurrence of the regexp in the string. -1 if not found.
function nthRegexIndex(str, regex, n) {
if (!regex.global) {
regex = new RegExp(regex.source, regex.flags + "g");
}
let match;
let count = 0;
while ((match = regex.exec(str)) !== null) {
count++;
if (count === n) {
return match.index;
}
// Prevent infinite loop for zero-length matches.
if (match.index === regex.lastIndex) {
regex.lastIndex++;
}
}
return -1;
}
const KeyMappingsParser = {
// Parses the text supplied by the user in their "keyMappings" setting.
// - shouldLogWarnings: if true, logs to the console when part of the user's config is invalid.
// Returns { keyToRegistryEntry, keyToMappedKey, validationErrors }.
parse(configText, shouldLogWarnings) {
let keyToRegistryEntry = {};
let mapKeyRegistry = {};
let errors = [];
const configLines = parseLines(configText);
const commandsByName = Utils.keyBy(allCommands, "name");
const validModifiers = ["a", "c", "m", "s"];
const validateParsedKey = function (key) {
if (!key?.match(modifiedKey)) return;
// Check that the modifier is valid and not capitalized.
const mod = key.split("-")[0].slice(1);
if (!validModifiers.includes(mod)) {
return `${key} has an invalid modifier; valid modifiers are ${validModifiers}`;
}
};
const validateUrl = function (str) {
try {
new URL(str);
return true;
} catch {
return false;
}
};
for (const line of configLines) {
const tokens = line.split(/\s+/);
const action = tokens[0].toLowerCase();
switch (action) {
case "map": {
if (tokens.length < 3) {
errors.push(`"map requires at least 2 arguments on line ${line}`);
continue;
}
const [_, key, command] = tokens;
let optionString;
const optionsStart = nthRegexIndex(line, /\s+/, 3);
if (optionsStart == -1) {
optionString = "";
} else {
optionString = line.slice(optionsStart).trim();
}
const commandInfo = commandsByName[command];
if (!commandInfo) {
errors.push(`"${command}" is not a valid command in the line: ${line}`);
continue;
}
const keySequence = this.parseKeySequence(key);
const keyErrors = keySequence.map((k) => validateParsedKey(k)).filter((e) => e);
if (keyErrors.length > 0) {
errors = errors.concat(keyErrors);
continue;
}
const options = this.parseCommandOptions(optionString);
const allowedOptions = Object.keys(commandInfo.options || {});
if (!commandInfo.noRepeat) {
allowedOptions.push("count");
}
let hasUnknownOption = false;
for (const option of Object.keys(options)) {
if (allowedOptions.includes(option)) continue;
if (allowedOptions.includes("(any url)")) {
// Since this command allows for any URL as an argument, we perform some basic
// validation to ensure the provided option string is indeed a URL.
if (validateUrl(option)) continue;
hasUnknownOption = true;
errors.push(
`Command ${command} does not support option ${option}. ` +
`Is this meant to be a valid URL?`,
);
break;
} else {
hasUnknownOption = true;
errors.push(`Command ${command} does not support option ${option}`);
break;
}
}
if (hasUnknownOption) break;
keyToRegistryEntry[key] = new RegistryEntry({
keySequence,
command,
noRepeat: commandInfo.noRepeat,
repeatLimit: commandInfo.repeatLimit,
background: commandInfo.background,
topFrame: commandInfo.topFrame,
options,
});
break;
}
case "unmap": {
if (tokens.length != 2) {
errors.push(`Incorrect usage for unmap in the line: ${line}`);
continue;
}
const key = tokens[1];
delete keyToRegistryEntry[key];
delete mapKeyRegistry[key];
break;
}
case "unmapall": {
keyToRegistryEntry = {};
mapKeyRegistry = {};
break;
}
case "mapkey": {
if (tokens.length != 3) {
errors.push(`Incorrect usage for mapKey in the line: ${line}`);
continue;
}
const fromChar = this.parseKeySequence(tokens[1]);
const toChar = this.parseKeySequence(tokens[2]);
// NOTE(philc): I'm not sure why we enforce that the fromChar and toChar have to be
// length one. It's been that way since this feature was introduced in 6596e30.
const isValid = fromChar.length == toChar.length && toChar.length === 1;
if (isValid) {
mapKeyRegistry[fromChar[0]] = toChar[0];
} else {
errors.push(
`mapkey only supports mapping keys which are single characters. Line: ${line}`,
);
}
break;
}
default:
errors.push(`"${action}" is not a valid config command in line: ${line}`);
}
}
return {
keyToRegistryEntry,
keyToMappedKey: mapKeyRegistry,
validationErrors: errors,
};
},
// Lower-case the appropriate portions of named keys.
//
// A key name is one of three forms exemplified by or (prefixed normal key,
// named key, or prefixed named key). Internally, for simplicity, we would like prefixes and key
// names to be lowercase, though humans may prefer other forms or .
// On the other hand, and are different named keys - for one of them you have to press
// "shift" as well.
// We sort modifiers here to match the order used in keyboard_utils.js.
// The return value is a sequence of keys: e.g. "b" -> ["", "", "b"].
parseKeySequence(key) {
if (key.length === 0) {
return [];
// Parse "bcd" as "" and "bcd".
} else if (0 === key.search(specialKeyRegexp)) {
const array = RegExp.$1.split("-");
const adjustedLength = Math.max(array.length, 1);
let modifiers = array.slice(0, adjustedLength - 1);
let keyChar = array[adjustedLength - 1];
if (keyChar.length !== 1) {
keyChar = keyChar.toLowerCase();
}
modifiers = modifiers.map((m) => m.toLowerCase());
modifiers.sort();
return [
"<" + modifiers.concat([keyChar]).join("-") + ">",
...this.parseKeySequence(RegExp.$2),
];
} else {
return [key[0], ...this.parseKeySequence(key.slice(1))];
}
},
// Command options follow command mappings, and are of one of these forms:
// key=value - a value
// key="value" - a value surrounded by quotes
// key - a flag
parseCommandOptions(optionString) {
const options = {};
while (optionString != "") {
// Note that option names are allowed to be letters only; no numbers.
let match, matchedString, key, value;
// Case: option value surrounded by quotes (key= "a b"). Spaces are allowed in the value.
if (match = optionString.match(/^([a-zA-Z]+)="([^"]+)"(\s+|$)/)) {
matchedString = match[0];
key = match[1];
value = match[2];
} // Case: option value not surrounded by quotes (key=value). Spaces aren't allowed.
else if (match = optionString.match(/^([a-zA-Z]+)=(\S+)(\s+|$)/)) {
matchedString = match[0];
key = match[1];
value = match[2];
} // Case: single option (flag), or "any URL". This correctly parses URLs because URLs cannot
// contain unescaped equals or space characters. The key will be the option's name (or the
// URL), and the value will be true.
else if (match = optionString.match(/^([^\s"]+)(\s+|$)/)) {
matchedString = match[0];
key = match[1];
value = true;
}
// NOTE(philc): If this string doesn't match any of our option regexps, we could throw an
// error here or use an assert. I think this might only happen in the case where there's a
// single equals sign. For now, just add the whole string as a flag option. If the command in
// question doesn't accept this option, then an error will get surfaced to the user.
if (match == null) {
console.log(`Warning: '${optionString}' isn't a valid option string.`);
options[optionString] = true;
break;
}
options[key] = value;
optionString = optionString.slice(matchedString.length);
}
// We parse any `count` option immediately (to avoid having to parse it repeatedly later).
if ("count" in options) {
options.count = parseInt(options.count);
if (isNaN(options.count)) {
delete options.count;
}
}
return options;
},
};
const Commands = {
// A map of keyString => RegistryEntry
keyToRegistryEntry: null,
// A map of typed key => key it's mapped to (via the `mapkey` config statement).
mapKeyRegistry: null,
async init() {
await Settings.onLoaded();
Settings.addEventListener("change", async () => {
await this.loadKeyMappings(Settings.get("keyMappings"));
});
await this.loadKeyMappings(Settings.get("keyMappings"));
},
// Parses the user's keyMapping config text and persists the parsed key mappings into the
// extension's storage, for use by the other parts of this extension.
async loadKeyMappings(userKeyMappingsConfigText) {
let key, command;
this.keyToRegistryEntry = {};
this.mapKeyRegistry = {};
const defaultKeyConfig = Object.keys(defaultKeyMappings).map((key) =>
`map ${key} ${defaultKeyMappings[key]}`
).join("\n");
const parsed = KeyMappingsParser.parse(
defaultKeyConfig + "\n" + userKeyMappingsConfigText,
true,
);
this.mapKeyRegistry = parsed.keyToMappedKey;
this.keyToRegistryEntry = parsed.keyToRegistryEntry;
await chrome.storage.session.set({ mapKeyRegistry: this.mapKeyRegistry });
await this.installKeyStateMapping();
this.prepareHelpPageData();
// Push the key mappings from any passNextKey commands into storage so that they're's available
// to the front end so they can be detected during insert mode. We exclude single-key mappings
// for this command (i.e. printable keys) because we're considering that a configuration error:
// when users press printable keys in insert mode, they expect that character to be input, not
// to be droppped into a special Vimium mode.
const passNextKeys = Object.entries(this.keyToRegistryEntry)
.filter(([key, v]) => v.command == "passNextKey" && key.length > 1)
.map(([key, v]) => key);
await chrome.storage.session.set({ passNextKeyKeys: passNextKeys });
},
// This generates and installs a nested key-to-command mapping structure. There is an example in
// mode_key_handler.js.
async installKeyStateMapping() {
const keyStateMapping = {};
for (const keys of Object.keys(this.keyToRegistryEntry || {})) {
const registryEntry = this.keyToRegistryEntry[keys];
let currentMapping = keyStateMapping;
for (let index = 0; index < registryEntry.keySequence.length; index++) {
const key = registryEntry.keySequence[index];
if (currentMapping[key] != null ? currentMapping[key].command : undefined) {
// Do not overwrite existing command bindings, they take priority. NOTE(smblott) This is
// the legacy behaviour.
break;
} else if (index < (registryEntry.keySequence.length - 1)) {
currentMapping = currentMapping[key] != null
? currentMapping[key]
: (currentMapping[key] = {});
} else {
currentMapping[key] = Object.assign({}, registryEntry);
// We don't need these properties in the content scripts.
for (const prop of ["keySequence"]) {
delete currentMapping[key][prop];
}
}
}
}
await chrome.storage.session.set({
normalModeKeyStateMapping: keyStateMapping,
// Inform `KeyboardUtils.isEscape()` whether `` should be interpreted as `Escape` (which it
// is by default).
useVimLikeEscape: !("" in keyStateMapping),
});
},
// Build the "commandToOptionsToKeys" data structure and place it in chrome's session storage.
// This is used by the help page and commands listing.
prepareHelpPageData() {
/*
Map of commands to option sets to keys to trigger that command option set.
Commands with no options will have the empty string options set.
Example:
{
"zoomReset": {
"": ["z0", "zz"] // No options, with two key maps, ie: `map zz zoomReset`
},
"setZoom": {
"1.1": ["z1"], // `map z1 setZoom 1.1`
"1.2": ["z2"], // `map z2 setZoom 1.2`
}
}
*/
const commandToOptionsToKeys = {};
const formatOptionString = (options) => {
return Object.entries(options)
.map(([k, v]) => {
// When the value of an option is true, then it was parsed as a flag.
if (v === true) {
return k;
} else {
return `${k}=${v}`;
}
})
.join(" ");
};
for (const key of Object.keys(this.keyToRegistryEntry || {})) {
const registryEntry = this.keyToRegistryEntry[key];
const optionString = formatOptionString(registryEntry.options || {});
commandToOptionsToKeys[registryEntry.command] ||= {};
commandToOptionsToKeys[registryEntry.command][optionString] ||= [];
commandToOptionsToKeys[registryEntry.command][optionString].push(key);
}
chrome.storage.session.set({ commandToOptionsToKeys });
},
};
const defaultKeyMappings = {
// Navigating the current page
"j": "scrollDown",
"k": "scrollUp",
"h": "scrollLeft",
"l": "scrollRight",
"gg": "scrollToTop",
"G": "scrollToBottom",
"zH": "scrollToLeft",
"zL": "scrollToRight",
"": "scrollDown",
"": "scrollUp",
"d": "scrollPageDown",
"u": "scrollPageUp",
"r": "reload",
"R": "reload hard",
"yy": "copyCurrentUrl",
"p": "openCopiedUrlInCurrentTab",
"P": "openCopiedUrlInNewTab",
"gi": "focusInput",
"[[": "goPrevious",
"]]": "goNext",
"gf": "nextFrame",
"gF": "mainFrame",
"gu": "goUp",
"gU": "goToRoot",
"i": "enterInsertMode",
"v": "enterVisualMode",
"V": "enterVisualLineMode",
// Link hints
"f": "LinkHints.activateMode",
"F": "LinkHints.activateModeToOpenInNewTab",
"": "LinkHints.activateModeWithQueue",
"yf": "LinkHints.activateModeToCopyLinkUrl",
// Using find
"/": "enterFindMode",
"n": "performFind",
"N": "performBackwardsFind",
"*": "findSelected",
"#": "findSelectedBackwards",
// Vomnibar
"o": "Vomnibar.activate",
"O": "Vomnibar.activateInNewTab",
"T": "Vomnibar.activateTabSelection",
"b": "Vomnibar.activateBookmarks",
"B": "Vomnibar.activateBookmarksInNewTab",
"ge": "Vomnibar.activateEditUrl",
"gE": "Vomnibar.activateEditUrlInNewTab",
// Navigating history
"H": "goBack",
"L": "goForward",
// Manipulating tabs
"K": "nextTab",
"J": "previousTab",
"gt": "nextTab",
"gT": "previousTab",
"^": "visitPreviousTab",
"<<": "moveTabLeft",
">>": "moveTabRight",
"g0": "firstTab",
"g$": "lastTab",
"W": "moveTabToNewWindow",
"t": "createTab",
"yt": "duplicateTab",
"x": "removeTab",
"X": "restoreTab",
"": "togglePinTab",
"": "toggleMuteTab",
"zi": "zoomIn",
"zo": "zoomOut",
"z0": "zoomReset",
// Marks
"m": "Marks.activateCreateMode",
"`": "Marks.activateGotoMode",
// Misc
"?": "showHelp",
"gs": "toggleViewSource",
};
export {
Commands,
// Exported for unit tests.
defaultKeyMappings,
KeyMappingsParser,
parseLines,
};
================================================
FILE: background_scripts/completion/completers.js
================================================
// This file contains the definition of the completers used for the Vomnibar's suggestion UI. A
// completer will take a query (whatever the user typed into the Vomnibar) and return a list of
// Suggestions, e.g. bookmarks, domains, URLs from history.
//
// The Vomnibar frontend script makes a "filterCompleter" request to the background page, which in
// turn calls filter() on each these completers.
//
// A completer is a class which has three functions:
// - filter(query): "query" will be whatever the user typed into the Vomnibar.
// - refresh(): (optional) refreshes the completer's data source (e.g. refetches the list of
// bookmarks).
// - cancel(): (optional) cancels any pending, cancelable action.
import * as bgUtils from "./../bg_utils.js";
import * as completionSearch from "./search_wrapper.js";
import * as userSearchEngines from "../user_search_engines.js";
import * as ranking from "./ranking.js";
import { RegexpCache } from "./ranking.js";
// Set this to true to render relevancy when debugging the ranking scores.
const showRelevancy = false;
// TODO(philc): Consider moving out the "computeRelevancy" function.
export class Suggestion {
queryTerms;
description;
url;
// A shortened URL (URI-decoded, protocol removed) suitable for dispaly purposes.
shortUrl;
title = "";
// A computed relevancy value.
relevancy;
relevancyFunction;
relevancyData;
// When true, then this suggestion is automatically pre-selected in the vomnibar. This only affects
// the suggestion in slot 0 in the vomnibar.
autoSelect = false;
// When true, we highlight matched terms in the title and URL. Otherwise we don't.
highlightTerms = true;
// The text to insert into the vomnibar input when this suggestion is selected.
insertText;
// This controls whether this suggestion is a candidate for deduplication after simplifying
// its URL.
deDuplicate = true;
// The tab represented by this suggestion. Populated by TabCompleter.
tabId;
// Whether this is a suggestion provided by a user's custom search engine.
isCustomSearch;
// Whether this is meant to be the first suggestion from the user's custom search engine which
// represents their query as typed, verbatim.
isPrimarySuggestion = false;
// The generated HTML string for showing this suggestion in the Vomnibar.
html;
searchUrl;
constructor(options) {
Object.seal(this);
Object.assign(this, options);
}
// Returns the relevancy score.
computeRelevancy() {
// We assume that, once the relevancy has been set, it won't change. Completers must set
// either @relevancy or @relevancyFunction.
if (this.relevancy == null) {
this.relevancy = this.relevancyFunction(this);
}
return this.relevancy;
}
generateHtml() {
if (this.html) return this.html;
const relevancyHtml = showRelevancy
? `${this.computeRelevancy()}`
: "";
const insertTextClass = this.insertText ? "" : "no-insert-text";
const insertTextIndicator = "↪"; // A right hooked arrow.
if (this.insertText && this.isCustomSearch) {
this.title = this.insertText;
}
let faviconHtml = "";
if (this.description === "tab" && !bgUtils.isFirefox()) {
const faviconUrl = new URL(chrome.runtime.getURL("/_favicon/"));
faviconUrl.searchParams.set("pageUrl", this.url);
faviconUrl.searchParams.set("size", "16");
faviconHtml = ``;
}
if (this.isCustomSearch) {
this.html = `\
\
`;
}
return this.html;
}
// Use neat trick to snatch a domain (http://stackoverflow.com/a/8498668).
getUrlRoot(url) {
const a = document.createElement("a");
a.href = url;
return a.protocol + "//" + a.hostname;
}
getHostname(url) {
const a = document.createElement("a");
a.href = url;
return a.hostname;
}
stripTrailingSlash(url) {
if (url[url.length - 1] === "/") {
url = url.substring(url, url.length - 1);
}
return url;
}
// Push the ranges within `string` which match `term` onto `ranges`.
pushMatchingRanges(string, term, ranges) {
let textPosition = 0;
// Split `string` into a (flat) list of pairs:
// - for i=0,2,4,6,...
// - splits[i] is unmatched text
// - splits[i+1] is the following matched text (matching `term`)
// (except for the final element, for which there is no following matched text).
// Example:
// - string = "Abacab"
// - term = "a"
// - splits = [ "", "A", "b", "a", "c", "a", b" ]
// UM M UM M UM M UM (M=Matched, UM=Unmatched)
const splits = string.split(RegexpCache.get(term, "(", ")"));
for (let index = 0, end = splits.length - 2; index <= end; index += 2) {
const unmatchedText = splits[index];
const matchedText = splits[index + 1];
// Add the indices spanning `matchedText` to `ranges`.
textPosition += unmatchedText.length;
ranges.push([textPosition, textPosition + matchedText.length]);
textPosition += matchedText.length;
}
}
// Wraps each occurence of the query terms in the given string in a .
highlightQueryTerms(string) {
if (!this.highlightTerms) return string;
let ranges = [];
const escapedTerms = this.queryTerms.map((term) => Utils.escapeHtml(term));
for (const term of escapedTerms) {
this.pushMatchingRanges(string, term, ranges);
}
if (ranges.length === 0) {
return string;
}
ranges = this.mergeRanges(ranges.sort((a, b) => a[0] - b[0]));
// Replace portions of the string from right to left.
ranges = ranges.sort((a, b) => b[0] - a[0]);
for (const [start, end] of ranges) {
string = string.substring(0, start) +
`${string.substring(start, end)}` +
string.substring(end);
}
return string;
}
// Merges the given list of ranges such that any overlapping regions are combined. E.g.
// mergeRanges([0, 4], [3, 6]) => [0, 6]. A range is [startIndex, endIndex].
mergeRanges(ranges) {
let previous = ranges.shift();
const mergedRanges = [previous];
for (const range of ranges) {
if (previous[1] >= range[0]) {
previous[1] = Math.max(range[1], previous[1]);
} else {
mergedRanges.push(range);
previous = range;
}
}
return mergedRanges;
}
// Simplify a suggestion's URL (by removing those parts which aren't useful for display or
// comparison).
shortenUrl() {
if (this.shortUrl != null) {
return this.shortUrl;
}
// We get easier-to-read shortened URLs if we URI-decode them.
let url = (Utils.decodeURIByParts(this.url) || this.url).toLowerCase();
for (const [filter, replacements] of Suggestion.stripPatterns) {
if (new RegExp(filter).test(url)) {
for (const replace of replacements) {
url = url.replace(replace, "");
}
}
}
this.shortUrl = url;
return this.shortUrl;
}
// Boost a relevancy score by a factor (in the range (0,1.0)), while keeping the score in the
// range [0,1]. This makes greater adjustments to scores near the middle of the range (so, very
// poor relevancy scores remain very poor).
static boostRelevancyScore(factor, score) {
return score + (score < 0.5 ? score * factor : (1.0 - score) * factor);
}
}
// Patterns to strip from URLs; of the form [ [ filter, replacements ], [ filter, replacements ], ... ]
// - filter is a regexp string; a URL must match this regexp first.
// - replacements (itself a list) is a list of regexp objects, each of which is removed from URLs
// matching the filter.
//
// Note. This includes site-specific patterns for very-popular sites with URLs which don't work well
// in the vomnibar.
//
Suggestion.stripPatterns = [
// Google search specific replacements; this replaces query parameters which are known to not be
// helpful. There's some additional information here:
// http://www.teknoids.net/content/google-search-parameters-2012
[
"^https?://www\\.google\\.(com|ca|com\\.au|co\\.uk|ie)/.*[&?]q=",
"ei gws_rd url ved usg sa usg sig2 bih biw cd aqs ie sourceid es_sm"
.split(/\s+/).map((param) => new RegExp(`\&${param}=[^&]+`)),
],
// On Google maps, we get a new history entry for every pan and zoom event.
["^https?://www\\.google\\.(com|ca|com\\.au|co\\.uk|ie)/maps/place/.*/@", [new RegExp("/@.*")]],
// General replacements; replaces leading and trailing fluff.
[".", ["^https?://", "\\W+$"].map((re) => new RegExp(re))],
];
const folderSeparator = "/";
// If these names occur as top-level bookmark names, then they are not included in the names of
// bookmark folders.
const ignoredTopLevelBookmarks = {
"Other Bookmarks": true,
"Mobile Bookmarks": true,
"Bookmarks Bar": true,
};
// this.bookmarks are loaded asynchronously when refresh() is called.
export class BookmarkCompleter {
async filter({ queryTerms }) {
if (!this.bookmarks) await this.refresh();
// If the folder separator character is the first character in any query term, then use the
// bookmark's full path as its title. Otherwise, just use the its regular title.
let results;
const usePathAndTitle = queryTerms.reduce(
(prev, term) => prev || term.startsWith(folderSeparator),
false,
);
if (queryTerms.length > 0) {
results = this.bookmarks.filter((bookmark) => {
const suggestionTitle = usePathAndTitle ? bookmark.pathAndTitle : bookmark.title;
if (bookmark.hasJavascriptProtocol == null) {
bookmark.hasJavascriptProtocol = UrlUtils.hasJavascriptProtocol(bookmark.url);
}
if (bookmark.hasJavascriptProtocol && bookmark.shortUrl == null) {
bookmark.shortUrl = "javascript:...";
}
const suggestionUrl = bookmark.shortUrl != null ? bookmark.shortUrl : bookmark.url;
return ranking.matches(queryTerms, suggestionUrl, suggestionTitle);
});
} else {
results = [];
}
const suggestions = results.map((bookmark) => {
return new Suggestion({
queryTerms,
description: "bookmark",
url: bookmark.url,
title: usePathAndTitle ? bookmark.pathAndTitle : bookmark.title,
relevancyFunction: this.computeRelevancy,
shortUrl: bookmark.shortUrl,
deDuplicate: bookmark.shortUrl == null,
});
});
return suggestions;
}
async refresh() {
// In case refresh() is called multiple times before chrome.bookmarks.getTree() completes, only
// call chrome.bookmarks.getTree() once.
if (this.bookmarksTreePromise) {
await this.bookmarksTreePromise;
return;
}
this.bookmarksTreePromise = chrome.bookmarks.getTree();
const bookmarksTree = await this.bookmarksTreePromise;
this.bookmarks = this.traverseBookmarks(bookmarksTree)
.filter((b) => b.url != null);
this.bookmarksTreePromise = null;
}
// Traverses the bookmark hierarchy, and returns a flattened list of all bookmarks.
traverseBookmarks(bookmarks) {
const results = [];
for (const folder of bookmarks) {
this.traverseBookmarksRecursive(folder, results);
}
return results;
}
// Recursive helper for `traverseBookmarks`.
traverseBookmarksRecursive(bookmark, results, parent) {
if (parent == null) {
parent = { pathAndTitle: "" };
}
if (
bookmark.title &&
!((parent.pathAndTitle === "") && ignoredTopLevelBookmarks[bookmark.title])
) {
bookmark.pathAndTitle = parent.pathAndTitle + folderSeparator + bookmark.title;
} else {
bookmark.pathAndTitle = parent.pathAndTitle;
}
results.push(bookmark);
if (bookmark.children) {
for (const child of bookmark.children) {
this.traverseBookmarksRecursive(child, results, bookmark);
}
}
}
computeRelevancy(suggestion) {
return ranking.wordRelevancy(
suggestion.queryTerms,
suggestion.shortUrl || suggestion.url,
suggestion.title,
);
}
}
export class HistoryCompleter {
// - seenTabToOpenCompletionList: true if the user has typed only , and nothing else.
// We interpret this to mean that they want to see all of their history in the Vomnibar, sorted
// by recency.
async filter({ queryTerms, seenTabToOpenCompletionList }) {
await HistoryCache.onLoaded();
let results;
if (queryTerms.length > 0) {
results = HistoryCache.history
.filter((entry) => ranking.matches(queryTerms, entry.url, entry.title));
} else if (seenTabToOpenCompletionList) {
// The user has typed to open the entire history (sorted by recency).
results = HistoryCache.history;
} else {
results = [];
}
const suggestions = results.map((entry) => {
return new Suggestion({
queryTerms,
description: "history",
url: entry.url,
title: entry.title,
relevancyFunction: this.computeRelevancy,
relevancyData: entry,
});
});
return suggestions;
}
computeRelevancy(suggestion) {
const historyEntry = suggestion.relevancyData;
const recencyScore = ranking.recencyScore(historyEntry.lastVisitTime);
// If there are no query terms, then relevancy is based on recency alone.
if (suggestion.queryTerms.length === 0) return recencyScore;
const wordRelevancy = ranking.wordRelevancy(
suggestion.queryTerms,
suggestion.url,
suggestion.title,
);
// Average out the word score and the recency. Recency has the ability to pull the score up, but
// not down.
return (wordRelevancy + Math.max(recencyScore, wordRelevancy)) / 2;
}
}
// The domain completer is designed to match a single-word query which looks like it is a domain.
// This supports the user experience where they quickly type a partial domain, hit tab -> enter, and
// expect to arrive there.
export class DomainCompleter {
// A map of domain -> { entry: , referenceCount: }
// - `entry` is the most recently accessed page in the History within this domain.
// - `referenceCount` is a count of the number of History entries within this domain.
// If `referenceCount` goes to zero, the domain entry can and should be deleted.
domains;
async filter({ queryTerms, query }) {
const isMultiWordQuery = /\S\s/.test(query);
if ((queryTerms.length === 0) || isMultiWordQuery) return [];
if (!this.domains) await this.populateDomains();
const firstTerm = queryTerms[0];
const domains = Object.keys(this.domains || []).filter((d) => d.includes(firstTerm));
const domainsAndScores = this.sortDomainsByRelevancy(queryTerms, domains);
const result = new Suggestion({
queryTerms,
description: "domain",
// This should be the URL or the domain, or an empty string, but not null.
url: domainsAndScores[0]?.[0] || "",
relevancy: 2.0,
});
return result.url.length > 0 ? [result] : [];
}
// Returns a list of domains of the form: [ [domain, relevancy], ... ]
sortDomainsByRelevancy(queryTerms, domainCandidates) {
const results = [];
for (const domain of domainCandidates) {
const recencyScore = ranking.recencyScore(this.domains[domain].entry.lastVisitTime || 0);
const wordRelevancy = ranking.wordRelevancy(queryTerms, domain, null);
const score = (wordRelevancy + Math.max(recencyScore, wordRelevancy)) / 2;
results.push([domain, score]);
}
results.sort((a, b) => b[1] - a[1]);
return results;
}
async populateDomains() {
await HistoryCache.onLoaded();
this.domains = {};
for (const entry of HistoryCache.history) {
this.onVisited(entry);
}
chrome.history.onVisited.addListener(this.onVisited.bind(this));
chrome.history.onVisitRemoved.addListener(this.onVisitRemoved.bind(this));
}
onVisited(newPage) {
const domain = this.parseDomainAndScheme(newPage.url);
if (domain) {
const slot = this.domains[domain] ||
(this.domains[domain] = { entry: newPage, referenceCount: 0 });
// We want each entry in our domains map to point to the most recent History entry for that
// domain.
if (slot.entry.lastVisitTime < newPage.lastVisitTime) {
slot.entry = newPage;
}
slot.referenceCount += 1;
}
}
onVisitRemoved(toRemove) {
if (toRemove.allHistory) {
this.domains = {};
} else {
for (const url of toRemove.urls) {
const domain = this.parseDomainAndScheme(url);
const entry = this.domains[domain];
if (entry == null) continue;
entry.referenceCount--;
if (entry.referenceCount <= 0) {
delete this.domains[domain];
}
}
}
}
// Return something like "http://www.example.com" or false.
parseDomainAndScheme(url) {
if (UrlUtils.urlHasProtocol(url) && !UrlUtils.hasChromeProtocol(url)) {
return url.split("/", 3).join("/");
}
}
}
// Searches through all open tabs, matching on title and URL.
// If the query is empty, then return a list of open tabs, sorted by recency.
export class TabCompleter {
async filter({ queryTerms }) {
await bgUtils.tabRecency.init();
// We search all tabs, not just those in the current window.
const tabs = await chrome.tabs.query({});
const results = tabs.filter((tab) => ranking.matches(queryTerms, tab.url, tab.title));
const suggestions = results
.map((tab) => {
const suggestion = new Suggestion({
queryTerms,
description: "tab",
url: tab.url,
title: tab.title,
tabId: tab.id,
deDuplicate: false,
});
suggestion.relevancy = this.computeRelevancy(suggestion);
return suggestion;
})
.sort((a, b) => b.relevancy - a.relevancy);
// Boost relevancy with a multiplier so a relevant tab doesn't get crowded out by results from
// competing completers. To prevent tabs from crowding out everything else in turn, penalize
// them for being further down the results list by scaling on a hyperbola starting at 1 and
// approaching 0 asymptotically for higher indexes. The multiplier and the curve fall-off were
// subjectively chosen on the grounds that they seem to work pretty well.
suggestions.forEach(function (suggestion, i) {
suggestion.relevancy *= 8;
suggestion.relevancy /= (i / 4) + 1;
});
return suggestions;
}
computeRelevancy(suggestion) {
if (suggestion.queryTerms.length > 0) {
return ranking.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title);
} else {
return bgUtils.tabRecency.recencyScore(suggestion.tabId);
}
}
}
export class SearchEngineCompleter {
cancel() {
completionSearch.cancel();
}
// Returns the UserSearchEngine for the given query. Returns null if the query does not begin with
// a keyword from one of the user's search engines.
getUserSearchEngineForQuery(query) {
const parts = query.trimStart().split(/\s+/);
// For a keyword "w", we match "w search terms" and "w ", but not "w" on its own.
const keyword = parts[0];
if (parts.length <= 1) return null;
// Don't match queries for built-in properties like "constructor". See #4396.
if (Object.hasOwn(userSearchEngines.keywordToEngine, keyword)) {
return userSearchEngines.keywordToEngine[keyword];
}
return null;
}
refresh() {
userSearchEngines.set(Settings.get("searchEngines"));
}
async filter(request) {
const { queryTerms } = request;
const keyword = queryTerms[0];
const queryTermsWithoutKeyword = queryTerms.slice(1);
const userSearchEngine = userSearchEngines.keywordToEngine[keyword];
if (!userSearchEngine) return [];
const searchUrl = userSearchEngine.url;
const completions = await completionSearch.complete(searchUrl, queryTermsWithoutKeyword);
const makeSuggestion = (query) => {
const url = UrlUtils.createSearchUrl(query, searchUrl);
return new Suggestion({
queryTerms,
description: userSearchEngine.description,
url,
title: query,
searchUrl,
highlightTerms: false,
isCustomSearch: true,
relevancy: null,
relevancyFunction: this.computeRelevancy,
});
};
const suggestions = completions.map((completion) => {
const s = makeSuggestion(completion);
s.insertText = completion;
return s;
});
if (suggestions[0]) suggestions[0].relevancy = 1.0;
// This is a suggestion which contains the user's query. It's the "search for exactly what I
// just typed" option. It should always appear first in the list.
const primarySuggestion = makeSuggestion(queryTermsWithoutKeyword.join(" "));
primarySuggestion.relevancy = 2;
primarySuggestion.isPrimarySuggestion = true;
primarySuggestion.autoSelect = true;
suggestions.unshift(primarySuggestion);
return suggestions;
}
computeRelevancy({ queryTerms, title }) {
// Tweaks:
// - Calibration: we boost relevancy scores to try to achieve an appropriate balance between
// relevancy scores here, and those provided by other completers.
// - Relevancy depends only on the title (which is the search terms), and not on the URL.
return Suggestion.boostRelevancyScore(
0.5,
0.7 * ranking.wordRelevancy(queryTerms, title, title),
);
}
}
SearchEngineCompleter.debug = false;
// A completer which calls filter() on many completers, aggregates the results, ranks them, and
// returns the top 10. All queries from the vomnibar come through a multi completer.
const maxResults = 10;
export class MultiCompleter {
constructor(completers) {
this.completers = completers;
}
refresh() {
for (const c of this.completers) {
if (c.refresh) c.refresh();
}
}
cancel() {
for (const c of this.completers) {
c.cancel?.();
}
}
async filter(request) {
const searchEngineCompleter = this.completers.find((c) => c instanceof SearchEngineCompleter);
const query = request.query;
const queryTerms = request.queryTerms;
// The only UX where we support showing results when there are no query terms is via
// Vomnibar.activateTabSelection, where we show the list of open tabs by recency.
const isTabCompleter = this.completers.length == 1 &&
this.completers[0] instanceof TabCompleter;
if (queryTerms.length == 0 && !isTabCompleter) {
return [];
}
const queryMatchesUserSearchEngine = searchEngineCompleter?.getUserSearchEngineForQuery(query);
// If the user's query matches one of their custom search engines, then use only that engine to
// provide completions for their query.
const completers = queryMatchesUserSearchEngine
? [searchEngineCompleter]
: this.completers.filter((c) => c != searchEngineCompleter);
RegexpCache.clear();
const promises = completers.map((c) => c.filter(request));
let results = (await Promise.all(promises)).flat(1);
results = this.postProcessSuggestions(request, queryTerms, results);
return results;
}
// Rank them, simplify the URLs, and de-duplicate suggestions with the same simplified URL.
postProcessSuggestions(request, queryTerms, suggestions) {
for (const s of suggestions) {
s.computeRelevancy(queryTerms);
}
suggestions.sort((a, b) => b.relevancy - a.relevancy);
// Simplify URLs and remove duplicates (duplicate simplified URLs, that is).
let count = 0;
const seenUrls = {};
const dedupedSuggestions = [];
for (const s of suggestions) {
const url = s.shortenUrl();
if (s.deDuplicate && seenUrls[url]) continue;
if (count++ === maxResults) break;
seenUrls[url] = s;
dedupedSuggestions.push(s);
}
// Give each completer the opportunity to tweak the suggestions.
for (const completer of this.completers) {
if (completer.postProcessSuggestions) {
completer.postProcessSuggestions(request, dedupedSuggestions);
}
}
// Generate HTML for the remaining suggestions and return them.
for (const s of dedupedSuggestions) {
s.generateHtml(request);
}
return dedupedSuggestions;
}
}
// Provides cached access to Chrome's history. As the user browses to new pages, we add those pages
// to this history cache.
export const HistoryCache = {
size: 20000,
// An array of History items returned from Chrome.
history: null,
reset() {
this.history = null;
chrome.history.onVisited.removeListener(this._onVisitedListener);
chrome.history.onVisitRemoved.removeListener(this._onVisitRemovedListener);
},
async onLoaded() {
if (this.history) return;
await this.fetchHistory();
},
async fetchHistory() {
if (this.chromeHistoryPromise) {
await this.chromeHistoryPromise;
return;
}
this.chromeHistoryPromise = chrome.history.search({
text: "",
maxResults: this.size,
startTime: 0,
});
const history = await this.chromeHistoryPromise;
// On Firefox, some history entries do not have titles.
for (const entry of history) {
if (entry.title == null) entry.title = "";
}
history.sort(this.compareHistoryByUrl);
this.history = history;
chrome.history.onVisited.addListener(this._onVisitedListener);
chrome.history.onVisitRemoved.addListener(this._onVisitRemovedListener);
this.chromeHistoryPromise = null;
},
compareHistoryByUrl(a, b) {
if (a.url === b.url) return 0;
if (a.url > b.url) return 1;
return -1;
},
// When a page we've seen before has been visited again, be sure to replace our History item so it
// has the correct "lastVisitTime". That's crucial for ranking Vomnibar suggestions.
onVisited(newPage) {
// On Firefox, some history entries do not have titles.
if (newPage.title == null) newPage.title = "";
const i = HistoryCache.binarySearch(newPage, this.history, this.compareHistoryByUrl);
const pageWasFound = this.history[i]?.url == newPage.url;
if (pageWasFound) {
this.history[i] = newPage;
} else {
this.history.splice(i, 0, newPage);
}
},
// When a page is removed from the chrome history, remove it from the vimium history too.
onVisitRemoved(toRemove) {
if (toRemove.allHistory) {
this.history = [];
} else {
for (const url of toRemove.urls) {
const i = HistoryCache.binarySearch({ url }, this.history, this.compareHistoryByUrl);
if ((i < this.history.length) && (this.history[i].url === url)) {
this.history.splice(i, 1);
}
}
}
},
};
HistoryCache._onVisitedListener = HistoryCache.onVisited.bind(HistoryCache);
HistoryCache._onVisitRemovedListener = HistoryCache.onVisitRemoved.bind(HistoryCache);
// Returns the matching index or the closest matching index if the element is not found. That means
// you must check the element at the returned index to know whether the element was actually found.
// This method is used for quickly searching through our history cache.
HistoryCache.binarySearch = function (targetElement, array, compareFunction) {
let element, middle;
let high = array.length - 1;
let low = 0;
while (low <= high) {
middle = Math.floor((low + high) / 2);
element = array[middle];
const compareResult = compareFunction(element, targetElement);
if (compareResult > 0) {
high = middle - 1;
} else if (compareResult < 0) {
low = middle + 1;
} else {
return middle;
}
}
// We didn't find the element. Return the position where it should be in this array.
if (compareFunction(element, targetElement) < 0) {
return middle + 1;
} else {
return middle;
}
};
================================================
FILE: background_scripts/completion/ranking.js
================================================
// Utilities which help us compute a relevancy score for a given item.
// Whether the given things (usually URLs or titles) match any one of the query terms.
// This is used to prune out irrelevant suggestions before we try to rank them, and for
// calculating word relevancy. Every term must match at least one thing.
export function matches(queryTerms, ...things) {
for (const term of queryTerms) {
const regexp = RegexpCache.get(term);
let matchedTerm = false;
for (const thing of things) {
if (!matchedTerm) {
matchedTerm = thing.match(regexp);
}
}
if (!matchedTerm) return false;
}
return true;
}
// Weights used for scoring matches.
const matchWeights = {
matchAnywhere: 1,
matchStartOfWord: 1,
matchWholeWord: 1,
// The following must be the sum of the three weights above; it is used for normalization.
maximumScore: 3,
//
// Calibration factor for balancing word relevancy and recency.
recencyCalibrator: 2.0 / 3.0,
};
// The current value of 2.0/3.0 has the effect of:
// - favoring the contribution of recency when matches are not on word boundaries ( because 2.0/3.0 > (1)/3 )
// - favoring the contribution of word relevance when matches are on whole words ( because 2.0/3.0 < (1+1+1)/3 )
// Calculate a score for matching term against string.
// The score is in the range [0, matchWeights.maximumScore], see above.
// Returns: [ score, count ], where count is the number of matched characters in string.
function scoreTerm(term, string) {
let score = 0;
let count = 0;
const nonMatching = string.split(RegexpCache.get(term));
if (nonMatching.length > 1) {
// Have match.
score = matchWeights.matchAnywhere;
count = nonMatching.reduce((p, c) => p - c.length, string.length);
if (RegexpCache.get(term, "\\b").test(string)) {
// Have match at start of word.
score += matchWeights.matchStartOfWord;
if (RegexpCache.get(term, "\\b", "\\b").test(string)) {
// Have match of whole word.
score += matchWeights.matchWholeWord;
}
}
}
return [score, count < string.length ? count : string.length];
}
// Returns a number between [0, 1] indicating how often the query terms appear in the url and title.
export function wordRelevancy(queryTerms, url, title) {
let titleCount, titleScore;
let urlScore = (titleScore = 0.0);
let urlCount = (titleCount = 0);
// Calculate initial scores.
for (const term of queryTerms) {
let [s, c] = scoreTerm(term, url);
urlScore += s;
urlCount += c;
if (title) {
[s, c] = scoreTerm(term, title);
titleScore += s;
titleCount += c;
}
}
const maximumPossibleScore = matchWeights.maximumScore * queryTerms.length;
// Normalize scores.
urlScore /= maximumPossibleScore;
urlScore *= normalizeDifference(urlCount, url.length);
if (title) {
titleScore /= maximumPossibleScore;
titleScore *= normalizeDifference(titleCount, title.length);
} else {
titleScore = urlScore;
}
// Prefer matches in the title over matches in the URL.
// In other words, don't let a poor urlScore pull down the titleScore.
// For example, urlScore can be unreasonably poor if the URL is very long.
if (urlScore < titleScore) {
urlScore = titleScore;
}
// Return the average.
return (urlScore + titleScore) / 2;
}
// Untested alternative to the above:
// - Don't let a poor urlScore pull down a good titleScore, and don't let a poor titleScore pull
// down a good urlScore.
//
// return Math.max(urlScore, titleScore)
let oneMonthAgo = 1000 * 60 * 60 * 24 * 30;
// Returns a score between [0, 1] which indicates how recent the given timestamp is. Items which
// are over a month old are counted as 0. This range is quadratic, so an item from one day ago has
// a much stronger score than an item from two days ago.
export function recencyScore(lastAccessedTime) {
const recency = Date.now() - lastAccessedTime;
const recencyDifference = Math.max(0, oneMonthAgo - recency) / oneMonthAgo;
// recencyScore is between [0, 1]. It is 1 when recenyDifference is 0. This quadratic equation
// will incresingly discount older history entries.
let recencyScore = recencyDifference * recencyDifference * recencyDifference;
// Calibrate recencyScore vis-a-vis word-relevancy scores.
return recencyScore *= matchWeights.recencyCalibrator;
}
// Takes the difference of two numbers and returns a number between [0, 1] (the percentage difference).
function normalizeDifference(a, b) {
const max = Math.max(a, b);
return (max - Math.abs(a - b)) / max;
}
// We cache regexps because we use them frequently when comparing a query to history entries and
// bookmarks, and we don't want to create fresh objects for every comparison.
export const RegexpCache = {
init() {
this.initialized = true;
this.clear();
},
clear() {
this.cache = {};
},
// Get rexexp for `string` from cache, creating it if necessary.
// Regexp meta-characters in `string` are escaped.
// Regexp is wrapped in `prefix`/`suffix`, which may contain meta-characters (these are not
// escaped).
// With their default values, `prefix` and `suffix` have no effect.
// Example:
// - string="go", prefix="\b", suffix=""
// - this returns regexp matching "google", but not "agog" (the "go" must occur at the start of
// a word)
// TODO: `prefix` and `suffix` might be useful in richer word-relevancy scoring.
get(string, prefix, suffix) {
if (prefix == null) prefix = "";
if (suffix == null) suffix = "";
if (!this.initialized) this.init();
let regexpString = Utils.escapeRegexSpecialCharacters(string);
// Avoid cost of constructing new strings if prefix/suffix are empty (which is expected to be a
// common case).
if (prefix) regexpString = prefix + regexpString;
if (suffix) regexpString = regexpString + suffix;
// Smartcase: Regexp is case insensitive, unless `string` contains a capital letter (testing
// `string`, not `regexpString`).
return this.cache[regexpString] ||
(this.cache[regexpString] = new RegExp(regexpString, Utils.hasUpperCase(string) ? "" : "i"));
},
};
================================================
FILE: background_scripts/completion/search_engines.js
================================================
// An engine provides search suggestions for a online search engine.
//
// An "engineUrl" is used for fetching suggestions, whereas a "searchUrl" is used for the actual
// search itself.
//
// Each engine defines:
//
// 1. An "engineUrl". This is the URL to use for search completions and is passed as the option
// "engineUrl" to the "BaseEngine" constructor.
//
// 2. One or more regular expressions which define the custom search engine URLs for which the
// completion engine will be used. This is passed as the "regexps" option to the "BaseEngine"
// constructor.
//
// 3. A "parse" function. This takes the text body of an HTTP response and returns a list of
// suggestions (a list of strings). This method is always executed within the context of a
// try/catch block, so errors do not propagate.
//
// 4. Each completion engine *must* include an example custom search engine. The example must
// include an example "keyword" and an example "searchUrl", and may include an example
// "description" and an "explanation". This info is shown as documentation to the user.
//
// Each new completion engine must be added to the list "CompletionEngines" at the bottom of this
// file.
//
// The lookup logic which uses these completion engines is in "./completers.js".
//
// A base class for common regexp-based matching engines. "options" must define:
// options.engineUrl: the URL to use for the completion engine. This must be a string.
// options.regexps: one or regular expressions. This may either a single string or a list of
// strings.
// options.example: an example object containing at least "keyword" and "searchUrl", and optional
// "description".
// TODO(philc): This base class is doing very little. We should remove it and use composition.
class BaseEngine {
constructor(options) {
Object.assign(this, options);
this.regexps = this.regexps.map((regexp) => new RegExp(regexp));
}
match(searchUrl) {
return Utils.matchesAnyRegexp(this.regexps, searchUrl);
}
getUrl(queryTerms) {
return UrlUtils.createSearchUrl(queryTerms.join(" "), this.engineUrl);
}
}
export class Google extends BaseEngine {
constructor() {
super({
engineUrl: "http://suggestqueries.google.com/complete/search?client=chrome&q=%s",
regexps: ["^https?://[a-z]+\\.google\\.(com|ie|co\\.(uk|jp)|ca|com\\.au)/"],
example: {
searchUrl: "https://www.google.com/search?q=%s",
keyword: "g",
},
});
}
parse(text) {
return JSON.parse(text)[1];
}
}
const googleMapsPrefix = "map of ";
export class GoogleMaps extends BaseEngine {
constructor() {
super({
engineUrl:
`http://suggestqueries.google.com/complete/search?client=chrome&ds=yt&q=${googleMapsPrefix}%s`,
regexps: ["^https?://[a-z]+\\.google\\.(com|ie|co\\.(uk|jp)|ca|com\\.au)/maps"],
example: {
searchUrl: "https://www.google.com/maps?q=%s",
keyword: "m",
explanation: `\
This uses regular Google completion, but prepends the text "map of " to the query. It works
well for places, countries, states, geographical regions and the like, but will not perform address
search.\
`,
},
});
}
parse(text) {
return JSON.parse(text)[1]
.filter((suggestion) => suggestion.startsWith(googleMapsPrefix))
.map((suggestion) => suggestion.slice(googleMapsPrefix));
}
}
export class Youtube extends BaseEngine {
constructor() {
super({
engineUrl: "http://suggestqueries.google.com/complete/search?client=chrome&ds=yt&q=%s",
regexps: ["^https?://[a-z]+\\.youtube\\.com/results"],
example: {
searchUrl: "https://www.youtube.com/results?search_query=%s",
keyword: "y",
},
});
}
parse(text) {
return JSON.parse(text)[1];
}
}
export class Wikipedia extends BaseEngine {
constructor() {
super({
engineUrl: "https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=%s",
regexps: ["^https?://[a-z]+\\.wikipedia\\.org/"],
example: {
searchUrl: "https://www.wikipedia.org/w/index.php?title=Special:Search&search=%s",
keyword: "w",
},
});
}
parse(text) {
return JSON.parse(text)[1];
}
}
export class Bing extends BaseEngine {
constructor() {
super({
engineUrl: "https://api.bing.com/osjson.aspx?query=%s",
regexps: ["^https?://www\\.bing\\.com/search"],
example: {
searchUrl: "https://www.bing.com/search?q=%s",
keyword: "b",
},
});
}
parse(text) {
return JSON.parse(text)[1];
}
}
export class Amazon extends BaseEngine {
constructor() {
super({
engineUrl:
"https://completion.amazon.com/api/2017/suggestions?mid=ATVPDKIKX0DER&alias=aps&prefix=%s",
regexps: ["^https?://(www|smile)\\.amazon\\.(com|co\\.uk|ca|de|com\\.au)/s/"],
example: {
searchUrl: "https://www.amazon.com/s/?field-keywords=%s",
keyword: "a",
},
});
}
parse(text) {
return JSON.parse(text).suggestions.map((suggestion) => suggestion.value);
}
}
export class DuckDuckGo extends BaseEngine {
constructor() {
super({
engineUrl: "https://duckduckgo.com/ac/?q=%s",
regexps: ["^https?://([a-z]+\\.)?duckduckgo\\.com/"],
example: {
searchUrl: "https://duckduckgo.com/?q=%s",
keyword: "d",
},
});
}
parse(text) {
return JSON.parse(text).map((suggestion) => suggestion.phrase);
}
}
export class Webster extends BaseEngine {
constructor() {
super({
engineUrl: "https://www.merriam-webster.com/lapi/v1/mwol-search/autocomplete?search=%s",
regexps: ["^https?://www.merriam-webster.com/dictionary/"],
example: {
searchUrl: "https://www.merriam-webster.com/dictionary/%s",
keyword: "dw",
description: "Dictionary",
},
});
}
parse(text) {
return JSON.parse(text).docs.map((suggestion) => suggestion.word);
}
}
// Qwant is a privacy-friendly search engine.
export class Qwant extends BaseEngine {
constructor() {
super({
engineUrl: "https://api.qwant.com/api/suggest?q=%s",
regexps: ["^https?://www\\.qwant\\.com/"],
example: {
searchUrl: "https://www.qwant.com/?q=%s",
keyword: "qw",
},
});
}
parse(text) {
return JSON.parse(text).data.items.map((suggestion) => suggestion.value);
}
}
// Brave is a privacy-friendly search engine.
export class Brave extends BaseEngine {
constructor() {
super({
engineUrl: "https://search.brave.com/api/suggest?rich=false&q=%s",
regexps: ["^https?://search\\.brave\\.com/"],
example: {
searchUrl: "https://search.brave.com/search?q=%s",
keyword: "br",
},
});
}
parse(text) {
return JSON.parse(text)[1];
}
}
// Kagi is a paid ad-free search engine
export class Kagi extends BaseEngine {
constructor() {
super({
engineUrl: "https://kagi.com/autosuggest?q=%s",
regexps: ["^https?://www\\.kagi\\.com/"],
example: {
searchUrl: "https://www.kagi.com/search?q=%s",
keyword: "k",
},
});
}
parse(text) {
return JSON.parse(text).map((suggestion) => suggestion.t);
}
}
// On the user-facing documentation page pages/doc_search_completion.html, the completion search
// engines will be shown in this order.
export const list = [
Youtube,
GoogleMaps,
Google,
DuckDuckGo,
Wikipedia,
Bing,
Amazon,
Webster,
Brave,
Qwant,
Kagi,
];
================================================
FILE: background_scripts/completion/search_wrapper.js
================================================
import * as searchEngines from "./search_engines.js";
// This is a wrapper class for completion engines. It handles the case where a custom search engine
// includes a prefix query term (or terms). For example:
//
// https://www.google.com/search?q=javascript+%s
//
// In this case, we get better suggestions if we include the term "javascript" in queries sent to
// the completion engine. This wrapper handles adding such prefixes to completion-engine queries and
// removing them from the resulting suggestions.
class EnginePrefixWrapper {
constructor(searchUrl, engine) {
this.searchUrl = searchUrl;
this.engine = engine;
}
getUrl(queryTerms) {
// This tests whether @searchUrl contains something of the form "...=abc+def+%s...", from which
// we extract a prefix of the form "abc def ".
if (/\=.+\+%s/.test(this.searchUrl)) {
let terms = this.searchUrl.replace(/\+%s.*/, "");
terms = terms.replace(/.*=/, "");
terms = terms.replace(/\+/g, " ");
queryTerms = [...terms.split(" "), ...queryTerms];
const prefix = `${terms} `;
this.transformSuggestionsFn = (suggestions) => {
return suggestions
.filter((s) => s.startsWith(prefix))
.map((s) => s.slice(prefix.length));
};
}
return this.engine.getUrl(queryTerms);
}
parse(responseText) {
const suggestions = this.engine.parse(responseText);
return this.transformSuggestionsFn ? this.transformSuggestionsFn(suggestions) : suggestions;
}
}
let debug = false;
const inTransit = {};
const completionCache = new SimpleCache(2 * 60 * 60 * 1000, 5000); // Two hours, 5000 entries.
const engineCache = new SimpleCache(1000 * 60 * 60 * 1000); // 1000 hours.
// The amount of time to wait for new requests before launching the current request (for example,
// if the user is still typing).
const DELAY = 100;
// This gets incremented each time we make a request to the completion engine. This allows us to
// dedupe requets which overlap, which is the case when the user is typing fast.
let requestId = 0;
async function get(url) {
const timeoutDuration = 2500;
const controller = new AbortController();
let isError = false;
let responseText;
const timer = Utils.setTimeout(timeoutDuration, () => controller.abort());
try {
const response = await fetch(url, { signal: controller.signal });
responseText = await response.text();
} catch {
// Fetch throws an error if the network is unreachable, etc.
isError = true;
}
clearTimeout(timer);
return isError ? null : responseText;
}
// Look up the completion engine for this searchUrl.
function lookupEngine(searchUrl) {
if (engineCache.has(searchUrl)) {
return engineCache.get(searchUrl);
} else {
for (const engineClass of searchEngines.list) {
const engine = new engineClass();
if (engine.match(searchUrl)) {
return engineCache.set(searchUrl, engine);
}
}
}
}
// This is the main entry point.
// - searchUrl is the search engine's URL, e.g. Settings.get("searchUrl"), or a custom search
// engine's URL. This is only used as a key for determining the relevant completion engine.
// - queryTerms are the query terms.
export async function complete(searchUrl, queryTerms) {
const query = queryTerms.join(" ").toLowerCase();
// We don't complete queries which are too short: the results are usually useless.
if (query.length < 4) return [];
// We don't complete regular URLs or Javascript URLs.
if (queryTerms.length == 1 && await UrlUtils.isUrl(query)) return [];
if (UrlUtils.hasJavascriptProtocol(query)) return [];
const engine = lookupEngine(searchUrl);
if (!engine) return [];
const completionCacheKey = JSON.stringify([searchUrl, queryTerms]);
if (completionCache.has(completionCacheKey)) {
if (debug) console.log("hit", completionCacheKey);
return completionCache.get(completionCacheKey);
}
const createTimeoutPromise = (ms) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, ms);
});
};
requestId++;
const lastRequestId = requestId;
// We delay sending a completion request in case the user is still typing.
await createTimeoutPromise(DELAY);
// If the user has issued a new query while we were waiting, then this query is old; abort it.
if (lastRequestId != requestId) return [];
const engineWrapper = new EnginePrefixWrapper(searchUrl, engine);
const url = engineWrapper.getUrl(queryTerms);
if (debug) console.log("GET", url);
const responseText = await get(url);
// Parsing the response may fail if we receive an unexpectedly-formatted response. In all cases,
// we fall back to the catch clause, below. Therefore, we "fail safe" in the case of incorrect
// or out-of-date completion engine implementations.
let suggestions = [];
let isError = responseText == null;
if (!isError) {
try {
suggestions = engineWrapper.parse(responseText)
// Make all suggestions lower case. It looks odd when suggestions from one
// completion engine are upper case, and those from another are lower case.
.map((s) => s.toLowerCase())
// Filter out the query itself. It's not adding anything.
.filter((s) => s !== query);
} catch (error) {
if (debug) console.log("error:", error);
isError = true;
}
}
if (isError) {
// We allow failures to be cached too, but remove them after just thirty seconds.
Utils.setTimeout(
30 * 1000,
() => completionCache.set(completionCacheKey, null),
);
}
completionCache.set(completionCacheKey, suggestions);
return suggestions;
}
// Cancel any pending (ie. blocked on @delay) queries. Does not cancel in-flight queries. This is
// called whenever the user is typing.
export function cancel() {
requestId++;
}
================================================
FILE: background_scripts/exclusions.js
================================================
// This module manages manages the exclusion rule setting. An exclusion is an object with two
// attributes: pattern and passKeys. The exclusion rules are an array of such objects.
const ExclusionRegexpCache = {
cache: {},
clear(cache) {
this.cache = cache || {};
},
get(pattern) {
if (pattern in this.cache) {
return this.cache[pattern];
} else {
let result;
// We use try/catch to ensure that a broken regexp doesn't wholly cripple Vimium.
try {
result = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$");
} catch {
if (!globalThis.isUnitTests) {
console.log(`bad regexp in exclusion rule: ${pattern}`);
}
result = /^$/; // Match the empty string.
}
this.cache[pattern] = result;
return result;
}
},
};
// Make RegexpCache, which is required on the page popup, accessible via the Exclusions object.
const RegexpCache = ExclusionRegexpCache;
// Merge the matching rules for URL, or null. In the normal case, we use the configured @rules;
// hence, this is the default. However, when called from the page popup, we are testing what
// effect candidate new rules would have on the current tab. In this case, the candidate rules are
// provided by the caller.
function getRule(url, rules) {
if (rules == null) {
rules = Settings.get("exclusionRules");
}
const matchingRules = rules.filter((r) =>
r.pattern && (url.search(ExclusionRegexpCache.get(r.pattern)) >= 0)
);
// An absolute exclusion rule (one with no passKeys) takes priority.
for (const rule of matchingRules) {
if (!rule.passKeys) return rule;
}
// Strip whitespace from all matching passKeys strings, and join them together.
const passKeys = matchingRules.map((r) => r.passKeys.split(/\s+/).join("")).join("");
// TODO(philc): Remove this commented out code.
// passKeys = (rule.passKeys.split(/\s+/).join "" for rule in matchingRules).join ""
if (matchingRules.length > 0) {
return { passKeys: Utils.distinctCharacters(passKeys) };
} else {
return null;
}
}
export function isEnabledForUrl(url) {
const rule = getRule(url);
return {
isEnabledForUrl: !rule || (rule.passKeys.length > 0),
passKeys: rule ? rule.passKeys : "",
};
}
function setRules(rules) {
// Callers map a rule to null to have it deleted, and rules without a pattern are useless.
const newRules = rules.filter((rule) => rule?.pattern);
Settings.set("exclusionRules", newRules);
}
function onSettingsUpdated() {
// NOTE(mrmr1993): In FF, the |rules| argument will be garbage collected when the exclusions
// popup is closed. Do NOT store it/use it asynchronously.
ExclusionRegexpCache.clear();
}
Settings.addEventListener("change", () => onSettingsUpdated());
================================================
FILE: background_scripts/main.js
================================================
import "../lib/utils.js";
import "../lib/settings.js";
import "../lib/url_utils.js";
import "../background_scripts/tab_recency.js";
import * as bgUtils from "../background_scripts/bg_utils.js";
import "../background_scripts/all_commands.js";
import { Commands } from "../background_scripts/commands.js";
import * as exclusions from "../background_scripts/exclusions.js";
import "../background_scripts/completion/search_engines.js";
import "../background_scripts/completion/search_wrapper.js";
import "../background_scripts/completion/completers.js";
import "../background_scripts/tab_operations.js";
import * as marks from "../background_scripts/marks.js";
import {
BookmarkCompleter,
DomainCompleter,
HistoryCompleter,
MultiCompleter,
SearchEngineCompleter,
TabCompleter,
} from "./completion/completers.js";
// NOTE(philc): This file has many superfluous return statements in its functions, as a result of
// converting from coffeescript to es6. Many can be removed, but I didn't take the time to
// diligently track down precisely which return statements could be removed when I was doing the
// conversion.
import * as TabOperations from "./tab_operations.js";
// Allow Vimium's content scripts to access chrome.storage.session. Otherwise,
// chrome.storage.session will be null in content scripts.
chrome.storage.session.setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" });
// This is exported for use by "marks.js".
globalThis.tabLoadedHandlers = {}; // tabId -> function()
// A Vimium secret, available only within the current browser session. The secret is a generated
// strong random string.
const randomArray = globalThis.crypto.getRandomValues(new Uint8Array(32)); // 32-byte random token.
const secretToken = randomArray.reduce((a, b) => a.toString(16) + b.toString(16));
chrome.storage.session.set({ vimiumSecret: secretToken });
const completionSources = {
bookmarks: new BookmarkCompleter(),
history: new HistoryCompleter(),
domains: new DomainCompleter(),
tabs: new TabCompleter(),
searchEngines: new SearchEngineCompleter(),
};
const completers = {
omni: new MultiCompleter([
completionSources.bookmarks,
completionSources.history,
completionSources.domains,
completionSources.tabs,
completionSources.searchEngines,
]),
bookmarks: new MultiCompleter([completionSources.bookmarks]),
tabs: new MultiCompleter([completionSources.tabs]),
};
// A query dictionary for `chrome.tabs.query` that will return only the visible tabs.
const visibleTabsQueryArgs = { currentWindow: true };
if (bgUtils.isFirefox()) {
// Only Firefox supports hidden tabs.
visibleTabsQueryArgs.hidden = false;
}
function onURLChange(details) {
// sendMessage will throw "Error: Could not establish connection. Receiving end does not exist."
// if there is no Vimium content script loaded in the given tab. This can occur if the user
// navigated to a page where Vimium doesn't have permissions, like chrome:// URLs. This error is
// noisy and mysterious (it usually doesn't have a valid line number), so we silence it.
const message = {
handler: "checkEnabledAfterURLChange",
silenceLogging: true,
};
chrome.tabs.sendMessage(details.tabId, message, { frameId: details.frameId })
.catch(() => {});
}
// Re-check whether Vimium is enabled for a frame when the URL changes without a reload.
// There's no reliable way to detect when the URL has changed in the content script, so we
// have to use the webNavigation API in our background script.
chrome.webNavigation.onHistoryStateUpdated.addListener(onURLChange); // history.pushState.
chrome.webNavigation.onReferenceFragmentUpdated.addListener(onURLChange); // Hash changed.
if (!globalThis.isUnitTests) {
// Cache "content_scripts/vimium.css" in chrome.storage.session for UI components.
(function () {
const url = chrome.runtime.getURL("content_scripts/vimium.css");
fetch(url).then(async (response) => {
if (response.ok) {
chrome.storage.session.set({ vimiumCSSInChromeStorage: await response.text() });
}
});
})();
}
function muteTab(tab) {
chrome.tabs.update(tab.id, { muted: !tab.mutedInfo.muted });
}
function toggleMuteTab(request, sender) {
const currentTab = request.tab;
const tabId = request.tabId;
const registryEntry = request.registryEntry;
if ((registryEntry.options.all != null) || (registryEntry.options.other != null)) {
// If there are any audible, unmuted tabs, then we mute them; otherwise we unmute any muted tabs.
chrome.tabs.query({ audible: true }, function (tabs) {
let tab;
if (registryEntry.options.other != null) {
tabs = tabs.filter((t) => t.id !== currentTab.id);
}
const audibleUnmutedTabs = tabs.filter((t) => t.audible && !t.mutedInfo.muted);
if (audibleUnmutedTabs.length >= 0) {
chrome.tabs.sendMessage(tabId, {
frameId: sender.frameId,
handler: "showMessage",
message: `Muting ${audibleUnmutedTabs.length} tab(s).`,
});
for (tab of audibleUnmutedTabs) {
muteTab(tab);
}
} else {
chrome.tabs.sendMessage(tabId, {
frameId: sender.frameId,
handler: "showMessage",
message: "Unmuting all muted tabs.",
});
for (tab of tabs) {
if (tab.mutedInfo.muted) {
muteTab(tab);
}
}
}
});
} else {
if (currentTab.mutedInfo.muted) {
chrome.tabs.sendMessage(tabId, {
frameId: sender.frameId,
handler: "showMessage",
message: "Unmuted tab.",
});
} else {
chrome.tabs.sendMessage(tabId, {
frameId: sender.frameId,
handler: "showMessage",
message: "Muted tab.",
});
}
muteTab(currentTab);
}
}
// Find a tab's actual index in a given tab array returned by chrome.tabs.query. In Firefox, there
// may be hidden tabs, so tab.tabIndex may not be the actual index into the array of visible tabs.
function getTabIndex(tab, tabs) {
// First check if the tab is where we expect it, to avoid searching the array.
if (tabs.length > tab.index && tabs[tab.index].index === tab.index) {
return tab.index;
} else {
return tabs.findIndex((t) => t.index === tab.index);
}
}
//
// Selects the tab with the ID specified in request.id
//
async function selectSpecificTab(request) {
const tab = await chrome.tabs.get(request.id);
// Focus the tab's window. TODO(philc): Why are we null-checking chrome.windows here?
if (chrome.windows != null) {
await chrome.windows.update(tab.windowId, { focused: true });
}
await chrome.tabs.update(request.id, { active: true });
}
function moveTab({ count, tab, registryEntry }) {
if (registryEntry.command === "moveTabLeft") {
count = -count;
}
return chrome.tabs.query(visibleTabsQueryArgs, function (tabs) {
const pinnedCount = (tabs.filter((tab) => tab.pinned)).length;
const minIndex = tab.pinned ? 0 : pinnedCount;
const maxIndex = (tab.pinned ? pinnedCount : tabs.length) - 1;
// The tabs array index of the new position.
const moveIndex = Math.max(minIndex, Math.min(maxIndex, getTabIndex(tab, tabs) + count));
return chrome.tabs.move(tab.id, {
index: tabs[moveIndex].index,
});
});
}
function createRepeatCommand(command) {
return async function (request) {
let i = request.count - 1;
const r = Object.assign({}, request);
delete r.count;
while (i >= 0) {
i--;
await command(r);
}
};
}
function nextZoomLevel(currentZoom, steps) {
// Chrome's default zoom levels.
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];
// Firefox's default zoom levels.
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];
let zoomLevels = chromeLevels; // Chrome by default
if (bgUtils.isFirefox()) {
zoomLevels = firefoxLevels;
}
if (steps === 0) { // Nothing
return currentZoom;
} else if (steps > 0) { // In
// Chrome sometimes returns values with floating point errors.
// Example: Chrome gives 0.32999999999999996 instead of 0.33.
currentZoom += 0.0000001; // This is needed to solve floating point bugs in Chrome.
const nextIndex = zoomLevels.findIndex((level) => level > currentZoom);
const floorIndex = nextIndex == -1 ? zoomLevels.length : nextIndex - 1;
return zoomLevels[Math.min(zoomLevels.length - 1, floorIndex + steps)];
} else if (steps < 0) { // Out
currentZoom -= 0.0000001; // This is needed to solve floating point bugs in Chrome.
let ceilIndex = zoomLevels.findIndex((level) => level >= currentZoom);
ceilIndex = ceilIndex == -1 ? zoomLevels.length : ceilIndex;
return zoomLevels[Math.max(0, ceilIndex + steps)];
}
}
// These are commands which are bound to keystrokes which must be handled by the background page.
// They are mapped in commands.js.
const BackgroundCommands = {
// Create a new tab. Also, with:
// map X createTab http://www.bbc.com/news
// create a new tab with the given URL.
createTab: createRepeatCommand(async function (request) {
if (request.urls == null) {
if (request.url) {
// If the request contains a URL, then use it.
request.urls = [request.url];
} else {
// Otherwise, if we have a registryEntry containing URLs, then use them.
const options = Object.keys(request.registryEntry.options);
const promises = options.map((opt) => UrlUtils.isUrl(opt));
const isUrl = await Promise.all(promises);
const urlList = options.filter((_, i) => isUrl[i]);
if (urlList.length > 0) {
request.urls = urlList;
} else {
// Otherwise, just create a new tab.
let url;
const destination = Settings.get("newTabDestination");
const customUrl = Settings.get("newTabCustomUrl");
if (destination == Settings.newTabDestinations.vimiumNewTabPage) {
url = Settings.vimiumNewTabPageUrl;
} else if (destination == Settings.newTabDestinations.customUrl && customUrl.length > 0) {
url = customUrl;
} else {
url = UrlUtils.chromeNewTabUrl;
}
request.urls = [url];
}
}
}
if (request.registryEntry.options.incognito || request.registryEntry.options.window) {
// Firefox does not allow an incognito window to be created with the URL about:newtab. It
// throws this error: "Illegal URL: about:newtab".
const urls = request.urls.filter((u) => u != UrlUtils.chromeNewTabUrl);
const windowConfig = {
url: urls,
incognito: request.registryEntry.options.incognito || false,
};
await chrome.windows.create(windowConfig);
} else {
const urls = request.urls.slice().reverse();
if (request.position == null) {
request.position = request.registryEntry.options.position;
}
while (urls.length > 0) {
const url = urls.pop();
const tab = await TabOperations.openUrlInNewTab(Object.assign(request, { url }));
// Ensure subsequent invocations of this command place the next tab directly after this one.
Object.assign(request, { tab, position: "after", active: false });
}
}
}),
duplicateTab: createRepeatCommand(async (request) => {
const tab = await chrome.tabs.duplicate(request.tabId);
// Ensure subsequent invocations of this command place the next tab directly after this one.
request.tabId = tab.id;
}),
moveTabToNewWindow({ count, tab }) {
// TODO(philc): Switch to the promise API of chrome.tabs.query.
chrome.tabs.query(visibleTabsQueryArgs, function (tabs) {
const activeTabIndex = getTabIndex(tab, tabs);
const startTabIndex = Math.max(0, Math.min(activeTabIndex, tabs.length - count));
[tab, ...tabs] = tabs.slice(startTabIndex, startTabIndex + count);
chrome.windows.create({ tabId: tab.id, incognito: tab.incognito }, function (window) {
chrome.tabs.move(tabs.map((t) => t.id), { windowId: window.id, index: -1 });
});
});
},
nextTab(request) {
return selectTab("next", request);
},
previousTab(request) {
return selectTab("previous", request);
},
firstTab(request) {
return selectTab("first", request);
},
lastTab(request) {
return selectTab("last", request);
},
async removeTab({ count, tab }) {
await forCountTabs(count, tab, (tab) => {
// In Firefox, Ctrl-W will not close a pinned tab, but on Chrome, it will. We try to be
// consistent with each browser's UX for pinned tabs.
if (tab.pinned && bgUtils.isFirefox()) return;
chrome.tabs.remove(tab.id);
});
},
restoreTab: createRepeatCommand(async (request) => {
await chrome.sessions.restore(null);
}),
async togglePinTab({ count, tab }) {
await forCountTabs(count, tab, (tab) => {
chrome.tabs.update(tab.id, { pinned: !tab.pinned });
});
},
toggleMuteTab,
moveTabLeft: moveTab,
moveTabRight: moveTab,
async setZoom({ tabId, registryEntry }) {
const level = registryEntry.options?.["level"] ?? "1";
const newZoom = parseFloat(level);
if (!isNaN(newZoom)) {
chrome.tabs.setZoom(tabId, newZoom);
}
},
async zoomIn({ count, tabId }) {
const currentZoom = await chrome.tabs.getZoom(tabId);
const newZoom = nextZoomLevel(currentZoom, count);
chrome.tabs.setZoom(tabId, newZoom);
},
async zoomOut({ count, tabId }) {
const currentZoom = await chrome.tabs.getZoom(tabId);
const newZoom = nextZoomLevel(currentZoom, -count);
chrome.tabs.setZoom(tabId, newZoom);
},
async zoomReset({ tabId }) {
chrome.tabs.setZoom(tabId, 0); // setZoom of 0 sets to the tab default.
},
async nextFrame({ count, tabId }) {
// We're assuming that these frames are returned in the order that they appear on the page. This
// seems to be the case empirically. If it's ever needed, we could also sort by frameId.
let frameIds = await getFrameIdsForTab(tabId);
const promises = frameIds.map(async (frameId) => {
// It is possible that this sendMessage call fails, if a frame gets unloaded while the request
// is in flight.
let isError = false;
const status = await (chrome.tabs.sendMessage(tabId, { handler: "getFocusStatus" }, {
frameId: frameId,
}).catch((_) => {
isError = true;
}));
return { frameId, status, isError };
});
const frameResponses = (await Promise.all(promises)).filter((r) => !r.isError);
const focusedFrameId = frameResponses.find(({ status }) => status.focused)?.frameId;
// It's theoretically possible that focusedFrameId is null if the user switched tabs or away
// from the browser while the request is in flight.
if (focusedFrameId == null) return;
// Prune any frames which gave an error response (i.e. they disappeared).
frameIds = frameResponses.filter((r) => r.status.focusable).map((r) => r.frameId);
const index = frameIds.indexOf(focusedFrameId);
count = count ?? 1;
const nextIndex = (index + count) % frameIds.length;
if (index == nextIndex) return;
await chrome.tabs.sendMessage(tabId, { handler: "focusFrame", highlight: true }, {
frameId: frameIds[nextIndex],
});
},
async closeTabsOnLeft(request) {
await removeTabsRelative("before", request);
},
async closeTabsOnRight(request) {
await removeTabsRelative("after", request);
},
async closeOtherTabs(request) {
await removeTabsRelative("both", request);
},
async visitPreviousTab({ count, tab }) {
await bgUtils.tabRecency.init();
let tabIds = bgUtils.tabRecency.getTabsByRecency();
tabIds = tabIds.filter((tabId) => tabId !== tab.id);
if (tabIds.length > 0) {
const id = tabIds[(count - 1) % tabIds.length];
selectSpecificTab({ id });
}
},
async reload({ count, tab, registryEntry }) {
const bypassCache = registryEntry.options.hard != null ? registryEntry.options.hard : false;
await forCountTabs(count, tab, (tab) => {
chrome.tabs.reload(tab.id, { bypassCache });
});
},
};
async function forCountTabs(count, currentTab, callback) {
const tabs = await chrome.tabs.query(visibleTabsQueryArgs);
const activeTabIndex = getTabIndex(currentTab, tabs);
const startTabIndex = Math.max(0, Math.min(activeTabIndex, tabs.length - count));
for (const tab of tabs.slice(startTabIndex, startTabIndex + count)) {
callback(tab);
}
}
// Remove tabs before, after, or either side of the currently active tab
async function removeTabsRelative(direction, { count, tab }) {
// count is null if the user didn't type a count prefix before issuing this command and didn't
// specify a count=n option in their keymapping settings. Interpret this as closing all tabs on
// either side.
if (count == null) count = 99999;
const activeTab = tab;
const tabs = await chrome.tabs.query(visibleTabsQueryArgs);
const activeIndex = getTabIndex(activeTab, tabs);
const toRemove = tabs.filter((tab, tabIndex) => {
if (tab.pinned || tab.id == activeTab.id) {
return false;
}
switch (direction) {
case "before":
return tabIndex < activeIndex &&
tabIndex >= activeIndex - count;
case "after":
return tabIndex > activeIndex &&
tabIndex <= activeIndex + count;
case "both":
return true;
}
});
await chrome.tabs.remove(toRemove.map((t) => t.id));
}
// Selects a tab before or after the currently selected tab.
// - direction: "next", "previous", "first" or "last".
function selectTab(direction, { count, tab }) {
chrome.tabs.query(visibleTabsQueryArgs, function (tabs) {
if (tabs.length > 1) {
const toSelect = (() => {
switch (direction) {
case "next":
return (getTabIndex(tab, tabs) + count) % tabs.length;
case "previous":
return ((getTabIndex(tab, tabs) - count) + (count * tabs.length)) % tabs.length;
case "first":
return Math.min(tabs.length - 1, count - 1);
case "last":
return Math.max(0, tabs.length - count);
}
})();
chrome.tabs.update(tabs[toSelect].id, { active: true });
}
});
}
chrome.webNavigation.onCommitted.addListener(async ({ tabId, frameId }) => {
// Vimium can't run on all tabs (e.g. chrome:// URLs). insertCSS will throw an error on such tabs,
// which is expected, and noise. Swallow that error.
const swallowError = () => {};
await Settings.onLoaded();
await chrome.scripting.insertCSS({
css: Settings.get("userDefinedLinkHintCss"),
target: {
tabId: tabId,
frameIds: [frameId],
},
}).catch(swallowError);
});
// Returns all frame IDs for the given tab. Note that in Chrome, this will omit frame IDs for frames
// or iFrames which contain chrome-extension:// URLs, even if those pages are listed in Vimium's
// web_accessible_resources in manifest.json.
async function getFrameIdsForTab(tabId) {
// getAllFrames unfortunately excludes frames and iframes from chrome-extension:// URLs.
// In Firefox, by contrast, pages with moz-extension:// URLs are included.
const frames = await chrome.webNavigation.getAllFrames({ tabId: tabId });
return frames.map((f) => f.frameId);
}
const HintCoordinator = {
// Forward the message in "request" to all frames the in sender's tab.
broadcastLinkHintsMessage(request, sender) {
chrome.tabs.sendMessage(
sender.tab.id,
Object.assign(request, { handler: "linkHintsMessage" }),
);
},
// This is sent by the content script once the user issues the link hints command.
async prepareToActivateLinkHintsMode(
tabId,
originatingFrameId,
{ modeIndex, requestedByHelpDialog, isExtensionPage },
) {
const frameIds = await getFrameIdsForTab(tabId);
// If link hints was triggered on a Vimium extension page (like the vimium help dialog or
// options page), we cannot directly retrieve the frameIds for those pages using the
// getFrameIdsForTab. However, as a workaround, if those pages were the pages activating hints,
// their frameId is equal to originatingFrameId.
if (isExtensionPage && !frameIds.includes(originatingFrameId)) {
frameIds.push(originatingFrameId);
}
const timeout = 3000;
let promises = frameIds.map(async (frameId) => {
let promise = chrome.tabs.sendMessage(
tabId,
{
handler: "linkHintsMessage",
messageType: "getHintDescriptors",
modeIndex,
requestedByHelpDialog,
},
{ frameId },
);
promise = Utils.promiseWithTimeout(promise, timeout)
.catch((error) => Utils.debugLog("Swallowed getHintDescriptors error:", error));
const descriptors = await promise;
return {
frameId,
descriptors,
};
});
const responses = (await Promise.all(promises))
.filter((r) => r.descriptors != null);
const frameIdToDescriptors = {};
for (const { frameId, descriptors } of responses) {
frameIdToDescriptors[frameId] = descriptors;
}
promises = responses.map(({ frameId }) => {
// Don't send this frame's own link hints back to it -- they're already stored in that frame's
// content script. At the time that we wrote this, this resulted in a 150% speedup for link
// busy sites like Reddit.
const outgoingFrameIdToHintDescriptors = Object.assign({}, frameIdToDescriptors);
delete outgoingFrameIdToHintDescriptors[frameId];
return chrome.tabs.sendMessage(
tabId,
{
handler: "linkHintsMessage",
messageType: "activateMode",
frameId: frameId,
originatingFrameId: originatingFrameId,
frameIdToHintDescriptors: outgoingFrameIdToHintDescriptors,
modeIndex: modeIndex,
},
{ frameId },
).catch((error) => {
Utils.debugLog(
"Swallowed linkHints activateMode error:",
error,
"tabId",
tabId,
"frameId",
frameId,
);
});
});
await Promise.all(promises);
},
};
const sendRequestHandlers = {
runBackgroundCommand(request, sender) {
return BackgroundCommands[request.registryEntry.command](request, sender);
},
// getCurrentTabUrl is used by the content scripts to get their full URL, because window.location
// cannot help with Chrome-specific URLs like "view-source:http:..".
getCurrentTabUrl({ tab }) {
return tab.url;
},
openUrlInNewTab: createRepeatCommand(async (request, callback) => {
await TabOperations.openUrlInNewTab(request, callback);
}),
async openUrlInNewWindow(request) {
await TabOperations.openUrlInNewWindow(request);
},
async openUrlInIncognito(request) {
await chrome.windows.create({
incognito: true,
url: await UrlUtils.convertToUrl(request.url),
});
},
openUrlInCurrentTab: TabOperations.openUrlInCurrentTab,
openOptionsPageInNewTab(request) {
return chrome.tabs.create({
url: chrome.runtime.getURL("pages/options.html"),
index: request.tab.index + 1,
});
},
launchSearchQuery({ query, openInNewTab }) {
const disposition = openInNewTab ? "NEW_TAB" : "CURRENT_TAB";
chrome.search.query({ disposition, text: query });
},
domReady(_, sender) {
const isTopFrame = sender.frameId == 0;
if (!isTopFrame) return;
const tabId = sender.tab.id;
// The only feature that uses tabLoadedHandlers is marks.
if (tabLoadedHandlers[tabId]) tabLoadedHandlers[tabId]();
delete tabLoadedHandlers[tabId];
},
nextFrame: BackgroundCommands.nextFrame,
selectSpecificTab,
createMark: marks.create,
gotoMark: marks.goto,
// Send a message to all frames in the current tab. If request.frameId is provided, then send
// messages to only the frame with that ID.
sendMessageToFrames(request, sender) {
const newRequest = Object.assign({}, request.message);
const options = request.frameId != null ? { frameId: request.frameId } : {};
chrome.tabs.sendMessage(sender.tab.id, newRequest, options);
},
broadcastLinkHintsMessage(request, sender) {
HintCoordinator.broadcastLinkHintsMessage(request, sender);
},
prepareToActivateLinkHintsMode(request, sender) {
HintCoordinator.prepareToActivateLinkHintsMode(sender.tab.id, sender.frameId, request);
},
async initializeFrame(request, sender) {
// Check whether the extension is enabled for the top frame's URL, rather than the URL of the
// specific frame that sent this request.
const enabledState = exclusions.isEnabledForUrl(sender.tab.url);
const isTopFrame = sender.frameId == 0;
if (isTopFrame) {
let whichIcon;
if (!enabledState.isEnabledForUrl) {
whichIcon = "disabled";
} else if (enabledState.passKeys.length > 0) {
whichIcon = "partial";
} else {
whichIcon = "enabled";
}
let iconSet = {
"enabled": {
"16": "../icons/action_enabled_16.png",
"32": "../icons/action_enabled_32.png",
},
"partial": {
"16": "../icons/action_partial_16.png",
"32": "../icons/action_partial_32.png",
},
"disabled": {
"16": "../icons/action_disabled_16.png",
"32": "../icons/action_disabled_32.png",
},
};
if (bgUtils.isFirefox()) {
// Only Firefox supports SVG icons.
iconSet = {
"enabled": "../icons/action_enabled.svg",
"partial": "../icons/action_partial.svg",
"disabled": "../icons/action_disabled.svg",
};
}
chrome.action.setIcon({ path: iconSet[whichIcon], tabId: sender.tab.id });
}
const response = Object.assign({
isFirefox: bgUtils.isFirefox(),
firefoxVersion: await bgUtils.getFirefoxVersion(),
frameId: sender.frameId,
}, enabledState);
return response;
},
async getBrowserInfo() {
return {
isFirefox: bgUtils.isFirefox(),
firefoxVersion: await bgUtils.getFirefoxVersion(),
};
},
async filterCompletions(request) {
const completer = completers[request.completerName];
let response = await completer.filter(request);
// NOTE(smblott): response contains `relevancyFunction` (function) properties which cause
// postMessage, below, to fail in Firefox. See #2576. We cannot simply delete these methods,
// as they're needed elsewhere. Converting the response to JSON and back is a quick and easy
// way to sanitize the object.
response = JSON.parse(JSON.stringify(response));
return response;
},
refreshCompletions(request) {
const completer = completers[request.completerName];
completer.refresh();
},
cancelCompletions(request) {
const completer = completers[request.completerName];
completer.cancel();
},
};
Utils.addChromeRuntimeOnMessageListener(
Object.keys(sendRequestHandlers),
async function (request, sender) {
Utils.debugLog(
"main.js: onMessage:%ourl:%otab:%oframe:%o",
request.handler,
sender.url.replace(/https?:\/\//, ""),
sender.tab?.id,
sender.frameId,
// request // Often useful for debugging.
);
// NOTE(philc): We expect all messages to come from a content script in a tab. I've observed in
// Firefox when the extension is first installed, domReady and initializeFrame messages come from
// content scripts in about:blank URLs, which have a null sender.tab. I don't know what this
// corresponds to. Since we expect a valid sender.tab, ignore those messages.
if (sender.tab == null) return;
await Settings.onLoaded();
request = Object.assign({ count: 1 }, request, {
tab: sender.tab,
tabId: sender.tab.id,
});
const handler = sendRequestHandlers[request.handler];
const result = handler ? await handler(request, sender) : null;
return result;
},
);
// Remove chrome.storage.local/findModeRawQueryListIncognito if there are no remaining
// incognito-mode windows. Since the common case is that there are none to begin with, we first
// check whether the key is set at all.
chrome.tabs.onRemoved.addListener(function (tabId) {
if (tabLoadedHandlers[tabId]) {
delete tabLoadedHandlers[tabId];
}
chrome.storage.session.get("findModeRawQueryListIncognito", function (items) {
if (items.findModeRawQueryListIncognito) {
return chrome.windows != null
? chrome.windows.getAll(null, function (windows) {
for (const window of windows) {
if (window.incognito) return;
}
// There are no remaining incognito-mode tabs, and findModeRawQueryListIncognito is set.
return chrome.storage.session.remove("findModeRawQueryListIncognito");
})
: undefined;
}
});
});
// Convenience function for development use.
globalThis.runTests = () => open(chrome.runtime.getURL("tests/dom_tests/dom_tests.html"));
//
// Begin initialization.
//
// True if the major version of Vimium has changed.
// - previousVersion: this will be null for new installs.
function majorVersionHasIncreased(previousVersion) {
const currentVersion = Utils.getCurrentVersion();
if (previousVersion == null) return false;
const currentMajorVersion = currentVersion.split(".").slice(0, 2).join(".");
const previousMajorVersion = previousVersion.split(".").slice(0, 2).join(".");
return Utils.compareVersions(currentMajorVersion, previousMajorVersion) == 1;
}
// Show notification on upgrade.
async function showUpgradeMessageIfNecessary(onInstalledDetails) {
const currentVersion = Utils.getCurrentVersion();
// We do not show an upgrade message for patch/silent releases. Such releases have the same
// major and minor version numbers.
if (
!majorVersionHasIncreased(onInstalledDetails.previousVersion) ||
Settings.get("hideUpdateNotifications")
) {
return;
}
// NOTE(philc): These notifications use the system notification UI. So, if you don't have
// notifications enabled from your browser (e.g. in Notification Settings in OSX), then
// chrome.notification.create will succeed, but you won't see it.
const notificationId = "VimiumUpgradeNotification";
await chrome.notifications.create(
notificationId,
{
type: "basic",
iconUrl: chrome.runtime.getURL("icons/icon128.png"),
title: "Vimium Upgrade",
message:
`Vimium has been upgraded to version ${currentVersion}. Click here for more information.`,
isClickable: true,
},
);
if (!chrome.runtime.lastError) {
chrome.notifications.onClicked.addListener(async function (id) {
if (id != notificationId) return;
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
const tab = tabs[0];
TabOperations.openUrlInNewTab({
tab,
tabId: tab.id,
url: "https://github.com/philc/vimium/blob/master/CHANGELOG.md",
});
});
}
}
async function injectContentScriptsAndCSSIntoExistingTabs() {
const manifest = chrome.runtime.getManifest();
const contentScriptConfig = manifest.content_scripts[0];
const contentScripts = contentScriptConfig.js;
const cssFiles = contentScriptConfig.css;
// The scripting.executeScript and scripting.insertCSS APIs can fail if we don't have permissions
// to run scripts in a given tab. Examples are: chrome:// URLs, file:// pages (if the user hasn't
// granted Vimium access to file URLs), and probably incognito tabs (unconfirmed). Calling these
// APIs on such tabs results in an error getting logged on the background page. To avoid this
// noise, we swallow the failures. We could instead try to determine if the tab is scriptable by
// checking its URL scheme before calling these APIs, but that approach has some nuance to it.
// This is simpler.
const swallowError = (_) => {};
const tabs = await chrome.tabs.query({ status: "complete" });
for (const tab of tabs) {
const target = { tabId: tab.id, allFrames: true };
// Inject all of our content javascripts.
chrome.scripting.executeScript({
files: contentScripts,
target: target,
}).catch(swallowError);
// Inject our extension's CSS.
chrome.scripting.insertCSS({
files: cssFiles,
target: target,
}).catch(swallowError);
// Inject the user's link hint CSS.
chrome.scripting.insertCSS({
css: Settings.get("userDefinedLinkHintCss"),
target: target,
}).catch(swallowError);
}
}
async function initializeExtension() {
await Settings.onLoaded();
await Commands.init();
}
// The browser may have tabs already open. We inject the content scripts and Vimium's CSS
// immediately so that the extension is running on the pages immediately after install, rather than
// having to reload those pages.
chrome.runtime.onInstalled.addListener(async (details) => {
Utils.debugLog("chrome.runtime.onInstalled");
// NOTE(philc): In my testing, when the onInstalled event occurs, the onStartup event does not
// also occur, so we need to initialize Vimium here.
await initializeExtension();
const shouldInjectContentScripts =
// NOTE(philc): 2023-06-16: we do not install the content scripts in all tabs on Firefox.
// I believe this is because Firefox does this already. See https://stackoverflow.com/a/37132144
// for commentary.
!bgUtils.isFirefox() &&
(["chrome_update", "shared_module_update"].includes(details.reason));
if (shouldInjectContentScripts) injectContentScriptsAndCSSIntoExistingTabs();
await showUpgradeMessageIfNecessary(details);
});
// Note that this event is not fired when an incognito profile is started.
chrome.runtime.onStartup.addListener(async () => {
Utils.debugLog("chrome.runtime.onStartup");
await initializeExtension();
});
Object.assign(globalThis, {
TabOperations,
// Exported for tests:
HintCoordinator,
BackgroundCommands,
majorVersionHasIncreased,
nextZoomLevel,
});
// The chrome.runtime.onStartup and onInstalled events are not fired when disabling and then
// re-enabling the extension in developer mode, so we also initialize the extension here.
initializeExtension();
================================================
FILE: background_scripts/marks.js
================================================
import * as TabOperations from "./tab_operations.js";
// This returns the key which is used for storing mark locations in chrome.storage.sync.
// Exported for tests.
export function getLocationKey(markName) {
return `vimiumGlobalMark|${markName}`;
}
// Get the part of a URL we use for matching here (that is, everything up to the first anchor).
function getBaseUrl(url) {
return url.split("#")[0];
}
// Create a global mark. We record vimiumSecret with the mark so that we can tell later, when the
// mark is used, whether this is the original Vimium session or a subsequent session. This affects
// whether or not tabId can be considered valid.
export async function create(req, sender) {
const items = await chrome.storage.session.get("vimiumSecret");
const markInfo = {
vimiumSecret: items.vimiumSecret,
markName: req.markName,
url: getBaseUrl(sender.tab.url),
tabId: sender.tab.id,
scrollX: req.scrollX,
scrollY: req.scrollY,
};
if ((markInfo.scrollX != null) && (markInfo.scrollY != null)) {
saveMark(markInfo);
} else {
// The front-end frame hasn't provided the scroll position (because it's not the top frame
// within its tab). We need to ask the top frame what its scroll position is.
chrome.tabs.sendMessage(sender.tab.id, { handler: "getScrollPosition" }, (response) => {
saveMark(Object.assign(markInfo, { scrollX: response.scrollX, scrollY: response.scrollY }));
});
}
}
function saveMark(markInfo) {
const item = {};
item[getLocationKey(markInfo.markName)] = markInfo;
chrome.storage.local.set(item);
}
// Goto a global mark. We try to find the original tab. If we can't find that, then we try to find
// another tab with the original URL, and use that. And if we can't find such an existing tab, then
// we create a new one. Whichever of those we do, we then set the scroll position to the original
// scroll position.
export async function goto(req) {
const vimiumSecret = (await chrome.storage.session.get("vimiumSecret"))["vimiumSecret"];
const key = getLocationKey(req.markName);
const items = await chrome.storage.local.get(key);
const markInfo = items[key];
if (markInfo.vimiumSecret !== vimiumSecret) {
// This is a different Vimium instantiation, so markInfo.tabId is definitely out of date.
Utils.debugLog("marks: vimiumSecret is incorrect.");
await focusOrLaunch(markInfo, req);
} else {
// Check whether markInfo.tabId still exists. According to
// https://developer.chrome.com/extensions/tabs, tab Ids are unqiue within a Chrome
// session. So, if we find a match, we can use it.
let tab;
// This will throw an error if the tab doesn't exist.
try {
tab = await chrome.tabs.get(markInfo.tabId);
} catch {
// Swallow.
}
const originalTabStillExists = tab?.url && (markInfo.url === getBaseUrl(tab.url));
if (originalTabStillExists) {
await gotoPositionInTab(markInfo);
} else {
await focusOrLaunch(markInfo, req);
}
}
}
// Focus an existing tab and scroll to the given position within it.
async function gotoPositionInTab({ tabId, scrollX, scrollY }) {
const tab = await chrome.tabs.update(tabId, { active: true });
chrome.windows.update(tab.windowId, { focused: true });
chrome.tabs.sendMessage(tabId, { handler: "setScrollPosition", scrollX, scrollY });
}
// The tab we're trying to find no longer exists. We either find another tab with a matching URL and
// use it, or we create a new tab.
async function focusOrLaunch(markInfo, req) {
// If we're not going to be scrolling to a particular position in the tab, then we choose all tabs
// with a matching URL prefix. Otherwise, we require an exact match (because it doesn't make sense
// to scroll unless there's an exact URL match).
const markIsScrolled = markInfo.scrollX > 0 || markInfo.scrollY > 0;
const query = markIsScrolled ? markInfo.url : `${markInfo.url}*`;
const tabs = await chrome.tabs.query({ url: query });
if (tabs.length > 0) {
// There is at least one matching tab. Pick one and go to it.
const tab = await pickTab(tabs);
gotoPositionInTab(Object.assign(markInfo, { tabId: tab.id }));
} else {
// There is no existing matching tab. We'll have to create one.
TabOperations.openUrlInNewTab(
Object.assign(req, { url: getBaseUrl(markInfo.url) }),
(tab) => {
// Note. tabLoadedHandlers is defined in "main.js". The handler below will be called when
// the tab is loaded, its DOM is ready and it registers with the background page.
return tabLoadedHandlers[tab.id] = () =>
gotoPositionInTab(Object.assign(markInfo, { tabId: tab.id }));
},
);
}
}
// Given a list of tabs candidate tabs, pick one. Prefer tabs in the current window and tabs with
// shorter (matching) URLs.
async function pickTab(tabs) {
// NOTE(philc): We assume getCurrent() can return null, but I didn't confirm this. Also, it should
// be impossible for the user to invoke Vimium-related keys if all windows are closed.
const window = await chrome.windows.getCurrent();
const windowId = window?.id;
// Prefer tabs in the current window, if there are any.
const tabsInWindow = tabs.filter((tab) => tab.windowId === windowId);
if (tabsInWindow.length > 0) tabs = tabsInWindow;
// If more than one tab remains and the current tab is still a candidate, then don't pick the
// current tab (because jumping to it does nothing).
if (tabs.length > 1) {
tabs = tabs.filter((t) => !t.active);
}
// Prefer shorter URLs.
tabs.sort((a, b) => a.url.length - b.url.length);
return tabs[0];
}
================================================
FILE: background_scripts/reload.js
================================================
// Used as part of a debugging workflow when developing the extension.
const tabs = await chrome.tabs.query({});
// Clear the background page's console log, if its console window is open.
console.clear();
await chrome.runtime.reload();
// Chrome does not execute past this point. This is for Firefox-based browsers. Note that Chrome
// will not reload every tab that Vimium was open in. That must be done outside of Vimium, e.g. via
// an Applescript on Mac.
// Firefox will reload every tab as a result of chrome.runtime.reload(). However, the console
// on those pages does not get cleared for some reason, so we manually clear it.
for (const tab of tabs) {
chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => {
console.clear();
},
});
}
// We want to close the reload.html page as part of reloading the extension. In both Chrome and
// Firefox, the browser will automatically close every tab that's specific to this extension,
// including this page. However, in Firefox, if there's an error in manifest.json and the extension
// can't reload, then the extension's pages will not get closed, so close this page manually.
// globalThis.close();
================================================
FILE: background_scripts/tab_operations.js
================================================
//
// Functions for opening URLs in tabs.
//
import * as bgUtils from "../background_scripts/bg_utils.js";
import "../lib/url_utils.js";
// Opens request.url in the current tab. If the URL is keywords, search for them in the default
// search engine. If the URL is a javascript: snippet, execute it in the current tab.
export async function openUrlInCurrentTab(request) {
const urlStr = await UrlUtils.convertToUrl(request.url);
if (urlStr == null) {
// The requested destination is not a URL, so treat it like a search query.
chrome.search.query({ text: request.url });
} else if (UrlUtils.hasJavascriptProtocol(urlStr)) {
// Note that when injecting JavaScript, it's subject to the site's CSP. Sites with strict CSPs
// (like github.com, developer.mozilla.org) will raise an error when we try to run this code.
// See https://github.com/philc/vimium/issues/4331.
const scriptingArgs = {
target: { tabId: request.tabId },
func: (text) => {
const prefix = "javascript:";
text = text.slice(prefix.length).trim();
// TODO(philc): Why do we try to double decode here? Discover and then document it.
text = decodeURIComponent(text);
try {
text = decodeURIComponent(text);
} catch {
// Swallow
}
const el = document.createElement("script");
el.textContent = text;
document.head.appendChild(el);
},
args: [urlStr],
};
if (!bgUtils.isFirefox()) {
// The MAIN world -- where the webpage runs -- is less privileged than the ISOLATED world.
// Specifying a world is required for Chrome, but not Firefox.
// As of Firefox 118, specifying "MAIN" as the world is not yet supported.
scriptingArgs.world = "MAIN";
}
chrome.scripting.executeScript(scriptingArgs);
} else {
// The requested destination is a regular URL.
chrome.tabs.update(request.tabId, { url: urlStr });
}
}
// Opens request.url in new tab and switches to it.
// Returns the created tab.
export async function openUrlInNewTab(request) {
const urlStr = await UrlUtils.convertToUrl(request.url);
const tabConfig = { windowId: request.tab.windowId };
const position = request.position;
let tabIndex = null;
switch (position) {
case "start":
tabIndex = 0;
break;
case "before":
tabIndex = request.tab.index;
break;
// if on Chrome or on Firefox but without openerTabId, `tabs.create` opens a tab at the end.
// but on Firefox and with openerTabId, it opens a new tab next to the opener tab
case "end":
tabIndex = bgUtils.isFirefox() ? 9999 : null;
break;
// "after" is the default case when there are no options.
default:
tabIndex = request.tab.index + 1;
}
tabConfig.index = tabIndex;
tabConfig.active = request.active ?? true;
tabConfig.openerTabId = request.tab.id;
let newTab;
if (urlStr == null) {
// The requested destination is not a URL, so treat it like a search query.
//
// The chrome.search.query API lets us open the search in a new tab, but it doesn't let us
// control the precise position of that tab. So, we open a new blank tab using our position
// parameter, and then execute the search in that tab.
// In Chrome, if we create a blank tab and call chrome.search.query, the omnibar is focused,
// which we don't want. To work around that, first create an empty page. This is not needed in
// Firefox. And in fact, firefox doesn't support a data:text URL to the chrome.tab.create API.
tabConfig.url = bgUtils.isFirefox() ? null : "data:text/html,";
newTab = await chrome.tabs.create(tabConfig);
const query = request.url;
await chrome.search.query({ text: query, tabId: newTab.id });
} else {
// The requested destination is a regular URL.
// Firefox does not support "about:newtab" in chrome.tabs.create, so omit it.
if (urlStr != UrlUtils.chromeNewTabUrl) {
tabConfig.url = urlStr;
}
newTab = await chrome.tabs.create(tabConfig);
}
return newTab;
}
// Open request.url in new window and switch to it.
export async function openUrlInNewWindow(request) {
const winConfig = {
url: await UrlUtils.convertToUrl(request.url),
active: true,
};
if (request.active != null) {
winConfig.active = request.active;
}
// Firefox does not support "about:newtab" in chrome.tabs.create, so omit it.
if (tabConfig["url"] === UrlUtils.chromeNewTabUrl) {
delete winConfig["url"];
}
await chrome.windows.create(winConfig);
}
================================================
FILE: background_scripts/tab_recency.js
================================================
// TabRecency associates an integer with each tab id representing how recently it has been accessed.
// The order of tabs as tracked by TabRecency is used to provide a recency-based ordering in the
// tabs vomnibar.
//
// The values are persisted to chrome.storage.session so that they're not lost when the extension's
// background page is unloaded.
//
// Callers must await TabRecency.init before calling recencyScore or getTabsByRecency.
//
// In theory, the browser's tab.lastAccessed timestamp field should allow us to sort tabs by
// recency, but in practice it does not work across several edge cases. See the comments on #4368.
class TabRecency {
constructor() {
this.counter = 1;
this.tabIdToCounter = {};
this.loaded = false;
this.queuedActions = [];
}
// Add listeners to chrome.tabs, and load the index from session storage.
async init() {
if (this.initPromise) {
await this.initPromise;
return;
}
let resolveFn;
this.initPromise = new Promise((resolve, _reject) => {
resolveFn = resolve;
});
chrome.tabs.onActivated.addListener((activeInfo) => {
this.queueAction("register", activeInfo.tabId);
});
chrome.tabs.onRemoved.addListener((tabId) => {
this.queueAction("deregister", tabId);
});
chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => {
this.queueAction("deregister", removedTabId);
this.queueAction("register", addedTabId);
});
chrome.windows.onFocusChanged.addListener(async (windowId) => {
if (windowId == chrome.windows.WINDOW_ID_NONE) return;
const tabs = await chrome.tabs.query({ windowId, active: true });
if (tabs[0]) {
this.queueAction("register", tabs[0].id);
}
});
await this.loadFromStorage();
while (this.queuedActions.length > 0) {
const [action, tabId] = this.queuedActions.shift();
this.handleAction(action, tabId);
}
this.loaded = true;
resolveFn();
}
// Loads the index from session storage.
async loadFromStorage() {
const tabsPromise = chrome.tabs.query({});
const storagePromise = chrome.storage.session.get("tabRecency");
const [tabs, storage] = await Promise.all([tabsPromise, storagePromise]);
if (storage.tabRecency == null) return;
let maxCounter = 0;
for (const counter of Object.values(storage.tabRecency)) {
if (maxCounter < counter) maxCounter = counter;
}
if (this.counter < maxCounter) {
this.counter = maxCounter;
}
this.tabIdToCounter = Object.assign({}, storage.tabRecency);
// Remove any tab IDs which aren't currently loaded.
const tabIds = new Set(tabs.map((t) => t.id));
for (const id in this.tabIdToCounter) {
if (!tabIds.has(parseInt(id))) {
delete this.tabIdToCounter[id];
}
}
}
async saveToStorage() {
await chrome.storage.session.set({ tabRecency: this.tabIdToCounter });
}
// - action: "register" or "unregister".
queueAction(action, tabId) {
if (!this.loaded) {
this.queuedActions.push([action, tabId]);
} else {
this.handleAction(action, tabId);
}
}
// - action: "register" or "unregister".
handleAction(action, tabId) {
if (action == "register") {
this.register(tabId);
} else if (action == "deregister") {
this.deregister(tabId);
} else {
throw new Error(`Unexpected action type: ${action}`);
}
}
register(tabId) {
this.counter++;
this.tabIdToCounter[tabId] = this.counter;
this.saveToStorage();
}
deregister(tabId) {
delete this.tabIdToCounter[tabId];
this.saveToStorage();
}
// Recently-visited tabs get a higher score (except the current tab, which gets a low score).
recencyScore(tabId) {
if (!this.loaded) throw new Error("TabRecency hasn't yet been loaded.");
const tabCounter = this.tabIdToCounter[tabId];
const isCurrentTab = tabCounter == this.counter;
if (isCurrentTab) return 0;
return (tabCounter ?? 1) / this.counter; // tabCounter may be null.
}
// Returns a list of tab Ids sorted by recency, most recent tab first.
getTabsByRecency() {
if (!this.loaded) throw new Error("TabRecency hasn't yet been loaded.");
const ids = Object.keys(this.tabIdToCounter);
ids.sort((a, b) => this.tabIdToCounter[b] - this.tabIdToCounter[a]);
return ids.map((id) => parseInt(id));
}
}
export { TabRecency };
================================================
FILE: background_scripts/user_search_engines.js
================================================
import "../lib/url_utils.js";
import * as commands from "./commands.js";
// A struct representing a search engine entry in the "searchEngine" setting.
export class UserSearchEngine {
keyword;
url;
description;
constructor(o) {
Object.seal(this);
if (o) Object.assign(this, o);
}
}
// Parses a user's search engine configuration from Settings, and stores the parsed results.
// TODO(philc): Should this be responsible for updating itself when Settings changes, rather than
// the callers doing so? Or, remove this class and re-parse the configuration every keystroke in
// Vomnibar, so we don't introduce another layer of caching in the code.
export let keywordToEngine = {};
// Returns a result of the shape: { keywordToEngine, validationErrors }.
export function parseConfig(configText) {
const results = {};
const errors = [];
for (const line of commands.parseLines(configText)) {
const tokens = line.split(/\s+/);
if (tokens.length < 2) {
errors.push(`This line has less than two tokens: ${line}`);
continue;
}
if (!tokens[0].includes(":")) {
errors.push(`This line doesn't include a ":" character: ${line}`);
continue;
}
const keyword = tokens[0].split(":")[0];
const url = tokens[1];
const description = tokens.length > 2 ? tokens.slice(2).join(" ") : `search (${keyword})`;
if (!UrlUtils.urlHasProtocol(url) && !UrlUtils.hasJavascriptProtocol(url)) {
errors.push(`This search engine doesn't have a valid URL: ${line}`);
continue;
}
results[keyword] = new UserSearchEngine({ keyword, url, description });
}
return {
keywordToEngine: results,
validationErrors: errors,
};
}
export function set(searchEnginesConfigText) {
keywordToEngine = parseConfig(searchEnginesConfigText).keywordToEngine;
}
================================================
FILE: build_scripts/write_command_listing_page.js
================================================
#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env
// Write a static version of the command_listing.html page to dist, to be hosted on vimium.github.io
// as an online reference.
import * as testHelper from "../tests/unit_tests/test_helper.js";
import "../tests/unit_tests/test_chrome_stubs.js";
import * as commandListing from "../pages/command_listing.js";
import * as fs from "@std/fs";
import * as path from "@std/path";
const scriptDir = path.dirname(path.fromFileUrl(import.meta.url));
chrome.storage.session.get = async (key) => {
if (key == "commandToOptionsToKeys") {
return { commandToOptionsToKeys: {} };
}
};
await testHelper.jsdomStub(path.join(scriptDir, "../pages/command_listing.html"));
await Settings.onLoaded();
await commandListing.populatePage();
const dist = path.join(scriptDir, "../dist/command_listing_page");
if (await fs.exists(dist)) {
await Deno.remove(dist, { recursive: true });
}
await Deno.mkdir(dist, { recursive: true });
// Write out all required CSS files to disk.
const linkEls = document.head.querySelectorAll("link[rel=stylesheet]");
for (const el of linkEls) {
const cssPath = el.getAttribute("href");
const src = path.join(scriptDir, "../pages/" + cssPath);
const dest = path.join(dist, path.basename(cssPath));
await Deno.copyFile(src, dest);
el.setAttribute("href", path.basename(cssPath));
}
// Remove any external javascripts. Since this page's HTML has already been generated, it doesn't
// need JS at runtime.
for (const el of document.head.querySelectorAll("script")) {
el.remove();
}
// Indicate that this is the hosted version of the page. This causes a link back to the
// Github repo to be shown.
document.querySelector("html").classList.add("hosted-version");
// Use the website's favicon.
const favicon = document.createElement("link");
favicon.setAttribute("rel", "shortcut icon");
favicon.href = "../vimium_logo.svg";
document.head.appendChild(favicon);
// The doctype tag is not included in outerHTML; add it back in.
const html = "" + document.documentElement.outerHTML;
await Deno.writeTextFile(path.join(dist, "index.html"), html);
================================================
FILE: content_scripts/file_urls.css
================================================
/* Chrome file:// URLs set draggable=true for links to files (CSS selector .icon.file). This
* automatically sets -webkit-user-select: none, which disables selecting the file names and so
* prevents Vimium's search from working as expected. Here, we reset the value back to default. */
.icon.file {
-webkit-user-select: auto !important;
}
================================================
FILE: content_scripts/hud.js
================================================
//
// A heads-up-display (HUD) for showing Vimium page operations.
// Note: you cannot interact with the HUD until document.body is available.
//
const HUD = {
tween: null,
hudUI: null,
findMode: null,
abandon() {
if (this.hudUI) {
this.hudUI.hide(false);
}
},
// Set by @pasteFromClipboard to handle the value returned by pasteResponse
pasteListener: null,
// This HUD is styled to precisely mimick the chrome HUD on Mac. Use the
// "has_popup_and_link_hud.html" test harness to tweak these styles to match Chrome's. One
// limitation of our HUD display is that it doesn't sit on top of horizontal scrollbars like
// Chrome's HUD does.
handleUIComponentMessage({ data }) {
const handlers = {
hideFindMode: this.hideFindMode,
search: this.search,
unfocusIfFocused: this.unfocusIfFocused,
pasteResponse: this.pasteResponse,
showClipboardUnavailableMessage: this.showClipboardUnavailableMessage,
};
const handler = handlers[data.name];
if (handler) {
return handler.bind(this)(data);
}
},
async init(focusable) {
await Settings.onLoaded();
if (focusable == null) {
focusable = true;
}
if (this.hudUI == null) {
const queryString = globalThis.vimiumDomTestsAreRunning ? "?dom_tests=true" : "";
this.hudUI = new UIComponent();
this.hudUI.load(
`pages/hud_page.html${queryString}`,
"vimium-hud-frame",
this.handleUIComponentMessage.bind(this),
);
}
// this[data.name]? data
if (this.tween == null) {
this.tween = new Tween(
"iframe.vimium-hud-frame.vimium-ui-component-visible",
this.hudUI.shadowDOM,
);
}
const classList = this.hudUI.iframeElement.classList;
if (focusable) {
classList.remove("vimium-non-clickable");
classList.add("vimium-clickable");
// Note(gdh1995): Chrome 74 only acknowledges text selection when a frame has been visible.
// See more in #3277.
// Note(mrmr1993): Show the HUD frame, so Firefox will actually perform the paste.
this.hudUI.setIframeVisible(true);
// Force the re-computation of styles, so Chrome sends a visibility change message to the
// child frame. See https://github.com/philc/vimium/pull/3277#issuecomment-487363284
getComputedStyle(this.hudUI.iframeElement).display;
} else {
classList.remove("vimium-non-clickable");
classList.add("vimium-clickable");
}
},
// duration - if omitted, the message will show until dismissed.
async show(text, duration) {
await DomUtils.documentComplete();
clearTimeout(this._showForDurationTimerId);
// @hudUI.activate will take charge of making it visible
await this.init(false);
this.hudUI.show({ name: "show", text });
this.tween.fade(1.0, 150);
if (duration != null) {
this._showForDurationTimerId = setTimeout(() => this.hide(), duration);
}
},
async showFindMode(findMode = null) {
this.findMode = findMode;
await DomUtils.documentComplete();
await this.init();
this.hudUI.show({ name: "showFindMode" });
this.tween.fade(1.0, 150);
},
search(data) {
// NOTE(mrmr1993): On Firefox, window.find moves the window focus away from the HUD. We use
// postFindFocus to put it back, so the user can continue typing.
this.findMode.findInPlace(data.query, {
"postFindFocus": this.hudUI.iframeElement.contentWindow,
});
// Show the number of matches in the HUD UI.
const matchCount = FindMode.query.parsedQuery.length > 0 ? FindMode.query.matchCount : 0;
const showMatchText = FindMode.query.rawQuery.length > 0;
this.hudUI.postMessage({ name: "updateMatchesCount", matchCount, showMatchText });
},
// Hide the HUD.
// If :immediate is falsy, then the HUD is faded out smoothly (otherwise it is hidden
// immediately).
// If :updateIndicator is truthy, then we also refresh the mode indicator. The only time we don't
// update the mode indicator, is when hide() is called for the mode indicator itself.
hide(immediate, updateIndicator) {
if (immediate == null) {
immediate = false;
}
if (updateIndicator == null) {
updateIndicator = true;
}
if ((this.hudUI != null) && (this.tween != null)) {
clearTimeout(this._showForDurationTimerId);
this.tween.stop();
if (immediate) {
if (updateIndicator) {
Mode.setIndicator();
} else {
this.hudUI.hide();
}
} else {
this.tween.fade(0, 150, () => this.hide(true, updateIndicator));
}
}
},
// These parameters describe the reason find mode is exiting, and come from the HUD UI component.
hideFindMode({ exitEventIsEnter, exitEventIsEscape }) {
let postExit;
this.findMode.checkReturnToViewPort();
// An element won't receive a focus event if the search landed on it while we were in the HUD
// iframe. To end up with the correct modes active, we create a focus/blur event manually after
// refocusing this window.
globalThis.focus();
const focusNode = DomUtils.getSelectionFocusElement();
if (document.activeElement != null) {
document.activeElement.blur();
}
if (focusNode && focusNode.focus) {
focusNode.focus();
}
if (exitEventIsEnter) {
FindMode.handleEnter();
if (FindMode.query.hasResults) {
postExit = () => newPostFindMode();
}
} else if (exitEventIsEscape) {
// We don't want FindMode to handle the click events that FindMode.handleEscape can generate,
// so we wait until the mode is closed before running it.
postExit = FindMode.handleEscape;
}
this.findMode.exit();
if (postExit) {
postExit();
}
},
// These commands manage copying and pasting from the clipboard in the HUD frame.
// NOTE(mrmr1993): We need this to copy and paste on Firefox:
// * an element can't be focused in the background page, so copying/pasting doesn't work
// * we don't want to disrupt the focus in the page, in case the page is listening for focus/blur
// * events.
// * the HUD shouldn't be active for this frame while any of the copy/paste commands are running.
async copyToClipboard(text) {
await DomUtils.documentComplete();
await this.init();
this.hudUI.postMessage({ name: "copyToClipboard", data: text });
},
async pasteFromClipboard(pasteListener) {
this.pasteListener = pasteListener;
await DomUtils.documentComplete();
await this.init();
this.tween.fade(0, 0);
this.hudUI.postMessage({ name: "pasteFromClipboard" });
},
pasteResponse({ data }) {
// Hide the HUD frame again.
this.hudUI.setIframeVisible(false);
this.unfocusIfFocused();
this.pasteListener(data);
},
unfocusIfFocused() {
// On Firefox, if an
Engine name
Example:
Regular expressions
================================================
FILE: pages/doc_search_completion.js
================================================
import "./all_content_scripts.js";
import * as completionEngines from "../background_scripts/completion/search_engines.js";
function cleanUpRegexp(re) {
return re.toString()
.replace(/^\//, "")
.replace(/\/$/, "")
.replace(/\\\//g, "/");
}
export function populatePage() {
const template = document.querySelector("#engine-template").content;
for (const engineClass of completionEngines.list) {
const el = template.cloneNode(true);
const engine = new engineClass();
const h4 = el.querySelector("h4");
h4.textContent = engine.constructor.name;
// This data attribute is used in tests.
h4.dataset.engine = engine.constructor.name;
const explanationEl = el.querySelector(".explanation");
if (engine.example.explanation) {
explanationEl.textContent = engine.example.explanation;
} else {
explanationEl.remove();
}
const exampleEl = el.querySelector(".engine-example");
if (engine.example.searchUrl && engine.example.keyword) {
const desc = engine.example.description || engine.constructor.name;
exampleEl.querySelector("pre").textContent =
`${engine.example.keyword}: ${engine.example.searchUrl} ${desc}`;
} else {
exampleEl.remove();
}
const regexpsEl = el.querySelector(".regexps");
if (engine.regexps) {
let content = "";
for (const re of engine.regexps) {
content += `${cleanUpRegexp(re)}\n`;
}
regexpsEl.querySelector("pre").textContent = content;
} else {
regexpsEl.remove();
}
document.querySelector("#engine-list").appendChild(el);
}
}
const testEnv = globalThis.window == null;
if (!testEnv) {
document.addEventListener("DOMContentLoaded", populatePage);
}
================================================
FILE: pages/exclusion_rules_editor.js
================================================
// The table-editor used for exclusion rules.
const ExclusionRulesEditor = {
// When the Add rule button is clicked, use this as the pattern for the new rule. This is used by
// the action.html toolbar popup.
defaultPatternForNewRules: null,
init() {
document.querySelector("#exclusion-add-button").addEventListener("click", () => {
this.addRow(this.defaultPatternForNewRules);
this.dispatchEvent("input");
});
},
// - exclusionRules: the value obtained from settings, with the shape [{pattern, passKeys}].
setForm(exclusionRules = []) {
const rulesTable = document.querySelector("#exclusion-rules");
// Remove any previous rows.
const existingRuleEls = rulesTable.querySelectorAll(".rule");
for (const el of existingRuleEls) {
el.remove();
}
const rowTemplate = document.querySelector("#exclusion-rule-template").content;
for (const rule of exclusionRules) {
this.addRow(rule.pattern, rule.passKeys);
}
},
// `pattern` and `passKeys` are optional.
addRow(pattern, passKeys) {
const rulesTable = document.querySelector("#exclusion-rules");
const rowTemplate = document.querySelector("#exclusion-rule-template").content;
const rowEl = rowTemplate.cloneNode(true);
const patternEl = rowEl.querySelector("[name=pattern]");
patternEl.value = pattern ?? "";
patternEl.addEventListener("input", () => this.dispatchEvent("input"));
const keysEl = rowEl.querySelector("[name=passKeys]");
keysEl.value = passKeys ?? "";
keysEl.addEventListener("input", () => this.dispatchEvent("input"));
rowEl.querySelector(".remove").addEventListener("click", (e) => {
e.target.closest("tr").remove();
this.dispatchEvent("input");
});
rulesTable.appendChild(rowEl);
},
// Returns an array of rules, which can be stored in Settings.
getRules() {
const rows = Array.from(document.querySelectorAll("#exclusion-rules tr.rule"));
const rules = rows
.map((el) => {
return {
// The ordering of these keys should match the order in defaultOptions in Settings.js.
passKeys: el.querySelector("[name=passKeys]").value.trim(),
pattern: el.querySelector("[name=pattern]").value.trim(),
};
})
// Exclude blank patterns.
.filter((rule) => rule.pattern);
return rules;
},
};
Object.assign(ExclusionRulesEditor, EventDispatcher);
export { ExclusionRulesEditor };
================================================
FILE: pages/help_dialog_page.css
================================================
body {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
#container {
background-color: white;
border: 2px solid #b3b3b3;
border-radius: 6px;
width: 840px;
max-width: calc(100% - 100px);
max-height: calc(100% - 100px);
margin: 50px auto;
overflow-y: auto;
overflow-x: auto;
}
#dialog {
min-width: 600px;
padding: 8px 12px;
}
a {
text-decoration: underline;
color: #2f508e;
cursor: pointer;
}
header {
display: flex;
flex-direction: row;
align-items: center;
}
a#close {
font-family: "courier new", monospace;
font-weight: bold;
color: #555;
text-decoration: none;
font-size: 24px;
position: relative;
top: 3px;
padding-left: 5px;
cursor: pointer;
}
a#close:hover {
color: black;
-webkit-user-select: none;
}
h1 {
font-size: 20px;
white-space: nowrap;
flex-grow: 1;
font-weight: normal;
margin: 4px 0;
}
h1 .vim {
color: #2f508e;
}
header a {
font-size: 14px;
padding-left: 5px;
padding-right: 5px;
}
header a.close {
padding-right: 0;
}
#commands-section {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.column {
display: grid;
grid-template-columns: auto 1fr;
row-gap: 3px;
}
h2 {
margin-top: 3px;
margin-bottom: 4px;
font-size: 16px;
font-weight: bold;
}
div[data-group] {
display: contents;
}
.row {
display: contents;
}
.help-description {
font-size: 14px;
}
div.divider {
height: 1px;
width: 100%;
margin: 10px auto;
background-color: #9a9a9a;
}
/* Advanced commands are hidden by default until "show advanced" is clicked. */
.row.advanced {
display: none;
}
#dialog.show-advanced .row.advanced {
display: contents;
}
footer {
font-size: 10px;
display: flex;
justify-content: space-between;
}
.version-info {
text-align: right;
}
#toggle-advanced {
text-align: right;
font-size: 10px;
}
/* Dark Mode CSS for Help Dialog */
@media (prefers-color-scheme: dark) {
#container {
border-color: rgba(255, 255, 255, 0.1);
background-color: #202124;
}
#dialog {
background-color: var(--vimium-background-color);
color: var(--vimium-background-text-color);
}
a {
color: var(--vimium-link-color);
}
h1,
h2 {
color: white;
}
h1 .vim {
color: var(--vimium-link-color);
}
div.divider {
background-color: rgba(255, 255, 255, 0.1);
}
.help-description {
/* Use a fainter color than --vimium-background-text-color, so the dialog text doesn't get
overwhelming. */
color: #c9cccf;
}
}
================================================
FILE: pages/help_dialog_page.html
================================================
Vimium Help
================================================
FILE: pages/help_dialog_page.js
================================================
import "./all_content_scripts.js";
import * as UIComponentMessenger from "./ui_component_messenger.js";
import { allCommands } from "../background_scripts/all_commands.js";
// The ordering we show key bindings is alphanumerical, except that special keys sort to the end.
function compareKeys(a, b) {
a = a.replace("<", "~");
b = b.replace("<", "~");
if (a < b) {
return -1;
} else if (b < a) {
return 1;
} else {
return 0;
}
}
const ellipsis = "...";
// Truncates `s` and appends an ellipsis if `s` is longer than maxLength.
function ellipsize(s, maxLength) {
if (s.length <= maxLength) return s;
return s.substring(0, Math.max(0, maxLength - ellipsis.length)) + ellipsis;
}
// Returns true if the command should be labeled as "advanced" for UI purposes.
function isAdvancedCommand(command, options) {
// Use some bespoke logic to label some command + option combos as advanced.
return command.advanced ||
(command.name == "reload" && options.includes("hard"));
}
const HelpDialogPage = {
dialogElement: null,
// This setting is pulled out of local storage. It's false by default.
getShowAdvancedCommands() {
return Settings.get("helpDialog_showAdvancedCommands");
},
init() {
if (this.dialogElement != null) {
return;
}
this.dialogElement = document.querySelector("#dialog");
const closeButton = this.dialogElement.querySelector("#close");
closeButton.addEventListener("click", (event) => {
event.preventDefault();
this.hide();
}, false);
// "auxclick" handles a click with the middle mouse button.
const optionsLink = document.querySelector("#options-page");
for (const eventName of ["click", "auxclick"]) {
optionsLink.addEventListener(eventName, (event) => {
event.preventDefault();
chrome.runtime.sendMessage({ handler: "openOptionsPageInNewTab" });
}, false);
}
document.querySelector("#toggle-advanced a").addEventListener(
"click",
HelpDialogPage.toggleAdvancedCommands.bind(HelpDialogPage),
false,
);
document.documentElement.addEventListener("click", (event) => {
if (!this.dialogElement.contains(event.target)) {
this.hide();
}
}, false);
},
// Returns the rows to show in the help dialog, grouped by command group.
// Returns: { group: [[command, args, keys], ...], ... }
getRowsForDialog(commandToOptionsToKeys) {
const result = {};
const byGroup = Object.groupBy(allCommands, (o) => o.group);
for (const [group, commands] of Object.entries(byGroup)) {
const list = [];
for (const command of commands) {
// Note that commands which are unbound won't be present in this data structure, and that's
// desired; we don't want to show unbound commands in the help dialog.
const variations = commandToOptionsToKeys[command.name] || {};
for (const [options, keys] of Object.entries(variations)) {
list.push([command, options, keys]);
}
}
result[group] = list;
}
return result;
},
getRowEl(command, options, keys) {
const rowTemplate = document.querySelector("template#row").content;
const keysTemplate = document.querySelector("template#keys").content;
const rowEl = rowTemplate.cloneNode(true);
rowEl.querySelector(".help-description").textContent = command.desc;
if (isAdvancedCommand(command, options)) {
rowEl.querySelector(".row").classList.add("advanced");
}
const keysEl = rowEl.querySelector(".key-bindings");
for (const key of keys.sort(compareKeys)) {
const node = keysTemplate.cloneNode(true);
node.querySelector(".key").textContent = key;
keysEl.appendChild(node);
}
const maxLength = 40;
const descEl = rowEl.querySelector(".help-description");
let desc = command.desc;
if (options != "") {
const optionsString = ellipsize(options, maxLength - command.desc.length);
desc += ` (${optionsString})`;
const isTruncated = optionsString != options;
if (isTruncated) {
// Show the full option string on hover.
descEl.title = `${command.desc} (${options})`;
}
}
descEl.textContent = desc;
return rowEl;
},
async show() {
document.getElementById("vimium-version").textContent = Utils.getCurrentVersion();
const commandToOptionsToKeys =
(await chrome.storage.session.get("commandToOptionsToKeys")).commandToOptionsToKeys;
const rowsByGroup = this.getRowsForDialog(commandToOptionsToKeys);
for (const [group, rows] of Object.entries(rowsByGroup)) {
const container = this.dialogElement.querySelector(`[data-group="${group}"]`);
container.innerHTML = "";
for (const [command, options, keys] of rows) {
const el = this.getRowEl(command, options, keys);
container.appendChild(el);
}
}
this.showAdvancedCommands(this.getShowAdvancedCommands());
// "Click" the dialog element (so that it becomes scrollable).
DomUtils.simulateClick(this.dialogElement);
},
hide() {
UIComponentMessenger.postMessage({ name: "hide" });
},
//
// Advanced commands are hidden by default so they don't overwhelm new and casual users.
//
toggleAdvancedCommands(event) {
const container = document.querySelector("#container");
const scrollHeightBefore = container.scrollHeight;
event.preventDefault();
const showAdvanced = HelpDialogPage.getShowAdvancedCommands();
HelpDialogPage.showAdvancedCommands(!showAdvanced);
Settings.set("helpDialog_showAdvancedCommands", !showAdvanced);
// Try to keep the "show advanced commands" button in the same scroll position.
const scrollHeightDelta = container.scrollHeight - scrollHeightBefore;
if (scrollHeightDelta > 0) {
container.scrollTop += scrollHeightDelta;
}
},
showAdvancedCommands(visible) {
const caption = visible ? "Hide advanced commands" : "Show advanced commands";
document.querySelector("#toggle-advanced a").textContent = caption;
if (visible) {
HelpDialogPage.dialogElement.classList.add("show-advanced");
} else {
HelpDialogPage.dialogElement.classList.remove("show-advanced");
}
},
};
function init() {
UIComponentMessenger.init();
UIComponentMessenger.registerHandler(async function (event) {
await Settings.onLoaded();
await Utils.populateBrowserInfo();
switch (event.data.name) {
case "hide":
HelpDialogPage.hide();
break;
case "show":
HelpDialogPage.init();
await HelpDialogPage.show(event.data);
// If we abandoned (see below) in a mode with a HUD indicator, then we have to reinstate it.
Mode.setIndicator();
break;
case "hidden":
// Abandon any HUD which might be showing within the help dialog.
HUD.abandon();
break;
default:
Utils.assert(false, "Unrecognized message type.", event.data);
}
});
}
globalThis.HelpDialogPage = HelpDialogPage;
globalThis.isVimiumHelpDialogPage = true;
const testEnv = globalThis.window == null;
if (!testEnv) {
document.addEventListener("DOMContentLoaded", async () => {
await Settings.onLoaded();
DomUtils.injectUserCss(); // Manually inject custom user styles.
});
init();
}
export { HelpDialogPage };
================================================
FILE: pages/hud_page.css
================================================
#hud-container {
display: block;
position: fixed;
width: calc(100% - 20px);
bottom: 8px;
left: 8px;
background-color: var(--vimium-foreground-color);
color: var(--vimium-foreground-text-color);
text-align: left;
border-radius: 4px;
box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8);
border: 1px solid #aaa;
z-index: 2147483647;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
#search-area {
display: block;
padding: 3px;
color: var(--vimium-foreground-text-color);
border-radius: 4px 4px 0 0;
}
#hud {
font-size: 14px;
height: 30px;
margin-bottom: 0;
padding: 2px 4px;
border-radius: 3px;
width: 100%;
outline: none;
box-sizing: border-box;
line-height: 20px;
}
span#hud-find-input, span#hud-match-count {
display: inline;
outline: none;
white-space: nowrap;
overflow-y: hidden;
}
span#hud-find-input:before {
content: "/";
}
span#hud-match-count {
color: #aaa;
font-size: 12px;
}
span#hud-find-input br {
display: none;
}
span#hud-find-input * {
display: inline;
white-space: nowrap;
}
@media (prefers-color-scheme: light) {
#hud-body {
background-color: #f1f1f1;
}
/* This creates a border around the area where you type, which is nice effect on the light color
* scheme. It's hard to make this effect visible in dark mode, so we don't use it. */
#hud.hud-find {
background-color: white;
border: 1px solid #ccc;
}
}
================================================
FILE: pages/hud_page.html
================================================
HUD
================================================
FILE: pages/hud_page.js
================================================
import "../lib/chrome_api_stubs.js";
import "../lib/utils.js";
import "../lib/dom_utils.js";
import "../lib/settings.js";
import "../lib/keyboard_utils.js";
import "../lib/find_mode_history.js";
import * as UIComponentMessenger from "./ui_component_messenger.js";
let findMode = null;
// Chrome creates a unique port for each MessageChannel, so there's a race condition between
// JavaScript messages of Vimium and browser messages during style recomputation. This duration was
// determined empirically. See https://github.com/philc/vimium/pull/3277#discussion_r283080348
const TIME_TO_WAIT_FOR_IPC_MESSAGES = 17;
// Set the input element's text, and move the cursor to the end.
function setTextInInputElement(inputEl, text) {
inputEl.textContent = text;
// Move the cursor to the end. Based on one of the solutions here:
// http://stackoverflow.com/questions/1125292/how-to-move-cursor-to-end-of-contenteditable-entity
const range = document.createRange();
range.selectNodeContents(inputEl);
range.collapse(false);
const selection = globalThis.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
export function onKeyEvent(event) {
// Handle on "keypress", and other events on "keydown"; this avoids interence with CJK
// translation (see #2915 and #2934).
let rawQuery;
if ((event.type === "keypress") && (event.key !== "Enter")) {
return null;
}
if ((event.type === "keydown") && (event.key === "Enter")) {
return null;
}
const inputEl = document.querySelector("#hud-find-input");
// Don't do anything if we're not in find mode.
if (inputEl == null) return;
if (
(KeyboardUtils.isBackspace(event) && (inputEl.textContent.length === 0)) ||
(event.key === "Enter") || KeyboardUtils.isEscape(event)
) {
inputEl.blur();
UIComponentMessenger.postMessage({
name: "hideFindMode",
exitEventIsEnter: event.key === "Enter",
exitEventIsEscape: KeyboardUtils.isEscape(event),
});
} else if (event.key === "ArrowUp") {
if (rawQuery = FindModeHistory.getQuery(findMode.historyIndex + 1)) {
findMode.historyIndex += 1;
if (findMode.historyIndex === 0) {
findMode.partialQuery = findMode.rawQuery;
}
setTextInInputElement(inputEl, rawQuery);
findMode.executeQuery();
}
} else if (event.key === "ArrowDown") {
findMode.historyIndex = Math.max(-1, findMode.historyIndex - 1);
rawQuery = 0 <= findMode.historyIndex
? FindModeHistory.getQuery(findMode.historyIndex)
: findMode.partialQuery;
setTextInInputElement(inputEl, rawQuery);
findMode.executeQuery();
} else {
return;
}
DomUtils.suppressEvent(event);
return false;
}
// Navigator.clipboard is only available in secure contexts. Show a warning when clipboard actions
// fail on non-HTTPS sites. See #4572.
function ensureClipboardIsAvailable() {
if (!navigator.clipboard) {
UIComponentMessenger.postMessage({ name: "showClipboardUnavailableMessage" });
return false;
}
return true;
}
// Exported for unit tests.
export const handlers = {
show(data) {
const el = document.querySelector("#hud");
el.textContent = data.text;
el.classList.add("vimium-ui-component-visible");
el.classList.remove("vimium-ui-component-hidden");
el.classList.remove("hud-find");
},
hidden() {
const el = document.querySelector("#hud");
// We get a flicker when the HUD later becomes visible again (with new text) unless we reset its
// contents here.
el.textContent = "";
el.classList.add("vimium-ui-component-hidden");
el.classList.remove("vimium-ui-component-visible");
},
showFindMode() {
let executeQuery;
const hudEl = document.querySelector("#hud");
hudEl.classList.add("hud-find");
const inputEl = document.createElement("span");
// NOTE(mrmr1993): Chrome supports non-standard "plaintext-only", which is what we *really*
// want.
try {
inputEl.contentEditable = "plaintext-only";
} catch (error) { // Fallback to standard-compliant version.
inputEl.contentEditable = "true";
}
inputEl.id = "hud-find-input";
hudEl.appendChild(inputEl);
inputEl.addEventListener(
"input",
executeQuery = function (event) {
// On Chrome when IME is on, the order of events is:
// keydown, input.isComposing=true, keydown, input.true, ..., keydown, input.true, compositionend;
// while on Firefox, the order is: keydown, input.true, ..., input.true, keydown, compositionend, input.false.
// Therefore, check event.isComposing here, to avoid window focus changes during typing with
// IME, since such changes will prevent normal typing on Firefox (see #3480)
if (Utils.isFirefox() && event.isComposing) {
return;
}
// Replace \u00A0 ( ) with a normal space.
findMode.rawQuery = inputEl.textContent.replace("\u00A0", " ");
UIComponentMessenger.postMessage({ name: "search", query: findMode.rawQuery });
},
);
const countEl = document.createElement("span");
countEl.id = "hud-match-count";
countEl.style.float = "right";
hudEl.appendChild(countEl);
Utils.setTimeout(TIME_TO_WAIT_FOR_IPC_MESSAGES, function () {
// On Firefox, the page must first be focused before the HUD input element can be focused.
// #3460.
if (Utils.isFirefox()) {
globalThis.focus();
}
inputEl.focus();
});
findMode = {
historyIndex: -1,
partialQuery: "",
rawQuery: "",
executeQuery,
};
},
updateMatchesCount({ matchCount, showMatchText }) {
const countEl = document.querySelector("#hud-match-count");
// Don't do anything if we're not in find mode.
if (countEl == null) return;
if (Utils.isFirefox()) {
document.querySelector("#hud-find-input").focus();
}
const countText = matchCount > 0
? ` (${matchCount} Match${matchCount === 1 ? "" : "es"})`
: " (No matches)";
countEl.textContent = showMatchText ? countText : "";
},
copyToClipboard(message) {
if (!ensureClipboardIsAvailable()) return;
Utils.setTimeout(TIME_TO_WAIT_FOR_IPC_MESSAGES, async function () {
const focusedElement = document.activeElement;
// In Chrome, if we do not focus the current window before invoking navigator.clipboard APIs,
// the error "DOMException: Document is not focused." is thrown.
globalThis.focus();
// Replace nbsp; characters with space. See #2217.
const value = message.data.replace(/\xa0/g, " ");
await navigator.clipboard.writeText(value);
if (focusedElement != null) focusedElement.focus();
globalThis.parent.focus();
UIComponentMessenger.postMessage({ name: "unfocusIfFocused" });
});
},
pasteFromClipboard() {
if (!ensureClipboardIsAvailable()) return;
Utils.setTimeout(TIME_TO_WAIT_FOR_IPC_MESSAGES, async function () {
const focusedElement = document.activeElement;
// In Chrome, if we do not focus the current window before invoking navigator.clipboard APIs,
// the error "DOMException: Document is not focused." is thrown.
globalThis.focus();
let value = await navigator.clipboard.readText();
// Replace nbsp; characters with space. See #2217.
value = value.replace(/\xa0/g, " ");
if (focusedElement != null) focusedElement.focus();
globalThis.parent.focus();
UIComponentMessenger.postMessage({ name: "pasteResponse", data: value });
});
},
};
function init() {
// Manually inject custom user styles.
document.addEventListener("DOMContentLoaded", async () => {
await Settings.onLoaded();
DomUtils.injectUserCss();
});
document.addEventListener("keydown", onKeyEvent);
document.addEventListener("keypress", onKeyEvent);
UIComponentMessenger.init();
UIComponentMessenger.registerHandler(async function (event) {
await Utils.populateBrowserInfo();
const handler = handlers[event.data.name];
Utils.assert(handler != null, "Unrecognized message type.", event.data);
return handler(event.data);
});
FindModeHistory.init();
}
const testEnv = globalThis.window == null;
if (!testEnv) {
init();
}
================================================
FILE: pages/key_mappings.css
================================================
/*
* Styles for showing key bindings. Shared by help_dialog_page.html and command_listing.html.
*/
.key-bindings {
max-width: 110px;
font-size: 14px;
text-align: right;
margin-right: 8px;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: flex-end;
}
/* A "key block" includes a key and a comma separator. */
.key-block {
margin-bottom: 4px;
}
.key {
background-color: rgb(243, 243, 243);
color: rgb(33, 33, 33);
margin-left: 2px;
padding: 2px 6px;
border-radius: 3px;
border: solid 1px #ccc;
border-bottom-color: #bbb;
box-shadow: inset 0 -1px 0 #bbb;
font-family: monospace;
font-size: 11px;
}
.comma {
margin-right: 3px;
}
/* Hide the trailing comma after the last key binding. */
.key-block:last-of-type .comma {
display: none;
}
@media (prefers-color-scheme: dark) {
.key {
/* We're using a color that pops more than --vimium-foreground-color because the squares
representing keys are small and hard to read otherwise. */
background-color: #393a3d;
border: solid 1px #101010;
box-shadow: none;
color: white;
}
}
================================================
FILE: pages/options.css
================================================
/* This stylesheet is included in both options.html and action.html, so changes affect both. */
:root {
--closeButtonWidth: 25px;
--validationErrorColor: #ff5300;
}
body {
font: 14px "DejaVu Sans", "Arial", sans-serif;
color: #303942;
margin: 0;
}
a,
a:visited {
color: #15c;
}
a:active {
color: #052577;
}
div#wrapper,
#footer-content {
max-width: 1050px;
margin: 0 25px;
}
header {
font-size: 18px;
font-weight: normal;
border-bottom: 1px solid #ccc;
padding: 20px 0 15px 0;
width: 100%;
}
button {
-webkit-user-select: none;
-webkit-appearance: none;
font: inherit;
border-width: 1px;
border-radius: 3px;
padding: 2px 10px;
}
input[type="checkbox"] {
-webkit-user-select: none;
margin: 0;
margin-right: 8px;
}
input[type="radio"] {
margin: 0;
margin-left: 0;
margin-right: 8px;
}
.boolean-label {
display: flex;
align-items: center;
gap: 0;
}
pre,
code,
.code {
font-family: Consolas, "Liberation Mono", Courier, monospace;
}
pre {
margin: 5px;
border-left: 1px solid #eee;
padding-left: 5px;
}
input,
textarea {
box-sizing: border-box;
}
textarea {
/* Horizontal resizing breaks the page's layout, so we just allow vertical. */
resize: vertical;
}
h2 {
margin: 12px 0;
font-size: 16px;
font-weight: normal;
}
#settings-grid-container {
display: grid;
font-size: 14px;
grid-template-columns: auto 320px;
/* This is required so the "save changes" panel at the bottom of the options page doesn't cover
* any settings content. */
margin-bottom: 100px;
}
/* Adds a blank line in the grid. */
#settings-grid-container .spacer {
grid-column-start: 1;
grid-column-end: 3;
height: 10px;
}
/* These should span the full width of the grid. */
#settings-grid-container h2,
#settings-grid-container header {
grid-column-start: 1;
grid-column-end: 3;
}
.example {
font-size: 12px;
line-height: 16px;
color: #979ca0;
margin-left: 20px;
}
.reset-link {
margin-left: 0px;
text-align: right;
}
.reset-link a {
text-decoration: none;
}
.validation-message {
color: var(--validationErrorColor);
/* Render newlines in validation messages. When there are multiple errors, they are separated by a
newline. */
white-space: pre-line;
}
/* This longer selector is required to take precedence over our dark scheme textarea colors which
* are defined in vimium.css. */
body.vimium-body textarea.validation-error, body.vimium-body input.validation-error {
border: 2px solid var(--validationErrorColor);
}
div#exampleKeyMapping {
margin-left: 10px;
margin-top: 5px;
}
#new-tab-url-container {
display: grid;
grid-template-columns: auto 100%;
grid-auto-rows: 1.5em;
row-gap: 10px;
align-items: center;
}
input[name="newTabCustomUrl"] {
grid-column-start: 2;
}
#openVomnibarContainer {
display: flex;
align-items: center;
grid-column-start: 2;
}
input[name="newTabCustomUrl"] {
width: 400px;
}
#link-hint-characters-container,
#link-hint-numbers-container,
#wait-for-enter {
display: contents;
}
.link-hint-characters-field {
width: 100%;
/* These text fields look strange when they're excessively long. */
max-width: 400px;
}
.link-hint-characters-field input {
width: 100%;
}
input[name="scrollStepSize"] {
width: 80px;
margin-right: 3px;
padding-left: 3px;
}
textarea[name="userDefinedLinkHintCss"],
textarea[name="keyMappings"],
textarea[name="searchEngines"] {
width: 100%;
min-height: 140px;
white-space: pre;
}
input[name="previousPatterns"],
input[name="nextPatterns"] {
width: 100%;
}
input#searchUrl {
width: 100%;
}
#status {
margin-left: 10px;
font-size: 80%;
}
input[type="text"]:read-only,
input[type="number"]:read-only,
textarea:read-only {
background-color: #eee;
color: #666;
pointer-events: none;
-webkit-user-select: none;
}
input[type="text"],
textarea {
border: 1px solid #bfbfbf;
border-radius: 2px;
color: #444;
background-color: white;
font: inherit;
padding: 3px;
}
button:focus,
input[type="text"]:focus,
textarea:focus {
-webkit-transition: border-color 200ms;
border-color: #4d90fe;
outline: none;
}
/*
* CSS for exclusion rules.
*/
#exclusion-scroll-box {
overflow: scroll;
overflow-x: hidden;
overflow-y: auto;
/* Each exclusion rule is about 30px tall, so this allows 7 rules before scrolling. */
max-height: 215px;
border-radius: 2px;
color: #444;
width: 100%;
}
#exclusion-rules {
width: 100%;
border-collapse: collapse;
}
#exclusion-rules td {
vertical-align: top;
border: 2px solid transparent;
padding: 0px;
}
#exclusion-rules td:nth-of-type(2) {
width: 33%;
}
#exclusion-rules td:nth-of-type(3) {
/* Make the close button td use only the minimum width needed. */
width: var(--closeButtonWidth);
padding-top: 3px;
}
#exclusion-rules tr.validationError .validationMessage {
display: block;
}
#exclusion-rules tr.validationError td:nth-of-type(1) input {
border-color: orange;
}
#exclusion-rules .validationMessage {
display: block;
color: orange;
margin-top: 2px;
margin-left: 2px;
}
#exclusion-rules .remove {
border: none;
color: #979ca0;
}
#exclusion-rules .remove:hover {
color: #444;
}
input[name="pattern"],
input[name="passKeys"],
.exclusion-header-text {
width: 100%;
font-family: Consolas, "Liberation Mono", Courier, monospace;
font-size: 14px;
}
.exclusion-header-text {
padding-left: 3px;
color: #979ca0;
}
#exclusion-add-button {
float: right;
/* Add the spacing between the table's cells to the right margin of this button. */
margin-right: calc(var(--closeButtonWidth) + 4px);
margin-top: 10px;
}
footer {
background: #f5f5f5;
border-top: 1px solid #979ca0;
position: fixed;
bottom: 0px;
left: 0;
padding: 15px 0;
z-index: 10;
width: 100%;
}
#footer-content {
width: 100%;
display: flex;
align-items: center;
}
#footer-help-text {
flex-grow: 1;
font-size: 12px;
}
#footer-save-options {
flex-grow: 0;
}
#save,
#exclusion-add-button {
white-space: nowrap;
}
#backupLink {
cursor: pointer;
}
input#upload-backup {
max-width: 400px;
}
@media (prefers-color-scheme: light) {
button {
background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede);
border-color: rgba(0, 0, 0, 0.25);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75);
color: #444;
text-shadow: 0 1px 0 #f0f0f0;
}
button:hover {
background-image: -webkit-linear-gradient(#f0f0f0, #f0f0f0 38%, #e0e0e0);
border-color: rgba(0, 0, 0, 0.3);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.12), inset 0 1px 2px rgba(255, 255, 255, 0.95);
color: black;
}
button:active {
background-image: -webkit-linear-gradient(#e7e7e7, #e7e7e7 38%, #d7d7d7);
box-shadow: none;
text-shadow: none;
}
button[disabled],
button[disabled]:hover,
button[disabled]:active {
background-image: -webkit-linear-gradient(#ededed, #ededed 38%, #dedede);
border-color: rgba(0, 0, 0, 0.25);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), inset 0 1px 2px rgba(255, 255, 255, 0.75);
text-shadow: 0 1px 0 #f0f0f0;
color: #888;
}
}
@media (prefers-color-scheme: dark) {
header {
border-bottom: 1px solid #999;
}
pre {
border-left: 1px solid #666;
}
#exclusion-rules .remove {
border: none;
color: var(--vimium-foreground-text-color);
}
#exclusion-rules .remove:hover {
color: #444;
color: #15c;
color: var(--vimium-link-color);
}
footer {
background-color: var(--vimium-foreground-color);
border-color: rgba(255, 255, 255, 0.1);
}
/* Our dark mode style for buttons matches MacOS Mojave's dark mode style for HTML buttons. MacOS
will automatically apply a dark mode style to unstyled buttons, but that doesn't happen on
other OSes, so to be cross-platform, we have to set these styles ourselves. */
button {
background: none;
background-color: #2b2a32;
border-color: #8f8f9c;
border-width: 1px;
color: var(--vimium-foreground-text-color);
text-shadow: none;
}
button:hover {}
button:active {}
button[disabled],
button[disabled]:hover,
button[disabled]:active {
color: #75747a;
border-color: #75747a;
background-color: #222127;
}
}
================================================
FILE: pages/options.html
================================================
Vimium Options
Vimium Options
Excluded URLs and keys
Patterns
Keys to exclude
Disable Vimium on URLs.
"Patterns" are URL regular expressions. * will match zero or more characters.
"Keys": Vimium will exclude these keys and pass them through to the page.
Custom key mappings
Example syntax:
map j scrollDown
map z2 setZoom level=2
unmap j
unmapAll
" this is a comment
# this is also a comment
Add search-engine shortcuts to the Vomnibar. Format:
a: http://a.com/?q=%s
b: http://b.com/?q=%s description
" this is a comment
# this is also a comment
%s is replaced with the search terms.
For search completion, see here.
New tab URL
The page to open when using Vimium's "create new tab" command. To have Vimium commands
work on all new tab pages opened by the browser, a separate Vimium new tab
extension is required. See the full details here.
These styles are applied to link hints, the Vomnibar, the help dialog, the exclusions
pop-up and the HUD.
By default, this CSS is used to style the characters next to each link hint.
These styles are used in addition to and take precedence over Vimium's default styles.
================================================
FILE: pages/options.js
================================================
import "./all_content_scripts.js";
import { ExclusionRulesEditor } from "./exclusion_rules_editor.js";
import { allCommands } from "../background_scripts/all_commands.js";
import { Commands, KeyMappingsParser } from "../background_scripts/commands.js";
import * as userSearchEngines from "../background_scripts/user_search_engines.js";
const options = {
filterLinkHints: "boolean",
grabBackFocus: "boolean",
hideHud: "boolean",
hideUpdateNotifications: "boolean",
ignoreKeyboardLayout: "boolean",
keyMappings: "string",
linkHintCharacters: "string",
linkHintNumbers: "string",
newTabCustomUrl: "string",
newTabDestination: "option",
nextPatterns: "string",
openVomnibarOnNewTabPage: "boolean",
previousPatterns: "string",
regexFindMode: "boolean",
scrollStepSize: "number",
searchEngines: "string",
settingsVersion: "string", // This is a hidden field.
smoothScroll: "boolean",
userDefinedLinkHintCss: "string",
waitForEnterForFilteredHints: "boolean",
};
export async function init() {
await Settings.onLoaded();
const shortcutLabel = document.querySelector("#shortcut-to-save-all");
shortcutLabel.textContent = KeyboardUtils.platform == "Mac" ? "Cmd-Enter" : "Ctrl-Enter";
const saveButton = document.querySelector("#save");
const onUpdated = () => {
maintainNewTabUrlView();
saveButton.disabled = false;
saveButton.textContent = "Save changes";
};
for (const el of document.querySelectorAll("input, textarea")) {
// We want to immediately enable the save button when a setting is changed, so we want to use
// the HTML element's "input" event here rather than the "change" event.
el.addEventListener("input", () => onUpdated());
el.addEventListener("blur", () => {
showValidationErrors();
});
}
saveButton.addEventListener("click", () => saveOptions());
getOptionEl("filterLinkHints").addEventListener(
"click",
() => maintainLinkHintsView(),
);
document.querySelector("#download-backup").addEventListener(
"mousedown",
() => onDownloadBackupClicked(),
true,
);
document.querySelector("#upload-backup").addEventListener(
"change",
() => onUploadBackupClicked(),
);
for (const el of document.querySelectorAll(".reset-link a")) {
el.addEventListener("click", (event) => {
resetInputValue(event);
showValidationErrors();
onUpdated();
});
}
globalThis.onbeforeunload = () => {
if (!saveButton.disabled) {
return "You have unsaved changes to options.";
}
};
document.addEventListener("keydown", (event) => {
// Firefox on Mac doesn't pass ctrl-enter to our page because MacOS Sequoia treats it as a
// shortcut for right click; typing it shows a context menu. So, we also allow cmd-enter to save
// all options. Note that ctrl-enter still works on Chrome for some reason.
const isCtrlEnter = event.ctrlKey && event.keyCode === 13;
const isCmdEnter = event.metaKey && event.keyCode === 13;
if (isCtrlEnter || isCmdEnter) {
saveOptions();
}
});
ExclusionRulesEditor.init();
ExclusionRulesEditor.addEventListener("input", onUpdated);
const settings = Settings.getSettings();
setFormFromSettings(settings);
}
export function getOptionEl(optionName) {
return document.querySelector(`*[name="${optionName}"]`);
}
// Invoked when the user clicks the "reset" button next to an option's text field.
function resetInputValue(event) {
const parentDiv = event.target.parentNode.parentNode;
console.assert(parentDiv?.tagName == "DIV", "Expected parent to be a div", event.target);
const input = parentDiv.querySelector("input") || parentDiv.querySelector("textarea");
const optionName = input.name;
const defaultValue = Settings.defaultOptions[optionName];
input.value = defaultValue;
event.preventDefault();
}
function setFormFromSettings(settings) {
for (const [optionName, optionType] of Object.entries(options)) {
const el = getOptionEl(optionName);
const value = settings[optionName];
switch (optionType) {
case "boolean":
el.checked = value;
break;
case "number":
el.value = value;
break;
case "string":
el.value = value;
break;
case "option":
const optionEl = document.querySelector(`input[name="${optionName}"][value="${value}"]`);
optionEl.checked = true;
break;
default:
throw new Error(`Unrecognized option type ${optionType}`);
}
}
ExclusionRulesEditor.setForm(settings["exclusionRules"]);
document.querySelector("#upload-backup").value = "";
maintainLinkHintsView();
maintainNewTabUrlView();
}
function getSettingsFromForm() {
const settings = {};
for (const [optionName, optionType] of Object.entries(options)) {
const el = getOptionEl(optionName);
let value;
switch (optionType) {
case "boolean":
value = el.checked;
break;
case "number":
value = parseFloat(el.value);
break;
case "string":
value = el.value.trim();
break;
case "option":
const optionEl = document.querySelector(`input[name="${optionName}"]:checked`);
value = optionEl.value;
break;
default:
throw new Error(`Unrecognized option type ${optionType}`);
}
if (value !== null) {
settings[optionName] = value;
}
}
if (settings["linkHintCharacters"] != null) {
settings["linkHintCharacters"] = settings["linkHintCharacters"].toLowerCase();
}
settings["exclusionRules"] = ExclusionRulesEditor.getRules();
return settings;
}
function getValidationErrors() {
const results = {};
let text, parsed;
// keyMappings field.
text = getOptionEl("keyMappings").value.trim();
parsed = KeyMappingsParser.parse(text);
if (parsed.validationErrors.length > 0) {
results["keyMappings"] = parsed.validationErrors.join("\n");
}
// searchEngines field.
text = getOptionEl("searchEngines").value.trim();
parsed = userSearchEngines.parseConfig(text);
if (parsed.validationErrors.length > 0) {
results["searchEngines"] = parsed.validationErrors.join("\n");
}
// linkHintCharacters field.
text = getOptionEl("linkHintCharacters").value.trim();
if (text != removeDuplicateChars(text)) {
results["linkHintCharacters"] = "This cannot contain duplicate characters.";
} else if (text.length <= 1) {
results["linkHintCharacters"] = "This must be at least two characters long.";
}
// linkHintNumbers field.
text = getOptionEl("linkHintNumbers").value.trim();
if (text != removeDuplicateChars(text)) {
results["linkHintNumbers"] = "This cannot contain duplicate characters.";
} else if (text.length <= 1) {
results["linkHintNumbers"] = "This must be at least two characters long.";
}
return results;
}
function addValidationMessage(el, message) {
el.classList.add("validation-error");
const exampleEl = el.nextElementSibling;
const messageEl = document.createElement("div");
messageEl.classList.add("validation-message");
messageEl.textContent = message;
exampleEl.after(messageEl);
}
// Returns true if there are errors, false otherwise.
function showValidationErrors() {
// Remove all previous validation errors.
let els = document.querySelectorAll(".validation-error");
for (const el of els) {
el.classList.remove("validation-error");
}
els = document.querySelectorAll(".validation-message");
for (const el of els) {
el.remove();
}
const errors = getValidationErrors();
for (const [optionName, message] of Object.entries(errors)) {
const el = getOptionEl(optionName);
addValidationMessage(el, message);
}
// Some options can be hidden in the UI. If they have validation errors, force them to be shown.
if (errors["linkHintCharacters"]) {
showElement(document.querySelector("#link-hint-characters-container"), true);
}
if (errors["linkHintNumbers"]) {
showElement(document.querySelector("#link-hint-numbers-container"), true);
}
const hasErrors = Object.keys(errors).length > 0;
return hasErrors;
}
function removeDuplicateChars(str) {
const seen = new Set();
let result = "";
for (let char of str) {
if (!seen.has(char)) {
result += char;
seen.add(char);
}
}
return result;
}
export async function saveOptions() {
const hasErrors = showValidationErrors();
if (hasErrors) {
// TODO(philc): If no fields with validation errors are in view, scroll one of them into view
// so it's clear what the issue is.
return;
}
await Settings.setSettings(getSettingsFromForm());
const el = document.querySelector("#save");
el.disabled = true;
el.textContent = "Saved";
}
function showElement(el, visible) {
el.style.display = visible ? null : "none";
}
// Hide or show extra form elements depending on which radio button is selected for
// newTabDestination.
function maintainNewTabUrlView() {
const destination = document.querySelector("[name=newTabDestination]:checked").value;
showElement(
document.querySelector("#openVomnibarContainer"),
destination == Settings.newTabDestinations.vimiumNewTabPage,
);
showElement(
document.querySelector("[name=newTabCustomUrl]"),
destination == Settings.newTabDestinations.customUrl,
);
}
// Display the UI for link hint numbers vs. characters, depending upon the value of
// "filterLinkHints".
function maintainLinkHintsView() {
const errors = getValidationErrors();
const isFilteredLinkhints = getOptionEl("filterLinkHints").checked;
showElement(
document.querySelector("#link-hint-characters-container"),
!isFilteredLinkhints || errors["linkHintCharacters"],
);
showElement(
document.querySelector("#link-hint-numbers-container"),
isFilteredLinkhints || errors["linkHintNumbers"],
);
showElement(
document.querySelector("#wait-for-enter"),
isFilteredLinkhints,
);
}
export function prepareBackupSettings() {
const settings = Settings.pruneOutDefaultValues(getSettingsFromForm());
// Serialize the JSON keys in order, so that they're stable across backups. See #4764.
const keys = Object.keys(settings).sort();
const sortedSettings = Object.fromEntries(keys.map((k) => [k, settings[k]]));
// Don't use an array replacer in JSON.stringify; it filters nested object keys too, which would
// drop nested fields inside exclusionRules (e.g. `pattern`, `passKeys`). See #4853.
return JSON.stringify(sortedSettings, null, 2) + "\n";
}
function onDownloadBackupClicked() {
const settings = prepareBackupSettings();
const blob = new Blob([settings]);
document.querySelector("#download-backup").href = URL.createObjectURL(blob);
}
function onUploadBackupClicked() {
if (document.activeElement) {
document.activeElement.blur();
}
const files = event.target.files;
if (files.length === 1) {
const file = files[0];
const reader = new FileReader();
reader.readAsText(file);
reader.onload = async () => {
let backup;
try {
backup = JSON.parse(reader.result);
} catch (error) {
console.log("parsing error:", error);
alert("Failed to parse Vimium backup: " + error);
return;
}
await Settings.setSettings(backup);
setFormFromSettings(Settings.getSettings());
const saveButton = document.querySelector("#save");
saveButton.disabled = true;
saveButton.textContent = "Saved";
alert("Settings have been restored from the backup.");
};
}
}
const testEnv = globalThis.window == null ||
globalThis.window.location.search.includes("dom_tests=true");
if (!testEnv) {
document.addEventListener("DOMContentLoaded", async () => {
await Settings.onLoaded();
DomUtils.injectUserCss();
await Commands.init();
await init();
});
}
================================================
FILE: pages/reload.html
================================================
================================================
FILE: pages/ui_component_messenger.js
================================================
//
// These are functions for a page in a UIComponent iframe to communicate to its parent frame.
//
let ownerPagePort = null;
let handleMessage = null;
export async function registerPortWithOwnerPage(event) {
if (event.source !== globalThis.parent) return;
// The Vimium content script that's running on the parent page has access to this vimiumSecret
// fetched from session storage, so if it matches, then we know that event.ports came from the
// Vimium extension.
const secret = (await chrome.storage.session.get("vimiumSecret")).vimiumSecret;
if (event.data !== secret) {
Utils.debugLog("ui_component_messenger.js: vimiumSecret is incorrect.");
return;
}
openPort(event.ports[0]);
// Once we complete a handshake with the parent page hosting this page's iframe, stop listening
// for messages on the window object.
globalThis.removeEventListener("message", registerPortWithOwnerPage);
}
// Used by unit tests.
export async function unregister() {
ownerPagePort = null;
handleMessage = null;
}
export function init() {
globalThis.addEventListener("message", registerPortWithOwnerPage);
}
function openPort(port) {
ownerPagePort = port;
ownerPagePort.onmessage = async (event) => {
if (handleMessage) {
return await handleMessage(event);
}
};
dispatchReadyEventWhenReady();
}
export function registerHandler(messageHandlerFn) {
handleMessage = messageHandlerFn;
}
export function postMessage(data) {
if (!ownerPagePort) return;
ownerPagePort.postMessage(data);
}
// We require both that the DOM is ready and that the port has been opened before the UIComponent
// is ready. These events can happen in either order. We count them, and notify the content script
// when we've seen both.
let hasDispatchedReadyEvent = false;
function dispatchReadyEventWhenReady() {
if (hasDispatchedReadyEvent) return;
if (document.readyState === "loading") {
globalThis.addEventListener("DOMContentLoaded", () => dispatchReadyEventWhenReady());
return;
}
if (!ownerPagePort) return;
if (globalThis.frameId != null) {
postMessage({ name: "setIframeFrameId", iframeFrameId: globalThis.frameId });
}
hasDispatchedReadyEvent = true;
postMessage({ name: "uiComponentIsReady" });
}
================================================
FILE: pages/vomnibar_page.css
================================================
ul {
list-style: none;
display: none;
margin: 0;
padding: 0;
}
#vomnibar {
display: block;
position: fixed;
width: calc(100% - 20px); /* adjusted to keep border radius and box-shadow visible*/
top: 8px;
left: 8px;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
background: #f1f1f1;
text-align: left;
border-radius: 4px;
box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8);
border: 1px solid #aaa;
/* One less than hint markers and the help dialog (see ../content_scripts/vimium.css). */
z-index: 2139999999;
}
#vomnibar input {
font-size: 20px;
height: 34px;
margin-bottom: 0;
padding: 4px;
background-color: white;
color: black;
border-radius: 3px;
border: 1px solid #e8e8e8;
box-shadow: #444 0px 0px 1px;
width: 100%;
outline: none;
box-sizing: border-box;
}
#vomnibar-search-area {
display: block;
padding: 10px;
border-radius: 4px 4px 0 0;
border-bottom: 1px solid #c6c9ce;
}
#vomnibar ul {
border-radius: 0 0 4px 4px;
}
#vomnibar li {
border-bottom: 1px solid #ddd;
line-height: 1.1em;
padding: 7px 10px;
font-size: 16px;
color: black;
position: relative;
display: list-item;
margin: auto;
}
#vomnibar li:last-of-type {
border-bottom: none;
}
#vomnibar li .top-half, #vomnibar li .bottom-half {
display: block;
overflow: hidden;
}
#vomnibar li .bottom-half {
font-size: 15px;
margin-top: 3px;
padding: 2px 0;
}
#vomnibar li .icon {
padding: 0 13px 0 6px;
vertical-align: bottom;
}
#vomnibar li .source {
color: #777;
margin-right: 4px;
}
#vomnibar li .relevancy {
position: absolute;
right: 0;
top: 0;
padding: 5px;
color: black;
font-family: monospace;
width: 100px;
overflow: hidden;
}
#vomnibar li .url {
white-space: nowrap;
color: #224684;
}
#vomnibar li .match {
font-weight: bold;
color: black;
}
#vomnibar li em, #vomnibar li .title {
color: black;
margin-left: 4px;
}
#vomnibar li em {
font-style: italic;
}
#vomnibar li em .match, #vomnibar li .title .match {
color: #333;
}
#vomnibar li.selected {
background-color: #bbcee9;
}
#vomnibar input::selection {
/* This is the light grey color of the vomnibar border. */
/* background-color: #F1F1F1; */
/* This is the light blue color of the vomnibar selected item. */
/* background-color: #BBCEE9; */
/* This is a considerably lighter blue than Vimium blue, which seems softer
* on the eye for this purpose. */
background-color: #e6eefb;
}
.no-insert-text {
visibility: hidden;
}
/* Dark Vomnibar */
@media (prefers-color-scheme: dark) {
#vomnibar {
background-color: var(--vimium-background-color);
color: var(--vimium-background-text-color);
border-radius: 6px;
border: 1px solid var(--vimium-foreground-color);
}
#vomnibar-search-area {
border-bottom: 1px solid var(--vimium-foreground-color);
}
#vomnibar input {
background-color: var(--vimium-foreground-color);
color: var(--vimium-foreground-text-color);
border: none;
}
/* Ensure selected text is visible in dark mode. */
#vomnibar input::selection {
color: var(--vimium-foreground-color);
background-color: var(--vimium-foreground-text-color);
}
#vomnibar li {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
#vomnibar li.selected {
background-color: #37383a;
}
#vomnibar li .url {
white-space: nowrap;
color: #5ca1f7;
}
#vomnibar li em,
#vomnibar li .title {
color: white;
}
#vomnibar li .source {
color: #9aa0a6;
}
#vomnibar li .match {
color: white;
}
#vomnibar li em .match,
#vomnibar li .title .match {
color: white;
}
}
================================================
FILE: pages/vomnibar_page.html
================================================
Vomnibar
================================================
FILE: pages/vomnibar_page.js
================================================
//
// This controls the contents of the Vomnibar iframe. We use an iframe to avoid changing the
// selection on the page (useful for bookmarklets), ensure that the Vomnibar style is unaffected by
// the page, and simplify key handling in vimium_frontend.js
//
import "../lib/types.js";
import "../lib/utils.js";
import "../lib/url_utils.js";
import "../lib/settings.js";
import "../lib/keyboard_utils.js";
import "../lib/dom_utils.js";
import "../lib/handler_stack.js";
import * as UIComponentMessenger from "./ui_component_messenger.js";
import * as userSearchEngines from "../background_scripts/user_search_engines.js";
export let ui; // An instance of VomnibarUI.
// Used for tests.
export function reset() {
ui = null;
}
export async function activate(options) {
Utils.assertType(VomnibarShowOptions, options || {});
await Settings.onLoaded();
userSearchEngines.set(Settings.get("searchEngines"));
const defaults = {
completer: "omni",
query: "",
newTab: false,
selectFirst: false,
keyword: null,
};
options = Object.assign(defaults, options);
if (ui == null) {
ui = new VomnibarUI();
}
ui.setCompleterName(options.completer);
ui.refreshCompletions();
ui.setInitialSelectionValue(options.selectFirst ? 0 : -1);
ui.setForceNewTab(options.newTab);
ui.setQuery(options.query);
ui.setActiveUserSearchEngine(userSearchEngines.keywordToEngine[options.keyword]);
// Use await here for vomnibar_test.js, so that this page doesn't get unloaded while a test is
// running.
await ui.update();
}
class VomnibarUI {
constructor() {
this.onKeyEvent = this.onKeyEvent.bind(this);
this.onInput = this.onInput.bind(this);
this.update = this.update.bind(this);
this.onHiddenCallback = null;
this.initDom();
// The user's custom search engine, if they have prefixed their query with the keyword for one
// of their search engines.
this.activeUserSearchEngine = null;
// Used for synchronizing requests and responses to the background page.
this.lastRequestId = null;
}
setQuery(query) {
this.input.value = query;
}
setActiveUserSearchEngine(userSearchEngine) {
this.activeUserSearchEngine = userSearchEngine;
}
setInitialSelectionValue(initialSelectionValue) {
this.initialSelectionValue = initialSelectionValue;
}
setForceNewTab(forceNewTab) {
this.forceNewTab = forceNewTab;
}
setCompleterName(name) {
this.completerName = name;
this.reset();
}
// True if the user has entered the keyword of one of their custom search engines.
isUserSearchEngineActive() {
return this.activeUserSearchEngine != null;
}
// The sequence of events when the vomnibar is hidden:
// 1. Post a "hide" message to the host page.
// 2. The host page hides the vomnibar.
// 3. When that page receives the focus, it posts back a "hidden" message.
// 4. Only once the "hidden" message is received here is onHiddenCallback called.
//
// This ensures that the vomnibar is actually hidden before any new tab is created, and avoids
// flicker after opening a link in a new tab then returning to the original tab. See #1485.
hide(onHiddenCallback = null) {
this.onHiddenCallback = onHiddenCallback;
this.input.blur();
this.reset();
// Wait until this iframe's DOM has been rendered before hiding the iframe. This is to prevent
// Chrome caching the previous visual state of the vomnibar iframe. See #4708.
setTimeout(() => {
UIComponentMessenger.postMessage({ name: "hide" });
}, 0);
}
onHidden() {
this.onHiddenCallback?.();
this.onHiddenCallback = null;
this.reset();
}
reset() {
this.input.value = "";
this.completions = [];
this.renderCompletions(this.completions);
this.previousInputValue = null;
this.activeUserSearchEngine = null;
this.selection = this.initialSelectionValue;
this.seenTabToOpenCompletionList = false;
this.lastRequestId = null;
}
updateSelection() {
// For suggestions from custom search engines, we copy the suggestion's text into the input when
// the suggestion is selected, and revert when it is not. This allows the user to select a
// suggestion and then continue typing.
const completion = this.completions[this.selection];
const shouldReplaceInputWithSuggestion = this.selection >= 0 &&
completion.insertText != null;
if (shouldReplaceInputWithSuggestion) {
if (this.previousInputValue == null) {
this.previousInputValue = this.input.value;
}
this.input.value = completion.insertText;
} else if (this.previousInputValue != null) {
this.input.value = this.previousInputValue;
this.previousInputValue = null;
}
// Highlight the selected entry.
for (const [i, el] of Object.entries(this.completionList.children)) {
el.className = i == this.selection ? "selected" : "";
}
}
// Returns the user's action ("up", "down", "tab", etc, or null) based on their keypress. We
// support the arrow keys and various other shortcuts, and this function hides the event-decoding
// complexity.
actionFromKeyEvent(event) {
const key = KeyboardUtils.getKeyChar(event);
// Handle on "keypress", and other events on "keydown". This avoids interence with CJK
// translation (see #2915 and #2934).
if ((event.type === "keypress") && (key !== "enter")) return null;
if ((event.type === "keydown") && (key === "enter")) return null;
if (KeyboardUtils.isEscape(event)) {
return "dismiss";
} else if (
(key === "up") ||
(event.shiftKey && (event.key === "Tab")) ||
(event.ctrlKey && ((key === "k") || (key === "p")))
) {
return "up";
} else if ((event.key === "Tab") && !event.shiftKey) {
return "tab";
} else if (
(key === "down") ||
(event.ctrlKey && ((key === "j") || (key === "n")))
) {
return "down";
} else if (event.ctrlKey && (key === "enter")) {
return "ctrl-enter";
} else if (event.key === "Enter") {
return "enter";
} else if ((event.key === "Delete") && event.shiftKey && !event.ctrlKey && !event.altKey) {
return "remove";
} else if (KeyboardUtils.isBackspace(event)) {
return "delete";
}
return null;
}
async onKeyEvent(event) {
const action = this.actionFromKeyEvent(event);
if (!action) {
return;
}
if (action === "dismiss") {
this.hide();
} else if (["tab", "down"].includes(action)) {
if (
(action === "tab") &&
(this.completerName === "omni") &&
!this.seenTabToOpenCompletionList &&
(this.input.value.trim().length === 0)
) {
this.seenTabToOpenCompletionList = true;
this.update();
} else if (this.completions.length > 0) {
this.selection += 1;
if (this.selection === this.completions.length) {
this.selection = this.initialSelectionValue;
}
this.updateSelection();
}
} else if (action === "up") {
this.selection -= 1;
if (this.selection < this.initialSelectionValue) {
this.selection = this.completions.length - 1;
}
this.updateSelection();
} else if (action === "enter") {
await this.handleEnterKey(event);
} else if (action === "ctrl-enter") {
// Populate the vomnibar with the current selection's URL.
if (!this.isUserSearchEngineActive() && (this.selection >= 0)) {
if (this.previousInputValue == null) {
this.previousInputValue = this.input.value;
}
this.input.value = this.completions[this.selection]?.url;
this.input.scrollLeft = this.input.scrollWidth;
}
} else if (action === "delete") {
if (this.isUserSearchEngineActive() && (this.input.selectionEnd === 0)) {
// Normally, with custom search engines, the keyword (e.g. the "w" of "w query terms") is
// suppressed. If the cursor is at the start of the input, then reinstate the keyword (the
// "w").
const keyword = this.activeUserSearchEngine.keyword;
this.input.value = keyword + this.input.value.trimStart();
this.input.selectionStart = this.input.selectionEnd = keyword.length;
this.activeUserSearchEngine = null;
this.update();
} else if (this.seenTabToOpenCompletionList && (this.input.value.trim().length === 0)) {
this.seenTabToOpenCompletionList = false;
this.update();
} else {
return; // Do not suppress event.
}
} else if ((action === "remove") && (this.selection >= 0)) {
const completion = this.completions[this.selection];
console.log(completion);
}
event.stopImmediatePropagation();
event.preventDefault();
}
async handleEnterKey(event) {
const isPrimarySearchSuggestion = (c) => c?.isPrimarySuggestion && c?.isCustomSearch;
let query = this.input.value.trim();
// Note that it's possible that this.completions is empty. This can happen in practice if the
// user hits enter quickly after loading the vomnibar, before the filterCompletions request to
// the background page finishes.
const waitingOnCompletions = this.completions.length == 0;
const completion = this.completions[this.selection];
const openInNewTab = this.forceNewTab || event.shiftKey || event.ctrlKey || event.altKey ||
event.metaKey;
// If the user types something and hits enter without selecting a completion from the list,
// then:
// - If they've activated a custom search engine in the Vomnibar, then launch that search
// using the typed-in query.
// - Otherwise, open the query as a URL or create a default search as appropriate.
//
// When launching a query in a custom search engine, the user may have typed more text than
// that which is included in the URL associated with the primary suggestion, because the
// suggestions are updated asynchronously. Therefore, to avoid a race condition, we construct
// the search URL from the actual contents of the input (query).
if (waitingOnCompletions || this.selection == -1) {
// on an empty query is a no-op.
if (query.length == 0) return;
const firstCompletion = this.completions[0];
const isPrimary = isPrimarySearchSuggestion(firstCompletion);
if (isPrimary) {
query = UrlUtils.createSearchUrl(query, firstCompletion.searchUrl);
await this.launchUrl(query);
} else {
// If the query looks like a URL, try to open it directly. Otherwise, pass the query to
// the user's default search engine.
// TODO(philc):
const isUrl = await UrlUtils.isUrl(query);
if (isUrl) {
this.hide(() => this.launchUrl(query, openInNewTab));
} else {
this.hide(() =>
chrome.runtime.sendMessage({
handler: "launchSearchQuery",
query,
openInNewTab,
})
);
}
}
} else if (isPrimarySearchSuggestion(completion)) {
query = UrlUtils.createSearchUrl(query, completion.searchUrl);
this.hide(() => this.launchUrl(query, openInNewTab));
} else {
this.hide(() => this.openCompletion(completion, openInNewTab));
}
}
// Return the background-page query corresponding to the current input state. In other words,
// reinstate any search engine keyword which is currently being suppressed, and strip any prompted
// text.
getInputValueAsQuery() {
const prefix = this.isUserSearchEngineActive() ? this.activeUserSearchEngine.keyword + " " : "";
return prefix + this.input.value;
}
async updateCompletions() {
const requestId = Utils.createUniqueId();
this.lastRequestId = requestId;
const query = this.getInputValueAsQuery();
const queryTerms = query.trim().split(/\s+/).filter((s) => s.length > 0);
const results = await chrome.runtime.sendMessage({
handler: "filterCompletions",
completerName: this.completerName,
queryTerms,
query,
seenTabToOpenCompletionList: this.seenTabToOpenCompletionList,
});
// Ensure that no new filter requests have gone out while waiting for this result.
if (this.lastRequestId != requestId) return;
this.completions = results;
this.selection = this.completions[0]?.autoSelect ? 0 : this.initialSelectionValue;
this.renderCompletions(this.completions);
this.selection = Math.min(
this.completions.length - 1,
Math.max(this.initialSelectionValue, this.selection),
);
this.updateSelection();
}
renderCompletions(completions) {
this.completionList.innerHTML = completions.map((c) => `
${c.html}
`).join("");
this.completionList.style.display = completions.length > 0 ? "block" : "";
}
refreshCompletions() {
chrome.runtime.sendMessage({
handler: "refreshCompletions",
completerName: this.completerName,
});
}
cancelCompletions() {
// Let the background page's completer optionally abandon any pending query, because the user is
// typing and another query will arrive soon.
chrome.runtime.sendMessage({
handler: "cancelCompletions",
completerName: this.completerName,
});
}
onInput() {
this.seenTabToOpenCompletionList = false;
this.cancelCompletions();
// For custom search engines, we suppress the leading prefix (e.g. the "w" of "w query terms")
// within the vomnibar input.
if (!this.isUserSearchEngineActive() && this.getUserSearchEngineForQuery() != null) {
this.activeUserSearchEngine = this.getUserSearchEngineForQuery();
const queryTerms = this.input.value.trim().split(/\s+/);
this.input.value = queryTerms.slice(1).join(" ");
}
// If the user types, then don't reset any previous text, and reset the selection.
if (this.previousInputValue != null) {
this.previousInputValue = null;
this.selection = -1;
}
this.update();
}
// Returns the UserSearchEngine for the given query. Returns null if the query does not begin with
// a keyword from one of the user's search engines.
getUserSearchEngineForQuery() {
// This logic is duplicated from SearchEngineCompleter.getEngineForQueryPrefix
const parts = this.input.value.trimStart().split(/\s+/);
// For a keyword "w", we match "w search terms" and "w ", but not "w" on its own.
const keyword = parts[0];
if (parts.length <= 1) return null;
// Don't match queries for built-in properties like "constructor". See #4396.
if (Object.hasOwn(userSearchEngines.keywordToEngine, keyword)) {
return userSearchEngines.keywordToEngine[keyword];
}
return null;
}
async update() {
await this.updateCompletions();
this.input.focus();
}
openCompletion(completion, openInNewTab) {
if (completion.description == "tab") {
chrome.runtime.sendMessage({ handler: "selectSpecificTab", id: completion.tabId });
} else {
this.launchUrl(completion.url, openInNewTab);
}
}
async launchUrl(url, openInNewTab) {
// If the URL is a bookmarklet (so, prefixed with "javascript:"), then always open it in the
// current tab.
if (openInNewTab && UrlUtils.hasJavascriptProtocol(url)) {
openInNewTab = false;
}
await chrome.runtime.sendMessage({
handler: openInNewTab ? "openUrlInNewTab" : "openUrlInCurrentTab",
url,
});
}
initDom() {
this.box = document.getElementById("vomnibar");
this.input = this.box.querySelector("input");
this.input.addEventListener("input", this.onInput);
this.input.addEventListener("keydown", this.onKeyEvent);
this.input.addEventListener("keypress", this.onKeyEvent);
this.completionList = this.box.querySelector("ul");
this.completionList.style.display = "";
window.addEventListener("focus", () => this.input.focus());
// A click in the vomnibar itself refocuses the input.
this.box.addEventListener("click", (event) => {
this.input.focus();
return event.stopImmediatePropagation();
});
// A click anywhere else hides the vomnibar.
document.addEventListener("click", () => this.hide());
}
}
let vomnibarInstance;
function init() {
UIComponentMessenger.init();
UIComponentMessenger.registerHandler(function (event) {
switch (event.data.name) {
case "hide":
ui?.hide();
break;
case "hidden":
ui?.onHidden();
break;
case "activate":
const options = Object.assign({}, event.data);
delete options.name;
activate(options);
break;
default:
Utils.assert(false, "Unrecognized message type.", event.data);
}
});
}
const testEnv = globalThis.window == null ||
globalThis.window.location.search.includes("dom_tests=true");
if (!testEnv) {
document.addEventListener("DOMContentLoaded", async () => {
await Settings.onLoaded();
DomUtils.injectUserCss(); // Manually inject custom user styles.
});
init();
}
================================================
FILE: resources/tlds.txt
================================================
aaa
aarp
abarth
abb
abbott
abbvie
abc
able
abogado
abudhabi
ac
academy
accenture
accountant
accountants
aco
active
actor
ad
adac
ads
adult
ae
aeg
aero
aetna
af
afamilycompany
afl
africa
ag
agakhan
agency
ai
aig
aigo
airbus
airforce
airtel
akdn
al
alfaromeo
alibaba
alipay
allfinanz
allstate
ally
alsace
alstom
am
amazon
americanexpress
americanfamily
amex
amfam
amica
amsterdam
an
analytics
android
anquan
anz
ao
aol
apartments
app
apple
aq
aquarelle
ar
arab
aramco
archi
army
arpa
art
arte
as
asda
asia
associates
at
athleta
attorney
au
auction
audi
audible
audio
auspost
author
auto
autos
avianca
aw
aws
ax
axa
az
azure
ba
baby
baidu
banamex
bananarepublic
band
bank
bar
barcelona
barclaycard
barclays
barefoot
bargains
baseball
basketball
bauhaus
bayern
bb
bbc
bbt
bbva
bcg
bcn
bd
be
beats
beauty
beer
bentley
berlin
best
bestbuy
bet
bf
bg
bh
bharti
bi
bible
bid
bike
bing
bingo
bio
biz
bj
bl
black
blackfriday
blanco
blockbuster
blog
bloomberg
blue
bm
bms
bmw
bn
bnl
bnpparibas
bo
boats
boehringer
bofa
bom
bond
boo
book
booking
boots
bosch
bostik
boston
bot
boutique
box
bq
br
bradesco
bridgestone
broadway
broker
brother
brussels
bs
bt
budapest
bugatti
build
builders
business
buy
buzz
bv
bw
by
bz
bzh
ca
cab
cafe
cal
call
calvinklein
cam
camera
camp
cancerresearch
canon
capetown
capital
capitalone
car
caravan
cards
care
career
careers
cars
cartier
casa
case
caseih
cash
casino
cat
catering
catholic
cba
cbn
cbre
cbs
cc
cd
ceb
center
ceo
cern
cf
cfa
cfd
cg
ch
chanel
channel
charity
chase
chat
cheap
chintai
chloe
christmas
chrome
chrysler
church
ci
cipriani
circle
cisco
citadel
citi
citic
city
cityeats
ck
cl
claims
cleaning
click
clinic
clinique
clothing
cloud
club
clubmed
cm
cn
co
coach
codes
coffee
college
cologne
com
comcast
commbank
community
company
compare
computer
comsec
condos
construction
consulting
contact
contractors
cooking
cookingchannel
cool
coop
corsica
country
coupon
coupons
courses
cpa
cr
credit
creditcard
creditunion
cricket
crown
crs
cruise
cruises
csc
cu
cuisinella
cv
cw
cx
cy
cymru
cyou
cz
dabur
dad
dance
data
date
dating
datsun
day
dclk
dds
de
deal
dealer
deals
degree
delivery
dell
deloitte
delta
democrat
dental
dentist
desi
design
dev
dhl
diamonds
diet
digital
direct
directory
discount
discover
dish
diy
dj
dk
dm
dnp
do
docs
doctor
dodge
dog
doha
domains
doosan
dot
download
drive
dtv
dubai
duck
dunlop
duns
dupont
durban
dvag
dvr
dz
earth
eat
ec
eco
edeka
edu
education
ee
eg
eh
email
emerck
energy
engineer
engineering
enterprises
epost
epson
equipment
er
ericsson
erni
es
esq
estate
esurance
et
etisalat
eu
eurovision
eus
events
everbank
exchange
expert
exposed
express
extraspace
fage
fail
fairwinds
faith
family
fan
fans
farm
farmers
fashion
fast
fedex
feedback
ferrari
ferrero
fi
fiat
fidelity
fido
film
final
finance
financial
fire
firestone
firmdale
fish
fishing
fit
fitness
fj
fk
flickr
flights
flir
florist
flowers
flsmidth
fly
fm
fo
foo
food
foodnetwork
football
ford
forex
forsale
forum
foundation
fox
fr
free
fresenius
frl
frogans
frontdoor
frontier
ftr
fujitsu
fujixerox
fun
fund
furniture
futbol
fyi
ga
gal
gallery
gallo
gallup
game
games
gap
garden
gay
gb
gbiz
gd
gdn
ge
gea
gent
genting
george
gf
gg
ggee
gh
gi
gift
gifts
gives
giving
gl
glade
glass
gle
global
globo
gm
gmail
gmbh
gmo
gmx
gn
godaddy
gold
goldpoint
golf
goo
goodhands
goodyear
goog
google
gop
got
gov
gp
gq
gr
grainger
graphics
gratis
green
gripe
grocery
group
gs
gt
gu
guardian
gucci
guge
guide
guitars
guru
gw
gy
hair
hamburg
hangout
haus
hbo
hdfc
hdfcbank
health
healthcare
help
helsinki
here
hermes
hgtv
hiphop
hisamitsu
hitachi
hiv
hk
hkt
hm
hn
hockey
holdings
holiday
homedepot
homegoods
homes
homesense
honda
honeywell
horse
hospital
host
hosting
hot
hoteles
hotels
hotmail
house
how
hr
hsbc
ht
htc
hu
hughes
hyatt
hyundai
ibm
icbc
ice
icu
id
ie
ieee
ifm
iinet
ikano
il
im
imamat
imdb
immo
immobilien
in
inc
industries
infiniti
info
ing
ink
institute
insurance
insure
int
intel
international
intuit
investments
io
ipiranga
iq
ir
irish
is
iselect
ismaili
ist
istanbul
it
itau
itv
iveco
iwc
jaguar
java
jcb
jcp
je
jeep
jetzt
jewelry
jio
jlc
jll
jm
jmp
jnj
jo
jobs
joburg
jot
joy
jp
jpmorgan
jprs
juegos
juniper
kaufen
kddi
ke
kerryhotels
kerrylogistics
kerryproperties
kfh
kg
kh
ki
kia
kids
kim
kinder
kindle
kitchen
kiwi
km
kn
koeln
komatsu
kosher
kp
kpmg
kpn
kr
krd
kred
kuokgroup
kw
ky
kyoto
kz
la
lacaixa
ladbrokes
lamborghini
lamer
lancaster
lancia
lancome
land
landrover
lanxess
lasalle
lat
latino
latrobe
law
lawyer
lb
lc
lds
lease
leclerc
lefrak
legal
lego
lexus
lgbt
li
liaison
lidl
life
lifeinsurance
lifestyle
lighting
like
lilly
limited
limo
lincoln
linde
link
lipsy
live
living
lixil
lk
llc
llp
loan
loans
locker
locus
loft
lol
london
lotte
lotto
love
lpl
lplfinancial
lr
ls
lt
ltd
ltda
lu
lundbeck
lupin
luxe
luxury
lv
ly
ma
macys
madrid
maif
maison
makeup
man
management
mango
map
market
marketing
markets
marriott
marshalls
maserati
mattel
mba
mc
mcd
mcdonalds
mckinsey
md
me
med
media
meet
melbourne
meme
memorial
men
menu
meo
merckmsd
metlife
mf
mg
mh
miami
microsoft
mil
mini
mint
mit
mitsubishi
mk
ml
mlb
mls
mm
mma
mn
mo
mobi
mobile
mobily
moda
moe
moi
mom
monash
money
monster
montblanc
mopar
mormon
mortgage
moscow
moto
motorcycles
mov
movie
movistar
mp
mq
mr
ms
msd
mt
mtn
mtpc
mtr
mu
museum
music
mutual
mutuelle
mv
mw
mx
my
mz
na
nab
nadex
nagoya
name
nationwide
natura
navy
nba
nc
ne
nec
net
netbank
netflix
network
neustar
new
newholland
news
next
nextdirect
nexus
nf
nfl
ng
ngo
nhk
ni
nico
nike
nikon
ninja
nissan
nissay
nl
no
nokia
northwesternmutual
norton
now
nowruz
nowtv
np
nr
nra
nrw
ntt
nu
nyc
nz
obi
observer
off
office
okinawa
olayan
olayangroup
oldnavy
ollo
om
omega
one
ong
onl
online
onyourside
ooo
open
oracle
orange
org
organic
orientexpress
origins
osaka
otsuka
ott
ovh
pa
page
pamperedchef
panasonic
panerai
paris
pars
partners
parts
party
passagens
pay
pccw
pe
pet
pf
pfizer
pg
ph
pharmacy
phd
philips
phone
photo
photography
photos
physio
piaget
pics
pictet
pictures
pid
pin
ping
pink
pioneer
pizza
pk
pl
place
play
playstation
plumbing
plus
pm
pn
pnc
pohl
poker
politie
porn
post
pr
pramerica
praxi
press
prime
pro
prod
productions
prof
progressive
promo
properties
property
protection
pru
prudential
ps
pt
pub
pw
pwc
py
qa
qpon
quebec
quest
qvc
racing
radio
raid
re
read
realestate
realtor
realty
recipes
red
redstone
redumbrella
rehab
reise
reisen
reit
reliance
ren
rent
rentals
repair
report
republican
rest
restaurant
review
reviews
rexroth
rich
richardli
ricoh
rightathome
ril
rio
rip
rmit
ro
rocher
rocks
rodeo
rogers
room
rs
rsvp
ru
rugby
ruhr
run
rw
rwe
ryukyu
sa
saarland
safe
safety
sakura
sale
salon
samsclub
samsung
sandvik
sandvikcoromant
sanofi
sap
sapo
sarl
sas
save
saxo
sb
sbi
sbs
sc
sca
scb
schaeffler
schmidt
scholarships
school
schule
schwarz
science
scjohnson
scor
scot
sd
se
search
seat
secure
security
seek
select
sener
services
ses
seven
sew
sex
sexy
sfr
sg
sh
shangrila
sharp
shaw
shell
shia
shiksha
shoes
shop
shopping
shouji
show
showtime
shriram
si
silk
sina
singles
site
sj
sk
ski
skin
sky
skype
sl
sling
sm
smart
smile
sn
sncf
so
soccer
social
softbank
software
sohu
solar
solutions
song
sony
soy
spa
space
spiegel
sport
spot
spreadbetting
sr
srl
srt
ss
st
stada
staples
star
starhub
statebank
statefarm
statoil
stc
stcgroup
stockholm
storage
store
stream
studio
study
style
su
sucks
supplies
supply
support
surf
surgery
suzuki
sv
swatch
swiftcover
swiss
sx
sy
sydney
symantec
systems
sz
tab
taipei
talk
taobao
target
tatamotors
tatar
tattoo
tax
taxi
tc
tci
td
tdk
team
tech
technology
tel
telecity
telefonica
temasek
tennis
teva
tf
tg
th
thd
theater
theatre
tiaa
tickets
tienda
tiffany
tips
tires
tirol
tj
tjmaxx
tjx
tk
tkmaxx
tl
tm
tmall
tn
to
today
tokyo
tools
top
toray
toshiba
total
tours
town
toyota
toys
tp
tr
trade
trading
training
travel
travelchannel
travelers
travelersinsurance
trust
trv
tt
tube
tui
tunes
tushu
tv
tvs
tw
tz
ua
ubank
ubs
uconnect
ug
uk
um
unicom
university
uno
uol
ups
us
uy
uz
va
vacations
vana
vanguard
vc
ve
vegas
ventures
verisign
versicherung
vet
vg
vi
viajes
video
vig
viking
villas
vin
vip
virgin
visa
vision
vista
vistaprint
viva
vivo
vlaanderen
vn
vodka
volkswagen
volvo
vote
voting
voto
voyage
vu
vuelos
wales
walmart
walter
wang
wanggou
warman
watch
watches
weather
weatherchannel
webcam
weber
website
wed
wedding
weibo
weir
wf
whoswho
wien
wiki
williamhill
win
windows
wine
winners
wme
wolterskluwer
woodside
work
works
world
wow
ws
wtc
wtf
xbox
xerox
xfinity
xihuan
xin
测试
कॉम
परीक्षा
セール
佛山
ಭಾರತ
慈善
集团
在线
한국
ଭାରତ
大众汽车
点看
คอม
ভাৰত
ভারত
八卦
.ישראל
.موقع
বাংলা
公益
公司
香格里拉
网站
移动
我爱你
москва
испытание
қаз
католик
онлайн
сайт
联通
срб
бг
бел
.קום
时尚
微博
테스트
淡马锡
ファッション
орг
नेट
ストア
アマゾン
삼성
சிங்கப்பூர்
商标
商店
商城
дети
мкд
.טעסט
ею
ポイント
新闻
工行
家電
.كوم
中文网
中信
中国
中國
娱乐
谷歌
భారత్
ලංකා
電訊盈科
购物
測試
クラウド
ભારત
通販
भारतम्
भारत
भारोत
.آزمایشی
பரிட்சை
网店
संगठन
餐厅
网络
ком
укр
香港
亚马逊
诺基亚
食品
δοκιμή
飞利浦
.إختبار
台湾
台灣
手表
手机
мон
.الجزائر
.عمان
.ارامكو
.ایران
.العليان
.اتصالات
.امارات
.بازار
.موريتانيا
.پاکستان
.الاردن
.موبايلي
.بارت
.بھارت
.المغرب
.ابوظبي
.البحرين
.السعودية
.ڀارت
.كاثوليك
.سودان
.همراه
.عراق
.مليسيا
澳門
닷컴
政府
.شبكة
.بيتك
.عرب
გე
机构
组织机构
健康
ไทย
.سورية
招聘
рус
рф
珠宝
.تونس
大拿
ລາວ
みんな
グーグル
ευ
ελ
世界
書籍
ഭാരതം
ਭਾਰਤ
网址
닷넷
コム
天主教
游戏
vermögensberater
vermögensberatung
企业
信息
嘉里大酒店
嘉里
.مصر
.قطر
广东
இலங்கை
இந்தியா
հայ
新加坡
.فلسطين
テスト
政务
xperia
xxx
xyz
yachts
yahoo
yamaxun
yandex
ye
yodobashi
yoga
yokohama
you
youtube
yt
yun
za
zappos
zara
zero
zip
zippo
zm
zone
zuerich
zw
================================================
FILE: test_harnesses/cross_origin_iframe.html
================================================
Cross origin iFrame
Iframe from a different origin as its parent:
================================================
FILE: test_harnesses/event_capture.html
================================================
Event capture
================================================
FILE: test_harnesses/form.html
================================================
Page with forms
================================================
FILE: test_harnesses/has_popup_and_link_hud.html
================================================
Link and popup HUD
================================================
FILE: test_harnesses/iframe.html
================================================
IFrame test harness
IFrame test page
Sample link
================================================
FILE: test_harnesses/page_with_links.html
================================================
Page with many linksThis will be a link spanning two lines
Below is an image map:
================================================
FILE: test_harnesses/visibility_test.html
================================================
Visibility test
Node/Test
testtest
test
test
test
test
test
test
================================================
FILE: test_harnesses/vomnibar_harness.html
================================================
Vomnibar harness
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat
non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut
labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco
laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in
voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat
non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
================================================
FILE: test_harnesses/vomnibar_harness.js
================================================
import "../pages/all_content_scripts.js";
import "../pages/vomnibar_page.js";
function setup() {
Vomnibar.activate(0, {});
}
document.addEventListener("DOMContentLoaded", setup, false);
================================================
FILE: tests/dom_tests/dom_test_setup.js
================================================
globalThis.vimiumDomTestsAreRunning = true;
import * as shoulda from "../vendor/shoulda.js";
// Attach shoulda's functions -- like setup, context, should -- to the global namespace.
Object.assign(globalThis, shoulda);
globalThis.shoulda = shoulda;
document.addEventListener("DOMContentLoaded", async () => {
isEnabledForUrl = true;
await Settings.onLoaded();
await HUD.init();
});
================================================
FILE: tests/dom_tests/dom_tests.html
================================================
Vimium Tests
================================================
FILE: tests/dom_tests/dom_tests.js
================================================
let commandCount = null;
let commandName = null;
// Some tests have side effects on the handler stack and the active mode, so these are reset on
// setup. Also, some tests affect the focus (e.g. Vomnibar tests), so we make sure the window has
// the focus.
const initializeModeState = () => {
globalThis.focus();
Mode.reset();
handlerStack.reset();
const normalMode = installModes();
normalMode.setPassKeys("p");
normalMode.setKeyMapping({
m: { options: {}, command: "m" }, // A mapped key.
p: { options: {}, command: "p" }, // A pass key.
z: { p: { options: {}, command: "zp" } }, // Not a pass key.
});
normalMode.setCommandHandler(({ command, count }) => {
[commandName, commandCount] = [command.command, count];
});
commandName = null;
commandCount = null;
return normalMode;
};
//
// Retrieve the hint markers as an array object.
//
const getHintMarkerEls = () => Array.from(document.querySelectorAll(".vimiumHintMarker"));
const stubSettings = (key, value) => stub(Settings._settings, key, value);
HintCoordinator.sendMessage = (name, request) => {
if (request == null) {
request = {};
}
if (HintCoordinator[name]) {
HintCoordinator[name](request);
}
return request;
};
const activateLinkHintsMode = () => {
HintCoordinator.getHintDescriptors({ modeIndex: 0 }, {}, () => {});
HintCoordinator.activateMode({
frameIdToHintDescriptors: {},
modeIndex: 0,
originatingFrameId: frameId,
});
return HintCoordinator.linkHintsMode;
};
//
// Generate tests that are common to both default and filtered
// link hinting modes.
//
const createGeneralHintTests = (isFilteredMode) => {
globalThis.vimiumOnClickAttributeName = "does-not-matter";
context("Link hints", () => {
setup(() => {
initializeModeState();
const testContent = "testtress";
document.getElementById("test-div").innerHTML = testContent;
stubSettings("filterLinkHints", isFilteredMode);
stubSettings("linkHintCharacters", "ab");
stubSettings("linkHintNumbers", "12");
stub(globalThis, "windowIsFocused", () => true);
});
teardown(() => document.getElementById("test-div").innerHTML = "");
should("create hints when activated, discard them when deactivated", () => {
const mode = activateLinkHintsMode();
assert.isFalse(mode.containerEl == null);
mode.deactivateMode();
assert.isTrue(mode.containerEl == null);
});
should("position items correctly", () => {
const assertStartPosition = (element1, element2) => {
assert.equal(element1.getClientRects()[0].left, element2.getClientRects()[0].left);
assert.equal(element1.getClientRects()[0].top, element2.getClientRects()[0].top);
};
stub(document.body.style, "position", "static");
let mode = activateLinkHintsMode();
let markerEls = getHintMarkerEls();
assertStartPosition(document.getElementsByTagName("a")[0], markerEls[0]);
assertStartPosition(document.getElementsByTagName("a")[1], markerEls[1]);
mode.deactivateMode();
stub(document.body.style, "position", "relative");
mode = activateLinkHintsMode();
markerEls = getHintMarkerEls();
assertStartPosition(document.getElementsByTagName("a")[0], markerEls[0]);
assertStartPosition(document.getElementsByTagName("a")[1], markerEls[1]);
mode.deactivateMode();
});
});
};
createGeneralHintTests(false);
createGeneralHintTests(true);
context("False positives in link-hint", () => {
setup(() => {
const testContent = 'false positiveclickable' +
'clickable';
document.getElementById("test-div").innerHTML = testContent;
stubSettings("filterLinkHints", true);
stubSettings("linkHintNumbers", "12");
stub(globalThis, "windowIsFocused", () => true);
});
teardown(() => document.getElementById("test-div").innerHTML = "");
should("handle false positives", () => {
const mode = activateLinkHintsMode();
mode.deactivateMode();
assert.equal(["clickable", "clickable"], mode.hintMarkers.map((m) => m.linkText));
});
});
context("jsaction matching", () => {
let element;
setup(() => {
stubSettings("filterLinkHints", true);
const testContent = '