Add client: VSCode. (#21)
parent
c990ba843f
commit
e992a0144b
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 6,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"rules": {
|
||||
"@typescript-eslint/naming-convention": "warn",
|
||||
"@typescript-eslint/semi": "warn",
|
||||
"curly": "warn",
|
||||
"eqeqeq": "warn",
|
||||
"no-throw-literal": "warn",
|
||||
"semi": "off"
|
||||
},
|
||||
"ignorePatterns": ["out", "dist", "**/*.d.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
out
|
||||
dist
|
||||
node_modules
|
||||
.vscode-test/
|
||||
*.vsix
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
// See http://go.microsoft.com/fwlink/?LinkId=827846
|
||||
// for the documentation about the extensions.json format
|
||||
"recommendations": ["dbaeumer.vscode-eslint", "amodio.tsl-problem-matcher"]
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// A launch configuration that compiles the extension and then opens it inside a new window
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run Extension without Local Extentions",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}", "--disable-extensions"],
|
||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
|
||||
"preLaunchTask": "${defaultBuildTask}"
|
||||
},
|
||||
{
|
||||
"name": "Run Extension",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
|
||||
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
|
||||
"preLaunchTask": "${defaultBuildTask}"
|
||||
},
|
||||
{
|
||||
"name": "Extension Tests",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--extensionTestsPath=${workspaceFolder}/out/test/suite/index"
|
||||
],
|
||||
"outFiles": ["${workspaceFolder}/out/**/*.js", "${workspaceFolder}/dist/**/*.js"],
|
||||
"preLaunchTask": "tasks: watch-tests"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
// Place your settings in this file to overwrite default and user settings.
|
||||
{
|
||||
"files.exclude": {
|
||||
"out": false, // set this to true to hide the "out" folder with the compiled JS files
|
||||
"dist": false // set this to true to hide the "dist" folder with the compiled JS files
|
||||
},
|
||||
"search.exclude": {
|
||||
"out": true, // set this to false to include "out" folder in search results
|
||||
"dist": true // set this to false to include "dist" folder in search results
|
||||
},
|
||||
// Turn off tsc task auto detection since we have the necessary tasks as npm scripts
|
||||
"typescript.tsc.autoDetect": "off",
|
||||
"terminal.integrated.profiles.windows": {
|
||||
"PowerShell": {
|
||||
"source": "PowerShell",
|
||||
"icon": "terminal-powershell"
|
||||
},
|
||||
"Command Prompt": {
|
||||
"path": ["${env:windir}\\Sysnative\\cmd.exe", "${env:windir}\\System32\\cmd.exe"],
|
||||
"args": [],
|
||||
"icon": "terminal-cmd"
|
||||
},
|
||||
"Git Bash": {
|
||||
"source": "Git Bash"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
// for the documentation about the tasks.json format
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "watch",
|
||||
"problemMatcher": "$ts-webpack-watch",
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"reveal": "never",
|
||||
"group": "watchers"
|
||||
},
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "watch-tests",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"reveal": "never",
|
||||
"group": "watchers"
|
||||
},
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "tasks: watch-tests",
|
||||
"dependsOn": ["npm: watch", "npm: watch-tests"],
|
||||
"problemMatcher": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
.vscode/**
|
||||
.vscode-test/**
|
||||
out/**
|
||||
node_modules/**
|
||||
src/**
|
||||
.gitignore
|
||||
.yarnrc
|
||||
webpack.config.js
|
||||
**/tsconfig.json
|
||||
**/.eslintrc.json
|
||||
**/*.map
|
||||
**/*.ts
|
||||
|
|
@ -0,0 +1 @@
|
|||
# TODO
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"name": "vscode-tabby",
|
||||
"displayName": "Tabby",
|
||||
"description": "Get completions from Tabby server",
|
||||
"version": "1.0.0",
|
||||
"keywords": [
|
||||
"code-suggestion",
|
||||
"copilot",
|
||||
"code-inference",
|
||||
"tabby"
|
||||
],
|
||||
"engines": {
|
||||
"vscode": "^1.70.0"
|
||||
},
|
||||
"categories": [
|
||||
"Other"
|
||||
],
|
||||
"activationEvents": [
|
||||
"onStartupFinished"
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"contributes": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "tabby.toggleEnabled",
|
||||
"title": "Tabby: Toggle Code Suggestion On/Off"
|
||||
},
|
||||
{
|
||||
"command": "tabby.setServerUrl",
|
||||
"title": "Tabby: Set URL of Tabby Server"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "Tabby",
|
||||
"properties": {
|
||||
"tabby.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable Tabby code suggestion or not."
|
||||
},
|
||||
"tabby.serverUrl": {
|
||||
"type": "string",
|
||||
"default": "http://127.0.0.1:5000",
|
||||
"markdownDescription": "Specifies the url of [Tabby Server](https://github.com/TabbyML/tabby)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "yarn package",
|
||||
"compile": "webpack",
|
||||
"watch": "webpack --watch",
|
||||
"package": "webpack --mode production --devtool hidden-source-map",
|
||||
"compile-tests": "tsc -p . --outDir out",
|
||||
"watch-tests": "tsc -p . -w --outDir out",
|
||||
"pretest": "yarn compile-tests && yarn compile && yarn lint",
|
||||
"lint": "eslint src --ext ts",
|
||||
"test": "node ./out/test/runTest.js",
|
||||
"vscode:package": "vsce package",
|
||||
"vscode:publish": "vsce publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/glob": "^7.2.0",
|
||||
"@types/mocha": "^9.1.1",
|
||||
"@types/node": "16.x",
|
||||
"@types/vscode": "^1.70.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.31.0",
|
||||
"@typescript-eslint/parser": "^5.31.0",
|
||||
"@vscode/test-electron": "^2.1.5",
|
||||
"eslint": "^8.20.0",
|
||||
"glob": "^8.0.3",
|
||||
"mocha": "^10.0.0",
|
||||
"ts-loader": "^9.3.1",
|
||||
"typescript": "^4.7.4",
|
||||
"webpack": "^5.74.0",
|
||||
"webpack-cli": "^4.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.3.4"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { ConfigurationTarget, workspace, window, commands } from "vscode";
|
||||
import { EventHandler } from "./EventHandler";
|
||||
|
||||
const target = ConfigurationTarget.Global;
|
||||
|
||||
type Command = {
|
||||
command: string;
|
||||
callback: (...args: any[]) => any;
|
||||
thisArg?: any;
|
||||
};
|
||||
|
||||
const toogleEnabled: Command = {
|
||||
command: "tabby.toggleEnabled",
|
||||
callback: () => {
|
||||
const configuration = workspace.getConfiguration("tabby");
|
||||
const enabled = configuration.get("enabled", true);
|
||||
console.debug(`Toggle Enabled: ${enabled} -> ${!enabled}.`);
|
||||
configuration.update("enabled", !enabled, target, false);
|
||||
},
|
||||
};
|
||||
|
||||
const setServerUrl: Command = {
|
||||
command: "tabby.setServerUrl",
|
||||
callback: () => {
|
||||
const configuration = workspace.getConfiguration("tabby");
|
||||
window
|
||||
.showInputBox({
|
||||
prompt: "Enter the URL of your Tabby Server",
|
||||
value: configuration.get("serverUrl", ""),
|
||||
})
|
||||
.then((url) => {
|
||||
if (url) {
|
||||
console.debug("Set Tabby Server URL: ", url);
|
||||
configuration.update("serverUrl", url, target, false);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const eventHandler = new EventHandler();
|
||||
const emitEvent: Command = {
|
||||
command: "tabby.emitEvent",
|
||||
callback: (event) => {
|
||||
eventHandler.handle(event);
|
||||
},
|
||||
};
|
||||
|
||||
export const tabbyCommands = [toogleEnabled, setServerUrl, emitEvent].map((command) =>
|
||||
commands.registerCommand(command.command, command.callback, command.thisArg)
|
||||
);
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { workspace } from "vscode";
|
||||
import axios from "axios";
|
||||
|
||||
export enum EventType {
|
||||
InlineCompletionDisplayed,
|
||||
InlineCompletionAccepted,
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
type: EventType,
|
||||
id?: string,
|
||||
index?: number,
|
||||
}
|
||||
|
||||
export class EventHandler {
|
||||
private tabbyServerUrl: string = "";
|
||||
|
||||
constructor() {
|
||||
this.updateConfiguration();
|
||||
workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration("tabby")) {
|
||||
this.updateConfiguration();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handle(event: Event) {
|
||||
console.debug("Event: ", event);
|
||||
switch (event.type) {
|
||||
case EventType.InlineCompletionDisplayed:
|
||||
axios.post(`${this.tabbyServerUrl}/v1/completions/${event.id}/choices/${event.index}/view`);
|
||||
break;
|
||||
case EventType.InlineCompletionAccepted:
|
||||
axios.post(`${this.tabbyServerUrl}/v1/completions/${event.id}/choices/${event.index}/select`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private updateConfiguration() {
|
||||
const configuration = workspace.getConfiguration("tabby");
|
||||
this.tabbyServerUrl = configuration.get("serverUrl", "http://127.0.0.1:5000");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import {
|
||||
CancellationToken,
|
||||
InlineCompletionContext,
|
||||
InlineCompletionItem,
|
||||
InlineCompletionItemProvider,
|
||||
InlineCompletionList,
|
||||
Position,
|
||||
ProviderResult,
|
||||
Range,
|
||||
TextDocument,
|
||||
workspace,
|
||||
} from "vscode";
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import { EventType } from "./EventHandler";
|
||||
|
||||
export class TabbyCompletionProvider implements InlineCompletionItemProvider {
|
||||
private uuid = Date.now();
|
||||
private latestTimestamp: number = 0;
|
||||
|
||||
// User Settings
|
||||
private enabled: boolean = true;
|
||||
private tabbyServerUrl: string = "";
|
||||
|
||||
constructor() {
|
||||
this.updateConfiguration();
|
||||
workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration("tabby")) {
|
||||
this.updateConfiguration();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//@ts-ignore because ASYNC and PROMISE
|
||||
//prettier-ignore
|
||||
public async provideInlineCompletionItems(document: TextDocument, position: Position, context: InlineCompletionContext, token: CancellationToken): ProviderResult<InlineCompletionItem[] | InlineCompletionList> {
|
||||
const emptyResponse = Promise.resolve([] as InlineCompletionItem[]);
|
||||
if (!this.enabled) {
|
||||
console.debug("Extension not enabled, skipping.");
|
||||
return emptyResponse;
|
||||
}
|
||||
|
||||
const prompt = this.getPrompt(document, position);
|
||||
if (this.isNil(prompt)) {
|
||||
console.debug("Prompt is empty, skipping");
|
||||
return emptyResponse;
|
||||
}
|
||||
|
||||
const currentTimestamp = Date.now();
|
||||
this.latestTimestamp = currentTimestamp;
|
||||
|
||||
const suggestionDelay = 150;
|
||||
await this.sleep(suggestionDelay);
|
||||
if (currentTimestamp < this.latestTimestamp) {
|
||||
return emptyResponse;
|
||||
}
|
||||
|
||||
console.debug(
|
||||
"Requesting: ",
|
||||
{
|
||||
uuid: this.uuid,
|
||||
timestamp: currentTimestamp,
|
||||
prompt
|
||||
}
|
||||
);
|
||||
// Prompt is already nil-checked
|
||||
const response = await this.getCompletions(prompt as String);
|
||||
|
||||
const hasSuffixParen = this.hasSuffixParen(document, position);
|
||||
const replaceRange = hasSuffixParen
|
||||
? new Range(
|
||||
position.line,
|
||||
position.character,
|
||||
position.line,
|
||||
position.character + 1
|
||||
)
|
||||
: new Range(position, position);
|
||||
const completions = this.toInlineCompletions(response.data, replaceRange);
|
||||
console.debug("Result completions: ", completions);
|
||||
return Promise.resolve(completions);
|
||||
}
|
||||
|
||||
private updateConfiguration() {
|
||||
const configuration = workspace.getConfiguration("tabby");
|
||||
this.enabled = configuration.get("enabled", true);
|
||||
this.tabbyServerUrl = configuration.get("serverUrl", "http://127.0.0.1:5000");
|
||||
}
|
||||
|
||||
private getPrompt(document: TextDocument, position: Position): String | undefined {
|
||||
const maxLines = 20;
|
||||
const firstLine = Math.max(position.line - maxLines, 0);
|
||||
|
||||
return document.getText(new Range(firstLine, 0, position.line, position.character));
|
||||
}
|
||||
|
||||
private isNil(value: String | undefined | null): boolean {
|
||||
return value === undefined || value === null || value.length === 0;
|
||||
}
|
||||
|
||||
private sleep(milliseconds: number) {
|
||||
return new Promise((r) => setTimeout(r, milliseconds));
|
||||
}
|
||||
|
||||
private toInlineCompletions(value: any, range: Range): InlineCompletionItem[] {
|
||||
return (
|
||||
value.choices?.map(
|
||||
(choice: any) =>
|
||||
new InlineCompletionItem(choice.text, range, {
|
||||
title: "Tabby: Emit Event",
|
||||
command: "tabby.emitEvent",
|
||||
arguments: [
|
||||
{
|
||||
type: EventType.InlineCompletionAccepted,
|
||||
id: value.id,
|
||||
index: choice.index,
|
||||
},
|
||||
],
|
||||
})
|
||||
) || []
|
||||
);
|
||||
}
|
||||
|
||||
private getCompletions(prompt: String): Promise<AxiosResponse<any, any>> {
|
||||
return axios.post(`${this.tabbyServerUrl}/v1/completions`, {
|
||||
prompt,
|
||||
});
|
||||
}
|
||||
|
||||
private hasSuffixParen(document: TextDocument, position: Position) {
|
||||
const suffix = document.getText(
|
||||
new Range(position.line, position.character, position.line, position.character + 1)
|
||||
);
|
||||
return ")]}".indexOf(suffix) > -1;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// The module 'vscode' contains the VS Code extensibility API
|
||||
// Import the module and reference it with the alias vscode in your code below
|
||||
import { ExtensionContext, languages } from "vscode";
|
||||
import { tabbyCommands } from "./Commands";
|
||||
import { TabbyCompletionProvider } from "./TabbyCompletionProvider";
|
||||
|
||||
// this method is called when your extension is activated
|
||||
// your extension is activated the very first time the command is executed
|
||||
export function activate(context: ExtensionContext) {
|
||||
console.debug("Activating Tabby extension", new Date());
|
||||
context.subscriptions.push(
|
||||
languages.registerInlineCompletionItemProvider(
|
||||
{ pattern: "**" },
|
||||
new TabbyCompletionProvider()
|
||||
),
|
||||
...tabbyCommands
|
||||
);
|
||||
}
|
||||
|
||||
// this method is called when your extension is deactivated
|
||||
export function deactivate() {
|
||||
console.debug("Deactivating Tabby extension", new Date());
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import * as path from "path";
|
||||
|
||||
import { runTests } from "@vscode/test-electron";
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
// The folder containing the Extension Manifest package.json
|
||||
// Passed to `--extensionDevelopmentPath`
|
||||
const extensionDevelopmentPath = path.resolve(__dirname, "../../");
|
||||
|
||||
// The path to test runner
|
||||
// Passed to --extensionTestsPath
|
||||
const extensionTestsPath = path.resolve(__dirname, "./suite/index");
|
||||
|
||||
// Download VS Code, unzip it and run the integration test
|
||||
await runTests({ extensionDevelopmentPath, extensionTestsPath });
|
||||
} catch (err) {
|
||||
console.error("Failed to run tests");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import * as assert from "assert";
|
||||
|
||||
// You can import and use all API from the 'vscode' module
|
||||
// as well as import your extension to test it
|
||||
import * as vscode from "vscode";
|
||||
// import * as myExtension from '../../extension';
|
||||
|
||||
suite("Extension Test Suite", () => {
|
||||
vscode.window.showInformationMessage("Start all tests.");
|
||||
|
||||
test("Sample test", () => {
|
||||
assert.strictEqual(-1, [1, 2, 3].indexOf(5));
|
||||
assert.strictEqual(-1, [1, 2, 3].indexOf(0));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import * as path from "path";
|
||||
import * as Mocha from "mocha";
|
||||
import * as glob from "glob";
|
||||
|
||||
export function run(): Promise<void> {
|
||||
// Create the mocha test
|
||||
const mocha = new Mocha({
|
||||
ui: "tdd",
|
||||
color: true,
|
||||
});
|
||||
|
||||
const testsRoot = path.resolve(__dirname, "..");
|
||||
|
||||
return new Promise((c, e) => {
|
||||
glob("**/**.test.js", { cwd: testsRoot }, (err, files) => {
|
||||
if (err) {
|
||||
return e(err);
|
||||
}
|
||||
|
||||
// Add files to the test suite
|
||||
files.forEach((f) => mocha.addFile(path.resolve(testsRoot, f)));
|
||||
|
||||
try {
|
||||
// Run the mocha test
|
||||
mocha.run((failures) => {
|
||||
if (failures > 0) {
|
||||
e(new Error(`${failures} tests failed.`));
|
||||
} else {
|
||||
c();
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
e(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"strict": true /* enable all strict type-checking options */,
|
||||
/* Additional Checks */
|
||||
"noImplicitReturns": true /* Report error when not all code paths in function return a value. */,
|
||||
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
|
||||
"noUnusedParameters": true /* Report errors on unused parameters. */
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
//@ts-check
|
||||
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
|
||||
//@ts-check
|
||||
/** @typedef {import('webpack').Configuration} WebpackConfig **/
|
||||
|
||||
/** @type WebpackConfig */
|
||||
const extensionConfig = {
|
||||
target: 'node', // VS Code extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/
|
||||
mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production')
|
||||
|
||||
entry: './src/extension.ts', // the entry point of this extension, 📖 -> https://webpack.js.org/configuration/entry-context/
|
||||
output: {
|
||||
// the bundle is stored in the 'dist' folder (check package.json), 📖 -> https://webpack.js.org/configuration/output/
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'extension.js',
|
||||
libraryTarget: 'commonjs2'
|
||||
},
|
||||
externals: {
|
||||
vscode: 'commonjs vscode' // the vscode-module is created on-the-fly and must be excluded. Add other modules that cannot be webpack'ed, 📖 -> https://webpack.js.org/configuration/externals/
|
||||
// modules added here also need to be added in the .vscodeignore file
|
||||
},
|
||||
resolve: {
|
||||
// support reading TypeScript and JavaScript files, 📖 -> https://github.com/TypeStrong/ts-loader
|
||||
extensions: ['.ts', '.js']
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
exclude: /node_modules/,
|
||||
use: [
|
||||
{
|
||||
loader: 'ts-loader'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
devtool: 'nosources-source-map',
|
||||
infrastructureLogging: {
|
||||
level: "log", // enables logging required for problem matchers
|
||||
},
|
||||
};
|
||||
module.exports = [ extensionConfig ];
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue