diff options
| -rw-r--r-- | CONTRIBUTING.md | 32 | ||||
| -rw-r--r-- | background_scripts/commands.coffee | 172 | ||||
| -rw-r--r-- | background_scripts/completion.coffee | 11 | ||||
| -rw-r--r-- | background_scripts/exclusions.coffee | 70 | ||||
| -rw-r--r-- | background_scripts/main.coffee | 175 | ||||
| -rw-r--r-- | background_scripts/settings.coffee | 17 | ||||
| -rw-r--r-- | content_scripts/link_hints.coffee | 12 | ||||
| -rw-r--r-- | content_scripts/vimium_frontend.coffee | 36 | ||||
| -rw-r--r-- | content_scripts/vomnibar.coffee | 11 | ||||
| -rw-r--r-- | manifest.json | 2 | ||||
| -rw-r--r-- | pages/options.coffee | 264 | ||||
| -rw-r--r-- | pages/options.html | 78 | ||||
| -rw-r--r-- | pages/popup.coffee | 93 | ||||
| -rw-r--r-- | pages/popup.html | 22 | ||||
| -rw-r--r-- | tests/unit_tests/commands_test.coffee | 45 | ||||
| -rw-r--r-- | tests/unit_tests/completion_test.coffee | 2 | ||||
| -rw-r--r-- | tests/unit_tests/exclusion_test.coffee | 38 |
17 files changed, 751 insertions, 329 deletions
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03ac26e9..9382a020 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -59,12 +59,44 @@ reports: * We follow two major differences from this style guide: * Wrap lines at 110 characters instead of 80. * Use double-quoted strings by default. + * When writing comments, uppercase the first letter of your sentence, and put a period at the end. + * If you have a short conditional, feel free to put it on one line: + + # No + if i < 10 + return + + # Yes + return if i < 10 ## Pull Requests When you're done with your changes, send us a pull request on Github. Feel free to include a change to the CREDITS file with your patch. +## What makes for a good feature request/contribution to Vimium? + +Good features: + +* Useful for lots of Vimium users +* Require no/little documentation +* Useful without configuration +* Intuitive or leverage strong convention from Vim +* Work robustly on most/all sites + +Less-good features: + +* Are very niche, and so aren't useful for many Vimium users +* Require explanation +* Require configuration before it becomes useful +* Unintuitive, or they don't leverage a strong convention from Vim +* Might be flaky and don't work in many cases + +We use these guidelines, in addition to the code complexity, when deciding whether to merge in a pull request. + +If you're worried that a feature you plan to build won't be a good fit for core Vimium, just open a github +issue for discussion or send an email to the Vimium mailing list. + ## How to release Vimium to the Chrome Store This process is currently only done by Phil or Ilya. diff --git a/background_scripts/commands.coffee b/background_scripts/commands.coffee index fe063066..10fa323b 100644 --- a/background_scripts/commands.coffee +++ b/background_scripts/commands.coffee @@ -22,17 +22,21 @@ Commands = isBackgroundCommand: options.background passCountToFunction: options.passCountToFunction noRepeat: options.noRepeat + repeatLimit: options.repeatLimit mapKeyToCommand: (key, command) -> unless @availableCommands[command] console.log(command, "doesn't exist!") return + commandDetails = @availableCommands[command] + @keyToCommandRegistry[key] = command: command - isBackgroundCommand: @availableCommands[command].isBackgroundCommand - passCountToFunction: @availableCommands[command].passCountToFunction - noRepeat: @availableCommands[command].noRepeat + isBackgroundCommand: commandDetails.isBackgroundCommand + passCountToFunction: commandDetails.passCountToFunction + noRepeat: commandDetails.noRepeat + repeatLimit: commandDetails.repeatLimit unmapKey: (key) -> delete @keyToCommandRegistry[key] @@ -86,22 +90,64 @@ 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.activateModeToOpenInNewForegroundTab", "LinkHints.activateModeWithQueue", "Vomnibar.activate", - "Vomnibar.activateInNewTab", "Vomnibar.activateTabSelection", "Vomnibar.activateBookmarks", - "Vomnibar.activateBookmarksInNewTab", "goPrevious", "goNext", "nextFrame", "Marks.activateCreateMode", + ["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", + "LinkHints.activateModeToDownloadLink", + "LinkHints.activateModeToOpenIncognito", + "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", "togglePinTab", "closeTabsOnLeft","closeTabsOnRight", - "closeOtherTabs", "moveTabLeft", "moveTabRight"] + ["nextTab", + "previousTab", + "firstTab", + "lastTab", + "createTab", + "duplicateTab", + "removeTab", + "restoreTab", + "moveTabToNewWindow", + "togglePinTab", + "closeTabsOnLeft","closeTabsOnRight", + "closeOtherTabs", + "moveTabLeft", + "moveTabRight"] misc: ["showHelp"] @@ -109,11 +155,26 @@ Commands = # a focused, high-signal set of commands to the new and casual user. Only those truly hungry for more power # from Vimium will uncover these gems. advancedCommands: [ - "scrollToLeft", "scrollToRight", "moveTabToNewWindow", - "goUp", "goToRoot", "focusInput", "LinkHints.activateModeWithQueue", - "LinkHints.activateModeToOpenIncognito", "goNext", "goPrevious", "Marks.activateCreateMode", - "Marks.activateGotoMode", "moveTabLeft", "moveTabRight", - "closeTabsOnLeft","closeTabsOnRight", "closeOtherTabs"] + "scrollToLeft", + "scrollToRight", + "moveTabToNewWindow", + "goUp", + "goToRoot", + "focusInput", + "LinkHints.activateModeWithQueue", + "LinkHints.activateModeToDownloadLink", + "Vomnibar.activateEditUrl", + "Vomnibar.activateEditUrlInNewTab", + "LinkHints.activateModeToOpenIncognito", + "goNext", + "goPrevious", + "Marks.activateCreateMode", + "Marks.activateGotoMode", + "moveTabLeft", + "moveTabRight", + "closeTabsOnLeft", + "closeTabsOnRight", + "closeOtherTabs"] defaultKeyMappings = "?": "showHelp" @@ -146,6 +207,8 @@ defaultKeyMappings = "F": "LinkHints.activateModeToOpenInNewTab" "<a-f>": "LinkHints.activateModeWithQueue" + "af": "LinkHints.activateModeToDownloadLink" + "/": "enterFindMode" "n": "performFind" "N": "performBackwardsFind" @@ -184,6 +247,9 @@ defaultKeyMappings = "b": "Vomnibar.activateBookmarks" "B": "Vomnibar.activateBookmarksInNewTab" + "ge": "Vomnibar.activateEditUrl" + "gE": "Vomnibar.activateEditUrlInNewTab" + "gf": "nextFrame" "m": "Marks.activateCreateMode" @@ -191,6 +257,7 @@ defaultKeyMappings = # This is a mapping of: commandIdentifier => [description, options]. +# If the noRepeat and repeatLimit options are both specified, then noRepeat takes precedence. commandDescriptions = # Navigating the current page showHelp: ["Show help", { background: true }] @@ -198,42 +265,43 @@ commandDescriptions = scrollUp: ["Scroll up"] scrollLeft: ["Scroll left"] scrollRight: ["Scroll right"] - scrollToTop: ["Scroll to the top of the page"] - scrollToBottom: ["Scroll to the bottom of the page"] - scrollToLeft: ["Scroll all the way to the left"] - scrollToRight: ["Scroll all the way to the right"] + scrollToTop: ["Scroll to the top of the page", { noRepeat: true }] + scrollToBottom: ["Scroll to the bottom of the page", { noRepeat: true }] + scrollToLeft: ["Scroll all the way to the left", { noRepeat: true }] + scrollToRight: ["Scroll all the way to the right", { noRepeat: true }] + scrollPageDown: ["Scroll a page down"] scrollPageUp: ["Scroll a page up"] scrollFullPageDown: ["Scroll a full page down"] scrollFullPageUp: ["Scroll a full page up"] - reload: ["Reload the page"] - toggleViewSource: ["View page source"] + reload: ["Reload the page", { noRepeat: true }] + toggleViewSource: ["View page source", { noRepeat: true }] - copyCurrentUrl: ["Copy the current URL to the clipboard"] - 'LinkHints.activateModeToCopyLinkUrl': ["Copy a link URL to the clipboard"] + copyCurrentUrl: ["Copy the current URL to the clipboard", { noRepeat: true }] + "LinkHints.activateModeToCopyLinkUrl": ["Copy a link URL to the clipboard", { noRepeat: true }] openCopiedUrlInCurrentTab: ["Open the clipboard's URL in the current tab", { background: true }] - openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { background: true }] + openCopiedUrlInNewTab: ["Open the clipboard's URL in a new tab", { background: true, repeatLimit: 20 }] - enterInsertMode: ["Enter insert mode"] + enterInsertMode: ["Enter insert mode", { noRepeat: 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.activateModeToOpenInNewForegroundTab": ["Open a link in a new tab & switch to it"] - "LinkHints.activateModeWithQueue": ["Open multiple links in a new tab"] + "LinkHints.activateMode": ["Open a link in the current tab", { noRepeat: true }] + "LinkHints.activateModeToOpenInNewTab": ["Open a link in a new tab", { noRepeat: true }] + "LinkHints.activateModeToOpenInNewForegroundTab": ["Open a link in a new tab & switch to it", { noRepeat: true }] + "LinkHints.activateModeWithQueue": ["Open multiple links in a new tab", { noRepeat: true }] + "LinkHints.activateModeToOpenIncognito": ["Open a link in incognito window", { noRepeat: true }] + "LinkHints.activateModeToDownloadLink": ["Download link url", { noRepeat: true }] - "LinkHints.activateModeToOpenIncognito": ["Open a link in incognito window"] - - enterFindMode: ["Enter find mode"] + enterFindMode: ["Enter find mode", { noRepeat: true }] performFind: ["Cycle forward to the next find match"] performBackwardsFind: ["Cycle backward to the previous find match"] - goPrevious: ["Follow the link labeled previous or <"] - goNext: ["Follow the link labeled next or >"] + goPrevious: ["Follow the link labeled previous or <", { noRepeat: true }] + goNext: ["Follow the link labeled next or >", { noRepeat: true }] # Navigating your history goBack: ["Go back in history", { passCountToFunction: true }] @@ -248,10 +316,14 @@ commandDescriptions = previousTab: ["Go one tab left", { background: true }] firstTab: ["Go to the first tab", { background: true }] lastTab: ["Go to the last tab", { background: true }] - createTab: ["Create new tab", { background: true }] - duplicateTab: ["Duplicate current tab", { background: true }] - removeTab: ["Close current tab", { background: true, noRepeat: true }] - restoreTab: ["Restore closed tab", { background: true }] + + createTab: ["Create new tab", { background: true, repeatLimit: 20 }] + duplicateTab: ["Duplicate current tab", { background: true, repeatLimit: 20 }] + removeTab: ["Close current tab", { background: true, repeatLimit: + # Require confirmation to remove more tabs than we can restore. + (if chrome.session then chrome.session.MAX_SESSION_RESULTS else 25) }] + restoreTab: ["Restore closed tab", { background: true, repeatLimit: 20 }] + moveTabToNewWindow: ["Move tab to new window", { background: true }] togglePinTab: ["Pin/unpin current tab", { background: true }] @@ -262,16 +334,18 @@ commandDescriptions = 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"] - "Vomnibar.activateTabSelection": ["Search through your open tabs"] - "Vomnibar.activateBookmarks": ["Open a bookmark"] - "Vomnibar.activateBookmarksInNewTab": ["Open a bookmark in a new tab"] + "Vomnibar.activate": ["Open URL, bookmark, or history entry", { noRepeat: true }] + "Vomnibar.activateInNewTab": ["Open URL, bookmark, history entry, in a new tab", { noRepeat: true }] + "Vomnibar.activateTabSelection": ["Search through your open tabs", { noRepeat: true }] + "Vomnibar.activateBookmarks": ["Open a bookmark", { noRepeat: true }] + "Vomnibar.activateBookmarksInNewTab": ["Open a bookmark in a new tab", { noRepeat: true }] + "Vomnibar.activateEditUrl": ["Edit the current URL", { noRepeat: true }] + "Vomnibar.activateEditUrlInNewTab": ["Edit the current URL and open in a new tab", { noRepeat: true }] nextFrame: ["Cycle forward to the next frame on the page", { background: true, passCountToFunction: true }] - "Marks.activateCreateMode": ["Create a new mark"] - "Marks.activateGotoMode": ["Go to a mark"] + "Marks.activateCreateMode": ["Create a new mark", { noRepeat: true }] + "Marks.activateGotoMode": ["Go to a mark", { noRepeat: true }] Commands.init() diff --git a/background_scripts/completion.coffee b/background_scripts/completion.coffee index b0ab4b88..23696185 100644 --- a/background_scripts/completion.coffee +++ b/background_scripts/completion.coffee @@ -26,6 +26,7 @@ class Suggestion generateHtml: -> return @html if @html + favIconUrl = @tabFavIconUrl or "#{@getUrlRoot(@url)}/favicon.ico" relevancyHtml = if @showRelevancy then "<span class='relevancy'>#{@computeRelevancy()}</span>" else "" # NOTE(philc): We're using these vimium-specific class names so we don't collide with the page's CSS. @html = @@ -34,12 +35,19 @@ class Suggestion <span class="vimiumReset vomnibarSource">#{@type}</span> <span class="vimiumReset vomnibarTitle">#{@highlightTerms(Utils.escapeHtml(@title))}</span> </div> - <div class="vimiumReset vomnibarBottomHalf"> + <div class="vimiumReset vomnibarBottomHalf vomnibarIcon" + style="background-image: url(#{favIconUrl});"> <span class="vimiumReset vomnibarUrl">#{@shortenUrl(@highlightTerms(Utils.escapeHtml(@url)))}</span> #{relevancyHtml} </div> """ + # 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 + shortenUrl: (url) -> @stripTrailingSlash(url).replace(/^https?:\/\//, "") stripTrailingSlash: (url) -> @@ -261,6 +269,7 @@ 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/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 03d6143d..0f2c6d85 100644 --- a/background_scripts/main.coffee +++ b/background_scripts/main.coffee @@ -73,57 +73,27 @@ getCurrentTabUrl = (request, sender) -> sender.tab.url # whether any keys should be passed through to the underlying page. # root.isEnabledForUrl = isEnabledForUrl = (request) -> - # Excluded URLs are stored as a series of URL expressions and optional passKeys, separated by newlines. - # Lines for which the first non-blank character is "#" or '"' are comments. - excludedLines = (line.trim() for line in Settings.get("excludedUrls").split("\n")) - excludedSpecs = (line.split(/\s+/) for line in excludedLines when line and line.indexOf("#") != 0 and line.indexOf('"') != 0) - for spec in excludedSpecs - url = spec[0] - # The user can add "*" to the URL which means ".*" - regexp = new RegExp("^" + url.replace(/\*/g, ".*") + "$") - if request.url.match(regexp) - passKeys = spec[1..].join("") - if passKeys - # Enabled, but not for these keys. - return { isEnabledForUrl: true, passKeys: passKeys, matchingUrl: url } - # Wholly disabled. - return { isEnabledForUrl: false, passKeys: "", matchingUrl: url } - # Enabled (the default). - { isEnabledForUrl: true, passKeys: undefined, matchingUrl: undefined } - -# Called by the popup UI. Strips leading/trailing whitespace and ignores new empty strings. If an existing -# exclusion rule has been changed, then the existing rule is updated. Otherwise, the new rule is added. -root.addExcludedUrl = (url) -> - return unless url = url.trim() - - parse = url.split(/\s+/) - url = parse[0] - passKeys = parse[1..].join(" ") - newSpec = (if passKeys then url + " " + passKeys else url) - - excludedUrls = Settings.get("excludedUrls").split("\n") - excludedUrls.push(newSpec) - - # Update excludedUrls. - # Try to keep the list as unchanged as possible: same order, same comments, same blank lines. - seenNew = false - newExcludedUrls = [] - for spec in excludedUrls - spec = spec.trim() - parse = spec.split(/\s+/) - # Keep just one copy of the new exclusion rule. - if parse.length and parse[0] == url - if !seenNew - newExcludedUrls.push(newSpec) - seenNew = true - continue - # And just keep everything else. - newExcludedUrls.push(spec) - - Settings.set("excludedUrls", newExcludedUrls.join("\n")) - - chrome.tabs.query({ windowId: chrome.windows.WINDOW_ID_CURRENT, active: true }, - (tabs) -> updateActiveState(tabs[0].id)) + 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) @@ -274,13 +244,15 @@ BackgroundCommands = previousTab: (callback) -> selectTab(callback, "previous") firstTab: (callback) -> selectTab(callback, "first") lastTab: (callback) -> selectTab(callback, "last") - removeTab: -> + removeTab: (callback) -> chrome.tabs.getSelected(null, (tab) -> - chrome.tabs.remove(tab.id)) + chrome.tabs.remove(tab.id) + selectionChangedHandlers.push(callback)) restoreTab: (callback) -> # TODO: remove if-else -block when adopted into stable if chrome.sessions - chrome.sessions.restore(null, (restoredSession) -> callback()) + chrome.sessions.restore(null, (restoredSession) -> + callback() unless chrome.runtime.lastError) else # TODO(ilya): Should this be getLastFocused instead? chrome.windows.getCurrent((window) -> @@ -389,24 +361,21 @@ updateActiveState = (tabId) -> partialIcon = "icons/browser_action_partial.png" chrome.tabs.get tabId, (tab) -> chrome.tabs.sendMessage tabId, { name: "getActiveState" }, (response) -> - console.log response if response isCurrentlyEnabled = response.enabled currentPasskeys = response.passKeys - # TODO: - # isEnabledForUrl is quite expensive to run each time we change tab. Perhaps memoize it? - shouldHaveConfig = isEnabledForUrl({url: tab.url}) - shouldBeEnabled = shouldHaveConfig.isEnabledForUrl - shouldHavePassKeys = shouldHaveConfig.passKeys - if (shouldBeEnabled and shouldHavePassKeys) + config = isEnabledForUrl({url: tab.url}) + enabled = config.isEnabledForUrl + passKeys = config.passKeys + if (enabled and passKeys) setBrowserActionIcon(tabId,partialIcon) - else if (shouldBeEnabled) + else if (enabled) setBrowserActionIcon(tabId,enabledIcon) else setBrowserActionIcon(tabId,disabledIcon) # Propagate the new state only if it has changed. - if (isCurrentlyEnabled != shouldBeEnabled || currentPasskeys != shouldHavePassKeys) - chrome.tabs.sendMessage(tabId, { name: "setState", enabled: shouldBeEnabled, passKeys: shouldHavePassKeys }) + if (isCurrentlyEnabled != enabled || currentPasskeys != passKeys) + chrome.tabs.sendMessage(tabId, { name: "setState", enabled: enabled, passKeys: passKeys }) else # We didn't get a response from the front end, so Vimium isn't running. setBrowserActionIcon(tabId,disabledIcon) @@ -420,12 +389,14 @@ updateScrollPosition = (tab, scrollX, scrollY) -> chrome.tabs.onUpdated.addListener (tabId, changeInfo, tab) -> return unless changeInfo.status == "loading" # only do this once per URL change - chrome.tabs.insertCSS tabId, + cssConf = allFrames: true code: Settings.get("userDefinedLinkHintCss") runAt: "document_start" - updateOpenTabs(tab) - updateActiveState(tabId) + chrome.tabs.insertCSS tabId, cssConf, -> + if not chrome.runtime.lastError + updateOpenTabs(tab) + updateActiveState(tabId) chrome.tabs.onAttached.addListener (tabId, attachedInfo) -> # We should update all the tabs in the old window and the new window. @@ -443,16 +414,17 @@ chrome.tabs.onRemoved.addListener (tabId) -> # If we restore pages that content scripts can't run on, they'll ignore Vimium keystrokes when they # reappear. Pretend they never existed and adjust tab indices accordingly. Could possibly expand this into # a blacklist in the future. - if (/^(chrome|view-source:)[^:]*:\/\/.*/.test(openTabInfo.url)) - for i of tabQueue[openTabInfo.windowId] - if (tabQueue[openTabInfo.windowId][i].positionIndex > openTabInfo.positionIndex) - tabQueue[openTabInfo.windowId][i].positionIndex-- - return - - if (tabQueue[openTabInfo.windowId]) - tabQueue[openTabInfo.windowId].push(openTabInfo) - else - tabQueue[openTabInfo.windowId] = [openTabInfo] + unless chrome.sessions + if (/^(chrome|view-source:)[^:]*:\/\/.*/.test(openTabInfo.url)) + for i of tabQueue[openTabInfo.windowId] + if (tabQueue[openTabInfo.windowId][i].positionIndex > openTabInfo.positionIndex) + tabQueue[openTabInfo.windowId][i].positionIndex-- + return + + if (tabQueue[openTabInfo.windowId]) + tabQueue[openTabInfo.windowId].push(openTabInfo) + else + tabQueue[openTabInfo.windowId] = [openTabInfo] # keep the reference around for a while to wait for the last messages from the closed tab (e.g. for updating # scroll position) @@ -462,7 +434,8 @@ chrome.tabs.onRemoved.addListener (tabId) -> chrome.tabs.onActiveChanged.addListener (tabId, selectInfo) -> updateActiveState(tabId) -chrome.windows.onRemoved.addListener (windowId) -> delete tabQueue[windowId] +unless chrome.sessions + chrome.windows.onRemoved.addListener (windowId) -> delete tabQueue[windowId] # End action functions @@ -558,23 +531,35 @@ checkKeyQueue = (keysToCheck, tabId, frameId) -> if (Commands.keyToCommandRegistry[command]) registryEntry = Commands.keyToCommandRegistry[command] - - if !registryEntry.isBackgroundCommand - chrome.tabs.sendMessage(tabId, - name: "executePageCommand", - command: registryEntry.command, - frameId: frameId, - count: count, - passCountToFunction: registryEntry.passCountToFunction, - completionKeys: generateCompletionKeys("")) - refreshedCompletionKeys = true - else - if registryEntry.passCountToFunction - BackgroundCommands[registryEntry.command](count) - else if registryEntry.noRepeat - BackgroundCommands[registryEntry.command]() + runCommand = true + + if registryEntry.noRepeat + count = 1 + else if registryEntry.repeatLimit and count > registryEntry.repeatLimit + runCommand = confirm """ + You have asked Vimium to perform #{count} repeats of the command: + #{Commands.availableCommands[registryEntry.command].description} + + Are you sure you want to continue? + """ + + if runCommand + if not registryEntry.isBackgroundCommand + chrome.tabs.sendMessage(tabId, + name: "executePageCommand", + command: registryEntry.command, + frameId: frameId, + count: count, + passCountToFunction: registryEntry.passCountToFunction, + completionKeys: generateCompletionKeys("")) + refreshedCompletionKeys = true else - repeatFunction(BackgroundCommands[registryEntry.command], count, 0, frameId) + if registryEntry.passCountToFunction + BackgroundCommands[registryEntry.command](count) + else if registryEntry.noRepeat + BackgroundCommands[registryEntry.command]() + else + repeatFunction(BackgroundCommands[registryEntry.command], count, 0, frameId) newKeyQueue = "" else if (getActualKeyStrokeLength(command) > 1) @@ -626,7 +611,7 @@ registerFrame = (request, sender) -> focusedFrame = request.frameId framesForTab[sender.tab.id].total = request.total - framesForTab[sender.tab.id].frames.push({ id: request.frameId, area: request.area }) + framesForTab[sender.tab.id].frames.push({ id: request.frameId }) handleFrameFocused = (request, sender) -> focusedFrame = request.frameId diff --git a/background_scripts/settings.coffee b/background_scripts/settings.coffee index 34d6e879..7150fcba 100644 --- a/background_scripts/settings.coffee +++ b/background_scripts/settings.coffee @@ -35,6 +35,9 @@ root.Settings = Settings = searchEngines: (value) -> root.Settings.parseSearchEngines value + exclusionRules: (value) -> + root.Exclusions.postUpdateHook value + # postUpdateHooks convenience wrapper performPostUpdateHook: (key, value) -> @postUpdateHooks[key] value if @postUpdateHooks[key] @@ -58,6 +61,7 @@ root.Settings = Settings = # or strings defaults: scrollStepSize: 60 + keyMappings: "# Insert your prefered key mappings here." linkHintCharacters: "sadfjklewcmpgh" linkHintNumbers: "0123456789" filterLinkHints: false @@ -81,14 +85,13 @@ root.Settings = Settings = div > .vimiumHintMarker > .matchingCharacter { } """ - excludedUrls: - """ - # Disable Vimium on Gmail: - http*://mail.google.com/* + # Default exclusion rules. + exclusionRules: + [ + # Disable Vimium on Gmail. + { pattern: "http*://mail.google.com/*", passKeys: "" } + ] - # Use Facebook's own j/k bindings: - http*://www.facebook.com/* jk - """ # 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 diff --git a/content_scripts/link_hints.coffee b/content_scripts/link_hints.coffee index 24314b26..24bd7126 100644 --- a/content_scripts/link_hints.coffee +++ b/content_scripts/link_hints.coffee @@ -14,6 +14,7 @@ OPEN_IN_NEW_FG_TAB = {} OPEN_WITH_QUEUE = {} COPY_LINK_URL = {} OPEN_INCOGNITO = {} +DOWNLOAD_LINK_URL = {} LinkHints = hintMarkerContainingDiv: null @@ -52,6 +53,7 @@ LinkHints = activateModeToCopyLinkUrl: -> @activateMode(COPY_LINK_URL) activateModeWithQueue: -> @activateMode(OPEN_WITH_QUEUE) activateModeToOpenIncognito: -> @activateMode(OPEN_INCOGNITO) + activateModeToDownloadLink: -> @activateMode(DOWNLOAD_LINK_URL) activateMode: (mode = OPEN_IN_CURRENT_TAB) -> # we need documentElement to be ready in order to append links @@ -93,7 +95,8 @@ LinkHints = DomUtils.simulateClick(link, { shiftKey: @mode is OPEN_IN_NEW_FG_TAB, metaKey: KeyboardUtils.platform == "Mac", - ctrlKey: KeyboardUtils.platform != "Mac" }) + ctrlKey: KeyboardUtils.platform != "Mac", + altKey: false}) else if @mode is COPY_LINK_URL HUD.show("Copy link URL to Clipboard") @linkActivator = (link) -> @@ -105,6 +108,13 @@ LinkHints = chrome.runtime.sendMessage( handler: 'openUrlInIncognito' url: link.href) + else if @mode is DOWNLOAD_LINK_URL + HUD.show("Download link URL") + @linkActivator = (link) -> + DomUtils.simulateClick(link, { + altKey: true, + ctrlKey: false, + metaKey: false }) else # OPEN_IN_CURRENT_TAB HUD.show("Open link in current tab") @linkActivator = (link) -> DomUtils.simulateClick.bind(DomUtils, link)() diff --git a/content_scripts/vimium_frontend.coffee b/content_scripts/vimium_frontend.coffee index 0a835de9..81427c1a 100644 --- a/content_scripts/vimium_frontend.coffee +++ b/content_scripts/vimium_frontend.coffee @@ -15,6 +15,8 @@ isShowingHelpDialog = false keyPort = null # Users can disable Vimium on URL patterns via the settings page. The following two variables # (isEnabledForUrl and passKeys) control Vimium's enabled/disabled behaviour. +# "passKeys" are keys which would normally be handled by Vimium, but are disabled on this tab, and therefore +# are passed through to the underlying page. isEnabledForUrl = true passKeys = null keyQueue = null @@ -132,6 +134,7 @@ initializePreDomReady = -> # These requests are delivered to the options page, but there are no handlers there. return if request.handler == "registerFrame" or request.handler == "frameFocused" sendResponse requestHandlers[request.name](request, sender) + # Ensure the sendResponse callback is freed. false # Wrapper to install event listeners. Syntactic sugar. @@ -143,9 +146,9 @@ installListener = (event, callback) -> document.addEventListener(event, callback # listeners, is error prone. It's more difficult to keep track of the state. # installedListeners = false -initializeWhenEnabled = (newPassKeys=undefined) -> +initializeWhenEnabled = (newPassKeys) -> isEnabledForUrl = true - passKeys = passKeys if typeof(newPassKeys) != 'undefined' + passKeys = newPassKeys if (!installedListeners) installListener "keydown", (event) -> if isEnabledForUrl then onKeydown(event) else true installListener "keypress", (event) -> if isEnabledForUrl then onKeypress(event) else true @@ -173,24 +176,19 @@ window.addEventListener "focus", -> # Initialization tasks that must wait for the document to be ready. # initializeOnDomReady = -> - registerFrameIfSizeAvailable(window.top == window.self) + registerFrame(window.top == window.self) enterInsertModeIfElementIsFocused() if isEnabledForUrl # Tell the background page we're in the dom ready state. chrome.runtime.connect({ name: "domReady" }) -# This is a little hacky but sometimes the size wasn't available on domReady? -registerFrameIfSizeAvailable = (is_top) -> - if (innerWidth != undefined && innerWidth != 0 && innerHeight != undefined && innerHeight != 0) - chrome.runtime.sendMessage( - handler: "registerFrame" - frameId: frameId - area: innerWidth * innerHeight - is_top: is_top - total: frames.length + 1) - else - setTimeout((-> registerFrameIfSizeAvailable(is_top)), 100) +registerFrame = (is_top) -> + chrome.runtime.sendMessage( + handler: "registerFrame" + frameId: frameId + is_top: is_top + total: frames.length + 1) # # Enters insert mode if the currently focused element in the DOM is focusable. @@ -330,14 +328,11 @@ extend window, false -# Should this keyChar be passed to the underlying page? +# Decide whether this keyChar should be passed to the underlying page. # Keystrokes are *never* considered passKeys if the keyQueue is not empty. So, for example, if 't' is a # passKey, then 'gt' and '99t' will neverthless be handled by vimium. -# TODO: This currently only works for unmodified keys (so not for '<c-a>', or the like). It's not clear if -# this is a problem or not. I don't recall coming across a web page with modifier key bindings. Such -# bindings might be too likely to conflict with browsers' native bindings. isPassKey = ( keyChar ) -> - !keyQueue and passKeys and 0 <= passKeys.indexOf keyChar + return !keyQueue and passKeys and 0 <= passKeys.indexOf(keyChar) handledKeydownEvents = [] @@ -367,7 +362,6 @@ onKeypress = (event) -> handleKeyCharForFindMode(keyChar) DomUtils.suppressEvent(event) else if (!isInsertMode() && !findMode) - # Is this keyChar is to be passed to the underlying page? if (isPassKey keyChar) return undefined if (currentCompletionKeys.indexOf(keyChar) != -1) @@ -524,7 +518,7 @@ isFocusable = (element) -> isEditable(element) || isEmbed(element) # Embedded elements like Flash and quicktime players can obtain focus but cannot be programmatically # unfocused. # -isEmbed = (element) -> ["embed", "object"].indexOf(element.nodeName.toLowerCase()) > 0 +isEmbed = (element) -> ["embed", "object"].indexOf(element.nodeName.toLowerCase()) >= 0 # # Input or text elements are considered focusable and able to receieve their own keyboard events, diff --git a/content_scripts/vomnibar.coffee b/content_scripts/vomnibar.coffee index 7b3cfdbe..10f75652 100644 --- a/content_scripts/vomnibar.coffee +++ b/content_scripts/vomnibar.coffee @@ -23,6 +23,17 @@ Vomnibar = selectFirst: true newTab: true } + activateEditUrl: -> @open { + completer: "omni" + selectFirst: false + query: window.location.href + } + activateEditUrlInNewTab: -> @open { + completer: "omni" + selectFirst: false + query: window.location.href + newTab: true + } # This function opens the vomnibar. It accepts options, a map with the values: # completer - The completer to fetch results from. diff --git a/manifest.json b/manifest.json index 84b91f0e..d52ac8f6 100644 --- a/manifest.json +++ b/manifest.json @@ -13,6 +13,7 @@ "lib/clipboard.js", "background_scripts/sync.js", "background_scripts/settings.js", + "background_scripts/exclusions.js", "background_scripts/completion.js", "background_scripts/marks.js", "background_scripts/main.js" @@ -25,6 +26,7 @@ "history", "clipboardRead", "storage", + "sessions", "<all_urls>" ], "content_scripts": [ diff --git a/pages/options.coffee b/pages/options.coffee index d4767da6..7f374f5d 100644 --- a/pages/options.coffee +++ b/pages/options.coffee @@ -1,36 +1,148 @@ -$ = (id) -> document.getElementById id +$ = (id) -> document.getElementById id bgSettings = chrome.extension.getBackgroundPage().Settings -editableFields = [ "scrollStepSize", "excludedUrls", "linkHintCharacters", "linkHintNumbers", - "userDefinedLinkHintCss", "keyMappings", "filterLinkHints", "previousPatterns", - "nextPatterns", "hideHud", "regexFindMode", "searchUrl", "searchEngines"] - -canBeEmptyFields = ["excludedUrls", "keyMappings", "userDefinedLinkHintCss", "searchEngines"] - -document.addEventListener "DOMContentLoaded", -> - populateOptions() - - for field in editableFields - $(field).addEventListener "keyup", onOptionKeyup, false - $(field).addEventListener "change", enableSaveButton, false - $(field).addEventListener "change", onDataLoaded, false - - $("advancedOptionsLink").addEventListener "click", toggleAdvancedOptions, false - $("showCommands").addEventListener "click", (-> - showHelpDialog chrome.extension.getBackgroundPage().helpDialogHtml(true, true, "Command Listing"), frameId - ), false - document.getElementById("restoreSettings").addEventListener "click", restoreToDefaults - document.getElementById("saveOptions").addEventListener "click", saveOptions +# +# Class hierarchy for various types of option. +class Option + # Base class for all option classes. + # Abstract. Option does not define @populateElement or @readValueFromElement. + + # Static. Array of all options. + @all = [] + + constructor: (field,enableSaveButton) -> + @field = field + @element = $(@field) + @element.addEventListener "change", enableSaveButton + @fetch() + Option.all.push @ + + # Fetch a setting from localStorage, remember the @previous value and populate the DOM element. + # Return the fetched value. + fetch: -> + @populateElement @previous = bgSettings.get @field + @previous + + # Write this option's new value back to localStorage, if necessary. + save: -> + value = @readValueFromElement() + if not @areEqual value, @previous + bgSettings.set @field, @previous = value + bgSettings.performPostUpdateHook @field, value + + # Compare values; this is overridden by sub-classes. + areEqual: (a,b) -> a == b + + restoreToDefault: -> + bgSettings.clear @field + @fetch() + + # Abstract method; only implemented in sub-classes. + # Populate the option's DOM element (@element) with the setting's current value. + # populateElement: (value) -> DO_SOMETHING + + # Abstract method; only implemented in sub-classes. + # Extract the setting's new value from the option's DOM element (@element). + # readValueFromElement: -> RETURN_SOMETHING + +class NumberOption extends Option + populateElement: (value) -> @element.value = value + readValueFromElement: -> parseFloat @element.value + +class TextOption extends Option + populateElement: (value) -> @element.value = value + readValueFromElement: -> @element.value.trim() + +class NonEmptyTextOption extends Option + populateElement: (value) -> @element.value = value + # If the new value is not empty, then return it. Otherwise, restore the default value. + readValueFromElement: -> if value = @element.value.trim() then value else @restoreToDefault() + +class CheckBoxOption extends Option + populateElement: (value) -> @element.checked = value + readValueFromElement: -> @element.checked + +class ExclusionRulesOption extends Option + constructor: (args...) -> + super(args...) + $("exclusionAddButton").addEventListener "click", (event) => + @appendRule { pattern: "", passKeys: "" } + @maintainExclusionMargin() + # Focus the pattern element in the new rule. + @element.children[@element.children.length-1].children[0].children[0].focus() + # Scroll the new rule into view. + exclusionScrollBox = $("exclusionScrollBox") + exclusionScrollBox.scrollTop = exclusionScrollBox.scrollHeight + + populateElement: (rules) -> + while @element.firstChild + @element.removeChild @element.firstChild + for rule in rules + @appendRule rule + @maintainExclusionMargin() + + # Append a row for a new rule. + appendRule: (rule) -> + content = document.querySelector('#exclusionRuleTemplate').content + row = document.importNode content, true + + for field in ["pattern", "passKeys"] + element = row.querySelector ".#{field}" + element.value = rule[field] + for event in [ "keyup", "change" ] + element.addEventListener event, enableSaveButton + + remove = row.querySelector ".exclusionRemoveButton" + remove.addEventListener "click", (event) => + row = event.target.parentNode.parentNode + row.parentNode.removeChild row + enableSaveButton() + @maintainExclusionMargin() + + @element.appendChild row + + readValueFromElement: -> + rules = + for element in @element.children + pattern = element.children[0].firstChild.value.trim() + passKeys = element.children[1].firstChild.value.trim() + { pattern: pattern, passKeys: passKeys } + rules.filter (rule) -> rule.pattern + + areEqual: (a,b) -> + # Flatten each list of rules to a newline-separated string representation, and then use string equality. + # This is correct because patterns and passKeys cannot themselves contain newlines. + flatten = (rule) -> if rule and rule.pattern then rule.pattern + "\n" + rule.passKeys else "" + a.map(flatten).join("\n") == b.map(flatten).join("\n") + + # Hack. There has to be a better way than... + # The y-axis scrollbar for "exclusionRules" is only displayed if it is needed. When visible, it appears on + # top of the enclosed content (partially obscuring it). Here, we adjust the margin of the "Remove" button to + # compensate. + maintainExclusionMargin: -> + scrollBox = $("exclusionScrollBox") + margin = if scrollBox.clientHeight < scrollBox.scrollHeight then "16px" else "0px" + for element in scrollBox.getElementsByClassName "exclusionRemoveButton" + element.style["margin-right"] = margin + +# +# Operations for page elements. +enableSaveButton = -> + $("saveOptions").removeAttribute "disabled" -window.onbeforeunload = -> "You have unsaved changes to options." unless $("saveOptions").disabled +saveOptions = -> + Option.all.map (option) -> option.save() + $("saveOptions").disabled = true -onOptionKeyup = (event) -> - if (event.target.getAttribute("type") isnt "checkbox" and - event.target.getAttribute("savedValue") isnt event.target.value) - enableSaveButton() +restoreToDefaults = -> + return unless confirm "Are you sure you want to permanently return all of Vimium's settings to their defaults?" + Option.all.map (option) -> option.restoreToDefault() + maintainLinkHintsView() + $("saveOptions").disabled = true -onDataLoaded = -> +# Display either "linkHintNumbers" or "linkHintCharacters", depending upon "filterLinkHints". +maintainLinkHintsView = -> hide = (el) -> el.parentNode.parentNode.style.display = "none" show = (el) -> el.parentNode.parentNode.style.display = "table-row" if $("filterLinkHints").checked @@ -40,66 +152,48 @@ onDataLoaded = -> show $("linkHintCharacters") hide $("linkHintNumbers") -enableSaveButton = -> - $("saveOptions").removeAttribute "disabled" - -# Saves options to localStorage. -saveOptions = -> - - # If the value is unchanged from the default, delete the preference from localStorage; this gives us - # the freedom to change the defaults in the future. - for fieldName in editableFields - field = $(fieldName) - switch field.getAttribute("type") - when "checkbox" - fieldValue = field.checked - when "number" - fieldValue = parseFloat field.value +toggleAdvancedOptions = + do (advancedMode=false) -> + (event) -> + if advancedMode + $("advancedOptions").style.display = "none" + $("advancedOptionsLink").innerHTML = "Show advanced options…" else - fieldValue = field.value.trim() - field.value = fieldValue - - # If it's empty and not a field that we allow to be empty, restore to the default value - if not fieldValue and canBeEmptyFields.indexOf(fieldName) is -1 - bgSettings.clear fieldName - fieldValue = bgSettings.get(fieldName) - else - bgSettings.set fieldName, fieldValue - $(fieldName).value = fieldValue - $(fieldName).setAttribute "savedValue", fieldValue - bgSettings.performPostUpdateHook fieldName, fieldValue + $("advancedOptions").style.display = "table-row-group" + $("advancedOptionsLink").innerHTML = "Hide advanced options" + advancedMode = !advancedMode + event.preventDefault() - $("saveOptions").disabled = true +activateHelpDialog = -> + showHelpDialog chrome.extension.getBackgroundPage().helpDialogHtml(true, true, "Command Listing"), frameId -# Restores select box state to saved value from localStorage. -populateOptions = -> - for field in editableFields - val = bgSettings.get(field) or "" - setFieldValue $(field), val - onDataLoaded() +# +# Initialization. +document.addEventListener "DOMContentLoaded", -> -restoreToDefaults = -> - return unless confirm "Are you sure you want to return Vimium's settings to their defaults?" - - for field in editableFields - val = bgSettings.defaults[field] or "" - setFieldValue $(field), val - onDataLoaded() - enableSaveButton() - -setFieldValue = (field, value) -> - unless field.getAttribute("type") is "checkbox" - field.value = value - field.setAttribute "savedValue", value - else - field.checked = value + # Populate options. The constructor adds each new object to "Option.all". + new type(name,enableSaveButton) for name, type of { + exclusionRules: ExclusionRulesOption + filterLinkHints: CheckBoxOption + hideHud: CheckBoxOption + keyMappings: TextOption + linkHintCharacters: NonEmptyTextOption + linkHintNumbers: NonEmptyTextOption + nextPatterns: NonEmptyTextOption + previousPatterns: NonEmptyTextOption + regexFindMode: CheckBoxOption + scrollStepSize: NumberOption + searchEngines: TextOption + searchUrl: NonEmptyTextOption + userDefinedLinkHintCss: TextOption + } + + $("saveOptions").addEventListener "click", saveOptions + $("restoreSettings").addEventListener "click", restoreToDefaults + $("advancedOptionsLink").addEventListener "click", toggleAdvancedOptions + $("showCommands").addEventListener "click", activateHelpDialog + $("filterLinkHints").addEventListener "click", maintainLinkHintsView + + maintainLinkHintsView() + window.onbeforeunload = -> "You have unsaved changes to options." unless $("saveOptions").disabled -toggleAdvancedOptions = do (advancedMode=false) -> (event) -> - if advancedMode - $("advancedOptions").style.display = "none" - $("advancedOptionsLink").innerHTML = "Show advanced options…" - else - $("advancedOptions").style.display = "table-row-group" - $("advancedOptionsLink").innerHTML = "Hide advanced options" - advancedMode = !advancedMode - event.preventDefault() diff --git a/pages/options.html b/pages/options.html index 07dcab1d..fb904316 100644 --- a/pages/options.html +++ b/pages/options.html @@ -109,11 +109,6 @@ width: 40px; margin-right: 3px; } - textarea#excludedUrls { - margin-top: 5px; - width: 100%; - min-height: 100px; - } textarea#userDefinedLinkHintCss { width: 100%;; min-height: 100px; @@ -178,6 +173,31 @@ padding: 15px 0; border-top: 1px solid #eee; } + /* Ids and classes for rendering exclusionRules */ + #exclusionScrollBox { + overflow: scroll; + overflow-x: hidden; + overflow-y: auto; + height: 225px; + border: 1px solid #bfbfbf; + border-radius: 2px; + color: #444; + } + input.pattern, input.passKeys { + font-family: Consolas, "Liberation Mono", Courier, monospace; + font-size: 14px; + } + .pattern { + width: 250px; + } + .passKeys { + width: 120px; + } + #exclusionAddButton { + float: right; + margin-top: 5px; + margin-right: 0px; + } </style> <link rel="stylesheet" type="text/css" href="../content_scripts/vimium.css" /> @@ -196,31 +216,31 @@ </td> </tr> <tr> - <td colspan="3"> - Excluded URLs and keys<br/> - <div class="help"> - <div class="example"> - <p> - To disable Vimium on a site, use:<br/> - <tt>http*://mail.google.com/*</tt><br/> - This will <i>wholly disable</i> Vimium on Gmail.<br/><br/> - To use Vimium together with a website's own<br/> - key bindings, use:<br/> - <tt>http*://mail.google.com/* jknpc</tt><br/> - This will <i>enable</i> Vimium on Gmail, but pass<br/> - the five listed keys through to Gmail itself.<br/><br/> - One entry per line.<br/> - </p> - </div> + <td>Excluded URLs<br/>and keys</td> + <td> + <div class="help"> + <div class="example"> + <p> + The left column contains URL patterns. Vimium will be wholly or partially disabled for URLs matching these patterns. Patterns are Javascript regular expressions. Additionally, the character "*" matches any zero or more characters. + </p> + <p> + The right column contains keys which Vimium would would normally handle, but should instead be passed through to the underlying web page (for pages matching the corresponding pattern). If empty, then Vimium is wholly disabled. + </p> </div> - <!-- Hack: fix a minimum size for the text area (below) so that it is - not too much smaller than its help text (above). --> - <!-- FIXME: - This text area should really be broken out into an array - of separate inputs. However, the whole options page really - needs a workover, so I'm leaving it like this, for now - (Steve, 23 Aug, 14). --> - <textarea id="excludedUrls" style="min-height:180px"></textarea> + </div> + <div> + <div id="exclusionScrollBox"> + <table id="exclusionRules"></table> + <template id="exclusionRuleTemplate"> + <tr> + <td><input/ type="text" class="pattern" placeholder="URL pattern"></td> + <td><input/ type="text" class="passKeys" placeholder="Exclude keys"></td> + <td><input/ type="button" class="exclusionRemoveButton" value="✖"></td> + </tr> + </template> + </div> + <button id="exclusionAddButton">Add Rule</button> + </div> </td> </tr> <tbody id='advancedOptions'> diff --git a/pages/popup.coffee b/pages/popup.coffee index 41fc17a9..ecf683e5 100644 --- a/pages/popup.coffee +++ b/pages/popup.coffee @@ -1,27 +1,88 @@ + +originalRule = undefined +originalPattern = undefined +originalPassKeys = undefined + onLoad = -> document.getElementById("optionsLink").setAttribute "href", chrome.runtime.getURL("pages/options.html") chrome.tabs.getSelected null, (tab) -> - # Check if we have an existing exclusing rule for this page. isEnabled = chrome.extension.getBackgroundPage().isEnabledForUrl(url: tab.url) - if isEnabled.matchingUrl - console.log isEnabled - # There is an existing rule for this page. - pattern = isEnabled.matchingUrl - if isEnabled.passKeys - pattern += " " + isEnabled.passKeys - document.getElementById("popupInput").value = pattern + # Check if we have an existing exclusing rule for this page. + if isEnabled.rule + originalRule = isEnabled.rule + originalPattern = originalRule.pattern + originalPassKeys = originalRule.passKeys else - # No existing exclusion rule. # The common use case is to disable Vimium at the domain level. # This regexp will match "http://www.example.com/" from "http://www.example.com/path/to/page.html". - domain = tab.url.match(/[^\/]*\/\/[^\/]*\//) or tab.url - document.getElementById("popupInput").value = domain + "*" + domain = (tab.url.match(/[^\/]*\/\/[^\/]*\//) or tab.url) + "*" + originalRule = null + originalPattern = domain + originalPassKeys = "" + document.getElementById("popupPattern").value = originalPattern + document.getElementById("popupPassKeys").value = originalPassKeys + onChange() + +onChange = -> + # As the text in the popup's input elements is changed, update the the popup's buttons accordingly. + # Aditionally, enable and disable those buttons as appropriate. + pattern = document.getElementById("popupPattern").value.trim() + passKeys = document.getElementById("popupPassKeys").value.trim() + popupExclude = document.getElementById("popupExclude") + + document.getElementById("popupRemove").disabled = + not (originalRule and pattern == originalPattern) + + if originalRule and pattern == originalPattern and passKeys == originalPassKeys + popupExclude.disabled = true + popupExclude.value = "Update Rule" + + else if originalRule and pattern == originalPattern + popupExclude.disabled = false + popupExclude.value = "Update Rule" + + else if originalRule + popupExclude.disabled = false + popupExclude.value = "Add Rule" -onExcludeUrl = (e) -> - url = document.getElementById("popupInput").value - chrome.extension.getBackgroundPage().addExcludedUrl url - document.getElementById("excludeConfirm").setAttribute "style", "display: inline-block" + else if pattern + popupExclude.disabled = false + popupExclude.value = "Add Rule" + + else + popupExclude.disabled = true + popupExclude.value = "Add Rule" + +showMessage = do -> + timer = null + + hideConfirmationMessage = -> + document.getElementById("confirmationMessage").setAttribute "style", "display: none" + timer = null + + (message) -> + document.getElementById("confirmationMessage").setAttribute "style", "display: inline-block" + document.getElementById("confirmationMessage").innerHTML = message + clearTimeout(timer) if timer + timer = setTimeout(hideConfirmationMessage,2000) + +addExclusionRule = -> + pattern = document.getElementById("popupPattern").value.trim() + passKeys = document.getElementById("popupPassKeys").value.trim() + chrome.extension.getBackgroundPage().addExclusionRule pattern, passKeys + showMessage("Updated.") + onLoad() + +removeExclusionRule = -> + pattern = document.getElementById("popupPattern").value.trim() + chrome.extension.getBackgroundPage().removeExclusionRule pattern + showMessage("Removed.") + onLoad() document.addEventListener "DOMContentLoaded", -> - document.getElementById("popupButton").addEventListener "click", onExcludeUrl, false + document.getElementById("popupExclude").addEventListener "click", addExclusionRule, false + document.getElementById("popupRemove").addEventListener "click", removeExclusionRule, false + for field in ["popupPattern", "popupPassKeys"] + for event in ["keyup", "change"] + document.getElementById(field).addEventListener event, onChange, false onLoad() diff --git a/pages/popup.html b/pages/popup.html index 89f1f02a..86982eae 100644 --- a/pages/popup.html +++ b/pages/popup.html @@ -6,17 +6,22 @@ padding: 0px; } - #vimiumPopup { width: 500px; } + #vimiumPopup { width: 400px; } #excludeControls { padding: 10px; } - #popupInput { + #popupPattern, #popupPassKeys { + margin: 5px; width: 330px; + /* Match the corresponding font and font size used on the options page. */ + /* TODO (smblott): Match other styles from the options page. */ + font-family: Consolas, "Liberation Mono", Courier, monospace; + font-size: 14px; } - #excludeConfirm { + #confirmationMessage { display: inline-block; width: 18px; height: 13px; @@ -24,7 +29,8 @@ display: none; } - #popupButton { margin-left: 10px; } + #popupRemove { margin: 5px; } + #popupExclude { margin: 5px; } #popupMenu ul { list-style: none; @@ -52,9 +58,11 @@ <body> <div id="vimiumPopup"> <div id="excludeControls"> - <input id="popupInput" type="text" /> - <input id="popupButton" type="button" value="Exclude URL" /> - <span id="excludeConfirm">Saved.</span> + <input id="popupPattern" placeholder="Pattern against which to match URLs..." type="text" /><br/> + <input id="popupPassKeys" placeholder="Only exclude these keys..." type="text" /><br/> + <input id="popupRemove" type="button" value="Remove Rule" /> + <input id="popupExclude" type="button" value="Add or Update Rule" /> + <span id="confirmationMessage">Text is added in popup.coffee.</span> </div> <div id="popupMenu"> diff --git a/tests/unit_tests/commands_test.coffee b/tests/unit_tests/commands_test.coffee index 99e0e444..c10c643b 100644 --- a/tests/unit_tests/commands_test.coffee +++ b/tests/unit_tests/commands_test.coffee @@ -1,3 +1,7 @@ +root.chrome = + session: + MAX_SESSION_RESULTS: 25 + require "./test_helper.js" {Commands} = require "../../background_scripts/commands.js" @@ -8,3 +12,44 @@ context "Key mappings", assert.equal (Commands.normalizeKey '<C-A>'), '<c-A>' assert.equal (Commands.normalizeKey '<F12>'), '<f12>' assert.equal (Commands.normalizeKey '<C-F12>'), '<c-f12>' + +context "Validate commands and options", + should "have either noRepeat or repeatLimit, but not both", -> + # TODO(smblott) For this and each following test, is there a way to structure the tests such that the name + # of the offending command appears in the output, if the test fails? + for command, options of Commands.availableCommands + assert.isTrue not (options.noRepeat and options.repeatLimit) + + should "describe each command", -> + for command, options of Commands.availableCommands + assert.equal 'string', typeof options.description + + should "define each command in each command group", -> + for group, commands of Commands.commandGroups + for command in commands + assert.equal 'string', typeof command + assert.isTrue Commands.availableCommands[command] + + should "have valid commands for each advanced command", -> + for command in Commands.advancedCommands + assert.equal 'string', typeof command + assert.isTrue Commands.availableCommands[command] + + should "have valid commands for each default key mapping", -> + count = Object.keys(Commands.keyToCommandRegistry).length + assert.isTrue (0 < count) + for key, command of Commands.keyToCommandRegistry + assert.equal 'object', typeof command + assert.isTrue Commands.availableCommands[command.command] + +context "Validate advanced commands", + setup -> + @allCommands = [].concat.apply [], (commands for group, commands of Commands.commandGroups) + + should "include each advanced command in a command group", -> + for command in Commands.advancedCommands + assert.isTrue 0 <= @allCommands.indexOf command + +# TODO (smblott) More tests: +# - Ensure each background command has an implmentation in BackgroundCommands +# - Ensure each foreground command has an implmentation in vimium_frontent.coffee diff --git a/tests/unit_tests/completion_test.coffee b/tests/unit_tests/completion_test.coffee index 02a741d5..811436a9 100644 --- a/tests/unit_tests/completion_test.coffee +++ b/tests/unit_tests/completion_test.coffee @@ -3,6 +3,8 @@ extend(global, require "../../lib/utils.js") extend(global, require "../../background_scripts/completion.js") global.chrome = {} +global.document = + createElement: -> {} context "bookmark completer", setup -> diff --git a/tests/unit_tests/exclusion_test.coffee b/tests/unit_tests/exclusion_test.coffee index 33dbccd3..a24c3b67 100644 --- a/tests/unit_tests/exclusion_test.coffee +++ b/tests/unit_tests/exclusion_test.coffee @@ -17,25 +17,29 @@ Utils.getCurrentVersion = -> '1.44' extend(global,require "../../background_scripts/sync.js") extend(global,require "../../background_scripts/settings.js") Sync.init() +extend(global, require "../../background_scripts/exclusions.js") extend(global, require "../../background_scripts/commands.js") extend(global, require "../../background_scripts/main.js") # These tests cover only the most basic aspects of excluded URLs and passKeys. # context "Excluded URLs and pass keys", - setup -> - Settings.set 'excludedUrls', 'http://mail.google.com/*\nhttp://www.facebook.com/* jk' + + # These tests have no setup, they use the default values from settings.coffee. should "be disabled for excluded sites", -> - rule = isEnabledForUrl({ url: 'http://mail.google.com/u/0/inbox' }) + rule = isEnabledForUrl({ url: 'http://www.google.com/calendar/page' }) assert.isFalse rule.isEnableForUrl - assert.isTrue rule.matchingUrl + assert.isFalse rule.passKeys should "be enabled, but with pass keys", -> - rule = isEnabledForUrl({ url: 'http://www.facebook.com/pages' }) + rule = isEnabledForUrl({ url: 'https://www.facebook.com/something' }) assert.isTrue rule.isEnabledForUrl - assert.equal rule.passKeys, 'jk' - assert.isTrue rule.matchingUrl + assert.isFalse rule.passKeys + addExclusionRule("http*://www.facebook.com/*","oO") + rule = isEnabledForUrl({ url: 'https://www.facebook.com/something' }) + assert.isTrue rule.isEnabledForUrl + assert.equal rule.passKeys, 'oO' should "be enabled", -> rule = isEnabledForUrl({ url: 'http://www.twitter.com/pages' }) @@ -45,27 +49,25 @@ context "Excluded URLs and pass keys", should "add a new excluded URL", -> rule = isEnabledForUrl({ url: 'http://www.example.com/page' }) assert.isTrue rule.isEnabledForUrl - addExcludedUrl("http://www.example.com*") + addExclusionRule("http://www.example.com*") rule = isEnabledForUrl({ url: 'http://www.example.com/page' }) assert.isFalse rule.isEnabledForUrl assert.isFalse rule.passKeys - assert.isTrue rule.matchingUrl should "add a new excluded URL with passkeys", -> - rule = isEnabledForUrl({ url: 'http://www.example.com/page' }) + rule = isEnabledForUrl({ url: 'http://www.anotherexample.com/page' }) assert.isTrue rule.isEnabledForUrl - addExcludedUrl("http://www.example.com/* jk") - rule = isEnabledForUrl({ url: 'http://www.example.com/page' }) + addExclusionRule("http://www.anotherexample.com/*","jk") + rule = isEnabledForUrl({ url: 'http://www.anotherexample.com/page' }) assert.isTrue rule.isEnabledForUrl assert.equal rule.passKeys, 'jk' - assert.isTrue rule.matchingUrl should "update an existing excluded URL with passkeys", -> - rule = isEnabledForUrl({ url: 'http://www.facebook.com/page' }) - assert.isTrue rule.isEnabledForUrl - addExcludedUrl("http://www.facebook.com/* jknp") - rule = isEnabledForUrl({ url: 'http://www.facebook.com/page' }) + rule = isEnabledForUrl({ url: 'http://mail.google.com/page' }) + assert.isFalse rule.isEnabledForUrl + assert.isFalse rule.passKeys + addExclusionRule("http*://mail.google.com/*","jknp") + rule = isEnabledForUrl({ url: 'http://mail.google.com/page' }) assert.isTrue rule.isEnabledForUrl assert.equal rule.passKeys, 'jknp' - assert.isTrue rule.matchingUrl |
