diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index 23696185a..deec932bb 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -26,7 +26,6 @@ class Suggestion generateHtml: -> return @html if @html - favIconUrl = @tabFavIconUrl or "#{@getUrlRoot(@url)}/favicon.ico" relevancyHtml = if @showRelevancy then "#{@computeRelevancy()}" else "" # NOTE(philc): We're using these vimium-specific class names so we don't collide with the page's CSS. @html = @@ -34,19 +33,16 @@ class Suggestion
#{@type} #{@highlightTerms(Utils.escapeHtml(@title))} -
-
- #{@shortenUrl(@highlightTerms(Utils.escapeHtml(@url)))} - #{relevancyHtml} +
+
+ + #{@shortenUrl(@highlightTerms(Utils.escapeHtml(@url)))} + #{relevancyHtml}
""" - # Use neat trick to snatch a domain (http://stackoverflow.com/a/8498668). - getUrlRoot: (url) -> - a = document.createElement 'a' - a.href = url - a.protocol + "//" + a.hostname + # Static method. + @parseDomain: (url) -> url.split("/")[2] || "" shortenUrl: (url) -> @stripTrailingSlash(url).replace(/^https?:\/\//, "") @@ -218,6 +214,7 @@ class DomainCompleter topDomain = domains[0][0] onComplete([new Suggestion(queryTerms, "domain", topDomain, null, @computeRelevancy)]) + # Returns a list of domains of the form: [ [domain, relevancy], ... ] sortDomainsByRelevancy: (queryTerms, domainCandidates) -> results = [] @@ -238,7 +235,7 @@ class DomainCompleter onComplete() onPageVisited: (newPage) -> - domain = @parseDomain(newPage.url) + domain = Suggestion.parseDomain(newPage.url) if domain slot = @domains[domain] ||= { entry: newPage, referenceCount: 0 } # We want each entry in our domains hash to point to the most recent History entry for that domain. @@ -250,12 +247,10 @@ class DomainCompleter @domains = {} else toRemove.urls.forEach (url) => - domain = @parseDomain(url) + domain = Suggestion.parseDomain(url) if domain and @domains[domain] and ( @domains[domain].referenceCount -= 1 ) == 0 delete @domains[domain] - parseDomain: (url) -> url.split("/")[2] || "" - # Suggestions from the Domain completer have the maximum relevancy. They should be shown first in the list. computeRelevancy: -> 1 @@ -269,7 +264,6 @@ class TabCompleter suggestions = results.map (tab) => suggestion = new Suggestion(queryTerms, "tab", tab.url, tab.title, @computeRelevancy) suggestion.tabId = tab.id - suggestion.tabFavIconUrl = tab.favIconUrl suggestion onComplete(suggestions) diff --git a/background_scripts/main.coffee b/background_scripts/main.coffee index 898f46f16..22477cc6a 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -56,11 +56,27 @@ chrome.runtime.onConnect.addListener((port, name) -> port.onMessage.addListener(portHandlers[port.name]) ) -chrome.runtime.onMessage.addListener((request, sender, sendResponse) -> +# With asynchronous message handling, failure to call sendResponse will result in a memory leak. We allow two +# minutes and one second for the response to be sent, after which time we simply *assume* there is an an error +# and call sendResponse with an error message. +sendResponseWithTimeout = (sendResponse) -> + timer = null + doSendResponse = (response=null) -> + timer = null + sendResponse (response || { error: "timeout", errorSource: "sendResponseWithTimeout" }) + timer = setTimeout doSendResponse, 121000 + (response) -> + if timer + clearTimeout timer + doSendResponse response + +chrome.runtime.onMessage.addListener (request, sender, sendResponse) -> + if (sendRequestAsyncHandlers[request.handler]) + # Handlers must return true if they will respond asynchronously, false otherwise. + return sendRequestAsyncHandlers[request.handler](request, sender, sendResponseWithTimeout(sendResponse)) if (sendRequestHandlers[request.handler]) sendResponse(sendRequestHandlers[request.handler](request, sender)) - # Ensure the sendResponse callback is freed. - return false) + false # sendResponse will not now be called, and can be freed. # # Used by the content scripts to get their full URL. This is needed for URLs like "view-source:http:# .." @@ -620,6 +636,49 @@ getCurrFrameIndex = (frames) -> return i if frames[i].id == focusedFrame frames.length + 1 +# Launch an asynchronous HTTP request and send the response as a base64-encoded data: URI. +fetchViaHttpAsBase64 = (request, sendResponse) -> + xhr = new XMLHttpRequest() + xhr.open("GET", request.url, true) + xhr.responseType = "blob" + xhr.timeout = request.timeout || 5000 + xhr.ontimeout = -> sendResponse {} + xhr.onerror = -> sendResponse {} + xhr.onload = -> + return sendResponse({}) unless xhr.status == 200 and xhr.readyState == 4 + reader = new window.FileReader() + reader.readAsDataURL xhr.response + reader.onerror = -> sendResponse {} + reader.onloadend = -> + sendResponse { data: reader.result, type: xhr.response.type } + xhr.send() + true # sendResponse will be called asynchronously. + +fetchFavicon = do -> + defaultFavicon = "" + cache = new SimpleCache 1000*60*60*24*8 # Eight days. + + fetcher = (request, _, sendResponse) -> + url = request.url + return sendResponse(response) if response = cache.get url + request.url = "chrome://favicon/#{url}" + fetchViaHttpAsBase64 request, (response) -> + return sendResponse({}) unless response.data + return sendResponse({}) unless response.type + return sendResponse({}) unless response.type.startsWith "image/" + if response.data == defaultFavicon + # Approach: only use the chrome default favicon for chrome URLs. + isChromeUrl = Utils.hasChromePrefix(url) + cache.set(url,response) if isChromeUrl + return sendResponse(if isChromeUrl then response else {}) + sendResponse cache.set url, response + + # Fetch the default chrome favicon. + fetcher {url: "chrome://favicon/does-not-exits---hopefully---kz85S6j"}, null, (response) -> + defaultFavicon = response.data if response.data + + return fetcher + # Port handler mapping portHandlers = keyDown: handleKeyDown, @@ -645,6 +704,9 @@ sendRequestHandlers = createMark: Marks.create.bind(Marks), gotoMark: Marks.goto.bind(Marks) +sendRequestAsyncHandlers = + fetchFavicon: fetchFavicon + # Convenience function for development use. window.runTests = -> open(chrome.runtime.getURL('tests/dom_tests/dom_tests.html')) diff --git a/content_scripts/vimium.css b/content_scripts/vimium.css index 24f229f38..ad0715606 100644 --- a/content_scripts/vimium.css +++ b/content_scripts/vimium.css @@ -349,14 +349,12 @@ body.vimiumFindMode ::selection { #vomnibar li .vomnibarBottomHalf { font-size: 15px; margin-top: 3px; - padding: 2px 0; + padding: 0px 0; } #vomnibar li .vomnibarIcon { - background-position-y: center; - background-size: 16px; - background-repeat: no-repeat; - padding-left: 20px; + height: 16px; + width: 16px; } #vomnibar li .vomnibarSource { diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index d4347f26d..4f7becbae 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -398,10 +398,12 @@ onKeydown = (event) -> keyChar = "<" + keyChar + ">" if (isInsertMode() && KeyboardUtils.isEscape(event)) - if isEditable(event.srcElement) or isEmbed(event.srcElement) + # Note that we can't programmatically blur out of Flash embeds from Javascript. + if (!isEmbed(event.srcElement)) # Remove focus so the user can't just get himself back into insert mode by typing in the same input # box. - event.srcElement.blur() + if (isEditable(event.srcElement)) + event.srcElement.blur() exitInsertMode() DomUtils.suppressEvent event handledKeydownEvents.push event diff --git a/content_scripts/vomnibar.coffee b/content_scripts/vomnibar.coffee index 22b9ed646..981b30be3 100644 --- a/content_scripts/vomnibar.coffee +++ b/content_scripts/vomnibar.coffee @@ -151,11 +151,27 @@ class VomnibarUI @populateUiWithCompletions(completions) callback() if callback + guessFavicons: do -> + googleCacheMissFavicon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAsSAAALEgHS3X78AAACiElEQVQ4EaVTzU8TURCf2tJuS7tQtlRb6UKBIkQwkRRSEzkQgyEc6lkOKgcOph78Y+CgjXjDs2i44FXY9AMTlQRUELZapVlouy3d7kKtb0Zr0MSLTvL2zb75eL838xtTvV6H/xELBptMJojeXLCXyobnyog4YhzXYvmCFi6qVSfaeRdXdrfaU1areV5KykmX06rcvzumjY/1ggkR3Jh+bNf1mr8v1D5bLuvR3qDgFbvbBJYIrE1mCIoCrKxsHuzK+Rzvsi29+6DEbTZz9unijEYI8ObBgXOzlcrx9OAlXyDYKUCzwwrDQx1wVDGg089Dt+gR3mxmhcUnaWeoxwMbm/vzDFzmDEKMMNhquRqduT1KwXiGt0vre6iSeAUHNDE0d26NBtAXY9BACQyjFusKuL2Ry+IPb/Y9ZglwuVscdHaknUChqLF/O4jn3V5dP4mhgRJgwSYm+gV0Oi3XrvYB30yvhGa7BS70eGFHPoTJyQHhMK+F0ZesRVVznvXw5Ixv7/C10moEo6OZXbWvlFAF9FVZDOqEABUMRIkMd8GnLwVWg9/RkJF9sA4oDfYQAuzzjqzwvnaRUFxn/X2ZlmGLXAE7AL52B4xHgqAUqrC1nSNuoJkQtLkdqReszz/9aRvq90NOKdOS1nch8TpL555WDp49f3uAMXhACRjD5j4ykuCtf5PP7Fm1b0DIsl/VHGezzP1KwOiZQobFF9YyjSRYQETRENSlVzI8iK9mWlzckpSSCQHVALmN9Az1euDho9Xo8vKGd2rqooA8yBcrwHgCqYR0kMkWci08t/R+W4ljDCanWTg9TJGwGNaNk3vYZ7VUdeKsYJGFNkfSzjXNrSX20s4/h6kB81/271ghG17l+rPTAAAAAElFTkSuQmCC" + cache = new SimpleCache(0) + -> + for favicon in @completionList.getElementsByClassName "vomnibarIcon" + do (favicon) -> + url = favicon.getAttribute("url").split("?",1)[0].split("#",1)[0] + if cachedFavicon = cache.get url + favicon.src = cachedFavicon + else + favicon.src = googleCacheMissFavicon + chrome.runtime.sendMessage {handler: "fetchFavicon", url: url}, (response) -> + if response.data + favicon.src = cache.set url, response.data + populateUiWithCompletions: (completions) -> # update completion list with the new data @completionList.innerHTML = completions.map((completion) -> "
  • #{completion.html}
  • ").join("") @completionList.style.display = if completions.length > 0 then "block" else "none" @selection = Math.min(Math.max(@initialSelectionValue, @selection), @completions.length - 1) + @guessFavicons() @updateSelection() update: (updateSynchronously, callback) -> diff --git a/lib/utils.coffee b/lib/utils.coffee index a93831d73..fc0185e9d 100644 --- a/lib/utils.coffee +++ b/lib/utils.coffee @@ -26,7 +26,7 @@ Utils = -> id += 1 hasChromePrefix: (url) -> - chromePrefixes = [ "about", "view-source", "chrome-extension", "data" ] + chromePrefixes = [ "about", "view-source", "chrome-extension", "data", "chrome-devtools" ] for prefix in chromePrefixes return true if url.startsWith prefix false @@ -150,5 +150,27 @@ globalRoot.extend = (hash1, hash2) -> hash1[key] = hash2[key] hash1 +# A simple synchronous cache. Entries which are used within each timeout period will remain cached. Those +# that aren't will be dropped. +class SimpleCache + constructor: (timeout=1000*60*60*24) -> + @current = {} + @previous = {} + setInterval((=> @rotate()), timeout) if timeout + + set: (key, value) -> + delete @previous[key] + @current[key] = value + + get: (key) -> + return @current[key] if @current[key] + return @set(key, @previous[key]) if @previous[key] + return null + + rotate: -> + @previous = @current + @current = {} + root = exports ? window root.Utils = Utils +root.SimpleCache = SimpleCache diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee index 811436a9e..28a30e22f 100644 --- a/tests/unit_tests/completion_test.coffee +++ b/tests/unit_tests/completion_test.coffee @@ -221,8 +221,8 @@ context "domain completer (removing entries)", context "tab completer", setup -> @tabs = [ - { url: "tab1.com", title: "tab1", id: 1 } - { url: "tab2.com", title: "tab2", id: 2 }] + { url: "tab1.com", title: "tab1", id: 1, favIconUrl: "http://tab1.com/favicon.ico" } + { url: "tab2.com", title: "tab2", id: 2, favIconUrl: "http://tab2.com/favicon.ico" }] chrome.tabs = { query: (args, onComplete) => onComplete(@tabs) } @completer = new TabCompleter() @@ -260,10 +260,6 @@ context "suggestions", expected = "ninjawords" assert.isTrue suggestion.generateHtml().indexOf(expected) >= 0 - should "shorten urls", -> - 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") diff --git a/tests/unit_tests/test_chrome_stubs.coffee b/tests/unit_tests/test_chrome_stubs.coffee index 2abd26c98..e329d4a93 100644 --- a/tests/unit_tests/test_chrome_stubs.coffee +++ b/tests/unit_tests/test_chrome_stubs.coffee @@ -91,3 +91,9 @@ exports.chrome = callback() if callback # Now, generate (supposedly asynchronous) notification for listeners. global.chrome.storage.onChanged.callEmpty(key) + +class XMLHttpRequest + open: -> true + send: -> true + +exports.XMLHttpRequest = XMLHttpRequest diff --git a/tests/unit_tests/utils_test.coffee b/tests/unit_tests/utils_test.coffee index 76a023edd..a001f0256 100644 --- a/tests/unit_tests/utils_test.coffee +++ b/tests/unit_tests/utils_test.coffee @@ -60,3 +60,37 @@ context "compare versions", assert.equal -1, Utils.compareVersions("1.40.1", "1.40.2") assert.equal -1, Utils.compareVersions("1.40.1", "1.41") assert.equal 1, Utils.compareVersions("1.41", "1.40") + +context "SimpleCache", + setup -> + @cache = new SimpleCache() + @cache.set "a", 1 + @cache.set "b", 2 + + should "cache values", -> + assert.equal 1, @cache.get "a" + assert.equal 2, @cache.get "b" + + should "return value from set", -> + assert.equal @cache.set("z", 123), 123 + + should "keep cached values when rotated once", -> + assert.equal 1, @cache.get "a" + assert.equal 2, @cache.get "b" + @cache.rotate() + assert.equal 1, @cache.get "a" + assert.equal 2, @cache.get "b" + + should "keep cached values when rotated twice and entries are re-used", -> + @cache.rotate() + assert.equal 1, @cache.get "a" + assert.equal 2, @cache.get "b" + @cache.rotate() + assert.equal 1, @cache.get "a" + assert.equal 2, @cache.get "b" + + should "not keep cached values when rotated twice and entries not are re-used", -> + @cache.rotate() + @cache.rotate() + assert.isFalse @cache.get "a" + assert.isFalse @cache.get "b"