diff options
Diffstat (limited to 'background_scripts')
| -rw-r--r-- | background_scripts/commands.coffee | 55 | ||||
| -rw-r--r-- | background_scripts/completion.coffee | 140 | ||||
| -rw-r--r-- | background_scripts/exclusions.coffee | 70 | ||||
| -rw-r--r-- | background_scripts/main.coffee | 182 | ||||
| -rw-r--r-- | background_scripts/settings.coffee | 68 | ||||
| -rw-r--r-- | background_scripts/sync.coffee | 102 |
6 files changed, 506 insertions, 111 deletions
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 |
