diff options
29 files changed, 802 insertions, 176 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 57133d8f..03ac26e9 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,7 +26,12 @@ 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: @@ -37,8 +43,8 @@ Our tests use [shoulda.js](https://github.com/philc/shoulda.js) and [PhantomJS]( ## 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` @@ -46,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 @@ -58,3 +59,28 @@ 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. + +## 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. + +## 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 @@ -28,6 +28,7 @@ Contributors: markstos Matthew Cline <matt@nightrealms.com> Matt Garriott (github: mgarriott) + Michael Hauser-Raspe (github: mijoharas) Murph (github: pandeiro) Niklas Baumstark <niklas.baumstark@gmail.com> (github: niklasb) rodimius @@ -2,7 +2,6 @@ util = require "util" fs = require "fs" path = require "path" child_process = require "child_process" -{Utils} = require './lib/utils' spawn = (procName, optArray, silent=false) -> if process.platform is "win32" @@ -118,6 +117,7 @@ task "test", "run all tests", (options) -> process.exit 0 task "coverage", "generate coverage report", -> + {Utils} = require './lib/utils' temp = require 'temp' tmpDir = temp.mkdirSync null jscoverage = spawn "jscoverage", [".", tmpDir].concat optArrayFromDict @@ -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,13 +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.43 (05/18/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 (2013-05-18) - Relevancy improvements to the Vomnibar's domain & history search. - Added gU, which goes to the root of the current URL. @@ -143,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. @@ -169,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. @@ -205,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. @@ -213,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. @@ -245,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. @@ -260,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. @@ -276,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. @@ -284,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. @@ -311,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. @@ -319,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. @@ -330,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 ce159c71..35c94bb9 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -86,21 +86,21 @@ 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", - "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", + "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", "moveTabLeft", "moveTabRight"] misc: ["showHelp"] @@ -111,7 +111,7 @@ Commands = "scrollToLeft", "scrollToRight", "moveTabToNewWindow", "goUp", "goToRoot", "focusInput", "LinkHints.activateModeWithQueue", "LinkHints.activateModeToOpenIncognito", "goNext", "goPrevious", "Marks.activateCreateMode", - "Marks.activateGotoMode"] + "Marks.activateGotoMode", "moveTabLeft", "moveTabRight"] defaultKeyMappings = "?": "showHelp" @@ -161,6 +161,8 @@ defaultKeyMappings = "J": "previousTab" "gt": "nextTab" "gT": "previousTab" + "<<": "moveTabLeft" + ">>": "moveTabRight" "g0": "firstTab" "g$": "lastTab" @@ -170,6 +172,8 @@ defaultKeyMappings = "x": "removeTab" "X": "restoreTab" + "<a-p>": "togglePinTab" + "o": "Vomnibar.activate" "O": "Vomnibar.activateInNewTab" @@ -212,11 +216,13 @@ 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"] @@ -245,6 +251,10 @@ 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 }] + + 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 fd41cdc8..b0ab4b88 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -35,12 +35,12 @@ 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> """ - shortenUrl: (url) -> @stripTrailingSlash(url).replace(/^http:\/\//, "") + shortenUrl: (url) -> @stripTrailingSlash(url).replace(/^https?:\/\//, "") stripTrailingSlash: (url) -> url = url.substring(url, url.length - 1) if url[url.length - 1] == "/" @@ -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/main.coffee b/background_scripts/main.coffee index f564f477..3b7670d4 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]) @@ -217,6 +219,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 @@ -238,29 +246,39 @@ 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.sessionRestore + chrome.sessionRestore.getRecentlyClosed((closed) -> + chrome.sessionRestore.restore(closed[0])) + 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 @@ -596,3 +614,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..175f3262 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,18 +8,54 @@ 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 + + # 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 linkHintCharacters: "sadfjklewcmpgh" @@ -49,7 +85,7 @@ root.Settings = Settings = """ http*://mail.google.com/* """ - # NOTE : If a page contains both a single angle-bracket link and a double angle-bracket link, then in + # 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 +96,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..24314b26 100644 --- a/content_scripts/link_hints.coffee +++ b/content_scripts/link_hints.coffee @@ -9,7 +9,8 @@ # 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 = {} @@ -24,7 +25,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 +34,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,7 +47,8 @@ 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) @@ -60,7 +62,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,15 +80,18 @@ 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" }) else if @mode is COPY_LINK_URL @@ -101,9 +107,7 @@ LinkHints = url: link.href) 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 +165,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 +198,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 +243,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 ccbcb339..a63fc3a5 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; @@ -296,6 +296,8 @@ body.vimiumFindMode ::selection { } #vomnibar input { + color: #000; + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 20px; height: 34px; margin-bottom: 0; @@ -332,6 +334,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..896253a6 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -8,7 +8,7 @@ window.handlerStack = new HandlerStack insertModeLock = null findMode = false -findModeQuery = { rawQuery: "" } +findModeQuery = { rawQuery: "", matchCount: 0 } findModeQueryHasResults = false findModeAnchorNode = null isShowingHelpDialog = false @@ -554,6 +554,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 +811,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 +827,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 +851,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 +881,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 +921,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 +930,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='#'>×</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 6782ab28..6997d387 100644 --- a/content_scripts/vomnibar.coffee +++ b/content_scripts/vomnibar.coffee @@ -68,8 +68,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. @@ -192,8 +202,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/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 96fa9ba7..b134ceaa 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Vimium", - "version": "1.43", + "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,6 +11,7 @@ "lib/utils.js", "background_scripts/commands.js", "lib/clipboard.js", + "background_scripts/sync.js", "background_scripts/settings.js", "background_scripts/completion.js", "background_scripts/marks.js", @@ -23,6 +24,7 @@ "bookmarks", "history", "clipboardRead", + "storage", "<all_urls>" ], "content_scripts": [ @@ -42,6 +44,11 @@ "css": ["content_scripts/vimium.css"], "run_at": "document_start", "all_frames": true + }, + { + "matches": ["file://*"], + "css": ["content_scripts/file_urls.css"], + "run_at": "document_start" } ], "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="#">×</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..d73d8f15 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -4,15 +4,9 @@ bgSettings = chrome.extension.getBackgroundPage().Settings editableFields = [ "scrollStepSize", "excludedUrls", "linkHintCharacters", "linkHintNumbers", "userDefinedLinkHintCss", "keyMappings", "filterLinkHints", "previousPatterns", - "nextPatterns", "hideHud", "regexFindMode", "searchUrl"] + "nextPatterns", "hideHud", "regexFindMode", "searchUrl", "searchEngines"] -canBeEmptyFields = ["excludedUrls", "keyMappings", "userDefinedLinkHintCss"] - -postSaveHooks = keyMappings: (value) -> - commands = chrome.extension.getBackgroundPage().Commands - commands.clearKeyMappingsAndSetDefaults() - commands.parseCustomKeyMappings value - chrome.extension.getBackgroundPage().refreshCompletionKeysAfterMappingSave() +canBeEmptyFields = ["excludedUrls", "keyMappings", "userDefinedLinkHintCss", "searchEngines"] document.addEventListener "DOMContentLoaded", -> populateOptions() @@ -73,7 +67,7 @@ saveOptions = -> bgSettings.set fieldName, fieldValue $(fieldName).value = fieldValue $(fieldName).setAttribute "savedValue", fieldValue - postSaveHooks[fieldName] fieldValue if postSaveHooks[fieldName] + bgSettings.performPostUpdateHook fieldName, fieldValue $("saveOptions").disabled = true diff --git a/pages/options.html b/pages/options.html index 8c7c007a..b71625e8 100644 --- a/pages/options.html +++ b/pages/options.html @@ -122,6 +122,10 @@ width: 100%; min-height: 135px; } + textarea#searchEngines { + width: 100%; + min-height: 135px; + } input#previousPatterns, input#nextPatterns { width: 100%; } @@ -326,16 +330,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.html b/pages/popup.html index c3cd3832..8ccf7126 100644 --- a/pages/popup.html +++ b/pages/popup.html @@ -54,7 +54,7 @@ <div id="excludeControls"> <input id="popupInput" type="text" /> <input id="popupButton" type="button" value="Exclude URL" /> - <span id="excludeConfirm"></span> + <span id="excludeConfirm">Saved exclude pattern.</span> </div> <div id="popupMenu"> 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/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/settings_test.coffee b/tests/unit_tests/settings_test.coffee index cfc0cfb6..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.43' +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..e9c48f31 --- /dev/null +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -0,0 +1,61 @@ + +# +# This is a stub for chrome.strorage.sync for testing. +# It does what chrome.storage.sync should do (roughly), but does so synchronously. +# + +global.chrome = + runtime: {} + + 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", -> |
