refactor🎨: (阅读代码):pseudoTerminal.ts增加注释

master
yetao 2 weeks ago
parent 2fe823977d
commit d6700c67ca

@ -1,92 +1,207 @@
// 导入 EnvironmentVariable 和 FileSystemConfig 类型,用于定义环境变量和文件系统配置
import type { EnvironmentVariable, FileSystemConfig } from 'common/fileSystemConfig';
// 导入 path 模块,用于处理路径
import * as path from 'path';
// 导入 ClientChannel 和 PseudoTtyOptions 类型,用于定义客户端通道和伪终端选项
import type { ClientChannel, PseudoTtyOptions } from 'ssh2';
// 导入 vscode 模块,用于访问 VS Code 的 API
import * as vscode from 'vscode';
// 导入 getFlagBoolean 函数,用于获取布尔类型的标志
import { getFlagBoolean } from './flags';
// 导入 Connection 类型,用于定义连接
import type { Connection } from './connection';
// 导入 Logging 和 LOGGING_NO_STACKTRACE 常量,用于日志记录
import { Logging, LOGGING_NO_STACKTRACE } from './logging';
// 导入 environmentToExportString、joinCommands、mergeEnvironment 和 toPromise 函数,用于处理环境变量、命令拼接、环境合并和 Promise 转换
import { environmentToExportString, joinCommands, mergeEnvironment, toPromise } from './utils';
// 定义伪终端的高度和宽度
const [HEIGHT, WIDTH] = [480, 640];
// 定义伪终端的选项对象
const PSEUDO_TTY_OPTIONS: Partial<PseudoTtyOptions> = {
height: HEIGHT, width: WIDTH, term: 'xterm-256color',
// 伪终端的高度
height: HEIGHT,
// 伪终端的宽度
width: WIDTH,
// 伪终端的类型
term: 'xterm-256color',
};
/**
* SSH vscode.Pseudoterminal
* SSH
*/
export interface SSHPseudoTerminal extends vscode.Pseudoterminal {
onDidClose: vscode.Event<number>; // Redeclaring that it isn't undefined
/**
*
*
*/
onDidClose: vscode.Event<number>; // 重新声明它不是未定义的
/**
*
*
*/
onDidOpen: vscode.Event<void>;
handleInput(data: string): void; // We don't support/need read-only terminals for now
/**
*
*
*
*/
handleInput(data: string): void; // 我们现在不支持/不需要只读终端
/**
*
* 'opening''open''closed''wait-to-close'
*/
status: 'opening' | 'open' | 'closed' | 'wait-to-close';
/**
*
* SSH
*/
connection: Connection;
/** Could be undefined if it only gets created during psy.open() instead of beforehand */
/**
*
* createTerminal undefined
*/
channel?: ClientChannel;
/** Either set by the code calling createTerminal, otherwise "calculated" and hopefully found */
/**
* vscode.Terminal
* createTerminal
*/
terminal?: vscode.Terminal;
}
/**
* SSHPseudoTerminal
*
* @param terminal -
* @returns SSHPseudoTerminal true false
*/
export function isSSHPseudoTerminal(terminal: vscode.Pseudoterminal): terminal is SSHPseudoTerminal {
// 将终端对象强制转换为 SSHPseudoTerminal 类型
const term = terminal as SSHPseudoTerminal;
// 检查转换后的对象是否具有 connection、status 和 handleInput 属性
return !!(term.connection && term.status && term.handleInput);
}
/**
*
*/
export interface TerminalOptions {
/**
*
* SSH
*/
connection: Connection;
/**
*
*
*/
environment?: EnvironmentVariable[];
/** If absent, this defaults to config.root if present, otherwise whatever the remote shell picks as default */
/**
*
* shell
*/
workingDirectory?: string;
/** The command to run in the remote shell. If undefined, a (regular interactive) shell is started instead by running $SHELL*/
/**
* shell
* $SHELL shell
*/
command?: string;
}
/**
*
*
* @param value -
* @param config -
* @returns
*/
export function replaceVariables(value: string, config: FileSystemConfig): string {
return value.replace(/\$\{(.*?)\}/g, (str, match: string) => {
// 如果变量不是以 "remote" 开头,则直接返回原字符串
if (!match.startsWith('remote')) return str; // Our variables always start with "remote"
// https://github.com/microsoft/vscode/blob/bebd06640734c37f6d5f1a82b13297ce1d297dd1/src/vs/workbench/services/configurationResolver/common/variableResolver.ts#L156
// 从变量中提取键和参数
const [key, argument] = match.split(':') as [string, string?];
// 获取当前文件的 URI
const getFilePath = (): vscode.Uri => {
// 获取当前活动编辑器的文档 URI
const uri = vscode.window.activeTextEditor?.document?.uri;
// 如果 URI 存在且协议为 ssh则返回该 URI
if (uri && uri.scheme === 'ssh') return uri;
// 如果 URI 存在但协议不是 ssh则抛出错误
if (uri) throw new Error(`Variable ${str}: Active editor is not a ssh:// file`);
// 如果 URI 不存在,则抛出错误
throw new Error(`Variable ${str} can not be resolved. Please open an editor.`);
}
// 获取当前文件所在的工作区文件夹的 URI
const getFolderPathForFile = (): vscode.Uri => {
// 获取当前文件的 URI
const filePath = getFilePath();
// 从工作区中获取包含该文件的文件夹的 URI
const uri = vscode.workspace.getWorkspaceFolder(filePath)?.uri;
// 如果找到了文件夹,则返回其 URI
if (uri) return uri;
// 如果没有找到文件夹,则抛出错误
throw new Error(`Variable ${str}: can not find workspace folder of '${filePath}'.`);
}
// 获取工作区中的所有文件夹
const { workspaceFolders = [] } = vscode.workspace;
// 过滤出所有以 "ssh" 开头的文件夹
const sshFolders = workspaceFolders.filter(ws => ws.uri.scheme === 'ssh');
// 如果只有一个 "ssh" 文件夹,则返回该文件夹,否则返回 undefined
const sshFolder = sshFolders.length === 1 ? sshFolders[0] : undefined;
// 获取指定参数的文件夹 URI
const getFolderUri = (): vscode.Uri => {
// 获取当前工作区的所有文件夹,默认值为空数组
const { workspaceFolders = [] } = vscode.workspace;
// 如果参数存在
if (argument) {
// 在工作区文件夹中查找名称与参数匹配的文件夹
const uri = workspaceFolders.find(ws => ws.name === argument)?.uri;
// 如果找到了匹配的文件夹,并且其 URI 协议为 ssh则返回该 URI
if (uri && uri.scheme === 'ssh') return uri;
// 如果找到了匹配的文件夹,但 URI 协议不是 ssh则抛出错误
if (uri) throw new Error(`Variable ${str}: Workspace folder '${argument}' is not a ssh:// folder`);
// 如果没有找到匹配的文件夹,则抛出错误
throw new Error(`Variable ${str} can not be resolved. No such folder '${argument}'.`);
}
// 如果存在唯一的 ssh 文件夹,则返回其 URI
if (sshFolder) return sshFolder.uri;
// 如果存在多个 ssh 文件夹,则抛出错误
if (sshFolders.length > 1) {
throw new Error(`Variable ${str} can not be resolved in a multi ssh:// folder workspace. Scope this variable using ':' and a workspace folder name.`);
}
// 如果没有找到 ssh 文件夹,则抛出错误
throw new Error(`Variable ${str} can not be resolved. Please open an ssh:// folder.`);
};
// 根据不同的键,返回不同的路径信息
switch (key) {
// 处理 remoteWorkspaceRoot 和 remoteWorkspaceFolder 变量,返回工作区根目录或文件夹的路径
case 'remoteWorkspaceRoot':
case 'remoteWorkspaceFolder':
return getFolderUri().path;
// 处理 remoteWorkspaceRootFolderName 和 remoteWorkspaceFolderBasename 变量,返回工作区根目录或文件夹的名称
case 'remoteWorkspaceRootFolderName':
case 'remoteWorkspaceFolderBasename':
return path.basename(getFolderUri().path);
// 处理 remoteFile 变量,返回当前文件的路径
case 'remoteFile':
return getFilePath().path;
// 处理 remoteFileWorkspaceFolder 变量,返回当前文件所在工作区文件夹的路径
case 'remoteFileWorkspaceFolder':
return getFolderPathForFile().path;
// 处理 remoteRelativeFile 变量,返回当前文件相对于工作区根目录或指定文件夹的路径
case 'remoteRelativeFile':
if (sshFolder || argument)
return path.relative(getFolderUri().path, getFilePath().path);
return getFilePath().path;
// 处理 remoteRelativeFileDirname 变量,返回当前文件所在目录相对于工作区根目录或指定文件夹的路径
case 'remoteRelativeFileDirname': {
const dirname = path.dirname(getFilePath().path);
if (sshFolder || argument) {
@ -95,21 +210,28 @@ export function replaceVariables(value: string, config: FileSystemConfig): strin
}
return dirname;
}
// 处理 remoteFileDirname 变量,返回当前文件所在目录的路径
case 'remoteFileDirname':
return path.dirname(getFilePath().path);
// 处理 remoteFileExtname 变量,返回当前文件的扩展名
case 'remoteFileExtname':
return path.extname(getFilePath().path);
// 处理 remoteFileBasename 变量,返回当前文件的文件名
case 'remoteFileBasename':
return path.basename(getFilePath().path);
// 处理 remoteFileBasenameNoExtension 变量,返回当前文件的文件名(不包含扩展名)
case 'remoteFileBasenameNoExtension': {
const basename = path.basename(getFilePath().path);
return (basename.slice(0, basename.length - path.extname(basename).length));
}
// 处理 remoteFileDirnameBasename 变量,返回当前文件所在目录的文件名
case 'remoteFileDirnameBasename':
return path.basename(path.dirname(getFilePath().path));
// 处理 remotePathSeparator 变量返回路径分隔符POSIX 风格)
case 'remotePathSeparator':
// Not sure if we even need/want this variable, but sure
return path.posix.sep;
// 如果变量未被识别,则记录警告并返回原始字符串
default:
const msg = `Unrecognized task variable '${str}' starting with 'remote', ignoring`;
Logging.warning(msg, LOGGING_NO_STACKTRACE);
@ -119,149 +241,289 @@ export function replaceVariables(value: string, config: FileSystemConfig): strin
});
}
/**
*
*
* @param object -
* @param handler -
* @returns
*/
export async function replaceVariablesRecursive<T>(object: T, handler: (value: string) => string | Promise<string>): Promise<T> {
// 如果对象是字符串,则直接调用处理函数进行替换
if (typeof object === 'string') return handler(object) as any;
// 如果对象是数组,则递归地替换数组中的每个元素
if (Array.isArray(object)) return object.map(v => this.replaceVariablesRecursive(v, handler)) as any;
// 如果对象是对象且不是正则表达式或日期对象,则递归地替换对象中的每个键值对
if (typeof object == 'object' && object !== null && !(object instanceof RegExp) && !(object instanceof Date)) {
// ^ Same requirements VS Code applies: https://github.com/microsoft/vscode/blob/bebd06640734c37f6d5f1a82b13297ce1d297dd1/src/vs/base/common/types.ts#L34
const result: any = {};
for (let key in object) {
// 递归地替换键和值
const value = await replaceVariablesRecursive(object[key], handler);
key = await replaceVariablesRecursive(key, handler);
result[key] = value;
}
return result;
}
// 如果对象不符合上述条件,则直接返回原对象
return object;
}
/**
* SSH
*
* @param options -
* @returns Promise SSHPseudoTerminal
* @throws
*/
export async function createTerminal(options: TerminalOptions): Promise<SSHPseudoTerminal> {
// 从 options 中提取 connection 对象
const { connection } = options;
// 从 connection 中提取 actualConfig、client 和 shellConfig 对象
const { actualConfig, client, shellConfig } = connection;
// 创建一个用于发送数据的事件发射器
const onDidWrite = new vscode.EventEmitter<string>();
// 创建一个用于接收关闭事件的事件发射器
const onDidClose = new vscode.EventEmitter<number>();
// 创建一个用于接收打开事件的事件发射器
const onDidOpen = new vscode.EventEmitter<void>();
// 初始化终端对象为 undefined
let terminal: vscode.Terminal | undefined;
// Won't actually open the remote terminal until pseudo.open(dims) is called
// 创建一个 SSH 伪终端实例
const pseudo: SSHPseudoTerminal = {
// 终端的当前状态,初始值为 'opening'
status: 'opening',
// 终端的连接对象
connection,
// 用于发送数据的事件发射器
onDidWrite: onDidWrite.event,
// 用于接收关闭事件的事件发射器
onDidClose: onDidClose.event,
// 用于接收打开事件的事件发射器
onDidOpen: onDidOpen.event,
close() {
// 获取伪终端的当前状态和连接通道
const { channel, status } = pseudo;
// 如果伪终端已经关闭,则直接返回
if (status === 'closed') return;
// 如果连接通道存在,则发送关闭信号
if (channel) {
// 更新伪终端状态为关闭
pseudo.status = 'closed';
// 发送 INT 信号
channel.signal!('INT');
// 发送 SIGINT 信号
channel.signal!('SIGINT');
// 写入控制字符 \x03通常用于中断进程
channel.write('\x03');
// 关闭连接通道
channel.close();
// 将伪终端的连接通道设置为 undefined
pseudo.channel = undefined;
}
// 如果伪终端的状态为 'wait-to-close',则执行清理操作
if (status === 'wait-to-close') {
// 释放终端资源
pseudo.terminal?.dispose();
// 将终端设置为 undefined
pseudo.terminal = undefined;
// 更新伪终端状态为关闭
pseudo.status = 'closed';
// 触发关闭事件
onDidClose.fire(0);
}
},
/**
* SSH
*
* @param dims -
* @remarks
* onDidOpen
*
*/
async open(dims) {
// 触发 onDidWrite 事件,发送正在连接的消息
onDidWrite.fire(`Connecting to ${actualConfig.label || actualConfig.name}...\r\n`);
try {
// 检查是否需要使用 Windows 命令分隔符
const [useWinCmdSep] = getFlagBoolean('WINDOWS_COMMAND_SEPARATOR', shellConfig.isWindows, actualConfig.flags);
// 根据是否使用 Windows 命令分隔符设置分隔符
const separator = useWinCmdSep ? ' && ' : '; ';
// 初始化命令列表
let commands: string[] = [];
// 设置默认的 shell 命令
let SHELL = '$SHELL';
// 如果是 Windows 环境,则使用指定的 shell
if (shellConfig.isWindows) SHELL = shellConfig.shell;
// Add exports for environment variables if needed
// 合并连接的环境变量和选项中的环境变量
const env = mergeEnvironment(connection.environment, options.environment);
// 将环境变量导出为字符串并添加到命令列表中
commands.push(environmentToExportString(env, shellConfig.setEnv));
// Beta feature to add a "code <file>" command in terminals to open the file locally
// 如果启用了远程命令功能,则执行相应的操作
if (getFlagBoolean('REMOTE_COMMANDS', false, actualConfig.flags)[0] && shellConfig.setupRemoteCommands) {
// 获取远程命令配置并执行
const rcCmds = await shellConfig.setupRemoteCommands(connection);
// 如果有远程命令,则将其添加到命令列表中
if (rcCmds?.length) commands.push(joinCommands(rcCmds, separator)!);
}
// Push the actual command or (default) shell command with replaced variables
// 如果用户指定了命令,则将其添加到命令列表中
if (options.command) {
// 使用实际配置中的变量替换命令中的变量,并将结果添加到命令列表中
commands.push(replaceVariables(options.command.replace(/$SHELL/g, SHELL), actualConfig));
} else {
}
// 如果用户没有指定命令,则使用配置中的终端命令
else {
// 将实际配置中的终端命令使用分隔符连接起来
const tc = joinCommands(actualConfig.terminalCommand, separator);
// 如果连接后的终端命令存在,则使用它,否则使用默认的 shell 命令
let cmd = tc ? replaceVariables(tc.replace(/$SHELL/g, SHELL), actualConfig) : SHELL;
// 将最终确定的命令添加到命令列表中
commands.push(cmd);
}
// There isn't a proper way of setting the working directory, but this should work in most cases
// 从选项中获取工作目录,如果没有则使用实际配置中的根目录
let { workingDirectory } = options;
// 如果用户没有指定工作目录,则使用实际配置中的根目录
workingDirectory = workingDirectory || actualConfig.root;
// 使用分隔符连接命令列表,生成最终的命令字符串
let cmd = joinCommands(commands, separator)!;
// 检查是否指定了工作目录
if (workingDirectory) {
// 如果命令字符串中包含 ${workingDirectory},则替换为实际的工作目录
if (cmd.includes('${workingDirectory}')) {
// 将命令字符串中的 ${workingDirectory} 替换为实际的工作目录
cmd = cmd.replace(/\${workingDirectory}/g, workingDirectory);
} else {
}
// TODO: Maybe replace with `connection.home`? Especially with Windows not supporting ~
// 如果命令字符串中不包含 ${workingDirectory}
else {
// 对于 Windows 系统,不支持 ~ 作为工作目录的前缀,因此抛出错误
if (workingDirectory.startsWith('~')) {
if (shellConfig.isWindows)
// 在 Windows 系统中,不支持 ~ 作为工作目录的前缀,因此抛出错误
throw new Error(`Working directory '${workingDirectory}' starts with ~ for a Windows shell`);
// So `cd "~/a/b/..." apparently doesn't work, but `~/"a/b/..."` does
// `"~"` would also fail but `~/""` works fine it seems
// 在非 Windows 系统中,将 ~ 替换为用户的主目录
workingDirectory = `~/"${workingDirectory.slice(2)}"`;
} else {
}
// 如果工作目录不是以 / 开头(在 Windows 系统中),则将其包围在双引号中
else {
// 如果是 Windows 系统,并且工作目录以 / 开头,后面跟着一个字母和冒号(例如 /C:
if (shellConfig.isWindows && workingDirectory.match(/^\/[a-zA-Z]:/))
// 去掉工作目录最前面的斜杠
workingDirectory = workingDirectory.slice(1);
// 将工作目录包围在双引号中
workingDirectory = `"${workingDirectory}"`;
}
// 将 cd 命令和原命令连接起来,形成新的命令字符串
cmd = joinCommands([`cd ${workingDirectory}`, ...commands], separator)!;
}
} else {
// 如果没有指定工作目录
else {
// 从命令字符串中移除 ${workingDirectory}
cmd = cmd.replace(/\${workingDirectory}/g, '');
}
const pseudoTtyOptions: Partial<PseudoTtyOptions> = { ...PSEUDO_TTY_OPTIONS, cols: dims?.columns, rows: dims?.rows };
// 根据提供的维度dims创建一个伪终端选项对象pseudoTtyOptions其中包含列数cols和行数rows
const pseudoTtyOptions: Partial<PseudoTtyOptions> = {...PSEUDO_TTY_OPTIONS, cols: dims?.columns, rows: dims?.rows };
// 记录一条调试信息,指示正在为指定的连接配置名称启动 shell并显示要执行的命令cmd
Logging.debug(`Starting shell for ${connection.actualConfig.name}: ${cmd}`);
// 使用提供的命令cmd和伪终端选项pseudoTtyOptions在客户端上执行一个命令并等待结果
const channel = await toPromise<ClientChannel | undefined>(cb => client.exec(cmd, { pty: pseudoTtyOptions }, cb));
// 如果没有成功创建通道channel则抛出一个错误指示无法创建远程终端
if (!channel) throw new Error('Could not create remote terminal');
// 将创建的通道channel分配给伪终端对象pseudo的 channel 属性
pseudo.channel = channel;
// 获取当前时间,作为命令执行的开始时间
const startTime = Date.now();
// 监听通道的退出事件
channel.once('exit', (code, signal, _, description) => {
// 记录一条调试信息,指示终端会话已关闭,并包含退出代码、信号、描述和伪终端的状态
Logging.debug`Terminal session closed: ${{ code, signal, description, status: pseudo.status }}`;
// 如果退出代码存在,并且终端在一秒内失败(如果这不是一个任务),则保持终端打开,以便用户看到错误
if (code && (Date.now() < startTime + 1000) && !options.command) {
// Terminal failed within a second, let's keep it open for the user to see the error (if this isn't a task)
// 向终端写入错误代码和可能的信号信息
onDidWrite.fire(`Got error code ${code}${signal ? ` with signal ${signal}` : ''}\r\n`);
// 如果有额外的描述信息,也将其写入终端
if (description) onDidWrite.fire(`Extra info: ${description}\r\n`);
// 提示用户按任意键关闭终端
onDidWrite.fire('Press a key to close the terminal\r\n');
// 提示用户可能还有更多的标准输出/标准错误信息
onDidWrite.fire('Possible more stdout/stderr below:\r\n');
// 将伪终端的状态设置为等待关闭
pseudo.status = 'wait-to-close';
} else {
// 触发终端关闭事件,并传递退出代码
onDidClose.fire(code || 0);
// 将伪终端的状态设置为关闭
pseudo.status = 'closed';
}
});
// 监听通道的可读事件
channel.once('readable', () => {
// Inform others (e.g. createTaskTerminal) that the terminal is ready to be used
// 如果伪终端的状态是“opening”则将其状态更新为“open”
if (pseudo.status === 'opening') pseudo.status = 'open';
// 触发 onDidOpen 事件,表示终端已打开
onDidOpen.fire();
});
// 监听通道的数据事件
channel.on('data', chunk => onDidWrite.fire(chunk.toString()));
// 监听通道的标准错误数据事件
channel.stderr!.on('data', chunk => onDidWrite.fire(chunk.toString()));
// TODO: ^ Keep track of stdout's color, switch to red, output, then switch back?
} catch (e) {
// 记录一条错误信息,指示在启动 SSH 终端时发生了错误,并包含错误的详细信息
Logging.error`Error starting SSH terminal:\n${e}`;
// 将错误信息写入终端,以便用户看到
onDidWrite.fire(`Error starting SSH terminal:\r\n${e}\r\n`);
// 触发终端关闭事件,并传递退出代码 1表示终端因错误而关闭
onDidClose.fire(1);
// 将伪终端的状态设置为“closed”表示终端已关闭
pseudo.status = 'closed';
// 如果存在通道,则销毁它,以释放资源
pseudo.channel?.destroy();
// 将通道设置为 undefined表明当前没有活动的通道
pseudo.channel = undefined;
}
},
/**
* vscode.Terminal
*
* @returns {vscode.Terminal | undefined} undefined
*/
get terminal(): vscode.Terminal | undefined {
return terminal ||= vscode.window.terminals.find(t => 'pty' in t.creationOptions && t.creationOptions.pty === pseudo);
},
/**
* vscode.Terminal
* @param {vscode.Terminal | undefined} term - undefined
*/
set terminal(term: vscode.Terminal | undefined) {
terminal = term;
},
/**
*
* @param {Object} dims -
*/
setDimensions(dims) {
pseudo.channel?.setWindow!(dims.rows, dims.columns, HEIGHT, WIDTH);
},
/**
*
* 'wait-to-close'
*
* @param {string} data -
*/
handleInput(data) {
if (pseudo.status === 'wait-to-close') return pseudo.close();
pseudo.channel?.write(data);
@ -270,23 +532,57 @@ export async function createTerminal(options: TerminalOptions): Promise<SSHPseud
return pseudo;
}
/**
* vscode.Pseudoterminal
*/
export interface TextTerminal extends vscode.Pseudoterminal {
/**
*
* @param text -
*/
write(text: string): void;
/**
*
* @param code - 退
*/
close(code?: number): void;
onDidClose: vscode.Event<number>; // Redeclaring that it isn't undefined
/**
*
* @type {vscode.Event<number>} - 退
*/
onDidClose: vscode.Event<number>; // 重新声明它不是未定义的 Redeclaring that it isn't undefined
/**
*
* @type {vscode.Event<void>} -
*/
onDidOpen: vscode.Event<void>;
}
/**
*
* @param initialText -
* @returns {TextTerminal} -
*/
export function createTextTerminal(initialText?: string): TextTerminal {
// 创建一个事件发射器,用于在终端中写入文本
const onDidWrite = new vscode.EventEmitter<string>();
// 创建一个事件发射器,用于在终端关闭时触发
const onDidClose = new vscode.EventEmitter<number>();
// 创建一个事件发射器,用于在终端打开时触发
const onDidOpen = new vscode.EventEmitter<void>();
return {
// 向终端写入文本
write: onDidWrite.fire.bind(onDidWrite),
// 关闭终端
close: onDidClose.fire.bind(onDidClose),
// 当有文本写入终端时触发的事件
onDidWrite: onDidWrite.event,
// 当终端关闭时触发的事件
onDidClose: onDidClose.event,
// 当终端打开时触发的事件
onDidOpen: onDidOpen.event,
// 打开终端,如果提供了 initialText则将其写入终端
open: () => initialText && (onDidWrite.fire(initialText + '\r\n'), onDidClose.fire(1)),
};
}

Loading…
Cancel
Save