├── .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.js ├── completion_engines.js ├── completion_search.js ├── exclusions.js ├── main.js ├── marks.js ├── reload.js ├── tab_operations.js ├── tab_recency.js └── user_search_engines.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 ├── deno.lock ├── icons ├── action_disabled.svg ├── action_disabled_16.png ├── action_disabled_32.png ├── action_enabled.svg ├── action_enabled_16.png ├── action_enabled_32.png ├── action_partial.svg ├── action_partial_16.png ├── action_partial_32.png ├── icon.svg ├── icon128.png ├── icon16.png └── icon48.png ├── 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 ├── blank.html ├── command_listing.css ├── command_listing.html ├── command_listing.js ├── completion_engines_page.css ├── completion_engines_page.html ├── completion_engines_page.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 ├── image_map.png ├── 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_engines_page_test.js ├── completion_engines_test.js ├── 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 ├── 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 /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: File a bug 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | 11 | Include a clear bug description. 12 | 13 | **To Reproduce** 14 | 15 | Steps to reproduce the behavior: 16 | 17 | 1. Go to URL '...' 18 | 2. Click on '....' 19 | 20 | Include a screenshot if applicable. 21 | 22 | **Browser and Vimium version** 23 | 24 | If you're using Chrome, include the Chrome and OS version found at chrome://version. Also include 25 | the Vimium version found at chrome://extensions. 26 | 27 | If you're using Firefox, report the Firefox and OS version found at about:support. Also include the 28 | Vimium version found at about:addons. 29 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Provide a rationale for this PR, and a reference to the corresponding issue, if there is one. 4 | 5 | Please review the "Which pull requests get merged?" section in `CONTRIBUTING.md`. 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Vimium 2 | 3 | ## Reporting a bug 4 | 5 | File the issue [here](https://github.com/philc/vimium/issues). 6 | 7 | ## Contributing code 8 | 9 | You'd like to fix a bug or implement a feature? Great! Before getting started, understand Vimium's 10 | design principles and the goals of the maintainers. 11 | 12 | ### Vimium design principles 13 | 14 | When people first start using Vimium, it provides an incredibly powerful workflow improvement and it 15 | makes them feel awesome. Surprisingly, Vimium is applicable to a huge, broad population of people, 16 | not just users of Vim. 17 | 18 | In addition to power, a secondary goal of Vimium is approachability: minimizing the barriers which 19 | prevent a new user from feeling awesome. Many of Vimium's users haven't used Vim before -- about 1 20 | in 5 Chrome Store reviews say this -- and most people have strong web browsing habits forged from 21 | years of browsing. Given that, it's a great experience when Vimium feels like a natural addition to 22 | Chrome which augments, but doesn't break, the user's current browsing habits. 23 | 24 | **Principles:** 25 | 26 | 1. **Easy to understand**. Even if you're not very familiar with Vim. The Vimium video shows you all 27 | you need to know to start using Vimium and feel awesome. 28 | 2. **Reliable**. The core feature set works on most sites on the web. 29 | 3. **Immediately useful**. Vimium doesn't require any configuration or doc-reading before it's 30 | useful. Just watch the video or hit `?`. You can transition into using Vimium piecemeal; you 31 | don't need to jump in whole-hog from the start. 32 | 4. **Feels native**. Vimium doesn't drastically change the way Chrome looks or behaves. 33 | 5. **Simple**. The core feature set isn't overwhelming. This principle is particularly vulnerable as 34 | we add to Vimium, so it requires our active effort to maintain this simplicity. 35 | 6. **Code simplicity**. Developers find the Vimium codebase relatively simple and easy to jump into. 36 | This allows more people to fix bugs and implement features. 37 | 38 | ### Which pull requests get merged? 39 | 40 | **Goals of the maintainers** 41 | 42 | The maintainers of Vimium have limited bandwidth, which influences which PRs we can review and 43 | merge. 44 | 45 | Our goals are generally to keep Vimium small, maintainable, and really nail the broad appeal use 46 | cases. This is in contrast to adding and maintaining an increasing number of complex or niche 47 | features. We recommend those live in forked repos rather than the mainline Vimium repo. 48 | 49 | PRs we'll likely merge: 50 | 51 | - Reflect all of the Vimium design principles. 52 | - Are useful for lots of Vimium users. 53 | - Have simple implementations (straightforward code, few lines of code). 54 | 55 | PRs we likely won't: 56 | 57 | - Violate one or more of our design principles. 58 | - Are niche. 59 | - Have complex implementations -- more code than they're worth. 60 | 61 | Tips for preparing a PR: 62 | 63 | - If you want to check with us first before implementing something big, open an issue proposing the 64 | idea. You'll get feedback from the maintainers as to whether it's something we'll likely merge. 65 | - Try to keep PRs around 50 LOC or less. Bigger PRs create inertia for review. 66 | 67 | Here's the rationale behind this policy: 68 | 69 | - Vimium is a volunteer effort. To make it possible to keep the project up-to-date as the web and 70 | browsers evolve, the codebase has to remain small and maintainable. 71 | - If the maintainers don't use a feature, and most other users don't, then the feature will likely 72 | get neglected. 73 | - Every feature, particularly neglected ones, increase the complexity of the codebase and makes it 74 | more difficult and less pleasant to work on. 75 | - Adding a new feature is only part of the work. Once it's added, a feature must be maintained 76 | forever. 77 | - Vimium is a project which suffers from the 78 | [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): 79 | there are many users but unfortunately few maintainers. As a result, there is bandwidth to 80 | maintain only a limited number of features in the main repo. 81 | 82 | ### Installing From Source 83 | 84 | Vimium is written in Javascript. To install Vimium from source: 85 | 86 | **On Chrome/Chromium:** 87 | 88 | 1. Navigate to `chrome://extensions` 89 | 1. Toggle into Developer Mode 90 | 1. Click on "Load Unpacked Extension..." 91 | 1. Select the Vimium directory you've cloned from Github. 92 | 93 | **On Firefox:** 94 | 95 | Firefox needs a modified version of the manifest.json that's used for Chrome. To generate this, run 96 | 97 | `./make.js write-firefox-manifest` 98 | 99 | After that: 100 | 101 | 1. Open Firefox 102 | 1. Enter "about:debugging" in the URL bar 103 | 1. Click "This Firefox" on the left side 104 | 1. Click "Load Temporary Add-on" 105 | 1. Open the Vimium directory you've cloned from Github, and select any file inside. 106 | 107 | ### Running the tests 108 | 109 | Our tests use [shoulda.js](https://github.com/philc/shoulda.js) and 110 | [Puppeteer](https://github.com/puppeteer/puppeteer). To run the tests: 111 | 112 | 1. Install [Deno](https://deno.land/) if you don't have it already. 113 | 2. `deno run -A npm:puppeteer browsers install chrome` to install puppeteer 114 | 3. `./make.js test` to build the code and run the tests. 115 | 116 | ### Coding Style 117 | 118 | - Run `deno fmt` at the root of the Vimium project to format your code. 119 | - We generally follow the recommendations from the 120 | [Airbnb Javascript style guide](https://github.com/airbnb/javascript). 121 | - We wrap lines at 100 characters. 122 | - When writing comments, uppercase the first letter of your sentence, and put a period at the end. 123 | - We're currently using JavaScript language features from ES2018 or earlier. If we desire to use 124 | something introduced in a later version of JavaScript, we need to remember to update the minimum 125 | Chrome and Firefox versions required. 126 | -------------------------------------------------------------------------------- /CREDITS: -------------------------------------------------------------------------------- 1 | Authors & Maintainers: 2 | Ilya Sukhar (github: ilya) 3 | Phil Crosby (github: philc) 4 | 5 | Contributors: 6 | acrollet 7 | Adam Lindberg (github: eproxus) 8 | akhilman 9 | Ângelo Otávio Nuffer Nunes (github: angelonuffer) 10 | Bernardo B. Marques (github: bernardofire) 11 | Bill Casarin (github: jb55) 12 | Bill Mill (github: llimllib) 13 | Branden Rolston (github: branden) 14 | Caleb Spare (github: cespare) 15 | Carl Helmertz (github: chelmertz) 16 | Christian Stefanescu (github: stchris) 17 | ConradIrwin 18 | Daniel MacDougall (github: dmacdougall) 19 | drizzd 20 | gpurkins 21 | hogelog 22 | int3 23 | Johannes Emerich (github: knuton) 24 | Julian Naydichev (github: naydichev) 25 | Justin Blake (github: blaix) 26 | Knorkebrot 27 | lack 28 | markstos 29 | Matthew Cline 30 | Matt Garriott (github: mgarriott) 31 | Matthew Ryan (github: mrmr1993) 32 | Michael Hauser-Raspe (github: mijoharas) 33 | Murph (github: pandeiro) 34 | Niklas Baumstark (github: niklasb) 35 | rodimius 36 | Stephen Blott (github: smblott-github) 37 | Svein-Erik Larsen (github: feinom) 38 | Tim Morgan (github: seven1m) 39 | tsigo 40 | R.T. Lechow (github: rtlechow) 41 | Wang Ning (github:daning) 42 | Werner Laurensse (github: ab3) 43 | Timo Sand (github: deiga) 44 | Shiyong Chen (github: UncleBill) 45 | Utkarsh Upadhyay (github: PrestanceDesign) 47 | Dahan Gong (github: gdh1995) 48 | Scott Pinkelman (github: sco-tt) 49 | Darryl Pogue (github: dpogue) 50 | tobimensch 51 | Ramiro Araujo (github: ramiroaraujo) 52 | Daniel Skogly (github: poacher2k) 53 | Matt Wanchap (github: mwanchap) 54 | Leo Solidum (github: leosolid) 55 | 56 | Feel free to add real names in addition to GitHub usernames. 57 | -------------------------------------------------------------------------------- /MIT-LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Phil Crosby, Ilya Sukhar. 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vimium - The Hacker's Browser 2 | 3 | Vimium is a browser extension that provides keyboard-based navigation and control of the web in the 4 | spirit of the Vim editor. 5 | 6 | **Installation instructions:** 7 | 8 | - Chrome: 9 | [Chrome web store](https://chrome.google.com/extensions/detail/dbepggeogbaibhgnhhndojpepiihcmeb) 10 | - Edge: 11 | [Edge Add-ons](https://microsoftedge.microsoft.com/addons/detail/vimium/djmieaghokpkpjfbpelnlkfgfjapaopa) 12 | - Firefox: [Firefox Add-ons](https://addons.mozilla.org/en-GB/firefox/addon/vimium-ff/) 13 | 14 | To install from source, see [here](CONTRIBUTING.md#installing-from-source). 15 | 16 | Vimium's Options page can be reached via a link on the help dialog (type `?`) or via the button next 17 | to Vimium on the extension pages of Chrome and Edge (`chrome://extensions`), or Firefox 18 | (`about:addons`). 19 | 20 | ## Keyboard Bindings 21 | 22 | Modifier keys are specified as ``, ``, and `` for ctrl+x, meta+x, and alt+x 23 | respectively. For shift+x and ctrl-shift-x, just type `X` and ``. See the next section for how 24 | to customize these bindings. 25 | 26 | Once you have Vimium installed, you can see this list of key bindings at any time by typing `?`. 27 | 28 | Navigating the current page: 29 | 30 | ? show the help dialog for a list of all available keys 31 | h scroll left 32 | j scroll down 33 | k scroll up 34 | l scroll right 35 | gg scroll to top of the page 36 | G scroll to bottom of the page 37 | d scroll down half a page 38 | u scroll up half a page 39 | f open a link in the current tab 40 | F open a link in a new tab 41 | r reload 42 | gs view source 43 | i enter insert mode -- all commands will be ignored until you hit Esc to exit 44 | yy copy the current url to the clipboard 45 | yf copy a link url to the clipboard 46 | gf cycle forward to the next frame 47 | gF focus the main/top frame 48 | 49 | Navigating to new pages: 50 | 51 | o Open URL, bookmark, or history entry 52 | O Open URL, bookmark, history entry in a new tab 53 | b Open bookmark 54 | B Open bookmark in a new tab 55 | 56 | Using find: 57 | 58 | / enter find mode 59 | -- type your search query and hit enter to search, or Esc to cancel 60 | n cycle forward to the next find match 61 | N cycle backward to the previous find match 62 | 63 | For advanced usage, see [regular expressions](https://github.com/philc/vimium/wiki/Find-Mode) on the 64 | wiki. 65 | 66 | Navigating your history: 67 | 68 | H go back in history 69 | L go forward in history 70 | 71 | Manipulating tabs: 72 | 73 | J, gT go one tab left 74 | K, gt go one tab right 75 | g0 go to the first tab. Use ng0 to go to n-th tab 76 | g$ go to the last tab 77 | ^ visit the previously-visited tab 78 | t create tab 79 | yt duplicate current tab 80 | x close current tab 81 | X restore closed tab (i.e. unwind the 'x' command) 82 | T search through your open tabs 83 | W move current tab to new window 84 | pin/unpin current tab 85 | 86 | Using marks: 87 | 88 | ma, mA set local mark "a" (global mark "A") 89 | `a, `A jump to local mark "a" (global mark "A") 90 | `` jump back to the position before the previous jump 91 | -- that is, before the previous gg, G, n, N, / or `a 92 | 93 | Additional advanced browsing commands: 94 | 95 | ]], [[ Follow the link labeled 'next' or '>' ('previous' or '<') 96 | - helpful for browsing paginated sites 97 | open multiple links in a new tab 98 | gi focus the first (or n-th) text input box on the page. Use to cycle through options. 99 | gu go up one level in the URL hierarchy 100 | gU go up to root of the URL hierarchy 101 | ge edit the current URL 102 | gE edit the current URL and open in a new tab 103 | zH scroll all the way left 104 | zL scroll all the way right 105 | v enter visual mode; use p/P to paste-and-go, use y to yank 106 | V enter visual line mode 107 | R Hard reload the page (skip the cache) 108 | 109 | Vimium supports command repetition so, for example, hitting `5t` will open 5 tabs in rapid 110 | succession. `` (or ``) will clear any partial commands in the queue and will also exit 111 | insert and find modes. 112 | 113 | There are some advanced commands which aren't documented here; refer to the help dialog (type `?`) 114 | for a full list. 115 | 116 | ## Custom Key Mappings 117 | 118 | You may remap or unmap any of the default key bindings in the "Custom key mappings" on the options 119 | page. 120 | 121 | Enter one of the following key mapping commands per line: 122 | 123 | - `map key command`: Maps a key to a Vimium command. Overrides Chrome's default behavior (if any). 124 | - `unmap key`: Unmaps a key and restores Chrome's default behavior (if any). 125 | - `unmapAll`: Unmaps all bindings. This is useful if you want to completely wipe Vimium's defaults 126 | and start from scratch with your own setup. 127 | 128 | Examples: 129 | 130 | - `map scrollPageDown` maps ctrl+d to scrolling the page down. Chrome's default behavior of 131 | bringing up a bookmark dialog is suppressed. 132 | - `map r reload` maps the r key to reloading the page. 133 | - `unmap ` removes any mapping for ctrl+d and restores Chrome's default behavior. 134 | - `unmap r` removes any mapping for the r key. 135 | 136 | Available Vimium commands can be found via the "Show available commands" link near the key mapping 137 | box on the options page. The command name appears to the right of the description in parenthesis. 138 | 139 | You can add comments to key mappings by starting a line with `"` or `#`. 140 | 141 | The following special keys are available for mapping: 142 | 143 | - ``, ``, ``, `` for ctrl, alt, shift, and meta (command on Mac) respectively 144 | with any key. Replace `*` with the key of choice. 145 | - ``, ``, ``, `` for the arrow keys. 146 | - `` through `` for the function keys. 147 | - `` for the space key. 148 | - ``, ``, ``, ``, ``, `` and `` for the 149 | corresponding non-printable keys. 150 | 151 | Shifts are automatically detected so, for example, `` corresponds to ctrl+shift+7 on an English 152 | keyboard. 153 | 154 | ## More documentation 155 | 156 | Many of the more advanced or involved features are documented on 157 | [Vimium's GitHub wiki](https://github.com/philc/vimium/wiki). Also see the 158 | [FAQ](https://github.com/philc/vimium/wiki/FAQ). 159 | 160 | ## Contributing 161 | 162 | See [CONTRIBUTING.md](CONTRIBUTING.md) for details. 163 | 164 | ## Release Notes 165 | 166 | See [CHANGELOG](CHANGELOG.md) for the major changes in each release. 167 | 168 | ## License 169 | 170 | Copyright (c) Phil Crosby, Ilya Sukhar. See [MIT-LICENSE.txt](MIT-LICENSE.txt) for details. 171 | -------------------------------------------------------------------------------- /background_scripts/bg_utils.js: -------------------------------------------------------------------------------- 1 | import { TabRecency } from "./tab_recency.js"; 2 | 3 | // We're using browser.runtime to determine the browser name and version for Firefox. That API is 4 | // only available on the background page. We're not using window.navigator because it's unreliable. 5 | // Sometimes browser vendors will provide fake values, like when `privacy.resistFingerprinting` is 6 | // enabled on `about:config` of Firefox. 7 | export function isFirefox() { 8 | // Only Firefox has a `browser` object defined. 9 | return globalThis.browser 10 | // We want this browser check to also cover Firefox variants, like LibreWolf. See #3773. 11 | // We could also just check browserInfo.name against Firefox and Librewolf. 12 | ? browser.runtime.getURL("").startsWith("moz") 13 | : false; 14 | } 15 | 16 | export async function getFirefoxVersion() { 17 | return globalThis.browser ? (await browser.runtime.getBrowserInfo()).version : null; 18 | } 19 | 20 | // TODO(philc): tabRecency imports bg_utils. We should resovle the cycle for the sake of clarity. 21 | export const tabRecency = new TabRecency(); 22 | tabRecency.init(); 23 | -------------------------------------------------------------------------------- /background_scripts/completion_search.js: -------------------------------------------------------------------------------- 1 | import * as completionEngines from "./completion_engines.js"; 2 | 3 | // This is a wrapper class for completion engines. It handles the case where a custom search engine 4 | // includes a prefix query term (or terms). For example: 5 | // 6 | // https://www.google.com/search?q=javascript+%s 7 | // 8 | // In this case, we get better suggestions if we include the term "javascript" in queries sent to 9 | // the completion engine. This wrapper handles adding such prefixes to completion-engine queries and 10 | // removing them from the resulting suggestions. 11 | class EnginePrefixWrapper { 12 | constructor(searchUrl, engine) { 13 | this.searchUrl = searchUrl; 14 | this.engine = engine; 15 | } 16 | 17 | getUrl(queryTerms) { 18 | // This tests whether @searchUrl contains something of the form "...=abc+def+%s...", from which 19 | // we extract a prefix of the form "abc def ". 20 | if (/\=.+\+%s/.test(this.searchUrl)) { 21 | let terms = this.searchUrl.replace(/\+%s.*/, ""); 22 | terms = terms.replace(/.*=/, ""); 23 | terms = terms.replace(/\+/g, " "); 24 | 25 | queryTerms = [...terms.split(" "), ...queryTerms]; 26 | const prefix = `${terms} `; 27 | 28 | this.transformSuggestionsFn = (suggestions) => { 29 | return suggestions 30 | .filter((s) => s.startsWith(prefix)) 31 | .map((s) => s.slice(prefix.length)); 32 | }; 33 | } 34 | 35 | return this.engine.getUrl(queryTerms); 36 | } 37 | 38 | parse(responseText) { 39 | const suggestions = this.engine.parse(responseText); 40 | return this.transformSuggestionsFn ? this.transformSuggestionsFn(suggestions) : suggestions; 41 | } 42 | } 43 | 44 | let debug = false; 45 | const inTransit = {}; 46 | const completionCache = new SimpleCache(2 * 60 * 60 * 1000, 5000); // Two hours, 5000 entries. 47 | const engineCache = new SimpleCache(1000 * 60 * 60 * 1000); // 1000 hours. 48 | 49 | // The amount of time to wait for new requests before launching the current request (for example, 50 | // if the user is still typing). 51 | const DELAY = 100; 52 | 53 | // This gets incremented each time we make a request to the completion engine. This allows us to 54 | // dedupe requets which overlap, which is the case when the user is typing fast. 55 | let requestId = 0; 56 | 57 | async function get(url) { 58 | const timeoutDuration = 2500; 59 | const controller = new AbortController(); 60 | let isError = false; 61 | let responseText; 62 | const timer = Utils.setTimeout(timeoutDuration, () => controller.abort()); 63 | 64 | try { 65 | const response = await fetch(url, { signal: controller.signal }); 66 | responseText = await response.text(); 67 | } catch { 68 | // Fetch throws an error if the network is unreachable, etc. 69 | isError = true; 70 | } 71 | 72 | clearTimeout(timer); 73 | 74 | return isError ? null : responseText; 75 | } 76 | 77 | // Look up the completion engine for this searchUrl. 78 | function lookupEngine(searchUrl) { 79 | if (engineCache.has(searchUrl)) { 80 | return engineCache.get(searchUrl); 81 | } else { 82 | for (const engineClass of completionEngines.list) { 83 | const engine = new engineClass(); 84 | if (engine.match(searchUrl)) { 85 | return engineCache.set(searchUrl, engine); 86 | } 87 | } 88 | } 89 | } 90 | 91 | // This is the main entry point. 92 | // - searchUrl is the search engine's URL, e.g. Settings.get("searchUrl"), or a custom search 93 | // engine's URL. This is only used as a key for determining the relevant completion engine. 94 | // - queryTerms are the query terms. 95 | export async function complete(searchUrl, queryTerms) { 96 | const query = queryTerms.join(" ").toLowerCase(); 97 | 98 | // We don't complete queries which are too short: the results are usually useless. 99 | if (query.length < 4) return []; 100 | 101 | // We don't complete regular URLs or Javascript URLs. 102 | if (queryTerms.length == 1 && await UrlUtils.isUrl(query)) return []; 103 | if (UrlUtils.hasJavascriptProtocol(query)) return []; 104 | 105 | const engine = lookupEngine(searchUrl); 106 | if (!engine) return []; 107 | 108 | const completionCacheKey = JSON.stringify([searchUrl, queryTerms]); 109 | if (completionCache.has(completionCacheKey)) { 110 | if (debug) console.log("hit", completionCacheKey); 111 | return completionCache.get(completionCacheKey); 112 | } 113 | 114 | const createTimeoutPromise = (ms) => { 115 | return new Promise((resolve) => { 116 | setTimeout(() => { 117 | resolve(); 118 | }, ms); 119 | }); 120 | }; 121 | 122 | requestId++; 123 | const lastRequestId = requestId; 124 | 125 | // We delay sending a completion request in case the user is still typing. 126 | await createTimeoutPromise(DELAY); 127 | 128 | // If the user has issued a new query while we were waiting, then this query is old; abort it. 129 | if (lastRequestId != requestId) return []; 130 | 131 | const engineWrapper = new EnginePrefixWrapper(searchUrl, engine); 132 | const url = engineWrapper.getUrl(queryTerms); 133 | 134 | if (debug) console.log("GET", url); 135 | const responseText = await get(url); 136 | 137 | // Parsing the response may fail if we receive an unexpectedly-formatted response. In all cases, 138 | // we fall back to the catch clause, below. Therefore, we "fail safe" in the case of incorrect 139 | // or out-of-date completion engine implementations. 140 | let suggestions = []; 141 | let isError = responseText == null; 142 | if (!isError) { 143 | try { 144 | suggestions = engineWrapper.parse(responseText) 145 | // Make all suggestions lower case. It looks odd when suggestions from one 146 | // completion engine are upper case, and those from another are lower case. 147 | .map((s) => s.toLowerCase()) 148 | // Filter out the query itself. It's not adding anything. 149 | .filter((s) => s !== query); 150 | } catch (error) { 151 | if (debug) console.log("error:", error); 152 | isError = true; 153 | } 154 | } 155 | if (isError) { 156 | // We allow failures to be cached too, but remove them after just thirty seconds. 157 | Utils.setTimeout( 158 | 30 * 1000, 159 | () => completionCache.set(completionCacheKey, null), 160 | ); 161 | } 162 | 163 | completionCache.set(completionCacheKey, suggestions); 164 | return suggestions; 165 | } 166 | 167 | // Cancel any pending (ie. blocked on @delay) queries. Does not cancel in-flight queries. This is 168 | // called whenever the user is typing. 169 | export function cancel() { 170 | requestId++; 171 | } 172 | -------------------------------------------------------------------------------- /background_scripts/exclusions.js: -------------------------------------------------------------------------------- 1 | // This module manages manages the exclusion rule setting. An exclusion is an object with two 2 | // attributes: pattern and passKeys. The exclusion rules are an array of such objects. 3 | 4 | const ExclusionRegexpCache = { 5 | cache: {}, 6 | clear(cache) { 7 | this.cache = cache || {}; 8 | }, 9 | get(pattern) { 10 | if (pattern in this.cache) { 11 | return this.cache[pattern]; 12 | } else { 13 | let result; 14 | // We use try/catch to ensure that a broken regexp doesn't wholly cripple Vimium. 15 | try { 16 | result = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$"); 17 | } catch { 18 | if (!globalThis.isUnitTests) { 19 | console.log(`bad regexp in exclusion rule: ${pattern}`); 20 | } 21 | result = /^$/; // Match the empty string. 22 | } 23 | this.cache[pattern] = result; 24 | return result; 25 | } 26 | }, 27 | }; 28 | 29 | // Make RegexpCache, which is required on the page popup, accessible via the Exclusions object. 30 | const RegexpCache = ExclusionRegexpCache; 31 | 32 | // Merge the matching rules for URL, or null. In the normal case, we use the configured @rules; 33 | // hence, this is the default. However, when called from the page popup, we are testing what 34 | // effect candidate new rules would have on the current tab. In this case, the candidate rules are 35 | // provided by the caller. 36 | function getRule(url, rules) { 37 | if (rules == null) { 38 | rules = Settings.get("exclusionRules"); 39 | } 40 | const matchingRules = rules.filter((r) => 41 | r.pattern && (url.search(ExclusionRegexpCache.get(r.pattern)) >= 0) 42 | ); 43 | // An absolute exclusion rule (one with no passKeys) takes priority. 44 | for (const rule of matchingRules) { 45 | if (!rule.passKeys) return rule; 46 | } 47 | // Strip whitespace from all matching passKeys strings, and join them together. 48 | const passKeys = matchingRules.map((r) => r.passKeys.split(/\s+/).join("")).join(""); 49 | // TODO(philc): Remove this commented out code. 50 | // passKeys = (rule.passKeys.split(/\s+/).join "" for rule in matchingRules).join "" 51 | if (matchingRules.length > 0) { 52 | return { passKeys: Utils.distinctCharacters(passKeys) }; 53 | } else { 54 | return null; 55 | } 56 | } 57 | 58 | export function isEnabledForUrl(url) { 59 | const rule = getRule(url); 60 | return { 61 | isEnabledForUrl: !rule || (rule.passKeys.length > 0), 62 | passKeys: rule ? rule.passKeys : "", 63 | }; 64 | } 65 | 66 | function setRules(rules) { 67 | // Callers map a rule to null to have it deleted, and rules without a pattern are useless. 68 | const newRules = rules.filter((rule) => rule?.pattern); 69 | Settings.set("exclusionRules", newRules); 70 | } 71 | 72 | function onSettingsUpdated() { 73 | // NOTE(mrmr1993): In FF, the |rules| argument will be garbage collected when the exclusions 74 | // popup is closed. Do NOT store it/use it asynchronously. 75 | ExclusionRegexpCache.clear(); 76 | } 77 | 78 | Settings.addEventListener("change", () => onSettingsUpdated()); 79 | -------------------------------------------------------------------------------- /background_scripts/marks.js: -------------------------------------------------------------------------------- 1 | import * as TabOperations from "./tab_operations.js"; 2 | 3 | // This returns the key which is used for storing mark locations in chrome.storage.sync. 4 | // Exported for tests. 5 | export function getLocationKey(markName) { 6 | return `vimiumGlobalMark|${markName}`; 7 | } 8 | 9 | // Get the part of a URL we use for matching here (that is, everything up to the first anchor). 10 | function getBaseUrl(url) { 11 | return url.split("#")[0]; 12 | } 13 | 14 | // Create a global mark. We record vimiumSecret with the mark so that we can tell later, when the 15 | // mark is used, whether this is the original Vimium session or a subsequent session. This affects 16 | // whether or not tabId can be considered valid. 17 | export async function create(req, sender) { 18 | const items = await chrome.storage.session.get("vimiumSecret"); 19 | const markInfo = { 20 | vimiumSecret: items.vimiumSecret, 21 | markName: req.markName, 22 | url: getBaseUrl(sender.tab.url), 23 | tabId: sender.tab.id, 24 | scrollX: req.scrollX, 25 | scrollY: req.scrollY, 26 | }; 27 | 28 | if ((markInfo.scrollX != null) && (markInfo.scrollY != null)) { 29 | saveMark(markInfo); 30 | } else { 31 | // The front-end frame hasn't provided the scroll position (because it's not the top frame 32 | // within its tab). We need to ask the top frame what its scroll position is. 33 | chrome.tabs.sendMessage(sender.tab.id, { handler: "getScrollPosition" }, (response) => { 34 | saveMark(Object.assign(markInfo, { scrollX: response.scrollX, scrollY: response.scrollY })); 35 | }); 36 | } 37 | } 38 | 39 | function saveMark(markInfo) { 40 | const item = {}; 41 | item[getLocationKey(markInfo.markName)] = markInfo; 42 | chrome.storage.local.set(item); 43 | } 44 | 45 | // Goto a global mark. We try to find the original tab. If we can't find that, then we try to find 46 | // another tab with the original URL, and use that. And if we can't find such an existing tab, then 47 | // we create a new one. Whichever of those we do, we then set the scroll position to the original 48 | // scroll position. 49 | export async function goto(req) { 50 | const vimiumSecret = (await chrome.storage.session.get("vimiumSecret"))["vimiumSecret"]; 51 | const key = getLocationKey(req.markName); 52 | const items = await chrome.storage.local.get(key); 53 | const markInfo = items[key]; 54 | if (markInfo.vimiumSecret !== vimiumSecret) { 55 | // This is a different Vimium instantiation, so markInfo.tabId is definitely out of date. 56 | Utils.debugLog("marks: vimiumSecret is incorrect."); 57 | await focusOrLaunch(markInfo, req); 58 | } else { 59 | // Check whether markInfo.tabId still exists. According to 60 | // https://developer.chrome.com/extensions/tabs, tab Ids are unqiue within a Chrome 61 | // session. So, if we find a match, we can use it. 62 | let tab; 63 | // This will throw an error if the tab doesn't exist. 64 | try { 65 | tab = await chrome.tabs.get(markInfo.tabId); 66 | } catch { 67 | // Swallow. 68 | } 69 | const originalTabStillExists = tab?.url && (markInfo.url === getBaseUrl(tab.url)); 70 | if (originalTabStillExists) { 71 | await gotoPositionInTab(markInfo); 72 | } else { 73 | await focusOrLaunch(markInfo, req); 74 | } 75 | } 76 | } 77 | 78 | // Focus an existing tab and scroll to the given position within it. 79 | async function gotoPositionInTab({ tabId, scrollX, scrollY }) { 80 | const tab = await chrome.tabs.update(tabId, { active: true }); 81 | chrome.windows.update(tab.windowId, { focused: true }); 82 | chrome.tabs.sendMessage(tabId, { handler: "setScrollPosition", scrollX, scrollY }); 83 | } 84 | 85 | // The tab we're trying to find no longer exists. We either find another tab with a matching URL and 86 | // use it, or we create a new tab. 87 | async function focusOrLaunch(markInfo, req) { 88 | // If we're not going to be scrolling to a particular position in the tab, then we choose all tabs 89 | // with a matching URL prefix. Otherwise, we require an exact match (because it doesn't make sense 90 | // to scroll unless there's an exact URL match). 91 | const markIsScrolled = markInfo.scrollX > 0 || markInfo.scrollY > 0; 92 | const query = markIsScrolled ? markInfo.url : `${markInfo.url}*`; 93 | const tabs = await chrome.tabs.query({ url: query }); 94 | if (tabs.length > 0) { 95 | // There is at least one matching tab. Pick one and go to it. 96 | const tab = await pickTab(tabs); 97 | gotoPositionInTab(Object.assign(markInfo, { tabId: tab.id })); 98 | } else { 99 | // There is no existing matching tab. We'll have to create one. 100 | TabOperations.openUrlInNewTab( 101 | Object.assign(req, { url: getBaseUrl(markInfo.url) }), 102 | (tab) => { 103 | // Note. tabLoadedHandlers is defined in "main.js". The handler below will be called when 104 | // the tab is loaded, its DOM is ready and it registers with the background page. 105 | return tabLoadedHandlers[tab.id] = () => 106 | gotoPositionInTab(Object.assign(markInfo, { tabId: tab.id })); 107 | }, 108 | ); 109 | } 110 | } 111 | 112 | // Given a list of tabs candidate tabs, pick one. Prefer tabs in the current window and tabs with 113 | // shorter (matching) URLs. 114 | async function pickTab(tabs) { 115 | // NOTE(philc): We assume getCurrent() can return null, but I didn't confirm this. Also, it should 116 | // be impossible for the user to invoke Vimium-related keys if all windows are closed. 117 | const window = await chrome.windows.getCurrent(); 118 | const windowId = window?.id; 119 | // Prefer tabs in the current window, if there are any. 120 | const tabsInWindow = tabs.filter((tab) => tab.windowId === windowId); 121 | if (tabsInWindow.length > 0) tabs = tabsInWindow; 122 | // If more than one tab remains and the current tab is still a candidate, then don't pick the 123 | // current tab (because jumping to it does nothing). 124 | if (tabs.length > 1) { 125 | tabs = tabs.filter((t) => !t.active); 126 | } 127 | 128 | // Prefer shorter URLs. 129 | tabs.sort((a, b) => a.url.length - b.url.length); 130 | return tabs[0]; 131 | } 132 | -------------------------------------------------------------------------------- /background_scripts/reload.js: -------------------------------------------------------------------------------- 1 | // Used as part of a debugging workflow when developing the extension. 2 | (async () => { 3 | await chrome.runtime.sendMessage({ handler: "reloadVimiumExtension" }); 4 | // NOTE(philc): This page's window is supposed to automatically close when the extension reloads 5 | // itself, but I've noticed sometimes this fails. 6 | globalThis.close(); 7 | })(); 8 | -------------------------------------------------------------------------------- /background_scripts/tab_operations.js: -------------------------------------------------------------------------------- 1 | // 2 | // Functions for opening URLs in tabs. 3 | // 4 | 5 | import * as bgUtils from "../background_scripts/bg_utils.js"; 6 | import "../lib/url_utils.js"; 7 | 8 | const chromeNewTabUrl = "about:newtab"; 9 | 10 | // Opens request.url in the current tab. If the URL is keywords, search for them in the default 11 | // search engine. If the URL is a javascript: snippet, execute it in the current tab. 12 | export async function openUrlInCurrentTab(request) { 13 | const urlStr = await UrlUtils.convertToUrl(request.url); 14 | if (urlStr == null) { 15 | // The requested destination is not a URL, so treat it like a search query. 16 | chrome.search.query({ text: request.url }); 17 | } else if (UrlUtils.hasJavascriptProtocol(urlStr)) { 18 | // Note that when injecting JavaScript, it's subject to the site's CSP. Sites with strict CSPs 19 | // (like github.com, developer.mozilla.org) will raise an error when we try to run this code. 20 | // See https://github.com/philc/vimium/issues/4331. 21 | const scriptingArgs = { 22 | target: { tabId: request.tabId }, 23 | func: (text) => { 24 | const prefix = "javascript:"; 25 | text = text.slice(prefix.length).trim(); 26 | // TODO(philc): Why do we try to double decode here? Discover and then document it. 27 | text = decodeURIComponent(text); 28 | try { 29 | text = decodeURIComponent(text); 30 | } catch { 31 | // Swallow 32 | } 33 | const el = document.createElement("script"); 34 | el.textContent = text; 35 | document.head.appendChild(el); 36 | }, 37 | args: [urlStr], 38 | }; 39 | if (!bgUtils.isFirefox()) { 40 | // The MAIN world -- where the webpage runs -- is less privileged than the ISOLATED world. 41 | // Specifying a world is required for Chrome, but not Firefox. 42 | // As of Firefox 118, specifying "MAIN" as the world is not yet supported. 43 | scriptingArgs.world = "MAIN"; 44 | } 45 | chrome.scripting.executeScript(scriptingArgs); 46 | } else { 47 | // The requested destination is a regular URL. 48 | chrome.tabs.update(request.tabId, { url: urlStr }); 49 | } 50 | } 51 | 52 | // Opens request.url in new tab and switches to it. 53 | // Returns the created tab. 54 | export async function openUrlInNewTab(request) { 55 | const urlStr = await UrlUtils.convertToUrl(request.url); 56 | const tabConfig = { windowId: request.tab.windowId }; 57 | const position = request.position; 58 | let tabIndex = null; 59 | switch (position) { 60 | case "start": 61 | tabIndex = 0; 62 | break; 63 | case "before": 64 | tabIndex = request.tab.index; 65 | break; 66 | // if on Chrome or on Firefox but without openerTabId, `tabs.create` opens a tab at the end. 67 | // but on Firefox and with openerTabId, it opens a new tab next to the opener tab 68 | case "end": 69 | tabIndex = bgUtils.isFirefox() ? 9999 : null; 70 | break; 71 | // "after" is the default case when there are no options. 72 | default: 73 | tabIndex = request.tab.index + 1; 74 | } 75 | tabConfig.index = tabIndex; 76 | tabConfig.active = request.active ?? true; 77 | tabConfig.openerTabId = request.tab.id; 78 | 79 | let newTab; 80 | 81 | if (urlStr == null) { 82 | // The requested destination is not a URL, so treat it like a search query. 83 | // 84 | // The chrome.search.query API lets us open the search in a new tab, but it doesn't let us 85 | // control the precise position of that tab. So, we open a new blank tab using our position 86 | // parameter, and then execute the search in that tab. 87 | 88 | // In Chrome, if we create a blank tab and call chrome.search.query, the omnibar is focused, 89 | // which we don't want. To work around that, first create an empty page. This is not needed in 90 | // Firefox. And in fact, firefox doesn't support a data:text URL to the chrome.tab.create API. 91 | tabConfig.url = bgUtils.isFirefox() ? null : "data:text/html,"; 92 | newTab = await chrome.tabs.create(tabConfig); 93 | const query = request.url; 94 | await chrome.search.query({ text: query, tabId: newTab.id }); 95 | } else { 96 | // The requested destination is a regular URL. 97 | if (urlStr != chromeNewTabUrl) { 98 | // Firefox does not support "about:newtab" in chrome.tabs.create. 99 | tabConfig.url = urlStr; 100 | } 101 | newTab = await chrome.tabs.create(tabConfig); 102 | } 103 | return newTab; 104 | } 105 | 106 | // Open request.url in new window and switch to it. 107 | export async function openUrlInNewWindow(request) { 108 | const winConfig = { 109 | url: await UrlUtils.convertToUrl(request.url), 110 | active: true, 111 | }; 112 | if (request.active != null) { 113 | winConfig.active = request.active; 114 | } 115 | // Firefox does not support "about:newtab" in chrome.tabs.create. 116 | if (tabConfig["url"] === chromeNewTabUrl) { 117 | delete winConfig["url"]; 118 | } 119 | await chrome.windows.create(winConfig); 120 | } 121 | -------------------------------------------------------------------------------- /background_scripts/tab_recency.js: -------------------------------------------------------------------------------- 1 | // TabRecency associates an integer with each tab id representing how recently it has been accessed. 2 | // The order of tabs as tracked by TabRecency is used to provide a recency-based ordering in the 3 | // tabs vomnibar. 4 | // 5 | // The values are persisted to chrome.storage.session so that they're not lost when the extension's 6 | // background page is unloaded. 7 | // 8 | // Callers must await TabRecency.init before calling recencyScore or getTabsByRecency. 9 | // 10 | // In theory, the browser's tab.lastAccessed timestamp field should allow us to sort tabs by 11 | // recency, but in practice it does not work across several edge cases. See the comments on #4368. 12 | class TabRecency { 13 | constructor() { 14 | this.counter = 1; 15 | this.tabIdToCounter = {}; 16 | this.loaded = false; 17 | this.queuedActions = []; 18 | } 19 | 20 | // Add listeners to chrome.tabs, and load the index from session storage. 21 | async init() { 22 | if (this.initPromise) { 23 | await this.initPromise; 24 | return; 25 | } 26 | let resolveFn; 27 | this.initPromise = new Promise((resolve, _reject) => { 28 | resolveFn = resolve; 29 | }); 30 | 31 | chrome.tabs.onActivated.addListener((activeInfo) => { 32 | this.queueAction("register", activeInfo.tabId); 33 | }); 34 | chrome.tabs.onRemoved.addListener((tabId) => { 35 | this.queueAction("deregister", tabId); 36 | }); 37 | 38 | chrome.tabs.onReplaced.addListener((addedTabId, removedTabId) => { 39 | this.queueAction("deregister", removedTabId); 40 | this.queueAction("register", addedTabId); 41 | }); 42 | 43 | chrome.windows.onFocusChanged.addListener(async (windowId) => { 44 | if (windowId == chrome.windows.WINDOW_ID_NONE) return; 45 | const tabs = await chrome.tabs.query({ windowId, active: true }); 46 | if (tabs[0]) { 47 | this.queueAction("register", tabs[0].id); 48 | } 49 | }); 50 | 51 | await this.loadFromStorage(); 52 | while (this.queuedActions.length > 0) { 53 | const [action, tabId] = this.queuedActions.shift(); 54 | this.handleAction(action, tabId); 55 | } 56 | this.loaded = true; 57 | resolveFn(); 58 | } 59 | 60 | // Loads the index from session storage. 61 | async loadFromStorage() { 62 | const tabsPromise = chrome.tabs.query({}); 63 | const storagePromise = chrome.storage.session.get("tabRecency"); 64 | const [tabs, storage] = await Promise.all([tabsPromise, storagePromise]); 65 | if (storage.tabRecency == null) return; 66 | 67 | let maxCounter = 0; 68 | for (const counter of Object.values(storage.tabRecency)) { 69 | if (maxCounter < counter) maxCounter = counter; 70 | } 71 | if (this.counter < maxCounter) { 72 | this.counter = maxCounter; 73 | } 74 | 75 | this.tabIdToCounter = Object.assign({}, storage.tabRecency); 76 | 77 | // Remove any tab IDs which aren't currently loaded. 78 | const tabIds = new Set(tabs.map((t) => t.id)); 79 | for (const id in this.tabIdToCounter) { 80 | if (!tabIds.has(parseInt(id))) { 81 | delete this.tabIdToCounter[id]; 82 | } 83 | } 84 | } 85 | 86 | async saveToStorage() { 87 | await chrome.storage.session.set({ tabRecency: this.tabIdToCounter }); 88 | } 89 | 90 | // - action: "register" or "unregister". 91 | queueAction(action, tabId) { 92 | if (!this.loaded) { 93 | this.queuedActions.push([action, tabId]); 94 | } else { 95 | this.handleAction(action, tabId); 96 | } 97 | } 98 | 99 | // - action: "register" or "unregister". 100 | handleAction(action, tabId) { 101 | if (action == "register") { 102 | this.register(tabId); 103 | } else if (action == "deregister") { 104 | this.deregister(tabId); 105 | } else { 106 | throw new Error(`Unexpected action type: ${action}`); 107 | } 108 | } 109 | 110 | register(tabId) { 111 | this.counter++; 112 | this.tabIdToCounter[tabId] = this.counter; 113 | this.saveToStorage(); 114 | } 115 | 116 | deregister(tabId) { 117 | delete this.tabIdToCounter[tabId]; 118 | this.saveToStorage(); 119 | } 120 | 121 | // Recently-visited tabs get a higher score (except the current tab, which gets a low score). 122 | recencyScore(tabId) { 123 | if (!this.loaded) throw new Error("TabRecency hasn't yet been loaded."); 124 | const tabCounter = this.tabIdToCounter[tabId]; 125 | const isCurrentTab = tabCounter == this.counter; 126 | if (isCurrentTab) return 0; 127 | return (tabCounter ?? 1) / this.counter; // tabCounter may be null. 128 | } 129 | 130 | // Returns a list of tab Ids sorted by recency, most recent tab first. 131 | getTabsByRecency() { 132 | if (!this.loaded) throw new Error("TabRecency hasn't yet been loaded."); 133 | const ids = Object.keys(this.tabIdToCounter); 134 | ids.sort((a, b) => this.tabIdToCounter[b] - this.tabIdToCounter[a]); 135 | return ids.map((id) => parseInt(id)); 136 | } 137 | } 138 | 139 | export { TabRecency }; 140 | -------------------------------------------------------------------------------- /background_scripts/user_search_engines.js: -------------------------------------------------------------------------------- 1 | import "../lib/url_utils.js"; 2 | import * as commands from "./commands.js"; 3 | 4 | // A struct representing a search engine entry in the "searchEngine" setting. 5 | export class UserSearchEngine { 6 | keyword; 7 | url; 8 | description; 9 | constructor(o) { 10 | Object.seal(this); 11 | if (o) Object.assign(this, o); 12 | } 13 | } 14 | 15 | // Parses a user's search engine configuration from Settings, and stores the parsed results. 16 | // TODO(philc): Should this be responsible for updating itself when Settings changes, rather than 17 | // the callers doing so? Or, remove this class and re-parse the configuration every keystroke in 18 | // Vomnibar, so we don't introduce another layer of caching in the code. 19 | export let keywordToEngine = {}; 20 | 21 | // Returns a result of the shape: { keywordToEngine, validationErrors }. 22 | export function parseConfig(configText) { 23 | const results = {}; 24 | const errors = []; 25 | for (const line of commands.parseLines(configText)) { 26 | const tokens = line.split(/\s+/); 27 | if (tokens.length < 2) { 28 | errors.push(`This line has less than two tokens: ${line}`); 29 | continue; 30 | } 31 | if (!tokens[0].includes(":")) { 32 | errors.push(`This line doesn't include a ":" character: ${line}`); 33 | continue; 34 | } 35 | const keyword = tokens[0].split(":")[0]; 36 | const url = tokens[1]; 37 | const description = tokens.length > 2 ? tokens.slice(2).join(" ") : `search (${keyword})`; 38 | 39 | if (!UrlUtils.urlHasProtocol(url) && !UrlUtils.hasJavascriptProtocol(url)) { 40 | errors.push(`This search engine doens't have a valid URL: ${line}`); 41 | continue; 42 | } 43 | results[keyword] = new UserSearchEngine({ keyword, url, description }); 44 | } 45 | return { 46 | keywordToEngine: results, 47 | validationErrors: errors, 48 | }; 49 | } 50 | 51 | export function set(searchEnginesConfigText) { 52 | keywordToEngine = parseConfig(searchEnginesConfigText).keywordToEngine; 53 | } 54 | -------------------------------------------------------------------------------- /content_scripts/file_urls.css: -------------------------------------------------------------------------------- 1 | /* Chrome file:// URLs set draggable=true for links to files (CSS selector .icon.file). This 2 | * automatically sets -webkit-user-select: none, which disables selecting the file names and so 3 | * prevents Vimium's search from working as expected. Here, we reset the value back to default. */ 4 | .icon.file { 5 | -webkit-user-select: auto !important; 6 | } 7 | -------------------------------------------------------------------------------- /content_scripts/marks.js: -------------------------------------------------------------------------------- 1 | const Marks = { 2 | previousPositionRegisters: ["`", "'"], 3 | localRegisters: {}, 4 | currentRegistryEntry: null, 5 | mode: null, 6 | 7 | exit(continuation = null) { 8 | if (this.mode != null) { 9 | this.mode.exit(); 10 | } 11 | this.mode = null; 12 | if (continuation) { 13 | return continuation(); // TODO(philc): Is this return necessary? 14 | } 15 | }, 16 | 17 | // This returns the key which is used for storing mark locations in localStorage. 18 | getLocationKey(keyChar) { 19 | return `vimiumMark|${globalThis.location.href.split("#")[0]}|${keyChar}`; 20 | }, 21 | 22 | getMarkString() { 23 | return JSON.stringify({ 24 | scrollX: globalThis.scrollX, 25 | scrollY: globalThis.scrollY, 26 | hash: globalThis.location.hash, 27 | }); 28 | }, 29 | 30 | setPreviousPosition() { 31 | const markString = this.getMarkString(); 32 | for (const reg of this.previousPositionRegisters) { 33 | this.localRegisters[reg] = markString; 34 | } 35 | }, 36 | 37 | showMessage(message, keyChar) { 38 | HUD.show(`${message} \"${keyChar}\".`, 1000); 39 | }, 40 | 41 | // If is depressed, then it's a global mark, otherwise it's a local mark. This is 42 | // consistent vim's [A-Z] for global marks and [a-z] for local marks. However, it also admits 43 | // other non-Latin characters. The exceptions are "`" and "'", which are always considered local 44 | // marks. The "swap" command option inverts global and local marks. 45 | isGlobalMark(event, keyChar) { 46 | let shiftKey = event.shiftKey; 47 | if (this.currentRegistryEntry.options.swap) { 48 | shiftKey = !shiftKey; 49 | } 50 | return shiftKey && !this.previousPositionRegisters.includes(keyChar); 51 | }, 52 | 53 | activateCreateMode(_count, { registryEntry }) { 54 | this.currentRegistryEntry = registryEntry; 55 | this.mode = new Mode(); 56 | this.mode.init({ 57 | name: "create-mark", 58 | indicator: "Create mark...", 59 | exitOnEscape: true, 60 | suppressAllKeyboardEvents: true, 61 | keydown: (event) => { 62 | if (KeyboardUtils.isPrintable(event)) { 63 | const keyChar = KeyboardUtils.getKeyChar(event); 64 | this.exit(() => { 65 | if (this.isGlobalMark(event, keyChar)) { 66 | // We record the current scroll position, but only if this is the top frame within the 67 | // tab. Otherwise, we'll fetch the scroll position of the top frame from the 68 | // background page later. 69 | let scrollX, scrollY; 70 | if (DomUtils.isTopFrame()) { 71 | [scrollX, scrollY] = [globalThis.scrollX, globalThis.scrollY]; 72 | } 73 | chrome.runtime.sendMessage({ 74 | handler: "createMark", 75 | markName: keyChar, 76 | scrollX, 77 | scrollY, 78 | }, () => this.showMessage("Created global mark", keyChar)); 79 | } else { 80 | localStorage[this.getLocationKey(keyChar)] = this.getMarkString(); 81 | this.showMessage("Created local mark", keyChar); 82 | } 83 | }); 84 | return handlerStack.suppressEvent; 85 | } 86 | }, 87 | }); 88 | }, 89 | 90 | activateGotoMode(_count, { registryEntry }) { 91 | this.currentRegistryEntry = registryEntry; 92 | this.mode = new Mode(); 93 | this.mode.init({ 94 | name: "goto-mark", 95 | indicator: "Go to mark...", 96 | exitOnEscape: true, 97 | suppressAllKeyboardEvents: true, 98 | keydown: (event) => { 99 | if (KeyboardUtils.isPrintable(event)) { 100 | this.exit(() => { 101 | const keyChar = KeyboardUtils.getKeyChar(event); 102 | if (this.isGlobalMark(event, keyChar)) { 103 | // This key must match @getLocationKey() in the back end. 104 | const key = `vimiumGlobalMark|${keyChar}`; 105 | chrome.storage.local.get(key, function (items) { 106 | if (key in items) { 107 | chrome.runtime.sendMessage({ handler: "gotoMark", markName: keyChar }); 108 | HUD.show(`Jumped to global mark '${keyChar}'`, 1000); 109 | } else { 110 | HUD.show(`Global mark not set '${keyChar}'`, 1000); 111 | } 112 | }); 113 | } else { 114 | const markString = this.localRegisters[keyChar] != null 115 | ? this.localRegisters[keyChar] 116 | : localStorage[this.getLocationKey(keyChar)]; 117 | if (markString != null) { 118 | this.setPreviousPosition(); 119 | const position = JSON.parse(markString); 120 | if (position.hash && (position.scrollX === 0) && (position.scrollY === 0)) { 121 | globalThis.location.hash = position.hash; 122 | } else { 123 | globalThis.scrollTo(position.scrollX, position.scrollY); 124 | } 125 | this.showMessage("Jumped to local mark", keyChar); 126 | } else { 127 | this.showMessage("Local mark not set", keyChar); 128 | } 129 | } 130 | }); 131 | return handlerStack.suppressEvent; 132 | } 133 | }, 134 | }); 135 | }, 136 | }; 137 | 138 | globalThis.Marks = Marks; 139 | -------------------------------------------------------------------------------- /content_scripts/mode_insert.js: -------------------------------------------------------------------------------- 1 | class InsertMode extends Mode { 2 | constructor(options) { 3 | super(); 4 | if (options == null) { 5 | options = {}; 6 | } 7 | 8 | // There is one permanently-installed instance of InsertMode. It tracks focus changes and 9 | // activates/deactivates itself (by setting @insertModeLock) accordingly. 10 | this.permanent = options.permanent; 11 | 12 | // If truthy, then we were activated by the user (with "i"). 13 | this.global = options.global; 14 | 15 | this.passNextKeyKeys = []; 16 | 17 | // This list of keys is parsed from the user's key mapping config by commands.js, and stored in 18 | // chrome.storage.session. 19 | chrome.storage.session.get("passNextKeyKeys").then((value) => { 20 | this.passNextKeyKeys = value.passNextKeyKeys || []; 21 | }); 22 | 23 | chrome.storage.onChanged.addListener(async (changes, areaName) => { 24 | if (areaName != "local") return; 25 | if (changes.passNextKeyKeys == null) return; 26 | this.passNextKeyKeys = changes.passNextKeyKeys.newValue; 27 | }); 28 | 29 | const handleKeyEvent = (event) => { 30 | if (!this.isActive(event)) { 31 | return this.continueBubbling; 32 | } 33 | 34 | // See comment here: 35 | // https://github.com/philc/vimium/commit/48c169bd5a61685bb4e67b1e76c939dbf360a658. 36 | const activeElement = this.getActiveElement(); 37 | if ((activeElement === document.body) && activeElement.isContentEditable) { 38 | return this.passEventToPage; 39 | } 40 | 41 | // Check for a pass-next-key key. 42 | const keyString = KeyboardUtils.getKeyCharString(event); 43 | if (this.passNextKeyKeys.includes(keyString)) { 44 | new PassNextKeyMode(); 45 | } else if ((event.type === "keydown") && KeyboardUtils.isEscape(event)) { 46 | if (DomUtils.isFocusable(activeElement)) { 47 | activeElement.blur(); 48 | } 49 | 50 | if (!this.permanent) { 51 | this.exit(); 52 | } 53 | } else { 54 | return this.passEventToPage; 55 | } 56 | 57 | return this.suppressEvent; 58 | }; 59 | 60 | const defaults = { 61 | name: "insert", 62 | indicator: !this.permanent && !Settings.get("hideHud") ? "Insert mode" : null, 63 | keypress: handleKeyEvent, 64 | keydown: handleKeyEvent, 65 | }; 66 | 67 | super.init(Object.assign(defaults, options)); 68 | 69 | // Only for tests. This gives us a hook to test the status of the permanently-installed 70 | // instance. 71 | if (this.permanent) { 72 | InsertMode.permanentInstance = this; 73 | } 74 | } 75 | 76 | isActive(event) { 77 | if (event === InsertMode.suppressedEvent) { 78 | return false; 79 | } 80 | if (this.global) { 81 | return true; 82 | } 83 | return DomUtils.isFocusable(this.getActiveElement()); 84 | } 85 | 86 | getActiveElement() { 87 | let activeElement = document.activeElement; 88 | while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) { 89 | activeElement = activeElement.shadowRoot.activeElement; 90 | } 91 | return activeElement; 92 | } 93 | 94 | static suppressEvent(event) { 95 | return this.suppressedEvent = event; 96 | } 97 | } 98 | 99 | // This allows PostFindMode to suppress the permanently-installed InsertMode instance. 100 | InsertMode.suppressedEvent = null; 101 | 102 | // This implements the pasNexKey command. 103 | class PassNextKeyMode extends Mode { 104 | constructor(count) { 105 | if (count == null) { 106 | count = 1; 107 | } 108 | super(); 109 | let seenKeyDown = false; 110 | let keyDownCount = 0; 111 | 112 | super.init({ 113 | name: "pass-next-key", 114 | indicator: "Pass next key.", 115 | // We exit on blur because, once we lose the focus, we can no longer track key events. 116 | exitOnBlur: globalThis, 117 | keypress: () => { 118 | return this.passEventToPage; 119 | }, 120 | 121 | keydown: () => { 122 | seenKeyDown = true; 123 | keyDownCount += 1; 124 | return this.passEventToPage; 125 | }, 126 | 127 | keyup: () => { 128 | if (seenKeyDown) { 129 | if (!(--keyDownCount > 0)) { 130 | if (!(--count > 0)) { 131 | this.exit(); 132 | } 133 | } 134 | } 135 | return this.passEventToPage; 136 | }, 137 | }); 138 | } 139 | } 140 | 141 | globalThis.InsertMode = InsertMode; 142 | globalThis.PassNextKeyMode = PassNextKeyMode; 143 | -------------------------------------------------------------------------------- /content_scripts/mode_key_handler.js: -------------------------------------------------------------------------------- 1 | // Example key mapping (@keyMapping): 2 | // i: 3 | // command: "enterInsertMode", ... # This is a registryEntry object (as too are the other commands). 4 | // g: 5 | // g: 6 | // command: "scrollToTop", ... 7 | // t: 8 | // command: "nextTab", ... 9 | // 10 | // This key-mapping structure is generated by Commands.installKeyStateMapping and may be 11 | // arbitrarily deep. Observe that @keyMapping["g"] is itself also a valid key mapping. At any point, 12 | // the key state (@keyState) consists of a (non-empty) list of such mappings. 13 | 14 | class KeyHandlerMode extends Mode { 15 | setKeyMapping(keyMapping) { 16 | this.keyMapping = keyMapping; 17 | this.reset(); 18 | } 19 | setPassKeys(passKeys) { 20 | this.passKeys = passKeys; 21 | this.reset(); 22 | } 23 | 24 | // Only for tests. 25 | setCommandHandler(commandHandler) { 26 | this.commandHandler = commandHandler; 27 | } 28 | 29 | // Reset the key state, optionally retaining the count provided. 30 | reset(countPrefix) { 31 | if (countPrefix == null) countPrefix = 0; 32 | this.countPrefix = countPrefix; 33 | this.keyState = [this.keyMapping]; 34 | } 35 | 36 | init(options) { 37 | const args = Object.assign(options, { keydown: this.onKeydown.bind(this) }); 38 | super.init(args); 39 | 40 | this.commandHandler = options.commandHandler || (function () {}); 41 | this.setKeyMapping(options.keyMapping || {}); 42 | 43 | if (options.exitOnEscape) { 44 | // If we're part way through a command's key sequence, then a first Escape should reset the 45 | // key state, and only a second Escape should actually exit this mode. 46 | this.push({ 47 | _name: "key-handler-escape-listener", 48 | keydown: (event) => { 49 | if (KeyboardUtils.isEscape(event) && !this.isInResetState()) { 50 | this.reset(); 51 | return this.suppressEvent; 52 | } else { 53 | return this.continueBubbling; 54 | } 55 | }, 56 | }); 57 | } 58 | } 59 | 60 | onKeydown(event) { 61 | const keyChar = KeyboardUtils.getKeyCharString(event); 62 | const isEscape = KeyboardUtils.isEscape(event); 63 | if (isEscape && ((this.countPrefix !== 0) || (this.keyState.length !== 1))) { 64 | return DomUtils.consumeKeyup(event, () => this.reset()); 65 | } else if (isEscape && HelpDialog && HelpDialog.isShowing()) { 66 | // If the help dialog loses the focus, then Escape should hide it; see point 2 in #2045. 67 | HelpDialog.toggle(); 68 | return this.suppressEvent; 69 | } else if (isEscape) { 70 | // Some links stay "open" after clicking, until you mouse off of them, like Wikipedia's link 71 | // preview popups. If the user types escape, issue a mouseout event here. See #3073. 72 | HintCoordinator.mouseOutOfLastClickedElement(); 73 | return this.continueBubbling; 74 | } else if (this.isMappedKey(keyChar)) { 75 | this.handleKeyChar(keyChar); 76 | return this.suppressEvent; 77 | } else if (this.isCountKey(keyChar)) { 78 | const digit = parseInt(keyChar); 79 | this.reset(this.keyState.length === 1 ? (this.countPrefix * 10) + digit : digit); 80 | return this.suppressEvent; 81 | } else { 82 | if (keyChar) this.reset(); 83 | return this.continueBubbling; 84 | } 85 | } 86 | 87 | // This tests whether there is a mapping of keyChar in the current key state (and accounts for 88 | // pass keys). 89 | isMappedKey(keyChar) { 90 | // TODO(philc): tweak the generated js. 91 | return ((this.keyState.filter((mapping) => keyChar in mapping))[0] != null) && 92 | !this.isPassKey(keyChar); 93 | } 94 | 95 | // This tests whether keyChar is a digit (and accounts for pass keys). 96 | isCountKey(keyChar) { 97 | return keyChar && 98 | ((this.countPrefix > 0 ? "0" : "1") <= keyChar && keyChar <= "9") && 99 | !this.isPassKey(keyChar); 100 | } 101 | 102 | // Keystrokes are *never* considered pass keys if the user has begun entering a command. So, for 103 | // example, if 't' is a passKey, then the "t"-s of 'gt' and '99t' are neverthless handled as 104 | // regular keys. 105 | isPassKey(keyChar) { 106 | // Find all *continuation* mappings for keyChar in the current key state (i.e. not the full key 107 | // mapping). 108 | const mappings = this.keyState.filter((mapping) => 109 | keyChar in mapping && (mapping !== this.keyMapping) 110 | ); 111 | // If there are no continuation mappings, and there's no count prefix, and keyChar is a pass 112 | // key, then it's a pass key. 113 | return mappings.length == 0 && 114 | this.countPrefix == 0 && 115 | this.passKeys && 116 | this.passKeys.includes(keyChar); 117 | } 118 | 119 | isInResetState() { 120 | return (this.countPrefix === 0) && (this.keyState.length === 1); 121 | } 122 | 123 | handleKeyChar(keyChar) { 124 | // A count prefix applies only so long a keyChar is mapped in @keyState[0]; e.g. 7gj should be 1j. 125 | if (!(keyChar in this.keyState[0])) { 126 | this.countPrefix = 0; 127 | } 128 | 129 | // Advance the key state. The new key state is the current mappings of keyChar, plus @keyMapping. 130 | const state = this.keyState.filter((mapping) => keyChar in mapping).map((mapping) => 131 | mapping[keyChar] 132 | ); 133 | state.push(this.keyMapping); 134 | this.keyState = state; 135 | 136 | if (this.keyState[0].command != null) { 137 | const command = this.keyState[0]; 138 | const count = this.countPrefix > 0 ? this.countPrefix : null; 139 | this.reset(); 140 | this.commandHandler({ command, count }); 141 | if ((this.options.count != null) && (--this.options.count <= 0)) { 142 | this.exit(); 143 | } 144 | } 145 | return this.suppressEvent; 146 | } 147 | } 148 | 149 | globalThis.KeyHandlerMode = KeyHandlerMode; 150 | -------------------------------------------------------------------------------- /content_scripts/vomnibar.js: -------------------------------------------------------------------------------- 1 | // 2 | // This wraps the vomnibar iframe, which we inject into the page to provide the vomnibar. 3 | // 4 | const Vomnibar = { 5 | vomnibarUI: null, 6 | 7 | // sourceFrameId here (and below) is the ID of the frame from which this request originates, which 8 | // may be different from the current frame. 9 | 10 | activate(sourceFrameId, registryEntry) { 11 | const options = Object.assign({}, registryEntry.options, { completer: "omni" }); 12 | this.open(sourceFrameId, options); 13 | }, 14 | 15 | activateInNewTab(sourceFrameId, registryEntry) { 16 | const options = Object.assign({}, registryEntry.options, { completer: "omni", newTab: true }); 17 | this.open(sourceFrameId, options); 18 | }, 19 | 20 | activateTabSelection(sourceFrameId) { 21 | this.open(sourceFrameId, { 22 | completer: "tabs", 23 | selectFirst: true, 24 | }); 25 | }, 26 | 27 | activateBookmarks(sourceFrameId, registryEntry) { 28 | const options = Object.assign({}, registryEntry.options, { 29 | completer: "bookmarks", 30 | selectFirst: true, 31 | }); 32 | this.open(sourceFrameId, options); 33 | }, 34 | 35 | activateBookmarksInNewTab(sourceFrameId, registryEntry) { 36 | const options = Object.assign({}, registryEntry.options, { 37 | completer: "bookmarks", 38 | selectFirst: true, 39 | newTab: true, 40 | }); 41 | this.open(sourceFrameId, options); 42 | }, 43 | 44 | activateEditUrl(sourceFrameId) { 45 | this.open(sourceFrameId, { 46 | completer: "omni", 47 | selectFirst: false, 48 | query: globalThis.location.href, 49 | }); 50 | }, 51 | 52 | activateEditUrlInNewTab(sourceFrameId) { 53 | this.open(sourceFrameId, { 54 | completer: "omni", 55 | selectFirst: false, 56 | query: globalThis.location.href, 57 | newTab: true, 58 | }); 59 | }, 60 | 61 | init() { 62 | if (!this.vomnibarUI) { 63 | this.vomnibarUI = new UIComponent(); 64 | this.vomnibarUI.load("pages/vomnibar_page.html", "vomnibar-frame"); 65 | } 66 | }, 67 | 68 | // Opens the vomnibar. 69 | // - vomnibarShowOptions: 70 | // completer: The name of the completer to fetch results from. 71 | // query: Optional. Text to prefill the Vomnibar with. 72 | // selectFirst: Optional. Whether to select the first entry. 73 | // newTab: Optional. Whether to open the result in a new tab. 74 | // keyword: A keyword which will scope the search to a UserSearchEngine. 75 | open(sourceFrameId, vomnibarShowOptions) { 76 | this.init(); 77 | // The Vomnibar cannot coexist with the help dialog (it causes focus issues). 78 | HelpDialog.abort(); 79 | Utils.assertType(VomnibarShowOptions, vomnibarShowOptions); 80 | this.vomnibarUI.show( 81 | Object.assign(vomnibarShowOptions, { name: "activate" }), 82 | { sourceFrameId, focus: true }, 83 | ); 84 | }, 85 | }; 86 | 87 | globalThis.Vomnibar = Vomnibar; 88 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "fmt": { 3 | "lineWidth": 100 4 | }, 5 | "imports": { 6 | "@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.49", 7 | "@std/fs": "jsr:@std/fs@^1.0.8", 8 | "@std/http": "jsr:@std/http@^1.0.12", 9 | "@std/path": "jsr:@std/path@^1.0.8", 10 | "jsdom": "npm:jsdom@^26.0.0", 11 | "json5": "npm:json5@^2.2.3" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /icons/action_disabled.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/action_disabled_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/vimium/b44dbff804bf4023b7ae8cd725c210b56769d293/icons/action_disabled_16.png -------------------------------------------------------------------------------- /icons/action_disabled_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/vimium/b44dbff804bf4023b7ae8cd725c210b56769d293/icons/action_disabled_32.png -------------------------------------------------------------------------------- /icons/action_enabled.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/action_enabled_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/vimium/b44dbff804bf4023b7ae8cd725c210b56769d293/icons/action_enabled_16.png -------------------------------------------------------------------------------- /icons/action_enabled_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/vimium/b44dbff804bf4023b7ae8cd725c210b56769d293/icons/action_enabled_32.png -------------------------------------------------------------------------------- /icons/action_partial.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/action_partial_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/vimium/b44dbff804bf4023b7ae8cd725c210b56769d293/icons/action_partial_16.png -------------------------------------------------------------------------------- /icons/action_partial_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/vimium/b44dbff804bf4023b7ae8cd725c210b56769d293/icons/action_partial_32.png -------------------------------------------------------------------------------- /icons/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/vimium/b44dbff804bf4023b7ae8cd725c210b56769d293/icons/icon128.png -------------------------------------------------------------------------------- /icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/vimium/b44dbff804bf4023b7ae8cd725c210b56769d293/icons/icon16.png -------------------------------------------------------------------------------- /icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/vimium/b44dbff804bf4023b7ae8cd725c210b56769d293/icons/icon48.png -------------------------------------------------------------------------------- /lib/chrome_api_stubs.js: -------------------------------------------------------------------------------- 1 | // 2 | // Mock the Chrome extension API for our tests. In Deno and Pupeteer, the Chrome extension APIs are 3 | // not available. 4 | // 5 | 6 | const shouldInstallStubs = globalThis.document?.location.pathname.includes("dom_tests.html") || 7 | // This query string is added to pages that we load in iframes from dom_tests.html, like 8 | // hud_page.html 9 | globalThis.document?.location.search.includes("dom_tests=true"); 10 | 11 | if (shouldInstallStubs) { 12 | globalThis.chromeMessages = []; 13 | 14 | document.hasFocus = () => true; 15 | 16 | globalThis.forTrusted = (handler) => handler; 17 | 18 | const fakeManifest = { 19 | version: "1.51", 20 | }; 21 | 22 | globalThis.chrome = { 23 | runtime: { 24 | id: 123456, 25 | 26 | connect() { 27 | return { 28 | onMessage: { 29 | addListener() {}, 30 | }, 31 | onDisconnect: { 32 | addListener() {}, 33 | }, 34 | postMessage() {}, 35 | }; 36 | }, 37 | onMessage: { 38 | addListener() {}, 39 | }, 40 | sendMessage(message) { 41 | // TODO(philc): This stub should return a an empty Promise, not the length of the 42 | // chromeMessages array. Some portion fo the dom_tests.html setup depends on this value, so 43 | // the tests break. Fix. 44 | return chromeMessages.unshift(message); 45 | }, 46 | getManifest() { 47 | return fakeManifest; 48 | }, 49 | getURL(url) { 50 | return `../../${url}`; 51 | }, 52 | }, 53 | storage: { 54 | local: { 55 | async get() { 56 | return await {}; 57 | }, 58 | async set() {}, 59 | }, 60 | sync: { 61 | async get() { 62 | return await {}; 63 | }, 64 | async set() {}, 65 | }, 66 | session: { 67 | async get() { 68 | return await {}; 69 | }, 70 | async set() {}, 71 | }, 72 | onChanged: { 73 | addListener() {}, 74 | }, 75 | }, 76 | extension: { 77 | inIncognitoContext: false, 78 | getURL(url) { 79 | return chrome.runtime.getURL(url); 80 | }, 81 | }, 82 | }; 83 | } 84 | -------------------------------------------------------------------------------- /lib/find_mode_history.js: -------------------------------------------------------------------------------- 1 | // This // implements find-mode query history as a list of raw queries, most recent first. 2 | // This is under lib/ since it is used by both content scripts and iframes from pages/. 3 | const FindModeHistory = { 4 | storage: chrome.storage.session, 5 | key: "findModeRawQueryList", 6 | max: 50, 7 | rawQueryList: null, 8 | 9 | async init() { 10 | this.isIncognitoMode = chrome.extension.inIncognitoContext; 11 | 12 | if (!this.rawQueryList) { 13 | if (this.isIncognitoMode) this.key = "findModeRawQueryListIncognito"; 14 | 15 | let result = await this.storage.get(this.key); 16 | if (this.isIncognitoMode) { 17 | // This is the first incognito tab, so we need to initialize the incognito-mode query 18 | // history. 19 | result = await this.storage.get("findModeRawQueryList"); 20 | this.rawQueryList = result.findModeRawQueryList || []; 21 | this.storage.set({ findModeRawQueryListIncognito: this.rawQueryList }); 22 | } else { 23 | this.rawQueryList = result[this.key] || []; 24 | } 25 | } 26 | 27 | chrome.storage.onChanged.addListener((changes, _area) => { 28 | if (changes[this.key]) { 29 | this.rawQueryList = changes[this.key].newValue; 30 | } 31 | }); 32 | }, 33 | 34 | getQuery(index) { 35 | if (index == null) index = 0; 36 | return this.rawQueryList[index] || ""; 37 | }, 38 | 39 | async saveQuery(query) { 40 | if (query.length == 0) return; 41 | this.rawQueryList = this.refreshRawQueryList(query, this.rawQueryList); 42 | const newSetting = {}; 43 | newSetting[this.key] = this.rawQueryList; 44 | await this.storage.set(newSetting); 45 | // If there are any active incognito-mode tabs, then propagate this query to those tabs too. 46 | if (!this.isIncognitoMode) { 47 | const result = await this.storage.get("findModeRawQueryListIncognito"); 48 | if (result.findModeRawQueryListIncognito) { 49 | await this.storage.set({ 50 | findModeRawQueryListIncognito: this.refreshRawQueryList( 51 | query, 52 | result.findModeRawQueryListIncognito, 53 | ), 54 | }); 55 | } 56 | } 57 | }, 58 | 59 | refreshRawQueryList(query, rawQueryList) { 60 | return ([query].concat(rawQueryList.filter((q) => q !== query))).slice(0, this.max + 1); 61 | }, 62 | }; 63 | 64 | globalThis.FindModeHistory = FindModeHistory; 65 | -------------------------------------------------------------------------------- /lib/handler_stack.js: -------------------------------------------------------------------------------- 1 | class HandlerStack { 2 | constructor() { 3 | this.debug = false; 4 | this.eventNumber = 0; 5 | this.stack = []; 6 | this.counter = 0; 7 | 8 | // A handler should return this value to immediately discontinue bubbling and pass the event on 9 | // to the underlying page. 10 | this.passEventToPage = new Object(); 11 | 12 | // A handler should return this value to indicate that the event has been consumed, and no 13 | // further processing should take place. The event does not propagate to the underlying page. 14 | this.suppressPropagation = new Object(); 15 | 16 | // A handler should return this value to indicate that bubbling should be restarted. Typically, 17 | // this is used when, while bubbling an event, a new mode is pushed onto the stack. 18 | this.restartBubbling = new Object(); 19 | 20 | // A handler should return this value to continue bubbling the event. 21 | this.continueBubbling = true; 22 | 23 | // A handler should return this value to suppress an event. 24 | this.suppressEvent = false; 25 | } 26 | 27 | // Adds a handler to the top of the stack. Returns a unique ID for that handler that can be used 28 | // to remove it later. 29 | push(handler) { 30 | handler.id = ++this.counter; 31 | if (!handler._name) handler._name = `anon-${handler.id}`; 32 | this.stack.push(handler); 33 | return handler.id = ++this.counter; 34 | } 35 | 36 | // As above, except the new handler is added to the bottom of the stack. 37 | unshift(handler) { 38 | handler.id = ++this.counter; 39 | if (!handler._name) handler._name = `anon-${handler.id}`; 40 | handler._name += "/unshift"; 41 | this.stack.unshift(handler); 42 | return handler.id = ++this.counter; 43 | } 44 | 45 | // Called whenever we receive a key or other event. Each individual handler has the option to stop 46 | // the event's propagation by returning a falsy value, or stop bubbling by 47 | // returning @suppressPropagation or 48 | // @passEventToPage. 49 | bubbleEvent(type, event) { 50 | this.eventNumber += 1; 51 | const eventNumber = this.eventNumber; 52 | for (const handler of this.stack.slice().reverse()) { 53 | // A handler might have been removed (handler.id == null), so check; or there might just be no 54 | // handler for this type of event. 55 | if (!(handler != null ? handler.id : undefined) || !handler[type]) { 56 | if (this.debug) { 57 | this.logResult(eventNumber, type, event, handler, `skip [${(handler[type] != null)}]`); 58 | } 59 | } else { 60 | this.currentId = handler.id; 61 | const result = handler[type].call(this, event); 62 | if (this.debug) this.logResult(eventNumber, type, event, handler, result); 63 | if (result === this.passEventToPage) { 64 | return true; 65 | } else if (result === this.suppressPropagation) { 66 | if (type === "keydown") { 67 | DomUtils.consumeKeyup(event, null, true); 68 | } else { 69 | DomUtils.suppressPropagation(event); 70 | } 71 | return false; 72 | } else if (result === this.restartBubbling) { 73 | return this.bubbleEvent(type, event); 74 | } else if ( 75 | (result === this.continueBubbling) || (result && (result !== this.suppressEvent)) 76 | ) { 77 | true; // Do nothing, but continue bubbling. 78 | } else { 79 | // result is @suppressEvent or falsy. 80 | if (this.isChromeEvent(event)) { 81 | if (type === "keydown") { 82 | DomUtils.consumeKeyup(event); 83 | } else { 84 | DomUtils.suppressEvent(event); 85 | } 86 | } 87 | return false; 88 | } 89 | } 90 | } 91 | 92 | // None of our handlers care about this event, so pass it to the page. 93 | return true; 94 | } 95 | 96 | remove(id) { 97 | if (id == null) id = this.currentId; 98 | for (let i = this.stack.length - 1; i >= 0; i--) { 99 | const handler = this.stack[i]; 100 | if (handler.id === id) { 101 | // Mark the handler as removed. 102 | handler.id = null; 103 | this.stack.splice(i, 1); 104 | break; 105 | } 106 | } 107 | } 108 | 109 | // The handler stack handles chrome events (which may need to be suppressed) and internal (pseudo) 110 | // events. This checks whether the event at hand is a chrome event. 111 | isChromeEvent(event) { 112 | // TODO(philc): Shorten this. 113 | return ((event != null ? event.preventDefault : undefined) != null) || 114 | ((event != null ? event.stopImmediatePropagation : undefined) != null); 115 | } 116 | 117 | // Convenience wrappers. Handlers must return an approriate value. These are wrappers which 118 | // handlers can use to always return the same value. This then means that the handler itself can 119 | // be implemented without regard to its return value. 120 | alwaysContinueBubbling(handler = null) { 121 | if (typeof handler === "function") { 122 | handler(); 123 | } 124 | return this.continueBubbling; 125 | } 126 | 127 | alwaysSuppressPropagation(handler = null) { 128 | // TODO(philc): Shorten this. 129 | if ((typeof handler === "function" ? handler() : undefined) === this.suppressEvent) { 130 | return this.suppressEvent; 131 | } else return this.suppressPropagation; 132 | } 133 | 134 | // Debugging. 135 | logResult(eventNumber, type, event, handler, result) { 136 | if ((event != null ? event.type : undefined) === "keydown") { // Tweak this as needed. 137 | let label = (() => { 138 | switch (result) { 139 | case this.passEventToPage: 140 | return "passEventToPage"; 141 | case this.suppressEvent: 142 | return "suppressEvent"; 143 | case this.suppressPropagation: 144 | return "suppressPropagation"; 145 | case this.restartBubbling: 146 | return "restartBubbling"; 147 | case "skip": 148 | return "skip"; 149 | case true: 150 | return "continue"; 151 | } 152 | })(); 153 | if (!label) label = result ? "continue/truthy" : "suppress"; 154 | console.log(`${eventNumber}`, type, handler._name, label); 155 | } 156 | } 157 | 158 | show() { 159 | console.log(`${this.eventNumber}:`); 160 | for (const handler of this.stack.slice().reverse()) { 161 | console.log(" ", handler._name); 162 | } 163 | } 164 | 165 | // For tests only. 166 | reset() { 167 | this.stack = []; 168 | } 169 | } 170 | 171 | globalThis.HandlerStack = HandlerStack; 172 | globalThis.handlerStack = new HandlerStack(); 173 | -------------------------------------------------------------------------------- /lib/keyboard_utils.js: -------------------------------------------------------------------------------- 1 | let mapKeyRegistry = {}; 2 | Utils.monitorChromeSessionStorage("mapKeyRegistry", (value) => { 3 | return mapKeyRegistry = value; 4 | }); 5 | 6 | const KeyboardUtils = { 7 | // This maps event.key key names to Vimium key names. 8 | keyNames: { 9 | "ArrowLeft": "left", 10 | "ArrowUp": "up", 11 | "ArrowRight": "right", 12 | "ArrowDown": "down", 13 | " ": "space", 14 | "\n": "enter", // on a keypress event of Ctrl+Enter, tested on Chrome 92 and Windows 10 15 | }, 16 | 17 | init() { 18 | // TODO(philc): Remove this guard clause once Deno has a userAgent. 19 | // https://github.com/denoland/deno/issues/14362 20 | // As of 2022-04-30, Deno does not have userAgent defined on navigator. 21 | if (navigator.userAgent == null) { 22 | this.platform = "Unknown"; 23 | return; 24 | } 25 | if (navigator.userAgent.indexOf("Mac") !== -1) { 26 | this.platform = "Mac"; 27 | } else if (navigator.userAgent.indexOf("Linux") !== -1) { 28 | this.platform = "Linux"; 29 | } else { 30 | this.platform = "Windows"; 31 | } 32 | }, 33 | 34 | getKeyChar(event) { 35 | let key; 36 | const canUseEventKey = !Settings.get("ignoreKeyboardLayout") && 37 | // On MacOS, when alt (option) is pressed, event.key is a symbol. E.g. the key press 38 | // yields ç. In such cases, use event.code instead to identify which key was pressed, so that 39 | // the user can intuitively map in their keymappings, rather than . See #3197. 40 | !(this.platform == "Mac" && event.altKey); 41 | 42 | if (canUseEventKey) { 43 | key = event.key; 44 | } else if (!event.code) { 45 | key = event.key != null ? event.key : ""; // Fall back to event.key (see #3099). 46 | } else if (event.code.slice(0, 6) === "Numpad") { 47 | // We cannot correctly emulate the numpad, so fall back to event.key; see #2626. 48 | key = event.key; 49 | } else { 50 | // The logic here is from the vim-like-key-notation project 51 | // (https://github.com/lydell/vim-like-key-notation). 52 | key = event.code; 53 | if (key.slice(0, 3) === "Key") key = key.slice(3); 54 | // Translate some special keys to event.key-like strings and handle . 55 | if (this.enUsTranslations[key]) { 56 | key = event.shiftKey ? this.enUsTranslations[key][1] : this.enUsTranslations[key][0]; 57 | } else if ((key.length === 1) && !event.shiftKey) { 58 | key = key.toLowerCase(); 59 | } 60 | } 61 | 62 | // It appears that key is not always defined (see #2453). 63 | if (!key) { 64 | return ""; 65 | } else if (key in this.keyNames) { 66 | return this.keyNames[key]; 67 | } else if (this.isModifier(event)) { 68 | return ""; // Don't resolve modifier keys. 69 | } else if (key.length === 1) { 70 | return key; 71 | } else { 72 | return key.toLowerCase(); 73 | } 74 | }, 75 | 76 | getKeyCharString(event) { 77 | let keyChar = this.getKeyChar(event); 78 | if (!keyChar) { 79 | return; 80 | } 81 | 82 | const modifiers = []; 83 | 84 | if (event.shiftKey && (keyChar.length === 1)) keyChar = keyChar.toUpperCase(); 85 | // These must be in alphabetical order (to match the sorted modifier order in 86 | // Commands.normalizeKey). 87 | if (event.altKey) modifiers.push("a"); 88 | if (event.ctrlKey) modifiers.push("c"); 89 | if (event.metaKey) modifiers.push("m"); 90 | if (event.shiftKey && (keyChar.length > 1)) modifiers.push("s"); 91 | 92 | keyChar = [...modifiers, keyChar].join("-"); 93 | if (1 < keyChar.length) keyChar = `<${keyChar}>`; 94 | keyChar = mapKeyRegistry[keyChar] != null ? mapKeyRegistry[keyChar] : keyChar; 95 | return keyChar; 96 | }, 97 | 98 | isEscape: (function () { 99 | let useVimLikeEscape = true; 100 | Utils.monitorChromeSessionStorage("useVimLikeEscape", (value) => useVimLikeEscape = value); 101 | 102 | return function (event) { 103 | // is mapped to Escape in Vim by default. 104 | // Escape with a keyCode 229 means that this event comes from IME, and should not be treated 105 | // as a direct/normal Escape event. IME will handle the event, not vimium. 106 | // See https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html 107 | return ((event.key === "Escape") && (event.keyCode !== 229)) || 108 | (useVimLikeEscape && (this.getKeyCharString(event) === "")); 109 | }; 110 | })(), 111 | 112 | isBackspace(event) { 113 | return ["Backspace", "Delete"].includes(event.key); 114 | }, 115 | 116 | isPrintable(event) { 117 | const s = this.getKeyCharString(event); 118 | return s && s.length == 1; 119 | }, 120 | 121 | isModifier(event) { 122 | return ["Control", "Shift", "Alt", "OS", "AltGraph", "Meta"].includes(event.key); 123 | }, 124 | 125 | enUsTranslations: { 126 | "Backquote": ["`", "~"], 127 | "Minus": ["-", "_"], 128 | "Equal": ["=", "+"], 129 | "Backslash": ["\\", "|"], 130 | "IntlBackslash": ["\\", "|"], 131 | "BracketLeft": ["[", "{"], 132 | "BracketRight": ["]", "}"], 133 | "Semicolon": [";", ":"], 134 | "Quote": ["'", '"'], 135 | "Comma": [",", "<"], 136 | "Period": [".", ">"], 137 | "Slash": ["/", "?"], 138 | "Space": [" ", " "], 139 | "Digit1": ["1", "!"], 140 | "Digit2": ["2", "@"], 141 | "Digit3": ["3", "#"], 142 | "Digit4": ["4", "$"], 143 | "Digit5": ["5", "%"], 144 | "Digit6": ["6", "^"], 145 | "Digit7": ["7", "&"], 146 | "Digit8": ["8", "*"], 147 | "Digit9": ["9", "("], 148 | "Digit0": ["0", ")"], 149 | }, 150 | }; 151 | 152 | KeyboardUtils.init(); 153 | 154 | globalThis.KeyboardUtils = KeyboardUtils; 155 | -------------------------------------------------------------------------------- /lib/rect.js: -------------------------------------------------------------------------------- 1 | // Commands for manipulating rects. 2 | const Rect = { 3 | // Create a rect given the top left and bottom right corners. 4 | create(x1, y1, x2, y2) { 5 | return { 6 | bottom: y2, 7 | top: y1, 8 | left: x1, 9 | right: x2, 10 | width: x2 - x1, 11 | height: y2 - y1, 12 | }; 13 | }, 14 | 15 | copy(rect) { 16 | return { 17 | bottom: rect.bottom, 18 | top: rect.top, 19 | left: rect.left, 20 | right: rect.right, 21 | width: rect.width, 22 | height: rect.height, 23 | }; 24 | }, 25 | 26 | // Translate a rect by x horizontally and y vertically. 27 | translate(rect, x, y) { 28 | if (x == null) x = 0; 29 | if (y == null) y = 0; 30 | return { 31 | bottom: rect.bottom + y, 32 | top: rect.top + y, 33 | left: rect.left + x, 34 | right: rect.right + x, 35 | width: rect.width, 36 | height: rect.height, 37 | }; 38 | }, 39 | 40 | // Subtract rect2 from rect1, returning an array of rects which are in rect1 but not rect2. 41 | subtract(rect1, rect2) { 42 | // Bound rect2 by rect1 43 | rect2 = this.create( 44 | Math.max(rect1.left, rect2.left), 45 | Math.max(rect1.top, rect2.top), 46 | Math.min(rect1.right, rect2.right), 47 | Math.min(rect1.bottom, rect2.bottom), 48 | ); 49 | 50 | // If bounding rect2 has made the width or height negative, rect1 does not contain rect2. 51 | if ((rect2.width < 0) || (rect2.height < 0)) return [Rect.copy(rect1)]; 52 | 53 | // 54 | // All the possible rects, in the order 55 | // +-+-+-+ 56 | // |1|2|3| 57 | // +-+-+-+ 58 | // |4| |5| 59 | // +-+-+-+ 60 | // |6|7|8| 61 | // +-+-+-+ 62 | // where the outer rectangle is rect1 and the inner rectangle is rect 2. Note that the rects may 63 | // be of width or height 0. 64 | // 65 | const rects = [ 66 | // Top row. 67 | this.create(rect1.left, rect1.top, rect2.left, rect2.top), 68 | this.create(rect2.left, rect1.top, rect2.right, rect2.top), 69 | this.create(rect2.right, rect1.top, rect1.right, rect2.top), 70 | // Middle row. 71 | this.create(rect1.left, rect2.top, rect2.left, rect2.bottom), 72 | this.create(rect2.right, rect2.top, rect1.right, rect2.bottom), 73 | // Bottom row. 74 | this.create(rect1.left, rect2.bottom, rect2.left, rect1.bottom), 75 | this.create(rect2.left, rect2.bottom, rect2.right, rect1.bottom), 76 | this.create(rect2.right, rect2.bottom, rect1.right, rect1.bottom), 77 | ]; 78 | 79 | return rects.filter((rect) => (rect.height > 0) && (rect.width > 0)); 80 | }, 81 | 82 | // Determine whether two rects overlap. 83 | intersects(rect1, rect2) { 84 | return (rect1.right > rect2.left) && 85 | (rect1.left < rect2.right) && 86 | (rect1.bottom > rect2.top) && 87 | (rect1.top < rect2.bottom); 88 | }, 89 | 90 | // Determine whether two rects overlap, including 0-width intersections at borders. 91 | intersectsStrict(rect1, rect2) { 92 | return (rect1.right >= rect2.left) && (rect1.left <= rect2.right) && 93 | (rect1.bottom >= rect2.top) && (rect1.top <= rect2.bottom); 94 | }, 95 | 96 | equals(rect1, rect2) { 97 | for (const property of ["top", "bottom", "left", "right", "width", "height"]) { 98 | if (rect1[property] !== rect2[property]) return false; 99 | } 100 | return true; 101 | }, 102 | 103 | intersect(rect1, rect2) { 104 | return this.create( 105 | Math.max(rect1.left, rect2.left), 106 | Math.max(rect1.top, rect2.top), 107 | Math.min(rect1.right, rect2.right), 108 | Math.min(rect1.bottom, rect2.bottom), 109 | ); 110 | }, 111 | }; 112 | 113 | globalThis.Rect = Rect; 114 | -------------------------------------------------------------------------------- /lib/types.js: -------------------------------------------------------------------------------- 1 | // A centralized file of types which can be shared by both content scripts and background pages. 2 | 3 | globalThis.VomnibarShowOptions = { 4 | // The name of the completer to fetch results from. 5 | completer: "string", 6 | // Text to prefill the Vomnibar with. 7 | query: "string", 8 | // Whether to open the result in a new tab. 9 | newTab: "boolean", 10 | // Whether to select the first entry. 11 | selectFirst: "boolean", 12 | // A keyword which will scope the search to a UserSearchEngine. 13 | keyword: "string", 14 | }; 15 | -------------------------------------------------------------------------------- /lib/url_utils.js: -------------------------------------------------------------------------------- 1 | const UrlUtils = { 2 | // A set of top-level domains, e.g. ["com"] recognized by https://www.iana.org/domains/root/db 3 | tlds: null, 4 | 5 | // Other hard-coded TLDs that we want to recognize as URLs. 6 | otherTlds: [ 7 | // Multicast DNS uses 'local' to resolve hostnames to IP addresses within small networks. 8 | "local", 9 | // A pseudo-domain used by TOR browsers. 10 | "onion", 11 | ], 12 | 13 | async init() { 14 | if (this.tlds != null) return; 15 | // Load the tlds.txt file relative to this module. This is required for this URL 16 | // to be valid both when running tests, and in the browser. 17 | const inUnitTests = globalThis.Deno; 18 | const path = "./resources/tlds.txt"; 19 | let text; 20 | // Deno and the browser require different URLs to resolve tlds.txt. If we change 21 | // url_utils.js to be imported as a module, then can both use an import path 22 | // that's relative to the module: 23 | // const tldsFileUrl = new URL("resources/tlds.txt", new URL(import.meta.url)); 24 | if (inUnitTests) { 25 | text = await Deno.readTextFile(path); 26 | } else { 27 | const response = await fetch(chrome.runtime.getURL(path)); 28 | text = await response.text(); 29 | } 30 | this.tlds = new Set(text.split("\n")); 31 | }, 32 | 33 | // Tries to detect if :str is a valid URL. 34 | async isUrl(str) { 35 | if (this.tlds == null) { 36 | await this.init(); 37 | } 38 | 39 | // Must not contain spaces 40 | if (str.includes(" ")) return false; 41 | 42 | // Starts with a scheme: URL 43 | if (this.urlHasProtocol(str)) return true; 44 | 45 | // More or less RFC compliant URL host part parsing. This should be sufficient for our needs 46 | const urlRegex = new RegExp( 47 | "^(?:([^:]+)(?::([^:]+))?@)?" + // user:password (optional) => \1, \2 48 | "([^:]+|\\[[^\\]]+\\])" + // host name (IPv6 addresses in square brackets allowed) => \3 49 | "(?::(\\d+))?$", // port number (optional) => \4 50 | ); 51 | 52 | const specialHostNames = ["localhost"]; 53 | 54 | // Try to parse the URL into its meaningful parts. If matching fails we're pretty sure that we 55 | // don't have some kind of URL here. 56 | // TODO(philc): Can't we use URL() here? This code might've been written before the URL class 57 | // existed. 58 | const match = urlRegex.exec((str.split("/"))[0]); 59 | if (!match) return false; 60 | const hostName = match[3]; 61 | 62 | // Allow known special host names 63 | if (specialHostNames.includes(hostName)) return true; 64 | 65 | // Allow IPv6 addresses (need to be wrapped in brackets as required by RFC). It is sufficient to 66 | // check for a colon, as the regex wouldn't match colons in the host name unless it's an v6 67 | // address 68 | if (hostName.includes(":")) return true; 69 | 70 | // At this point we have to make a decision. As a heuristic, we check if the input has dots in 71 | // it. If yes, and if the last part could be a TLD, treat it as an URL 72 | const dottedParts = hostName.split("."); 73 | 74 | if (dottedParts.length > 1) { 75 | const lastPart = dottedParts.pop(); 76 | if (this.tlds.has(lastPart) || this.otherTlds.includes(lastPart)) { 77 | return true; 78 | } 79 | } 80 | 81 | // Allow IPv4 addresses 82 | if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostName)) return true; 83 | 84 | // Fallback: no URL 85 | return false; 86 | }, 87 | 88 | // Converts string into a full URL if it's not already one. We don't escape characters as the 89 | // browser will do that for us. 90 | async convertToUrl(string) { 91 | string = string.trim(); 92 | 93 | // Special-case about:[url], view-source:[url] and the like 94 | if (this.hasChromeProtocol(string)) { 95 | return string; 96 | } else if (this.hasJavascriptProtocol(string)) { 97 | return string; 98 | } else if (await this.isUrl(string)) { 99 | return this.urlHasProtocol(string) ? string : `http://${string}`; 100 | } else { 101 | return null; 102 | } 103 | }, 104 | 105 | _chromePrefixes: ["about:", "view-source:", "extension:", "chrome-extension:", "data:"], 106 | hasChromeProtocol(url) { 107 | return this._chromePrefixes.some((prefix) => url.startsWith(prefix)); 108 | }, 109 | 110 | hasJavascriptProtocol(url) { 111 | return url.startsWith("javascript:"); 112 | }, 113 | 114 | _urlPrefix: new RegExp("^[a-z][-+.a-z0-9]{2,}://."), 115 | urlHasProtocol(url) { 116 | return this._urlPrefix.test(url); 117 | }, 118 | 119 | // Create a search URL from the given :query using the provided search URL. 120 | createSearchUrl(query, searchUrl) { 121 | if (!["%s", "%S"].some((token) => searchUrl.indexOf(token) >= 0)) { 122 | searchUrl += "%s"; 123 | } 124 | searchUrl = searchUrl.replace(/%S/g, query); 125 | 126 | // Map a search query to its URL encoded form. E.g. "BBC Sport" -> "BBC%20Sport". 127 | const parts = query.split(/\s+/); 128 | const encodedQuery = parts.map(encodeURIComponent).join("%20"); 129 | return searchUrl.replace(/%s/g, encodedQuery); 130 | }, 131 | }; 132 | 133 | globalThis.UrlUtils = UrlUtils; 134 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Vimium", 4 | "version": "2.2.1", 5 | "description": "The Hacker's Browser. Vimium provides keyboard shortcuts for navigation and control in the spirit of Vim.", 6 | "icons": { "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }, 7 | "minimum_chrome_version": "105.0", 8 | "background": { 9 | "service_worker": "background_scripts/main.js", 10 | "type": "module" 11 | // Uncomment when developing in Firefox. 12 | // "scripts": ["background_scripts/main.js"] 13 | }, 14 | "options_ui": { 15 | "page": "pages/options.html", 16 | "browser_style": false, 17 | "open_in_tab": true 18 | }, 19 | "host_permissions": [""], 20 | "permissions": [ 21 | "tabs", 22 | "bookmarks", 23 | "history", 24 | "storage", 25 | "sessions", 26 | // Notifications are used to show a message when Vimium's major version has been upgraded. 27 | "notifications", 28 | // We're using the scripting permission to 1) inject our content scripts and CSS into existing 29 | // tabs when Vimium is first installed, and 2) inject the user's link hints CSS when a page 30 | // loads. This permission was required when moving to manifest V3. 31 | "scripting", 32 | "favicon", // The favicon permission is not yet supported by Firefox. 33 | "webNavigation", 34 | "search" 35 | ], 36 | "content_scripts": [ 37 | { 38 | "matches": [""], 39 | "js": [ 40 | "lib/types.js", 41 | "lib/utils.js", 42 | "lib/keyboard_utils.js", 43 | "lib/dom_utils.js", 44 | "lib/rect.js", 45 | "lib/handler_stack.js", 46 | "lib/settings.js", 47 | "lib/find_mode_history.js", 48 | "content_scripts/mode.js", 49 | "content_scripts/ui_component.js", 50 | "content_scripts/link_hints.js", 51 | "content_scripts/vomnibar.js", 52 | "content_scripts/scroller.js", 53 | "content_scripts/marks.js", 54 | "content_scripts/mode_insert.js", 55 | "content_scripts/mode_find.js", 56 | "content_scripts/mode_key_handler.js", 57 | "content_scripts/mode_visual.js", 58 | "content_scripts/hud.js", 59 | "content_scripts/mode_normal.js", 60 | "content_scripts/vimium_frontend.js" 61 | ], 62 | "css": ["content_scripts/vimium.css"], 63 | "run_at": "document_start", 64 | "all_frames": true, 65 | "match_about_blank": true 66 | }, 67 | { 68 | "matches": ["file:///", "file:///*/"], 69 | "css": ["content_scripts/file_urls.css"], 70 | "run_at": "document_start", 71 | "all_frames": true 72 | } 73 | ], 74 | // Uncomment when developing in Firefox. 75 | // "browser_specific_settings": { 76 | // "gecko": { 77 | // // This ID was generated by the Firefox store upon first submission. 78 | // "id": "{d7742d87-e61d-4b78-b8a1-b469842139fa}", 79 | // "strict_min_version": "109.0" 80 | // } 81 | // }, 82 | "action": { 83 | "default_icon": { 84 | "16": "icons/action_disabled_16.png", 85 | "32": "icons/action_disabled_32.png" 86 | }, 87 | // Uncomment for Firefox. 88 | // "default_area": "navbar", 89 | "default_popup": "pages/action.html" 90 | }, 91 | "web_accessible_resources": [ 92 | { 93 | "resources": [ 94 | "pages/vomnibar_page.html", 95 | "content_scripts/vimium.css", 96 | "pages/hud_page.html", 97 | "pages/help_dialog_page.html", 98 | "pages/completion_engines_page.html", 99 | "pages/command_listing.html", 100 | "resources/tlds.txt", 101 | // This allows one to script the reloading of Vimium. 102 | // This should only be enabled in development. 103 | // "pages/reload.html", 104 | "_favicon/*" 105 | ], 106 | "matches": [""] 107 | } 108 | ] 109 | } 110 | -------------------------------------------------------------------------------- /pages/action.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --padding: 15px; 3 | } 4 | 5 | body { 6 | /* This will be the size of the toolbar action popup. */ 7 | width: 600px; 8 | height: 300px; 9 | display: flex; 10 | flex-direction: column; 11 | } 12 | 13 | h1 { 14 | font-size: 18px; 15 | } 16 | 17 | #dialog-body { 18 | padding: var(--padding); 19 | padding-right: 0; 20 | display: flex; 21 | flex-direction: column; 22 | flex-grow: 1; 23 | } 24 | 25 | #not-enabled-error, #firefox-missing-permissions-error { 26 | padding: var(--padding); 27 | } 28 | 29 | #dialog-body > * { 30 | margin: 10px 0; 31 | } 32 | 33 | #dialog-body > *:first-child { 34 | margin-top: 0; 35 | } 36 | 37 | #dialog-body > *:last-child { 38 | margin-bottom: 0; 39 | } 40 | 41 | #how-many-enabled { 42 | font-weight: bold; 43 | } 44 | 45 | #exclusion-scroll-box { 46 | max-height: 140px; 47 | } 48 | 49 | footer { 50 | background-color: var(--vimium-foreground-color); 51 | padding: var(--padding); 52 | box-sizing: border-box; 53 | display: flex; 54 | align-items: center; 55 | position: inherit; 56 | } 57 | 58 | footer .options-message { 59 | flex-grow: 1; 60 | } 61 | -------------------------------------------------------------------------------- /pages/action.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 21 | 22 | 48 | 49 |
50 |
51 | All Vimium keys are enabled on this page. 52 |
53 | 54 |
55 | 56 |
57 | 58 | 74 |
75 | 76 |
77 |
78 | See all exclusion rules on the Options page. 79 |
80 |
81 | 82 | 83 |
84 |
85 | 86 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /pages/all_content_scripts.js: -------------------------------------------------------------------------------- 1 | // This is the set of all content scripts required to make Vimium's functionality work. This file is 2 | // imported by background pages that we want to work with Vimium's key mappings, e.g. the options 3 | // page. This should be the same list of files as in manifest.js's content_scripts section. 4 | 5 | import "../lib/types.js"; 6 | import "../lib/utils.js"; 7 | import "../lib/url_utils.js"; 8 | import "../lib/keyboard_utils.js"; 9 | import "../lib/dom_utils.js"; 10 | import "../lib/rect.js"; 11 | import "../lib/handler_stack.js"; 12 | import "../lib/settings.js"; 13 | import "../lib/find_mode_history.js"; 14 | 15 | import "../content_scripts/mode.js"; 16 | import "../content_scripts/ui_component.js"; 17 | import "../content_scripts/link_hints.js"; 18 | import "../content_scripts/vomnibar.js"; 19 | import "../content_scripts/scroller.js"; 20 | import "../content_scripts/marks.js"; 21 | import "../content_scripts/mode_insert.js"; 22 | import "../content_scripts/mode_find.js"; 23 | import "../content_scripts/mode_key_handler.js"; 24 | import "../content_scripts/mode_visual.js"; 25 | import "../content_scripts/hud.js"; 26 | import "../content_scripts/mode_normal.js"; 27 | import "../content_scripts/vimium_frontend.js"; 28 | -------------------------------------------------------------------------------- /pages/blank.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | New Tab 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /pages/command_listing.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --border-color: #666; 3 | } 4 | 5 | html, body { 6 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | body { 12 | display: grid; 13 | grid-template-columns: 200px auto; 14 | } 15 | 16 | nav { 17 | border-right: 1px solid var(--border-color); 18 | padding-top: 20px; 19 | } 20 | 21 | nav ul { 22 | position: fixed; 23 | margin: 0; 24 | padding-left: 20px; 25 | } 26 | 27 | nav ul li { 28 | list-style-type: none; 29 | margin: 8px 0; 30 | } 31 | 32 | header { 33 | font-size: 18px; 34 | border-bottom: 1px solid var(--border-color); 35 | padding: 20px 20px; 36 | grid-column: span 2; 37 | } 38 | 39 | main { 40 | margin-top: 10px; 41 | } 42 | 43 | h2 { 44 | font-size: 20px; 45 | background-color: var(--vimium-foreground-color); 46 | padding: 20px 20px; 47 | } 48 | 49 | .command { 50 | max-width: 900px; 51 | padding: 1px 10px; 52 | margin: 10px 0; 53 | box-sizing: border-box; 54 | } 55 | 56 | h3 { 57 | font-size: 18px; 58 | margin: 6px 0; 59 | font-weight: normal; 60 | display: flex; 61 | justify-items: space-between; 62 | } 63 | 64 | h3 code { 65 | background-color: rgb(243, 243, 243); 66 | padding: 4px 6px; 67 | /* This pushes the key bindings to the right of the container. */ 68 | margin-right: auto; 69 | } 70 | 71 | p.desc { 72 | margin-left: 1rem; 73 | } 74 | 75 | .options { 76 | margin-left: 1rem; 77 | } 78 | 79 | .key { 80 | font-size: 14px; 81 | } 82 | 83 | @media (prefers-color-scheme: dark) { 84 | h3 code { 85 | background-color: var(--vimium-foreground-color); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /pages/command_listing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vimium Commands 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
Vimium Commands
16 | 25 | 26 |
27 | 28 |

Using the vomnibar

29 |

Using find

30 |

Navigating history

31 |

Manipulating tabs

32 |

Miscellaneous

33 |
34 | 35 | 48 | 49 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /pages/command_listing.js: -------------------------------------------------------------------------------- 1 | import "./all_content_scripts.js"; 2 | import { allCommands } from "../background_scripts/all_commands.js"; 3 | 4 | // The ordering we show key bindings is alphanumerical, except that special keys sort to the end. 5 | function compareKeys(a, b) { 6 | a = a.replace("<", "~"); 7 | b = b.replace("<", "~"); 8 | if (a < b) { 9 | return -1; 10 | } else if (b < a) { 11 | return 1; 12 | } else { 13 | return 0; 14 | } 15 | } 16 | 17 | function replaceBackticksWithCodeTags(str) { 18 | let count = 0; 19 | return str.replace(/`/g, (match) => { 20 | count++; 21 | return count % 2 === 1 ? "" : ""; 22 | }); 23 | } 24 | 25 | async function populatePage() { 26 | const h2s = document.querySelectorAll("h2"); 27 | const byGroup = Object.groupBy(allCommands, (el) => el.group); 28 | const commandToOptionsToKeys = 29 | (await chrome.storage.session.get("commandToOptionsToKeys")).commandToOptionsToKeys; 30 | 31 | const commandTemplate = document.querySelector("template#command").content; 32 | const keysTemplate = document.querySelector("template#keys").content; 33 | 34 | for (const h2 of Array.from(h2s)) { 35 | const group = h2.dataset["group"]; 36 | let commands = byGroup[group]; 37 | // Display them in alphabetical order. 38 | commands = commands.sort((a, b) => b.name.localeCompare(a.name)); 39 | for (const command of commands) { 40 | // Here, we're going to list all of the keys bound to this command, and for now, we're not 41 | // going to visually distinguish versions of the command with options and versions without. 42 | const keys = Object.values(commandToOptionsToKeys[command.name] || {}) 43 | .flat(1); 44 | const el = commandTemplate.cloneNode(true); 45 | el.querySelector(".command").dataset.command = command.name; // used by tests 46 | el.querySelector("h3 code").textContent = command.name; 47 | 48 | const keysEl = el.querySelector(".key-bindings"); 49 | for (const key of keys.sort(compareKeys)) { 50 | const node = keysTemplate.cloneNode(true); 51 | node.querySelector(".key").textContent = key; 52 | keysEl.appendChild(node); 53 | } 54 | 55 | el.querySelector(".desc").textContent = command.desc; 56 | 57 | if (command.options) { 58 | const ul = el.querySelector(".options ul"); 59 | for (const [name, desc] of Object.entries(command.options)) { 60 | const li = document.createElement("li"); 61 | li.innerHTML = `${name}: ` + replaceBackticksWithCodeTags(desc); 62 | ul.appendChild(li); 63 | } 64 | } else { 65 | el.querySelector(".options").remove(); 66 | } 67 | h2.after(el); 68 | } 69 | } 70 | } 71 | 72 | const testEnv = globalThis.window == null; 73 | if (!testEnv) { 74 | document.addEventListener("DOMContentLoaded", async () => { 75 | await Settings.onLoaded(); 76 | DomUtils.injectUserCss(); 77 | await populatePage(); 78 | }); 79 | } 80 | 81 | export { populatePage }; 82 | -------------------------------------------------------------------------------- /pages/completion_engines_page.css: -------------------------------------------------------------------------------- 1 | div#wrapper { 2 | max-width: 730px; 3 | } 4 | 5 | div.engine { 6 | margin-left: 20px; 7 | } 8 | -------------------------------------------------------------------------------- /pages/completion_engines_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Vimium Search Completion 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
Vimium Search Completion
17 |

18 | Search completion is available for custom search engines whose search URL matches one of 19 | Vimium's built-in completion engines; that is, the search URL matches one of the regular 20 | expressions below. Search completion is not available for the default search engine. 21 |

22 |

23 | Custom search engines can be configured on the options 25 | page.
26 | Further information is available on the wiki. 30 |

31 |
Available Completion Engines
32 |

33 | Search completion is available in this version of Vimium for the following custom search 34 | engines. 35 |

36 |

37 |

38 |

39 |
40 | 41 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /pages/completion_engines_page.js: -------------------------------------------------------------------------------- 1 | import "./all_content_scripts.js"; 2 | import * as completionEngines from "../background_scripts/completion_engines.js"; 3 | 4 | function cleanUpRegexp(re) { 5 | return re.toString() 6 | .replace(/^\//, "") 7 | .replace(/\/$/, "") 8 | .replace(/\\\//g, "/"); 9 | } 10 | 11 | export function populatePage() { 12 | const template = document.querySelector("#engine-template").content; 13 | for (const engineClass of completionEngines.list) { 14 | const el = template.cloneNode(true); 15 | const engine = new engineClass(); 16 | const h4 = el.querySelector("h4"); 17 | h4.textContent = engine.constructor.name; 18 | // This data attribute is used in tests. 19 | h4.dataset.engine = engine.constructor.name; 20 | const explanationEl = el.querySelector(".explanation"); 21 | if (engine.example.explanation) { 22 | explanationEl.textContent = engine.example.explanation; 23 | } else { 24 | explanationEl.remove(); 25 | } 26 | 27 | const exampleEl = el.querySelector(".engine-example"); 28 | if (engine.example.searchUrl && engine.example.keyword) { 29 | const desc = engine.example.description || engine.constructor.name; 30 | exampleEl.querySelector("pre").textContent = 31 | `${engine.example.keyword}: ${engine.example.searchUrl} ${desc}`; 32 | } else { 33 | exampleEl.remove(); 34 | } 35 | 36 | const regexpsEl = el.querySelector(".regexps"); 37 | if (engine.regexps) { 38 | let content = ""; 39 | for (const re of engine.regexps) { 40 | content += `${cleanUpRegexp(re)}\n`; 41 | } 42 | regexpsEl.querySelector("pre").textContent = content; 43 | } else { 44 | regexpsEl.remove(); 45 | } 46 | document.querySelector("#engine-list").appendChild(el); 47 | } 48 | } 49 | 50 | const testEnv = globalThis.window == null; 51 | if (!testEnv) { 52 | document.addEventListener("DOMContentLoaded", populatePage); 53 | } 54 | -------------------------------------------------------------------------------- /pages/exclusion_rules_editor.js: -------------------------------------------------------------------------------- 1 | // The table-editor used for exclusion rules. 2 | const ExclusionRulesEditor = { 3 | // When the Add rule button is clicked, use this as the pattern for the new rule. This is used by 4 | // the action.html toolbar popup. 5 | defaultPatternForNewRules: null, 6 | 7 | init() { 8 | document.querySelector("#exclusion-add-button").addEventListener("click", () => { 9 | this.addRow(this.defaultPatternForNewRules); 10 | this.dispatchEvent("input"); 11 | }); 12 | }, 13 | 14 | // - exclusionRules: the value obtained from settings, with the shape [{pattern, passKeys}]. 15 | setForm(exclusionRules = []) { 16 | const rulesTable = document.querySelector("#exclusion-rules"); 17 | // Remove any previous rows. 18 | const existingRuleEls = rulesTable.querySelectorAll(".rule"); 19 | for (const el of existingRuleEls) el.remove(); 20 | 21 | const rowTemplate = document.querySelector("#exclusion-rule-template").content; 22 | for (const rule of exclusionRules) { 23 | this.addRow(rule.pattern, rule.passKeys); 24 | } 25 | }, 26 | 27 | // `pattern` and `passKeys` are optional. 28 | addRow(pattern, passKeys) { 29 | const rulesTable = document.querySelector("#exclusion-rules"); 30 | const rowTemplate = document.querySelector("#exclusion-rule-template").content; 31 | const rowEl = rowTemplate.cloneNode(true); 32 | 33 | const patternEl = rowEl.querySelector("[name=pattern]"); 34 | patternEl.value = pattern ?? ""; 35 | patternEl.addEventListener("input", () => this.dispatchEvent("input")); 36 | 37 | const keysEl = rowEl.querySelector("[name=passKeys]"); 38 | keysEl.value = passKeys ?? ""; 39 | keysEl.addEventListener("input", () => this.dispatchEvent("input")); 40 | 41 | rowEl.querySelector(".remove").addEventListener("click", (e) => { 42 | e.target.closest("tr").remove(); 43 | this.dispatchEvent("input"); 44 | }); 45 | rulesTable.appendChild(rowEl); 46 | }, 47 | 48 | // Returns an array of rules, which can be stored in Settings. 49 | getRules() { 50 | const rows = Array.from(document.querySelectorAll("#exclusion-rules tr.rule")); 51 | const rules = rows 52 | .map((el) => { 53 | return { 54 | // The ordering of these keys should match the order in defaultOptions in Settings.js. 55 | passKeys: el.querySelector("[name=passKeys]").value.trim(), 56 | pattern: el.querySelector("[name=pattern]").value.trim(), 57 | }; 58 | }) 59 | // Exclude blank patterns. 60 | .filter((rule) => rule.pattern); 61 | return rules; 62 | }, 63 | }; 64 | 65 | Object.assign(ExclusionRulesEditor, EventDispatcher); 66 | 67 | export { ExclusionRulesEditor }; 68 | -------------------------------------------------------------------------------- /pages/help_dialog_page.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 3 | } 4 | 5 | #container { 6 | background-color: white; 7 | border: 2px solid #b3b3b3; 8 | border-radius: 6px; 9 | width: 840px; 10 | max-width: calc(100% - 100px); 11 | max-height: calc(100% - 100px); 12 | margin: 50px auto; 13 | overflow-y: auto; 14 | overflow-x: auto; 15 | } 16 | 17 | #dialog { 18 | min-width: 600px; 19 | padding: 8px 12px; 20 | } 21 | 22 | a { 23 | text-decoration: underline; 24 | color: #2f508e; 25 | cursor: pointer; 26 | } 27 | 28 | header { 29 | display: flex; 30 | flex-direction: row; 31 | align-items: center; 32 | } 33 | 34 | a#close { 35 | font-family: "courier new", monospace; 36 | font-weight: bold; 37 | color: #555; 38 | text-decoration: none; 39 | font-size: 24px; 40 | position: relative; 41 | top: 3px; 42 | padding-left: 5px; 43 | cursor: pointer; 44 | } 45 | 46 | a#close:hover { 47 | color: black; 48 | -webkit-user-select: none; 49 | } 50 | 51 | h1 { 52 | font-size: 20px; 53 | white-space: nowrap; 54 | flex-grow: 1; 55 | font-weight: normal; 56 | margin: 4px 0; 57 | } 58 | 59 | h1 .vim { 60 | color: #2f508e; 61 | } 62 | 63 | header a { 64 | font-size: 14px; 65 | padding-left: 5px; 66 | padding-right: 5px; 67 | } 68 | 69 | header a.close { 70 | padding-right: 0; 71 | } 72 | 73 | #commands-section { 74 | display: flex; 75 | align-items: flex-start; 76 | justify-content: space-between; 77 | } 78 | 79 | .column { 80 | display: grid; 81 | grid-template-columns: auto 1fr; 82 | row-gap: 3px; 83 | } 84 | 85 | h2 { 86 | margin-top: 3px; 87 | margin-bottom: 4px; 88 | font-size: 16px; 89 | font-weight: bold; 90 | } 91 | 92 | div[data-group] { 93 | display: contents; 94 | } 95 | 96 | .row { 97 | display: contents; 98 | } 99 | 100 | .help-description { 101 | font-size: 14px; 102 | } 103 | 104 | div.divider { 105 | height: 1px; 106 | width: 100%; 107 | margin: 10px auto; 108 | background-color: #9a9a9a; 109 | } 110 | 111 | /* Advanced commands are hidden by default until "show advanced" is clicked. */ 112 | .row.advanced { 113 | display: none; 114 | } 115 | 116 | #dialog.show-advanced .row.advanced { 117 | display: contents; 118 | } 119 | 120 | footer { 121 | font-size: 10px; 122 | display: flex; 123 | justify-content: space-between; 124 | } 125 | 126 | .version-info { 127 | text-align: right; 128 | } 129 | 130 | #toggle-advanced { 131 | text-align: right; 132 | font-size: 10px; 133 | } 134 | 135 | /* Dark Mode CSS for Help Dialog */ 136 | @media (prefers-color-scheme: dark) { 137 | #container { 138 | border-color: rgba(255, 255, 255, 0.1); 139 | background-color: #202124; 140 | } 141 | 142 | #dialog { 143 | background-color: var(--vimium-background-color); 144 | color: var(--vimium-background-text-color); 145 | } 146 | 147 | a { 148 | color: var(--vimium-link-color); 149 | } 150 | 151 | h1, 152 | h2 { 153 | color: white; 154 | } 155 | 156 | h1 .vim { 157 | color: var(--vimium-link-color); 158 | } 159 | 160 | div.divider { 161 | background-color: rgba(255, 255, 255, 0.1); 162 | } 163 | 164 | .help-description { 165 | /* Use a fainter color than --vimium-background-text-color, so the dialog text doesn't get 166 | overwhelming. */ 167 | color: #c9cccf; 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /pages/help_dialog_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Vimium Help 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 17 |
18 |
19 |
20 |

21 | Vimium Help 22 | 23 |

24 | 25 | 26 |
27 | Options 28 | Wiki 29 | × 30 |
31 |
32 | 33 |
34 | 35 |
36 |
37 |
38 |

Navigating the page

39 |
40 |
41 | 42 |
43 |
44 |

Using the Vomnibar

45 |
46 | 47 |
48 |

Using find

49 |
50 | 51 |
52 |

Navigating history

53 |
54 | 55 |
56 |

Manipulating tabs

57 |
58 | 59 |
60 |

Miscellaneous

61 |
62 |
63 |
64 | 65 | 68 | 69 |
70 | 71 | 93 |
94 |
95 | 96 | 102 | 103 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /pages/hud_page.css: -------------------------------------------------------------------------------- 1 | #hud-container { 2 | display: block; 3 | position: fixed; 4 | width: calc(100% - 20px); 5 | bottom: 8px; 6 | left: 8px; 7 | background-color: var(--vimium-foreground-color); 8 | color: var(--vimium-foreground-text-color); 9 | text-align: left; 10 | border-radius: 4px; 11 | box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8); 12 | border: 1px solid #aaa; 13 | z-index: 2147483647; 14 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 15 | } 16 | 17 | #search-area { 18 | display: block; 19 | padding: 3px; 20 | color: var(--vimium-foreground-text-color); 21 | border-radius: 4px 4px 0 0; 22 | } 23 | 24 | #hud { 25 | font-size: 14px; 26 | height: 30px; 27 | margin-bottom: 0; 28 | padding: 2px 4px; 29 | border-radius: 3px; 30 | width: 100%; 31 | outline: none; 32 | box-sizing: border-box; 33 | line-height: 20px; 34 | } 35 | 36 | span#hud-find-input, span#hud-match-count { 37 | display: inline; 38 | outline: none; 39 | white-space: nowrap; 40 | overflow-y: hidden; 41 | } 42 | 43 | span#hud-find-input:before { 44 | content: "/"; 45 | } 46 | 47 | span#hud-match-count { 48 | color: #aaa; 49 | font-size: 12px; 50 | } 51 | 52 | span#hud-find-input br { 53 | display: none; 54 | } 55 | 56 | span#hud-find-input * { 57 | display: inline; 58 | white-space: nowrap; 59 | } 60 | 61 | @media (prefers-color-scheme: light) { 62 | #hud-body { 63 | background-color: #f1f1f1; 64 | } 65 | 66 | /* This creates a border around the area where you type, which is nice effect on the light color 67 | * scheme. It's hard to make this effect visible in dark mode, so we don't use it. */ 68 | #hud.hud-find { 69 | background-color: white; 70 | border: 1px solid #ccc; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /pages/hud_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | HUD 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /pages/key_mappings.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Styles for showing key bindings. Shared by help_dialog_page.html and command_listing.html. 3 | */ 4 | 5 | .key-bindings { 6 | max-width: 110px; 7 | font-size: 14px; 8 | text-align: right; 9 | margin-right: 8px; 10 | display: flex; 11 | flex-wrap: wrap; 12 | align-items: flex-end; 13 | justify-content: flex-end; 14 | } 15 | 16 | /* A "key block" includes a key and a comma separator. */ 17 | .key-block { 18 | margin-bottom: 4px; 19 | } 20 | 21 | .key { 22 | background-color: rgb(243, 243, 243); 23 | color: rgb(33, 33, 33); 24 | margin-left: 2px; 25 | padding: 2px 6px; 26 | border-radius: 3px; 27 | border: solid 1px #ccc; 28 | border-bottom-color: #bbb; 29 | box-shadow: inset 0 -1px 0 #bbb; 30 | font-family: monospace; 31 | font-size: 11px; 32 | } 33 | 34 | .comma { 35 | margin-right: 3px; 36 | } 37 | 38 | /* Hide the trailing comma after the last key binding. */ 39 | .key-block:last-of-type .comma { 40 | display: none; 41 | } 42 | 43 | @media (prefers-color-scheme: dark) { 44 | .key { 45 | /* We're using a color that pops more than --vimium-foreground-color because the squares 46 | representing keys are small and hard to read otherwise. */ 47 | background-color: #393a3d; 48 | border: solid 1px #101010; 49 | box-shadow: none; 50 | color: white; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /pages/reload.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /pages/ui_component_messenger.js: -------------------------------------------------------------------------------- 1 | // 2 | // These are functions for a page in a UIComponent iframe to communicate to its parent frame. 3 | // 4 | 5 | let ownerPagePort = null; 6 | let handleMessage = null; 7 | 8 | export async function registerPortWithOwnerPage(event) { 9 | if (event.source !== globalThis.parent) return; 10 | // The Vimium content script that's running on the parent page has access to this vimiumSecret 11 | // fetched from session storage, so if it matches, then we know that event.ports came from the 12 | // Vimium extension. 13 | const secret = (await chrome.storage.session.get("vimiumSecret")).vimiumSecret; 14 | if (event.data !== secret) { 15 | Utils.debugLog("ui_component_messenger.js: vimiumSecret is incorrect."); 16 | return; 17 | } 18 | openPort(event.ports[0]); 19 | // Once we complete a handshake with the parent page hosting this page's iframe, stop listening 20 | // for messages on the window object. 21 | globalThis.removeEventListener("message", registerPortWithOwnerPage); 22 | } 23 | 24 | // Used by unit tests. 25 | export async function unregister() { 26 | ownerPagePort = null; 27 | handleMessage = null; 28 | } 29 | 30 | export function init() { 31 | globalThis.addEventListener("message", registerPortWithOwnerPage); 32 | } 33 | 34 | function openPort(port) { 35 | ownerPagePort = port; 36 | ownerPagePort.onmessage = async (event) => { 37 | if (handleMessage) { 38 | return await handleMessage(event); 39 | } 40 | }; 41 | dispatchReadyEventWhenReady(); 42 | } 43 | 44 | export function registerHandler(messageHandlerFn) { 45 | handleMessage = messageHandlerFn; 46 | } 47 | 48 | export function postMessage(data) { 49 | if (!ownerPagePort) return; 50 | ownerPagePort.postMessage(data); 51 | } 52 | 53 | // We require both that the DOM is ready and that the port has been opened before the UIComponent 54 | // is ready. These events can happen in either order. We count them, and notify the content script 55 | // when we've seen both. 56 | let hasDispatchedReadyEvent = false; 57 | function dispatchReadyEventWhenReady() { 58 | if (hasDispatchedReadyEvent) return; 59 | 60 | if (document.readyState === "loading") { 61 | globalThis.addEventListener("DOMContentLoaded", () => dispatchReadyEventWhenReady()); 62 | return; 63 | } 64 | if (!ownerPagePort) return; 65 | 66 | if (globalThis.frameId != null) { 67 | postMessage({ name: "setIframeFrameId", iframeFrameId: globalThis.frameId }); 68 | } 69 | hasDispatchedReadyEvent = true; 70 | postMessage({ name: "uiComponentIsReady" }); 71 | } 72 | -------------------------------------------------------------------------------- /pages/vomnibar_page.css: -------------------------------------------------------------------------------- 1 | ul { 2 | list-style: none; 3 | display: none; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | #vomnibar { 9 | display: block; 10 | position: fixed; 11 | width: calc(100% - 20px); /* adjusted to keep border radius and box-shadow visible*/ 12 | top: 8px; 13 | left: 8px; 14 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 15 | 16 | background: #f1f1f1; 17 | text-align: left; 18 | border-radius: 4px; 19 | box-shadow: 0px 2px 10px rgba(0, 0, 0, 0.8); 20 | border: 1px solid #aaa; 21 | /* One less than hint markers and the help dialog (see ../content_scripts/vimium.css). */ 22 | z-index: 2139999999; 23 | } 24 | 25 | #vomnibar input { 26 | font-size: 20px; 27 | height: 34px; 28 | margin-bottom: 0; 29 | padding: 4px; 30 | background-color: white; 31 | color: black; 32 | border-radius: 3px; 33 | border: 1px solid #e8e8e8; 34 | box-shadow: #444 0px 0px 1px; 35 | width: 100%; 36 | outline: none; 37 | box-sizing: border-box; 38 | } 39 | 40 | #vomnibar-search-area { 41 | display: block; 42 | padding: 10px; 43 | border-radius: 4px 4px 0 0; 44 | border-bottom: 1px solid #c6c9ce; 45 | } 46 | 47 | #vomnibar ul { 48 | border-radius: 0 0 4px 4px; 49 | } 50 | 51 | #vomnibar li { 52 | border-bottom: 1px solid #ddd; 53 | line-height: 1.1em; 54 | padding: 7px 10px; 55 | font-size: 16px; 56 | color: black; 57 | position: relative; 58 | display: list-item; 59 | margin: auto; 60 | } 61 | 62 | #vomnibar li:last-of-type { 63 | border-bottom: none; 64 | } 65 | 66 | #vomnibar li .top-half, #vomnibar li .bottom-half { 67 | display: block; 68 | overflow: hidden; 69 | } 70 | 71 | #vomnibar li .bottom-half { 72 | font-size: 15px; 73 | margin-top: 3px; 74 | padding: 2px 0; 75 | } 76 | 77 | #vomnibar li .icon { 78 | padding: 0 13px 0 6px; 79 | vertical-align: bottom; 80 | } 81 | 82 | #vomnibar li .source { 83 | color: #777; 84 | margin-right: 4px; 85 | } 86 | #vomnibar li .relevancy { 87 | position: absolute; 88 | right: 0; 89 | top: 0; 90 | padding: 5px; 91 | color: black; 92 | font-family: monospace; 93 | width: 100px; 94 | overflow: hidden; 95 | } 96 | 97 | #vomnibar li .url { 98 | white-space: nowrap; 99 | color: #224684; 100 | } 101 | 102 | #vomnibar li .match { 103 | font-weight: bold; 104 | color: black; 105 | } 106 | 107 | #vomnibar li em, #vomnibar li .title { 108 | color: black; 109 | margin-left: 4px; 110 | } 111 | #vomnibar li em { 112 | font-style: italic; 113 | } 114 | #vomnibar li em .match, #vomnibar li .title .match { 115 | color: #333; 116 | } 117 | 118 | #vomnibar li.selected { 119 | background-color: #bbcee9; 120 | } 121 | 122 | #vomnibar input::selection { 123 | /* This is the light grey color of the vomnibar border. */ 124 | /* background-color: #F1F1F1; */ 125 | 126 | /* This is the light blue color of the vomnibar selected item. */ 127 | /* background-color: #BBCEE9; */ 128 | 129 | /* This is a considerably lighter blue than Vimium blue, which seems softer 130 | * on the eye for this purpose. */ 131 | background-color: #e6eefb; 132 | } 133 | 134 | .no-insert-text { 135 | visibility: hidden; 136 | } 137 | 138 | /* Dark Vomnibar */ 139 | 140 | @media (prefers-color-scheme: dark) { 141 | #vomnibar { 142 | background-color: var(--vimium-background-color); 143 | color: var(--vimium-background-text-color); 144 | border-radius: 6px; 145 | border: 1px solid var(--vimium-foreground-color); 146 | } 147 | 148 | #vomnibar-search-area { 149 | border-bottom: 1px solid var(--vimium-foreground-color); 150 | } 151 | 152 | #vomnibar input { 153 | background-color: #202124; 154 | color: white; 155 | background-color: var(--vimium-foreground-color); 156 | color: var(--vimium-foreground-text-color); 157 | border: none; 158 | } 159 | 160 | #vomnibar li { 161 | border-bottom: 1px solid rgba(255, 255, 255, 0.1); 162 | } 163 | 164 | #vomnibar li.selected { 165 | background-color: #37383a; 166 | } 167 | 168 | #vomnibar li .url { 169 | white-space: nowrap; 170 | color: #5ca1f7; 171 | } 172 | 173 | #vomnibar li em, 174 | #vomnibar li .title { 175 | color: white; 176 | } 177 | 178 | #vomnibar li .source { 179 | color: #9aa0a6; 180 | } 181 | 182 | #vomnibar li .match { 183 | color: white; 184 | } 185 | 186 | #vomnibar li em .match, 187 | #vomnibar li .title .match { 188 | color: white; 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /pages/vomnibar_page.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Vomnibar 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 |
16 |
    17 |
    18 | 19 | 20 | -------------------------------------------------------------------------------- /test_harnesses/cross_origin_iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Cross origin iFrame 7 | 20 | 21 | 22 | 23 | Iframe from a different origin as its parent:
    24 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /test_harnesses/image_map.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philc/vimium/b44dbff804bf4023b7ae8cd725c210b56769d293/test_harnesses/image_map.png -------------------------------------------------------------------------------- /test_harnesses/page_with_links.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Page with many links 5 | 6 | 7 | 33 | 34 | 35 | This will be a link spanning two
    lines
    36 | 37 |
    38 |
    39 |
    40 |
    41 | 42 | This link has a lot of vertical padding 43 | 44 |
    45 |
    46 |
    47 |
    48 |
    49 |
    50 |
    51 |
    52 | 53 | This link has a lot of vertical padding on the top 55 | 56 |
    57 |
    58 |
    div with an onclick attribute
    59 | 60 |
    61 |
    62 | 63 | An anchor with just a name 64 | 65 |
    66 |
    67 | Next and previous 68 | links. 69 | 70 |
    71 |
    72 | 73 | Below is an image map:
    74 | 75 | 76 | 77 | Section A 78 | Section B 79 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /test_harnesses/vomnibar_harness.html: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | Vomnibar harness 10 | 11 | 12 | 13 | 14 | 15 | 16 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut 17 | labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco 18 | laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in 19 | voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat 20 | non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 21 | 22 |
    23 |
    24 | 25 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut 26 | labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco 27 | laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in 28 | voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat 29 | non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 30 | 31 | 32 | -------------------------------------------------------------------------------- /test_harnesses/vomnibar_harness.js: -------------------------------------------------------------------------------- 1 | import "../pages/all_content_scripts.js"; 2 | import "../pages/vomnibar_page.js"; 3 | 4 | function setup() { 5 | Vomnibar.activate(0, {}); 6 | } 7 | 8 | document.addEventListener("DOMContentLoaded", setup, false); 9 | -------------------------------------------------------------------------------- /tests/dom_tests/dom_test_setup.js: -------------------------------------------------------------------------------- 1 | globalThis.vimiumDomTestsAreRunning = true; 2 | 3 | import * as shoulda from "../vendor/shoulda.js"; 4 | 5 | // Attach shoulda's functions -- like setup, context, should -- to the global namespace. 6 | Object.assign(globalThis, shoulda); 7 | globalThis.shoulda = shoulda; 8 | 9 | document.addEventListener("DOMContentLoaded", async () => { 10 | isEnabledForUrl = true; 11 | await Settings.onLoaded(); 12 | await HUD.init(); 13 | }); 14 | -------------------------------------------------------------------------------- /tests/dom_tests/dom_tests.html: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 |
    36 | 37 |

    Vimium Tests

    38 | 39 | 40 | -------------------------------------------------------------------------------- /tests/dom_tests/dom_utils_test.js: -------------------------------------------------------------------------------- 1 | context("Check visibility", () => { 2 | should("detect visible elements as visible", () => { 3 | document.getElementById("test-div").innerHTML = `\ 4 |
    test
    `; 5 | assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("foo"), true)) !== null); 6 | }); 7 | 8 | should("detect display:none links as hidden", () => { 9 | document.getElementById("test-div").innerHTML = `\ 10 | `; 11 | assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("foo"), true)); 12 | }); 13 | 14 | should("detect visibility:hidden links as hidden", () => { 15 | document.getElementById("test-div").innerHTML = `\ 16 | `; 17 | assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("foo"), true)); 18 | }); 19 | 20 | should("detect elements nested in display:none elements as hidden", () => { 21 | document.getElementById("test-div").innerHTML = `\ 22 |
    23 | test 24 |
    `; 25 | assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("foo"), true)); 26 | }); 27 | 28 | should("detect links nested in visibility:hidden elements as hidden", () => { 29 | document.getElementById("test-div").innerHTML = `\ 30 |
    31 | test 32 |
    `; 33 | assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("foo"), true)); 34 | }); 35 | 36 | should("detect links outside viewport as hidden", () => { 37 | document.getElementById("test-div").innerHTML = `\ 38 | test 39 | test`; 40 | assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("foo"), true)); 41 | assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("bar"), true)); 42 | }); 43 | 44 | should("detect links only partially outside viewport as visible", () => { 45 | document.getElementById("test-div").innerHTML = `\ 46 | test 47 | test`; 48 | assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("foo"), true)) !== null); 49 | assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("bar"), true)) !== null); 50 | }); 51 | 52 | should("detect links that contain only floated / absolutely-positioned divs as visible", () => { 53 | document.getElementById("test-div").innerHTML = `\ 54 | 55 |
    test
    56 |
    `; 57 | assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("foo"), true)) !== null); 58 | 59 | document.getElementById("test-div").innerHTML = `\ 60 | 61 |
    test
    62 |
    `; 63 | assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("foo"), true)) !== null); 64 | }); 65 | 66 | should("detect links that contain only invisible floated divs as invisible", () => { 67 | document.getElementById("test-div").innerHTML = `\ 68 | 69 |
    test
    70 |
    `; 71 | assert.equal(null, DomUtils.getVisibleClientRect(document.getElementById("foo"), true)); 72 | }); 73 | 74 | should( 75 | "detect font-size: 0; and display: inline; links when their children are display: inline", 76 | () => { 77 | // This test represents the minimal test case covering issue #1554. 78 | document.getElementById("test-div").innerHTML = `\ 79 | 80 |
    test
    81 |
    `; 82 | assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("foo"), true)) !== null); 83 | }, 84 | ); 85 | 86 | should("detect links inside opacity:0 elements as visible", () => { 87 | // XXX This is an expected failure. See issue #16. 88 | document.getElementById("test-div").innerHTML = `\ 89 |
    90 | test 91 |
    `; 92 | assert.isTrue((DomUtils.getVisibleClientRect(document.getElementById("foo"), true)) !== null); 93 | }); 94 | }); 95 | 96 | context("getClientRectsForAreas", () => { 97 | let img, area; 98 | setup(() => { 99 | img = document.createElement("img"); 100 | area = document.createElement("area"); 101 | }); 102 | 103 | should("return the associated rect for an image map", () => { 104 | area.setAttribute("coords", "1,2,3,4"); 105 | const result = DomUtils.getClientRectsForAreas(img, [area]); 106 | assert.equal([{ element: area, rect: Rect.create(1, 2, 3, 4) }], result); 107 | }); 108 | 109 | should("skip when a map's coords are malformed", () => { 110 | area.setAttribute("coords", "1,2,3"); // This is only 3 coords rather than 4. 111 | assert.equal([], DomUtils.getClientRectsForAreas(img, [area])); 112 | area.setAttribute("coords", "1,2,3,junk-value"); 113 | assert.equal([], DomUtils.getClientRectsForAreas(img, [area])); 114 | }); 115 | }); 116 | 117 | // NOTE(philc): This test doesn't pass on puppeteer. It's unclear from the XXX comment if it's 118 | // supposed to. 119 | // should("Detect links within SVGs as visible"), () => { 120 | // # XXX this is an expected failure 121 | // document.getElementById("test-div").innerHTML = """ 122 | // 123 | // 124 | // test 125 | // 126 | // 127 | // """ 128 | // assert.equal(null, (DomUtils.getVisibleClientRect (document.getElementById 'foo'), true)); 129 | // } 130 | -------------------------------------------------------------------------------- /tests/unit_tests/bg_utils_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import "../../lib/url_utils.js"; 3 | import "../../background_scripts/tab_recency.js"; 4 | import "../../background_scripts/bg_utils.js"; 5 | -------------------------------------------------------------------------------- /tests/unit_tests/command_listing_test.js: -------------------------------------------------------------------------------- 1 | import * as testHelper from "./test_helper.js"; 2 | import "../../tests/unit_tests/test_chrome_stubs.js"; 3 | import "../../lib/utils.js"; 4 | import "../../lib/settings.js"; 5 | import { allCommands } from "../../background_scripts/all_commands.js"; 6 | import * as commandListing from "../../pages/command_listing.js"; 7 | 8 | context("command listing", () => { 9 | setup(async () => { 10 | await testHelper.jsdomStub("pages/command_listing.html"); 11 | await Settings.onLoaded(); 12 | stub(chrome.storage.session, "get", async (key) => { 13 | if (key == "commandToOptionsToKeys") { 14 | const data = { 15 | "reload": { 16 | "": ["a"], 17 | "hard": ["b"], 18 | }, 19 | }; 20 | return { commandToOptionsToKeys: data }; 21 | } 22 | }); 23 | }); 24 | 25 | should("have a section in the html for every group", async () => { 26 | // This is to prevent editing errors, where a new command group is added, and we forget to add a 27 | // corresponding group to the command listing. 28 | await commandListing.populatePage(); 29 | const groups = Array.from(new Set(allCommands.map((c) => c.group))).sort(); 30 | const groupsInPage = Array.from(globalThis.document.querySelectorAll("h2[data-group]")) 31 | .map((e) => e.dataset.group) 32 | .sort(); 33 | assert.equal(groups, groupsInPage); 34 | }); 35 | 36 | should("have one entry per command", async () => { 37 | await commandListing.populatePage(); 38 | const rows = globalThis.document.querySelectorAll(".command"); 39 | assert.equal(allCommands.length, rows.length); 40 | }); 41 | 42 | should("show key mappings for mapped commands", async () => { 43 | const getKeys = (commandName) => { 44 | const el = globalThis.document.querySelector(`.command[data-command=${commandName}]`); 45 | if (!el) throw new Error(`${commandName} el not found.`); 46 | const keys = Array.from(el.querySelectorAll(".key")).map((el) => el.textContent); 47 | return keys; 48 | }; 49 | await commandListing.populatePage(); 50 | assert.equal(["a", "b"], getKeys("reload")); 51 | // This command isn't bound in our stubbed test environment: 52 | assert.equal([], getKeys("scrollDown")); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /tests/unit_tests/completion_engines_page_test.js: -------------------------------------------------------------------------------- 1 | import * as testHelper from "./test_helper.js"; 2 | import "../../tests/unit_tests/test_chrome_stubs.js"; 3 | import "../../lib/utils.js"; 4 | import "../../lib/settings.js"; 5 | import * as completionEngines from "../../background_scripts/completion_engines.js"; 6 | import * as page from "../../pages/completion_engines_page.js"; 7 | 8 | context("completion engines page", () => { 9 | setup(async () => { 10 | await testHelper.jsdomStub("pages/completion_engines_page.html"); 11 | }); 12 | 13 | should("have a section in the html for every engine", () => { 14 | // This is to prevent editing errors, where a new command group is added, and we forget to add a 15 | // corresponding group to the command listing. 16 | page.populatePage(); 17 | const engines = completionEngines.list.map((e) => e.name); 18 | const enginesInPage = Array.from(globalThis.document.querySelectorAll("h4[data-engine]")) 19 | .map((e) => e.dataset.engine); 20 | assert.equal(engines, enginesInPage); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/unit_tests/completion_engines_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import "../../background_scripts/bg_utils.js"; 3 | import * as Engines from "../../background_scripts/completion_engines.js"; 4 | import "../../background_scripts/completion.js"; 5 | 6 | context("Amazon completion", () => { 7 | should("parses results", () => { 8 | const response = JSON.stringify({ 9 | "suggestions": [ 10 | { "value": "one" }, 11 | { "value": "two" }, 12 | ], 13 | }); 14 | const results = new Engines.Amazon().parse(response); 15 | assert.equal(["one", "two"], results); 16 | }); 17 | }); 18 | 19 | context("Brave completion", () => { 20 | should("parses results", () => { 21 | const response = JSON.stringify(["the-query", ["one", "two"]]); 22 | const results = new Engines.Brave().parse(response); 23 | assert.equal(["one", "two"], results); 24 | }); 25 | }); 26 | 27 | context("Kagi completion", () => { 28 | should("parses results", () => { 29 | const response = JSON.stringify([{ t: "one" }, { t: "two" }]); 30 | const results = new Engines.Kagi().parse(response); 31 | assert.equal(["one", "two"], results); 32 | }); 33 | }); 34 | 35 | context("DuckDuckGo completion", () => { 36 | should("parses results", () => { 37 | const response = JSON.stringify([ 38 | { "phrase": "one" }, 39 | { "phrase": "two" }, 40 | ]); 41 | const results = new Engines.DuckDuckGo().parse(response); 42 | assert.equal(["one", "two"], results); 43 | }); 44 | }); 45 | 46 | context("Qwant completion", () => { 47 | should("parses results", () => { 48 | const response = JSON.stringify({ 49 | "data": { 50 | "items": [ 51 | { "value": "one" }, 52 | { "value": "two" }, 53 | ], 54 | }, 55 | }); 56 | const results = new Engines.Qwant().parse(response); 57 | assert.equal(["one", "two"], results); 58 | }); 59 | }); 60 | 61 | // Engines which have trivial parsers are omitted from these tests. 62 | context("Webster completion", () => { 63 | should("parses results", () => { 64 | const response = JSON.stringify({ 65 | "docs": [ 66 | { "word": "one" }, 67 | { "word": "two" }, 68 | ], 69 | }); 70 | const results = new Engines.Webster().parse(response); 71 | assert.equal(["one", "two"], results); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /tests/unit_tests/exclusion_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | 3 | Utils.getCurrentVersion = () => "1.44"; 4 | 5 | import "../../lib/settings.js"; 6 | import "../../background_scripts/bg_utils.js"; 7 | import * as exclusions from "../../background_scripts/exclusions.js"; 8 | import "../../background_scripts/commands.js"; 9 | 10 | const isEnabledForUrl = (request) => exclusions.isEnabledForUrl(request.url); 11 | 12 | // These tests cover only the most basic aspects of excluded URLs and passKeys. 13 | context("Excluded URLs and pass keys", () => { 14 | setup(async () => { 15 | await Settings.onLoaded(); 16 | await Settings.set("exclusionRules", [ 17 | { pattern: "http*://mail.google.com/*", passKeys: "" }, 18 | { pattern: "http*://www.facebook.com/*", passKeys: "abab" }, 19 | { pattern: "http*://www.facebook.com/*", passKeys: "cdcd" }, 20 | { pattern: "http*://www.bbc.com/*", passKeys: "" }, 21 | { pattern: "http*://www.bbc.com/*", passKeys: "ab" }, 22 | { pattern: "http*://www.example.com/*", passKeys: "a bb c bba a" }, 23 | { pattern: "http*://www.duplicate.com/*", passKeys: "ace" }, 24 | { pattern: "http*://www.duplicate.com/*", passKeys: "bdf" }, 25 | ]); 26 | }); 27 | 28 | teardown(async () => { 29 | await Settings.clear(); 30 | }); 31 | 32 | should("be disabled for excluded sites", () => { 33 | const rule = isEnabledForUrl({ url: "http://mail.google.com/calendar/page" }); 34 | assert.isFalse(rule.isEnabledForUrl); 35 | assert.isFalse(rule.passKeys); 36 | }); 37 | 38 | should("be disabled for excluded sites, one exclusion", () => { 39 | const rule = isEnabledForUrl({ url: "http://www.bbc.com/calendar/page" }); 40 | assert.isFalse(rule.isEnabledForUrl); 41 | assert.isFalse(rule.passKeys); 42 | }); 43 | 44 | should("be enabled, but with pass keys", () => { 45 | const rule = isEnabledForUrl({ url: "https://www.facebook.com/something" }); 46 | assert.isTrue(rule.isEnabledForUrl); 47 | assert.equal(rule.passKeys, "abcd"); 48 | }); 49 | 50 | should("be enabled", () => { 51 | const rule = isEnabledForUrl({ url: "http://www.twitter.com/pages" }); 52 | assert.isTrue(rule.isEnabledForUrl); 53 | assert.isFalse(rule.passKeys); 54 | }); 55 | 56 | should("handle spaces and duplicates in passkeys", () => { 57 | const rule = isEnabledForUrl({ url: "http://www.example.com/pages" }); 58 | assert.isTrue(rule.isEnabledForUrl); 59 | assert.equal("abc", rule.passKeys); 60 | }); 61 | 62 | should("handle multiple passkeys rules", () => { 63 | const rule = isEnabledForUrl({ url: "http://www.duplicate.com/pages" }); 64 | assert.isTrue(rule.isEnabledForUrl); 65 | assert.equal("abcdef", rule.passKeys); 66 | }); 67 | 68 | should("be enabled when given malformed regular expressions", async () => { 69 | await Settings.set("exclusionRules", [ 70 | { pattern: "http*://www.bad-regexp.com/*[a-", passKeys: "" }, 71 | ]); 72 | const rule = isEnabledForUrl({ url: "http://www.bad-regexp.com/pages" }); 73 | assert.isTrue(rule.isEnabledForUrl); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /tests/unit_tests/handler_stack_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import "../../lib/handler_stack.js"; 3 | 4 | context("handlerStack", () => { 5 | let handlerStack, handler1Called, handler2Called; 6 | 7 | setup(() => { 8 | stub(globalThis, "DomUtils", {}); 9 | stub(DomUtils, "consumeKeyup", () => {}); 10 | stub(DomUtils, "suppressEvent", () => {}); 11 | stub(DomUtils, "suppressPropagation", () => {}); 12 | handlerStack = new HandlerStack(); 13 | handler1Called = false; 14 | handler2Called = false; 15 | }); 16 | 17 | should("bubble events", () => { 18 | handlerStack.push({ 19 | keydown: () => { 20 | return handler1Called = true; 21 | }, 22 | }); 23 | handlerStack.push({ 24 | keydown: () => { 25 | return handler2Called = true; 26 | }, 27 | }); 28 | handlerStack.bubbleEvent("keydown", {}); 29 | assert.isTrue(handler2Called); 30 | assert.isTrue(handler1Called); 31 | }); 32 | 33 | should("terminate bubbling on falsy return value", () => { 34 | handlerStack.push({ 35 | keydown: () => { 36 | return handler1Called = true; 37 | }, 38 | }); 39 | handlerStack.push({ 40 | keydown: () => { 41 | handler2Called = true; 42 | return false; 43 | }, 44 | }); 45 | handlerStack.bubbleEvent("keydown", {}); 46 | assert.isTrue(handler2Called); 47 | assert.isFalse(handler1Called); 48 | }); 49 | 50 | should("terminate bubbling on passEventToPage, and be true", () => { 51 | handlerStack.push({ 52 | keydown: () => { 53 | return handler1Called = true; 54 | }, 55 | }); 56 | handlerStack.push({ 57 | keydown: () => { 58 | handler2Called = true; 59 | return handlerStack.passEventToPage; 60 | }, 61 | }); 62 | assert.isTrue(handlerStack.bubbleEvent("keydown", {})); 63 | assert.isTrue(handler2Called); 64 | assert.isFalse(handler1Called); 65 | }); 66 | 67 | should("terminate bubbling on passEventToPage, and be false", () => { 68 | handlerStack.push({ 69 | keydown: () => { 70 | return handler1Called = true; 71 | }, 72 | }); 73 | handlerStack.push({ 74 | keydown: () => { 75 | handler2Called = true; 76 | return handlerStack.suppressPropagation; 77 | }, 78 | }); 79 | assert.isFalse(handlerStack.bubbleEvent("keydown", {})); 80 | assert.isTrue(handler2Called); 81 | assert.isFalse(handler1Called); 82 | }); 83 | 84 | should("restart bubbling on restartBubbling", () => { 85 | handler1Called = 0; 86 | handler2Called = 0; 87 | const id = handlerStack.push({ 88 | keydown: () => { 89 | handler1Called++; 90 | handlerStack.remove(id); 91 | return handlerStack.restartBubbling; 92 | }, 93 | }); 94 | handlerStack.push({ 95 | keydown: () => { 96 | handler2Called++; 97 | return true; 98 | }, 99 | }); 100 | assert.isTrue(handlerStack.bubbleEvent("keydown", {})); 101 | assert.isTrue(handler1Called === 1); 102 | assert.isTrue(handler2Called === 2); 103 | }); 104 | 105 | should("remove handlers correctly", () => { 106 | handlerStack.push({ 107 | keydown: () => { 108 | handler1Called = true; 109 | }, 110 | }); 111 | const handlerId = handlerStack.push({ 112 | keydown: () => { 113 | handler2Called = true; 114 | }, 115 | }); 116 | handlerStack.remove(handlerId); 117 | handlerStack.bubbleEvent("keydown", {}); 118 | assert.isFalse(handler2Called); 119 | assert.isTrue(handler1Called); 120 | }); 121 | 122 | should("remove handlers correctly", () => { 123 | const handlerId = handlerStack.push({ 124 | keydown: () => { 125 | handler1Called = true; 126 | }, 127 | }); 128 | handlerStack.push({ 129 | keydown: () => { 130 | handler2Called = true; 131 | }, 132 | }); 133 | handlerStack.remove(handlerId); 134 | handlerStack.bubbleEvent("keydown", {}); 135 | assert.isTrue(handler2Called); 136 | assert.isFalse(handler1Called); 137 | }); 138 | 139 | should("handle self-removing handlers correctly", () => { 140 | handlerStack.push({ 141 | keydown: () => { 142 | handler1Called = true; 143 | }, 144 | }); 145 | handlerStack.push({ 146 | keydown() { 147 | handler2Called = true; 148 | this.remove(); 149 | return true; 150 | }, 151 | }); 152 | handlerStack.bubbleEvent("keydown", {}); 153 | assert.isTrue(handler2Called); 154 | assert.isTrue(handler1Called); 155 | assert.equal(handlerStack.stack.length, 1); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /tests/unit_tests/help_dialog_test.js: -------------------------------------------------------------------------------- 1 | import * as testHelper from "./test_helper.js"; 2 | import "../../tests/unit_tests/test_chrome_stubs.js"; 3 | import "../../background_scripts/completion.js"; 4 | import { allCommands } from "../../background_scripts/all_commands.js"; 5 | import { HelpDialogPage } from "../../pages/help_dialog_page.js"; 6 | 7 | context("help dialog", () => { 8 | setup(async () => { 9 | await testHelper.jsdomStub("pages/help_dialog_page.html"); 10 | await Settings.onLoaded(); 11 | stub(chrome.storage.session, "get", async (key) => { 12 | if (key == "commandToOptionsToKeys") { 13 | const data = { 14 | "reload": { 15 | "": ["a"], 16 | "hard": ["b"], 17 | }, 18 | }; 19 | return { commandToOptionsToKeys: data }; 20 | } 21 | }); 22 | }); 23 | 24 | should("getRowsForDialog includes one row per command-options pair", () => { 25 | const config = { 26 | "reload": { 27 | "": ["a"], 28 | "hard": ["b", "c"], 29 | }, 30 | }; 31 | const result = HelpDialogPage.getRowsForDialog(config); 32 | const rows = result["navigation"] 33 | .filter((row) => row[0].name == "reload"); 34 | assert.equal(2, rows.length); 35 | assert.equal(["reload", "", ["a"]], [rows[0][0].name, rows[0][1], rows[0][2]]); 36 | assert.equal(["reload", "hard", ["b", "c"]], [rows[1][0].name, rows[1][1], rows[1][2]]); 37 | }); 38 | 39 | should("have a section in the help dialog for every group", async () => { 40 | // This test is to prevent code editing errors, where a command is added but doesn't have a 41 | // corresponding group in the help dialog. 42 | HelpDialogPage.init(); 43 | await HelpDialogPage.show(); 44 | const groups = Array.from(new Set(allCommands.map((c) => c.group))).sort(); 45 | const groupsInDialog = Array.from( 46 | HelpDialogPage.dialogElement.querySelectorAll("div[data-group]"), 47 | ) 48 | .map((e) => e.dataset.group) 49 | .sort(); 50 | assert.equal(groups, groupsInDialog); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/unit_tests/hud_page_test.js: -------------------------------------------------------------------------------- 1 | import * as testHelper from "./test_helper.js"; 2 | import "../../tests/unit_tests/test_chrome_stubs.js"; 3 | import * as hudPage from "../../pages/hud_page.js"; 4 | import * as UIComponentMessenger from "../../pages/ui_component_messenger.js"; 5 | 6 | function newKeyEvent(properties) { 7 | return Object.assign( 8 | { 9 | type: "keydown", 10 | key: "a", 11 | ctrlKey: false, 12 | shiftKey: false, 13 | altKey: false, 14 | metaKey: false, 15 | stopImmediatePropagation: function () {}, 16 | preventDefault: function () {}, 17 | }, 18 | properties, 19 | ); 20 | } 21 | 22 | context("hud page", () => { 23 | let ui; 24 | setup(async () => { 25 | stub(Utils, "isFirefox", () => false); 26 | await testHelper.jsdomStub("pages/hud_page.html"); 27 | // Make Utils.setTimeout synchronous so that the tests easier to deal with. 28 | stub(Utils, "setTimeout", (timeout, fn) => { 29 | fn(); 30 | }); 31 | }); 32 | 33 | teardown(() => { 34 | UIComponentMessenger.unregister(); 35 | }); 36 | 37 | should("find mode hides when escape is pressed", async () => { 38 | let message; 39 | const stubPort = { 40 | postMessage: (event) => { 41 | message = event; 42 | }, 43 | }; 44 | await UIComponentMessenger.registerPortWithOwnerPage({ 45 | data: (await chrome.storage.session.get("vimiumSecret")).vimiumSecret, 46 | ports: [stubPort], 47 | }); 48 | hudPage.handlers.showFindMode(); 49 | await hudPage.onKeyEvent(newKeyEvent({ key: "Escape" })); 50 | assert.equal("hideFindMode", message.name); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/unit_tests/link_hints_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import "../../lib/keyboard_utils.js"; 3 | import "../../lib/settings.js"; 4 | import "../../content_scripts/mode.js"; 5 | import "../../content_scripts/link_hints.js"; 6 | 7 | context("With insufficient link characters", () => { 8 | setup(async () => { 9 | await Settings.onLoaded(); 10 | }); 11 | 12 | teardown(async () => { 13 | await Settings.clear(); 14 | }); 15 | 16 | should("throw error in AlphabetHints", async () => { 17 | await Settings.set("linkHintCharacters", "ab"); 18 | new AlphabetHints(); 19 | await Settings.set("linkHintCharacters", "a"); 20 | assert.throwsError(() => new AlphabetHints(), "Error"); 21 | }); 22 | 23 | should("throw error in FilterHints", async () => { 24 | await Settings.set("linkHintNumbers", "12"); 25 | new FilterHints(); 26 | await Settings.set("linkHintNumbers", "1"); 27 | assert.throwsError(() => new FilterHints(), "Error"); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /tests/unit_tests/main_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import "../../lib/settings.js"; 3 | import "../../background_scripts/main.js"; 4 | 5 | context("HintCoordinator", () => { 6 | should("prepareToActivateLinKhintsMode", async () => { 7 | let receivedMessages = []; 8 | const frameIdToHintDescriptors = { 9 | "0": { frameId: 0, localIndex: 123, linkText: null }, 10 | "1": { frameId: 1, localIndex: 456, linkText: null }, 11 | }; 12 | 13 | stub(chrome.webNavigation, "getAllFrames", () => [{ frameId: 0 }, { frameId: 1 }]); 14 | 15 | stub(chrome.tabs, "sendMessage", async (_tabId, message, options) => { 16 | if (message.messageType == "getHintDescriptors") { 17 | return frameIdToHintDescriptors[options.frameId]; 18 | } else if (message.messageType == "activateMode") { 19 | receivedMessages.push(message); 20 | } 21 | }); 22 | 23 | await HintCoordinator.prepareToActivateLinkHintsMode(0, 0, { 24 | modeIndex: 0, 25 | requestedByHelpDialog: false, 26 | }); 27 | 28 | receivedMessages = receivedMessages.map( 29 | (m) => Utils.pick(m, ["frameId", "frameIdToHintDescriptors"]), 30 | ); 31 | 32 | // Each frame should receive only the hint descriptors from the other frames. 33 | assert.equal([ 34 | { frameId: 0, frameIdToHintDescriptors: { "1": frameIdToHintDescriptors[1] } }, 35 | { frameId: 1, frameIdToHintDescriptors: { "0": frameIdToHintDescriptors[0] } }, 36 | ], receivedMessages); 37 | }); 38 | }); 39 | 40 | context("Next zoom level", () => { 41 | // NOTE: All these tests use the Chrome zoom levels, which are the default! 42 | should("Zoom in 0 times", async () => { 43 | const count = 0; 44 | const currentZoom = 1.00; 45 | const nextZoom = await nextZoomLevel(currentZoom, count); 46 | assert.equal(1.00, nextZoom); 47 | }); 48 | 49 | should("Zoom in 1", async () => { 50 | const count = 1; 51 | const currentZoom = 1.00; 52 | const nextZoom = await nextZoomLevel(currentZoom, count); 53 | assert.equal(1.10, nextZoom); 54 | }); 55 | 56 | should("Zoom out 1", async () => { 57 | const count = -1; 58 | const currentZoom = 1.00; 59 | const nextZoom = await nextZoomLevel(currentZoom, count); 60 | assert.equal(0.90, nextZoom); 61 | }); 62 | 63 | should("Zoom in 2", async () => { 64 | const count = 2; 65 | const currentZoom = 1.00; 66 | const nextZoom = await nextZoomLevel(currentZoom, count); 67 | assert.equal(1.25, nextZoom); 68 | }); 69 | 70 | should("Zoom out 2", async () => { 71 | const count = -2; 72 | const currentZoom = 1.00; 73 | const nextZoom = await nextZoomLevel(currentZoom, count); 74 | assert.equal(0.80, nextZoom); 75 | }); 76 | 77 | should("Zoom in from between values", async () => { 78 | const count = 1; 79 | const currentZoom = 1.05; 80 | const nextZoom = await nextZoomLevel(currentZoom, count); 81 | assert.equal(1.10, nextZoom); 82 | }); 83 | 84 | should("Zoom out from between values", async () => { 85 | const count = -1; 86 | const currentZoom = 1.05; 87 | const nextZoom = await nextZoomLevel(currentZoom, count); 88 | assert.equal(1.00, nextZoom); 89 | }); 90 | 91 | should("Zoom in past the maximum", async () => { 92 | const count = 15; 93 | const currentZoom = 1.00; 94 | const nextZoom = await nextZoomLevel(currentZoom, count); 95 | assert.equal(5.00, nextZoom); 96 | }); 97 | 98 | should("Zoom out past the minimum", async () => { 99 | const count = -15; 100 | const currentZoom = 1.00; 101 | const nextZoom = await nextZoomLevel(currentZoom, count); 102 | assert.equal(0.25, nextZoom); 103 | }); 104 | 105 | should("Zoom in from below the minimum", async () => { 106 | const count = 1; 107 | const currentZoom = 0.01; // lowest non-broken Chrome zoom level 108 | const nextZoom = await nextZoomLevel(currentZoom, count); 109 | assert.equal(0.25, nextZoom); 110 | }); 111 | 112 | should("Zoom out from above the maximum", async () => { 113 | const count = -1; 114 | const currentZoom = 9.99; // highest non-broken Chrome zoom level 115 | const nextZoom = await nextZoomLevel(currentZoom, count); 116 | assert.equal(5.00, nextZoom); 117 | }); 118 | 119 | should("Zoom in from above the maximum", async () => { 120 | const count = 1; 121 | const currentZoom = 9.99; // highest non-broken Chrome zoom level 122 | const nextZoom = await nextZoomLevel(currentZoom, count); 123 | assert.equal(5.00, nextZoom); 124 | }); 125 | 126 | should("Zoom out from below the minimum", async () => { 127 | const count = -1; 128 | const currentZoom = 0.01; // lowest non-broken Chrome zoom level 129 | const nextZoom = await nextZoomLevel(currentZoom, count); 130 | assert.equal(0.25, nextZoom); 131 | }); 132 | 133 | should("Test Chrome 33% zoom in with float error", async () => { 134 | const count = 1; 135 | const currentZoom = 0.32999999999999996; // The value chrome actually gives for 33%. 136 | const nextZoom = await nextZoomLevel(currentZoom, count); 137 | assert.equal(0.50, nextZoom); 138 | }); 139 | 140 | should("Test Chrome 175% zoom in with float error", async () => { 141 | const count = 1; 142 | const currentZoom = 1.7499999999999998; // The value chrome actually gives for 175%. 143 | const nextZoom = await nextZoomLevel(currentZoom, count); 144 | assert.equal(2.00, nextZoom); 145 | }); 146 | }); 147 | 148 | context("Selecting frames", () => { 149 | should("nextFrame", async () => { 150 | const focusedFrames = []; 151 | stub(chrome.webNavigation, "getAllFrames", () => [{ frameId: 1 }, { frameId: 2 }]); 152 | stub(chrome.tabs, "sendMessage", async (_tabId, message, options) => { 153 | if (message.handler == "getFocusStatus") { 154 | return { focused: options.frameId == 2, focusable: true }; 155 | } else if (message.handler == "focusFrame") { 156 | focusedFrames.push(options.frameId); 157 | } 158 | }); 159 | 160 | await BackgroundCommands.nextFrame(1, 0); 161 | assert.equal([1], focusedFrames); 162 | }); 163 | }); 164 | 165 | context("majorVersionHasIncreased", () => { 166 | should("return whether the major version has changed", () => { 167 | assert.equal(false, majorVersionHasIncreased(null)); 168 | shoulda.stub(Utils, "getCurrentVersion", () => "2.0.1"); 169 | assert.equal(false, majorVersionHasIncreased("2.0.0")); 170 | shoulda.stub(Utils, "getCurrentVersion", () => "2.1.0"); 171 | assert.equal(true, majorVersionHasIncreased("2.0.0")); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /tests/unit_tests/marks_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import * as marks from "../../background_scripts/marks.js"; 3 | 4 | context("marks", () => { 5 | const createMark = async (markProperties, tabProperties) => { 6 | const mark = Object.assign({ scrollX: 0, scrollY: 0 }, markProperties); 7 | const tab = Object.assign({ url: "http://example.com" }, tabProperties); 8 | const sender = { tab: tab }; 9 | await marks.create(mark, sender); 10 | }; 11 | 12 | setup(() => { 13 | chrome.storage.session.clear(); 14 | chrome.storage.session.set({ vimiumSecret: "secret" }); 15 | }); 16 | 17 | teardown(() => { 18 | chrome.storage.session.clear(); 19 | chrome.storage.local.clear(); 20 | }); 21 | 22 | should("record the vimium secret in the mark's info", async () => { 23 | await createMark({ markName: "a" }); 24 | const key = marks.getLocationKey("a"); 25 | const savedMark = (await chrome.storage.local.get(key))[key]; 26 | assert.equal("secret", savedMark.vimiumSecret); 27 | }); 28 | 29 | should("goto a mark when its tab exists", async () => { 30 | await createMark({ markName: "A" }, { id: 1 }); 31 | const tab = { url: "http://example.com" }; 32 | stub(globalThis.chrome.tabs, "get", (id) => id == 1 ? tab : null); 33 | const updatedTabs = []; 34 | stub(globalThis.chrome.tabs, "update", (id, properties) => updatedTabs[id] = properties); 35 | await marks.goto({ markName: "A" }); 36 | assert.isTrue(updatedTabs[1] && updatedTabs[1].active); 37 | }); 38 | 39 | should("find a new tab if a mark's tab no longer exists", async () => { 40 | await createMark({ markName: "A" }, { id: 1 }); 41 | const tab = { url: "http://example.com", id: 2 }; 42 | stub(globalThis.chrome.tabs, "get", (_id) => { 43 | throw new Error(); 44 | }); 45 | stub(globalThis.chrome.tabs, "query", (_) => [tab]); 46 | const updatedTabs = []; 47 | stub(globalThis.chrome.tabs, "update", (id, properties) => updatedTabs[id] = properties); 48 | await marks.goto({ markName: "A" }); 49 | assert.isTrue(updatedTabs[2] && updatedTabs[2].active); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /tests/unit_tests/settings_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import "../../lib/settings.js"; 3 | 4 | context("settings", () => { 5 | setup(async () => { 6 | // Prior to Vimium 2.0.0, the settings values were encoded as JSON strings. 7 | await chrome.storage.sync.set({ scrollStepSize: JSON.stringify(123) }); 8 | }); 9 | 10 | teardown(async () => { 11 | await Settings.clear(); 12 | }); 13 | 14 | should("Run v2.0.0 migration when loading settings", async () => { 15 | let storage = await chrome.storage.sync.get(null); 16 | assert.equal("123", storage.scrollStepSize); 17 | // The JSON value should've been migrated to an int when loading settings. 18 | await Settings.load(); 19 | const settings = Settings.getSettings(); 20 | assert.equal(123, settings["scrollStepSize"]); 21 | // When writing settings, the JSON value should be persisted back to storage. 22 | await Settings.set(settings); 23 | storage = await chrome.storage.sync.get(null); 24 | assert.equal(123, storage.scrollStepSize); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /tests/unit_tests/tab_operations_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import "../../lib/settings.js"; 3 | import * as to from "../../background_scripts/tab_operations.js"; 4 | 5 | context("TabOperations openurlInCurrentTab", () => { 6 | should("open a regular URL", async () => { 7 | let url = null; 8 | stub(chrome.tabs, "update", (id, args) => { 9 | url = args.url; 10 | }); 11 | const expected = "http://example.com"; 12 | await to.openUrlInCurrentTab({ url: expected }); 13 | assert.equal(expected, url); 14 | }); 15 | 16 | should("open a non-URL in the default search engine", async () => { 17 | let searchQuery = null; 18 | stub(chrome.search, "query", (queryInfo) => { 19 | searchQuery = queryInfo.text; 20 | }); 21 | const expected = "example query"; 22 | await to.openUrlInCurrentTab({ url: expected }); 23 | assert.equal(expected, searchQuery); 24 | }); 25 | 26 | should("open a javascript URL", async () => { 27 | let details = null; 28 | // NOTE(philc): This is a shallow test. 29 | stub(chrome.scripting, "executeScript", (_details) => { 30 | details = _details; 31 | }); 32 | const expected = "javascript:console.log('hello')"; 33 | await to.openUrlInCurrentTab({ url: expected }); 34 | assert.equal(expected, details.args[0]); 35 | }); 36 | }); 37 | 38 | context("TabOperations openUrlInNewTab", () => { 39 | should("open a regular URL", async () => { 40 | let config = null; 41 | stub(chrome.tabs, "create", (_config) => { 42 | config = _config; 43 | const newTab = { url: config.url }; 44 | return newTab; 45 | }); 46 | const expected = "http://example.com"; 47 | const tab = await to.openUrlInNewTab({ 48 | tab: { index: 1 }, 49 | position: "after", 50 | url: expected, 51 | }); 52 | assert.equal(2, config.index); 53 | assert.equal(expected, tab.url); 54 | }); 55 | 56 | should("open a non-URL in the default search engine", async () => { 57 | let createConfig, queryInfo; 58 | stub(chrome.tabs, "create", (config) => { 59 | createConfig = config; 60 | const newTab = { id: config.index }; 61 | return newTab; 62 | }); 63 | stub(chrome.search, "query", (info) => { 64 | queryInfo = info; 65 | }); 66 | await to.openUrlInNewTab({ 67 | tab: { index: 1 }, 68 | position: "after", 69 | url: "example query", 70 | }); 71 | assert.equal("data:text/html,", createConfig.url); 72 | assert.equal(2, createConfig.index); 73 | assert.equal("example query", queryInfo.text); 74 | assert.equal(2, queryInfo.tabId); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /tests/unit_tests/tab_recency_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import { TabRecency } from "../../background_scripts/tab_recency.js"; 3 | 4 | context("TabRecency", () => { 5 | let tabRecency; 6 | 7 | setup(() => tabRecency = new TabRecency()); 8 | 9 | context("order", () => { 10 | setup(async () => { 11 | stub(chrome.tabs, "query", () => Promise.resolve([])); 12 | await tabRecency.init(); 13 | tabRecency.queueAction("register", 1); 14 | tabRecency.queueAction("register", 2); 15 | tabRecency.queueAction("register", 3); 16 | tabRecency.queueAction("register", 4); 17 | tabRecency.queueAction("deregister", 4); 18 | tabRecency.queueAction("register", 2); 19 | }); 20 | 21 | should("have the correct entries in the correct order", () => { 22 | const expected = [2, 3, 1]; 23 | assert.equal(expected, tabRecency.getTabsByRecency()); 24 | }); 25 | 26 | should("score tabs by recency; current tab should be last", () => { 27 | const score = (id) => tabRecency.recencyScore(id); 28 | assert.equal(0, score(2)); 29 | assert.isTrue(score(2) < score(1)); 30 | assert.isTrue(score(1) < score(3)); 31 | }); 32 | }); 33 | 34 | should("navigate actions are queued until state from storage is loaded", async () => { 35 | let onActivated; 36 | stub(chrome.tabs.onActivated, "addListener", (fn) => { 37 | onActivated = fn; 38 | }); 39 | let resolveStorage; 40 | const storagePromise = new Promise((resolve, _) => resolveStorage = resolve); 41 | stub(chrome.storage.session, "get", () => storagePromise); 42 | tabRecency.init(); 43 | // Here, chrome.tabs.onActivated listeners have been added by tabrecency, but the 44 | // chrome.storage.session data hasn't yet loaded. 45 | onActivated({ tabId: 5 }); 46 | resolveStorage({}); 47 | await tabRecency.init(); 48 | assert.equal([5], tabRecency.getTabsByRecency()); 49 | }); 50 | 51 | should("loadFromStorage handles empty values", async () => { 52 | stub(chrome.tabs, "query", () => Promise.resolve([{ id: 1 }])); 53 | 54 | stub(chrome.storage.session, "get", () => Promise.resolve({})); 55 | await tabRecency.init(); 56 | assert.equal([], tabRecency.getTabsByRecency()); 57 | 58 | stub(chrome.storage.session, "get", () => Promise.resolve({ tabRecency: {} })); 59 | await tabRecency.loadFromStorage(); 60 | assert.equal([], tabRecency.getTabsByRecency()); 61 | }); 62 | 63 | should("loadFromStorage works", async () => { 64 | const tabs = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; 65 | stub(chrome.tabs, "query", () => Promise.resolve(tabs)); 66 | 67 | const storage = { tabRecency: { 1: 5, 2: 6 } }; 68 | stub(chrome.storage.session, "get", () => Promise.resolve(storage)); 69 | 70 | // Even though the in-storage tab counters are higher than the in-memory tabs, during 71 | // loading, the in-memory tab counters are adjusted to be the most recent. 72 | await tabRecency.init(); 73 | 74 | assert.equal([2, 1], tabRecency.getTabsByRecency()); 75 | 76 | tabRecency.queueAction("register", 3); 77 | tabRecency.queueAction("register", 1); 78 | 79 | assert.equal([1, 3, 2], tabRecency.getTabsByRecency()); 80 | }); 81 | 82 | should("loadFromStorage prunes out tabs which are no longer active", async () => { 83 | const tabs = [{ id: 1 }]; 84 | stub(chrome.tabs, "query", () => Promise.resolve(tabs)); 85 | 86 | const storage = { tabRecency: { 1: 5, 2: 6 } }; 87 | stub(chrome.storage.session, "get", () => Promise.resolve(storage)); 88 | await tabRecency.init(); 89 | assert.equal([1], tabRecency.getTabsByRecency()); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /tests/unit_tests/test_chrome_stubs.js: -------------------------------------------------------------------------------- 1 | // 2 | // This file contains stubs for a number of browser and chrome APIs which are missing in Deno. 3 | // 4 | 5 | // There are 3 chrome.storage.* objects with identical APIs. 6 | // - areaName: one of "local", "sync", "session". 7 | const createStorageAPI = (areaName) => { 8 | const storage = { 9 | store: {}, 10 | 11 | async set(items) { 12 | let key, value; 13 | chrome.runtime.lastError = undefined; 14 | for (key of Object.keys(items)) { 15 | value = items[key]; 16 | this.store[key] = value; 17 | } 18 | for (key of Object.keys(items)) { 19 | value = items[key]; 20 | globalThis.chrome.storage.onChanged.call(key, value, areaName); 21 | } 22 | }, 23 | 24 | async get(keysArg) { 25 | chrome.runtime.lastError = undefined; 26 | if (keysArg == null) { 27 | return globalThis.structuredClone(this.store); 28 | } else if (typeof keysArg == "string") { 29 | const result = {}; 30 | result[keysArg] = globalThis.structuredClone(this.store[keysArg]); 31 | return result; 32 | } else { 33 | const result = {}; 34 | for (key of keysArg) { 35 | result[key] = globalThis.structuredClone(this.store[key]); 36 | } 37 | return result; 38 | } 39 | }, 40 | 41 | async remove(key) { 42 | chrome.runtime.lastError = undefined; 43 | if (key in this.store) { 44 | delete this.store[key]; 45 | } 46 | globalThis.chrome.storage.onChanged.callEmpty(key); 47 | }, 48 | 49 | async clear() { 50 | // TODO: Consider firing the change listener if Chrome's API implementation does. 51 | this.store = {}; 52 | }, 53 | }; 54 | 55 | // The "session" storage has one API that the others don't. 56 | if (areaName == "session") storage.setAccessLevel = () => {}; 57 | return storage; 58 | }; 59 | 60 | globalThis.chrome = { 61 | areRunningVimiumTests: true, 62 | 63 | runtime: { 64 | getURL() { 65 | return ""; 66 | }, 67 | getManifest() { 68 | return { version: "1.2.3" }; 69 | }, 70 | onConnect: { 71 | addListener() { 72 | return true; 73 | }, 74 | }, 75 | onMessage: { 76 | addListener() { 77 | return true; 78 | }, 79 | }, 80 | onInstalled: { 81 | addListener() {}, 82 | }, 83 | onStartup: { 84 | addListener() {}, 85 | }, 86 | }, 87 | 88 | extension: { 89 | getURL(path) { 90 | return path; 91 | }, 92 | getBackgroundPage() { 93 | return {}; 94 | }, 95 | getViews() { 96 | return []; 97 | }, 98 | }, 99 | 100 | scripting: { 101 | executeScript() {}, 102 | }, 103 | 104 | search: { 105 | query() {}, 106 | }, 107 | 108 | tabs: { 109 | get(_id) {}, 110 | onUpdated: { 111 | addListener() { 112 | return true; 113 | }, 114 | }, 115 | onAttached: { 116 | addListener() { 117 | return true; 118 | }, 119 | }, 120 | onMoved: { 121 | addListener() { 122 | return true; 123 | }, 124 | }, 125 | onRemoved: { 126 | addListener() { 127 | return true; 128 | }, 129 | }, 130 | onActivated: { 131 | addListener() { 132 | return true; 133 | }, 134 | }, 135 | onReplaced: { 136 | addListener() { 137 | return true; 138 | }, 139 | }, 140 | query() { 141 | return true; 142 | }, 143 | sendMessage(_id, _properties) {}, 144 | update(_id, _properties) {}, 145 | }, 146 | 147 | webNavigation: { 148 | onHistoryStateUpdated: { 149 | addListener() {}, 150 | }, 151 | onReferenceFragmentUpdated: { 152 | addListener() {}, 153 | }, 154 | onCommitted: { 155 | addListener() {}, 156 | }, 157 | }, 158 | 159 | windows: { 160 | onRemoved: { 161 | addListener() { 162 | return true; 163 | }, 164 | }, 165 | getAll() { 166 | return true; 167 | }, 168 | getCurrent() { 169 | return {}; 170 | }, 171 | onFocusChanged: { 172 | addListener() { 173 | return true; 174 | }, 175 | }, 176 | update(_id, _properties) {}, 177 | }, 178 | 179 | browserAction: { 180 | setBadgeBackgroundColor() {}, 181 | }, 182 | 183 | sessions: { 184 | MAX_SESSION_RESULTS: 25, 185 | }, 186 | 187 | storage: { 188 | onChanged: { 189 | addListener(func) { 190 | this.func = func; 191 | }, 192 | 193 | // Fake a callback from chrome.storage.sync. 194 | call(key, value, area) { 195 | chrome.runtime.lastError = undefined; 196 | const key_value = {}; 197 | key_value[key] = { newValue: value }; 198 | if (this.func) return this.func(key_value, area); 199 | }, 200 | 201 | callEmpty(key) { 202 | chrome.runtime.lastError = undefined; 203 | if (this.func) { 204 | const items = {}; 205 | items[key] = {}; 206 | this.func(items, "sync"); 207 | } 208 | }, 209 | }, 210 | 211 | local: createStorageAPI("sync"), 212 | sync: createStorageAPI("sync"), 213 | session: createStorageAPI("session"), 214 | }, 215 | 216 | bookmarks: { 217 | getTree: () => [], 218 | }, 219 | }; 220 | -------------------------------------------------------------------------------- /tests/unit_tests/test_helper.js: -------------------------------------------------------------------------------- 1 | import * as shoulda from "../vendor/shoulda.js"; 2 | import * as jsdom from "jsdom"; 3 | import "./test_chrome_stubs.js"; 4 | import "../../lib/utils.js"; 5 | 6 | const shouldaSubset = { 7 | assert: shoulda.assert, 8 | context: shoulda.context, 9 | ensureCalled: shoulda.ensureCalled, 10 | setup: shoulda.setup, 11 | should: shoulda.should, 12 | shoulda: shoulda, 13 | stub: shoulda.stub, 14 | returns: shoulda.returns, 15 | teardown: shoulda.teardown, 16 | }; 17 | 18 | globalThis.isUnitTests = true; 19 | 20 | // Attach shoulda's functions, like setup, context, should, to the global namespace. 21 | Object.assign(globalThis, shouldaSubset); 22 | 23 | export async function jsdomStub(htmlFile) { 24 | const html = await Deno.readTextFile(htmlFile); 25 | const w = new jsdom.JSDOM(html).window; 26 | stub(globalThis, "window", w); 27 | stub(globalThis, "document", w.document); 28 | stub(globalThis, "MouseEvent", w.MouseEvent); 29 | stub(globalThis, "MutationObserver", w.MutationObserver); 30 | // We might not need to stub HTMLElement once we resolve the TODO on DomUtils.createElement 31 | stub(globalThis, "HTMLElement", w.HTMLElement); 32 | } 33 | -------------------------------------------------------------------------------- /tests/unit_tests/ui_component_test.js: -------------------------------------------------------------------------------- 1 | import * as testHelper from "./test_helper.js"; 2 | import "../../lib/dom_utils.js"; 3 | import "../../content_scripts/ui_component.js"; 4 | 5 | function stubPostMessage(iframeEl, fn) { 6 | if (!iframeEl || !fn) throw new Error("iframeEl and fn are required."); 7 | Object.defineProperty(iframeEl, "contentWindow", { 8 | value: { postMessage: fn }, 9 | writable: false, 10 | configurable: true, 11 | }); 12 | } 13 | 14 | context("UIComponent", () => { 15 | let c; 16 | 17 | setup(async () => { 18 | // Which page we load doesn't matter; we just need any DOM. 19 | await testHelper.jsdomStub("pages/help_dialog_page.html"); 20 | }); 21 | 22 | teardown(() => { 23 | // MessageChannel ports must be closed, or our test process will never terminate. See 24 | // https://github.com/facebook/react/issues/26608 25 | for (const port of c?.messageChannelPorts) { 26 | port.close(); 27 | } 28 | }); 29 | 30 | should("focus the frame when showing", async () => { 31 | c = new UIComponent("testing.html", "example-class"); 32 | await c.load("example.html", "example-class"); 33 | stubPostMessage(c.iframeElement, function () {}); 34 | c.iframeElement.dispatchEvent(new window.Event("load")); 35 | assert.equal(document.body, document.activeElement); 36 | 37 | // The shadow root element containing the iframe should be focused. 38 | c.show(); 39 | assert.equal(c.iframeElement.getRootNode().host, document.activeElement); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /tests/unit_tests/url_utils_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import "../../lib/settings.js"; 3 | import "../../lib/url_utils.js"; 4 | 5 | context("isUrl", () => { 6 | should("accept valid URLs", async () => { 7 | assert.isTrue(await UrlUtils.isUrl("www.google.com")); 8 | assert.isTrue(await UrlUtils.isUrl("www.bbc.co.uk")); 9 | assert.isTrue(await UrlUtils.isUrl("yahoo.com")); 10 | assert.isTrue(await UrlUtils.isUrl("nunames.nu")); 11 | assert.isTrue(await UrlUtils.isUrl("user:pass@ftp.xyz.com/test")); 12 | 13 | assert.isTrue(await UrlUtils.isUrl("localhost/index.html")); 14 | assert.isTrue(await UrlUtils.isUrl("127.0.0.1:8192/test.php")); 15 | 16 | // IPv6 17 | assert.isTrue(await UrlUtils.isUrl("[::]:9000")); 18 | 19 | // Long TLDs 20 | assert.isTrue(await UrlUtils.isUrl("testing.social")); 21 | assert.isTrue(await UrlUtils.isUrl("testing.onion")); 22 | 23 | // // Internal URLs. 24 | assert.isTrue( 25 | await UrlUtils.isUrl( 26 | "moz-extension://c66906b4-3785-4a60-97bc-094a6366017e/pages/options.html", 27 | ), 28 | ); 29 | }); 30 | 31 | should("reject invalid URLs", async () => { 32 | assert.isFalse(await UrlUtils.isUrl("a.x")); 33 | assert.isFalse(await UrlUtils.isUrl("www-domain-tld")); 34 | assert.isFalse(await UrlUtils.isUrl("http://www.example.com/ has-space")); 35 | }); 36 | }); 37 | 38 | context("convertToUrl", async () => { 39 | should("detect and clean up valid URLs", async () => { 40 | assert.equal("http://www.google.com/", await UrlUtils.convertToUrl("http://www.google.com/")); 41 | assert.equal( 42 | "http://www.google.com/", 43 | await UrlUtils.convertToUrl(" http://www.google.com/ "), 44 | ); 45 | assert.equal("http://www.google.com", await UrlUtils.convertToUrl("www.google.com")); 46 | assert.equal("http://google.com", await UrlUtils.convertToUrl("google.com")); 47 | assert.equal("http://localhost", await UrlUtils.convertToUrl("localhost")); 48 | assert.equal("http://xyz.museum", await UrlUtils.convertToUrl("xyz.museum")); 49 | assert.equal("chrome://extensions", await UrlUtils.convertToUrl("chrome://extensions")); 50 | assert.equal( 51 | "http://user:pass@ftp.xyz.com/test", 52 | await UrlUtils.convertToUrl("user:pass@ftp.xyz.com/test"), 53 | ); 54 | assert.equal("http://127.0.0.1", await UrlUtils.convertToUrl("127.0.0.1")); 55 | assert.equal("http://127.0.0.1:8080", await UrlUtils.convertToUrl("127.0.0.1:8080")); 56 | assert.equal("http://[::]:8080", await UrlUtils.convertToUrl("[::]:8080")); 57 | assert.equal("view-source: 0.0.0.0", await UrlUtils.convertToUrl("view-source: 0.0.0.0")); 58 | assert.equal( 59 | "javascript:alert('25 % 20 * 25%20');", 60 | await UrlUtils.convertToUrl("javascript:alert('25 % 20 * 25%20');"), 61 | ); 62 | }); 63 | }); 64 | 65 | context("createSearchUrl", () => { 66 | should("replace %S without encoding", () => { 67 | assert.equal( 68 | "https://www.github.com/philc/vimium/pulls", 69 | UrlUtils.createSearchUrl("vimium/pulls", "https://www.github.com/philc/%S"), 70 | ); 71 | }); 72 | }); 73 | 74 | context("hasChromeProtocol", () => { 75 | should("detect chrome prefixes of URLs", () => { 76 | assert.isTrue(UrlUtils.hasChromeProtocol("about:foobar")); 77 | assert.isTrue(UrlUtils.hasChromeProtocol("view-source:foobar")); 78 | assert.isTrue(UrlUtils.hasChromeProtocol("chrome-extension:foobar")); 79 | assert.isTrue(UrlUtils.hasChromeProtocol("data:foobar")); 80 | assert.isTrue(UrlUtils.hasChromeProtocol("data:")); 81 | assert.isFalse(UrlUtils.hasChromeProtocol("")); 82 | assert.isFalse(UrlUtils.hasChromeProtocol("about")); 83 | assert.isFalse(UrlUtils.hasChromeProtocol("view-source")); 84 | assert.isFalse(UrlUtils.hasChromeProtocol("chrome-extension")); 85 | assert.isFalse(UrlUtils.hasChromeProtocol("data")); 86 | assert.isFalse(UrlUtils.hasChromeProtocol("data :foobar")); 87 | }); 88 | }); 89 | 90 | context("hasJavascriptProtocol", () => { 91 | should("detect javascript: URLs", () => { 92 | assert.isTrue(UrlUtils.hasJavascriptProtocol("javascript:foobar")); 93 | assert.isFalse(UrlUtils.hasJavascriptProtocol("http:foobar")); 94 | }); 95 | }); 96 | -------------------------------------------------------------------------------- /tests/unit_tests/user_search_engines_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | 3 | import * as userSearchEngines from "../../background_scripts/user_search_engines.js"; 4 | import { UserSearchEngine } from "../../background_scripts/user_search_engines.js"; 5 | 6 | context("UserSearchEngines", () => { 7 | should("parse out search engine text", () => { 8 | const config = [ 9 | "g: http://google.com/%s Google Search", 10 | "random line", 11 | "# comment", 12 | " w: http://wikipedia.org/%s", 13 | ].join("\n"); 14 | 15 | const results = userSearchEngines.parseConfig(config).keywordToEngine; 16 | 17 | assert.equal( 18 | { 19 | g: new UserSearchEngine({ 20 | keyword: "g", 21 | url: "http://google.com/%s", 22 | description: "Google Search", 23 | }), 24 | w: new UserSearchEngine({ 25 | keyword: "w", 26 | url: "http://wikipedia.org/%s", 27 | description: "search (w)", 28 | }), 29 | }, 30 | results, 31 | ); 32 | }); 33 | 34 | should("return validation errors", () => { 35 | const getErrors = (config) => userSearchEngines.parseConfig(config).validationErrors; 36 | assert.equal(0, getErrors("g: http://google.com").length); 37 | // Missing colon. 38 | assert.equal(1, getErrors("g http://google.com").length); 39 | // Not enough tokens. 40 | assert.equal(1, getErrors("g:").length); 41 | // Invalid search engine URL. 42 | assert.equal(1, getErrors("g: invalid-url").length); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /tests/unit_tests/utils_test.js: -------------------------------------------------------------------------------- 1 | import "./test_helper.js"; 2 | import "../../lib/settings.js"; 3 | import "../../lib/url_utils.js"; 4 | 5 | context("forTrusted", () => { 6 | should("invoke an event handler if the event is trusted", () => { 7 | let called = false; 8 | const f = forTrusted(() => called = true); 9 | const event = { isTrusted: true }; 10 | f(event); 11 | assert.equal(true, called); 12 | }); 13 | 14 | should("not invoke an event handler if the event is untrusted", () => { 15 | let called = false; 16 | const f = forTrusted(() => called = true); 17 | const event = { isTrusted: false }; 18 | f(event); 19 | assert.equal(false, called); 20 | f(null); 21 | assert.equal(false, called); 22 | }); 23 | }); 24 | 25 | context("extractQuery", () => { 26 | should("extract queries from search URLs", () => { 27 | assert.equal( 28 | "bbc sport 1", 29 | Utils.extractQuery( 30 | "https://www.google.ie/search?q=%s", 31 | "https://www.google.ie/search?q=bbc+sport+1", 32 | ), 33 | ); 34 | assert.equal( 35 | "bbc sport 2", 36 | Utils.extractQuery( 37 | "http://www.google.ie/search?q=%s", 38 | "https://www.google.ie/search?q=bbc+sport+2", 39 | ), 40 | ); 41 | assert.equal( 42 | "bbc sport 3", 43 | Utils.extractQuery( 44 | "https://www.google.ie/search?q=%s", 45 | "http://www.google.ie/search?q=bbc+sport+3", 46 | ), 47 | ); 48 | assert.equal( 49 | "bbc sport 4", 50 | Utils.extractQuery( 51 | "https://www.google.ie/search?q=%s", 52 | "http://www.google.ie/search?q=bbc+sport+4&blah", 53 | ), 54 | ); 55 | }); 56 | }); 57 | 58 | context("decodeURIByParts", () => { 59 | should("decode javascript: URLs", () => { 60 | assert.equal("foobar", Utils.decodeURIByParts("foobar")); 61 | assert.equal(" ", Utils.decodeURIByParts("%20")); 62 | assert.equal("25 % 20 25 ", Utils.decodeURIByParts("25 % 20 25%20")); 63 | }); 64 | }); 65 | 66 | context("compare versions", () => { 67 | should("compare correctly", () => { 68 | assert.equal(0, Utils.compareVersions("1.40.1", "1.40.1")); 69 | assert.equal(0, Utils.compareVersions("1.40", "1.40.0")); 70 | assert.equal(0, Utils.compareVersions("1.40.0", "1.40")); 71 | assert.equal(-1, Utils.compareVersions("1.40.1", "1.40.2")); 72 | assert.equal(-1, Utils.compareVersions("1.40.1", "1.41")); 73 | assert.equal(-1, Utils.compareVersions("1.40", "1.40.1")); 74 | assert.equal(1, Utils.compareVersions("1.41", "1.40")); 75 | assert.equal(1, Utils.compareVersions("1.41.0", "1.40")); 76 | assert.equal(1, Utils.compareVersions("1.41.1", "1.41")); 77 | }); 78 | }); 79 | 80 | context("makeIdempotent", () => { 81 | let func; 82 | let count = 0; 83 | 84 | setup(() => { 85 | count = 0; 86 | func = Utils.makeIdempotent((n) => { 87 | if (n == null) { 88 | n = 1; 89 | } 90 | count += n; 91 | }); 92 | }); 93 | 94 | should("call a function once", () => { 95 | func(); 96 | assert.equal(1, count); 97 | }); 98 | 99 | should("call a function once with an argument", () => { 100 | func(2); 101 | assert.equal(2, count); 102 | }); 103 | 104 | should("not call a function a second time", () => { 105 | func(); 106 | assert.equal(1, count); 107 | }); 108 | 109 | should("not call a function a second time", () => { 110 | func(); 111 | assert.equal(1, count); 112 | func(); 113 | assert.equal(1, count); 114 | }); 115 | }); 116 | 117 | context("distinctCharacters", () => { 118 | should( 119 | "eliminate duplicate characters", 120 | () => assert.equal("abc", Utils.distinctCharacters("bbabaabbacabbbab")), 121 | ); 122 | }); 123 | 124 | context("escapeRegexSpecialCharacters", () => { 125 | should("escape regexp special characters", () => { 126 | const str = "-[]/{}()*+?.^$|"; 127 | const regexp = new RegExp(Utils.escapeRegexSpecialCharacters(str)); 128 | assert.isTrue(regexp.test(str)); 129 | }); 130 | }); 131 | 132 | context("extractQuery", () => { 133 | should("extract the query terms from a URL", () => { 134 | const url = "https://www.google.ie/search?q=star+wars&foo&bar"; 135 | const searchUrl = "https://www.google.ie/search?q=%s"; 136 | assert.equal("star wars", Utils.extractQuery(searchUrl, url)); 137 | }); 138 | 139 | should("require trailing URL components", () => { 140 | const url = "https://www.google.ie/search?q=star+wars&foo&bar"; 141 | const searchUrl = "https://www.google.ie/search?q=%s&foobar=x"; 142 | assert.equal(null, Utils.extractQuery(searchUrl, url)); 143 | }); 144 | 145 | should("accept trailing URL components", () => { 146 | const url = "https://www.google.ie/search?q=star+wars&foo&bar&foobar=x"; 147 | const searchUrl = "https://www.google.ie/search?q=%s&foobar=x"; 148 | assert.equal("star wars", Utils.extractQuery(searchUrl, url)); 149 | }); 150 | }); 151 | 152 | context("pick", () => { 153 | should("omit properties", () => { 154 | assert.equal({ a: 1, b: 2 }, Utils.pick({ a: 1, b: 2, c: 3 }, ["a", "b", "d"])); 155 | }); 156 | }); 157 | 158 | context("keyBy", () => { 159 | const array = [ 160 | { key: "a" }, 161 | { key: "b" }, 162 | ]; 163 | 164 | should("group by string key", () => { 165 | assert.equal( 166 | { a: array[0], b: array[1] }, 167 | Utils.keyBy(array, "key"), 168 | ); 169 | }); 170 | 171 | should("group by key function", () => { 172 | assert.equal( 173 | { a: array[0], b: array[1] }, 174 | Utils.keyBy(array, (el) => el.key), 175 | ); 176 | }); 177 | }); 178 | 179 | context("assertType", () => { 180 | should("fail if schema or object is null", () => { 181 | assert.throwsError(() => Utils.assertType(null, { a: 1 })); 182 | assert.throwsError(() => Utils.assertType({ a: null }, null)); 183 | }); 184 | 185 | should("not allow unknown fields", () => { 186 | const schema = { a: null }; 187 | Utils.assertType(schema, { a: 1 }); 188 | assert.throwsError(() => Utils.assertType(schema, { b: 1 })); 189 | }); 190 | 191 | should("type check fields with types", () => { 192 | const schema = { 193 | bool: "boolean", 194 | num: "number", 195 | string: "string" 196 | }; 197 | Utils.assertType(schema, { 198 | bool: true, 199 | num: 1, 200 | string: "example" 201 | }); 202 | assert.throwsError(() => Utils.assertType(schema, { bool: 1 })); 203 | assert.throwsError(() => Utils.assertType(schema, { num: "example" })); 204 | assert.throwsError(() => Utils.assertType(schema, { string: 1 })); 205 | }); 206 | 207 | should("allow null values for typed fields", () => { 208 | Utils.assertType({ bool: "boolean" }, { bool: null }); 209 | }); 210 | }); 211 | -------------------------------------------------------------------------------- /tests/unit_tests/vomnibar_page_test.js: -------------------------------------------------------------------------------- 1 | import * as testHelper from "./test_helper.js"; 2 | import "../../tests/unit_tests/test_chrome_stubs.js"; 3 | import { Suggestion } from "../../background_scripts/completion.js"; 4 | import * as vomnibarPage from "../../pages/vomnibar_page.js"; 5 | 6 | function newKeyEvent(properties) { 7 | return Object.assign( 8 | { 9 | type: "keydown", 10 | key: "a", 11 | ctrlKey: false, 12 | shiftKey: false, 13 | altKey: false, 14 | metaKey: false, 15 | stopImmediatePropagation: function () {}, 16 | preventDefault: function () {}, 17 | }, 18 | properties, 19 | ); 20 | } 21 | 22 | context("vomnibar page", () => { 23 | let ui; 24 | setup(async () => { 25 | await testHelper.jsdomStub("pages/vomnibar_page.html"); 26 | stub(chrome.runtime, "sendMessage", async (message) => { 27 | if (message.handler == "filterCompletions") { 28 | return []; 29 | } 30 | }); 31 | vomnibarPage.reset(); 32 | await vomnibarPage.activate(); 33 | ui = vomnibarPage.ui; 34 | }); 35 | 36 | should("hide when escape is pressed", async () => { 37 | ui.setQuery("www.example.com"); 38 | // Here we assert that the dialog has been reset when esc is pressed, which happens as part of 39 | // hiding the dialog. It would be better to check more directly that the dialog was hidden, but 40 | // jacking into the channels for this are not worthwhile for this test. 41 | await ui.onKeyEvent(newKeyEvent({ key: "Escape" })); 42 | assert.equal("", ui.input.value); 43 | }); 44 | 45 | should("edit a completion's URL when ctrl-enter is pressed", async () => { 46 | stub(chrome.runtime, "sendMessage", async (message) => { 47 | if (message.handler == "filterCompletions") { 48 | const s = new Suggestion({ url: "http://hello.com" }); 49 | return [s]; 50 | } 51 | }); 52 | await ui.update(); 53 | await ui.onKeyEvent(newKeyEvent({ type: "keydown", key: "up" })); 54 | // TODO(philc): Why does this need to be lowercase enter? 55 | await ui.onKeyEvent(newKeyEvent({ type: "keypress", ctrlKey: true, key: "enter" })); 56 | assert.equal("http://hello.com", ui.input.value); 57 | }); 58 | 59 | should("open a URL-like query when enter is pressed", async () => { 60 | ui.setQuery("www.example.com"); 61 | let handler = null; 62 | let url = null; 63 | stub(chrome.runtime, "sendMessage", async (message) => { 64 | handler = message.handler; 65 | url = message.url; 66 | }); 67 | await ui.onKeyEvent(newKeyEvent({ type: "keypress", key: "Enter" })); 68 | assert.equal("openUrlInCurrentTab", handler); 69 | assert.equal("www.example.com", url); 70 | }); 71 | 72 | should("search for a non-URL query when enter is pressed", async () => { 73 | ui.setQuery("example"); 74 | let handler = null; 75 | let query = null; 76 | stub(chrome.runtime, "sendMessage", async (message) => { 77 | handler = message.handler; 78 | query = message.query; 79 | }); 80 | await ui.onKeyEvent(newKeyEvent({ type: "keypress", key: "Enter" })); 81 | ui.onHidden(); 82 | assert.equal("launchSearchQuery", handler); 83 | assert.equal("example", query); 84 | }); 85 | 86 | // This test covers #4396. 87 | should("not treat javascript keywords as user-defined search engines", async () => { 88 | ui.setQuery("constructor "); // "constructor" is a built-in JS property 89 | ui.onInput(); 90 | // The query should not be treated as a user search engine. 91 | assert.equal("constructor ", ui.input.value); 92 | }); 93 | }); 94 | --------------------------------------------------------------------------------