aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--.travis.yml3
-rw-r--r--CONTRIBUTING.md85
-rw-r--r--CREDITS2
-rw-r--r--Cakefile6
-rw-r--r--README.md82
-rw-r--r--background_scripts/commands.coffee55
-rw-r--r--background_scripts/completion.coffee140
-rw-r--r--background_scripts/exclusions.coffee70
-rw-r--r--background_scripts/main.coffee182
-rw-r--r--background_scripts/settings.coffee68
-rw-r--r--background_scripts/sync.coffee102
-rw-r--r--content_scripts/file_urls.css6
-rw-r--r--content_scripts/link_hints.coffee64
-rw-r--r--content_scripts/scroller.coffee26
-rw-r--r--content_scripts/vimium.css7
-rw-r--r--content_scripts/vimium_frontend.coffee141
-rw-r--r--content_scripts/vomnibar.coffee16
-rw-r--r--icons/browser_action_partial.pngbin0 -> 34384 bytes
-rw-r--r--icons/icon48partial.pngbin0 -> 3815 bytes
-rw-r--r--lib/dom_utils.coffee20
-rw-r--r--lib/keyboard_utils.coffee3
-rw-r--r--lib/utils.coffee2
-rw-r--r--manifest.json11
-rw-r--r--pages/help_dialog.html2
-rw-r--r--pages/options.coffee268
-rw-r--r--pages/options.html87
-rw-r--r--pages/popup.coffee90
-rw-r--r--pages/popup.html24
-rw-r--r--test_harnesses/vomnibar.html16
-rw-r--r--tests/dom_tests/dom_tests.coffee29
-rw-r--r--tests/dom_tests/dom_utils_test.coffee8
-rw-r--r--tests/dom_tests/phantom_runner.coffee15
-rw-r--r--tests/unit_tests/completion_test.coffee78
-rw-r--r--tests/unit_tests/exclusion_test.coffee73
-rw-r--r--tests/unit_tests/settings_test.coffee55
-rw-r--r--tests/unit_tests/test_chrome_stubs.coffee90
-rw-r--r--tests/unit_tests/utils_test.coffee4
38 files changed, 1551 insertions, 380 deletions
diff --git a/.gitignore b/.gitignore
index 9ac3be14..9df0d559 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,5 +3,6 @@
*.swp
*.crx
*.js
+*.sublime*
node_modules/*
dist
diff --git a/.travis.yml b/.travis.yml
index d79a1559..6393bfaf 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,7 +1,8 @@
language: node_js
-node_js: 0.8
+node_js: 0.10
before_install:
- "npm install -g coffee-script"
+ - "npm install path"
- "cake build"
script: "cake test"
notifications:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 21cdb98b..9382a020 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,10 +1,11 @@
# Contributing to Vimium
You'd like to fix a bug or implement a feature? Great! Check out the bugs on our issues tracker, or implement
-one of the suggestions there that have been tagged 'todo'. If you have a suggestion of your own, start a
-discussion on the issues tracker or on the [mailing list](http://groups.google.com/group/vimium-dev?hl=en). If
-it mirrors a similar feature in another browser or in Vim itself, let us know! Once you've picked something to
-work on, add a comment to the respective issue so others don't duplicate your effort.
+one of the suggestions there that have been tagged "help wanted". If you have a suggestion of your own, start
+a discussion on the issues tracker or on the
+[mailing list](http://groups.google.com/group/vimium-dev?hl=en). If it mirrors a similar feature in another
+browser or in Vim itself, let us know. Once you've picked something to work on, add a comment to the
+respective issue so others don't duplicate your effort.
## Reporting Issues
@@ -25,19 +26,25 @@ install Vimium from source:
1. Click on "Load Unpacked Extension..."
1. Select the Vimium directory.
-## Tests
+## Development tips
+
+ 1. Run `cake autobuild` to watch for changes to coffee files, and have the .js files automatically
+ regenerated
+
+## Running the tests
Our tests use [shoulda.js](https://github.com/philc/shoulda.js) and [PhantomJS](http://phantomjs.org/). To run the tests:
1. `git submodule update --init --recursive` -- this pulls in shoulda.js.
- 1. [Install PhantomJS.](http://phantomjs.org/download.html)
+ 1. Install [PhantomJS](http://phantomjs.org/download.html).
+ 1. `npm install path` to install the [Node.js Path module](http://nodejs.org/api/path.html), used by the test runner.
1. `cake build` to compile `*.coffee` to `*.js`
1. `cake test` to run the tests.
## Code Coverage
-Bugs and features are not the only way to contribute -- more tests are always welcome. You can find out which
-portions of code need them by looking at our coverage reports. To generate these reports:
+You can find out which portions of code need them by looking at our coverage reports. To generate these
+reports:
1. Download [JSCoverage](http://siliconforks.com/jscoverage/download.html) or `brew install jscoverage`
1. `npm install temp`
@@ -45,11 +52,6 @@ portions of code need them by looking at our coverage reports. To generate these
then be viewed using [jscoverage-report](https://github.com/int3/jscoverage-report). See
jscoverage-report's [README](https://github.com/int3/jscoverage-report#jscoverage-report) for more details.
-## Pull Requests
-
-When you're done with your changes, send us a pull request on Github. Feel free to include a change to the
-CREDITS file with your patch.
-
## Coding Style
* We follow the recommendations from
@@ -57,3 +59,60 @@ CREDITS file with your patch.
* We follow two major differences from this style guide:
* Wrap lines at 110 characters instead of 80.
* Use double-quoted strings by default.
+ * When writing comments, uppercase the first letter of your sentence, and put a period at the end.
+ * If you have a short conditional, feel free to put it on one line:
+
+ # No
+ if i < 10
+ return
+
+ # Yes
+ return if i < 10
+
+## Pull Requests
+
+When you're done with your changes, send us a pull request on Github. Feel free to include a change to the
+CREDITS file with your patch.
+
+## What makes for a good feature request/contribution to Vimium?
+
+Good features:
+
+* Useful for lots of Vimium users
+* Require no/little documentation
+* Useful without configuration
+* Intuitive or leverage strong convention from Vim
+* Work robustly on most/all sites
+
+Less-good features:
+
+* Are very niche, and so aren't useful for many Vimium users
+* Require explanation
+* Require configuration before it becomes useful
+* Unintuitive, or they don't leverage a strong convention from Vim
+* Might be flaky and don't work in many cases
+
+We use these guidelines, in addition to the code complexity, when deciding whether to merge in a pull request.
+
+If you're worried that a feature you plan to build won't be a good fit for core Vimium, just open a github
+issue for discussion or send an email to the Vimium mailing list.
+
+## How to release Vimium to the Chrome Store
+
+This process is currently only done by Phil or Ilya.
+
+1. Increment the version number in manifest.json
+2. Update the Changelog in README.md
+
+ You can see a summary of commits since the last version: `git log --oneline v1.45..`
+
+3. Push your commits
+4. Create a git tag for this newly released version
+
+ git tag -a v1.45 -m "v1.45 release"
+
+5. Run `cake package`
+6. Take the distributable found in `dist` and upload it
+ [here](https://chrome.google.com/webstore/developer/dashboard)
+7. Update the description in the Chrome store to include the latest version's release notes
+8. Celebrate
diff --git a/CREDITS b/CREDITS
index 605860fe..60a5acaa 100644
--- a/CREDITS
+++ b/CREDITS
@@ -28,6 +28,8 @@ Contributors:
markstos
Matthew Cline <matt@nightrealms.com>
Matt Garriott (github: mgarriott)
+ Matthew Ryan (github: mrmr1993)
+ Michael Hauser-Raspe (github: mijoharas)
Murph (github: pandeiro)
Niklas Baumstark <niklas.baumstark@gmail.com> (github: niklasb)
rodimius
diff --git a/Cakefile b/Cakefile
index bae79009..0fa75e24 100644
--- a/Cakefile
+++ b/Cakefile
@@ -1,8 +1,14 @@
+util = require "util"
fs = require "fs"
path = require "path"
child_process = require "child_process"
spawn = (procName, optArray, silent=false) ->
+ if process.platform is "win32"
+ # if win32, prefix arguments with "/c {original command}"
+ # e.g. "coffee -c c:\git\vimium" becomes "cmd.exe /c coffee -c c:\git\vimium"
+ optArray.unshift "/c", procName
+ procName = "cmd.exe"
proc = child_process.spawn procName, optArray
unless silent
proc.stdout.on 'data', (data) -> process.stdout.write data
diff --git a/README.md b/README.md
index ca2bf7fb..fc38c2b8 100644
--- a/README.md
+++ b/README.md
@@ -22,7 +22,7 @@ Keyboard Bindings
-----------------
Modifier keys are specified as `<c-x>`, `<m-x>`, and `<a-x>` for ctrl+x, meta+x, and alt+x
-respectively. See the next section for instructions on modifying these bindings.
+respectively. See the next section for instructions on customizing these bindings.
Navigating the current page:
@@ -74,6 +74,7 @@ Manipulating tabs:
x close current tab
X restore closed tab (i.e. unwind the 'x' command)
T search through your open tabs
+ <a-p> pin/unpin current tab
Additional advanced browsing commands:
@@ -89,6 +90,8 @@ Additional advanced browsing commands:
Vimium supports command repetition so, for example, hitting '5t' will open 5 tabs in rapid succession. `<ESC>` (or
`<c-[>`) will clear any partial commands in the queue and will also exit insert and find modes.
+There are some advanced commands which aren't documented here. Refer to the help dialog (type `?`) for a full
+list.
Custom Key Mappings
-------------------
@@ -125,18 +128,31 @@ The following special keys are available for mapping:
Shifts are automatically detected so, for example, `<c-&>` corresponds to ctrl+shift+7 on an English keyboard.
+More documentation
+------------------
+Many of the more advanced or involved features are documented on [Vimium's github wiki](https://github.com/philc/vimium/wiki).
+
Contributing
------------
Please see [CONTRIBUTING.md](https://github.com/philc/vimium/blob/master/CONTRIBUTING.md) for details.
Release Notes
-------------
-1.44 (11/06/2013)
+1.45 (2014-07-20)
+
+- Vimium's settings are now synced across computers.
+- New commands: "open link in new tab and focus", "move tab left", "move tab right", "pin/unpin tab".
+- Vomnibar can now use [search engine shortcuts](https://github.com/philc/vimium/wiki/Search-Engines), similar to Chrome's Omnibar.
+- Due to significant ranking improvements, Vomnibar's search results are now even more helpful.
+- When reopening a closed tab, its history is now preserved.
+- Bugfixes.
+
+1.44 (2013-11-06)
- Add support for recent versions of Chromium.
- Bugfixes.
-1.43 (05/18/2013)
+1.43 (2013-05-18)
- Relevancy improvements to the Vomnibar's domain & history search.
- Added gU, which goes to the root of the current URL.
@@ -148,25 +164,25 @@ Release Notes
- Update our Chrome APIs so Vimium works on Chrome 28+.
- Bugfixes.
-1.42 (11/03/2012)
+1.42 (2012-11-03)
- Bugfixes.
-1.41 (10/27/2012)
+1.41 (2012-10-27)
- Bugfixes.
-1.40 (10/27/2012)
+1.40 (2012-10-27)
- Bugfixes.
- Added options for search engines and regex find.
- Pressing unmapped keys in hints mode now deactivates the mode.
-1.39 (09/09/2012)
+1.39 (2012-09-09)
- Bugfixes.
-1.38 (09/08/2012)
+1.38 (2012-09-08)
- `O` now opens Vomnibar results in a new tab. `B` does the same for bookmarks only.
- Add a browser icon to quickly add sites to Vimium's exclude list.
@@ -174,33 +190,33 @@ Release Notes
- `gi` now launches a new mode that allows the user to tab through the input elements on the page.
- Bugfixes.
-1.37 (07/07/2012)
+1.37 (2012-07-07)
- Select the first result by default in Vomnibar tab and bookmark modes.
-1.36 (07/07/2012)
+1.36 (2012-07-07)
- 'b' brings up a bookmark-only Vomnibar.
- Better support for some bookmarklets.
-1.35 (07/05/2012)
+1.35 (2012-07-05)
- Bugfixes.
-1.34 (07/03/2012)
+1.34 (2012-07-03)
- A bugfix for bookmarklets in Vomnibar.
-1.33 (07/02/2012)
+1.33 (2012-07-02)
- A Vomnibar, which allows you to open sites from history, bookmarks, and tabs using Vimium's UI. Type "o" to try it.
-1.32 (03/05/2012)
+1.32 (2012-03-05)
- More tweaks to the next / previous link-detection algorithm.
- Minor bug fixes.
-1.31 (02/28/2012)
+1.31 (2012-02-28)
- Improve style of link hints, and use fewer characters for hints.
- Add an option to hide the heads up display (HUD). Notably, the HUD obscures Facebook Chat's textbox.
@@ -210,7 +226,7 @@ Release Notes
- A new find mode which optionally supports case sensitivity and regular expressions.
- Bug fixes.
-1.30 (12/04/2011)
+1.30 (2011-12-04)
- Support for image maps in link hints.
- Counts now work with forward & backward navigation.
@@ -218,29 +234,29 @@ Release Notes
- An alternate link hints mode: type the title of a link to select it. You can enable it in Vimium's Advanced Preferences.
- Bug fixes.
-1.29 (07/30/2011)
+1.29 (2012-07-30)
- `yf` to copy a link hint url to the clipboard.
- Scatter link hints to prevent clustering on dense sites.
- Don't show insert mode notification unless you specifically hit `i`.
- Remove zooming functionality now that Chrome does it all natively.
-1.28 (06/29/2011)
+1.28 (2011-06-29)
- Support for opening bookmarks (`b` and `B`).
- Support for contenteditable text boxes.
- Speed improvements and bugfixes.
-1.27 (03/24/2011)
+1.27 (2011-03-24)
- Improvements and bugfixes.
-1.26 (02/17/2011)
+1.26 (2011-02-17)
- `<c-d>`, `<c-f>` and related are no longer bound by default. You can rebind them on the options page.
- Faster link hinting.
-1.22, 1.23, 1.24, 1.25 (02/10/2011)
+1.22, 1.23, 1.24, 1.25 (2011-02-10)
- Some sites are now excluded by default.
- View source (`gs`) now opens in a new tab.
@@ -250,11 +266,11 @@ Release Notes
- Improvements to link hinting.
- Bugfixes.
-1.21 (10/24/2010)
+1.21 (2010-10-24)
- Critical bugfix for an excluded URLs regression due to frame support.
-1.20 (10/24/2010)
+1.20 (2010-10-24)
- In link hints mode, holding down the shift key will now toggle between opening in the current tab and
opening in a new tab.
@@ -265,12 +281,12 @@ Release Notes
- More robust support for non-US keyboard layouts.
- Numerous bug fixes.
-1.19 (06/29/2010)
+1.19 (2010-06-29)
- A critical bug fix for development channel Chromium.
- Vimium icons for the Chrome extensions panel and other places.
-1.18 (06/22/2010)
+1.18 (2010-06-22)
- Vimium now runs on pages with file:/// and ftp:///
- The Options page is now linked from the Help dialog.
@@ -281,7 +297,7 @@ Release Notes
does not support command repetition.
- Bug fixes and optimizations.
-1.17 (04/18/2010)
+1.17 (2010-04-18)
- 'u' now restores tabs that were closed by the mouse or with native shortcuts. Tabs are also restored in
their prior position.
@@ -289,24 +305,24 @@ does not support command repetition.
- Link hints are now faster and more reliable.
- Bug fixes.
-1.16 (03/09/2010)
+1.16 (2010-03-09)
- Add support for configurable key mappings under Advanced Options.
- A help dialog which shows all currently bound keyboard shortcuts. Type "?" to see it.
- Bug fixes related to key stroke handling.
-1.15 (01/31/2010)
+1.15 (2010-01-31)
- Make the CSS used by the link hints configurable. It's under Advanced Options.
- Add a notification linking to the changelog when Vimium is updated in the background.
- Link-hinting performance improvements and bugfixes.
- Ctrl+D and Ctrl+U now scroll by 1/2 page instead of a fixed amount, to mirror Vim's behavior.
-1.14 (01/21/2010)
+1.14 (2010-01-21)
- Fixed a bug introduced in 1.13 that prevented excluded URLs from being saved.
-1.13 (01/21/2010)
+1.13 (2010-01-21)
- `<c-f>` and `<c-b>` are now mapped to scroll a full page up or down respectively.
- Bugfixes related to entering insert mode when the page first loads, and when focusing Flash embeds.
@@ -316,7 +332,7 @@ does not support command repetition.
- `<c-e>` and `<c-y>` are now mapped to scroll down and up respectively.
- The characters used for link hints are now configurable under Advanced Options.
-1.11, 1.12 (01/08/2010)
+1.11, 1.12 (2010-01-08)
- Commands 'gt' & 'gT' to move to the next & previous tab.
- Command 'yy' to yank (copy) the current tab's url to the clipboard.
@@ -324,7 +340,7 @@ does not support command repetition.
- Fix for Shift+F link hints.
- ESC now clears the keyQueue. So, for example, hitting 'g', 'ESC', 'g' will no longer scroll the page.
-1.1 (01/03/2010)
+1.1 (2010-01-03)
- A nicer looking settings page.
- An exclusion list that allows you to define URL patterns for which Vimium will be disabled (e.g.
@@ -335,4 +351,4 @@ does not support command repetition.
License
-------
-Copyright (c) 2010 Phil Crosby, Ilya Sukhar. See MIT-LICENSE.txt for details.
+Copyright (c) Phil Crosby, Ilya Sukhar. See MIT-LICENSE.txt for details.
diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee
index 22153eda..3a85c6fd 100644
--- a/background_scripts/commands.coffee
+++ b/background_scripts/commands.coffee
@@ -86,22 +86,23 @@ Commands =
# be shown in the help page.
commandGroups:
pageNavigation:
- ["scrollDown", "scrollUp", "scrollLeft", "scrollRight",
- "scrollToTop", "scrollToBottom", "scrollToLeft", "scrollToRight", "scrollPageDown",
- "scrollPageUp", "scrollFullPageUp", "scrollFullPageDown",
- "reload", "toggleViewSource", "copyCurrentUrl", "LinkHints.activateModeToCopyLinkUrl",
- "openCopiedUrlInCurrentTab", "openCopiedUrlInNewTab", "goUp", "goToRoot",
- "enterInsertMode", "focusInput",
- "LinkHints.activateMode", "LinkHints.activateModeToOpenInNewTab", "LinkHints.activateModeWithQueue",
- "Vomnibar.activate", "Vomnibar.activateInNewTab", "Vomnibar.activateTabSelection",
- "Vomnibar.activateBookmarks", "Vomnibar.activateBookmarksInNewTab",
- "Vomnibar.activateEditUrl", "Vomnibar.activateEditUrlInNewTab",
- "goPrevious", "goNext", "nextFrame", "Marks.activateCreateMode", "Marks.activateGotoMode"]
+ ["scrollDown", "scrollUp", "scrollLeft", "scrollRight", "scrollToTop", "scrollToBottom", "scrollToLeft",
+ "scrollToRight", "scrollPageDown", "scrollPageUp", "scrollFullPageUp", "scrollFullPageDown", "reload",
+ "toggleViewSource", "copyCurrentUrl", "LinkHints.activateModeToCopyLinkUrl",
+ "openCopiedUrlInCurrentTab", "openCopiedUrlInNewTab", "goUp", "goToRoot", "enterInsertMode",
+ "focusInput", "LinkHints.activateMode", "LinkHints.activateModeToOpenInNewTab",
+ "LinkHints.activateModeToOpenInNewForegroundTab", "LinkHints.activateModeWithQueue", "Vomnibar.activate",
+ "Vomnibar.activateInNewTab", "Vomnibar.activateTabSelection", "Vomnibar.activateBookmarks",
+ "Vomnibar.activateBookmarksInNewTab", "goPrevious", "goNext", "nextFrame", "Marks.activateCreateMode",
+ "Vomnibar.activateEditUrl", "Vomnibar.activateEditUrlInNewTab",
+ "Marks.activateGotoMode"]
findCommands: ["enterFindMode", "performFind", "performBackwardsFind"]
historyNavigation:
["goBack", "goForward"]
tabManipulation:
- ["nextTab", "previousTab", "firstTab", "lastTab", "createTab", "duplicateTab", "removeTab", "restoreTab", "moveTabToNewWindow"]
+ ["nextTab", "previousTab", "firstTab", "lastTab", "createTab", "duplicateTab", "removeTab",
+ "restoreTab", "moveTabToNewWindow", "togglePinTab", "closeTabsOnLeft","closeTabsOnRight",
+ "closeOtherTabs", "moveTabLeft", "moveTabRight"]
misc:
["showHelp"]
@@ -110,10 +111,11 @@ Commands =
# from Vimium will uncover these gems.
advancedCommands: [
"scrollToLeft", "scrollToRight", "moveTabToNewWindow",
- "goUp", "goToRoot", "focusInput", "LinkHints.activateModeWithQueue",
+ "goUp", "goToRoot", "focusInput", "LinkHints.activateModeWithQueue", "LinkHints.activateModeToDownloadLink",
"Vomnibar.activateEditUrl", "Vomnibar.activateEditUrlInNewTab",
"LinkHints.activateModeToOpenIncognito", "goNext", "goPrevious", "Marks.activateCreateMode",
- "Marks.activateGotoMode"]
+ "Marks.activateGotoMode", "moveTabLeft", "moveTabRight",
+ "closeTabsOnLeft","closeTabsOnRight", "closeOtherTabs"]
defaultKeyMappings =
"?": "showHelp"
@@ -146,6 +148,8 @@ defaultKeyMappings =
"F": "LinkHints.activateModeToOpenInNewTab"
"<a-f>": "LinkHints.activateModeWithQueue"
+ "af": "LinkHints.activateModeToDownloadLink"
+
"/": "enterFindMode"
"n": "performFind"
"N": "performBackwardsFind"
@@ -163,6 +167,8 @@ defaultKeyMappings =
"J": "previousTab"
"gt": "nextTab"
"gT": "previousTab"
+ "<<": "moveTabLeft"
+ ">>": "moveTabRight"
"g0": "firstTab"
"g$": "lastTab"
@@ -172,6 +178,8 @@ defaultKeyMappings =
"x": "removeTab"
"X": "restoreTab"
+ "<a-p>": "togglePinTab"
+
"o": "Vomnibar.activate"
"O": "Vomnibar.activateInNewTab"
@@ -217,14 +225,17 @@ commandDescriptions =
enterInsertMode: ["Enter insert mode"]
- focusInput: ["Focus the first (or n-th) text box on the page", { passCountToFunction: true }]
+ focusInput: ["Focus the first text box on the page. Cycle between them using tab",
+ { passCountToFunction: true }]
- 'LinkHints.activateMode': ["Open a link in the current tab"]
- 'LinkHints.activateModeToOpenInNewTab': ["Open a link in a new tab"]
- 'LinkHints.activateModeWithQueue': ["Open multiple links in a new tab"]
+ "LinkHints.activateMode": ["Open a link in the current tab"]
+ "LinkHints.activateModeToOpenInNewTab": ["Open a link in a new tab"]
+ "LinkHints.activateModeToOpenInNewForegroundTab": ["Open a link in a new tab & switch to it"]
+ "LinkHints.activateModeWithQueue": ["Open multiple links in a new tab"]
"LinkHints.activateModeToOpenIncognito": ["Open a link in incognito window"]
+ "LinkHints.activateModeToDownloadLink": ["Download link url"]
enterFindMode: ["Enter find mode"]
performFind: ["Cycle forward to the next find match"]
performBackwardsFind: ["Cycle backward to the previous find match"]
@@ -250,6 +261,14 @@ commandDescriptions =
removeTab: ["Close current tab", { background: true, noRepeat: true }]
restoreTab: ["Restore closed tab", { background: true }]
moveTabToNewWindow: ["Move tab to new window", { background: true }]
+ togglePinTab: ["Pin/unpin current tab", { background: true }]
+
+ closeTabsOnLeft: ["Close tabs on the left", {background: true, noRepeat: true}]
+ closeTabsOnRight: ["Close tabs on the right", {background: true, noRepeat: true}]
+ closeOtherTabs: ["Close all other tabs", {background: true, noRepeat: true}]
+
+ moveTabLeft: ["Move tab to the left", { background: true, passCountToFunction: true }]
+ moveTabRight: ["Move tab to the right", { background: true, passCountToFunction: true }]
"Vomnibar.activate": ["Open URL, bookmark, or history entry"]
"Vomnibar.activateInNewTab": ["Open URL, bookmark, history entry, in a new tab"]
diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee
index d266f503..b0ab4b88 100644
--- a/background_scripts/completion.coffee
+++ b/background_scripts/completion.coffee
@@ -35,7 +35,7 @@ class Suggestion
<span class="vimiumReset vomnibarTitle">#{@highlightTerms(Utils.escapeHtml(@title))}</span>
</div>
<div class="vimiumReset vomnibarBottomHalf">
- <span class="vimiumReset vomnibarUrl">#{@shortenUrl(@highlightTerms(@url))}</span>
+ <span class="vimiumReset vomnibarUrl">#{@shortenUrl(@highlightTerms(Utils.escapeHtml(@url)))}</span>
#{relevancyHtml}
</div>
"""
@@ -71,7 +71,8 @@ class Suggestion
# Wraps each occurence of the query terms in the given string in a <span>.
highlightTerms: (string) ->
ranges = []
- for term in @queryTerms
+ escapedTerms = @queryTerms.map (term) -> Utils.escapeHtml(term)
+ for term in escapedTerms
@pushMatchingRanges string, term, ranges
return string if ranges.length == 0
@@ -101,6 +102,7 @@ class Suggestion
class BookmarkCompleter
+ folderSeparator: "/"
currentSearch: null
# These bookmarks are loaded asynchronously when refresh() is called.
bookmarks: null
@@ -112,14 +114,19 @@ class BookmarkCompleter
onBookmarksLoaded: -> @performSearch() if @currentSearch
performSearch: ->
+ # If the folder separator character the first character in any query term, then we'll use the bookmark's full path as its title.
+ # Otherwise, we'll just use the its regular title.
+ usePathAndTitle = @currentSearch.queryTerms.reduce ((prev,term) => prev || term.indexOf(@folderSeparator) == 0), false
results =
if @currentSearch.queryTerms.length > 0
@bookmarks.filter (bookmark) =>
- RankingUtils.matches(@currentSearch.queryTerms, bookmark.url, bookmark.title)
+ suggestionTitle = if usePathAndTitle then bookmark.pathAndTitle else bookmark.title
+ RankingUtils.matches(@currentSearch.queryTerms, bookmark.url, suggestionTitle)
else
[]
suggestions = results.map (bookmark) =>
- new Suggestion(@currentSearch.queryTerms, "bookmark", bookmark.url, bookmark.title, @computeRelevancy)
+ suggestionTitle = if usePathAndTitle then bookmark.pathAndTitle else bookmark.title
+ new Suggestion(@currentSearch.queryTerms, "bookmark", bookmark.url, suggestionTitle, @computeRelevancy)
onComplete = @currentSearch.onComplete
@currentSearch = null
onComplete(suggestions)
@@ -130,16 +137,29 @@ class BookmarkCompleter
@bookmarks = @traverseBookmarks(bookmarks).filter((bookmark) -> bookmark.url?)
@onBookmarksLoaded()
- # Traverses the bookmark hierarchy, and retuns a flattened list of all bookmarks in the tree.
+ # If these names occur as top-level bookmark names, then they are not included in the names of bookmark folders.
+ ignoreTopLevel:
+ 'Other Bookmarks': true
+ 'Mobile Bookmarks': true
+ 'Bookmarks Bar': true
+
+ # Traverses the bookmark hierarchy, and returns a flattened list of all bookmarks.
traverseBookmarks: (bookmarks) ->
results = []
- toVisit = bookmarks.reverse()
- while toVisit.length > 0
- bookmark = toVisit.pop()
- results.push(bookmark)
- toVisit.push.apply(toVisit, bookmark.children.reverse()) if (bookmark.children)
+ bookmarks.forEach (folder) =>
+ @traverseBookmarksRecursive folder, results
results
+ # Recursive helper for `traverseBookmarks`.
+ traverseBookmarksRecursive: (bookmark, results, parent={pathAndTitle:""}) ->
+ bookmark.pathAndTitle =
+ if bookmark.title and not (parent.pathAndTitle == "" and @ignoreTopLevel[bookmark.title])
+ parent.pathAndTitle + @folderSeparator + bookmark.title
+ else
+ parent.pathAndTitle
+ results.push bookmark
+ bookmark.children.forEach((child) => @traverseBookmarksRecursive child, results, bookmark) if bookmark.children
+
computeRelevancy: (suggestion) ->
RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title)
@@ -247,6 +267,27 @@ class TabCompleter
computeRelevancy: (suggestion) ->
RankingUtils.wordRelevancy(suggestion.queryTerms, suggestion.url, suggestion.title)
+# A completer which will return your search engines
+class SearchEngineCompleter
+ searchEngines: {}
+
+ filter: (queryTerms, onComplete) ->
+ searchEngineMatch = this.getSearchEngineMatches(queryTerms[0])
+ suggestions = []
+ if searchEngineMatch
+ searchEngineMatch = searchEngineMatch.replace(/%s/g, queryTerms[1..].join(" "))
+ suggestion = new Suggestion(queryTerms, "search", searchEngineMatch, queryTerms[0] + ": " + queryTerms[1..].join(" "), @computeRelevancy)
+ suggestions.push(suggestion)
+ onComplete(suggestions)
+
+ computeRelevancy: -> 1
+
+ refresh: ->
+ this.searchEngines = root.Settings.getSearchEngines()
+
+ getSearchEngineMatches: (queryTerm) ->
+ this.searchEngines[queryTerm]
+
# A completer which calls filter() on many completers, aggregates the results, ranks them, and returns the top
# 10. Queries from the vomnibar frontend script come through a multi completer.
class MultiCompleter
@@ -295,24 +336,79 @@ RankingUtils =
return false unless matchedTerm
true
+ # Weights used for scoring matches.
+ matchWeights:
+ matchAnywhere: 1
+ matchStartOfWord: 1
+ matchWholeWord: 1
+ # The following must be the sum of the three weights above; it is used for normalization.
+ maximumScore: 3
+ #
+ # Calibration factor for balancing word relevancy and recency.
+ recencyCalibrator: 2.0/3.0
+ # The current value of 2.0/3.0 has the effect of:
+ # - favoring the contribution of recency when matches are not on word boundaries ( because 2.0/3.0 > (1)/3 )
+ # - favoring the contribution of word relevance when matches are on whole words ( because 2.0/3.0 < (1+1+1)/3 )
+
+ # Calculate a score for matching term against string.
+ # The score is in the range [0, matchWeights.maximumScore], see above.
+ # Returns: [ score, count ], where count is the number of matched characters in string.
+ scoreTerm: (term, string) ->
+ score = 0
+ count = 0
+ nonMatching = string.split(RegexpCache.get term)
+ if nonMatching.length > 1
+ # Have match.
+ score = RankingUtils.matchWeights.matchAnywhere
+ count = nonMatching.reduce(((p,c) -> p - c.length), string.length)
+ if RegexpCache.get(term, "\\b").test string
+ # Have match at start of word.
+ score += RankingUtils.matchWeights.matchStartOfWord
+ if RegexpCache.get(term, "\\b", "\\b").test string
+ # Have match of whole word.
+ score += RankingUtils.matchWeights.matchWholeWord
+ [ score, if count < string.length then count else string.length ]
+
# Returns a number between [0, 1] indicating how often the query terms appear in the url and title.
wordRelevancy: (queryTerms, url, title) ->
- queryLength = 0
- urlScore = 0.0
- titleScore = 0.0
+ urlScore = titleScore = 0.0
+ urlCount = titleCount = 0
+ # Calculate initial scores.
for term in queryTerms
- queryLength += term.length
- urlScore += 1 if url && RankingUtils.matches [term], url
- titleScore += 1 if title && RankingUtils.matches [term], title
- urlScore = urlScore / queryTerms.length
- urlScore = urlScore * RankingUtils.normalizeDifference(queryLength, url.length)
+ [ s, c ] = RankingUtils.scoreTerm term, url
+ urlScore += s
+ urlCount += c
+ if title
+ [ s, c ] = RankingUtils.scoreTerm term, title
+ titleScore += s
+ titleCount += c
+
+ maximumPossibleScore = RankingUtils.matchWeights.maximumScore * queryTerms.length
+
+ # Normalize scores.
+ urlScore /= maximumPossibleScore
+ urlScore *= RankingUtils.normalizeDifference urlCount, url.length
+
if title
- titleScore = titleScore / queryTerms.length
- titleScore = titleScore * RankingUtils.normalizeDifference(queryLength, title.length)
+ titleScore /= maximumPossibleScore
+ titleScore *= RankingUtils.normalizeDifference titleCount, title.length
else
titleScore = urlScore
+
+ # Prefer matches in the title over matches in the URL.
+ # In other words, don't let a poor urlScore pull down the titleScore.
+ # For example, urlScore can be unreasonably poor if the URL is very long.
+ urlScore = titleScore if urlScore < titleScore
+
+ # Return the average.
(urlScore + titleScore) / 2
+ # Untested alternative to the above:
+ # - Don't let a poor urlScore pull down a good titleScore, and don't let a poor titleScore pull down a
+ # good urlScore.
+ #
+ # return Math.max(urlScore, titleScore)
+
# Returns a score between [0, 1] which indicates how recent the given timestamp is. Items which are over
# a month old are counted as 0. This range is quadratic, so an item from one day ago has a much stronger
# score than an item from two days ago.
@@ -325,6 +421,9 @@ RankingUtils =
# incresingly discount older history entries.
recencyScore = recencyDifference * recencyDifference * recencyDifference
+ # Calibrate recencyScore vis-a-vis word-relevancy scores.
+ recencyScore *= RankingUtils.matchWeights.recencyCalibrator
+
# Takes the difference of two numbers and returns a number between [0, 1] (the percentage difference).
normalizeDifference: (a, b) ->
max = Math.max(a, b)
@@ -435,6 +534,7 @@ root.MultiCompleter = MultiCompleter
root.HistoryCompleter = HistoryCompleter
root.DomainCompleter = DomainCompleter
root.TabCompleter = TabCompleter
+root.SearchEngineCompleter = SearchEngineCompleter
root.HistoryCache = HistoryCache
root.RankingUtils = RankingUtils
root.RegexpCache = RegexpCache
diff --git a/background_scripts/exclusions.coffee b/background_scripts/exclusions.coffee
new file mode 100644
index 00000000..3a8ef1e7
--- /dev/null
+++ b/background_scripts/exclusions.coffee
@@ -0,0 +1,70 @@
+root = exports ? window
+
+RegexpCache =
+ cache: {}
+ get: (pattern) ->
+ if regexp = @cache[pattern]
+ regexp
+ else
+ @cache[pattern] = new RegExp("^" + pattern.replace(/\*/g, ".*") + "$")
+
+# The Exclusions class manages the exclusion rule setting.
+# An exclusion is an object with two attributes: pattern and passKeys.
+# The exclusions are an array of such objects (because the order matters).
+
+root.Exclusions = Exclusions =
+
+ rules: Settings.get("exclusionRules")
+
+ # Return the first exclusion rule matching the URL, or null.
+ getRule: (url) ->
+ for rule in @rules
+ return rule if url.match(RegexpCache.get(rule.pattern))
+ return null
+
+ setRules: (rules) ->
+ # Callers map a rule to null to have it deleted, and rules without a pattern are useless.
+ @rules = rules.filter (rule) -> rule and rule.pattern
+ Settings.set("exclusionRules", @rules)
+
+ postUpdateHook: (rules) ->
+ @rules = rules
+
+ # Update an existing rule or add a new rule.
+ updateOrAdd: (newRule) ->
+ seen = false
+ @rules.push(newRule)
+ @setRules @rules.map (rule) ->
+ if rule.pattern == newRule.pattern
+ if seen then null else seen = newRule
+ else
+ rule
+
+ remove: (pattern) ->
+ @setRules(@rules.filter((rule) -> rule and rule.pattern != pattern))
+
+# Development and debug only.
+# Enable this (temporarily) to restore legacy exclusion rules from backup.
+if false and Settings.has("excludedUrlsBackup")
+ Settings.clear("exclusionRules")
+ Settings.set("excludedUrls", Settings.get("excludedUrlsBackup"))
+
+if not Settings.has("exclusionRules") and Settings.has("excludedUrls")
+ # Migration from the legacy representation of exclusion rules.
+ #
+ # In Vimium 1.45 and in github/master on 27 August, 2014, exclusion rules are represented by the setting:
+ # excludedUrls: "http*://www.google.com/reader/*\nhttp*://mail.google.com/* jk"
+ #
+ # The new (equivalent) settings is:
+ # exclusionRules: [ { pattern: "http*://www.google.com/reader/*", passKeys: "" }, { pattern: "http*://mail.google.com/*", passKeys: "jk" } ]
+
+ parseLegacyRules = (lines) ->
+ for line in lines.trim().split("\n").map((line) -> line.trim())
+ if line.length and line.indexOf("#") != 0 and line.indexOf('"') != 0
+ parse = line.split(/\s+/)
+ { pattern: parse[0], passKeys: parse[1..].join("") }
+
+ Exclusions.setRules(parseLegacyRules(Settings.get("excludedUrls")))
+ # We'll keep a backup of the "excludedUrls" setting, just in case.
+ Settings.set("excludedUrlsBackup", Settings.get("excludedUrls")) if not Settings.has("excludedUrlsBackup")
+ Settings.clear("excludedUrls")
diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee
index f564f477..352cfa48 100644
--- a/background_scripts/main.coffee
+++ b/background_scripts/main.coffee
@@ -24,9 +24,11 @@ completionSources =
history: new HistoryCompleter()
domains: new DomainCompleter()
tabs: new TabCompleter()
+ seachEngines: new SearchEngineCompleter()
completers =
omni: new MultiCompleter([
+ completionSources.seachEngines,
completionSources.bookmarks,
completionSources.history,
completionSources.domains])
@@ -67,30 +69,31 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) ->
getCurrentTabUrl = (request, sender) -> sender.tab.url
#
-# Checks the user's preferences in local storage to determine if Vimium is enabled for the given URL.
+# Checks the user's preferences in local storage to determine if Vimium is enabled for the given URL, and
+# whether any keys should be passed through to the underlying page.
#
-isEnabledForUrl = (request) ->
- # excludedUrls are stored as a series of URL expressions separated by newlines.
- excludedUrls = Settings.get("excludedUrls").split("\n")
- isEnabled = true
- for url in excludedUrls
- # The user can add "*" to the URL which means ".*"
- regexp = new RegExp("^" + url.replace(/\*/g, ".*") + "$")
- isEnabled = false if request.url.match(regexp)
- { isEnabledForUrl: isEnabled }
-
-# Called by the popup UI. Strips leading/trailing whitespace and ignores empty strings.
-root.addExcludedUrl = (url) ->
- return unless url = url.trim()
-
- excludedUrls = Settings.get("excludedUrls")
- return if excludedUrls.indexOf(url) >= 0
-
- excludedUrls += "\n" + url
- Settings.set("excludedUrls", excludedUrls)
-
- chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true },
- (tabs) -> updateActiveState(tabs[0].id))
+root.isEnabledForUrl = isEnabledForUrl = (request) ->
+ rule = Exclusions.getRule(request.url)
+ {
+ rule: rule
+ isEnabledForUrl: not rule or rule.passKeys
+ passKeys: rule?.passKeys or ""
+ }
+
+# Called by the popup UI.
+# If the URL pattern matches an existing rule, then the existing rule is updated. Otherwise, a new rule is created.
+root.addExclusionRule = (pattern,passKeys) ->
+ if pattern = pattern.trim()
+ Exclusions.updateOrAdd({ pattern: pattern, passKeys: passKeys })
+ chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true },
+ (tabs) -> updateActiveState(tabs[0].id))
+
+# Called by the popup UI. Remove all existing exclusion rules with this pattern.
+root.removeExclusionRule = (pattern) ->
+ if pattern = pattern.trim()
+ Exclusions.remove(pattern)
+ chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true },
+ (tabs) -> updateActiveState(tabs[0].id))
saveHelpDialogSettings = (request) ->
Settings.set("helpDialog_showAdvancedCommands", request.showAdvancedCommands)
@@ -217,6 +220,12 @@ repeatFunction = (func, totalCount, currentCount, frameId) ->
-> repeatFunction(func, totalCount, currentCount + 1, frameId),
frameId)
+moveTab = (callback, direction) ->
+ chrome.tabs.getSelected(null, (tab) ->
+ # Use Math.max to prevent -1 as the new index, otherwise the tab of index n will wrap to the far RHS when
+ # moved left by exactly (n+1) places.
+ chrome.tabs.move(tab.id, {index: Math.max(0, tab.index + direction) }, callback))
+
# Start action functions
# These are commands which are bound to keystroke which must be handled by the background page. They are
@@ -228,8 +237,9 @@ BackgroundCommands =
chrome.tabs.duplicate(tab.id)
selectionChangedHandlers.push(callback))
moveTabToNewWindow: (callback) ->
- chrome.tabs.getSelected(null, (tab) ->
- chrome.windows.create({tabId: tab.id}))
+ chrome.tabs.query {active: true, currentWindow: true}, (tabs) ->
+ tab = tabs[0]
+ chrome.windows.create {tabId: tab.id, incognito: tab.incognito}
nextTab: (callback) -> selectTab(callback, "next")
previousTab: (callback) -> selectTab(callback, "previous")
firstTab: (callback) -> selectTab(callback, "first")
@@ -238,29 +248,38 @@ BackgroundCommands =
chrome.tabs.getSelected(null, (tab) ->
chrome.tabs.remove(tab.id))
restoreTab: (callback) ->
- # TODO(ilya): Should this be getLastFocused instead?
- chrome.windows.getCurrent((window) ->
- return unless (tabQueue[window.id] && tabQueue[window.id].length > 0)
- tabQueueEntry = tabQueue[window.id].pop()
- # Clean out the tabQueue so we don't have unused windows laying about.
- delete tabQueue[window.id] if (tabQueue[window.id].length == 0)
-
- # We have to chain a few callbacks to set the appropriate scroll position. We can't just wait until the
- # tab is created because the content script is not available during the "loading" state. We need to
- # wait until that's over before we can call setScrollPosition.
- chrome.tabs.create({ url: tabQueueEntry.url, index: tabQueueEntry.positionIndex }, (tab) ->
- tabLoadedHandlers[tab.id] = ->
- chrome.tabs.sendMessage(tab.id,
- name: "setScrollPosition",
- scrollX: tabQueueEntry.scrollX,
- scrollY: tabQueueEntry.scrollY)
- callback()))
+ # TODO: remove if-else -block when adopted into stable
+ if chrome.sessions
+ chrome.sessions.restore(null, (restoredSession) -> callback())
+ else
+ # TODO(ilya): Should this be getLastFocused instead?
+ chrome.windows.getCurrent((window) ->
+ return unless (tabQueue[window.id] && tabQueue[window.id].length > 0)
+ tabQueueEntry = tabQueue[window.id].pop()
+ # Clean out the tabQueue so we don't have unused windows laying about.
+ delete tabQueue[window.id] if (tabQueue[window.id].length == 0)
+
+ # We have to chain a few callbacks to set the appropriate scroll position. We can't just wait until the
+ # tab is created because the content script is not available during the "loading" state. We need to
+ # wait until that's over before we can call setScrollPosition.
+ chrome.tabs.create({ url: tabQueueEntry.url, index: tabQueueEntry.positionIndex }, (tab) ->
+ tabLoadedHandlers[tab.id] = ->
+ chrome.tabs.sendRequest(tab.id,
+ name: "setScrollPosition",
+ scrollX: tabQueueEntry.scrollX,
+ scrollY: tabQueueEntry.scrollY)
+ callback()))
openCopiedUrlInCurrentTab: (request) -> openUrlInCurrentTab({ url: Clipboard.paste() })
openCopiedUrlInNewTab: (request) -> openUrlInNewTab({ url: Clipboard.paste() })
+ togglePinTab: (request) ->
+ chrome.tabs.getSelected(null, (tab) ->
+ chrome.tabs.update(tab.id, { pinned: !tab.pinned }))
showHelp: (callback, frameId) ->
chrome.tabs.getSelected(null, (tab) ->
chrome.tabs.sendMessage(tab.id,
{ name: "toggleHelpDialog", dialogHtml: helpDialogHtml(), frameId:frameId }))
+ moveTabLeft: (count) -> moveTab(null, -count)
+ moveTabRight: (count) -> moveTab(null, count)
nextFrame: (count) ->
chrome.tabs.getSelected(null, (tab) ->
frames = framesForTab[tab.id].frames
@@ -272,6 +291,30 @@ BackgroundCommands =
chrome.tabs.sendMessage(tab.id, { name: "focusFrame", frameId: frames[newIndex].id, highlight: true }))
+ closeTabsOnLeft: -> removeTabsRelative "before"
+ closeTabsOnRight: -> removeTabsRelative "after"
+ closeOtherTabs: -> removeTabsRelative "both"
+
+# Remove tabs before, after, or either side of the currently active tab
+removeTabsRelative = (direction) ->
+ chrome.tabs.query {currentWindow: true}, (tabs) ->
+ chrome.tabs.query {currentWindow: true, active: true}, (activeTabs) ->
+ activeTabIndex = activeTabs[0].index
+
+ shouldDelete = switch direction
+ when "before"
+ (index) -> index < activeTabIndex
+ when "after"
+ (index) -> index > activeTabIndex
+ when "both"
+ (index) -> index != activeTabIndex
+
+ toRemove = []
+ for tab in tabs
+ if not tab.pinned and shouldDelete tab.index
+ toRemove.push tab.id
+ chrome.tabs.remove toRemove
+
# Selects a tab before or after the currently selected tab.
# - direction: "next", "previous", "first" or "last".
selectTab = (callback, direction) ->
@@ -304,32 +347,36 @@ updateOpenTabs = (tab) ->
# Frames are recreated on refresh
delete framesForTab[tab.id]
-# Updates the browserAction icon to indicated whether Vimium is enabled or disabled on the current page.
-# Also disables Vimium if it is currently enabled but should be disabled according to the url blacklist.
+setBrowserActionIcon = (tabId,path) ->
+ chrome.browserAction.setIcon({ tabId: tabId, path: path })
+
+# Updates the browserAction icon to indicate whether Vimium is enabled or disabled on the current page.
+# Also propagates new enabled/disabled/passkeys state to active window, if necessary.
# This lets you disable Vimium on a page without needing to reload.
-#
-# Three situations are considered:
-# 1. Active tab is disabled -> disable icon
-# 2. Active tab is enabled and should be enabled -> enable icon
-# 3. Active tab is enabled but should be disabled -> disable icon and disable vimium
updateActiveState = (tabId) ->
enabledIcon = "icons/browser_action_enabled.png"
disabledIcon = "icons/browser_action_disabled.png"
- chrome.tabs.get(tabId, (tab) ->
- # Default to disabled state in case we can't connect to Vimium, primarily for the "New Tab" page.
- chrome.browserAction.setIcon({ path: disabledIcon })
- chrome.tabs.sendMessage(tabId, { name: "getActiveState" }, (response) ->
- isCurrentlyEnabled = (response? && response.enabled)
- shouldBeEnabled = isEnabledForUrl({url: tab.url}).isEnabledForUrl
-
- if (isCurrentlyEnabled)
- if (shouldBeEnabled)
- chrome.browserAction.setIcon({ path: enabledIcon })
+ partialIcon = "icons/browser_action_partial.png"
+ chrome.tabs.get tabId, (tab) ->
+ chrome.tabs.sendMessage tabId, { name: "getActiveState" }, (response) ->
+ if response
+ isCurrentlyEnabled = response.enabled
+ currentPasskeys = response.passKeys
+ config = isEnabledForUrl({url: tab.url})
+ enabled = config.isEnabledForUrl
+ passKeys = config.passKeys
+ if (enabled and passKeys)
+ setBrowserActionIcon(tabId,partialIcon)
+ else if (enabled)
+ setBrowserActionIcon(tabId,enabledIcon)
else
- chrome.browserAction.setIcon({ path: disabledIcon })
- chrome.tabs.sendMessage(tabId, { name: "disableVimium" })
+ setBrowserActionIcon(tabId,disabledIcon)
+ # Propagate the new state only if it has changed.
+ if (isCurrentlyEnabled != enabled || currentPasskeys != passKeys)
+ chrome.tabs.sendMessage(tabId, { name: "setState", enabled: enabled, passKeys: passKeys })
else
- chrome.browserAction.setIcon({ path: disabledIcon })))
+ # We didn't get a response from the front end, so Vimium isn't running.
+ setBrowserActionIcon(tabId,disabledIcon)
handleUpdateScrollPosition = (request, sender) ->
updateScrollPosition(sender.tab, request.scrollX, request.scrollY)
@@ -458,6 +505,14 @@ handleKeyDown = (request, port) ->
console.log("checking keyQueue: [", keyQueue + key, "]")
keyQueue = checkKeyQueue(keyQueue + key, port.sender.tab.id, request.frameId)
console.log("new KeyQueue: " + keyQueue)
+ # Tell the content script whether there are keys in the queue.
+ # FIXME: There is a race condition here. The behaviour in the content script depends upon whether this message gets
+ # back there before or after the next keystroke.
+ # That being said, I suspect there are other similar race conditions here, for example in checkKeyQueue().
+ # Steve (23 Aug, 14).
+ chrome.tabs.sendMessage(port.sender.tab.id,
+ name: "currentKeyQueue",
+ keyQueue: keyQueue)
checkKeyQueue = (keysToCheck, tabId, frameId) ->
refreshedCompletionKeys = false
@@ -596,3 +651,6 @@ chrome.windows.getAll { populate: true }, (windows) ->
createScrollPositionHandler = ->
(response) -> updateScrollPosition(tab, response.scrollX, response.scrollY) if response?
chrome.tabs.sendMessage(tab.id, { name: "getScrollPosition" }, createScrollPositionHandler())
+
+# Start pulling changes from synchronized storage.
+Sync.init()
diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee
index 0fe1e1bb..7150fcba 100644
--- a/background_scripts/settings.coffee
+++ b/background_scripts/settings.coffee
@@ -1,5 +1,5 @@
#
-# Used by everyone to manipulate localStorage.
+# Used by all parts of Vimium to manipulate localStorage.
#
root = exports ? window
@@ -8,20 +8,60 @@ root.Settings = Settings =
if (key of localStorage) then JSON.parse(localStorage[key]) else @defaults[key]
set: (key, value) ->
- # don't store the value if it is equal to the default, so we can change the defaults in the future
+ # Don't store the value if it is equal to the default, so we can change the defaults in the future
if (value == @defaults[key])
@clear(key)
else
- localStorage[key] = JSON.stringify(value)
+ jsonValue = JSON.stringify value
+ localStorage[key] = jsonValue
+ Sync.set key, jsonValue
- clear: (key) -> delete localStorage[key]
+ clear: (key) ->
+ if @has key
+ delete localStorage[key]
+ Sync.clear key
has: (key) -> key of localStorage
- # options/options.(coffee|html) only handle booleans and strings; therefore
- # all defaults must be booleans or strings
+ # For settings which require action when their value changes, add hooks here called from
+ # options/options.coffee (when the options page is saved), and from background_scripts/sync.coffee (when an
+ # update propagates from chrome.storage.sync).
+ postUpdateHooks:
+ keyMappings: (value) ->
+ root.Commands.clearKeyMappingsAndSetDefaults()
+ root.Commands.parseCustomKeyMappings value
+ root.refreshCompletionKeysAfterMappingSave()
+
+ searchEngines: (value) ->
+ root.Settings.parseSearchEngines value
+
+ exclusionRules: (value) ->
+ root.Exclusions.postUpdateHook value
+
+ # postUpdateHooks convenience wrapper
+ performPostUpdateHook: (key, value) ->
+ @postUpdateHooks[key] value if @postUpdateHooks[key]
+
+ # Here we have our functions that parse the search engines
+ # this is a map that we use to store our search engines for use.
+ searchEnginesMap: {}
+
+ # this parses the search engines settings and clears the old searchEngines and sets the new one
+ parseSearchEngines: (searchEnginesText) ->
+ @searchEnginesMap = {}
+ # find the split pairs by first splitting by line then splitting on the first `: `
+ split_pairs = ( pair.split( /: (.+)/, 2) for pair in searchEnginesText.split( /\n/ ) when pair[0] != "#" )
+ @searchEnginesMap[a[0]] = a[1] for a in split_pairs
+ @searchEnginesMap
+ getSearchEngines: ->
+ this.parseSearchEngines(@get("searchEngines") || "") if Object.keys(@searchEnginesMap).length == 0
+ @searchEnginesMap
+
+ # options.coffee and options.html only handle booleans and strings; therefore all defaults must be booleans
+ # or strings
defaults:
scrollStepSize: 60
+ keyMappings: "# Insert your prefered key mappings here."
linkHintCharacters: "sadfjklewcmpgh"
linkHintNumbers: "0123456789"
filterLinkHints: false
@@ -45,11 +85,14 @@ root.Settings = Settings =
div > .vimiumHintMarker > .matchingCharacter {
}
"""
- excludedUrls:
- """
- http*://mail.google.com/*
- """
- # NOTE : If a page contains both a single angle-bracket link and a double angle-bracket link, then in
+ # Default exclusion rules.
+ exclusionRules:
+ [
+ # Disable Vimium on Gmail.
+ { pattern: "http*://mail.google.com/*", passKeys: "" }
+ ]
+
+ # NOTE: If a page contains both a single angle-bracket link and a double angle-bracket link, then in
# most cases the single bracket link will be "prev/next page" and the double bracket link will be
# "first/last page", so we put the single bracket first in the pattern string so that it gets searched
# for first.
@@ -60,9 +103,12 @@ root.Settings = Settings =
nextPatterns: "next,more,>,\u2192,\xbb,\u226b,>>"
# default/fall back search engine
searchUrl: "http://www.google.com/search?q="
+ # put in an example search engine
+ searchEngines: "w: http://www.wikipedia.org/w/index.php?title=Special:Search&search=%s"
settingsVersion: Utils.getCurrentVersion()
+
# We use settingsVersion to coordinate any necessary schema changes.
if Utils.compareVersions("1.42", Settings.get("settingsVersion")) != -1
Settings.set("scrollStepSize", parseFloat Settings.get("scrollStepSize"))
diff --git a/background_scripts/sync.coffee b/background_scripts/sync.coffee
new file mode 100644
index 00000000..93430856
--- /dev/null
+++ b/background_scripts/sync.coffee
@@ -0,0 +1,102 @@
+#
+# * Sync.set() and Sync.clear() propagate local changes to chrome.storage.sync.
+# * Sync.handleStorageUpdate() listens for changes to chrome.storage.sync and propagates those
+# changes to localStorage and into vimium's internal state.
+# * Sync.fetchAsync() polls chrome.storage.sync at startup, similarly propagating
+# changes to localStorage and into vimium's internal state.
+#
+# Changes are propagated into vimium's state using the same mechanism
+# (Settings.performPostUpdateHook) that is used when options are changed on
+# the options page.
+#
+# The effect is best-effort synchronization of vimium options/settings between
+# chrome/vimium instances.
+#
+# NOTE:
+# Values handled within this module are ALWAYS already JSON.stringifed, so
+# they're always non-empty strings.
+#
+
+root = exports ? window
+root.Sync = Sync =
+
+ # April 19 2014: Leave logging statements in, but disable debugging. We may need to come back to this, so
+ # removing logging now would be premature. However, if users report problems, they are unlikely to notice
+ # and make sense of console logs on background pages. So disable it, by default. For genuine errors, we
+ # call console.log directly.
+ debug: false
+ storage: chrome.storage.sync
+ doNotSync: ["settingsVersion", "previousVersion"]
+
+ # This is called in main.coffee.
+ init: ->
+ chrome.storage.onChanged.addListener (changes, area) -> Sync.handleStorageUpdate changes, area
+ @fetchAsync()
+
+ # Asynchronous fetch from synced storage, called only at startup.
+ fetchAsync: ->
+ @storage.get null, (items) =>
+ # Chrome sets chrome.runtime.lastError if there is an error.
+ if chrome.runtime.lastError is undefined
+ for own key, value of items
+ @log "fetchAsync: #{key} <- #{value}"
+ @storeAndPropagate key, value
+ else
+ console.log "callback for Sync.fetchAsync() indicates error"
+ console.log chrome.runtime.lastError
+
+ # Asynchronous message from synced storage.
+ handleStorageUpdate: (changes, area) ->
+ for own key, change of changes
+ @log "handleStorageUpdate: #{key} <- #{change.newValue}"
+ @storeAndPropagate key, change?.newValue
+
+ # Only ever called from asynchronous synced-storage callbacks (fetchAsync and handleStorageUpdate).
+ storeAndPropagate: (key, value) ->
+ return if not key of Settings.defaults
+ return if not @shouldSyncKey key
+ return if value and key of localStorage and localStorage[key] is value
+ defaultValue = Settings.defaults[key]
+ defaultValueJSON = JSON.stringify(defaultValue)
+
+ if value and value != defaultValueJSON
+ # Key/value has been changed to non-default value at remote instance.
+ @log "storeAndPropagate update: #{key}=#{value}"
+ localStorage[key] = value
+ Settings.performPostUpdateHook key, JSON.parse(value)
+ else
+ # Key has been reset to default value at remote instance.
+ @log "storeAndPropagate clear: #{key}"
+ if key of localStorage
+ delete localStorage[key]
+ Settings.performPostUpdateHook key, defaultValue
+
+ # Only called synchronously from within vimium, never on a callback.
+ # No need to propagate updates to the rest of vimium, that's already been done.
+ set: (key, value) ->
+ if @shouldSyncKey key
+ @log "set scheduled: #{key}=#{value}"
+ key_value = {}
+ key_value[key] = value
+ @storage.set key_value, =>
+ # Chrome sets chrome.runtime.lastError if there is an error.
+ if chrome.runtime.lastError
+ console.log "callback for Sync.set() indicates error: #{key} <- #{value}"
+ console.log chrome.runtime.lastError
+
+ # Only called synchronously from within vimium, never on a callback.
+ clear: (key) ->
+ if @shouldSyncKey key
+ @log "clear scheduled: #{key}"
+ @storage.remove key, =>
+ # Chrome sets chrome.runtime.lastError if there is an error.
+ if chrome.runtime.lastError
+ console.log "for Sync.clear() indicates error: #{key}"
+ console.log chrome.runtime.lastError
+
+ # Should we synchronize this key?
+ shouldSyncKey: (key) ->
+ key not in @doNotSync
+
+ log: (msg) ->
+ console.log "Sync: #{msg}" if @debug
diff --git a/content_scripts/file_urls.css b/content_scripts/file_urls.css
new file mode 100644
index 00000000..fd63c224
--- /dev/null
+++ b/content_scripts/file_urls.css
@@ -0,0 +1,6 @@
+/* Chrome file:// URLs set draggable=true for links to files (CSS selector .icon.file). This automatically
+ * sets -webkit-user-select: none, which disables selecting the file names and so prevents Vimium's search
+ * from working as expected. Here, we reset the value back to default. */
+.icon.file {
+ -webkit-user-select: auto !important;
+}
diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee
index f2479d0f..24bd7126 100644
--- a/content_scripts/link_hints.coffee
+++ b/content_scripts/link_hints.coffee
@@ -9,10 +9,12 @@
# typing the text of the link itself.
#
OPEN_IN_CURRENT_TAB = {}
-OPEN_IN_NEW_TAB = {}
+OPEN_IN_NEW_BG_TAB = {}
+OPEN_IN_NEW_FG_TAB = {}
OPEN_WITH_QUEUE = {}
COPY_LINK_URL = {}
OPEN_INCOGNITO = {}
+DOWNLOAD_LINK_URL = {}
LinkHints =
hintMarkerContainingDiv: null
@@ -24,7 +26,8 @@ LinkHints =
delayMode: false
# Handle the link hinting marker generation and matching. Must be initialized after settings have been
# loaded, so that we can retrieve the option setting.
- markerMatcher: undefined
+ getMarkerMatcher: ->
+ if settings.get("filterLinkHints") then filterHints else alphabetHints
# lock to ensure only one instance runs at a time
isActive: false
@@ -32,7 +35,6 @@ LinkHints =
# To be called after linkHints has been generated from linkHintsBase.
#
init: ->
- @markerMatcher = if settings.get("filterLinkHints") then filterHints else alphabetHints
#
# Generate an XPath describing what a clickable element is.
@@ -46,10 +48,12 @@ LinkHints =
"@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']"])
# We need this as a top-level function because our command system doesn't yet support arguments.
- activateModeToOpenInNewTab: -> @activateMode(OPEN_IN_NEW_TAB)
+ activateModeToOpenInNewTab: -> @activateMode(OPEN_IN_NEW_BG_TAB)
+ activateModeToOpenInNewForegroundTab: -> @activateMode(OPEN_IN_NEW_FG_TAB)
activateModeToCopyLinkUrl: -> @activateMode(COPY_LINK_URL)
activateModeWithQueue: -> @activateMode(OPEN_WITH_QUEUE)
activateModeToOpenIncognito: -> @activateMode(OPEN_INCOGNITO)
+ activateModeToDownloadLink: -> @activateMode(DOWNLOAD_LINK_URL)
activateMode: (mode = OPEN_IN_CURRENT_TAB) ->
# we need documentElement to be ready in order to append links
@@ -60,7 +64,8 @@ LinkHints =
@isActive = true
@setOpenLinkMode(mode)
- hintMarkers = @markerMatcher.fillInMarkers(@createMarkerFor(el) for el in @getVisibleClickableElements())
+ hintMarkers = (@createMarkerFor(el) for el in @getVisibleClickableElements())
+ @getMarkerMatcher().fillInMarkers(hintMarkers)
# Note(philc): Append these markers as top level children instead of as child nodes to the link itself,
# because some clickable elements cannot contain children, e.g. submit buttons. This has the caveat
@@ -77,17 +82,21 @@ LinkHints =
})
setOpenLinkMode: (@mode) ->
- if @mode is OPEN_IN_NEW_TAB or @mode is OPEN_WITH_QUEUE
- if @mode is OPEN_IN_NEW_TAB
+ if @mode is OPEN_IN_NEW_BG_TAB or @mode is OPEN_IN_NEW_FG_TAB or @mode is OPEN_WITH_QUEUE
+ if @mode is OPEN_IN_NEW_BG_TAB
HUD.show("Open link in new tab")
+ else if @mode is OPEN_IN_NEW_FG_TAB
+ HUD.show("Open link in new tab and switch to it")
else
HUD.show("Open multiple links in a new tab")
@linkActivator = (link) ->
# When "clicking" on a link, dispatch the event with the appropriate meta key (CMD on Mac, CTRL on
# windows) to open it in a new tab if necessary.
DomUtils.simulateClick(link, {
+ shiftKey: @mode is OPEN_IN_NEW_FG_TAB,
metaKey: KeyboardUtils.platform == "Mac",
- ctrlKey: KeyboardUtils.platform != "Mac" })
+ ctrlKey: KeyboardUtils.platform != "Mac",
+ altKey: false})
else if @mode is COPY_LINK_URL
HUD.show("Copy link URL to Clipboard")
@linkActivator = (link) ->
@@ -99,11 +108,16 @@ LinkHints =
chrome.runtime.sendMessage(
handler: 'openUrlInIncognito'
url: link.href)
+ else if @mode is DOWNLOAD_LINK_URL
+ HUD.show("Download link URL")
+ @linkActivator = (link) ->
+ DomUtils.simulateClick(link, {
+ altKey: true,
+ ctrlKey: false,
+ metaKey: false })
else # OPEN_IN_CURRENT_TAB
HUD.show("Open link in current tab")
- # When we're opening the link in the current tab, don't navigate to the selected link immediately
- # we want to give the user some time to notice which link has received focus.
- @linkActivator = (link) -> setTimeout(DomUtils.simulateClick.bind(DomUtils, link), 400)
+ @linkActivator = (link) -> DomUtils.simulateClick.bind(DomUtils, link)()
#
# Creates a link marker for the given link.
@@ -161,29 +175,29 @@ LinkHints =
visibleElements
#
- # Handles shift and esc keys. The other keys are passed to markerMatcher.matchHintsByKey.
+ # Handles shift and esc keys. The other keys are passed to getMarkerMatcher().matchHintsByKey.
#
onKeyDownInMode: (hintMarkers, event) ->
return if @delayMode
- if (event.keyCode == keyCodes.shiftKey && @mode != COPY_LINK_URL)
+ if ((event.keyCode == keyCodes.shiftKey or event.keyCode == keyCodes.ctrlKey) and
+ (@mode == OPEN_IN_CURRENT_TAB or
+ @mode == OPEN_IN_NEW_BG_TAB or
+ @mode == OPEN_IN_NEW_FG_TAB))
# Toggle whether to open link in a new or current tab.
prev_mode = @mode
- @setOpenLinkMode(if @mode is OPEN_IN_CURRENT_TAB then OPEN_IN_NEW_TAB else OPEN_IN_CURRENT_TAB)
+ if event.keyCode == keyCodes.shiftKey
+ @setOpenLinkMode(if @mode is OPEN_IN_CURRENT_TAB then OPEN_IN_NEW_BG_TAB else OPEN_IN_CURRENT_TAB)
- handlerStack.push({
- keyup: (event) =>
- return if (event.keyCode != keyCodes.shiftKey)
- @setOpenLinkMode(prev_mode) if @isActive
- @remove()
- })
+ else # event.keyCode == keyCodes.ctrlKey
+ @setOpenLinkMode(if @mode is OPEN_IN_NEW_FG_TAB then OPEN_IN_NEW_BG_TAB else OPEN_IN_NEW_FG_TAB)
# TODO(philc): Ignore keys that have modifiers.
if (KeyboardUtils.isEscape(event))
@deactivateMode()
- else if (event.keyCode != keyCodes.shiftKey)
- keyResult = @markerMatcher.matchHintsByKey(hintMarkers, event)
+ else
+ keyResult = @getMarkerMatcher().matchHintsByKey(hintMarkers, event)
linksMatched = keyResult.linksMatched
delay = keyResult.delay ? 0
if (linksMatched.length == 0)
@@ -194,7 +208,7 @@ LinkHints =
for marker in hintMarkers
@hideMarker(marker)
for matched in linksMatched
- @showMarker(matched, @markerMatcher.hintKeystrokeQueue.length)
+ @showMarker(matched, @getMarkerMatcher().hintKeystrokeQueue.length)
false # We've handled this key, so prevent propagation.
#
@@ -239,8 +253,8 @@ LinkHints =
#
deactivateMode: (delay, callback) ->
deactivate = =>
- if (LinkHints.markerMatcher.deactivate)
- LinkHints.markerMatcher.deactivate()
+ if (LinkHints.getMarkerMatcher().deactivate)
+ LinkHints.getMarkerMatcher().deactivate()
if (LinkHints.hintMarkerContainingDiv)
DomUtils.removeElement LinkHints.hintMarkerContainingDiv
LinkHints.hintMarkerContainingDiv = null
diff --git a/content_scripts/scroller.coffee b/content_scripts/scroller.coffee
index 08ab14a1..f3c632b3 100644
--- a/content_scripts/scroller.coffee
+++ b/content_scripts/scroller.coffee
@@ -39,17 +39,17 @@ ensureScrollChange = (direction, changeFn) ->
loop
oldScrollValue = element[axisName]
changeFn(element, axisName)
+ break unless (element[axisName] == oldScrollValue && element != document.body)
lastElement = element
# we may have an orphaned element. if so, just scroll the body element.
element = element.parentElement || document.body
- break unless (lastElement[axisName] == oldScrollValue && lastElement != document.body)
# if the activated element has been scrolled completely offscreen, subsequent changes in its scroll
# position will not provide any more visual feedback to the user. therefore we deactivate it so that
# subsequent scrolls only move the parent element.
rect = activatedElement.getBoundingClientRect()
if (rect.bottom < 0 || rect.top > window.innerHeight || rect.right < 0 || rect.left > window.innerWidth)
- activatedElement = lastElement
+ activatedElement = element
# scroll the active element in :direction by :amount * :factor.
# :factor is needed because :amount can take on string values, which scrollBy converts to element dimensions.
@@ -65,12 +65,13 @@ root.scrollBy = (direction, amount, factor = 1) ->
if (!activatedElement || !isRendered(activatedElement))
activatedElement = document.body
- amount = getDimension activatedElement, direction, amount if Utils.isString amount
-
- amount *= factor
-
- if (amount != 0)
- ensureScrollChange direction, (element, axisName) -> element[axisName] += amount
+ ensureScrollChange direction, (element, axisName) ->
+ if Utils.isString amount
+ elementAmount = getDimension element, direction, amount
+ else
+ elementAmount = amount
+ elementAmount *= factor
+ element[axisName] += elementAmount
root.scrollTo = (direction, pos) ->
return unless document.body
@@ -78,9 +79,12 @@ root.scrollTo = (direction, pos) ->
if (!activatedElement || !isRendered(activatedElement))
activatedElement = document.body
- pos = getDimension activatedElement, direction, pos if Utils.isString pos
-
- ensureScrollChange direction, (element, axisName) -> element[axisName] = pos
+ ensureScrollChange direction, (element, axisName) ->
+ if Utils.isString pos
+ elementPos = getDimension element, direction, pos
+ else
+ elementPos = pos
+ element[axisName] = elementPos
# TODO refactor and put this together with the code in getVisibleClientRect
isRendered = (element) ->
diff --git a/content_scripts/vimium.css b/content_scripts/vimium.css
index fd2228b9..7998fe5c 100644
--- a/content_scripts/vimium.css
+++ b/content_scripts/vimium.css
@@ -175,14 +175,14 @@ div#vimiumHelpDialog div.advanced { display: none; }
div#vimiumHelpDialog div.advanced td:nth-of-type(3) { color: #555; }
div#vimiumHelpDialog a.closeButton {
position:absolute;
- right:10px;
+ right:7px;
top:5px;
font-family:"courier new";
font-weight:bold;
color:#555;
text-decoration:none;
padding-left:10px;
- font-size:16px;
+ font-size:20px;
}
div#vimiumHelpDialog a {
text-decoration: underline;
@@ -198,7 +198,6 @@ div#vimiumHelpDialog .optionsPage {
}
div#vimiumHelpDialog a.closeButton:hover {
color:black;
- cursor:default;
-webkit-user-select:none;
}
div#vimiumHelpDialogFooter {
@@ -334,6 +333,8 @@ body.vimiumFindMode ::selection {
font-size: 16px;
color: black;
position: relative;
+ display: list-item;
+ margin: auto;
}
#vomnibar li:last-of-type {
diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee
index a2139df6..6db0d830 100644
--- a/content_scripts/vimium_frontend.coffee
+++ b/content_scripts/vimium_frontend.coffee
@@ -8,13 +8,18 @@ window.handlerStack = new HandlerStack
insertModeLock = null
findMode = false
-findModeQuery = { rawQuery: "" }
+findModeQuery = { rawQuery: "", matchCount: 0 }
findModeQueryHasResults = false
findModeAnchorNode = null
isShowingHelpDialog = false
keyPort = null
-# Users can disable Vimium on URL patterns via the settings page.
+# Users can disable Vimium on URL patterns via the settings page. The following two variables
+# (isEnabledForUrl and passKeys) control Vimium's enabled/disabled behaviour.
+# "passKeys" are keys which would normally be handled by Vimium, but are disabled on this tab, and therefore
+# are passed through to the underlying page.
isEnabledForUrl = true
+passKeys = null
+keyQueue = null
# The user's operating system.
currentCompletionKeys = null
validFirstKeys = null
@@ -115,42 +120,47 @@ initializePreDomReady = ->
getScrollPosition: -> scrollX: window.scrollX, scrollY: window.scrollY
setScrollPosition: (request) -> setScrollPosition request.scrollX, request.scrollY
executePageCommand: executePageCommand
- getActiveState: -> { enabled: isEnabledForUrl }
- disableVimium: disableVimium
+ getActiveState: -> { enabled: isEnabledForUrl, passKeys: passKeys }
+ setState: setState
+ currentKeyQueue: (request) -> keyQueue = request.keyQueue
chrome.runtime.onMessage.addListener (request, sender, sendResponse) ->
- # in the options page, we will receive requests from both content and background scripts. ignore those
+ # In the options page, we will receive requests from both content and background scripts. ignore those
# from the former.
return if sender.tab and not sender.tab.url.startsWith 'chrome-extension://'
- return unless isEnabledForUrl or request.name == 'getActiveState'
+ return unless isEnabledForUrl or request.name == 'getActiveState' or request.name == 'setState'
+ # These requests are delivered to the options page, but there are no handlers there.
+ return if request.handler == "registerFrame" or request.handler == "frameFocused"
sendResponse requestHandlers[request.name](request, sender)
# Ensure the sendResponse callback is freed.
false
-#
-# This is called once the background page has told us that Vimium should be enabled for the current URL.
-#
-initializeWhenEnabled = ->
- document.addEventListener("keydown", onKeydown, true)
- document.addEventListener("keypress", onKeypress, true)
- document.addEventListener("keyup", onKeyup, true)
- document.addEventListener("focus", onFocusCapturePhase, true)
- document.addEventListener("blur", onBlurCapturePhase, true)
- document.addEventListener("DOMActivate", onDOMActivate, true)
- enterInsertModeIfElementIsFocused()
+# Wrapper to install event listeners. Syntactic sugar.
+installListener = (event, callback) -> document.addEventListener(event, callback, true)
#
-# Used to disable Vimium without needing to reload the page.
-# This is called if the current page's url is blacklisted using the popup UI.
+# This is called once the background page has told us that Vimium should be enabled for the current URL.
+# We enable/disable Vimium by toggling isEnabledForUrl. The alternative, installing or uninstalling
+# listeners, is error prone. It's more difficult to keep track of the state.
#
-disableVimium = ->
- document.removeEventListener("keydown", onKeydown, true)
- document.removeEventListener("keypress", onKeypress, true)
- document.removeEventListener("keyup", onKeyup, true)
- document.removeEventListener("focus", onFocusCapturePhase, true)
- document.removeEventListener("blur", onBlurCapturePhase, true)
- document.removeEventListener("DOMActivate", onDOMActivate, true)
- isEnabledForUrl = false
+installedListeners = false
+initializeWhenEnabled = (newPassKeys) ->
+ isEnabledForUrl = true
+ passKeys = newPassKeys
+ if (!installedListeners)
+ installListener "keydown", (event) -> if isEnabledForUrl then onKeydown(event) else true
+ installListener "keypress", (event) -> if isEnabledForUrl then onKeypress(event) else true
+ installListener "keyup", (event) -> if isEnabledForUrl then onKeyup(event) else true
+ installListener "focus", (event) -> if isEnabledForUrl then onFocusCapturePhase(event) else true
+ installListener "blur", (event) -> if isEnabledForUrl then onBlurCapturePhase(event)
+ installListener "DOMActivate", (event) -> if isEnabledForUrl then onDOMActivate(event)
+ enterInsertModeIfElementIsFocused()
+ installedListeners = true
+
+setState = (request) ->
+ isEnabledForUrl = request.enabled
+ passKeys = request.passKeys
+ initializeWhenEnabled(passKeys) if isEnabledForUrl and !installedListeners
#
# The backend needs to know which frame has focus.
@@ -321,6 +331,14 @@ extend window,
false
+# Decide whether this keyChar should be passed to the underlying page.
+# Keystrokes are *never* considered passKeys if the keyQueue is not empty. So, for example, if 't' is a
+# passKey, then 'gt' and '99t' will neverthless be handled by vimium.
+isPassKey = ( keyChar ) ->
+ return !keyQueue and passKeys and 0 <= passKeys.indexOf(keyChar)
+
+handledKeydownEvents = []
+
#
# Sends everything except i & ESC to the handler in background_page. i & ESC are special because they control
# insert mode which is local state to the page. The key will be are either a single ascii letter or a
@@ -347,6 +365,8 @@ onKeypress = (event) ->
handleKeyCharForFindMode(keyChar)
DomUtils.suppressEvent(event)
else if (!isInsertMode() && !findMode)
+ if (isPassKey keyChar)
+ return undefined
if (currentCompletionKeys.indexOf(keyChar) != -1)
DomUtils.suppressEvent(event)
@@ -390,37 +410,48 @@ onKeydown = (event) ->
if (isEditable(event.srcElement))
event.srcElement.blur()
exitInsertMode()
- DomUtils.suppressEvent(event)
+ DomUtils.suppressEvent event
+ handledKeydownEvents.push event
else if (findMode)
if (KeyboardUtils.isEscape(event))
handleEscapeForFindMode()
- DomUtils.suppressEvent(event)
+ DomUtils.suppressEvent event
+ handledKeydownEvents.push event
else if (event.keyCode == keyCodes.backspace || event.keyCode == keyCodes.deleteKey)
handleDeleteForFindMode()
- DomUtils.suppressEvent(event)
+ DomUtils.suppressEvent event
+ handledKeydownEvents.push event
else if (event.keyCode == keyCodes.enter)
handleEnterForFindMode()
- DomUtils.suppressEvent(event)
+ DomUtils.suppressEvent event
+ handledKeydownEvents.push event
else if (!modifiers)
event.stopPropagation()
+ handledKeydownEvents.push event
else if (isShowingHelpDialog && KeyboardUtils.isEscape(event))
hideHelpDialog()
+ DomUtils.suppressEvent event
+ handledKeydownEvents.push event
else if (!isInsertMode() && !findMode)
if (keyChar)
if (currentCompletionKeys.indexOf(keyChar) != -1)
- DomUtils.suppressEvent(event)
+ DomUtils.suppressEvent event
+ handledKeydownEvents.push event
keyPort.postMessage({ keyChar:keyChar, frameId:frameId })
else if (KeyboardUtils.isEscape(event))
keyPort.postMessage({ keyChar:"<ESC>", frameId:frameId })
+ else if isPassKey KeyboardUtils.getKeyChar(event)
+ return undefined
+
# Added to prevent propagating this event to other listeners if it's one that'll trigger a Vimium command.
# The goal is to avoid the scenario where Google Instant Search uses every keydown event to dump us
# back into the search box. As a side effect, this should also prevent overriding by other sites.
@@ -432,8 +463,23 @@ onKeydown = (event) ->
(currentCompletionKeys.indexOf(KeyboardUtils.getKeyChar(event)) != -1 ||
isValidFirstKey(KeyboardUtils.getKeyChar(event))))
event.stopPropagation()
+ handledKeydownEvents.push event
+
+onKeyup = (event) ->
+ return unless handlerStack.bubbleEvent("keyup", event)
+ return if isInsertMode()
-onKeyup = (event) -> return unless handlerStack.bubbleEvent('keyup', event)
+ # Don't propagate the keyup to the underlying page if Vimium has handled it. See #733.
+ for keydown, i in handledKeydownEvents
+ if event.metaKey == keydown.metaKey and
+ event.altKey == keydown.altKey and
+ event.ctrlKey == keydown.ctrlKey and
+ event.keyIdentifier == keydown.keyIdentifier and
+ event.keyCode == keydown.keyCode
+
+ handledKeydownEvents.splice i, 1
+ event.stopPropagation()
+ break
checkIfEnabledForUrl = ->
url = window.location.toString()
@@ -441,7 +487,7 @@ checkIfEnabledForUrl = ->
chrome.runtime.sendMessage { handler: "isEnabledForUrl", url: url }, (response) ->
isEnabledForUrl = response.isEnabledForUrl
if (isEnabledForUrl)
- initializeWhenEnabled()
+ initializeWhenEnabled(response.passKeys)
else if (HUD.isReady())
# Quickly hide any HUD we might already be showing, e.g. if we entered insert mode on page load.
HUD.hide()
@@ -554,6 +600,18 @@ updateFindModeQuery = ->
text = document.body.innerText
findModeQuery.regexMatches = text.match(pattern)
findModeQuery.activeRegexIndex = 0
+ findModeQuery.matchCount = findModeQuery.regexMatches?.length
+ # if we are doing a basic plain string match, we still want to grep for matches of the string, so we can
+ # show a the number of results. We can grep on document.body.innerText, as it should be indistinguishable
+ # from the internal representation used by window.find.
+ else
+ # escape all special characters, so RegExp just parses the string 'as is'.
+ # Taken from http://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex
+ escapeRegExp = /[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g
+ parsedNonRegexQuery = findModeQuery.parsedQuery.replace(escapeRegExp, (char) -> "\\" + char)
+ pattern = new RegExp(parsedNonRegexQuery, "g" + (if findModeQuery.ignoreCase then "i" else ""))
+ text = document.body.innerText
+ findModeQuery.matchCount = text.match(pattern)?.length
handleKeyCharForFindMode = (keyChar) ->
findModeQuery.rawQuery += keyChar
@@ -799,7 +857,7 @@ findAndFollowRel = (value) ->
for tag in relTags
elements = document.getElementsByTagName(tag)
for element in elements
- if (element.hasAttribute("rel") && element.rel == value)
+ if (element.hasAttribute("rel") && element.rel.toLowerCase() == value)
followLink(element)
return true
@@ -815,7 +873,7 @@ window.goNext = ->
showFindModeHUDForQuery = ->
if (findModeQueryHasResults || findModeQuery.parsedQuery.length == 0)
- HUD.show("/" + findModeQuery.rawQuery)
+ HUD.show("/" + findModeQuery.rawQuery + " (" + findModeQuery.matchCount + " Matches)")
else
HUD.show("/" + findModeQuery.rawQuery + " (No Matches)")
@@ -839,7 +897,7 @@ window.showHelpDialog = (html, fid) ->
container.innerHTML = html
container.getElementsByClassName("closeButton")[0].addEventListener("click", hideHelpDialog, false)
-
+
VimiumHelpDialog =
# This setting is pulled out of local storage. It's false by default.
getShowAdvancedCommands: -> settings.get("helpDialog_showAdvancedCommands")
@@ -869,8 +927,9 @@ window.showHelpDialog = (html, fid) ->
VimiumHelpDialog.init()
- container.getElementsByClassName("optionsPage")[0].addEventListener("click",
- -> chrome.runtime.sendMessage({ handler: "openOptionsPageInNewTab" })
+ container.getElementsByClassName("optionsPage")[0].addEventListener("click", (clickEvent) ->
+ clickEvent.preventDefault()
+ chrome.runtime.sendMessage({handler: "openOptionsPageInNewTab"})
false)
@@ -908,7 +967,7 @@ HUD =
show: (text) ->
return unless HUD.enabled()
clearTimeout(HUD._showForDurationTimerId)
- HUD.displayElement().innerHTML = text
+ HUD.displayElement().innerText = text
clearInterval(HUD._tweenId)
HUD._tweenId = Tween.fade(HUD.displayElement(), 1.0, 150)
HUD.displayElement().style.display = ""
@@ -917,7 +976,7 @@ HUD =
HUD.upgradeNotificationElement().innerHTML = "Vimium has been updated to
<a class='vimiumReset'
href='https://chrome.google.com/extensions/detail/dbepggeogbaibhgnhhndojpepiihcmeb'>
- #{version}</a>.<a class='vimiumReset close-button' href='#'>x</a>"
+ #{version}</a>.<a class='vimiumReset close-button' href='#'>&times;</a>"
links = HUD.upgradeNotificationElement().getElementsByTagName("a")
links[0].addEventListener("click", HUD.onUpdateLinkClicked, false)
links[1].addEventListener "click", (event) ->
diff --git a/content_scripts/vomnibar.coffee b/content_scripts/vomnibar.coffee
index b0850f8e..22b9ed64 100644
--- a/content_scripts/vomnibar.coffee
+++ b/content_scripts/vomnibar.coffee
@@ -70,8 +70,18 @@ class VomnibarUI
@update(true)
updateSelection: ->
+ # We have taken the option to add some global state here (previousCompletionType) to tell if a search
+ # item has just appeared or disappeared, if that happens we either set the initialSelectionValue to 0 or 1
+ # I feel that this approach is cleaner than bubbling the state up from the suggestion level
+ # so we just inspect it afterwards
+ if @completions[0]
+ if @previousCompletionType != "search" && @completions[0].type == "search"
+ @selection = 0
+ else if @previousCompletionType == "search" && @completions[0].type != "search"
+ @selection = -1
for i in [0...@completionList.children.length]
@completionList.children[i].className = (if i == @selection then "vomnibarSelected" else "")
+ @previousCompletionType = @completions[0].type if @completions[0]
#
# Returns the user's action ("up", "down", "enter", "dismiss" or null) based on their keypress.
@@ -103,10 +113,12 @@ class VomnibarUI
else if (action == "up")
@selection -= 1
@selection = @completions.length - 1 if @selection < @initialSelectionValue
+ @input.value = @completions[@selection].url
@updateSelection()
else if (action == "down")
@selection += 1
@selection = @initialSelectionValue if @selection == @completions.length
+ @input.value = @completions[@selection].url
@updateSelection()
else if (action == "enter")
# If they type something and hit enter without selecting a completion from our list of suggestions,
@@ -194,8 +206,8 @@ class BackgroundCompleter
filter: (query, callback) ->
id = Utils.createUniqueId()
- @filterPort.onMessage.addListener (msg) ->
- return if (msg.id != id)
+ @filterPort.onMessage.addListener (msg) =>
+ @filterPort.onMessage.removeListener(arguments.callee)
# The result objects coming from the background page will be of the form:
# { html: "", type: "", url: "" }
# type will be one of [tab, bookmark, history, domain].
diff --git a/icons/browser_action_partial.png b/icons/browser_action_partial.png
new file mode 100644
index 00000000..e713f005
--- /dev/null
+++ b/icons/browser_action_partial.png
Binary files differ
diff --git a/icons/icon48partial.png b/icons/icon48partial.png
new file mode 100644
index 00000000..088099b1
--- /dev/null
+++ b/icons/icon48partial.png
Binary files differ
diff --git a/lib/dom_utils.coffee b/lib/dom_utils.coffee
index 38b23202..dcdd5518 100644
--- a/lib/dom_utils.coffee
+++ b/lib/dom_utils.coffee
@@ -46,11 +46,21 @@ DomUtils =
#
getVisibleClientRect: (element) ->
# Note: this call will be expensive if we modify the DOM in between calls.
- clientRects = element.getClientRects()
+ clientRects = ({
+ top: clientRect.top, right: clientRect.right, bottom: clientRect.bottom, left: clientRect.left,
+ width: clientRect.width, height: clientRect.height
+ } for clientRect in element.getClientRects())
for clientRect in clientRects
- if (clientRect.top < -2 || clientRect.top >= window.innerHeight - 4 ||
- clientRect.left < -2 || clientRect.left >= window.innerWidth - 4)
+ if (clientRect.top < 0)
+ clientRect.height += clientRect.top
+ clientRect.top = 0
+
+ if (clientRect.left < 0)
+ clientRect.width += clientRect.left
+ clientRect.left = 0
+
+ if (clientRect.top >= window.innerHeight - 4 || clientRect.left >= window.innerWidth - 4)
continue
if (clientRect.width < 3 || clientRect.height < 3)
@@ -99,8 +109,8 @@ DomUtils =
eventSequence = ["mouseover", "mousedown", "mouseup", "click"]
for event in eventSequence
mouseEvent = document.createEvent("MouseEvents")
- mouseEvent.initMouseEvent(event, true, true, window, 1, 0, 0, 0, 0, modifiers.ctrlKey, false, false,
- modifiers.metaKey, 0, null)
+ mouseEvent.initMouseEvent(event, true, true, window, 1, 0, 0, 0, 0, modifiers.ctrlKey, modifiers.altKey,
+ modifiers.shiftKey, modifiers.metaKey, 0, null)
# Debugging note: Firefox will not execute the element's default action if we dispatch this click event,
# but Webkit will. Dispatching a click on an input box does not seem to focus it; we do that separately
element.dispatchEvent(mouseEvent)
diff --git a/lib/keyboard_utils.coffee b/lib/keyboard_utils.coffee
index df5bbbad..d2a843f9 100644
--- a/lib/keyboard_utils.coffee
+++ b/lib/keyboard_utils.coffee
@@ -1,6 +1,7 @@
KeyboardUtils =
keyCodes:
- { ESC: 27, backspace: 8, deleteKey: 46, enter: 13, space: 32, shiftKey: 16, f1: 112, f12: 123, tab: 9 }
+ { ESC: 27, backspace: 8, deleteKey: 46, enter: 13, space: 32, shiftKey: 16, ctrlKey: 17, f1: 112,
+ f12: 123, tab: 9 }
keyNames:
{ 37: "left", 38: "up", 39: "right", 40: "down" }
diff --git a/lib/utils.coffee b/lib/utils.coffee
index 8d588a43..a93831d7 100644
--- a/lib/utils.coffee
+++ b/lib/utils.coffee
@@ -26,7 +26,7 @@ Utils =
-> id += 1
hasChromePrefix: (url) ->
- chromePrefixes = [ 'about', 'view-source', "chrome-extension" ]
+ chromePrefixes = [ "about", "view-source", "chrome-extension", "data" ]
for prefix in chromePrefixes
return true if url.startsWith prefix
false
diff --git a/manifest.json b/manifest.json
index 8de7f009..48681675 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "Vimium",
- "version": "1.44",
+ "version": "1.45",
"description": "The Hacker's Browser. Vimium provides keyboard shortcuts for navigation and control in the spirit of Vim.",
"icons": { "16": "icons/icon16.png",
"48": "icons/icon48.png",
@@ -11,7 +11,9 @@
"lib/utils.js",
"background_scripts/commands.js",
"lib/clipboard.js",
+ "background_scripts/sync.js",
"background_scripts/settings.js",
+ "background_scripts/exclusions.js",
"background_scripts/completion.js",
"background_scripts/marks.js",
"background_scripts/main.js"
@@ -23,6 +25,7 @@
"bookmarks",
"history",
"clipboardRead",
+ "storage",
"<all_urls>"
],
"content_scripts": [
@@ -42,6 +45,12 @@
"css": ["content_scripts/vimium.css"],
"run_at": "document_start",
"all_frames": true
+ },
+ {
+ "matches": ["file:///", "file:///*/"],
+ "css": ["content_scripts/file_urls.css"],
+ "run_at": "document_start",
+ "all_frames": true
}
],
"browser_action": {
diff --git a/pages/help_dialog.html b/pages/help_dialog.html
index 2a69ea03..0884f2cd 100644
--- a/pages/help_dialog.html
+++ b/pages/help_dialog.html
@@ -7,7 +7,7 @@
page with the up-to-date key bindings when the dialog is shown. -->
<div id="vimiumHelpDialog" class="vimiumReset">
<a class="vimiumReset optionsPage" href="#">Options</a>
- <a class="vimiumReset closeButton" href="#">x</a>
+ <a class="vimiumReset closeButton" href="#">&times;</a>
<div id="vimiumTitle" class="vimiumReset"><span class="vimiumReset" style="color:#2f508e">Vim</span>ium {{title}}</div>
<div class="vimiumReset vimiumColumn">
<table class="vimiumReset">
diff --git a/pages/options.coffee b/pages/options.coffee
index 117ce4a6..7f374f5d 100644
--- a/pages/options.coffee
+++ b/pages/options.coffee
@@ -1,42 +1,148 @@
-$ = (id) -> document.getElementById id
+$ = (id) -> document.getElementById id
bgSettings = chrome.extension.getBackgroundPage().Settings
-editableFields = [ "scrollStepSize", "excludedUrls", "linkHintCharacters", "linkHintNumbers",
- "userDefinedLinkHintCss", "keyMappings", "filterLinkHints", "previousPatterns",
- "nextPatterns", "hideHud", "regexFindMode", "searchUrl"]
-
-canBeEmptyFields = ["excludedUrls", "keyMappings", "userDefinedLinkHintCss"]
-
-postSaveHooks = keyMappings: (value) ->
- commands = chrome.extension.getBackgroundPage().Commands
- commands.clearKeyMappingsAndSetDefaults()
- commands.parseCustomKeyMappings value
- chrome.extension.getBackgroundPage().refreshCompletionKeysAfterMappingSave()
-
-document.addEventListener "DOMContentLoaded", ->
- populateOptions()
-
- for field in editableFields
- $(field).addEventListener "keyup", onOptionKeyup, false
- $(field).addEventListener "change", enableSaveButton, false
- $(field).addEventListener "change", onDataLoaded, false
-
- $("advancedOptionsLink").addEventListener "click", toggleAdvancedOptions, false
- $("showCommands").addEventListener "click", (->
- showHelpDialog chrome.extension.getBackgroundPage().helpDialogHtml(true, true, "Command Listing"), frameId
- ), false
- document.getElementById("restoreSettings").addEventListener "click", restoreToDefaults
- document.getElementById("saveOptions").addEventListener "click", saveOptions
+#
+# Class hierarchy for various types of option.
+class Option
+ # Base class for all option classes.
+ # Abstract. Option does not define @populateElement or @readValueFromElement.
+
+ # Static. Array of all options.
+ @all = []
+
+ constructor: (field,enableSaveButton) ->
+ @field = field
+ @element = $(@field)
+ @element.addEventListener "change", enableSaveButton
+ @fetch()
+ Option.all.push @
+
+ # Fetch a setting from localStorage, remember the @previous value and populate the DOM element.
+ # Return the fetched value.
+ fetch: ->
+ @populateElement @previous = bgSettings.get @field
+ @previous
+
+ # Write this option's new value back to localStorage, if necessary.
+ save: ->
+ value = @readValueFromElement()
+ if not @areEqual value, @previous
+ bgSettings.set @field, @previous = value
+ bgSettings.performPostUpdateHook @field, value
+
+ # Compare values; this is overridden by sub-classes.
+ areEqual: (a,b) -> a == b
+
+ restoreToDefault: ->
+ bgSettings.clear @field
+ @fetch()
+
+ # Abstract method; only implemented in sub-classes.
+ # Populate the option's DOM element (@element) with the setting's current value.
+ # populateElement: (value) -> DO_SOMETHING
+
+ # Abstract method; only implemented in sub-classes.
+ # Extract the setting's new value from the option's DOM element (@element).
+ # readValueFromElement: -> RETURN_SOMETHING
+
+class NumberOption extends Option
+ populateElement: (value) -> @element.value = value
+ readValueFromElement: -> parseFloat @element.value
+
+class TextOption extends Option
+ populateElement: (value) -> @element.value = value
+ readValueFromElement: -> @element.value.trim()
+
+class NonEmptyTextOption extends Option
+ populateElement: (value) -> @element.value = value
+ # If the new value is not empty, then return it. Otherwise, restore the default value.
+ readValueFromElement: -> if value = @element.value.trim() then value else @restoreToDefault()
+
+class CheckBoxOption extends Option
+ populateElement: (value) -> @element.checked = value
+ readValueFromElement: -> @element.checked
+
+class ExclusionRulesOption extends Option
+ constructor: (args...) ->
+ super(args...)
+ $("exclusionAddButton").addEventListener "click", (event) =>
+ @appendRule { pattern: "", passKeys: "" }
+ @maintainExclusionMargin()
+ # Focus the pattern element in the new rule.
+ @element.children[@element.children.length-1].children[0].children[0].focus()
+ # Scroll the new rule into view.
+ exclusionScrollBox = $("exclusionScrollBox")
+ exclusionScrollBox.scrollTop = exclusionScrollBox.scrollHeight
+
+ populateElement: (rules) ->
+ while @element.firstChild
+ @element.removeChild @element.firstChild
+ for rule in rules
+ @appendRule rule
+ @maintainExclusionMargin()
+
+ # Append a row for a new rule.
+ appendRule: (rule) ->
+ content = document.querySelector('#exclusionRuleTemplate').content
+ row = document.importNode content, true
+
+ for field in ["pattern", "passKeys"]
+ element = row.querySelector ".#{field}"
+ element.value = rule[field]
+ for event in [ "keyup", "change" ]
+ element.addEventListener event, enableSaveButton
+
+ remove = row.querySelector ".exclusionRemoveButton"
+ remove.addEventListener "click", (event) =>
+ row = event.target.parentNode.parentNode
+ row.parentNode.removeChild row
+ enableSaveButton()
+ @maintainExclusionMargin()
+
+ @element.appendChild row
+
+ readValueFromElement: ->
+ rules =
+ for element in @element.children
+ pattern = element.children[0].firstChild.value.trim()
+ passKeys = element.children[1].firstChild.value.trim()
+ { pattern: pattern, passKeys: passKeys }
+ rules.filter (rule) -> rule.pattern
+
+ areEqual: (a,b) ->
+ # Flatten each list of rules to a newline-separated string representation, and then use string equality.
+ # This is correct because patterns and passKeys cannot themselves contain newlines.
+ flatten = (rule) -> if rule and rule.pattern then rule.pattern + "\n" + rule.passKeys else ""
+ a.map(flatten).join("\n") == b.map(flatten).join("\n")
+
+ # Hack. There has to be a better way than...
+ # The y-axis scrollbar for "exclusionRules" is only displayed if it is needed. When visible, it appears on
+ # top of the enclosed content (partially obscuring it). Here, we adjust the margin of the "Remove" button to
+ # compensate.
+ maintainExclusionMargin: ->
+ scrollBox = $("exclusionScrollBox")
+ margin = if scrollBox.clientHeight < scrollBox.scrollHeight then "16px" else "0px"
+ for element in scrollBox.getElementsByClassName "exclusionRemoveButton"
+ element.style["margin-right"] = margin
+
+#
+# Operations for page elements.
+enableSaveButton = ->
+ $("saveOptions").removeAttribute "disabled"
-window.onbeforeunload = -> "You have unsaved changes to options." unless $("saveOptions").disabled
+saveOptions = ->
+ Option.all.map (option) -> option.save()
+ $("saveOptions").disabled = true
-onOptionKeyup = (event) ->
- if (event.target.getAttribute("type") isnt "checkbox" and
- event.target.getAttribute("savedValue") isnt event.target.value)
- enableSaveButton()
+restoreToDefaults = ->
+ return unless confirm "Are you sure you want to permanently return all of Vimium's settings to their defaults?"
+ Option.all.map (option) -> option.restoreToDefault()
+ maintainLinkHintsView()
+ $("saveOptions").disabled = true
-onDataLoaded = ->
+# Display either "linkHintNumbers" or "linkHintCharacters", depending upon "filterLinkHints".
+maintainLinkHintsView = ->
hide = (el) -> el.parentNode.parentNode.style.display = "none"
show = (el) -> el.parentNode.parentNode.style.display = "table-row"
if $("filterLinkHints").checked
@@ -46,64 +152,48 @@ onDataLoaded = ->
show $("linkHintCharacters")
hide $("linkHintNumbers")
-enableSaveButton = ->
- $("saveOptions").removeAttribute "disabled"
-
-# Saves options to localStorage.
-saveOptions = ->
-
- # If the value is unchanged from the default, delete the preference from localStorage; this gives us
- # the freedom to change the defaults in the future.
- for fieldName in editableFields
- field = $(fieldName)
- switch field.getAttribute("type")
- when "checkbox"
- fieldValue = field.checked
- when "number"
- fieldValue = parseFloat field.value
+toggleAdvancedOptions =
+ do (advancedMode=false) ->
+ (event) ->
+ if advancedMode
+ $("advancedOptions").style.display = "none"
+ $("advancedOptionsLink").innerHTML = "Show advanced options&hellip;"
else
- fieldValue = field.value.trim()
- field.value = fieldValue
-
- # If it's empty and not a field that we allow to be empty, restore to the default value
- if not fieldValue and canBeEmptyFields.indexOf(fieldName) is -1
- bgSettings.clear fieldName
- fieldValue = bgSettings.get(fieldName)
- else
- bgSettings.set fieldName, fieldValue
- $(fieldName).value = fieldValue
- $(fieldName).setAttribute "savedValue", fieldValue
- postSaveHooks[fieldName] fieldValue if postSaveHooks[fieldName]
+ $("advancedOptions").style.display = "table-row-group"
+ $("advancedOptionsLink").innerHTML = "Hide advanced options"
+ advancedMode = !advancedMode
+ event.preventDefault()
- $("saveOptions").disabled = true
+activateHelpDialog = ->
+ showHelpDialog chrome.extension.getBackgroundPage().helpDialogHtml(true, true, "Command Listing"), frameId
-# Restores select box state to saved value from localStorage.
-populateOptions = ->
- for field in editableFields
- val = bgSettings.get(field) or ""
- setFieldValue $(field), val
- onDataLoaded()
+#
+# Initialization.
+document.addEventListener "DOMContentLoaded", ->
-restoreToDefaults = ->
- for field in editableFields
- val = bgSettings.defaults[field] or ""
- setFieldValue $(field), val
- onDataLoaded()
- enableSaveButton()
-
-setFieldValue = (field, value) ->
- unless field.getAttribute("type") is "checkbox"
- field.value = value
- field.setAttribute "savedValue", value
- else
- field.checked = value
+ # Populate options. The constructor adds each new object to "Option.all".
+ new type(name,enableSaveButton) for name, type of {
+ exclusionRules: ExclusionRulesOption
+ filterLinkHints: CheckBoxOption
+ hideHud: CheckBoxOption
+ keyMappings: TextOption
+ linkHintCharacters: NonEmptyTextOption
+ linkHintNumbers: NonEmptyTextOption
+ nextPatterns: NonEmptyTextOption
+ previousPatterns: NonEmptyTextOption
+ regexFindMode: CheckBoxOption
+ scrollStepSize: NumberOption
+ searchEngines: TextOption
+ searchUrl: NonEmptyTextOption
+ userDefinedLinkHintCss: TextOption
+ }
+
+ $("saveOptions").addEventListener "click", saveOptions
+ $("restoreSettings").addEventListener "click", restoreToDefaults
+ $("advancedOptionsLink").addEventListener "click", toggleAdvancedOptions
+ $("showCommands").addEventListener "click", activateHelpDialog
+ $("filterLinkHints").addEventListener "click", maintainLinkHintsView
+
+ maintainLinkHintsView()
+ window.onbeforeunload = -> "You have unsaved changes to options." unless $("saveOptions").disabled
-toggleAdvancedOptions = do (advancedMode=false) -> (event) ->
- if advancedMode
- $("advancedOptions").style.display = "none"
- $("advancedOptionsLink").innerHTML = "Show advanced options&hellip;"
- else
- $("advancedOptions").style.display = "table-row-group"
- $("advancedOptionsLink").innerHTML = "Hide advanced options"
- advancedMode = !advancedMode
- event.preventDefault()
diff --git a/pages/options.html b/pages/options.html
index 8c7c007a..fb904316 100644
--- a/pages/options.html
+++ b/pages/options.html
@@ -109,11 +109,6 @@
width: 40px;
margin-right: 3px;
}
- textarea#excludedUrls {
- margin-top: 5px;
- width: 100%;
- min-height: 100px;
- }
textarea#userDefinedLinkHintCss {
width: 100%;;
min-height: 100px;
@@ -122,6 +117,10 @@
width: 100%;
min-height: 135px;
}
+ textarea#searchEngines {
+ width: 100%;
+ min-height: 135px;
+ }
input#previousPatterns, input#nextPatterns {
width: 100%;
}
@@ -174,6 +173,31 @@
padding: 15px 0;
border-top: 1px solid #eee;
}
+ /* Ids and classes for rendering exclusionRules */
+ #exclusionScrollBox {
+ overflow: scroll;
+ overflow-x: hidden;
+ overflow-y: auto;
+ height: 225px;
+ border: 1px solid #bfbfbf;
+ border-radius: 2px;
+ color: #444;
+ }
+ input.pattern, input.passKeys {
+ font-family: Consolas, "Liberation Mono", Courier, monospace;
+ font-size: 14px;
+ }
+ .pattern {
+ width: 250px;
+ }
+ .passKeys {
+ width: 120px;
+ }
+ #exclusionAddButton {
+ float: right;
+ margin-top: 5px;
+ margin-right: 0px;
+ }
</style>
<link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" />
@@ -192,16 +216,31 @@
</td>
</tr>
<tr>
- <td colspan="3">
- Excluded URLs<br/>
- <div class="help">
- <div class="example">
- e.g. http*://mail.google.com/*<br/>
- This will disable Vimium on Gmail.<br/><br/>
- Enter one URL per line.<br/>
- </div>
+ <td>Excluded URLs<br/>and keys</td>
+ <td>
+ <div class="help">
+ <div class="example">
+ <p>
+ The left column contains URL patterns. Vimium will be wholly or partially disabled for URLs matching these patterns. Patterns are Javascript regular expressions. Additionally, the character "*" matches any zero or more characters.
+ </p>
+ <p>
+ The right column contains keys which Vimium would would normally handle, but should instead be passed through to the underlying web page (for pages matching the corresponding pattern). If empty, then Vimium is wholly disabled.
+ </p>
</div>
- <textarea id="excludedUrls"></textarea>
+ </div>
+ <div>
+ <div id="exclusionScrollBox">
+ <table id="exclusionRules"></table>
+ <template id="exclusionRuleTemplate">
+ <tr>
+ <td><input/ type="text" class="pattern" placeholder="URL pattern"></td>
+ <td><input/ type="text" class="passKeys" placeholder="Exclude keys"></td>
+ <td><input/ type="button" class="exclusionRemoveButton" value="&#x2716;"></td>
+ </tr>
+ </template>
+ </div>
+ <button id="exclusionAddButton">Add Rule</button>
+ </div>
</td>
</tr>
<tbody id='advancedOptions'>
@@ -326,16 +365,32 @@ unmapAll
</td>
</tr>
<tr>
- <td class="caption">Search</td>
+ <td class="caption">Default Search<br/>Engine</td>
<td verticalAlign="top">
<div class="help">
<div class="example">
- Set which search engine is used when searching from the Vomnibar (examples: "http://duckduckgo.com/?q=", "http://www.google.com/search?q=").
+ The search engine which is used when searching from the Vomnibar
+ (e.g.: "http://duckduckgo.com/?q=").
</div>
</div>
<input id="searchUrl" type="text" />
</td>
</tr>
+ <tr>
+ <td class="caption">Custom Search<br/>Engines</td>
+ <td verticalAlign="top">
+ <div class="help">
+ <div class="example">
+ Use this to add shortcuts for your common search engines when using the Vomnibar.<br/><br/>
+ The format is:<br/>
+ <pre>your-keyword: http://the-site.com/?q=%s</pre>
+ %s will be replaced with your search term.<br/>
+ Lines which start with "#" are comments.
+ </div>
+ </div>
+ <textarea id="searchEngines"></textarea>
+ </td>
+ </tr>
</tbody>
</table>
diff --git a/pages/popup.coffee b/pages/popup.coffee
index 6d7afafc..ecf683e5 100644
--- a/pages/popup.coffee
+++ b/pages/popup.coffee
@@ -1,16 +1,88 @@
+
+originalRule = undefined
+originalPattern = undefined
+originalPassKeys = undefined
+
onLoad = ->
document.getElementById("optionsLink").setAttribute "href", chrome.runtime.getURL("pages/options.html")
chrome.tabs.getSelected null, (tab) ->
- # The common use case is to disable Vimium at the domain level.
- # This regexp will match "http://www.example.com/" from "http://www.example.com/path/to/page.html".
- domain = tab.url.match(/[^\/]*\/\/[^\/]*\//) or tab.url
- document.getElementById("popupInput").value = domain + "*"
+ isEnabled = chrome.extension.getBackgroundPage().isEnabledForUrl(url: tab.url)
+ # Check if we have an existing exclusing rule for this page.
+ if isEnabled.rule
+ originalRule = isEnabled.rule
+ originalPattern = originalRule.pattern
+ originalPassKeys = originalRule.passKeys
+ else
+ # The common use case is to disable Vimium at the domain level.
+ # This regexp will match "http://www.example.com/" from "http://www.example.com/path/to/page.html".
+ domain = (tab.url.match(/[^\/]*\/\/[^\/]*\//) or tab.url) + "*"
+ originalRule = null
+ originalPattern = domain
+ originalPassKeys = ""
+ document.getElementById("popupPattern").value = originalPattern
+ document.getElementById("popupPassKeys").value = originalPassKeys
+ onChange()
+
+onChange = ->
+ # As the text in the popup's input elements is changed, update the the popup's buttons accordingly.
+ # Aditionally, enable and disable those buttons as appropriate.
+ pattern = document.getElementById("popupPattern").value.trim()
+ passKeys = document.getElementById("popupPassKeys").value.trim()
+ popupExclude = document.getElementById("popupExclude")
+
+ document.getElementById("popupRemove").disabled =
+ not (originalRule and pattern == originalPattern)
+
+ if originalRule and pattern == originalPattern and passKeys == originalPassKeys
+ popupExclude.disabled = true
+ popupExclude.value = "Update Rule"
+
+ else if originalRule and pattern == originalPattern
+ popupExclude.disabled = false
+ popupExclude.value = "Update Rule"
+
+ else if originalRule
+ popupExclude.disabled = false
+ popupExclude.value = "Add Rule"
+
+ else if pattern
+ popupExclude.disabled = false
+ popupExclude.value = "Add Rule"
-onExcludeUrl = (e) ->
- url = document.getElementById("popupInput").value
- chrome.extension.getBackgroundPage().addExcludedUrl url
- document.getElementById("excludeConfirm").setAttribute "style", "display: inline-block"
+ else
+ popupExclude.disabled = true
+ popupExclude.value = "Add Rule"
+
+showMessage = do ->
+ timer = null
+
+ hideConfirmationMessage = ->
+ document.getElementById("confirmationMessage").setAttribute "style", "display: none"
+ timer = null
+
+ (message) ->
+ document.getElementById("confirmationMessage").setAttribute "style", "display: inline-block"
+ document.getElementById("confirmationMessage").innerHTML = message
+ clearTimeout(timer) if timer
+ timer = setTimeout(hideConfirmationMessage,2000)
+
+addExclusionRule = ->
+ pattern = document.getElementById("popupPattern").value.trim()
+ passKeys = document.getElementById("popupPassKeys").value.trim()
+ chrome.extension.getBackgroundPage().addExclusionRule pattern, passKeys
+ showMessage("Updated.")
+ onLoad()
+
+removeExclusionRule = ->
+ pattern = document.getElementById("popupPattern").value.trim()
+ chrome.extension.getBackgroundPage().removeExclusionRule pattern
+ showMessage("Removed.")
+ onLoad()
document.addEventListener "DOMContentLoaded", ->
- document.getElementById("popupButton").addEventListener "click", onExcludeUrl, false
+ document.getElementById("popupExclude").addEventListener "click", addExclusionRule, false
+ document.getElementById("popupRemove").addEventListener "click", removeExclusionRule, false
+ for field in ["popupPattern", "popupPassKeys"]
+ for event in ["keyup", "change"]
+ document.getElementById(field).addEventListener event, onChange, false
onLoad()
diff --git a/pages/popup.html b/pages/popup.html
index c3cd3832..86982eae 100644
--- a/pages/popup.html
+++ b/pages/popup.html
@@ -6,17 +6,22 @@
padding: 0px;
}
- #vimiumPopup { width: 300px; }
+ #vimiumPopup { width: 400px; }
#excludeControls {
padding: 10px;
}
- #popupInput {
- width: 160px;
+ #popupPattern, #popupPassKeys {
+ margin: 5px;
+ width: 330px;
+ /* Match the corresponding font and font size used on the options page. */
+ /* TODO (smblott): Match other styles from the options page. */
+ font-family: Consolas, "Liberation Mono", Courier, monospace;
+ font-size: 14px;
}
- #excludeConfirm {
+ #confirmationMessage {
display: inline-block;
width: 18px;
height: 13px;
@@ -24,7 +29,8 @@
display: none;
}
- #popupButton { margin-left: 10px; }
+ #popupRemove { margin: 5px; }
+ #popupExclude { margin: 5px; }
#popupMenu ul {
list-style: none;
@@ -52,9 +58,11 @@
<body>
<div id="vimiumPopup">
<div id="excludeControls">
- <input id="popupInput" type="text" />
- <input id="popupButton" type="button" value="Exclude URL" />
- <span id="excludeConfirm"></span>
+ <input id="popupPattern" placeholder="Pattern against which to match URLs..." type="text" /><br/>
+ <input id="popupPassKeys" placeholder="Only exclude these keys..." type="text" /><br/>
+ <input id="popupRemove" type="button" value="Remove Rule" />
+ <input id="popupExclude" type="button" value="Add or Update Rule" />
+ <span id="confirmationMessage">Text is added in popup.coffee.</span>
</div>
<div id="popupMenu">
diff --git a/test_harnesses/vomnibar.html b/test_harnesses/vomnibar.html
index f484279e..4d50e749 100644
--- a/test_harnesses/vomnibar.html
+++ b/test_harnesses/vomnibar.html
@@ -20,7 +20,7 @@
var itemHtml = '<span class="source">history</span> http://<span class="fuzzyMatch">n</span><span class="fuzzyMatch">i</span><span class="fuzzyMatch">n</span><span class="fuzzyMatch">j</span><span class="fuzzyMatch">a</span>words.com/info/about <span class="title">Ninjawords - a really fast dictionary</span>';
var results = [{ html: itemHtml }, { html: itemHtml }];
- // Stub out the chrome extension apis needed by the Vomnibar.
+ // Stub out the chrome extension APIs needed by the Vomnibar.
window.chrome = { };
var port = {
onMessage: { addListener: function(callback) { } },
@@ -53,8 +53,18 @@
</head>
<body>
- Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.<br/><br/>
+ Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et
+ dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
+ ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
+ nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
+ anim id est laborum.
- Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
+ <br/><br/>
+
+ Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et
+ dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex
+ ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat
+ nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
+ anim id est laborum.
</body>
</html>
diff --git a/tests/dom_tests/dom_tests.coffee b/tests/dom_tests/dom_tests.coffee
index 04c81068..ac3f9ebe 100644
--- a/tests/dom_tests/dom_tests.coffee
+++ b/tests/dom_tests/dom_tests.coffee
@@ -184,6 +184,12 @@ context "Input focus",
assert.equal "third", document.activeElement.id
handlerStack.bubbleEvent 'keydown', mockKeyboardEvent("A")
+# TODO: these find prev/next link tests could be refactored into unit tests which invoke a function which has
+# a tighter contract than goNext(), since they test minor aspects of goNext()'s link matching behavior, and we
+# don't need to construct external state many times over just to test that.
+# i.e. these tests should look something like:
+# assert.equal(findLink(html("<a href=...">))[0].href, "first")
+# These could then move outside of the dom_tests file.
context "Find prev / next links",
setup ->
@@ -215,6 +221,29 @@ context "Find prev / next links",
goNext()
assert.equal '#second', window.location.hash
+ should "find link relation in header", ->
+ document.getElementById("test-div").innerHTML = """
+ <link rel='next' href='#first'>
+ """
+ goNext()
+ assert.equal '#first', window.location.hash
+
+ should "favor link relation to text matching", ->
+ document.getElementById("test-div").innerHTML = """
+ <link rel='next' href='#first'>
+ <a href='#second'>next</a>
+ """
+ goNext()
+ assert.equal '#first', window.location.hash
+
+ should "match mixed case link relation", ->
+ document.getElementById("test-div").innerHTML = """
+ <link rel='Next' href='#first'>
+ """
+ goNext()
+ assert.equal '#first', window.location.hash
+
+
createLinks = (n) ->
for i in [0...n] by 1
link = document.createElement("a")
diff --git a/tests/dom_tests/dom_utils_test.coffee b/tests/dom_tests/dom_utils_test.coffee
index d0f881ba..130a3014 100644
--- a/tests/dom_tests/dom_utils_test.coffee
+++ b/tests/dom_tests/dom_utils_test.coffee
@@ -42,6 +42,14 @@ context "Check visibility",
assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'foo'
assert.equal null, DomUtils.getVisibleClientRect document.getElementById 'bar'
+ should "detect links only partially outside viewport as visible", ->
+ document.getElementById("test-div").innerHTML = """
+ <a id='foo' style='position:absolute;top:-10px'>test</a>
+ <a id='bar' style='position:absolute;left:-10px'>test</a>
+ """
+ assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'foo') != null
+ assert.isTrue (DomUtils.getVisibleClientRect document.getElementById 'bar') != null
+
should "detect opacity:0 links as hidden", ->
document.getElementById("test-div").innerHTML = """
<a id='foo' style='opacity:0'>test</a>
diff --git a/tests/dom_tests/phantom_runner.coffee b/tests/dom_tests/phantom_runner.coffee
index 91eeb526..d05d9ab4 100644
--- a/tests/dom_tests/phantom_runner.coffee
+++ b/tests/dom_tests/phantom_runner.coffee
@@ -1,5 +1,6 @@
system = require 'system'
fs = require 'fs'
+path = require 'path'
page = require('webpage').create()
page.settings.userAgent = 'phantom'
@@ -12,12 +13,16 @@ page.viewportSize =
page.onConsoleMessage = (msg) ->
console.log msg
-dirname = do ->
- pathParts = system.args[0].split(fs.separator)
- pathParts[pathParts.length - 1] = ''
- pathParts.join(fs.separator)
+page.onError = (msg, trace) ->
+ console.log(msg);
+ trace.forEach (item) ->
+ console.log(' ', item.file, ':', item.line)
-page.open dirname + 'dom_tests.html', (status) ->
+page.onResourceError = (resourceError) ->
+ console.log(resourceError.errorString)
+
+testfile = path.join(path.dirname(system.args[0]), 'dom_tests.html')
+page.open testfile, (status) ->
if status != 'success'
console.log 'Unable to load tests.'
phantom.exit 1
diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee
index fb267f63..02a741d5 100644
--- a/tests/unit_tests/completion_test.coffee
+++ b/tests/unit_tests/completion_test.coffee
@@ -23,6 +23,26 @@ context "bookmark completer",
results = filterCompleter(@completer, ["mark2"])
assert.arrayEqual [@bookmark2.url], results.map (suggestion) -> suggestion.url
+ should "return *no* matching bookmarks when there is no match", ->
+ @completer.refresh()
+ results = filterCompleter(@completer, ["does-not-match"])
+ assert.arrayEqual [], results.map (suggestion) -> suggestion.url
+
+ should "construct bookmark paths correctly", ->
+ @completer.refresh()
+ results = filterCompleter(@completer, ["mark2"])
+ assert.equal "/bookmark1/bookmark2", @bookmark2.pathAndTitle
+
+ should "return matching bookmark *titles* when searching *without* the folder separator character", ->
+ @completer.refresh()
+ results = filterCompleter(@completer, ["mark2"])
+ assert.arrayEqual ["bookmark2"], results.map (suggestion) -> suggestion.title
+
+ should "return matching bookmark *paths* when searching with the folder separator character", ->
+ @completer.refresh()
+ results = filterCompleter(@completer, ["/bookmark1", "mark2"])
+ assert.arrayEqual ["/bookmark1/bookmark2"], results.map (suggestion) -> suggestion.title
+
context "HistoryCache",
context "binary search",
setup ->
@@ -209,6 +229,20 @@ context "tab completer",
assert.arrayEqual ["tab2.com"], results.map (tab) -> tab.url
assert.arrayEqual [2], results.map (tab) -> tab.tabId
+context "search engines",
+ setup ->
+ searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s"
+ Settings.set 'searchEngines', searchEngines
+ @completer = new SearchEngineCompleter()
+ # note, I couldn't just call @completer.refresh() here as I couldn't set root.Settings without errors
+ # workaround is below, would be good for someone that understands the testing system better than me to improve
+ @completer.searchEngines = Settings.getSearchEngines()
+
+ should "return search engine suggestion", ->
+ results = filterCompleter(@completer, ["foo", "hello"])
+ assert.arrayEqual ["bar?q=hello"], results.map (result) -> result.url
+ assert.arrayEqual ["foo: hello"], results.map (result) -> result.title
+
context "suggestions",
should "escape html in page titles", ->
suggestion = new Suggestion(["queryterm"], "tab", "url", "title <span>", returns(1))
@@ -228,6 +262,50 @@ context "suggestions",
suggestion = new Suggestion(["queryterm"], "tab", "http://ninjawords.com", "ninjawords", returns(1))
assert.equal -1, suggestion.generateHtml().indexOf("http://ninjawords.com")
+context "RankingUtils.wordRelevancy",
+ should "score higher in shorter URLs", ->
+ highScore = RankingUtils.wordRelevancy(["stack"], "http://stackoverflow.com/short", "a-title")
+ lowScore = RankingUtils.wordRelevancy(["stack"], "http://stackoverflow.com/longer", "a-title")
+ assert.isTrue highScore > lowScore
+
+ should "score higher in shorter titles", ->
+ highScore = RankingUtils.wordRelevancy(["coffee"], "a-url", "Coffeescript")
+ lowScore = RankingUtils.wordRelevancy(["coffee"], "a-url", "Coffeescript rocks")
+ assert.isTrue highScore > lowScore
+
+ should "score higher for matching the start of a word (in a URL)", ->
+ lowScore = RankingUtils.wordRelevancy(["stack"], "http://Xstackoverflow.com/same", "a-title")
+ highScore = RankingUtils.wordRelevancy(["stack"], "http://stackoverflowX.com/same", "a-title")
+ assert.isTrue highScore > lowScore
+
+ should "score higher for matching the start of a word (in a title)", ->
+ lowScore = RankingUtils.wordRelevancy(["te"], "a-url", "Dist racted")
+ highScore = RankingUtils.wordRelevancy(["te"], "a-url", "Distrac ted")
+ assert.isTrue highScore > lowScore
+
+ should "score higher for matching a whole word (in a URL)", ->
+ lowScore = RankingUtils.wordRelevancy(["com"], "http://stackoverflow.comX/same", "a-title")
+ highScore = RankingUtils.wordRelevancy(["com"], "http://stackoverflowX.com/same", "a-title")
+ assert.isTrue highScore > lowScore
+
+ should "score higher for matching a whole word (in a title)", ->
+ lowScore = RankingUtils.wordRelevancy(["com"], "a-url", "abc comX")
+ highScore = RankingUtils.wordRelevancy(["com"], "a-url", "abcX com")
+ assert.isTrue highScore > lowScore
+
+ # # TODO: (smblott)
+ # # Word relevancy should take into account the number of matches (it doesn't currently).
+ # should "score higher for multiple matches (in a URL)", ->
+ # lowScore = RankingUtils.wordRelevancy(["stack"], "http://stackoverflow.com/Xxxxxx", "a-title")
+ # highScore = RankingUtils.wordRelevancy(["stack"], "http://stackoverflow.com/Xstack", "a-title")
+ # assert.isTrue highScore > lowScore
+
+ # should "score higher for multiple matches (in a title)", ->
+ # lowScore = RankingUtils.wordRelevancy(["bbc"], "http://stackoverflow.com/same", "BBC Radio 4 (XBCr4)")
+ # highScore = RankingUtils.wordRelevancy(["bbc"], "http://stackoverflow.com/same", "BBC Radio 4 (BBCr4)")
+ # assert.isTrue highScore > lowScore
+
+context "Suggestion.pushMatchingRanges",
should "extract ranges matching term (simple case, two matches)", ->
ranges = []
[ one, two, three ] = [ "one", "two", "three" ]
diff --git a/tests/unit_tests/exclusion_test.coffee b/tests/unit_tests/exclusion_test.coffee
new file mode 100644
index 00000000..a24c3b67
--- /dev/null
+++ b/tests/unit_tests/exclusion_test.coffee
@@ -0,0 +1,73 @@
+
+require "./test_helper.js"
+require "./test_chrome_stubs.js"
+
+# FIXME:
+# Would like to do:
+# extend(global, require "../../background_scripts/marks.js")
+# But it looks like marks.coffee has never been included in a test before!
+# Temporary fix...
+root.Marks =
+ create: () -> true
+ goto:
+ bind: () -> true
+
+extend(global, require "../../lib/utils.js")
+Utils.getCurrentVersion = -> '1.44'
+extend(global,require "../../background_scripts/sync.js")
+extend(global,require "../../background_scripts/settings.js")
+Sync.init()
+extend(global, require "../../background_scripts/exclusions.js")
+extend(global, require "../../background_scripts/commands.js")
+extend(global, require "../../background_scripts/main.js")
+
+# These tests cover only the most basic aspects of excluded URLs and passKeys.
+#
+context "Excluded URLs and pass keys",
+
+ # These tests have no setup, they use the default values from settings.coffee.
+
+ should "be disabled for excluded sites", ->
+ rule = isEnabledForUrl({ url: 'http://www.google.com/calendar/page' })
+ assert.isFalse rule.isEnableForUrl
+ assert.isFalse rule.passKeys
+
+ should "be enabled, but with pass keys", ->
+ rule = isEnabledForUrl({ url: 'https://www.facebook.com/something' })
+ assert.isTrue rule.isEnabledForUrl
+ assert.isFalse rule.passKeys
+ addExclusionRule("http*://www.facebook.com/*","oO")
+ rule = isEnabledForUrl({ url: 'https://www.facebook.com/something' })
+ assert.isTrue rule.isEnabledForUrl
+ assert.equal rule.passKeys, 'oO'
+
+ should "be enabled", ->
+ rule = isEnabledForUrl({ url: 'http://www.twitter.com/pages' })
+ assert.isTrue rule.isEnabledForUrl
+ assert.isFalse rule.passKeys
+
+ should "add a new excluded URL", ->
+ rule = isEnabledForUrl({ url: 'http://www.example.com/page' })
+ assert.isTrue rule.isEnabledForUrl
+ addExclusionRule("http://www.example.com*")
+ rule = isEnabledForUrl({ url: 'http://www.example.com/page' })
+ assert.isFalse rule.isEnabledForUrl
+ assert.isFalse rule.passKeys
+
+ should "add a new excluded URL with passkeys", ->
+ rule = isEnabledForUrl({ url: 'http://www.anotherexample.com/page' })
+ assert.isTrue rule.isEnabledForUrl
+ addExclusionRule("http://www.anotherexample.com/*","jk")
+ rule = isEnabledForUrl({ url: 'http://www.anotherexample.com/page' })
+ assert.isTrue rule.isEnabledForUrl
+ assert.equal rule.passKeys, 'jk'
+
+ should "update an existing excluded URL with passkeys", ->
+ rule = isEnabledForUrl({ url: 'http://mail.google.com/page' })
+ assert.isFalse rule.isEnabledForUrl
+ assert.isFalse rule.passKeys
+ addExclusionRule("http*://mail.google.com/*","jknp")
+ rule = isEnabledForUrl({ url: 'http://mail.google.com/page' })
+ assert.isTrue rule.isEnabledForUrl
+ assert.equal rule.passKeys, 'jknp'
+
diff --git a/tests/unit_tests/settings_test.coffee b/tests/unit_tests/settings_test.coffee
index b2c5484b..1283497c 100644
--- a/tests/unit_tests/settings_test.coffee
+++ b/tests/unit_tests/settings_test.coffee
@@ -1,15 +1,22 @@
require "./test_helper.js"
+require "./test_chrome_stubs.js"
extend(global, require "../../lib/utils.js")
Utils.getCurrentVersion = -> '1.44'
global.localStorage = {}
-{Settings} = require "../../background_scripts/settings.js"
+extend(global,require "../../background_scripts/sync.js")
+extend(global,require "../../background_scripts/settings.js")
+Sync.init()
context "settings",
setup ->
stub global, 'localStorage', {}
+ should "save settings in localStorage as JSONified strings", ->
+ Settings.set 'dummy', ""
+ assert.equal localStorage.dummy, '""'
+
should "obtain defaults if no key is stored", ->
assert.isFalse Settings.has 'scrollStepSize'
assert.equal Settings.get('scrollStepSize'), 60
@@ -28,3 +35,49 @@ context "settings",
Settings.set 'scrollStepSize', 20
Settings.clear 'scrollStepSize'
assert.equal Settings.get('scrollStepSize'), 60
+
+ should "propagate non-default value via synced storage listener", ->
+ Settings.set 'scrollStepSize', 20
+ assert.equal Settings.get('scrollStepSize'), 20
+ Sync.handleStorageUpdate { scrollStepSize: { newValue: "40" } }
+ assert.equal Settings.get('scrollStepSize'), 40
+
+ should "propagate default value via synced storage listener", ->
+ Settings.set 'scrollStepSize', 20
+ assert.equal Settings.get('scrollStepSize'), 20
+ Sync.handleStorageUpdate { scrollStepSize: { newValue: "60" } }
+ assert.isFalse Settings.has 'scrollStepSize'
+
+ should "propagate non-default values from synced storage", ->
+ chrome.storage.sync.set { scrollStepSize: JSON.stringify(20) }
+ Sync.fetchAsync()
+ assert.equal Settings.get('scrollStepSize'), 20
+
+ should "propagate default values from synced storage", ->
+ Settings.set 'scrollStepSize', 20
+ chrome.storage.sync.set { scrollStepSize: JSON.stringify(60) }
+ Sync.fetchAsync()
+ assert.isFalse Settings.has 'scrollStepSize'
+
+ should "clear a setting from synced storage", ->
+ Settings.set 'scrollStepSize', 20
+ chrome.storage.sync.remove 'scrollStepSize'
+ assert.isFalse Settings.has 'scrollStepSize'
+
+ should "trigger a postUpdateHook", ->
+ message = "Hello World"
+ Settings.postUpdateHooks['scrollStepSize'] = (value) -> Sync.message = value
+ chrome.storage.sync.set { scrollStepSize: JSON.stringify(message) }
+ assert.equal message, Sync.message
+
+ should "set search engines, retrieve them correctly and check that it has been parsed correctly", ->
+ searchEngines = "foo: bar?q=%s\n# comment\nbaz: qux?q=%s"
+ parsedSearchEngines = {"foo": "bar?q=%s", "baz": "qux?q=%s"}
+ Settings.set 'searchEngines', searchEngines
+ assert.equal(searchEngines, Settings.get('searchEngines'))
+ result = Settings.getSearchEngines()
+ assert.isTrue(parsedSearchEngines["foo"] == result["foo"] &&
+ parsedSearchEngines["baz"] == result["baz"] && Object.keys(result).length == 2)
+
+ should "sync a key which is not a known setting (without crashing)", ->
+ chrome.storage.sync.set { notASetting: JSON.stringify("notAUsefullValue") }
diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee
new file mode 100644
index 00000000..9622f85f
--- /dev/null
+++ b/tests/unit_tests/test_chrome_stubs.coffee
@@ -0,0 +1,90 @@
+
+#
+# This is a stub for chrome.strorage.sync for testing.
+# It does what chrome.storage.sync should do (roughly), but does so synchronously.
+# It also provides stubs for a number of other chrome APIs.
+#
+
+global.window = {}
+global.localStorage = {}
+
+global.chrome =
+ runtime:
+ getManifest: () ->
+ version: "1.2.3"
+ onConnect:
+ addListener: () -> true
+ onMessage:
+ addListener: () -> true
+
+ tabs:
+ onSelectionChanged:
+ addListener: () -> true
+ onUpdated:
+ addListener: () -> true
+ onAttached:
+ addListener: () -> true
+ onMoved:
+ addListener: () -> true
+ onRemoved:
+ addListener: () -> true
+ onActiveChanged:
+ addListener: () -> true
+ query: () -> true
+
+ windows:
+ onRemoved:
+ addListener: () -> true
+ getAll: () -> true
+
+ storage:
+ # chrome.storage.onChanged
+ onChanged:
+ addListener: (func) -> @func = func
+
+ # Fake a callback from chrome.storage.sync.
+ call: (key, value) ->
+ chrome.runtime.lastError = undefined
+ key_value = {}
+ key_value[key] = { newValue: value }
+ @func(key_value,'synced storage stub') if @func
+
+ callEmpty: (key) ->
+ chrome.runtime.lastError = undefined
+ if @func
+ items = {}
+ items[key] = {}
+ @func(items,'synced storage stub')
+
+ # chrome.storage.sync
+ sync:
+ store: {}
+
+ set: (items, callback) ->
+ chrome.runtime.lastError = undefined
+ for own key, value of items
+ @store[key] = value
+ callback() if callback
+ # Now, generate (supposedly asynchronous) notifications for listeners.
+ for own key, value of items
+ global.chrome.storage.onChanged.call(key,value)
+
+ get: (keys, callback) ->
+ chrome.runtime.lastError = undefined
+ if keys == null
+ keys = []
+ for own key, value of @store
+ keys.push key
+ items = {}
+ for key in keys
+ items[key] = @store[key]
+ # Now, generate (supposedly asynchronous) callback
+ callback items if callback
+
+ remove: (key, callback) ->
+ chrome.runtime.lastError = undefined
+ if key of @store
+ delete @store[key]
+ callback() if callback
+ # Now, generate (supposedly asynchronous) notification for listeners.
+ global.chrome.storage.onChanged.callEmpty(key)
diff --git a/tests/unit_tests/utils_test.coffee b/tests/unit_tests/utils_test.coffee
index e1aa32c7..c4139dbb 100644
--- a/tests/unit_tests/utils_test.coffee
+++ b/tests/unit_tests/utils_test.coffee
@@ -1,6 +1,10 @@
require "./test_helper.js"
+require "./test_chrome_stubs.js"
extend(global, require "../../lib/utils.js")
+Utils.getCurrentVersion = -> '1.43'
+extend(global, require "../../background_scripts/sync.js")
extend(global, require "../../background_scripts/settings.js")
+Sync.init()
context "isUrl",
should "accept valid URLs", ->