Rework the way configs are loaded

feature/search
Kelvin Schoofs 6 years ago
parent bb7702ee0c
commit e36472da90

@ -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<FileSystemConfig[]>('configs')!;
let randomIndex = 0;
const patch = (v?: FileSystemConfig[]) => {
if (v) {
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(randomIndex);
[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}`));
}
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(() => { }, () => { });
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<FileSystemConfig[]> {
const content = await toPromise<Buffer>(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 configpaths = { workspace: [] as string[], global: [] as string[] };
if (config) {
const inspect = config.inspect<FileSystemConfig[]>('configs')!;
let configs: FileSystemConfig[] = [
...(inspect.workspaceFolderValue || []),
...(inspect.workspaceValue || []),
...(inspect.globalValue || []),
// 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<string[]>('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<FileSystemConfig[]>('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,
];
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();
}
}
// 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 === '<config>') return null;
return loadConfigs().find(c => c.name === name);
return getConfigs().find(c => c.name === name);
}
export function openConfigurationEditor(name: string) {

@ -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<NodeJS.Rea
if (!config) return null;
Logging.info(`[${config.name}] Creating socket`);
if (config.hop) {
Logging.debug(`[${config.name}] \tHopping through ${config.hop}`);
const hop = loadConfigs().find(c => 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) {

@ -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<string | undefined> {
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://<config>/${name}.sshfs.jsonc`), { preview: false });
});

@ -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<SSHFileSystem>(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<string[]> {
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) {

Loading…
Cancel
Save