diff options
-rw-r--r-- | e2e/repeat_n_times.test.ts | 60 | ||||
-rw-r--r-- | src/background/controllers/OperationController.ts | 175 | ||||
-rw-r--r-- | src/background/infrastructures/ContentMessageListener.ts | 9 | ||||
-rw-r--r-- | src/content/client/OperationClient.ts | 6 | ||||
-rw-r--r-- | src/content/controllers/KeymapController.ts | 83 | ||||
-rw-r--r-- | src/content/usecases/KeymapUseCase.ts | 12 | ||||
-rw-r--r-- | src/shared/messages.ts | 1 | ||||
-rw-r--r-- | src/shared/operations.ts | 26 |
8 files changed, 235 insertions, 137 deletions
diff --git a/e2e/repeat_n_times.test.ts b/e2e/repeat_n_times.test.ts new file mode 100644 index 0000000..d28f3c9 --- /dev/null +++ b/e2e/repeat_n_times.test.ts @@ -0,0 +1,60 @@ +import * as path from 'path'; +import * as assert from 'assert'; + +import TestServer from './lib/TestServer'; +import eventually from './eventually'; +import { Builder, Lanthan } from 'lanthan'; +import { WebDriver } from 'selenium-webdriver'; +import Page from './lib/Page'; + +describe("tab test", () => { + let server = new TestServer().receiveContent('/', + `<!DOCTYPE html><html lang="en"><body style="width:10000px; height:10000px"></body></html>`, + ); + let lanthan: Lanthan; + let webdriver: WebDriver; + let browser: any; + + before(async() => { + lanthan = await Builder + .forBrowser('firefox') + .spyAddon(path.join(__dirname, '..')) + .build(); + webdriver = lanthan.getWebDriver(); + browser = lanthan.getWebExtBrowser(); + await server.start(); + + browser = browser; + }); + + after(async() => { + await server.stop(); + if (lanthan) { + await lanthan.quit(); + } + }); + + it('repeats scroll 3-times', async () => { + let page = await Page.navigateTo(webdriver, server.url()); + await page.sendKeys('3', 'j'); + + let scrollY = await page.getScrollY(); + assert.strictEqual(scrollY, 64 * 3); + }); + + it('repeats tab deletion 3-times', async () => { + let win = await browser.windows.create({ url: server.url('/#0') }); + for (let i = 1; i < 5; ++i) { + await browser.tabs.create({ url: server.url('/#' + i), windowId: win.id }); + await webdriver.navigate().to(server.url('/#' + i)); + } + + let page = await Page.navigateTo(webdriver, server.url()); + await page.sendKeys('3', 'd'); + + await eventually(async() => { + let current = await browser.tabs.query({ windowId: win.id }); + assert.strictEqual(current.length, 2); + }); + }); +}); diff --git a/src/background/controllers/OperationController.ts b/src/background/controllers/OperationController.ts index 7a10ad6..69b45c3 100644 --- a/src/background/controllers/OperationController.ts +++ b/src/background/controllers/OperationController.ts @@ -21,95 +21,108 @@ export default class OperationController { ) { } - async exec(op: operations.Operation): Promise<any> { - await this.doOperation(op); + async exec(count: number, op: operations.Operation): Promise<any> { + await this.doOperation(count, op); if (this.repeatUseCase.isRepeatable(op)) { this.repeatUseCase.storeLastOperation(op); } } // eslint-disable-next-line complexity, max-lines-per-function - doOperation(operation: operations.Operation): Promise<any> { - switch (operation.type) { - case operations.TAB_CLOSE: - return this.tabUseCase.close(false, operation.select === 'left'); - case operations.TAB_CLOSE_RIGHT: - return this.tabUseCase.closeRight(); - case operations.TAB_CLOSE_FORCE: - return this.tabUseCase.close(true); - case operations.TAB_REOPEN: - return this.tabUseCase.reopen(); - case operations.TAB_PREV: - return this.tabSelectUseCase.selectPrev(1); - case operations.TAB_NEXT: - return this.tabSelectUseCase.selectNext(1); - case operations.TAB_FIRST: - return this.tabSelectUseCase.selectFirst(); - case operations.TAB_LAST: - return this.tabSelectUseCase.selectLast(); - case operations.TAB_PREV_SEL: - return this.tabSelectUseCase.selectPrevSelected(); - case operations.TAB_RELOAD: - return this.tabUseCase.reload(operation.cache); - case operations.TAB_PIN: - return this.tabUseCase.setPinned(true); - case operations.TAB_UNPIN: - return this.tabUseCase.setPinned(false); - case operations.TAB_TOGGLE_PINNED: - return this.tabUseCase.togglePinned(); - case operations.TAB_DUPLICATE: - return this.tabUseCase.duplicate(); - case operations.PAGE_SOURCE: - return this.tabUseCase.openPageSource(); - case operations.PAGE_HOME: - return this.tabUseCase.openHome(operation.newTab); - case operations.ZOOM_IN: - return this.zoomUseCase.zoomIn(); - case operations.ZOOM_OUT: - return this.zoomUseCase.zoomOut(); - case operations.ZOOM_NEUTRAL: - return this.zoomUseCase.zoomNutoral(); - case operations.COMMAND_SHOW: - return this.consoleUseCase.showCommand(); - case operations.COMMAND_SHOW_OPEN: - return this.consoleUseCase.showOpenCommand(operation.alter); - case operations.COMMAND_SHOW_TABOPEN: - return this.consoleUseCase.showTabopenCommand(operation.alter); - case operations.COMMAND_SHOW_WINOPEN: - return this.consoleUseCase.showWinopenCommand(operation.alter); - case operations.COMMAND_SHOW_BUFFER: - return this.consoleUseCase.showBufferCommand(); - case operations.COMMAND_SHOW_ADDBOOKMARK: - return this.consoleUseCase.showAddbookmarkCommand(operation.alter); - case operations.FIND_START: - return this.findUseCase.findStart(); - case operations.CANCEL: - return this.consoleUseCase.hideConsole(); - case operations.NAVIGATE_HISTORY_PREV: - return this.navigateUseCase.openHistoryPrev(); - case operations.NAVIGATE_HISTORY_NEXT: - return this.navigateUseCase.openHistoryNext(); - case operations.NAVIGATE_LINK_PREV: - return this.navigateUseCase.openLinkPrev(); - case operations.NAVIGATE_LINK_NEXT: - return this.navigateUseCase.openLinkNext(); - case operations.NAVIGATE_PARENT: - return this.navigateUseCase.openParent(); - case operations.NAVIGATE_ROOT: - return this.navigateUseCase.openRoot(); - case operations.REPEAT_LAST: - { - let last = this.repeatUseCase.getLastOperation(); - if (typeof last !== 'undefined') { - return this.doOperation(last); + async doOperation( + count: number, + operation: operations.Operation, + ): Promise<any> { + // eslint-disable-next-line complexity, max-lines-per-function + const opFunc = (() => { + switch (operation.type) { + case operations.TAB_CLOSE: + return () => this.tabUseCase.close(false, operation.select === 'left'); + case operations.TAB_CLOSE_RIGHT: + return () => this.tabUseCase.closeRight(); + case operations.TAB_CLOSE_FORCE: + return () => this.tabUseCase.close(true); + case operations.TAB_REOPEN: + return () => this.tabUseCase.reopen(); + case operations.TAB_PREV: + return () => this.tabSelectUseCase.selectPrev(1); + case operations.TAB_NEXT: + return () => this.tabSelectUseCase.selectNext(1); + case operations.TAB_FIRST: + return () => this.tabSelectUseCase.selectFirst(); + case operations.TAB_LAST: + return () => this.tabSelectUseCase.selectLast(); + case operations.TAB_PREV_SEL: + return () => this.tabSelectUseCase.selectPrevSelected(); + case operations.TAB_RELOAD: + return () => this.tabUseCase.reload(operation.cache); + case operations.TAB_PIN: + return () => this.tabUseCase.setPinned(true); + case operations.TAB_UNPIN: + return () => this.tabUseCase.setPinned(false); + case operations.TAB_TOGGLE_PINNED: + return () => this.tabUseCase.togglePinned(); + case operations.TAB_DUPLICATE: + return () => this.tabUseCase.duplicate(); + case operations.PAGE_SOURCE: + return () => this.tabUseCase.openPageSource(); + case operations.PAGE_HOME: + return () => this.tabUseCase.openHome(operation.newTab); + case operations.ZOOM_IN: + return () => this.zoomUseCase.zoomIn(); + case operations.ZOOM_OUT: + return () => this.zoomUseCase.zoomOut(); + case operations.ZOOM_NEUTRAL: + return () => this.zoomUseCase.zoomNutoral(); + case operations.COMMAND_SHOW: + return () => this.consoleUseCase.showCommand(); + case operations.COMMAND_SHOW_OPEN: + return () => this.consoleUseCase.showOpenCommand(operation.alter); + case operations.COMMAND_SHOW_TABOPEN: + return () => this.consoleUseCase.showTabopenCommand(operation.alter); + case operations.COMMAND_SHOW_WINOPEN: + return () => this.consoleUseCase.showWinopenCommand(operation.alter); + case operations.COMMAND_SHOW_BUFFER: + return () => this.consoleUseCase.showBufferCommand(); + case operations.COMMAND_SHOW_ADDBOOKMARK: + return () => this.consoleUseCase.showAddbookmarkCommand( + operation.alter); + case operations.FIND_START: + return () => this.findUseCase.findStart(); + case operations.CANCEL: + return () => this.consoleUseCase.hideConsole(); + case operations.NAVIGATE_HISTORY_PREV: + return () => this.navigateUseCase.openHistoryPrev(); + case operations.NAVIGATE_HISTORY_NEXT: + return () => this.navigateUseCase.openHistoryNext(); + case operations.NAVIGATE_LINK_PREV: + return () => this.navigateUseCase.openLinkPrev(); + case operations.NAVIGATE_LINK_NEXT: + return () => this.navigateUseCase.openLinkNext(); + case operations.NAVIGATE_PARENT: + return () => this.navigateUseCase.openParent(); + case operations.NAVIGATE_ROOT: + return () => this.navigateUseCase.openRoot(); + case operations.REPEAT_LAST: + return () => { + let last = this.repeatUseCase.getLastOperation(); + if (typeof last !== 'undefined') { + return this.doOperation(1, last); + } + return Promise.resolve(); + }; + case operations.INTERNAL_OPEN_URL: + return () => this.tabUseCase.openURL( + operation.url, operation.newTab, operation.newWindow); + default: + throw new Error('unknown operation: ' + operation.type); } - return Promise.resolve(); - } - case operations.INTERNAL_OPEN_URL: - return this.tabUseCase.openURL( - operation.url, operation.newTab, operation.newWindow); + })(); + + for (let i = 0; i < count; ++i) { + // eslint-disable-next-line no-await-in-loop + await opFunc(); } - throw new Error('unknown operation: ' + operation.type); } } diff --git a/src/background/infrastructures/ContentMessageListener.ts b/src/background/infrastructures/ContentMessageListener.ts index f80d686..51a9f82 100644 --- a/src/background/infrastructures/ContentMessageListener.ts +++ b/src/background/infrastructures/ContentMessageListener.ts @@ -1,5 +1,6 @@ import { injectable } from 'tsyringe'; import * as messages from '../../shared/messages'; +import * as operations from '../../shared/operations'; import CompletionGroup from '../domains/CompletionGroup'; import CommandController from '../controllers/CommandController'; import SettingController from '../controllers/SettingController'; @@ -19,7 +20,7 @@ export default class ContentMessageListener { private findController: FindController, private addonEnabledController: AddonEnabledController, private linkController: LinkController, - private backgroundOperationController: OperationController, + private operationController: OperationController, private markController: MarkController, ) { this.consolePorts = {}; @@ -79,7 +80,7 @@ export default class ContentMessageListener { senderTab.id as number, message.background); case messages.BACKGROUND_OPERATION: - return this.onBackgroundOperation(message.operation); + return this.onBackgroundOperation(message.count, message.operation); case messages.MARK_SET_GLOBAL: return this.onMarkSetGlobal(message.key, message.x, message.y); case messages.MARK_JUMP_GLOBAL: @@ -126,8 +127,8 @@ export default class ContentMessageListener { return this.linkController.openToTab(url, openerId); } - onBackgroundOperation(operation: any): Promise<any> { - return this.backgroundOperationController.exec(operation); + onBackgroundOperation(count: number, op: operations.Operation): Promise<any> { + return this.operationController.exec(count, op); } onMarkSetGlobal(key: string, x: number, y: number): Promise<any> { diff --git a/src/content/client/OperationClient.ts b/src/content/client/OperationClient.ts index 5dbe555..06077dc 100644 --- a/src/content/client/OperationClient.ts +++ b/src/content/client/OperationClient.ts @@ -2,7 +2,7 @@ import * as operations from '../../shared/operations'; import * as messages from '../../shared/messages'; export default interface OperationClient { - execBackgroundOp(op: operations.Operation): Promise<void>; + execBackgroundOp(count: number, op: operations.Operation): Promise<void>; internalOpenUrl( url: string, newTab?: boolean, background?: boolean, @@ -10,9 +10,10 @@ export default interface OperationClient { } export class OperationClientImpl implements OperationClient { - execBackgroundOp(op: operations.Operation): Promise<void> { + execBackgroundOp(count: number, op: operations.Operation): Promise<void> { return browser.runtime.sendMessage({ type: messages.BACKGROUND_OPERATION, + count, operation: op, }); } @@ -22,6 +23,7 @@ export class OperationClientImpl implements OperationClient { ): Promise<void> { return browser.runtime.sendMessage({ type: messages.BACKGROUND_OPERATION, + count: 1, operation: { type: operations.INTERNAL_OPEN_URL, url, diff --git a/src/content/controllers/KeymapController.ts b/src/content/controllers/KeymapController.ts index cf59ae5..b8069f9 100644 --- a/src/content/controllers/KeymapController.ts +++ b/src/content/controllers/KeymapController.ts @@ -23,7 +23,7 @@ export default class KeymapController { private markKeyUseCase: MarkKeyyUseCase, @inject('OperationClient') - private backgroundClient: OperationClient, + private operationClient: OperationClient, @inject('FollowMasterClient') private followMasterClient: FollowMasterClient, @@ -32,74 +32,69 @@ export default class KeymapController { // eslint-disable-next-line complexity, max-lines-per-function press(key: Key): boolean { - let ops = this.keymapUseCase.nextOps(key); - if (ops.length === 0) { + let nextOp = this.keymapUseCase.nextOps(key); + if (nextOp === null) { return false; } - // Do not await asynchronous methods to return a boolean immidiately. The - // caller requires the synchronous response from the callback to identify - // to continue of abandon the event propagations. - for (let op of ops) { + if (!operations.isNRepeatable(nextOp.op.type)) { + nextOp.count = 1; + } + + const doFunc = ((op: operations.Operation) => { switch (op.type) { case operations.ADDON_ENABLE: - this.addonEnabledUseCase.enable(); - break; + return () => this.addonEnabledUseCase.enable(); case operations.ADDON_DISABLE: - this.addonEnabledUseCase.disable(); - break; + return () => this.addonEnabledUseCase.disable(); case operations.ADDON_TOGGLE_ENABLED: - this.addonEnabledUseCase.toggle(); - break; + return () => this.addonEnabledUseCase.toggle(); case operations.FIND_NEXT: - this.findSlaveUseCase.findNext(); - break; + return () => this.findSlaveUseCase.findNext(); case operations.FIND_PREV: - this.findSlaveUseCase.findPrev(); - break; + return () => this.findSlaveUseCase.findPrev(); case operations.SCROLL_VERTICALLY: - this.scrollUseCase.scrollVertically(op.count); - break; + return () => this.scrollUseCase.scrollVertically(op.count); case operations.SCROLL_HORIZONALLY: - this.scrollUseCase.scrollHorizonally(op.count); - break; + return () => this.scrollUseCase.scrollHorizonally(op.count); case operations.SCROLL_PAGES: - this.scrollUseCase.scrollPages(op.count); - break; + return () => this.scrollUseCase.scrollPages(op.count); case operations.SCROLL_TOP: - this.scrollUseCase.scrollToTop(); - break; + return () => this.scrollUseCase.scrollToTop(); case operations.SCROLL_BOTTOM: - this.scrollUseCase.scrollToBottom(); - break; + return () => this.scrollUseCase.scrollToBottom(); case operations.SCROLL_HOME: - this.scrollUseCase.scrollToHome(); - break; + return () => this.scrollUseCase.scrollToHome(); case operations.SCROLL_END: - this.scrollUseCase.scrollToEnd(); - break; + return () => this.scrollUseCase.scrollToEnd(); case operations.FOLLOW_START: - this.followMasterClient.startFollow(op.newTab, op.background); - break; + return () => this.followMasterClient.startFollow( + op.newTab, op.background); case operations.MARK_SET_PREFIX: - this.markKeyUseCase.enableSetMode(); - break; + return () => this.markKeyUseCase.enableSetMode(); case operations.MARK_JUMP_PREFIX: - this.markKeyUseCase.enableJumpMode(); - break; + return () => this.markKeyUseCase.enableJumpMode(); case operations.FOCUS_INPUT: - this.focusUseCase.focusFirstInput(); - break; + return () => this.focusUseCase.focusFirstInput(); case operations.URLS_YANK: - this.clipbaordUseCase.yankCurrentURL(); - break; + return () => this.clipbaordUseCase.yankCurrentURL(); case operations.URLS_PASTE: - this.clipbaordUseCase.openOrSearch( + return () => this.clipbaordUseCase.openOrSearch( op.newTab ? op.newTab : false, ); - break; default: - this.backgroundClient.execBackgroundOp(op); + return null; + } + })(nextOp.op); + + if (doFunc === null) { + // Do not await asynchronous methods to return a boolean immidiately. The + // caller requires the synchronous response from the callback to identify + // to continue of abandon the event propagations. + this.operationClient.execBackgroundOp(nextOp.count, nextOp.op); + } else { + for (let i = 0; i < nextOp.count; ++i) { + doFunc(); } } return true; diff --git a/src/content/usecases/KeymapUseCase.ts b/src/content/usecases/KeymapUseCase.ts index f7de334..fab13f5 100644 --- a/src/content/usecases/KeymapUseCase.ts +++ b/src/content/usecases/KeymapUseCase.ts @@ -36,13 +36,13 @@ export default class KeymapUseCase { } // eslint-disable-next-line max-statements - nextOps(key: Key): operations.Operation[] { + nextOps(key: Key): { count: number, op: operations.Operation } | null { let sequence = this.repository.enqueueKey(key); let baseSequence = sequence.trimNumericPrefix(); if (baseSequence.length() === 1 && this.blacklistKey(key)) { // ignore if the input starts with black list keys this.repository.clear(); - return []; + return null; } let keymaps = this.keymapEntityMap(); @@ -53,21 +53,21 @@ export default class KeymapUseCase { sequence.length() === matched[0][0].length()) { // keys are matched with an operation this.repository.clear(); - return [matched[0][1]]; + return { count: 1, op: matched[0][1] }; } else if ( baseMatched.length === 1 && baseSequence.length() === baseMatched[0][0].length()) { // keys are matched with an operation with a numeric prefix this.repository.clear(); - return Array(sequence.repeatCount()).fill(baseMatched[0][1]); + return { count: sequence.repeatCount(), op: baseMatched[0][1] }; } else if (matched.length >= 1 || baseMatched.length >= 1) { // keys are matched with an operation's prefix - return []; + return null; } // matched with no operations this.repository.clear(); - return []; + return null; } private keymapEntityMap(): [KeySequence, operations.Operation][] { diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 36a23d8..8032109 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -49,6 +49,7 @@ export const NAVIGATE_LINK_PREV = 'navigate.link.prev'; export interface BackgroundOperationMessage { type: typeof BACKGROUND_OPERATION; + count: number; operation: operations.Operation; } diff --git a/src/shared/operations.ts b/src/shared/operations.ts index 1ce5256..67c5ca8 100644 --- a/src/shared/operations.ts +++ b/src/shared/operations.ts @@ -508,3 +508,29 @@ export const valueOf = (o: any): Operation => { } throw new TypeError('Unknown operation type: ' + o.type); }; + +export const isNRepeatable = (type: string): boolean => { + switch (type) { + case SCROLL_VERTICALLY: + case SCROLL_HORIZONALLY: + case SCROLL_PAGES: + case NAVIGATE_HISTORY_PREV: + case NAVIGATE_HISTORY_NEXT: + case NAVIGATE_PARENT: + case TAB_CLOSE: + case TAB_CLOSE_FORCE: + case TAB_CLOSE_RIGHT: + case TAB_REOPEN: + case TAB_PREV: + case TAB_NEXT: + case TAB_DUPLICATE: + case ZOOM_IN: + case ZOOM_OUT: + case URLS_PASTE: + case FIND_NEXT: + case FIND_PREV: + case REPEAT_LAST: + return true; + } + return false; +}; |