aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShin'ya Ueoka <ueokande@i-beam.org>2019-05-26 16:24:14 +0900
committerGitHub <noreply@github.com>2019-05-26 16:24:14 +0900
commitcd584c8e243bafa8fc284279f716e8113607cd65 (patch)
treebc39bc30369f149e4ba4b6dc9c353b2906c4ef90
parent07897df636ca3e732490d53fd2acf947738bf16e (diff)
parent34a96cdc9c5d7c8a11c6f1ae512fbc97724f61c4 (diff)
Merge pull request #592 from ueokande/repeat-last-operation
Add "repeat last operation" command
-rw-r--r--e2e/repeat.test.js92
-rw-r--r--package-lock.json14
-rw-r--r--package.json2
-rw-r--r--src/background/clients/NavigateClient.ts29
-rw-r--r--src/background/controllers/OperationController.ts36
-rw-r--r--src/background/presenters/TabPresenter.ts4
-rw-r--r--src/background/repositories/RepeatRepository.ts22
-rw-r--r--src/background/usecases/CommandUseCase.ts17
-rw-r--r--src/background/usecases/NavigateUseCase.ts57
-rw-r--r--src/background/usecases/RepeatUseCase.ts50
-rw-r--r--src/background/usecases/TabUseCase.ts15
-rw-r--r--src/content/Application.ts10
-rw-r--r--src/content/client/BackgroundClient.ts13
-rw-r--r--src/content/client/OperationClient.ts33
-rw-r--r--src/content/controllers/KeymapController.ts26
-rw-r--r--src/content/controllers/NavigateController.ts31
-rw-r--r--src/content/di.ts2
-rw-r--r--src/content/presenters/NavigationPresenter.ts27
-rw-r--r--src/content/usecases/ClipboardUseCase.ts10
-rw-r--r--src/content/usecases/NavigateUseCase.ts8
-rw-r--r--src/settings/keymaps.ts1
-rw-r--r--src/shared/SettingData.ts1
-rw-r--r--src/shared/Settings.ts1
-rw-r--r--src/shared/messages.ts31
-rw-r--r--src/shared/operations.ts42
-rw-r--r--test/background/usecases/NavigateUseCase.test.ts82
-rw-r--r--test/content/presenters/NavigationPresenter.test.ts9
-rw-r--r--test/content/usecases/ClipboardUseCase.test.ts83
28 files changed, 606 insertions, 142 deletions
diff --git a/e2e/repeat.test.js b/e2e/repeat.test.js
new file mode 100644
index 0000000..4072005
--- /dev/null
+++ b/e2e/repeat.test.js
@@ -0,0 +1,92 @@
+const express = require('express');
+const lanthan = require('lanthan');
+const path = require('path');
+const assert = require('assert');
+const eventually = require('./eventually');
+
+const Key = lanthan.Key;
+
+const newApp = () => {
+ let app = express();
+ app.get('/', (req, res) => {
+ res.send('ok');
+ });
+ return app;
+};
+
+describe("tab test", () => {
+
+ const port = 12321;
+ const url = `http://127.0.0.1:${port}/`;
+
+ let http;
+ let firefox;
+ let session;
+ let browser;
+ let tabs;
+
+ before(async() => {
+ firefox = await lanthan.firefox();
+ await firefox.session.installAddonFromPath(path.join(__dirname, '..'));
+ session = firefox.session;
+ browser = firefox.browser;
+ http = newApp().listen(port);
+
+ await session.navigateTo(`${url}`);
+ });
+
+ after(async() => {
+ http.close();
+ if (firefox) {
+ await firefox.close();
+ }
+ });
+
+ it('repeats last operation', async () => {
+ let before = await browser.tabs.query({});
+
+ let body = await session.findElementByCSS('body');
+ await body.sendKeys(':');
+
+ await session.switchToFrame(0);
+ let input = await session.findElementByCSS('input');
+ input.sendKeys(`tabopen ${url}newtab`, Key.Enter);
+
+ await eventually(async() => {
+ let current = await browser.tabs.query({ url: `*://*/newtab` });
+ assert.equal(current.length, 1);
+ });
+
+ body = await session.findElementByCSS('body');
+ await body.sendKeys('.');
+
+ await eventually(async() => {
+ let current = await browser.tabs.query({ url: `*://*/newtab` });
+ assert.equal(current.length, 2);
+ });
+ });
+
+ it('repeats last operation', async () => {
+ for (let i = 1; i < 5; ++i) {
+ await browser.tabs.create({ url: `${url}#${i}` });
+ }
+ let before = await browser.tabs.query({});
+
+ let body = await session.findElementByCSS('body');
+ await body.sendKeys('d');
+
+ await eventually(async() => {
+ let current = await browser.tabs.query({});
+ assert.equal(current.length, before.length - 1);
+ });
+
+ await browser.tabs.update(before[2].id, { active: true });
+ body = await session.findElementByCSS('body');
+ await body.sendKeys('.');
+
+ await eventually(async() => {
+ let current = await browser.tabs.query({});
+ assert.equal(current.length, before.length - 2);
+ });
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index 00792ab..9ae10bf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -105,14 +105,6 @@
"@sinonjs/commons": "^1.0.2",
"array-from": "^2.1.1",
"lodash": "^4.17.11"
- },
- "dependencies": {
- "lodash": {
- "version": "4.17.11",
- "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz",
- "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==",
- "dev": true
- }
}
},
"@sinonjs/text-encoding": {
@@ -202,6 +194,12 @@
}
}
},
+ "@types/sinon": {
+ "version": "7.0.11",
+ "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-7.0.11.tgz",
+ "integrity": "sha512-6ee09Ugx6GyEr0opUIakmxIWFNmqYPjkqa3/BuxCBokA0klsOLPgMD5K4q40lH7/yZVuJVzOfQpd7pipwjngkQ==",
+ "dev": true
+ },
"@typescript-eslint/eslint-plugin": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-1.9.0.tgz",
diff --git a/package.json b/package.json
index 7feeea1..a556e50 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,7 @@
"@types/react-dom": "^16.8.4",
"@types/react-redux": "^7.0.8",
"@types/redux-promise": "^0.5.28",
+ "@types/sinon": "^7.0.11",
"@typescript-eslint/eslint-plugin": "^1.9.0",
"chai": "^4.2.0",
"css-loader": "^2.1.1",
@@ -54,6 +55,7 @@
"redux-promise": "^0.6.0",
"reflect-metadata": "^0.1.13",
"sass-loader": "^7.1.0",
+ "sinon": "^7.3.2",
"sinon-chrome": "^3.0.1",
"style-loader": "^0.23.1",
"ts-loader": "^6.0.1",
diff --git a/src/background/clients/NavigateClient.ts b/src/background/clients/NavigateClient.ts
new file mode 100644
index 0000000..bdd94ec
--- /dev/null
+++ b/src/background/clients/NavigateClient.ts
@@ -0,0 +1,29 @@
+import { injectable } from 'tsyringe';
+import * as messages from '../../shared/messages';
+
+@injectable()
+export default class NavigateClient {
+ async historyNext(tabId: number): Promise<void> {
+ await browser.tabs.sendMessage(tabId, {
+ type: messages.NAVIGATE_HISTORY_NEXT,
+ });
+ }
+
+ async historyPrev(tabId: number): Promise<void> {
+ await browser.tabs.sendMessage(tabId, {
+ type: messages.NAVIGATE_HISTORY_PREV,
+ });
+ }
+
+ async linkNext(tabId: number): Promise<void> {
+ await browser.tabs.sendMessage(tabId, {
+ type: messages.NAVIGATE_LINK_NEXT,
+ });
+ }
+
+ async linkPrev(tabId: number): Promise<void> {
+ await browser.tabs.sendMessage(tabId, {
+ type: messages.NAVIGATE_LINK_PREV,
+ });
+ }
+}
diff --git a/src/background/controllers/OperationController.ts b/src/background/controllers/OperationController.ts
index de6f8cb..51cff28 100644
--- a/src/background/controllers/OperationController.ts
+++ b/src/background/controllers/OperationController.ts
@@ -5,6 +5,8 @@ import ConsoleUseCase from '../usecases/ConsoleUseCase';
import TabUseCase from '../usecases/TabUseCase';
import TabSelectUseCase from '../usecases/TabSelectUseCase';
import ZoomUseCase from '../usecases/ZoomUseCase';
+import NavigateUseCase from '../usecases/NavigateUseCase';
+import RepeatUseCase from '../usecases/RepeatUseCase';
@injectable()
export default class OperationController {
@@ -14,11 +16,20 @@ export default class OperationController {
private tabUseCase: TabUseCase,
private tabSelectUseCase: TabSelectUseCase,
private zoomUseCase: ZoomUseCase,
+ private navigateUseCase: NavigateUseCase,
+ private repeatUseCase: RepeatUseCase,
) {
}
+ async exec(op: operations.Operation): Promise<any> {
+ await this.doOperation(op);
+ if (this.repeatUseCase.isRepeatable(op)) {
+ this.repeatUseCase.storeLastOperation(op);
+ }
+ }
+
// eslint-disable-next-line complexity, max-lines-per-function
- exec(operation: operations.Operation): Promise<any> {
+ doOperation(operation: operations.Operation): Promise<any> {
switch (operation.type) {
case operations.TAB_CLOSE:
return this.tabUseCase.close(false);
@@ -74,6 +85,29 @@ export default class OperationController {
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);
+ }
+ return Promise.resolve();
+ }
+ case operations.INTERNAL_OPEN_URL:
+ return this.tabUseCase.openURL(
+ operation.url, operation.newTab, operation.newWindow);
}
throw new Error('unknown operation: ' + operation.type);
}
diff --git a/src/background/presenters/TabPresenter.ts b/src/background/presenters/TabPresenter.ts
index 5665bf0..ed88f26 100644
--- a/src/background/presenters/TabPresenter.ts
+++ b/src/background/presenters/TabPresenter.ts
@@ -36,7 +36,9 @@ export default class TabPresenter {
return tabId;
}
- async getByKeyword(keyword: string, excludePinned = false): Promise<Tab[]> {
+ async getByKeyword(
+ keyword: string, excludePinned: boolean = false,
+ ): Promise<Tab[]> {
let tabs = await browser.tabs.query({ currentWindow: true });
return tabs.filter((t) => {
return t.url && t.url.toLowerCase().includes(keyword.toLowerCase()) ||
diff --git a/src/background/repositories/RepeatRepository.ts b/src/background/repositories/RepeatRepository.ts
new file mode 100644
index 0000000..c7f7a71
--- /dev/null
+++ b/src/background/repositories/RepeatRepository.ts
@@ -0,0 +1,22 @@
+import { injectable } from 'tsyringe';
+import { Operation } from '../../shared/operations';
+import MemoryStorage from '../infrastructures/MemoryStorage';
+
+const REPEAT_KEY = 'repeat';
+
+@injectable()
+export default class RepeatRepository {
+ private cache: MemoryStorage;
+
+ constructor() {
+ this.cache = new MemoryStorage();
+ }
+
+ getLastOperation(): Operation | undefined {
+ return this.cache.get(REPEAT_KEY);
+ }
+
+ setLastOperation(op: Operation): void {
+ this.cache.set(REPEAT_KEY, op);
+ }
+}
diff --git a/src/background/usecases/CommandUseCase.ts b/src/background/usecases/CommandUseCase.ts
index 921a779..a526cfc 100644
--- a/src/background/usecases/CommandUseCase.ts
+++ b/src/background/usecases/CommandUseCase.ts
@@ -1,4 +1,5 @@
import { injectable } from 'tsyringe';
+import * as operations from '../../shared/operations';
import * as parsers from './parsers';
import * as urls from '../../shared/urls';
import TabPresenter from '../presenters/TabPresenter';
@@ -7,6 +8,7 @@ import SettingRepository from '../repositories/SettingRepository';
import BookmarkRepository from '../repositories/BookmarkRepository';
import ConsoleClient from '../infrastructures/ConsoleClient';
import ContentMessageClient from '../infrastructures/ContentMessageClient';
+import RepeatUseCase from '../usecases/RepeatUseCase';
@injectable()
export default class CommandIndicator {
@@ -17,21 +19,36 @@ export default class CommandIndicator {
private bookmarkRepository: BookmarkRepository,
private consoleClient: ConsoleClient,
private contentMessageClient: ContentMessageClient,
+ private repeatUseCase: RepeatUseCase,
) {
}
async open(keywords: string): Promise<browser.tabs.Tab> {
let url = await this.urlOrSearch(keywords);
+ this.repeatUseCase.storeLastOperation({
+ type: operations.INTERNAL_OPEN_URL,
+ url,
+ });
return this.tabPresenter.open(url);
}
async tabopen(keywords: string): Promise<browser.tabs.Tab> {
let url = await this.urlOrSearch(keywords);
+ this.repeatUseCase.storeLastOperation({
+ type: operations.INTERNAL_OPEN_URL,
+ url,
+ newTab: true,
+ });
return this.tabPresenter.create(url);
}
async winopen(keywords: string): Promise<browser.windows.Window> {
let url = await this.urlOrSearch(keywords);
+ this.repeatUseCase.storeLastOperation({
+ type: operations.INTERNAL_OPEN_URL,
+ url,
+ newWindow: true,
+ });
return this.windowPresenter.create(url);
}
diff --git a/src/background/usecases/NavigateUseCase.ts b/src/background/usecases/NavigateUseCase.ts
new file mode 100644
index 0000000..152339a
--- /dev/null
+++ b/src/background/usecases/NavigateUseCase.ts
@@ -0,0 +1,57 @@
+import { injectable } from 'tsyringe';
+import NavigateClient from '../clients/NavigateClient';
+import TabPresenter from '../presenters/TabPresenter';
+
+@injectable()
+export default class NavigateUseCase {
+ constructor(
+ private tabPresenter: TabPresenter,
+ private navigateClient: NavigateClient,
+ ) {
+ }
+
+ async openHistoryNext(): Promise<void> {
+ let tab = await this.tabPresenter.getCurrent();
+ await this.navigateClient.historyNext(tab.id!!);
+ }
+
+ async openHistoryPrev(): Promise<void> {
+ let tab = await this.tabPresenter.getCurrent();
+ await this.navigateClient.historyPrev(tab.id!!);
+ }
+
+ async openLinkNext(): Promise<void> {
+ let tab = await this.tabPresenter.getCurrent();
+ await this.navigateClient.linkNext(tab.id!!);
+ }
+
+ async openLinkPrev(): Promise<void> {
+ let tab = await this.tabPresenter.getCurrent();
+ await this.navigateClient.linkPrev(tab.id!!);
+ }
+
+ async openParent(): Promise<void> {
+ let tab = await this.tabPresenter.getCurrent();
+ let url = new URL(tab.url!!);
+ if (url.hash.length > 0) {
+ url.hash = '';
+ } else if (url.search.length > 0) {
+ url.search = '';
+ } else {
+ const basenamePattern = /\/[^/]+$/;
+ const lastDirPattern = /\/[^/]+\/$/;
+ if (basenamePattern.test(url.pathname)) {
+ url.pathname = url.pathname.replace(basenamePattern, '/');
+ } else if (lastDirPattern.test(url.pathname)) {
+ url.pathname = url.pathname.replace(lastDirPattern, '/');
+ }
+ }
+ await this.tabPresenter.open(url.href);
+ }
+
+ async openRoot(): Promise<void> {
+ let tab = await this.tabPresenter.getCurrent();
+ let url = new URL(tab.url!!);
+ await this.tabPresenter.open(url.origin);
+ }
+}
diff --git a/src/background/usecases/RepeatUseCase.ts b/src/background/usecases/RepeatUseCase.ts
new file mode 100644
index 0000000..d78de34
--- /dev/null
+++ b/src/background/usecases/RepeatUseCase.ts
@@ -0,0 +1,50 @@
+import { injectable } from 'tsyringe';
+import * as operations from '../../shared/operations';
+import RepeatRepository from '../repositories/RepeatRepository';
+
+type Operation = operations.Operation;
+
+@injectable()
+export default class RepeatUseCase {
+ constructor(
+ private repeatRepository: RepeatRepository,
+ ) {
+ }
+
+ storeLastOperation(op: Operation): void {
+ this.repeatRepository.setLastOperation(op);
+ }
+
+ getLastOperation(): operations.Operation | undefined {
+ return this.repeatRepository.getLastOperation();
+ }
+
+ // eslint-disable-next-line complexity
+ isRepeatable(op: Operation): boolean {
+ switch (op.type) {
+ case operations.NAVIGATE_HISTORY_PREV:
+ case operations.NAVIGATE_HISTORY_NEXT:
+ case operations.NAVIGATE_LINK_PREV:
+ case operations.NAVIGATE_LINK_NEXT:
+ case operations.NAVIGATE_PARENT:
+ case operations.NAVIGATE_ROOT:
+ case operations.PAGE_SOURCE:
+ case operations.PAGE_HOME:
+ case operations.TAB_CLOSE:
+ case operations.TAB_CLOSE_FORCE:
+ case operations.TAB_CLOSE_RIGHT:
+ case operations.TAB_REOPEN:
+ case operations.TAB_RELOAD:
+ case operations.TAB_PIN:
+ case operations.TAB_UNPIN:
+ case operations.TAB_TOGGLE_PINNED:
+ case operations.TAB_DUPLICATE:
+ case operations.ZOOM_IN:
+ case operations.ZOOM_OUT:
+ case operations.ZOOM_NEUTRAL:
+ case operations.INTERNAL_OPEN_URL:
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/src/background/usecases/TabUseCase.ts b/src/background/usecases/TabUseCase.ts
index 0239a87..31112a9 100644
--- a/src/background/usecases/TabUseCase.ts
+++ b/src/background/usecases/TabUseCase.ts
@@ -1,11 +1,13 @@
import { injectable } from 'tsyringe';
import TabPresenter from '../presenters/TabPresenter';
+import WindowPresenter from '../presenters/WindowPresenter';
import BrowserSettingRepository from '../repositories/BrowserSettingRepository';
@injectable()
export default class TabUseCase {
constructor(
private tabPresenter: TabPresenter,
+ private windowPresenter: WindowPresenter,
private browserSettingRepository: BrowserSettingRepository,
) {
}
@@ -77,4 +79,17 @@ export default class TabUseCase {
this.tabPresenter.create(url);
}
}
+
+ async openURL(
+ url: string, newTab?: boolean, newWindow?: boolean,
+ ): Promise<void> {
+ if (newWindow) {
+ await this.windowPresenter.create(url);
+ } else if (newTab) {
+ await this.tabPresenter.create(url);
+ } else {
+ let tab = await this.tabPresenter.getCurrent();
+ await this.tabPresenter.open(url, tab.id);
+ }
+ }
}
diff --git a/src/content/Application.ts b/src/content/Application.ts
index 470bf53..1677655 100644
--- a/src/content/Application.ts
+++ b/src/content/Application.ts
@@ -12,6 +12,7 @@ import MarkKeyController from './controllers/MarkKeyController';
import AddonEnabledController from './controllers/AddonEnabledController';
import SettingController from './controllers/SettingController';
import ConsoleFrameController from './controllers/ConsoleFrameController';
+import NavigateController from './controllers/NavigateController';
import * as messages from '../shared/messages';
type Message = messages.Message;
@@ -33,6 +34,7 @@ export default class Application {
private addonEnabledController: AddonEnabledController,
private settingController: SettingController,
private consoleFrameController: ConsoleFrameController,
+ private navigateController: NavigateController,
) {
}
@@ -98,6 +100,14 @@ export default class Application {
return this.settingController.reloadSettings(msg);
case messages.ADDON_TOGGLE_ENABLED:
return this.addonEnabledUseCase.toggle();
+ case messages.NAVIGATE_HISTORY_NEXT:
+ return this.navigateController.openHistoryNext(msg);
+ case messages.NAVIGATE_HISTORY_PREV:
+ return this.navigateController.openHistoryPrev(msg);
+ case messages.NAVIGATE_LINK_NEXT:
+ return this.navigateController.openLinkNext(msg);
+ case messages.NAVIGATE_LINK_PREV:
+ return this.navigateController.openLinkPrev(msg);
}
});
diff --git a/src/content/client/BackgroundClient.ts b/src/content/client/BackgroundClient.ts
deleted file mode 100644
index 4a41184..0000000
--- a/src/content/client/BackgroundClient.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { injectable } from 'tsyringe';
-import * as operations from '../../shared/operations';
-import * as messages from '../../shared/messages';
-
-@injectable()
-export default class BackgroundClient {
- execBackgroundOp(op: operations.Operation): Promise<void> {
- return browser.runtime.sendMessage({
- type: messages.BACKGROUND_OPERATION,
- operation: op,
- });
- }
-}
diff --git a/src/content/client/OperationClient.ts b/src/content/client/OperationClient.ts
new file mode 100644
index 0000000..5dbe555
--- /dev/null
+++ b/src/content/client/OperationClient.ts
@@ -0,0 +1,33 @@
+import * as operations from '../../shared/operations';
+import * as messages from '../../shared/messages';
+
+export default interface OperationClient {
+ execBackgroundOp(op: operations.Operation): Promise<void>;
+
+ internalOpenUrl(
+ url: string, newTab?: boolean, background?: boolean,
+ ): Promise<void>;
+}
+
+export class OperationClientImpl implements OperationClient {
+ execBackgroundOp(op: operations.Operation): Promise<void> {
+ return browser.runtime.sendMessage({
+ type: messages.BACKGROUND_OPERATION,
+ operation: op,
+ });
+ }
+
+ internalOpenUrl(
+ url: string, newTab?: boolean, background?: boolean,
+ ): Promise<void> {
+ return browser.runtime.sendMessage({
+ type: messages.BACKGROUND_OPERATION,
+ operation: {
+ type: operations.INTERNAL_OPEN_URL,
+ url,
+ newTab,
+ background,
+ },
+ });
+ }
+}
diff --git a/src/content/controllers/KeymapController.ts b/src/content/controllers/KeymapController.ts
index 1835546..fcfaff1 100644
--- a/src/content/controllers/KeymapController.ts
+++ b/src/content/controllers/KeymapController.ts
@@ -4,10 +4,9 @@ import KeymapUseCase from '../usecases/KeymapUseCase';
import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase';
import FindSlaveUseCase from '../usecases/FindSlaveUseCase';
import ScrollUseCase from '../usecases/ScrollUseCase';
-import NavigateUseCase from '../usecases/NavigateUseCase';
import FocusUseCase from '../usecases/FocusUseCase';
import ClipboardUseCase from '../usecases/ClipboardUseCase';
-import BackgroundClient from '../client/BackgroundClient';
+import OperationClient from '../client/OperationClient';
import MarkKeyyUseCase from '../usecases/MarkKeyUseCase';
import FollowMasterClient from '../client/FollowMasterClient';
import Key from '../domains/Key';
@@ -19,12 +18,13 @@ export default class KeymapController {
private addonEnabledUseCase: AddonEnabledUseCase,
private findSlaveUseCase: FindSlaveUseCase,
private scrollUseCase: ScrollUseCase,
- private navigateUseCase: NavigateUseCase,
private focusUseCase: FocusUseCase,
private clipbaordUseCase: ClipboardUseCase,
- private backgroundClient: BackgroundClient,
private markKeyUseCase: MarkKeyyUseCase,
+ @inject('OperationClient')
+ private backgroundClient: OperationClient,
+
@inject('FollowMasterClient')
private followMasterClient: FollowMasterClient,
) {
@@ -84,24 +84,6 @@ export default class KeymapController {
case operations.MARK_JUMP_PREFIX:
this.markKeyUseCase.enableJumpMode();
break;
- case operations.NAVIGATE_HISTORY_PREV:
- this.navigateUseCase.openHistoryPrev();
- break;
- case operations.NAVIGATE_HISTORY_NEXT:
- this.navigateUseCase.openHistoryNext();
- break;
- case operations.NAVIGATE_LINK_PREV:
- this.navigateUseCase.openLinkPrev();
- break;
- case operations.NAVIGATE_LINK_NEXT:
- this.navigateUseCase.openLinkNext();
- break;
- case operations.NAVIGATE_PARENT:
- this.navigateUseCase.openParent();
- break;
- case operations.NAVIGATE_ROOT:
- this.navigateUseCase.openRoot();
- break;
case operations.FOCUS_INPUT:
this.focusUseCase.focusFirstInput();
break;
diff --git a/src/content/controllers/NavigateController.ts b/src/content/controllers/NavigateController.ts
new file mode 100644
index 0000000..3f2df7a
--- /dev/null
+++ b/src/content/controllers/NavigateController.ts
@@ -0,0 +1,31 @@
+import { injectable } from 'tsyringe';
+import { Message } from '../../shared/messages';
+import NavigateUseCase from '../usecases/NavigateUseCase';
+
+@injectable()
+export default class NavigateController {
+ constructor(
+ private navigateUseCase: NavigateUseCase,
+ ) {
+ }
+
+ openHistoryNext(_m: Message): Promise<void> {
+ this.navigateUseCase.openHistoryNext();
+ return Promise.resolve();
+ }
+
+ openHistoryPrev(_m: Message): Promise<void> {
+ this.navigateUseCase.openHistoryPrev();
+ return Promise.resolve();
+ }
+
+ openLinkNext(_m: Message): Promise<void> {
+ this.navigateUseCase.openLinkNext();
+ return Promise.resolve();
+ }
+
+ openLinkPrev(_m: Message): Promise<void> {
+ this.navigateUseCase.openLinkPrev();
+ return Promise.resolve();
+ }
+}
diff --git a/src/content/di.ts b/src/content/di.ts
index 23be027..e18806a 100644
--- a/src/content/di.ts
+++ b/src/content/di.ts
@@ -21,6 +21,7 @@ import { MarkClientImpl } from './client/MarkClient';
import { MarkKeyRepositoryImpl } from './repositories/MarkKeyRepository';
import { MarkRepositoryImpl } from './repositories/MarkRepository';
import { NavigationPresenterImpl } from './presenters/NavigationPresenter';
+import { OperationClientImpl } from './client/OperationClient';
import { ScrollPresenterImpl } from './presenters/ScrollPresenter';
import { SettingClientImpl } from './client/SettingClient';
import { SettingRepositoryImpl } from './repositories/SettingRepository';
@@ -48,6 +49,7 @@ container.register('MarkClient', { useClass: MarkClientImpl });
container.register('MarkKeyRepository', { useClass: MarkKeyRepositoryImpl });
container.register('MarkRepository', { useClass: MarkRepositoryImpl });
container.register('NavigationPresenter', { useClass: NavigationPresenterImpl });
+container.register('OperationClient', { useClass: OperationClientImpl });
container.register('ScrollPresenter', { useClass: ScrollPresenterImpl });
container.register('SettingClient', { useClass: SettingClientImpl });
container.register('SettingRepository', { useClass: SettingRepositoryImpl });
diff --git a/src/content/presenters/NavigationPresenter.ts b/src/content/presenters/NavigationPresenter.ts
index c141112..11d96ec 100644
--- a/src/content/presenters/NavigationPresenter.ts
+++ b/src/content/presenters/NavigationPresenter.ts
@@ -6,10 +6,6 @@ export default interface NavigationPresenter {
openLinkPrev(): void;
openLinkNext(): void;
-
- openParent(): void;
-
- openRoot(): void;
}
const REL_PATTERN: {[key: string]: RegExp} = {
@@ -51,29 +47,6 @@ export class NavigationPresenterImpl implements NavigationPresenter {
this.linkRel('next');
}
- openParent(): void {
- const loc = window.location;
- if (loc.hash !== '') {
- loc.hash = '';
- return;
- } else if (loc.search !== '') {
- loc.search = '';
- return;
- }
-
- const basenamePattern = /\/[^/]+$/;
- const lastDirPattern = /\/[^/]+\/$/;
- if (basenamePattern.test(loc.pathname)) {
- loc.pathname = loc.pathname.replace(basenamePattern, '/');
- } else if (lastDirPattern.test(loc.pathname)) {
- loc.pathname = loc.pathname.replace(lastDirPattern, '/');
- }
- }
-
- openRoot(): void {
- window.location.href = window.location.origin;
- }
-
// Code common to linkPrev and linkNext which navigates to the specified page.
private linkRel(rel: 'prev' | 'next'): void {
let link = selectLast<HTMLLinkElement>(`link[rel~=${rel}][href]`);
diff --git a/src/content/usecases/ClipboardUseCase.ts b/src/content/usecases/ClipboardUseCase.ts
index 8c4d621..c8fe719 100644
--- a/src/content/usecases/ClipboardUseCase.ts
+++ b/src/content/usecases/ClipboardUseCase.ts
@@ -2,16 +2,16 @@ import { injectable, inject } from 'tsyringe';
import * as urls from '../../shared/urls';
import ClipboardRepository from '../repositories/ClipboardRepository';
import SettingRepository from '../repositories/SettingRepository';
-import TabsClient from '../client/TabsClient';
import ConsoleClient from '../client/ConsoleClient';
+import OperationClient from '../client/OperationClient';
@injectable()
export default class ClipboardUseCase {
constructor(
@inject('ClipboardRepository') private repository: ClipboardRepository,
@inject('SettingRepository') private settingRepository: SettingRepository,
- @inject('TabsClient') private client: TabsClient,
@inject('ConsoleClient') private consoleClient: ConsoleClient,
+ @inject('OperationClient') private operationClinet: OperationClient,
) {
}
@@ -26,6 +26,10 @@ export default class ClipboardUseCase {
let search = this.settingRepository.get().search;
let text = this.repository.read();
let url = urls.searchUrl(text, search);
- await this.client.openUrl(url, newTab);
+
+ // TODO: Repeat pasting from clipboard instead of opening a certain url.
+ // 'Repeat last' command is implemented in the background script and cannot
+ // access to clipboard until Firefox 63.
+ await this.operationClinet.internalOpenUrl(url, newTab);
}
}
diff --git a/src/content/usecases/NavigateUseCase.ts b/src/content/usecases/NavigateUseCase.ts
index 4711c5e..7adccfd 100644
--- a/src/content/usecases/NavigateUseCase.ts
+++ b/src/content/usecases/NavigateUseCase.ts
@@ -24,12 +24,4 @@ export default class NavigateUseCase {
openLinkNext(): void {
this.navigationPresenter.openLinkNext();
}
-
- openParent(): void {
- this.navigationPresenter.openParent();
- }
-
- openRoot(): void {
- this.navigationPresenter.openRoot();
- }
}
diff --git a/src/settings/keymaps.ts b/src/settings/keymaps.ts
index 38045ad..ffe0d48 100644
--- a/src/settings/keymaps.ts
+++ b/src/settings/keymaps.ts
@@ -63,6 +63,7 @@ const fields = [
['zoom.out', 'Zoom-out'],
['zoom.neutral', 'Reset zoom level'],
['page.source', 'Open a page source'],
+ ['repeat.last', 'Repeat last change'],
]
];
diff --git a/src/shared/SettingData.ts b/src/shared/SettingData.ts
index 05e21fa..1c085cf 100644
--- a/src/shared/SettingData.ts
+++ b/src/shared/SettingData.ts
@@ -390,6 +390,7 @@ export const DefaultSettingData: SettingData = SettingData.valueOf({
"/": { "type": "find.start" },
"n": { "type": "find.next" },
"N": { "type": "find.prev" },
+ ".": { "type": "repeat.last" },
"<S-Esc>": { "type": "addon.toggle.enabled" }
},
"search": {
diff --git a/src/shared/Settings.ts b/src/shared/Settings.ts
index c1b5a51..e1e2046 100644
--- a/src/shared/Settings.ts
+++ b/src/shared/Settings.ts
@@ -177,6 +177,7 @@ export const DefaultSetting: Settings = {
'/': { 'type': 'find.start' },
'n': { 'type': 'find.next' },
'N': { 'type': 'find.prev' },
+ '.': { 'type': 'repeat.last' },
'<S-Esc>': { 'type': 'addon.toggle.enabled' }
},
search: {
diff --git a/src/shared/messages.ts b/src/shared/messages.ts
index fbd3478..36a23d8 100644
--- a/src/shared/messages.ts
+++ b/src/shared/messages.ts
@@ -42,6 +42,11 @@ export const SETTINGS_QUERY = 'settings.query';
export const CONSOLE_FRAME_MESSAGE = 'console.frame.message';
+export const NAVIGATE_HISTORY_NEXT = 'navigate.history.next';
+export const NAVIGATE_HISTORY_PREV = 'navigate.history.prev';
+export const NAVIGATE_LINK_NEXT = 'navigate.link.next';
+export const NAVIGATE_LINK_PREV = 'navigate.link.prev';
+
export interface BackgroundOperationMessage {
type: typeof BACKGROUND_OPERATION;
operation: operations.Operation;
@@ -204,6 +209,22 @@ export interface ConsoleFrameMessageMessage {
message: any;
}
+export interface NavigateHistoryNextMessage {
+ type: typeof NAVIGATE_HISTORY_NEXT;
+}
+
+export interface NavigateHistoryPrevMessage {
+ type: typeof NAVIGATE_HISTORY_PREV;
+}
+
+export interface NavigateLinkNext {
+ type: typeof NAVIGATE_LINK_NEXT;
+}
+
+export interface NavigateLinkPrev {
+ type: typeof NAVIGATE_LINK_PREV;
+}
+
export type Message =
BackgroundOperationMessage |
ConsoleUnfocusMessage |
@@ -236,7 +257,11 @@ export type Message =
OpenUrlMessage |
SettingsChangedMessage |
SettingsQueryMessage |
- ConsoleFrameMessageMessage;
+ ConsoleFrameMessageMessage |
+ NavigateHistoryNextMessage |
+ NavigateHistoryPrevMessage |
+ NavigateLinkNext |
+ NavigateLinkPrev;
// eslint-disable-next-line complexity
export const valueOf = (o: any): Message => {
@@ -272,6 +297,10 @@ export const valueOf = (o: any): Message => {
case SETTINGS_CHANGED:
case SETTINGS_QUERY:
case CONSOLE_FRAME_MESSAGE:
+ case NAVIGATE_HISTORY_NEXT:
+ case NAVIGATE_HISTORY_PREV:
+ case NAVIGATE_LINK_NEXT:
+ case NAVIGATE_LINK_PREV:
return o;
}
throw new Error('unknown operation type: ' + o.type);
diff --git a/src/shared/operations.ts b/src/shared/operations.ts
index 688c240..2b03d9d 100644
--- a/src/shared/operations.ts
+++ b/src/shared/operations.ts
@@ -75,6 +75,12 @@ export const FIND_PREV = 'find.prev';
export const MARK_SET_PREFIX = 'mark.set.prefix';
export const MARK_JUMP_PREFIX = 'mark.jump.prefix';
+// Repeat
+export const REPEAT_LAST = 'repeat.last';
+
+// Internal
+export const INTERNAL_OPEN_URL = 'internal.open.url';
+
export interface CancelOperation {
type: typeof CANCEL;
}
@@ -291,6 +297,18 @@ export interface MarkJumpPrefixOperation {
type: typeof MARK_JUMP_PREFIX;
}
+export interface RepeatLastOperation {
+ type: typeof REPEAT_LAST;
+}
+
+export interface InternalOpenUrl {
+ type: typeof INTERNAL_OPEN_URL;
+ url: string;
+ newTab?: boolean;
+ newWindow?: boolean;
+ background?: boolean;
+}
+
export type Operation =
CancelOperation |
AddonEnableOperation |
@@ -342,7 +360,9 @@ export type Operation =
FindNextOperation |
FindPrevOperation |
MarkSetPrefixOperation |
- MarkJumpPrefixOperation;
+ MarkJumpPrefixOperation |
+ RepeatLastOperation |
+ InternalOpenUrl;
const assertOptionalBoolean = (obj: any, name: string) => {
if (Object.prototype.hasOwnProperty.call(obj, name) &&
@@ -358,6 +378,13 @@ const assertRequiredNumber = (obj: any, name: string) => {
}
};
+const assertRequiredString = (obj: any, name: string) => {
+ if (!Object.prototype.hasOwnProperty.call(obj, name) ||
+ typeof obj[name] !== 'string') {
+ throw new TypeError(`Missing string parameter '${name}`);
+ }
+};
+
// eslint-disable-next-line complexity, max-lines-per-function
export const valueOf = (o: any): Operation => {
if (!Object.prototype.hasOwnProperty.call(o, 'type')) {
@@ -401,6 +428,18 @@ export const valueOf = (o: any): Operation => {
type: URLS_PASTE,
newTab: Boolean(typeof o.newTab === undefined ? false : o.newTab),
};
+ case INTERNAL_OPEN_URL:
+ assertOptionalBoolean(o, 'newTab');
+ assertOptionalBoolean(o, 'newWindow');
+ assertOptionalBoolean(o, 'background');
+ assertRequiredString(o, 'url');
+ return {
+ type: INTERNAL_OPEN_URL,
+ url: o.url,
+ newTab: Boolean(typeof o.newTab === undefined ? false : o.newTab),
+ newWindow: Boolean(typeof o.newWindow === undefined ? false : o.newWindow), // eslint-disable-line max-len
+ background: Boolean(typeof o.background === undefined ? true : o.background), // eslint-disable-line max-len
+ };
case CANCEL:
case ADDON_ENABLE:
case ADDON_DISABLE:
@@ -441,6 +480,7 @@ export const valueOf = (o: any): Operation => {
case FIND_PREV:
case MARK_SET_PREFIX:
case MARK_JUMP_PREFIX:
+ case REPEAT_LAST:
return { type: o.type };
}
throw new TypeError('unknown operation type: ' + o.type);
diff --git a/test/background/usecases/NavigateUseCase.test.ts b/test/background/usecases/NavigateUseCase.test.ts
new file mode 100644
index 0000000..ecbf888
--- /dev/null
+++ b/test/background/usecases/NavigateUseCase.test.ts
@@ -0,0 +1,82 @@
+import TabPresenter from '../../../src/background/presenters/TabPresenter';
+import NavigateUseCase from '../../../src/background/usecases/NavigateUseCase';
+import NavigateClient from '../../../src/background/clients/NavigateClient';
+// import { expect } from 'chai';
+import * as sinon from 'sinon';
+
+describe('NavigateUseCase', () => {
+ let sut: NavigateUseCase;
+ let tabPresenter: TabPresenter;
+ let navigateClient: NavigateClient;
+ beforeEach(() => {
+ tabPresenter = new TabPresenter();
+ navigateClient = new NavigateClient();
+ sut = new NavigateUseCase(tabPresenter, navigateClient);
+ });
+
+ describe('#openParent()', async () => {
+ it('opens parent directory of file', async() => {
+ var stub = sinon.stub(tabPresenter, 'getCurrent');
+ stub.returns(Promise.resolve({ url: 'https://google.com/fruits/yellow/banana' }))
+
+ var mock = sinon.mock(tabPresenter);
+ mock.expects('open').withArgs('https://google.com/fruits/yellow/');
+
+ await sut.openParent();
+
+ mock.verify();
+ });
+
+ it('opens parent directory of directory', async() => {
+ var stub = sinon.stub(tabPresenter, 'getCurrent');
+ stub.returns(Promise.resolve({ url: 'https://google.com/fruits/yellow/' }))
+
+ var mock = sinon.mock(tabPresenter);
+ mock.expects('open').withArgs('https://google.com/fruits/');
+
+ await sut.openParent();
+
+ mock.verify();
+ });
+
+ it('removes hash', async() => {
+ var stub = sinon.stub(tabPresenter, 'getCurrent');
+ stub.returns(Promise.resolve({ url: 'https://google.com/#top' }))
+
+ var mock = sinon.mock(tabPresenter);
+ mock.expects('open').withArgs('https://google.com/');
+
+ await sut.openParent();
+
+ mock.verify();
+ });
+
+ it('removes search query', async() => {
+ var stub = sinon.stub(tabPresenter, 'getCurrent');
+ stub.returns(Promise.resolve({ url: 'https://google.com/search?q=apple' }))
+
+ var mock = sinon.mock(tabPresenter);
+ mock.expects('open').withArgs('https://google.com/search');
+
+ await sut.openParent();
+
+ mock.verify();
+ });
+ });
+
+ describe('#openRoot()', () => {
+ it('opens root direectory', async() => {
+ var stub = sinon.stub(tabPresenter, 'getCurrent');
+ stub.returns(Promise.resolve({
+ url: 'https://google.com/seach?q=apple',
+ }))
+
+ var mock = sinon.mock(tabPresenter);
+ mock.expects('open').withArgs('https://google.com');
+
+ await sut.openRoot();
+
+ mock.verify();
+ });
+ });
+});
diff --git a/test/content/presenters/NavigationPresenter.test.ts b/test/content/presenters/NavigationPresenter.test.ts
index c1aca9a..5b6a8ca 100644
--- a/test/content/presenters/NavigationPresenter.test.ts
+++ b/test/content/presenters/NavigationPresenter.test.ts
@@ -132,13 +132,4 @@ describe('NavigationPresenter', () => {
'<a href="#dummy">next page</a><a rel="next" href="#next">click me</a>'
));
});
-
- describe('#parent', () => {
- // NOTE: not able to test location
- it('removes hash', () => {
- window.location.hash = '#section-1';
- sut.openParent();
- expect(document.location.hash).to.be.empty;
- });
- });
});
diff --git a/test/content/usecases/ClipboardUseCase.test.ts b/test/content/usecases/ClipboardUseCase.test.ts
index 551c3f7..a863651 100644
--- a/test/content/usecases/ClipboardUseCase.test.ts
+++ b/test/content/usecases/ClipboardUseCase.test.ts
@@ -1,82 +1,69 @@
import ClipboardRepository from '../../../src/content/repositories/ClipboardRepository';
import { SettingRepositoryImpl } from '../../../src/content/repositories/SettingRepository';
-import TabsClient from '../../../src/content/client/TabsClient';
-import MockConsoleClient from '../mock/MockConsoleClient';
import ClipboardUseCase from '../../../src/content/usecases/ClipboardUseCase';
-import { expect } from 'chai';
-
-class MockClipboardRepository implements ClipboardRepository {
- public clipboard: string;
-
- constructor() {
- this.clipboard = '';
- }
-
- read(): string {
- return this.clipboard;
- }
-
- write(text: string): void {
- this.clipboard = text;
- }
-}
-
-class MockTabsClient implements TabsClient {
- public last: string;
-
- constructor() {
- this.last = '';
- }
+import OperationClient from '../../../src/content/client/OperationClient';
+import ConsoleClient from '../../../src/content/client/ConsoleClient';
- openUrl(url: string, _newTab: boolean): Promise<void> {
- this.last = url;
- return Promise.resolve();
- }
-}
+import * as sinon from 'sinon';
+import { expect } from 'chai';
describe('ClipboardUseCase', () => {
- let repository: MockClipboardRepository;
- let client: MockTabsClient;
- let consoleClient: MockConsoleClient;
+ let clipboardRepository: ClipboardRepository;
+ let operationClient: OperationClient;
+ let consoleClient: ConsoleClient;
let sut: ClipboardUseCase;
beforeEach(() => {
- repository = new MockClipboardRepository();
- client = new MockTabsClient();
- consoleClient = new MockConsoleClient();
+ var modal = <ConsoleClient>{};
+
+ clipboardRepository = <ClipboardRepository>{ read() {}, write(_) {} };
+ operationClient = <OperationClient>{ internalOpenUrl(_) {} };
+ consoleClient = <ConsoleClient>{ info(_) {}};
sut = new ClipboardUseCase(
- repository,
+ clipboardRepository,
new SettingRepositoryImpl(),
- client,
- consoleClient
+ consoleClient,
+ operationClient,
);
});
describe('#yankCurrentURL', () => {
it('yanks current url', async () => {
+ let href = window.location.href;
+ var mockRepository = sinon.mock(clipboardRepository);
+ mockRepository.expects('write').withArgs(href);
+ var mockConsoleClient = sinon.mock(consoleClient);
+ mockConsoleClient.expects('info').withArgs('Yanked ' + href);
+
let yanked = await sut.yankCurrentURL();
- expect(yanked).to.equal(window.location.href);
- expect(repository.clipboard).to.equal(yanked);
- expect(consoleClient.text).to.equal('Yanked ' + yanked);
+ expect(yanked).to.equal(href);
+ mockRepository.verify();
+ mockConsoleClient.verify();
});
});
describe('#openOrSearch', () => {
it('opens url from the clipboard', async () => {
let url = 'https://github.com/ueokande/vim-vixen'
- repository.clipboard = url;
+ sinon.stub(clipboardRepository, 'read').returns(url);
+ let mockOperationClient = sinon.mock(operationClient);
+ mockOperationClient.expects('internalOpenUrl').withArgs(url, true);
+
await sut.openOrSearch(true);
- expect(client.last).to.equal(url);
+ mockOperationClient.verify();
});
it('opens search results from the clipboard', async () => {
- repository.clipboard = 'banana';
+ let url = 'https://google.com/search?q=banana';
+ sinon.stub(clipboardRepository, 'read').returns('banana');
+ let mockOperationClient = sinon.mock(operationClient);
+ mockOperationClient.expects('internalOpenUrl').withArgs(url, true);
+
await sut.openOrSearch(true);
- expect(client.last).to.equal('https://google.com/search?q=banana');
+ mockOperationClient.verify();
});
});
});
-