js api improvements: responnders, communication protocol
Summary: A few improvements to JS API: 1) non-dummy responders - now we can reply to flipper 2) respecting flipper communication protocol: getPlugins, getBackgroundplugins, init, deinit, execute adding linters Reviewed By: jknoxville Differential Revision: D22307525 fbshipit-source-id: 2f629210f398d118cc0cb99097c9d473bb466e57
This commit is contained in:
committed by
Facebook GitHub Bot
parent
3814c8fdfc
commit
7dbcfc89b0
4
flipper-js-client-sdk/.eslintignore
Normal file
4
flipper-js-client-sdk/.eslintignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
*.bundle.js
|
||||||
|
node_modules
|
||||||
|
lib
|
||||||
|
!.eslintrc.js
|
||||||
90
flipper-js-client-sdk/.eslintrc.js
Normal file
90
flipper-js-client-sdk/.eslintrc.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*
|
||||||
|
* @format
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fbjs = require('eslint-config-fbjs');
|
||||||
|
|
||||||
|
// enforces copyright header and @format directive to be present in every file
|
||||||
|
const pattern = /^\*\r?\n[\S\s]*Facebook[\S\s]* \* @format\r?\n/;
|
||||||
|
|
||||||
|
const prettierConfig = {
|
||||||
|
// arrowParens=always is the default for Prettier 2.0, but other configs
|
||||||
|
// at Facebook appear to be leaking into this file, which is still on
|
||||||
|
// Prettier 1.x at the moment, so it is best to be explicit.
|
||||||
|
arrowParens: 'always',
|
||||||
|
requirePragma: true,
|
||||||
|
singleQuote: true,
|
||||||
|
trailingComma: 'all',
|
||||||
|
bracketSpacing: false,
|
||||||
|
jsxBracketSameLine: true,
|
||||||
|
parser: 'flow',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parser: 'babel-eslint',
|
||||||
|
root: true,
|
||||||
|
extends: ['fbjs', 'prettier'],
|
||||||
|
plugins: [
|
||||||
|
...fbjs.plugins,
|
||||||
|
'header',
|
||||||
|
'prettier',
|
||||||
|
'@typescript-eslint',
|
||||||
|
'import',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
// disable rules from eslint-config-fbjs
|
||||||
|
'no-new': 0, // new keyword needed e.g. new Notification
|
||||||
|
'no-catch-shadow': 0, // only relevant for IE8 and below
|
||||||
|
'no-bitwise': 0, // bitwise operations needed in some places
|
||||||
|
'consistent-return': 0,
|
||||||
|
'no-var': 2,
|
||||||
|
'prefer-const': [2, {destructuring: 'all'}],
|
||||||
|
'prefer-spread': 1,
|
||||||
|
'prefer-rest-params': 1,
|
||||||
|
'no-console': 0, // we're setting window.console in App.js
|
||||||
|
'no-multi-spaces': 2,
|
||||||
|
'prefer-promise-reject-errors': 1,
|
||||||
|
'no-throw-literal': 'error',
|
||||||
|
'no-extra-boolean-cast': 2,
|
||||||
|
'no-extra-semi': 2,
|
||||||
|
'no-unsafe-negation': 2,
|
||||||
|
'no-useless-computed-key': 2,
|
||||||
|
'no-useless-rename': 2,
|
||||||
|
|
||||||
|
// additional rules for this project
|
||||||
|
'header/header': [2, 'block', {pattern}],
|
||||||
|
'prettier/prettier': [2, prettierConfig],
|
||||||
|
'flowtype/object-type-delimiter': [0],
|
||||||
|
'import/no-unresolved': [2, {commonjs: true, amd: true}],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'import/resolver': {
|
||||||
|
typescript: {
|
||||||
|
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['*.tsx', '*.ts'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
rules: {
|
||||||
|
'prettier/prettier': [2, {...prettierConfig, parser: 'typescript'}],
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
ignoreRestSiblings: true,
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
caughtErrorsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "flipper-client-sdk",
|
"name": "flipper-client-sdk",
|
||||||
"version": "0.0.1",
|
"version": "0.0.2",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"types": "lib/index.d.ts",
|
"types": "lib/index.d.ts",
|
||||||
"title": "Flipper SDK API",
|
"title": "Flipper SDK API",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"reset": "rimraf lib *.tsbuildinfo",
|
"reset": "rimraf lib *.tsbuildinfo",
|
||||||
"build": "tsc -b"
|
"build": "tsc -b",
|
||||||
|
"fix": "eslint . --fix --ext .js,.ts,.tsx",
|
||||||
|
"lint:tsc": "tsc --noemit",
|
||||||
|
"lint:eslint": "eslint . --ext .js,.ts,.tsx",
|
||||||
|
"lint": "yarn lint:eslint && yarn lint:tsc"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -23,6 +27,24 @@
|
|||||||
"licenseFilename": "LICENSE",
|
"licenseFilename": "LICENSE",
|
||||||
"readmeFilename": "README.md",
|
"readmeFilename": "README.md",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^3.9.2"
|
"typescript": "^3.9.2",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^2.27.0",
|
||||||
|
"@typescript-eslint/parser": "^2.19.2",
|
||||||
|
"ansi-to-html": "^0.6.3",
|
||||||
|
"babel-eslint": "^10.0.1",
|
||||||
|
"eslint": "^6.7.0",
|
||||||
|
"eslint-config-fbjs": "^3.1.1",
|
||||||
|
"eslint-config-prettier": "^6.10.1",
|
||||||
|
"eslint-import-resolver-typescript": "^2.0.0",
|
||||||
|
"eslint-plugin-babel": "^5.3.0",
|
||||||
|
"eslint-plugin-flowtype": "^4.7.0",
|
||||||
|
"eslint-plugin-header": "^3.0.0",
|
||||||
|
"eslint-plugin-import": "^2.19.1",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.0.3",
|
||||||
|
"eslint-plugin-node": "^11.1.0",
|
||||||
|
"eslint-plugin-prettier": "^3.1.2",
|
||||||
|
"eslint-plugin-react": "^7.20.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.0.4",
|
||||||
|
"prettier": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,47 +12,65 @@ export type FlipperPluginID = string;
|
|||||||
export type FlipperMethodID = string;
|
export type FlipperMethodID = string;
|
||||||
|
|
||||||
export class FlipperResponder {
|
export class FlipperResponder {
|
||||||
pluginId: FlipperPluginID;
|
messageID?: number;
|
||||||
methodId: FlipperMethodID;
|
private client: FlipperClient;
|
||||||
private _client: FlipperClient;
|
|
||||||
|
|
||||||
constructor(
|
constructor(messageID: number, client: FlipperClient) {
|
||||||
pluginId: FlipperPluginID,
|
this.messageID = messageID;
|
||||||
methodId: FlipperMethodID,
|
this.client = client;
|
||||||
client: FlipperClient
|
|
||||||
) {
|
|
||||||
this.pluginId = pluginId;
|
|
||||||
this.methodId = methodId;
|
|
||||||
this._client = client;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
success(_response: any) {}
|
success(response?: any) {
|
||||||
|
this.client.sendData({id: this.messageID, success: response});
|
||||||
|
}
|
||||||
|
|
||||||
error(_response: any) {}
|
error(response?: any) {
|
||||||
|
this.client.sendData({id: this.messageID, error: response});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FlipperReceiver<T> = (
|
export type FlipperReceiver = (
|
||||||
params: T,
|
params: any,
|
||||||
responder: FlipperResponder,
|
responder: FlipperResponder,
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
export class FlipperConnection {
|
export class FlipperConnection {
|
||||||
pluginId: FlipperPluginID;
|
pluginId: FlipperPluginID;
|
||||||
private client: FlipperClient;
|
private client: FlipperClient;
|
||||||
|
private subscriptions: Map<FlipperMethodID, FlipperReceiver> = new Map();
|
||||||
|
|
||||||
constructor(pluginId: FlipperPluginID, client: FlipperClient) {
|
constructor(pluginId: FlipperPluginID, client: FlipperClient) {
|
||||||
this.pluginId = pluginId;
|
this.pluginId = pluginId;
|
||||||
this.client = client;
|
this.client = client;
|
||||||
}
|
}
|
||||||
|
|
||||||
send(method: FlipperMethodID, data: any) {
|
send(method: FlipperMethodID, params: any) {
|
||||||
this.client.sendData(this.pluginId, method, data);
|
this.client.sendData({
|
||||||
|
method: 'execute',
|
||||||
|
params: {
|
||||||
|
api: this.pluginId,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
receive<T>(method: FlipperMethodID, receiver: FlipperReceiver<T>) {
|
receive(method: FlipperMethodID, receiver: FlipperReceiver) {
|
||||||
this.client.subscribe(this.pluginId, method, (data: T) => {
|
this.subscriptions.set(method, receiver);
|
||||||
receiver(data, new FlipperResponder(this.pluginId, method, this.client));
|
}
|
||||||
});
|
|
||||||
|
call(method: FlipperMethodID, params: any, responder: FlipperResponder) {
|
||||||
|
const receiver = this.subscriptions.get(method);
|
||||||
|
if (receiver == null) {
|
||||||
|
const errorMessage = `Receiver ${method} not found.`;
|
||||||
|
responder.error({message: errorMessage});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
receiver.call(receiver, params, responder);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasReceiver(method: FlipperMethodID): boolean {
|
||||||
|
return this.subscriptions.has(method);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,28 +100,14 @@ export interface FlipperPlugin {
|
|||||||
runInBackground(): boolean;
|
runInBackground(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class AbstractFlipperPlugin implements FlipperPlugin{
|
|
||||||
protected connection: FlipperConnection | null | undefined;
|
|
||||||
|
|
||||||
onConnect(connection: FlipperConnection): void {
|
|
||||||
this.connection = connection;
|
|
||||||
}
|
|
||||||
onDisconnect(): void {
|
|
||||||
this.connection = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract getId(): string;
|
|
||||||
abstract runInBackground(): boolean;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export abstract class FlipperClient {
|
export abstract class FlipperClient {
|
||||||
_isConnected: boolean = false;
|
private _isConnected: boolean = false;
|
||||||
plugins: Map<FlipperPluginID, FlipperPlugin> = new Map();
|
protected plugins: Map<FlipperPluginID, FlipperPlugin> = new Map();
|
||||||
|
protected connections: Map<FlipperPluginID, FlipperConnection> = new Map();
|
||||||
|
|
||||||
addPlugin(plugin: FlipperPlugin) {
|
addPlugin(plugin: FlipperPlugin) {
|
||||||
if (this._isConnected) {
|
if (this._isConnected) {
|
||||||
plugin.onConnect(new FlipperConnection(plugin.getId(), this));
|
this.connectPlugin(plugin);
|
||||||
}
|
}
|
||||||
this.plugins.set(plugin.getId(), plugin);
|
this.plugins.set(plugin.getId(), plugin);
|
||||||
}
|
}
|
||||||
@@ -117,31 +121,130 @@ export abstract class FlipperClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._isConnected = true;
|
this._isConnected = true;
|
||||||
Array.from(this.plugins.values()).map((plugin) =>
|
Array.from(this.plugins.values())
|
||||||
plugin.onConnect(new FlipperConnection(plugin.getId(), this)),
|
.filter((plugin) => plugin.runInBackground())
|
||||||
);
|
.map(this.connectPlugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
onDisconnect() {
|
onDisconnect() {
|
||||||
this._isConnected = false;
|
this._isConnected = false;
|
||||||
Array.from(this.plugins.values()).map((plugin) => plugin.onDisconnect());
|
Array.from(this.plugins.values()).map(this.disconnectPlugin);
|
||||||
}
|
}
|
||||||
|
|
||||||
abstract start: (appName: string) => void;
|
abstract start(appName: string): void;
|
||||||
|
|
||||||
abstract stop: () => void;
|
abstract stop(): void;
|
||||||
|
|
||||||
abstract sendData: (
|
abstract sendData(payload: any): void;
|
||||||
plugin: FlipperPluginID,
|
|
||||||
method: FlipperMethodID,
|
|
||||||
data: any,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
abstract subscribe: <T>(
|
abstract isAvailable(): boolean;
|
||||||
plugin: FlipperPluginID,
|
|
||||||
method: FlipperMethodID,
|
|
||||||
handler: (message: T) => void,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
abstract isAvailable: () => boolean;
|
protected onMessageReceived(message: {
|
||||||
|
method: string;
|
||||||
|
id: number;
|
||||||
|
params: any;
|
||||||
|
}) {
|
||||||
|
let responder: FlipperResponder | undefined;
|
||||||
|
try {
|
||||||
|
const {method, params, id} = message;
|
||||||
|
responder = new FlipperResponder(id, this);
|
||||||
|
|
||||||
|
if (method === 'getPlugins') {
|
||||||
|
responder.success({plugins: [...this.plugins.keys()]});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'getBackgroundPlugins') {
|
||||||
|
responder.success({
|
||||||
|
plugins: [...this.plugins.keys()].filter((key) =>
|
||||||
|
this.plugins.get(key)?.runInBackground(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'init') {
|
||||||
|
const identifier = params['plugin'] as string;
|
||||||
|
const plugin = this.plugins.get(identifier);
|
||||||
|
if (plugin == null) {
|
||||||
|
const errorMessage = `Plugin ${identifier} not found for method ${method}`;
|
||||||
|
responder.error({message: errorMessage, name: 'PluginNotFound'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectPlugin(plugin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'deinit') {
|
||||||
|
const identifier = params['plugin'] as string;
|
||||||
|
const plugin = this.plugins.get(identifier);
|
||||||
|
if (plugin == null) {
|
||||||
|
const errorMessage = `Plugin ${identifier} not found for method ${method}`;
|
||||||
|
responder.error({message: errorMessage, name: 'PluginNotFound'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disconnectPlugin(plugin);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'execute') {
|
||||||
|
const identifier = params['api'] as string;
|
||||||
|
const connection = this.connections.get(identifier);
|
||||||
|
if (connection == null) {
|
||||||
|
const errorMessage = `Connection ${identifier} not found for plugin identifier`;
|
||||||
|
|
||||||
|
responder.error({message: errorMessage, name: 'ConnectionNotFound'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.call(
|
||||||
|
params['method'] as string,
|
||||||
|
params['params'],
|
||||||
|
responder,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'isMethodSupported') {
|
||||||
|
const identifier = params['api'].getString();
|
||||||
|
const connection = this.connections.get(identifier);
|
||||||
|
if (connection == null) {
|
||||||
|
const errorMessage = `Connection ${identifier} not found for plugin identifier`;
|
||||||
|
|
||||||
|
responder.error({message: errorMessage, name: 'ConnectionNotFound'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isSupported = connection.hasReceiver(
|
||||||
|
params['method'].getString(),
|
||||||
|
);
|
||||||
|
responder.success({isSupported: isSupported});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = {message: 'Received unknown method: ' + method};
|
||||||
|
responder.error(response);
|
||||||
|
} catch (e) {
|
||||||
|
if (responder) {
|
||||||
|
responder.error({
|
||||||
|
message: 'Unknown error during ' + JSON.stringify(message),
|
||||||
|
name: 'Unknown',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private connectPlugin(plugin: FlipperPlugin): void {
|
||||||
|
const id = plugin.getId();
|
||||||
|
const connection = new FlipperConnection(id, this);
|
||||||
|
plugin.onConnect(connection);
|
||||||
|
this.connections.set(id, connection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private disconnectPlugin(plugin: FlipperPlugin): void {
|
||||||
|
const id = plugin.getId();
|
||||||
|
plugin.onDisconnect();
|
||||||
|
this.connections.delete(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,19 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {FlipperClient, AbstractFlipperPlugin} from './api';
|
import {FlipperClient, FlipperConnection, FlipperPlugin} from './api';
|
||||||
import {newWebviewClient} from './webviewImpl';
|
import {newWebviewClient} from './webviewImpl';
|
||||||
|
|
||||||
class SeaMammalPlugin extends AbstractFlipperPlugin {
|
export class SeaMammalPlugin implements FlipperPlugin {
|
||||||
|
protected connection: FlipperConnection | null | undefined;
|
||||||
|
|
||||||
|
onConnect(connection: FlipperConnection): void {
|
||||||
|
this.connection = connection;
|
||||||
|
}
|
||||||
|
onDisconnect(): void {
|
||||||
|
this.connection = null;
|
||||||
|
}
|
||||||
|
|
||||||
getId(): string {
|
getId(): string {
|
||||||
return 'sea-mammals';
|
return 'sea-mammals';
|
||||||
}
|
}
|
||||||
@@ -19,10 +28,9 @@ class SeaMammalPlugin extends AbstractFlipperPlugin {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
newRow(row: {id: string, url: string, title: string}) {
|
newRow(row: {id: string; url: string; title: string}) {
|
||||||
this.connection?.send("newRow", row)
|
this.connection?.send('newRow', row);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class FlipperManager {
|
class FlipperManager {
|
||||||
|
|||||||
@@ -9,8 +9,6 @@
|
|||||||
|
|
||||||
import {FlipperClient} from './api';
|
import {FlipperClient} from './api';
|
||||||
|
|
||||||
import type {FlipperPluginID, FlipperMethodID} from './api';
|
|
||||||
|
|
||||||
class FlipperWebviewClient extends FlipperClient {
|
class FlipperWebviewClient extends FlipperClient {
|
||||||
_subscriptions: Map<string, (message: any) => void> = new Map();
|
_subscriptions: Map<string, (message: any) => void> = new Map();
|
||||||
_client: FlipperClient | null = null;
|
_client: FlipperClient | null = null;
|
||||||
@@ -26,31 +24,14 @@ class FlipperWebviewClient extends FlipperClient {
|
|||||||
bridge?.FlipperWebviewBridge.stop();
|
bridge?.FlipperWebviewBridge.stop();
|
||||||
};
|
};
|
||||||
|
|
||||||
sendData = (plugin: FlipperPluginID, method: FlipperMethodID, data: any) => {
|
sendData = (data: any) => {
|
||||||
const bridge = (window as any).FlipperWebviewBridge;
|
const bridge = (window as any).FlipperWebviewBridge;
|
||||||
bridge && bridge.sendFlipperObject(plugin, method, JSON.stringify(data));
|
bridge && bridge.sendFlipperObject(data);
|
||||||
};
|
|
||||||
|
|
||||||
subscribe = (
|
|
||||||
plugin: FlipperPluginID,
|
|
||||||
method: FlipperMethodID,
|
|
||||||
handler: (msg: any) => void,
|
|
||||||
) => {
|
|
||||||
this._subscriptions.set(plugin + method, handler);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
isAvailable = () => {
|
isAvailable = () => {
|
||||||
return (window as any).FlipperWebviewBridge != null;
|
return (window as any).FlipperWebviewBridge != null;
|
||||||
};
|
};
|
||||||
|
|
||||||
receive(plugin: FlipperPluginID, method: FlipperMethodID, data: string) {
|
|
||||||
const handler = this._subscriptions.get(plugin + method);
|
|
||||||
handler && handler(JSON.parse(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
setClient(client: FlipperClient) {
|
|
||||||
this._client = client;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newWebviewClient(): FlipperClient {
|
export function newWebviewClient(): FlipperClient {
|
||||||
|
|||||||
@@ -18,7 +18,6 @@
|
|||||||
"dom",
|
"dom",
|
||||||
"es2017"
|
"es2017"
|
||||||
],
|
],
|
||||||
"composite": true,
|
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"allowJs": true
|
"allowJs": true
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user