diff --git a/src/config.ts b/src/config.ts index 386c8fa..12c682f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -454,27 +454,53 @@ export async function deleteConfig(config: FileSystemConfig) { * Otherwise, if it contains a `@`, we parse it as a connection string. * If this results in no (valid) configuration, `undefined` is returned. */ +/** + * 根据输入的字符串获取文件系统配置。 + * 如果输入的字符串是一个已存在的配置名称(不区分大小写),则返回该配置。 + * 如果输入的字符串包含 '@' 符号,则尝试将其解析为一个连接字符串,并返回相应的配置。 + * 如果解析后的配置中的主机名与已存在的配置名称匹配,则返回该配置。 + * 如果没有找到匹配的配置,则返回解析后的配置。 + * 如果解析后的配置是一个即时连接字符串,则将主机名替换为会话名称,并标记该配置为合并配置。 + * @param input - 要查找的配置的名称或连接字符串。 + * @returns 找到的文件系统配置或 undefined,如果没有找到匹配的配置。 + */ export function getConfig(input: string): FileSystemConfig | undefined { + // 将输入字符串转换为小写,以便进行不区分大小写的比较 const lower = input.toLowerCase(); + // 查找已加载的配置中是否存在名称与输入字符串匹配的配置 const loaded = getConfigs().find(c => c.name.toLowerCase() === lower); + // 如果找到匹配的配置,则直接返回该配置 if (loaded) return loaded; + // 如果输入字符串不包含 '@' 符号,则返回 undefined if (!input.includes('@')) return undefined; + // 尝试解析输入字符串为连接字符串 const parseString = parseConnectionString(input); + // 如果解析失败(返回类型为字符串),则返回 undefined if (typeof parseString === 'string') return undefined; + // 从解析结果中提取第一个配置对象 const [parsed] = parseString; // If we're using the instant connection string, the host name might be a config name + // 如果我们使用的是即时连接字符串,主机名可能是一个配置名称,我们需要将其替换为会话名称,并标记该配置为合并配置 const existing = getConfigs().find(c => c.name.toLowerCase() === parsed.host!.toLowerCase()); + // 如果找到匹配的配置 if (existing) { + // 记录日志,说明 getConfig 函数的调用导致了一个配置名称与现有配置名称相匹配 Logging.info`getConfig('${input}') led to '${parsed.name}' which matches config '${existing.name}'`; // Take the existing config, but (more or less) override it with the values present in `parsed` // `name` be the same as in `parsed`, meaning it can be reused with `getConfig` on window reload. + // 合并现有配置和解析后的配置,优先使用解析后的配置中的值 return { + // 复制现有配置的所有属性,复制解析后的配置的所有属性 ...existing, ...parsed, + // 如果现有配置中存在 host 属性,则使用现有配置的 host 属性,否则使用解析后的配置的 host 属性 host: existing.host || parsed.host, // `parsed.host` is the session name, which might not be the actual hostname + // 由于这是一个合并配置,我们将 _location 属性标记为 undefined _location: undefined, // Since this is a merged config, we have to flag it as such + // 合并现有配置和解析后的配置的 _locations 属性 _locations: [...existing._locations, ...parsed._locations], // Merge locations }; } + // 如果没有找到匹配的配置,则直接返回解析后的配置 return parsed; } diff --git a/src/connection.ts b/src/connection.ts index f612cac..bdebaf6 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -11,20 +11,35 @@ import { calculateShellConfig, KNOWN_SHELL_CONFIGS, ShellConfig, tryCommand, try import type { SSHFileSystem } from './sshFileSystem'; import { mergeEnvironment, toPromise } from './utils'; +/** + * 表示一个 SSH 连接的配置和状态。 + */ export interface Connection { + // 文件系统的配置。 config: FileSystemConfig; + // 实际使用的文件系统配置。 actualConfig: FileSystemConfig; + // SSH 客户端实例。 client: Client; + // 用户的主目录。 home: string; + // Shell 配置。 shellConfig: ShellConfig; + // 环境变量。 environment: EnvironmentVariable[]; + // 伪终端列表 terminals: SSHPseudoTerminal[]; + // 文件系统列表。 filesystems: SSHFileSystem[]; + // 缓存数据。 cache: Record; + // 等待连接的用户数量。 pendingUserCount: number; + //空闲计时器。 idleTimer: NodeJS.Timeout; } + export class ConnectionManager { protected onConnectionAddedEmitter = new vscode.EventEmitter(); protected onConnectionRemovedEmitter = new vscode.EventEmitter(); diff --git a/src/extension.ts b/src/extension.ts index d8e2aa5..f28c3a3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -77,6 +77,35 @@ export let MANAGER: Manager | undefined; * * @param context - 扩展上下文,包含扩展的各种资源和服务。 */ +/** 流程图 + * @startuml + * start + * :记录接收到的配置命令信息; + * if (目标是一个对象) then (是) + * :检查对象是否有 _location 或 _locations 属性; + * if (没有) then (是) + * :显示错误信息; + * :结束; + * else (否) + * :打开设置界面,导航到编辑配置页面; + * :结束; + * endif + * else (否) + * :将目标字符串转换为小写; + * :加载所有配置; + * :过滤出名称与目标匹配的配置; + * if (没有找到匹配的配置) then (是) + * :显示错误信息; + * :记录错误信息; + * :结束; + * else (否) + * :如果只有一个匹配的配置,直接使用它; + * :打开设置界面,导航到编辑配置页面; + * endif + * endif + * stop + * @enduml + */ export function activate(context: vscode.ExtensionContext) { // 通过 VS Code API 获取名为 'Kelvin.vscode-sshfs' 的扩展实例 const extension = vscode.extensions.getExtension('Kelvin.vscode-sshfs'); @@ -132,7 +161,26 @@ export function activate(context: vscode.ExtensionContext) { // I really don't like having to pass context to *everything*, so let's do it this way // 真的很糟糕,我们确实需要扩展上下文(ExtensionContext)来获取相对资源。 // 我真的不喜欢必须将上下文传递给所有东西,所以让我们这样做吧。 - // 将 context.asAbsolutePath 方法绑定到当前上下文中,并赋值给 setAsAbsolutePath 变量 + // 将 context.asAbsolutePath 方法绑定到当前上下文中,并赋值给 asAbsolutePath 变量 + // 这样,我们就可以在代码中使用 asAbsolutePath 变量来获取相对路径的绝对路径 + /** Function 实例的 bind() 方法创建一个新函数,当调用该新函数时,它会调用原始函数并将其 this 关键字设置为给定的值, + * 同时,还可以传入一系列指定的参数,这些参数会插入到调用新函数时传入的参数的前面。 + * 示例 + * const module = { + * x: 42, + * getX: function () { + * return this.x; + * }, + * }; + * + * const unboundGetX = module.getX; + * console.log(unboundGetX()); // The function gets invoked at the global scope + * // Expected output: undefined + * + * const boundGetX = unboundGetX.bind(module); + * console.log(boundGetX()); + * // Expected output: 42 + */ setAsAbsolutePath(context.asAbsolutePath.bind(context)); // 创建一个 Manager 实例,并将其赋值给全局变量 MANAGER diff --git a/src/manager.ts b/src/manager.ts index 4e5c086..51fcfa9 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -107,6 +107,74 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider * @param {FileSystemConfig} config - 文件系统的配置 * @returns {Promise} - 当文件系统创建完成时解析的 Promise */ + /** 类图 + * @startuml + * class SSHFileSystemManager { + * + createFileSystem(name: string, config?: FileSystemConfig): Promise + * + createTerminal(name: string, config?: FileSystemConfig | Connection, uri?: vscode.Uri): Promise + * + getActiveFileSystems(): readonly SSHFileSystem[] + * + getFs(uri: vscode.Uri): SSHFileSystem | null + * + promptReconnect(name: string): Promise + * } + * class SSHFileSystem { + * + onClose(handler: () => void): void + * } + * class Connection { + * + actualConfig: FileSystemConfig + * + filesystems: SSHFileSystem[] + * + pendingUserCount: number + * + home: string + * + client: any + * } + * class FileSystemConfig { + * + flags: any + * } + * SSHFileSystemManager --> Connection : uses + * SSHFileSystemManager --> SSHFileSystem : creates + * SSHFileSystem --> Connection : belongs to + * Connection --> FileSystemConfig : has + * @enduml + */ + /** 流程图 + * @startuml + * start + * :防止启动时的竞态条件,并等待当前配置重载完成; + * if (检查是否已经存在一个具有指定名称的 SSHFileSystem 实例?) then (yes) + * :如果存在,则直接返回该实例; + * else (no) + * :声明一个名为 con 的变量,用于存储即将创建的 Connection 实例; + * :尝试从 creatingFileSystems 中获取已存在的 Promise,如果不存在则创建一个新的 Promise; + * :获取文件系统配置; + * if (如果没有提供配置?) then (yes) + * :如果没有找到配置,则抛出错误; + * else (no) + * :创建一个新的连接; + * :更新连接管理器中的连接信息; + * :获取连接的实际配置; + * :导入 getSFTP 和 SSHFileSystem 模块; + * :使用连接的实际配置创建 SFTP 会话; + * :创建一个新的 SSH 文件系统实例; + * :记录日志,表示已创建 SSH 文件系统并正在读取根目录; + * :添加文件系统到连接管理器,更新连接管理器中的文件系统列表; + * :将新创建的文件系统添加到 fileSystems 数组中; + * :从 creatingFileSystems 中删除该文件系统的 Promise; + * :监听文件系统的关闭事件,当文件系统关闭时,从 fileSystems 数组中删除该文件系统,并更新连接管理器中的文件系统列表; + * :刷新文件资源管理器,以显示新创建的文件系统; + * :更新连接管理器中的连接信息; + * if (检查是否可以访问主目录?) then (yes) + * :解决 Promise,返回创建的文件系统实例; + * else (no) + * :show error message and options; + * if (如果用户选择停止?) then (yes) + * :则拒绝 Promise 并抛出错误; + * else (no) + * :解决 Promise,返回创建的文件系统实例; + * endif + * endif + * endif + * stop + * @enduml + */ public async createFileSystem(name: string, config?: FileSystemConfig): Promise { await LOADING_CONFIGS; // Prevent race condition on startup, and wait for any current config reload to finish 防止启动时的竞态条件,并等待当前配置重载完成 // 检查是否已经存在一个具有指定名称的 SSHFileSystem 实例 @@ -117,7 +185,7 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider let con: Connection | undefined; // 尝试从 creatingFileSystems 中获取已存在的 Promise,如果不存在则创建一个新的 Promise return this.creatingFileSystems[name] ||= catchingPromise(async (resolve, reject) => { - // 如果没有提供配置,则从默认配置中获取 + // 获取文件系统配置,如果没有提供配置,则尝试从配置管理器中获取 config ||= getConfig(name); // 如果没有找到配置,则抛出错误 if (!config) throw new Error(`Couldn't find a configuration with the name '${name}'`); @@ -221,6 +289,21 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider * @param {vscode.Uri} uri - URI 对象,指定终端的工作目录 * @returns {Promise} - 当终端创建完成时解析的 Promise */ + /** 流程图 + * @startuml + * start + * :导入 createTerminal 模块; + * :创建连接(如果配置中没有提供连接); + * :更新连接管理器中的连接信息(pendingUserCount 加 1); + * :创建伪终端实例; + * :监听伪终端关闭事件; + * :更新连接管理器中的连接信息(移除已关闭的伪终端,pendingUserCount 减 1); + * :创建并显示图形终端; + * :将伪终端实例与图形终端关联; + * :显示图形终端; + * end + * @enduml + */ public async createTerminal(name: string, config?: FileSystemConfig | Connection, uri?: vscode.Uri): Promise { const { createTerminal } = await import('./pseudoTerminal'); // Create connection (early so we have .actualConfig.root) @@ -421,6 +504,24 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider * @param {FileSystemConfig} config - 文件系统的配置 * @returns {Promise} - 当连接完成时解析的 Promise */ + /** 流程图 + * @startuml + * start + * :记录接收到的连接命令信息; + * :获取当前工作区的文件夹; + * :在工作区文件夹中查找是否已经存在指定名称的 SSH 文件系统; + * if (已经存在) then (是) + * :刷新文件资源管理器; + * :结束; + * else (否) + * :获取文件系统的根目录,默认值为 '/'; + * :如果根目录以 '~' 开头,则将其替换为连接的主目录; + * :如果根目录以 '/' 开头,则去除开头的 '/'; + * :更新工作区文件夹,添加新的 SSH 文件系统; + * endif + * stop + * @enduml + */ public async commandConnect(config: FileSystemConfig) { // 记录接收到的连接命令信息 Logging.info`Command received to connect ${config.name}`; @@ -452,6 +553,27 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider * @param target - 要断开连接的文件系统的名称或连接对象 * @returns {Promise} - 当断开连接完成时解析的 Promise */ + /** 流程图 + * @startuml + * start + * :记录接收到的断开连接命令信息; + * :初始化一个数组来存储要断开连接的连接对象; + * if (目标是一个对象并且包含 'client' 属性) then (是) + * :将目标连接对象添加到数组中; + * :获取目标连接对象的实际配置名称; + * else (否) + * :获取所有活动连接中名称与目标匹配的连接对象; + * endif + * :遍历所有要断开连接的连接对象,调用连接管理器的 closeConnection 方法来关闭它们; + * :获取所有活动连接中名称与目标相同的其他连接对象; + * if (没有其他相同名称的文件系统连接) then (是) + * :直接返回; + * else (否) + * :移除所有相关的工作区文件夹; + * endif + * stop + * @enduml + */ public commandDisconnect(target: string | Connection) { // 记录接收到的断开连接命令信息 Logging.info`Command received to disconnect ${commandArgumentToName(target)}`; @@ -503,6 +625,24 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider * @param uri - 可选的 URI 对象,指定终端的工作目录 * @returns {Promise} - 当终端创建完成时解析的 Promise */ + /** 流程图 + * @startuml + * start + * :记录接收到的连接命令信息; + * :获取当前工作区的文件夹; + * :在工作区文件夹中查找是否已经存在指定名称的 SSH 文件系统; + * if (已经存在) then (是) + * :刷新文件资源管理器; + * :结束; + * else (否) + * :获取文件系统的根目录,默认值为 '/'; + * :如果根目录以 '~' 开头,则将其替换为连接的主目录; + * :如果根目录以 '/' 开头,则去除开头的 '/'; + * :更新工作区文件夹,添加新的 SSH 文件系统; + * endif + * stop + * @enduml + */ public async commandTerminal(target: FileSystemConfig | Connection, uri?: vscode.Uri) { // 记录接收到的打开终端命令信息 Logging.info`Command received to open a terminal for ${commandArgumentToName(target)}${uri ? ` in ${uri}` : ''}`; @@ -527,6 +667,35 @@ export class Manager implements vscode.TaskProvider, vscode.TerminalLinkProvider * @param target - 要配置的文件系统的名称或配置对象 * @returns {Promise} - 当配置完成时解析的 Promise */ + /** 流程图 + * @startuml + * start + * :记录接收到的配置命令信息; + * if (目标是一个对象) then (是) + * :检查对象是否有 _location 或 _locations 属性; + * if (没有) then (是) + * :显示错误信息; + * stop + * else (否) + * :打开设置界面,导航到编辑配置页面; + * stop + * endif + * else (否) + * :将目标字符串转换为小写; + * :加载所有配置; + * :过滤出名称与目标匹配的配置; + * if (没有找到匹配的配置) then (是) + * :显示错误信息; + * :记录错误信息; + * :结束; + * else (否) + * :如果只有一个匹配的配置,直接使用它; + * :打开设置界面,导航到编辑配置页面; + * endif + * endif + * stop + * @enduml + */ public async commandConfigure(target: string | FileSystemConfig) { // 记录接收到的配置命令信息 Logging.info`Command received to configure ${typeof target === 'string' ? target : target.name}`; diff --git a/src/utils.ts b/src/utils.ts index a26bab8..ac928e9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -17,40 +17,67 @@ function trimError(error: Error, depth: number): [string[], Error] { Error.prepareStackTrace = pst; return [trimmed.split('\n').slice(1), error]; } +/** + * 捕获 Promise 执行过程中的错误,并在错误堆栈中添加额外信息 + * @param executor - 执行器函数,接受 resolve 和 reject 函数作为参数 + * @param trimStack - 堆栈跟踪中要修剪的帧数 + * @param causeName - 附加到错误对象的名称 + * @returns 一个 Promise,成功时返回执行器函数的结果,失败时返回带有额外信息的错误 + */ /** Wrapper around async callback-based functions */ export async function catchingPromise(executor: (resolve: (value?: T | PromiseLike) => void, reject: (reason?: any) => void) => any, trimStack = 0, causeName = 'catchingPromise'): Promise { + // 声明一个包含两个元素的数组,其中第一个元素是一个字符串数组,第二个元素是一个错误对象 let [trimmed, promiseCause]: [string[], Error] = [] as any; return new Promise((resolve, reject) => { + // 使用 trimError 函数处理新创建的错误对象,并设置要修剪的堆栈帧数 [trimmed, promiseCause] = trimError(new Error(), trimStack + 2); + // 如果 DEBUG 模式开启,则修改 promiseCause 的堆栈跟踪信息 if (DEBUG) promiseCause.stack = promiseCause.stack!.split('\n', 2)[0] + trimmed.map(l => l.replace('at', '~at')).join('\n') + '\n' + promiseCause.stack!.split('\n').slice(1).join('\n'); try { + // 执行传入的执行器函数,并获取其返回的 Promise const p = executor(resolve, reject); + // 如果返回的是 Promise,则捕获其拒绝事件 if (p instanceof Promise) { p.catch(reject); } } catch (e) { + // 如果执行器函数抛出异常,则拒绝 Promise,并将错误对象传递给 reject 函数 reject(e); } }).catch(e => { + // 检查捕获的异常是否为 Error 类型的实例 if (e instanceof Error) { + // 获取异常的堆栈跟踪信息 let stack = e.stack; + // 如果堆栈跟踪信息存在 if (stack) { + // 将堆栈跟踪信息分割成行 const lines = stack.split('\n'); + // 查找修剪后的堆栈跟踪信息的第三行在原始堆栈跟踪信息中的索引 let index = lines.indexOf(trimmed[3]); + // 如果找到了 if (index !== -1) { + // 计算新的索引,减去 2 是因为要跳过前两行,trimStack 是用户指定的修剪帧数 index -= 2 + trimStack; + // 更新异常的堆栈跟踪信息,只保留前 index 行 e.stack = lines[0] + '\n' + lines.slice(1, index).join('\n'); + // 如果 DEBUG 模式开启,则在堆栈跟踪信息后面添加修剪后的堆栈跟踪信息,并将 'at' 替换为 '~at' if (DEBUG) e.stack += '\n' + lines.slice(index).map(l => l.replace('at', '~at')).join('\n'); } } + // 尝试获取异常对象的 promiseCause 属性 let t = (e as any).promiseCause; + // 如果 promiseCause 属性不存在或者不是 Error 类型 if (!(t instanceof Error)) t = e; + // 如果异常对象没有 promiseCause 属性 if (!('promiseCause' in t)) { + // 为异常对象定义一个 promiseCause 属性,其值为 promiseCause 变量的值 Object.defineProperty(t, 'promiseCause', { value: promiseCause, configurable: true, enumerable: false, }); + // 为异常对象定义一个 promiseCauseName 属性,其值为 causeName 变量的值 Object.defineProperty(t, 'promiseCauseName', { value: causeName, configurable: true,