From e36472da90ad9d7f1a6b23e549c466da17c480c2 Mon Sep 17 00:00:00 2001 From: Kelvin Schoofs Date: Thu, 7 Feb 2019 17:28:43 +0100 Subject: [PATCH] Rework the way configs are loaded --- src/config.ts | 170 ++++++++++++++++++++++++++++++++++++++--------- src/connect.ts | 6 +- src/extension.ts | 6 +- src/manager.ts | 31 ++++++--- 4 files changed, 167 insertions(+), 46 deletions(-) diff --git a/src/config.ts b/src/config.ts index 2144009..2ad3d57 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,11 @@ +import { readFile } from 'fs'; +import { parse as parseJsonc, ParseError } from 'jsonc-parser'; +import * as path from 'path'; import * as vscode from 'vscode'; import * as Logging from './logging'; import { FileSystemConfig } from './manager'; +import { toPromise } from './toPromise'; export const skippedConfigNames: string[] = []; @@ -11,9 +15,9 @@ export function invalidConfigName(name: string) { return `A SSH FS name can only exists of lowercase alphanumeric characters, slashes and any of these: _.+-@`; } -function randomAvailableName(index = 0): [string, number] { +function randomAvailableName(configs: FileSystemConfig[], index = 0): [string, number] { let name = index ? `unnamed${index}` : 'unnamed'; - while (loadConfigs(true).find(c => c.name === name)) { + while (configs.find(c => c.name === name)) { index += 1; name = `unnamed${index}`; } @@ -24,42 +28,143 @@ export async function renameNameless() { const conf = vscode.workspace.getConfiguration('sshfs'); const inspect = conf.inspect('configs')!; let randomIndex = 0; - const patch = (v?: FileSystemConfig[]) => { - if (v) { - v.forEach((config) => { - if (!config.name) { - [config.name, randomIndex] = randomAvailableName(randomIndex); - Logging.warning(`Renamed unnamed config to ${config.name}`); - } - }); - } - return v; - }; - await conf.update('configs', patch(inspect.globalValue), vscode.ConfigurationTarget.Global).then(() => { }, () => { }); - await conf.update('configs', patch(inspect.workspaceValue), vscode.ConfigurationTarget.Workspace).then(() => { }, () => { }); - await conf.update('configs', patch(inspect.workspaceFolderValue), vscode.ConfigurationTarget.WorkspaceFolder).then(() => { }, () => { }); + const configs = [ + ...(inspect.globalValue || []), + ...(inspect.workspaceValue || []), + ...(inspect.workspaceFolderValue || []), + ]; + function patch(v: FileSystemConfig[] | undefined, loc: vscode.ConfigurationTarget) { + if (!v) return; + let okay = true; + v.forEach((config) => { + if (!config.name) { + [config.name, randomIndex] = randomAvailableName(configs, randomIndex); + Logging.warning(`Renamed unnamed config to ${config.name}`); + okay = false; + } + }); + if (okay) return; + return conf.update('configs', v, loc).then(() => { }, res => Logging.error(`Error while saving configs (CT=${loc}): ${res}`)); + } + await patch(inspect.globalValue, vscode.ConfigurationTarget.Global); + await patch(inspect.workspaceValue, vscode.ConfigurationTarget.Workspace); + await patch(inspect.workspaceFolderValue, vscode.ConfigurationTarget.WorkspaceFolder); +} + +let loadedConfigs: FileSystemConfig[] = []; +export function getConfigs() { + return loadedConfigs; } -export function loadConfigs(raw = false) { +export const UPDATE_LISTENERS: ((configs: FileSystemConfig[]) => any)[] = []; + +async function readConfigFile(location: string, shouldExist = false): Promise { + const content = await toPromise(cb => readFile(location, cb)).catch((e: NodeJS.ErrnoException) => e); + if (content instanceof Error) { + if (content.code === 'ENOENT' && !shouldExist) return []; + Logging.error(`Error while reading ${location}: ${content.message}`); + return []; + } + const errors: ParseError[] = []; + const parsed: FileSystemConfig[] | null = parseJsonc(content.toString(), errors); + if (!parsed || errors.length) { + Logging.error(`Couldn't parse ${location} as a 'JSON with Comments' file`); + vscode.window.showErrorMessage(`Couldn't parse ${location} as a 'JSON with Comments' file`); + return []; + } + parsed.forEach(c => c._locations = [location]); + Logging.debug(`Read ${parsed.length} configs from ${location}`); + return parsed; +} + +export async function loadConfigs() { + Logging.info('Loading configurations...'); + await renameNameless(); + // Keep all found configs "ordened" by layer, for proper deduplication/merging + const layered = { + folder: [] as FileSystemConfig[], + workspace: [] as FileSystemConfig[], + global: [] as FileSystemConfig[], + }; + // Fetch configs from vscode settings const config = vscode.workspace.getConfiguration('sshfs'); - if (!config) return []; - const inspect = config.inspect('configs')!; - let configs: FileSystemConfig[] = [ - ...(inspect.workspaceFolderValue || []), - ...(inspect.workspaceValue || []), - ...(inspect.globalValue || []), - ]; - configs.forEach(c => c.name = (c.name || '').toLowerCase()); - configs = configs.filter((c, i) => configs.findIndex(c2 => c2.name === c.name) === i); - if (raw) return configs; - renameNameless(); + const configpaths = { workspace: [] as string[], global: [] as string[] }; + if (config) { + const inspect = config.inspect('configs')!; + // Note: workspaceFolderValue not used here, we do it later for all workspace folders + layered.workspace = inspect.workspaceValue || []; + layered.global = inspect.globalValue || []; + layered.workspace.forEach(c => c._locations = ['Workspace']); + layered.global.forEach(c => c._locations = ['Global']); + // Get all sshfs.configpaths values into an array + const inspect2 = config.inspect('configpaths')!; + configpaths.workspace = inspect2.workspaceValue || []; + configpaths.global = inspect2.globalValue || []; + } + // Fetch configs from config files + for (const location of configpaths.workspace) { + layered.workspace = [ + ...layered.workspace, + ...await readConfigFile(location, true), + ]; + } + for (const location of configpaths.global) { + layered.global = [ + ...layered.global, + ...await readConfigFile(location, true), + ]; + } + // Fetch configs from opened folders (workspaces) + const { workspaceFolders } = vscode.workspace; + if (workspaceFolders) { + for (const { uri } of workspaceFolders) { + if (uri.scheme !== 'file') continue; + const fConfig = vscode.workspace.getConfiguration('sshfs', uri).inspect('configs'); + const fConfigs = fConfig && fConfig.workspaceFolderValue || []; + if (fConfigs.length) { + Logging.debug(`Read ${fConfigs.length} configs from workspace folder ${uri}`); + fConfigs.forEach(c => c._locations = [`WorkspaceFolder ${uri}`]); + } + layered.folder = [ + ...await readConfigFile(path.resolve(uri.fsPath, 'sshfs.json')), + ...await readConfigFile(path.resolve(uri.fsPath, 'sshfs.jsonc')), + ...fConfigs, + ...layered.folder, + ]; + } + } + // Start merging and cleaning up all configs + const all = [...layered.folder, ...layered.workspace, ...layered.global]; + all.forEach(c => c.name = (c.name || '').toLowerCase()); // It being undefined shouldn't happen, but better be safe + // Remove duplicates, merging those where the more specific config has `merge` set + // Folder comes before Workspace, comes before Global + const configs: FileSystemConfig[] = []; + for (const conf of all) { + const dup = configs.find(d => d.name === conf.name); + if (dup) { + if (dup.merge) { + // The folder settings should overwrite the higher up defined settings + // Since .sshfs.json gets read after vscode settings, these can overwrite configs + // of the same level, which I guess is a nice feature? + Logging.debug(`\tMerging duplicate ${conf.name} from ${conf._locations}`); + dup._locations = [...dup._locations, ...conf._locations]; + Object.assign(dup, Object.assign(conf, dup)); + } else { + Logging.debug(`\tIgnoring duplicate ${conf.name} from ${conf._locations}`); + } + } else { + Logging.debug(`\tAdded configuration ${conf.name} from ${conf._locations}`); + configs.push(conf); + } + } + // Let the user do some cleaning for (const conf of configs) { if (!conf.name) { Logging.error(`Skipped an invalid SSH FS config (missing a name field):\n${JSON.stringify(conf, undefined, 4)}`); vscode.window.showErrorMessage(`Skipped an invalid SSH FS config (missing a name field)`); } else if (invalidConfigName(conf.name)) { if (skippedConfigNames.indexOf(conf.name) !== -1) continue; - Logging.error(`Found a SSH FS config with the invalid name "${conf.name}", prompting user how to handle`); + Logging.warning(`Found a SSH FS config with the invalid name "${conf.name}", prompting user how to handle`); vscode.window.showErrorMessage(`Invalid SSH FS config name: ${conf.name}`, 'Rename', 'Delete', 'Skip').then(async (answer) => { if (answer === 'Rename') { const name = await vscode.window.showInputBox({ prompt: `New name for: ${conf.name}`, validateInput: invalidConfigName, placeHolder: 'New name' }); @@ -78,7 +183,10 @@ export function loadConfigs(raw = false) { }); } } - return configs.filter(c => !invalidConfigName(c.name)); + loadedConfigs = configs.filter(c => !invalidConfigName(c.name)); + Logging.info(`Found ${loadedConfigs.length} configurations`); + UPDATE_LISTENERS.forEach(listener => listener(loadedConfigs)); + return loadedConfigs; } export function getConfigLocation(name: string) { @@ -113,7 +221,7 @@ export async function updateConfig(name: string, config?: FileSystemConfig) { export function getConfig(name: string) { if (name === '') return null; - return loadConfigs().find(c => c.name === name); + return getConfigs().find(c => c.name === name); } export function openConfigurationEditor(name: string) { diff --git a/src/connect.ts b/src/connect.ts index 0009948..e1a3cf6 100644 --- a/src/connect.ts +++ b/src/connect.ts @@ -3,7 +3,7 @@ import { Socket } from 'net'; import { Client, ClientChannel, ConnectConfig, SFTPWrapper as SFTPWrapperReal } from 'ssh2'; import { SFTPStream } from 'ssh2-streams'; import * as vscode from 'vscode'; -import { loadConfigs, openConfigurationEditor } from './config'; +import { getConfigs, openConfigurationEditor } from './config'; import * as Logging from './logging'; import { FileSystemConfig } from './manager'; import * as proxy from './proxy'; @@ -134,8 +134,8 @@ export async function createSocket(config: FileSystemConfig): Promise c.name === config.hop); + Logging.debug(`\tHopping through ${config.hop}`); + const hop = getConfigs().find(c => c.name === config.hop); if (!hop) throw new Error(`A SSH FS configuration with the name '${config.hop}' doesn't exist`); const ssh = await createSSH(hop); if (!ssh) { diff --git a/src/extension.ts b/src/extension.ts index 1c7de5d..4c5ec57 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; -import { invalidConfigName, loadConfigs, renameNameless } from './config'; +import { invalidConfigName, loadConfigs } from './config'; import * as Logging from './logging'; import { FileSystemConfig, Manager } from './manager'; @@ -19,9 +19,8 @@ function generateDetail(config: FileSystemConfig): string | undefined { } async function pickConfig(manager: Manager, activeOrNot?: boolean): Promise { - await renameNameless(); let names = manager.getActive(); - const others = loadConfigs(); + const others = await loadConfigs(); if (activeOrNot === false) { names = others.filter(c => !names.find(cc => cc.name === c.name)); } else if (activeOrNot === undefined) { @@ -54,7 +53,6 @@ export function activate(context: vscode.ExtensionContext) { } registerCommand('sshfs.new', async () => { - await renameNameless(); const name = await vscode.window.showInputBox({ placeHolder: 'Name for the new SSH file system', validateInput: invalidConfigName }); if (name) vscode.window.showTextDocument(vscode.Uri.parse(`ssh:///${name}.sshfs.jsonc`), { preview: false }); }); diff --git a/src/manager.ts b/src/manager.ts index 0921dab..3c08be1 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -4,7 +4,7 @@ import { parse as parseJsonc, ParseError } from 'jsonc-parser'; import * as path from 'path'; import { Client, ClientChannel, ConnectConfig } from 'ssh2'; import * as vscode from 'vscode'; -import { getConfig, loadConfigs, openConfigurationEditor, updateConfig } from './config'; +import { getConfig, getConfigs, loadConfigs, openConfigurationEditor, UPDATE_LISTENERS, updateConfig } from './config'; import { createSSH, getSFTP } from './connect'; import * as Logging from './logging'; import SSHFileSystem, { EMPTY_FILE_SYSTEM } from './sshFileSystem'; @@ -24,16 +24,30 @@ export interface ProxyConfig { } export interface FileSystemConfig extends ConnectConfig { + /* Name of the config. Can only exists of lowercase alphanumeric characters, slashes and any of these: _.+-@ */ name: string; + /* Optional label to display in some UI places (e.g. popups) */ label?: string; + /* Whether to merge this "lower" config (e.g. from folders) into higher configs (e.g. from global settings) */ + merge?: boolean; + /* Path on the remote server where the root path in vscode should point to. Defaults to / */ root?: string; + /* A name of a PuTTY session, or `true` to find the PuTTY session from the host address */ putty?: string | boolean; + /* Optional object defining a proxy to use */ proxy?: ProxyConfig; + /* Optional path to a private keyfile to authenticate with */ privateKeyPath?: string; + /* A name of another config to use as a hop */ hop?: string; + /* A command to run on the remote SSH session to start a SFTP session (defaults to sftp subsystem) */ sftpCommand?: string; + /* Whether to use a sudo shell (and for which user) to run the sftpCommand in (sftpCommand defaults to /usr/lib/openssh/sftp-server if missing) */ sftpSudo?: string | boolean; + /* The filemode to assign to created files */ newFileMode?: number | string; + /* Internal property keeping track of where this config comes from (including merges) */ + _locations: string[]; } export enum ConfigStatus { @@ -130,7 +144,7 @@ export class Manager implements vscode.FileSystemProvider, vscode.TreeDataProvid constructor(public readonly context: vscode.ExtensionContext) { this.onDidChangeFile = this.onDidChangeFileEmitter.event; this.onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event; - const folderAdded = async (folder) => { + const folderAdded = async (folder: vscode.WorkspaceFolder) => { if (folder.uri.scheme !== 'ssh') return; this.createFileSystem(folder.uri.authority); }; @@ -144,15 +158,16 @@ export class Manager implements vscode.FileSystemProvider, vscode.TreeDataProvid }); this.onDidChangeTreeDataEmitter.fire(); }); - vscode.workspace.onDidChangeConfiguration((e) => { + vscode.workspace.onDidChangeConfiguration(async (e) => { // if (!e.affectsConfiguration('sshfs.configs')) return; - this.onDidChangeTreeDataEmitter.fire(); - // TODO: Offer to reconnect everything + return loadConfigs(); }); + UPDATE_LISTENERS.push(() => this.fireConfigChanged()); loadConfigs(); } public fireConfigChanged(): void { this.onDidChangeTreeDataEmitter.fire(); + // TODO: Offer to reconnect everything } public getStatus(name: string): ConfigStatus { const config = getConfig(name); @@ -178,7 +193,7 @@ export class Manager implements vscode.FileSystemProvider, vscode.TreeDataProvid if (existing) return existing; let promise = this.creatingFileSystems[name]; if (promise) return promise; - config = config || loadConfigs().find(c => c.name === name); + config = config || (await loadConfigs()).find(c => c.name === name); promise = catchingPromise(async (resolve, reject) => { if (!config) { throw new Error(`A SSH filesystem with the name '${name}' doesn't exist`); @@ -303,11 +318,11 @@ export class Manager implements vscode.FileSystemProvider, vscode.TreeDataProvid return createTreeItem(this, element); } public getChildren(element?: string | undefined): vscode.ProviderResult { - const configs = loadConfigs().map(c => c.name); + const configs = getConfigs().map(c => c.name); this.fileSystems.forEach(fs => configs.indexOf(fs.authority) === -1 && configs.push(fs.authority)); const folders = vscode.workspace.workspaceFolders || []; folders.filter(f => f.uri.scheme === 'ssh').forEach(f => configs.indexOf(f.uri.authority) === -1 && configs.push(f.uri.authority)); - return configs; + return configs.filter((c,i) => configs.indexOf(c) === i); } /* Commands (stuff for e.g. context menu for ssh-configs tree) */ public commandDisconnect(name: string) {