From 803747a6a566408fb8a99998957f19ceb08fce9f Mon Sep 17 00:00:00 2001 From: worksofliam Date: Mon, 11 Nov 2024 11:55:35 -0500 Subject: [PATCH 01/12] Support for local and server profiles Signed-off-by: worksofliam --- package.json | 9 ++- schemas/profiles.json | 93 +++++++++++++++++++++++++++++++ src/api/local/profiles.ts | 113 ++++++++++++++++++++++++++++++++++++++ src/instantiate.ts | 8 ++- src/views/ProfilesView.ts | 74 ++++++++++++++++++++----- 5 files changed, 279 insertions(+), 18 deletions(-) create mode 100644 schemas/profiles.json create mode 100644 src/api/local/profiles.ts diff --git a/package.json b/package.json index 364b69f9c..abe8c991d 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,13 @@ ".vscode/actions.json" ], "url": "./schemas/actions.json" + }, + { + "fileMatch": [ + ".vscode/profiles.json", + "/.vscode/profiles.json" + ], + "url": "./schemas/profiles.json" } ], "configuration": { @@ -2520,7 +2527,7 @@ }, { "command": "code-for-ibmi.loadConnectionProfile", - "when": "view == profilesView && viewItem == profile", + "when": "view == profilesView && (viewItem == profile || viewItem == localProfile)", "group": "inline" }, { diff --git a/schemas/profiles.json b/schemas/profiles.json new file mode 100644 index 000000000..34f1e6664 --- /dev/null +++ b/schemas/profiles.json @@ -0,0 +1,93 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "FilterType": { + "type": "string", + "enum": ["simple", "regex"] + }, + "ObjectFilters": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "filterType": { "$ref": "#/definitions/FilterType" }, + "library": { "type": "string" }, + "object": { "type": "string" }, + "types": { + "type": "array", + "items": { "type": "string" } + }, + "member": { "type": "string" }, + "memberType": { "type": "string" }, + "protected": { "type": "boolean" } + }, + "required": ["name", "filterType", "library", "object", "types", "member", "memberType", "protected"] + }, + "CustomVariable": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "value": { "type": "string" } + }, + "required": ["name", "value"] + }, + "ConnectionProfile": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "homeDirectory": { "type": "string" }, + "currentLibrary": { "type": "string" }, + "libraryList": { + "type": "array", + "items": { "type": "string" } + }, + "objectFilters": { + "type": "array", + "items": { "$ref": "#/definitions/ObjectFilters" } + }, + "ifsShortcuts": { + "type": "array", + "items": { "type": "string" } + }, + "customVariables": { + "type": "array", + "items": { "$ref": "#/definitions/CustomVariable" } + } + }, + "required": ["name"] + }, + "TopLevel": { + "type": "object", + "properties": { + "profiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "homeDirectory": { "type": "string" }, + "currentLibrary": { "type": "string" }, + "libraryList": { + "type": "array", + "items": { "type": "string" } + }, + "objectFilters": { + "type": "array", + "items": { "$ref": "#/definitions/ObjectFilters" } + }, + "ifsShortcuts": { + "type": "array", + "items": { "type": "string" } + }, + "customVariables": { + "type": "array", + "items": { "$ref": "#/definitions/CustomVariable" } + } + } + } + } + }, + "required": ["profiles"] + } + }, + "$ref": "#/definitions/TopLevel" +} \ No newline at end of file diff --git a/src/api/local/profiles.ts b/src/api/local/profiles.ts new file mode 100644 index 000000000..9bfc158c0 --- /dev/null +++ b/src/api/local/profiles.ts @@ -0,0 +1,113 @@ + +import { workspace, window } from "vscode"; +import { ConnectionConfiguration } from "../../api/Configuration"; +import IBMi from "../IBMi"; + +type PartialConnectionProfile = Partial; +type FullConnectionProfile = ConnectionConfiguration.ConnectionProfile; + +const PROFILES_PATH = `/.vscode/profiles.json`; + +let serverProfiles: FullConnectionProfile[]|undefined; + +interface ProfilesFile { + profiles: PartialConnectionProfile[] +} + +export async function getProfiles(connection: IBMi) { + const profiles: ConnectionConfiguration.ConnectionProfile[] = []; + + if (workspace.workspaceFolders) { + const actionsFiles = await workspace.findFiles(`**${PROFILES_PATH}`); + + for (const file of actionsFiles) { + const content = await workspace.fs.readFile(file); + try { + profiles.push(...parseJsonIntoProfilesFile(content.toString())); + } catch (e: any) { + // ignore + window.showErrorMessage(`Error parsing ${file.fsPath}: ${e.message}\n`); + } + }; + } + + if (serverProfiles === undefined) { + serverProfiles = []; + const isAvailable = await connection.content.testStreamFile(PROFILES_PATH, `r`); + if (isAvailable) { + const content = await connection.content.downloadStreamfileRaw(PROFILES_PATH); + try { + serverProfiles = parseJsonIntoProfilesFile(content.toString()); + } catch (e: any) { + // ignore + window.showErrorMessage(`Error parsing server file ${PROFILES_PATH}: ${e.message}\n`); + } + } + } else if (Array.isArray(serverProfiles)) { + profiles.push(...serverProfiles); + } + + return profiles; +} + +export function resetServerProfiles() { + serverProfiles = undefined; +} + +function parseJsonIntoProfilesFile(json: string) { + const profiles: ConnectionConfiguration.ConnectionProfile[] = []; + const theJson: ProfilesFile = JSON.parse(json.toString()); + + if (theJson.profiles) { + const profilesJson = theJson.profiles; + // Maybe one day replace this with real schema validation + if (Array.isArray(profilesJson)) { + profilesJson.forEach((profile, index) => { + const validProfile = validateLocalProfile(profile); + profiles.push(validProfile); + }) + } + } + + return profiles; +} + +function validateLocalProfile(input: PartialConnectionProfile): FullConnectionProfile { + if (!input.name) { + throw new Error(`Profile name is required.`); + } + + if (input.homeDirectory && typeof input.homeDirectory !== `string`) { + throw new Error(`Home directory must a string.`); + } + + if (input.currentLibrary && typeof input.currentLibrary !== `string`) { + throw new Error(`Current library must a string.`); + } + + if (input.libraryList && !Array.isArray(input.libraryList)) { + throw new Error(`Library list must be an array of strings.`); + } + + if (input.ifsShortcuts && !Array.isArray(input.ifsShortcuts)) { + throw new Error(`IFS shortcuts must be an array of strings.`); + } + + if (input.objectFilters && !Array.isArray(input.objectFilters)) { + throw new Error(`Object filters must be an array of objects.`); + } + + if (input.customVariables && !Array.isArray(input.customVariables)) { + throw new Error(`Custom variables must be an array of objects.`); + } + + return { + name: input.name, + homeDirectory: input.homeDirectory || `.`, + currentLibrary: input.currentLibrary || ``, + libraryList: input.libraryList || [], + objectFilters: input.objectFilters || [], + ifsShortcuts: input.ifsShortcuts || [], + customVariables: input.customVariables || [] + } +} \ No newline at end of file diff --git a/src/instantiate.ts b/src/instantiate.ts index c8fbfea57..e205a313e 100644 --- a/src/instantiate.ts +++ b/src/instantiate.ts @@ -16,6 +16,8 @@ import { SEUColorProvider } from "./languages/general/SEUColorProvider"; import { Action, BrowserItem, DeploymentMethod, MemberItem, OpenEditableOptions, WithPath } from "./typings"; import { ActionsUI } from './webviews/actions'; import { VariablesUI } from "./webviews/variables"; +import { getAllProfiles } from './views/ProfilesView'; +import { resetServerProfiles } from './api/local/profiles'; export let instance: Instance; @@ -817,7 +819,7 @@ async function updateConnectedBar() { } async function onConnected() { - const config = instance.getConfig(); + const connection = instance.getConnection()!; [ connectedBarItem, @@ -827,7 +829,9 @@ async function onConnected() { updateConnectedBar(); // Enable the profile view if profiles exist. - vscode.commands.executeCommand(`setContext`, `code-for-ibmi:hasProfiles`, (config?.connectionProfiles || []).length > 0); + resetServerProfiles(); + const profiles = await getAllProfiles(connection); + vscode.commands.executeCommand(`setContext`, `code-for-ibmi:hasProfiles`, profiles.length > 0); } async function onDisconnected() { diff --git a/src/views/ProfilesView.ts b/src/views/ProfilesView.ts index ef4445d1c..4856b130f 100644 --- a/src/views/ProfilesView.ts +++ b/src/views/ProfilesView.ts @@ -5,6 +5,8 @@ import { GetNewLibl } from '../components/getNewLibl'; import { instance } from '../instantiate'; import { Profile } from '../typings'; import { CommandProfile } from '../webviews/commandProfile'; +import { getProfiles } from '../api/local/profiles'; +import IBMi from '../api/IBMi'; export class ProfilesView { private _onDidChangeTreeData = new vscode.EventEmitter(); @@ -59,7 +61,7 @@ export class ProfilesView { const currentProfiles = config.connectionProfiles; const chosenProfile = await getOrPickAvailableProfile(currentProfiles, profileNode); if (chosenProfile) { - vscode.window.showWarningMessage(l10n.t(`Are you sure you want to delete the "{0}" profile?`, chosenProfile.name), l10n.t(`Are you sure you want to delete the "{0}" profile?`, chosenProfile.name), l10n.t(`Are you sure you want to delete the "{0}" profile?`, chosenProfile.name)).then(async result => { + vscode.window.showWarningMessage(l10n.t(`Are you sure you want to delete the "{0}" profile?`, chosenProfile.name), l10n.t(`Yes`), l10n.t(`No`)).then(async result => { if (result === l10n.t(`Yes`)) { currentProfiles.splice(currentProfiles.findIndex(profile => profile === chosenProfile), 1); config.connectionProfiles = currentProfiles; @@ -73,10 +75,12 @@ export class ProfilesView { }), vscode.commands.registerCommand(`code-for-ibmi.loadConnectionProfile`, async (profileNode?: Profile) => { + const connection = instance.getConnection(); const config = instance.getConfig(); const storage = instance.getStorage(); - if (config && storage) { - const chosenProfile = await getOrPickAvailableProfile(config.connectionProfiles, profileNode); + if (connection && config && storage) { + const connectionProfiles = await getAllProfiles(connection); + const chosenProfile = await getOrPickAvailableProfile(connectionProfiles, profileNode); if (chosenProfile) { assignProfile(chosenProfile, config); await ConnectionConfiguration.update(config); @@ -185,10 +189,12 @@ export class ProfilesView { ) } - refresh() { + async refresh() { + const connection = instance.getConnection(); const config = instance.getConfig(); - if (config) { - vscode.commands.executeCommand(`setContext`, `code-for-ibmi:hasProfiles`, config.connectionProfiles.length > 0 || config.commandProfiles.length > 0); + if (connection && config) { + const profiles = await getAllProfiles(connection); + vscode.commands.executeCommand(`setContext`, `code-for-ibmi:hasProfiles`, profiles.length > 0 || config.commandProfiles.length > 0); this._onDidChangeTreeData.fire(null); } } @@ -205,11 +211,15 @@ export class ProfilesView { const storage = instance.getStorage(); if (config && storage) { const currentProfile = storage.getLastProfile(); + const profiles = await getProfilesInGroups(connection); return [ new ResetProfileItem(), - ...config.connectionProfiles + ...profiles.connectionProfiles .map(profile => profile.name) .map(name => new ProfileItem(name, name === currentProfile)), + ...profiles.localProfiles + .map(profile => profile.name) + .map(name => new ProfileItem(name, name === currentProfile, true)), ...config.commandProfiles .map(profile => profile.name) .map(name => new CommandProfileItem(name, name === currentProfile)), @@ -221,6 +231,23 @@ export class ProfilesView { } } +export async function getAllProfiles(connection: IBMi) { + const profiles = connection.config!.connectionProfiles; + const localProfiles = await getProfiles(connection); + + return [...profiles, ...localProfiles]; +} + +async function getProfilesInGroups(connection: IBMi) { + const profiles = connection.config!.connectionProfiles || []; + const localProfiles = await getAllProfiles(connection); + + return { + connectionProfiles: profiles, + localProfiles: localProfiles + } +} + async function getOrPickAvailableProfile(availableProfiles: ConnectionConfiguration.ConnectionProfile[], profileNode?: Profile): Promise { if (availableProfiles.length > 0) { if (profileNode) { @@ -242,12 +269,29 @@ async function getOrPickAvailableProfile(availableProfiles: ConnectionConfigurat } function assignProfile(fromProfile: ConnectionConfiguration.ConnectionProfile, toProfile: ConnectionConfiguration.ConnectionProfile) { - toProfile.homeDirectory = fromProfile.homeDirectory; - toProfile.currentLibrary = fromProfile.currentLibrary; - toProfile.libraryList = fromProfile.libraryList; - toProfile.objectFilters = fromProfile.objectFilters; - toProfile.ifsShortcuts = fromProfile.ifsShortcuts; - toProfile.customVariables = fromProfile.customVariables; + if (fromProfile.homeDirectory && fromProfile.homeDirectory !== `.`) { + toProfile.homeDirectory = fromProfile.homeDirectory; + } + + if (fromProfile.currentLibrary) { + toProfile.currentLibrary = fromProfile.currentLibrary; + } + + if (fromProfile.libraryList.length > 0) { + toProfile.libraryList = fromProfile.libraryList; + } + + if (fromProfile.objectFilters.length > 0) { + toProfile.objectFilters = fromProfile.objectFilters; + } + + if (fromProfile.ifsShortcuts.length > 0) { + toProfile.ifsShortcuts = fromProfile.ifsShortcuts; + } + + if (fromProfile.customVariables) { + toProfile.customVariables = fromProfile.customVariables; + } } function cloneProfile(fromProfile: ConnectionConfiguration.ConnectionProfile, newName: string): ConnectionConfiguration.ConnectionProfile { @@ -264,10 +308,10 @@ function cloneProfile(fromProfile: ConnectionConfiguration.ConnectionProfile, ne class ProfileItem extends vscode.TreeItem implements Profile { readonly profile; - constructor(name: string, active: boolean) { + constructor(name: string, active: boolean, isLocal?: boolean) { super(name, vscode.TreeItemCollapsibleState.None); - this.contextValue = `profile`; + this.contextValue = isLocal ? `localProfile` : `profile`; this.iconPath = new vscode.ThemeIcon(active ? `layers-active` : `layers`); this.description = active ? `Active` : ``; this.tooltip = ``; From 03002989c545d7a829cc012348287d5cfff63bf4 Mon Sep 17 00:00:00 2001 From: worksofliam Date: Mon, 11 Nov 2024 12:12:52 -0500 Subject: [PATCH 02/12] Ability to copy configurable profiles as JSON Signed-off-by: worksofliam --- package.json | 12 ++++++++++++ src/api/local/profiles.ts | 2 +- src/views/ProfilesView.ts | 17 ++++++++++++++--- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index abe8c991d..500e7d13d 100644 --- a/package.json +++ b/package.json @@ -1249,6 +1249,13 @@ "category": "IBM i", "icon": "$(arrow-circle-right)" }, + { + "command": "code-for-ibmi.profiles.copyAsJson", + "enablement": "code-for-ibmi:connected", + "title": "Copy as JSON", + "category": "IBM i", + "icon": "$(clippy)" + }, { "command": "code-for-ibmi.searchSourceFile", "enablement": "code-for-ibmi:connected", @@ -2570,6 +2577,11 @@ "when": "view == profilesView && viewItem == profile", "group": "profiles@1" }, + { + "command": "code-for-ibmi.profiles.copyAsJson", + "when": "view == profilesView && viewItem == profile", + "group": "profiles@3" + }, { "command": "code-for-ibmi.manageCommandProfile", "when": "view == profilesView && viewItem == commandProfile", diff --git a/src/api/local/profiles.ts b/src/api/local/profiles.ts index 9bfc158c0..61112d247 100644 --- a/src/api/local/profiles.ts +++ b/src/api/local/profiles.ts @@ -14,7 +14,7 @@ interface ProfilesFile { profiles: PartialConnectionProfile[] } -export async function getProfiles(connection: IBMi) { +export async function getStaticProfiles(connection: IBMi) { const profiles: ConnectionConfiguration.ConnectionProfile[] = []; if (workspace.workspaceFolders) { diff --git a/src/views/ProfilesView.ts b/src/views/ProfilesView.ts index 4856b130f..cfbab5833 100644 --- a/src/views/ProfilesView.ts +++ b/src/views/ProfilesView.ts @@ -5,7 +5,7 @@ import { GetNewLibl } from '../components/getNewLibl'; import { instance } from '../instantiate'; import { Profile } from '../typings'; import { CommandProfile } from '../webviews/commandProfile'; -import { getProfiles } from '../api/local/profiles'; +import { getStaticProfiles } from '../api/local/profiles'; import IBMi from '../api/IBMi'; export class ProfilesView { @@ -98,6 +98,17 @@ export class ProfilesView { } }), + vscode.commands.registerCommand(`code-for-ibmi.profiles.copyAsJson`, async (profileNode?: Profile) => { + const config = instance.getConfig(); + if (config && profileNode) { + const currentProfiles = config.connectionProfiles; + const chosenProfile = await getOrPickAvailableProfile(currentProfiles, profileNode); + if (chosenProfile) { + await vscode.env.clipboard.writeText(JSON.stringify(chosenProfile, null, 2)); + } + } + }), + vscode.commands.registerCommand(`code-for-ibmi.manageCommandProfile`, async (commandProfile?: CommandProfileItem) => { CommandProfile.show(commandProfile ? commandProfile.profile : undefined); }), @@ -233,14 +244,14 @@ export class ProfilesView { export async function getAllProfiles(connection: IBMi) { const profiles = connection.config!.connectionProfiles; - const localProfiles = await getProfiles(connection); + const localProfiles = await getStaticProfiles(connection); return [...profiles, ...localProfiles]; } async function getProfilesInGroups(connection: IBMi) { const profiles = connection.config!.connectionProfiles || []; - const localProfiles = await getAllProfiles(connection); + const localProfiles = await getStaticProfiles(connection); return { connectionProfiles: profiles, From 06c31d996c601e3b0d69841d5f782227a830309c Mon Sep 17 00:00:00 2001 From: worksofliam Date: Fri, 10 Jan 2025 15:22:00 -0500 Subject: [PATCH 03/12] Refactor profile handling to load configurations from a dedicated config file and remove deprecated methods Signed-off-by: worksofliam --- src/api/IBMi.ts | 34 +++++++++++ src/api/config/configFile.ts | 107 +++++++++++++++++++++++++++++++++ src/api/config/profiles.ts | 70 ++++++++++++++++++++++ src/api/local/profiles.ts | 113 ----------------------------------- src/instantiate.ts | 4 -- src/views/ProfilesView.ts | 7 ++- 6 files changed, 215 insertions(+), 120 deletions(-) create mode 100644 src/api/config/configFile.ts create mode 100644 src/api/config/profiles.ts delete mode 100644 src/api/local/profiles.ts diff --git a/src/api/IBMi.ts b/src/api/IBMi.ts index 358f34e6e..d76c978f1 100644 --- a/src/api/IBMi.ts +++ b/src/api/IBMi.ts @@ -17,6 +17,8 @@ import { Tools } from './Tools'; import * as configVars from './configVars'; import { DebugConfiguration } from "./debug/config"; import { debugPTFInstalled } from "./debug/server"; +import { ConfigFile } from './config/configFile'; +import { getProfilesConfig, ProfilesConfigFile } from './config/profiles'; export interface MemberParts extends IBMiMember { basename: string @@ -53,6 +55,10 @@ const remoteApps = [ // All names MUST also be defined as key in 'remoteFeatures type DisconnectCallback = (conn: IBMi) => Promise; +interface ConnectionConfigFiles { + profiles: ConfigFile +} + export default class IBMi { static readonly CCSID_NOCONVERSION = 65535; static readonly CCSID_SYSVAL = -2; @@ -71,6 +77,11 @@ export default class IBMi { * @deprecated Will become private in v3.0.0 - use {@link IBMi.getConfig} instead. */ config?: ConnectionConfiguration.Parameters; + + private configFiles: ConnectionConfigFiles = { + profiles: getProfilesConfig(this) + } + /** * @deprecated Will become private in v3.0.0 - use {@link IBMi.getContent} instead. */ @@ -115,6 +126,10 @@ export default class IBMi { this.disconnectedCallback = callback; } + getConfigFile(id: keyof ConnectionConfigFiles) { + return this.configFiles[id]; + } + get canUseCqsh() { return this.getComponent(CustomQSh.ID) !== undefined; } @@ -482,6 +497,25 @@ export default class IBMi { } this.appendOutput(`\n`); + // Next, load in all the config files! + + const totalConfigs = Object.keys(this.configFiles).length; + let currentI = 1; + for (const configFile in this.configFiles) { + progress.report({ + message: `Loading from local and remote configuration. (${currentI++}/${totalConfigs})` + }); + + const currentConfig = this.configFiles[configFile as keyof ConnectionConfigFiles]; + + try { + await this.configFiles[configFile as keyof ConnectionConfigFiles].load(); + } catch (e) {} + + + this.appendOutput(`${configFile} config state: ` + JSON.stringify(currentConfig.getState()) + `\n`); + } + progress.report({ message: `Checking library list configuration.` }); diff --git a/src/api/config/configFile.ts b/src/api/config/configFile.ts new file mode 100644 index 000000000..17f6d3210 --- /dev/null +++ b/src/api/config/configFile.ts @@ -0,0 +1,107 @@ + +import path from "path"; +import { workspace } from "vscode"; +import IBMi from "../IBMi"; + +const WORKSPACE_ROOT = `.vscode`; +const SERVER_ROOT = path.posix.join(`/`, `etc`, `.vscode`); + +type ConfigResult = `not_loaded`|`no_exist`|`failed_to_parse`|`ok`; + +interface LoadResult { + workspace: ConfigResult; + server: ConfigResult; +} + +export class ConfigFile { + private state: LoadResult = {server: `not_loaded`, workspace: `not_loaded`}; + private basename: string; + private workspaceFile: string; + private serverFile: string; + private data: T|undefined; + + public hasServerFile = false; + public mergeArrays = false; + public validateAndCleanInPlace: ((loadedConfig: any) => T)|undefined; + + constructor(private connection: IBMi, configId: string) { + this.basename = configId + `.json`; + this.workspaceFile = path.join(WORKSPACE_ROOT, this.basename); + this.serverFile = path.posix.join(SERVER_ROOT, this.basename); + } + + async load(): Promise { + if (this.data) return this.data; + + let resultingConfig: any; + let workspaceConfig: any|undefined; + let serverConfig: any|undefined; + + if (workspace.workspaceFolders) { + const configFiles = await workspace.findFiles(`**${this.workspaceFile}`, null, 1); + + this.state.server = `no_exist`; + + for (const file of configFiles) { + const content = await workspace.fs.readFile(file); + try { + workspaceConfig = JSON.parse(content.toString()); + this.state.workspace = `ok`; + } catch (e: any) { + this.state.server = `failed_to_parse`; + } + }; + } + + if (this.hasServerFile) { + this.state.server = `no_exist`; + + const isAvailable = await this.connection.content.testStreamFile(this.serverFile, `r`); + if (isAvailable) { + const content = await this.connection.content.downloadStreamfileRaw(this.serverFile); + try { + serverConfig = JSON.parse(content.toString()); + this.state.server = `ok`; + } catch (e: any) { + this.state.server = `failed_to_parse`; + } + } + } + + if (workspaceConfig === undefined && serverConfig === undefined) { + return undefined; + } + + if (this.mergeArrays && workspaceConfig && serverConfig) { + resultingConfig = workspaceConfig; + + for (const key in serverConfig) { + if (Array.isArray(serverConfig[key]) && Array.isArray(workspaceConfig[key])) { + resultingConfig = [...workspaceConfig[key], ...serverConfig[key]]; + } + } + + } else { + // Workspace config takes precedence over server config + resultingConfig = workspaceConfig || serverConfig; + } + + + if (this.validateAndCleanInPlace) { + // Should throw an error. + this.validateAndCleanInPlace(resultingConfig); + } + + this.data = resultingConfig; + + return this.data; + } + + reset() { + this.data = undefined; + } + + getState() { + return this.state; + } +} \ No newline at end of file diff --git a/src/api/config/profiles.ts b/src/api/config/profiles.ts new file mode 100644 index 000000000..145f7fd83 --- /dev/null +++ b/src/api/config/profiles.ts @@ -0,0 +1,70 @@ +import { ConnectionConfiguration } from "../Configuration"; +import IBMi from "../IBMi"; +import { ConfigFile } from "./configFile"; + +type FullConnectionProfile = ConnectionConfiguration.ConnectionProfile; + +export interface ProfilesConfigFile { + profiles: FullConnectionProfile[] +} + +export function getProfilesConfig(connection: IBMi) { + const ProfilesConfig = new ConfigFile(connection, `profiles`); + + ProfilesConfig.hasServerFile = true; + + ProfilesConfig.validateAndCleanInPlace = (loadedConfig: ProfilesConfigFile) => { + if (loadedConfig.profiles) { + const profilesJson = loadedConfig.profiles; + // Maybe one day replace this with real schema validation + if (Array.isArray(profilesJson)) { + for (let i = 0; i < profilesJson.length; i++) { + let profile = profilesJson[i]; + if (!profile.name) { + throw new Error(`Profile name is required.`); + } + + if (profile.homeDirectory && typeof profile.homeDirectory !== `string`) { + throw new Error(`Home directory must a string.`); + } + + if (profile.currentLibrary && typeof profile.currentLibrary !== `string`) { + throw new Error(`Current library must a string.`); + } + + if (profile.libraryList && !Array.isArray(profile.libraryList)) { + throw new Error(`Library list must be an array of strings.`); + } + + if (profile.ifsShortcuts && !Array.isArray(profile.ifsShortcuts)) { + throw new Error(`IFS shortcuts must be an array of strings.`); + } + + if (profile.objectFilters && !Array.isArray(profile.objectFilters)) { + throw new Error(`Object filters must be an array of objects.`); + } + + if (profile.customVariables && !Array.isArray(profile.customVariables)) { + throw new Error(`Custom variables must be an array of objects.`); + } + + profilesJson[i] = { + name: profile.name, + homeDirectory: profile.homeDirectory || `.`, + currentLibrary: profile.currentLibrary || ``, + libraryList: profile.libraryList || [], + objectFilters: profile.objectFilters || [], + ifsShortcuts: profile.ifsShortcuts || [], + customVariables: profile.customVariables || [] + } + } + } + } else { + throw new Error(`Profiles file must contain a profiles array.`); + } + + return loadedConfig; + } + + return ProfilesConfig; +} diff --git a/src/api/local/profiles.ts b/src/api/local/profiles.ts deleted file mode 100644 index 61112d247..000000000 --- a/src/api/local/profiles.ts +++ /dev/null @@ -1,113 +0,0 @@ - -import { workspace, window } from "vscode"; -import { ConnectionConfiguration } from "../../api/Configuration"; -import IBMi from "../IBMi"; - -type PartialConnectionProfile = Partial; -type FullConnectionProfile = ConnectionConfiguration.ConnectionProfile; - -const PROFILES_PATH = `/.vscode/profiles.json`; - -let serverProfiles: FullConnectionProfile[]|undefined; - -interface ProfilesFile { - profiles: PartialConnectionProfile[] -} - -export async function getStaticProfiles(connection: IBMi) { - const profiles: ConnectionConfiguration.ConnectionProfile[] = []; - - if (workspace.workspaceFolders) { - const actionsFiles = await workspace.findFiles(`**${PROFILES_PATH}`); - - for (const file of actionsFiles) { - const content = await workspace.fs.readFile(file); - try { - profiles.push(...parseJsonIntoProfilesFile(content.toString())); - } catch (e: any) { - // ignore - window.showErrorMessage(`Error parsing ${file.fsPath}: ${e.message}\n`); - } - }; - } - - if (serverProfiles === undefined) { - serverProfiles = []; - const isAvailable = await connection.content.testStreamFile(PROFILES_PATH, `r`); - if (isAvailable) { - const content = await connection.content.downloadStreamfileRaw(PROFILES_PATH); - try { - serverProfiles = parseJsonIntoProfilesFile(content.toString()); - } catch (e: any) { - // ignore - window.showErrorMessage(`Error parsing server file ${PROFILES_PATH}: ${e.message}\n`); - } - } - } else if (Array.isArray(serverProfiles)) { - profiles.push(...serverProfiles); - } - - return profiles; -} - -export function resetServerProfiles() { - serverProfiles = undefined; -} - -function parseJsonIntoProfilesFile(json: string) { - const profiles: ConnectionConfiguration.ConnectionProfile[] = []; - const theJson: ProfilesFile = JSON.parse(json.toString()); - - if (theJson.profiles) { - const profilesJson = theJson.profiles; - // Maybe one day replace this with real schema validation - if (Array.isArray(profilesJson)) { - profilesJson.forEach((profile, index) => { - const validProfile = validateLocalProfile(profile); - profiles.push(validProfile); - }) - } - } - - return profiles; -} - -function validateLocalProfile(input: PartialConnectionProfile): FullConnectionProfile { - if (!input.name) { - throw new Error(`Profile name is required.`); - } - - if (input.homeDirectory && typeof input.homeDirectory !== `string`) { - throw new Error(`Home directory must a string.`); - } - - if (input.currentLibrary && typeof input.currentLibrary !== `string`) { - throw new Error(`Current library must a string.`); - } - - if (input.libraryList && !Array.isArray(input.libraryList)) { - throw new Error(`Library list must be an array of strings.`); - } - - if (input.ifsShortcuts && !Array.isArray(input.ifsShortcuts)) { - throw new Error(`IFS shortcuts must be an array of strings.`); - } - - if (input.objectFilters && !Array.isArray(input.objectFilters)) { - throw new Error(`Object filters must be an array of objects.`); - } - - if (input.customVariables && !Array.isArray(input.customVariables)) { - throw new Error(`Custom variables must be an array of objects.`); - } - - return { - name: input.name, - homeDirectory: input.homeDirectory || `.`, - currentLibrary: input.currentLibrary || ``, - libraryList: input.libraryList || [], - objectFilters: input.objectFilters || [], - ifsShortcuts: input.ifsShortcuts || [], - customVariables: input.customVariables || [] - } -} \ No newline at end of file diff --git a/src/instantiate.ts b/src/instantiate.ts index 2fcd029e2..1fd45f11d 100644 --- a/src/instantiate.ts +++ b/src/instantiate.ts @@ -16,7 +16,6 @@ import { SEUColorProvider } from "./languages/general/SEUColorProvider"; import { ActionsUI } from './webviews/actions'; import { VariablesUI } from "./webviews/variables"; import { getAllProfiles } from './views/ProfilesView'; -import { resetServerProfiles } from './api/local/profiles'; export let instance: Instance; @@ -136,9 +135,6 @@ async function onConnected() { updateConnectedBar(); - // Enable the profile view if profiles exist. - resetServerProfiles(); - const connection = instance.getConnection()!; const profiles = await getAllProfiles(connection); vscode.commands.executeCommand(`setContext`, `code-for-ibmi:hasProfiles`, profiles.length > 0); diff --git a/src/views/ProfilesView.ts b/src/views/ProfilesView.ts index 5e3d5b394..46aa0104a 100644 --- a/src/views/ProfilesView.ts +++ b/src/views/ProfilesView.ts @@ -5,7 +5,6 @@ import { GetNewLibl } from '../components/getNewLibl'; import { instance } from '../instantiate'; import { Profile } from '../typings'; import { CommandProfile } from '../webviews/commandProfile'; -import { getStaticProfiles } from '../api/local/profiles'; import IBMi from '../api/IBMi'; export class ProfilesView { @@ -244,14 +243,16 @@ export class ProfilesView { export async function getAllProfiles(connection: IBMi) { const profiles = connection.config!.connectionProfiles; - const localProfiles = await getStaticProfiles(connection); + const profilesConfig = await connection.getConfigFile(`profiles`).load(); + const localProfiles = profilesConfig ? profilesConfig.profiles : []; return [...profiles, ...localProfiles]; } async function getProfilesInGroups(connection: IBMi) { const profiles = connection.config!.connectionProfiles || []; - const localProfiles = await getStaticProfiles(connection); + const profilesConfig = await connection.getConfigFile(`profiles`).load(); + const localProfiles = profilesConfig ? profilesConfig.profiles : []; return { connectionProfiles: profiles, From 190c80cb0617159674b0c4fa99bc057c0704c514 Mon Sep 17 00:00:00 2001 From: worksofliam Date: Fri, 10 Jan 2025 15:58:02 -0500 Subject: [PATCH 04/12] Refactor action handling to load configurations from a dedicated config file and remove deprecated methods Signed-off-by: worksofliam --- src/api/CompileTools.ts | 7 ++-- src/api/IBMi.ts | 13 +++--- src/api/config/actions.ts | 37 ++++++++++++++++++ src/api/config/configFile.ts | 76 ++++++++++++++++++++---------------- src/api/local/actions.ts | 40 ------------------- src/api/local/deployment.ts | 9 ++--- src/views/ProfilesView.ts | 5 ++- 7 files changed, 99 insertions(+), 88 deletions(-) create mode 100644 src/api/config/actions.ts diff --git a/src/api/CompileTools.ts b/src/api/CompileTools.ts index d87fd71be..ec2007c0d 100644 --- a/src/api/CompileTools.ts +++ b/src/api/CompileTools.ts @@ -9,7 +9,6 @@ import IBMi from './IBMi'; import Instance from './Instance'; import { Tools } from './Tools'; import { EvfEventInfo, refreshDiagnosticsFromLocal, refreshDiagnosticsFromServer, registerDiagnostics } from './errors/diagnostics'; -import { getLocalActions } from './local/actions'; import { DeployTools } from './local/deployTools'; import { getBranchLibraryName, getEnvConfig } from './local/env'; import { getGitBranch } from './local/git'; @@ -102,8 +101,10 @@ export namespace CompileTools { // Then, if we're being called from a local file // we fetch the Actions defined from the workspace. if (workspaceFolder && uri.scheme === `file`) { - const localActions = await getLocalActions(workspaceFolder); - allActions.push(...localActions); + const localActions = await connection.getConfigFile(`actions`).get(workspaceFolder); + if (localActions) { + allActions.push(...localActions); + } } // We make sure all extensions are uppercase diff --git a/src/api/IBMi.ts b/src/api/IBMi.ts index d76c978f1..28b453258 100644 --- a/src/api/IBMi.ts +++ b/src/api/IBMi.ts @@ -8,7 +8,7 @@ import { IBMiComponent } from "../components/component"; import { CopyToImport } from "../components/copyToImport"; import { CustomQSh } from '../components/cqsh'; import { ComponentManager } from "../components/manager"; -import { CommandData, CommandResult, ConnectionData, IBMiMember, RemoteCommand, WrapResult } from "../typings"; +import { Action, CommandData, CommandResult, ConnectionData, IBMiMember, RemoteCommand, WrapResult } from "../typings"; import { CompileTools } from "./CompileTools"; import { ConnectionConfiguration } from "./Configuration"; import IBMiContent from "./IBMiContent"; @@ -19,6 +19,7 @@ import { DebugConfiguration } from "./debug/config"; import { debugPTFInstalled } from "./debug/server"; import { ConfigFile } from './config/configFile'; import { getProfilesConfig, ProfilesConfigFile } from './config/profiles'; +import { getActionsConfig } from './config/actions'; export interface MemberParts extends IBMiMember { basename: string @@ -57,6 +58,7 @@ type DisconnectCallback = (conn: IBMi) => Promise; interface ConnectionConfigFiles { profiles: ConfigFile + actions: ConfigFile } export default class IBMi { @@ -79,7 +81,8 @@ export default class IBMi { config?: ConnectionConfiguration.Parameters; private configFiles: ConnectionConfigFiles = { - profiles: getProfilesConfig(this) + profiles: getProfilesConfig(this), + actions: getActionsConfig(this) } /** @@ -126,8 +129,8 @@ export default class IBMi { this.disconnectedCallback = callback; } - getConfigFile(id: keyof ConnectionConfigFiles) { - return this.configFiles[id]; + getConfigFile(id: keyof ConnectionConfigFiles) { + return this.configFiles[id] as ConfigFile; } get canUseCqsh() { @@ -509,7 +512,7 @@ export default class IBMi { const currentConfig = this.configFiles[configFile as keyof ConnectionConfigFiles]; try { - await this.configFiles[configFile as keyof ConnectionConfigFiles].load(); + await this.configFiles[configFile as keyof ConnectionConfigFiles].loadFromServer(); } catch (e) {} diff --git a/src/api/config/actions.ts b/src/api/config/actions.ts new file mode 100644 index 000000000..f55f5d7f4 --- /dev/null +++ b/src/api/config/actions.ts @@ -0,0 +1,37 @@ +import { Action } from "../../typings"; +import { ConnectionConfiguration } from "../Configuration"; +import IBMi from "../IBMi"; +import { ConfigFile } from "./configFile"; + +export function getActionsConfig(connection: IBMi) { + const ActionsConfig = new ConfigFile(connection, `actions`); + + ActionsConfig.hasServerFile = true; + ActionsConfig.mergeArrays = true; + + ActionsConfig.validateAndCleanInPlace = (loadedConfig) => { + let actions: Action[] = []; + // Maybe one day replace this with real schema validation + if (Array.isArray(loadedConfig)) { + loadedConfig.forEach((action, index) => { + if ( + typeof action.name === `string` && + typeof action.command === `string` && + [`ile`, `pase`, `qsh`].includes(action.environment) && + Array.isArray(action.extensions) + ) { + actions.push({ + ...action, + type: `file` + }); + } else { + throw new Error(`Invalid Action defined at index ${index}.`); + } + }) + } + + return actions; + } + + return ActionsConfig; +} diff --git a/src/api/config/configFile.ts b/src/api/config/configFile.ts index 17f6d3210..5342d0cd4 100644 --- a/src/api/config/configFile.ts +++ b/src/api/config/configFile.ts @@ -1,6 +1,6 @@ import path from "path"; -import { workspace } from "vscode"; +import { RelativePattern, workspace, WorkspaceFolder } from "vscode"; import IBMi from "../IBMi"; const WORKSPACE_ROOT = `.vscode`; @@ -18,7 +18,7 @@ export class ConfigFile { private basename: string; private workspaceFile: string; private serverFile: string; - private data: T|undefined; + private serverData: T|undefined; public hasServerFile = false; public mergeArrays = false; @@ -30,15 +30,41 @@ export class ConfigFile { this.serverFile = path.posix.join(SERVER_ROOT, this.basename); } - async load(): Promise { - if (this.data) return this.data; + async loadFromServer() { + let serverConfig: any|undefined; + + if (this.hasServerFile) { + this.state.server = `no_exist`; + + const isAvailable = await this.connection.content.testStreamFile(this.serverFile, `r`); + if (isAvailable) { + const content = await this.connection.content.downloadStreamfileRaw(this.serverFile); + try { + serverConfig = JSON.parse(content.toString()); + this.state.server = `ok`; + } catch (e: any) { + this.state.server = `failed_to_parse`; + } + } + + if (this.validateAndCleanInPlace) { + // Should throw an error. + this.validateAndCleanInPlace(serverConfig); + } + + this.serverData = serverConfig; + } + } + + async get(currentWorkspace?: WorkspaceFolder): Promise { + if (this.serverData) return this.serverData; let resultingConfig: any; let workspaceConfig: any|undefined; - let serverConfig: any|undefined; - if (workspace.workspaceFolders) { - const configFiles = await workspace.findFiles(`**${this.workspaceFile}`, null, 1); + if (workspace.workspaceFolders && currentWorkspace) { + const relativeSearch = new RelativePattern(currentWorkspace, `**/${this.workspaceFile}`); + const configFiles = await workspace.findFiles(relativeSearch, null, 1); this.state.server = `no_exist`; @@ -53,52 +79,36 @@ export class ConfigFile { }; } - if (this.hasServerFile) { - this.state.server = `no_exist`; - - const isAvailable = await this.connection.content.testStreamFile(this.serverFile, `r`); - if (isAvailable) { - const content = await this.connection.content.downloadStreamfileRaw(this.serverFile); - try { - serverConfig = JSON.parse(content.toString()); - this.state.server = `ok`; - } catch (e: any) { - this.state.server = `failed_to_parse`; - } - } - } - - if (workspaceConfig === undefined && serverConfig === undefined) { + if (workspaceConfig === undefined && this.serverData === undefined) { return undefined; } - if (this.mergeArrays && workspaceConfig && serverConfig) { + if (this.mergeArrays && workspaceConfig && this.serverData) { resultingConfig = workspaceConfig; - for (const key in serverConfig) { - if (Array.isArray(serverConfig[key]) && Array.isArray(workspaceConfig[key])) { - resultingConfig = [...workspaceConfig[key], ...serverConfig[key]]; + + for (const key in resultingConfig) { + if (Array.isArray(resultingConfig[key]) && Array.isArray((this.serverData as any)[key])) { + resultingConfig = [...workspaceConfig[key], ...(this.serverData as any)[key]]; } } } else { // Workspace config takes precedence over server config - resultingConfig = workspaceConfig || serverConfig; + resultingConfig = workspaceConfig || this.serverData; } if (this.validateAndCleanInPlace) { // Should throw an error. - this.validateAndCleanInPlace(resultingConfig); + resultingConfig = this.validateAndCleanInPlace(resultingConfig); } - this.data = resultingConfig; - - return this.data; + return resultingConfig as T; } reset() { - this.data = undefined; + this.serverData = undefined; } getState() { diff --git a/src/api/local/actions.ts b/src/api/local/actions.ts index a95c0776e..f6465ccac 100644 --- a/src/api/local/actions.ts +++ b/src/api/local/actions.ts @@ -1,46 +1,6 @@ import { RelativePattern, window, workspace, WorkspaceFolder } from "vscode"; import { Action } from "../../typings"; -export async function getLocalActions(currentWorkspace: WorkspaceFolder) { - const actions: Action[] = []; - - if (currentWorkspace) { - const relativeSearch = new RelativePattern(currentWorkspace, `**/.vscode/actions.json`); - const actionsFiles = await workspace.findFiles(relativeSearch); - - for (const file of actionsFiles) { - const actionsContent = await workspace.fs.readFile(file); - try { - const actionsJson: Action[] = JSON.parse(actionsContent.toString()); - - // Maybe one day replace this with real schema validation - if (Array.isArray(actionsJson)) { - actionsJson.forEach((action, index) => { - if ( - typeof action.name === `string` && - typeof action.command === `string` && - [`ile`, `pase`, `qsh`].includes(action.environment) && - Array.isArray(action.extensions) - ) { - actions.push({ - ...action, - type: `file` - }); - } else { - throw new Error(`Invalid Action defined at index ${index}.`); - } - }) - } - } catch (e: any) { - // ignore - window.showErrorMessage(`Error parsing ${file.fsPath}: ${e.message}\n`); - } - }; - } - - return actions; -} - export async function getEvfeventFiles(currentWorkspace: WorkspaceFolder) { if (currentWorkspace) { const relativeSearch = new RelativePattern(currentWorkspace, `**/.evfevent/*`); diff --git a/src/api/local/deployment.ts b/src/api/local/deployment.ts index 26ff45939..49fc99ea0 100644 --- a/src/api/local/deployment.ts +++ b/src/api/local/deployment.ts @@ -4,10 +4,9 @@ import tar from 'tar'; import tmp from 'tmp'; import vscode from 'vscode'; import { instance } from '../../instantiate'; -import { DeploymentParameters } from '../../typings'; +import { Action, DeploymentParameters } from '../../typings'; import IBMi from '../IBMi'; import { Tools } from '../Tools'; -import { getLocalActions } from './actions'; import { DeployTools } from './deployTools'; export namespace Deployment { @@ -78,8 +77,8 @@ export namespace Deployment { }); } - getLocalActions(workspace).then(result => { - if (result.length === 0) { + connection.getConfigFile(`actions`).get(workspace).then(result => { + if (result === undefined || result.length === 0) { vscode.window.showInformationMessage( `There are no local Actions defined for this project.`, `Run Setup` @@ -232,7 +231,7 @@ export namespace Deployment { deploymentLog.appendLine(`Created deployment tarball ${localTarball.name}`); progress?.report({ message: `sending deployment tarball...` }); - await connection.client.putFile(localTarball.name, remoteTarball); + await connection.client!.putFile(localTarball.name, remoteTarball); deploymentLog.appendLine(`Uploaded deployment tarball as ${remoteTarball}`); progress?.report({ message: `extracting deployment tarball to ${parameters.remotePath}...` }); diff --git a/src/views/ProfilesView.ts b/src/views/ProfilesView.ts index 46aa0104a..4f8ca1cc7 100644 --- a/src/views/ProfilesView.ts +++ b/src/views/ProfilesView.ts @@ -6,6 +6,7 @@ import { instance } from '../instantiate'; import { Profile } from '../typings'; import { CommandProfile } from '../webviews/commandProfile'; import IBMi from '../api/IBMi'; +import { ProfilesConfigFile } from '../api/config/profiles'; export class ProfilesView { private _onDidChangeTreeData = new vscode.EventEmitter(); @@ -243,7 +244,7 @@ export class ProfilesView { export async function getAllProfiles(connection: IBMi) { const profiles = connection.config!.connectionProfiles; - const profilesConfig = await connection.getConfigFile(`profiles`).load(); + const profilesConfig = await connection.getConfigFile(`profiles`).get(); const localProfiles = profilesConfig ? profilesConfig.profiles : []; return [...profiles, ...localProfiles]; @@ -251,7 +252,7 @@ export async function getAllProfiles(connection: IBMi) { async function getProfilesInGroups(connection: IBMi) { const profiles = connection.config!.connectionProfiles || []; - const profilesConfig = await connection.getConfigFile(`profiles`).load(); + const profilesConfig = await connection.getConfigFile(`profiles`).get(); const localProfiles = profilesConfig ? profilesConfig.profiles : []; return { From c230fbd3425d85e03380431eebb2c22c648042cb Mon Sep 17 00:00:00 2001 From: worksofliam Date: Fri, 10 Jan 2025 16:09:48 -0500 Subject: [PATCH 05/12] Add 'type' property to actions schema and update actions configuration Signed-off-by: worksofliam --- schemas/actions.json | 12 ++++++++++++ src/api/config/actions.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/schemas/actions.json b/schemas/actions.json index b7dcf3b44..f433f36d8 100644 --- a/schemas/actions.json +++ b/schemas/actions.json @@ -34,6 +34,18 @@ "description": "The command that will be run when executing this Action.", "default": "" }, + "type": { + "$id": "#/actions/items/anyOf/0/properties/type", + "type": "string", + "title": "File system type", + "description": "Which environment the command will run in.", + "default": "file", + "enum": [ + "file", + "member", + "streamfile" + ] + }, "environment": { "$id": "#/actions/items/anyOf/0/properties/commandEnvironment", "type": "string", diff --git a/src/api/config/actions.ts b/src/api/config/actions.ts index f55f5d7f4..b71e0c149 100644 --- a/src/api/config/actions.ts +++ b/src/api/config/actions.ts @@ -21,8 +21,8 @@ export function getActionsConfig(connection: IBMi) { Array.isArray(action.extensions) ) { actions.push({ + type: `file`, ...action, - type: `file` }); } else { throw new Error(`Invalid Action defined at index ${index}.`); From 3029ec59bee5b7ed1c1e5136077d78331229d8a5 Mon Sep 17 00:00:00 2001 From: worksofliam Date: Fri, 10 Jan 2025 16:15:53 -0500 Subject: [PATCH 06/12] Refactor configuration loading and enhance settings UI with reload option Signed-off-by: worksofliam --- src/api/IBMi.ts | 33 ++++++++++++++++++--------------- src/webviews/settings/index.ts | 11 +++++++++++ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/api/IBMi.ts b/src/api/IBMi.ts index 28b453258..0047662a4 100644 --- a/src/api/IBMi.ts +++ b/src/api/IBMi.ts @@ -502,22 +502,11 @@ export default class IBMi { // Next, load in all the config files! - const totalConfigs = Object.keys(this.configFiles).length; - let currentI = 1; - for (const configFile in this.configFiles) { - progress.report({ - message: `Loading from local and remote configuration. (${currentI++}/${totalConfigs})` - }); - - const currentConfig = this.configFiles[configFile as keyof ConnectionConfigFiles]; - - try { - await this.configFiles[configFile as keyof ConnectionConfigFiles].loadFromServer(); - } catch (e) {} + progress.report({ + message: `Loading remote configuration files.` + }); - - this.appendOutput(`${configFile} config state: ` + JSON.stringify(currentConfig.getState()) + `\n`); - } + await this.loadRemoteConfigs(); progress.report({ message: `Checking library list configuration.` @@ -1164,6 +1153,20 @@ export default class IBMi { } } + async loadRemoteConfigs() { + for (const configFile in this.configFiles) { + const currentConfig = this.configFiles[configFile as keyof ConnectionConfigFiles]; + + this.configFiles[configFile as keyof ConnectionConfigFiles].reset(); + + try { + await this.configFiles[configFile as keyof ConnectionConfigFiles].loadFromServer(); + } catch (e) { } + + this.appendOutput(`${configFile} config state: ` + JSON.stringify(currentConfig.getState()) + `\n`); + } + } + /** * Can return 0 if the OS version was not detected. */ diff --git a/src/webviews/settings/index.ts b/src/webviews/settings/index.ts index 7df552708..3555416b8 100644 --- a/src/webviews/settings/index.ts +++ b/src/webviews/settings/index.ts @@ -69,6 +69,13 @@ export class SettingsUI { .addCheckbox(`autoSaveBeforeAction`, `Auto Save for Actions`, `When current editor has unsaved changes, automatically save it before running an action.`, config.autoSaveBeforeAction) .addInput(`hideCompileErrors`, `Errors to ignore`, `A comma delimited list of errors to be hidden from the result of an Action in the EVFEVENT file. Useful for codes like RNF5409.`, { default: config.hideCompileErrors.join(`, `) }) + if (connection) { + featuresTab + .addHorizontalRule() + .addParagraph(`Profiles and Actions can be stored on the server, but are only loaded when connecting to the server. Use this to reload them now.`) + .addButtons({ id: `reloadConfigs`, label: `Reload config files` }) + } + const tempDataTab = new Section(); tempDataTab .addInput(`tempLibrary`, `Temporary library`, `Temporary library. Cannot be QTEMP.`, { default: config.tempLibrary, minlength: 1, maxlength: 10 }) @@ -270,6 +277,10 @@ export class SettingsUI { const button = data.buttons; switch (button) { + case `reloadConfigs`: + await connection?.loadRemoteConfigs(); + break; + case `import`: vscode.commands.executeCommand(`code-for-ibmi.debug.setup.local`); break; From 5ad9021631e3c6934b4539cb0b886efd384cc9e3 Mon Sep 17 00:00:00 2001 From: worksofliam Date: Mon, 13 Jan 2025 18:39:13 -0500 Subject: [PATCH 07/12] Remove redundant serverData check in config retrieval and correct error state assignment Signed-off-by: worksofliam --- src/api/config/configFile.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/api/config/configFile.ts b/src/api/config/configFile.ts index 5342d0cd4..091b17de6 100644 --- a/src/api/config/configFile.ts +++ b/src/api/config/configFile.ts @@ -57,8 +57,6 @@ export class ConfigFile { } async get(currentWorkspace?: WorkspaceFolder): Promise { - if (this.serverData) return this.serverData; - let resultingConfig: any; let workspaceConfig: any|undefined; @@ -74,7 +72,7 @@ export class ConfigFile { workspaceConfig = JSON.parse(content.toString()); this.state.workspace = `ok`; } catch (e: any) { - this.state.server = `failed_to_parse`; + this.state.workspace = `failed_to_parse`; } }; } From c435b8b41148c6d6c1d2abebf817e5073430150a Mon Sep 17 00:00:00 2001 From: worksofliam Date: Tue, 14 Jan 2025 08:29:20 -0500 Subject: [PATCH 08/12] Update to schemas Signed-off-by: worksofliam --- package.json | 5 +++-- schemas/profiles.json | 25 +------------------------ 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index 88cf46267..3747ab87f 100644 --- a/package.json +++ b/package.json @@ -47,14 +47,15 @@ "jsonValidation": [ { "fileMatch": [ - ".vscode/actions.json" + ".vscode/actions.json", + "/etc/.vscode/actions.json" ], "url": "./schemas/actions.json" }, { "fileMatch": [ ".vscode/profiles.json", - "/.vscode/profiles.json" + "/etc/.vscode/profiles.json" ], "url": "./schemas/profiles.json" } diff --git a/schemas/profiles.json b/schemas/profiles.json index 34f1e6664..a40dc567f 100644 --- a/schemas/profiles.json +++ b/schemas/profiles.json @@ -60,30 +60,7 @@ "properties": { "profiles": { "type": "array", - "items": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "homeDirectory": { "type": "string" }, - "currentLibrary": { "type": "string" }, - "libraryList": { - "type": "array", - "items": { "type": "string" } - }, - "objectFilters": { - "type": "array", - "items": { "$ref": "#/definitions/ObjectFilters" } - }, - "ifsShortcuts": { - "type": "array", - "items": { "type": "string" } - }, - "customVariables": { - "type": "array", - "items": { "$ref": "#/definitions/CustomVariable" } - } - } - } + "items": { "$ref": "#/definitions/ConnectionProfile" } } }, "required": ["profiles"] From 66afb89c86ebeac4b31e4d1694c97e17fc554a80 Mon Sep 17 00:00:00 2001 From: worksofliam Date: Tue, 14 Jan 2025 08:46:07 -0500 Subject: [PATCH 09/12] Refactor configuration handling to include fallback values and streamline profile retrieval Signed-off-by: worksofliam --- src/api/CompileTools.ts | 4 +--- src/api/config/actions.ts | 2 +- src/api/config/configFile.ts | 14 ++++++++++---- src/api/config/profiles.ts | 2 +- src/views/ProfilesView.ts | 10 ++++------ 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/src/api/CompileTools.ts b/src/api/CompileTools.ts index ec2007c0d..cb7ad9681 100644 --- a/src/api/CompileTools.ts +++ b/src/api/CompileTools.ts @@ -102,9 +102,7 @@ export namespace CompileTools { // we fetch the Actions defined from the workspace. if (workspaceFolder && uri.scheme === `file`) { const localActions = await connection.getConfigFile(`actions`).get(workspaceFolder); - if (localActions) { - allActions.push(...localActions); - } + allActions.push(...localActions); } // We make sure all extensions are uppercase diff --git a/src/api/config/actions.ts b/src/api/config/actions.ts index b71e0c149..542a0ead2 100644 --- a/src/api/config/actions.ts +++ b/src/api/config/actions.ts @@ -4,7 +4,7 @@ import IBMi from "../IBMi"; import { ConfigFile } from "./configFile"; export function getActionsConfig(connection: IBMi) { - const ActionsConfig = new ConfigFile(connection, `actions`); + const ActionsConfig = new ConfigFile(connection, `actions`, []); ActionsConfig.hasServerFile = true; ActionsConfig.mergeArrays = true; diff --git a/src/api/config/configFile.ts b/src/api/config/configFile.ts index 091b17de6..8f615ee4e 100644 --- a/src/api/config/configFile.ts +++ b/src/api/config/configFile.ts @@ -2,6 +2,7 @@ import path from "path"; import { RelativePattern, workspace, WorkspaceFolder } from "vscode"; import IBMi from "../IBMi"; +import { ConnectionConfiguration, ConnectionManager, onCodeForIBMiConfigurationChange } from "../../api/Configuration"; const WORKSPACE_ROOT = `.vscode`; const SERVER_ROOT = path.posix.join(`/`, `etc`, `.vscode`); @@ -24,7 +25,7 @@ export class ConfigFile { public mergeArrays = false; public validateAndCleanInPlace: ((loadedConfig: any) => T)|undefined; - constructor(private connection: IBMi, configId: string) { + constructor(private connection: IBMi, configId: string, readonly fallback: T) { this.basename = configId + `.json`; this.workspaceFile = path.join(WORKSPACE_ROOT, this.basename); this.serverFile = path.posix.join(SERVER_ROOT, this.basename); @@ -56,7 +57,7 @@ export class ConfigFile { } } - async get(currentWorkspace?: WorkspaceFolder): Promise { + async get(currentWorkspace?: WorkspaceFolder): Promise { let resultingConfig: any; let workspaceConfig: any|undefined; @@ -78,7 +79,7 @@ export class ConfigFile { } if (workspaceConfig === undefined && this.serverData === undefined) { - return undefined; + return this.fallback; } if (this.mergeArrays && workspaceConfig && this.serverData) { @@ -99,7 +100,12 @@ export class ConfigFile { if (this.validateAndCleanInPlace) { // Should throw an error. - resultingConfig = this.validateAndCleanInPlace(resultingConfig); + try { + resultingConfig = this.validateAndCleanInPlace(resultingConfig); + } catch (e: any) { + resultingConfig = this.fallback; + console.log(`Error validating config file: ${e.message}`); + } } return resultingConfig as T; diff --git a/src/api/config/profiles.ts b/src/api/config/profiles.ts index 145f7fd83..3702fd523 100644 --- a/src/api/config/profiles.ts +++ b/src/api/config/profiles.ts @@ -9,7 +9,7 @@ export interface ProfilesConfigFile { } export function getProfilesConfig(connection: IBMi) { - const ProfilesConfig = new ConfigFile(connection, `profiles`); + const ProfilesConfig = new ConfigFile(connection, `profiles`, {profiles: []}); ProfilesConfig.hasServerFile = true; diff --git a/src/views/ProfilesView.ts b/src/views/ProfilesView.ts index 4f8ca1cc7..f7b8de1e2 100644 --- a/src/views/ProfilesView.ts +++ b/src/views/ProfilesView.ts @@ -244,20 +244,18 @@ export class ProfilesView { export async function getAllProfiles(connection: IBMi) { const profiles = connection.config!.connectionProfiles; - const profilesConfig = await connection.getConfigFile(`profiles`).get(); - const localProfiles = profilesConfig ? profilesConfig.profiles : []; + const profilesConfig = (await connection.getConfigFile(`profiles`).get()).profiles; - return [...profiles, ...localProfiles]; + return [...profiles, ...profilesConfig]; } async function getProfilesInGroups(connection: IBMi) { const profiles = connection.config!.connectionProfiles || []; - const profilesConfig = await connection.getConfigFile(`profiles`).get(); - const localProfiles = profilesConfig ? profilesConfig.profiles : []; + const profilesConfig = (await connection.getConfigFile(`profiles`).get()).profiles; return { connectionProfiles: profiles, - localProfiles: localProfiles + localProfiles: profilesConfig } } From 51bc3dfce27498a103c163e9c2aecde026c38e2a Mon Sep 17 00:00:00 2001 From: worksofliam Date: Tue, 14 Jan 2025 09:29:53 -0500 Subject: [PATCH 10/12] Enhance configuration loading by adding 'invalid' state and improving error handling Signed-off-by: worksofliam --- src/api/config/configFile.ts | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/api/config/configFile.ts b/src/api/config/configFile.ts index 8f615ee4e..b55654217 100644 --- a/src/api/config/configFile.ts +++ b/src/api/config/configFile.ts @@ -7,7 +7,7 @@ import { ConnectionConfiguration, ConnectionManager, onCodeForIBMiConfigurationC const WORKSPACE_ROOT = `.vscode`; const SERVER_ROOT = path.posix.join(`/`, `etc`, `.vscode`); -type ConfigResult = `not_loaded`|`no_exist`|`failed_to_parse`|`ok`; +type ConfigResult = `not_loaded`|`no_exist`|`failed_to_parse`|`invalid`|`ok`; interface LoadResult { workspace: ConfigResult; @@ -31,6 +31,13 @@ export class ConfigFile { this.serverFile = path.posix.join(SERVER_ROOT, this.basename); } + getPaths() { + return { + workspace: this.workspaceFile, + server: this.serverFile, + } + } + async loadFromServer() { let serverConfig: any|undefined; @@ -46,14 +53,17 @@ export class ConfigFile { } catch (e: any) { this.state.server = `failed_to_parse`; } - } - if (this.validateAndCleanInPlace) { - // Should throw an error. - this.validateAndCleanInPlace(serverConfig); + if (this.validateAndCleanInPlace) { + // Should throw an error. + try { + this.serverData = this.validateAndCleanInPlace(serverConfig); + } catch (e) { + this.state.server = `invalid`; + this.serverData = undefined; + } + } } - - this.serverData = serverConfig; } } @@ -88,7 +98,7 @@ export class ConfigFile { for (const key in resultingConfig) { if (Array.isArray(resultingConfig[key]) && Array.isArray((this.serverData as any)[key])) { - resultingConfig = [...workspaceConfig[key], ...(this.serverData as any)[key]]; + resultingConfig[key] = [...workspaceConfig[key], ...(this.serverData as any)[key]]; } } @@ -104,6 +114,7 @@ export class ConfigFile { resultingConfig = this.validateAndCleanInPlace(resultingConfig); } catch (e: any) { resultingConfig = this.fallback; + this.state.workspace = `invalid`; console.log(`Error validating config file: ${e.message}`); } } From 08316723a20f43e6cc522960ba01822747f802d7 Mon Sep 17 00:00:00 2001 From: worksofliam Date: Tue, 14 Jan 2025 09:30:00 -0500 Subject: [PATCH 11/12] Add configuration file tests and ensure proper validation and merging of server and workspace configurations Signed-off-by: worksofliam --- src/api/IBMiContent.ts | 2 +- src/testing/configFile.ts | 174 ++++++++++++++++++++++++++++++++++++++ src/testing/index.ts | 2 + 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 src/testing/configFile.ts diff --git a/src/api/IBMiContent.ts b/src/api/IBMiContent.ts index 8455e5f1c..7918f7af9 100644 --- a/src/api/IBMiContent.ts +++ b/src/api/IBMiContent.ts @@ -104,7 +104,7 @@ export default class IBMiContent { * @param encoding Optional encoding to write. */ async writeStreamfileRaw(originalPath: string, content: Uint8Array, encoding?: string) { - const client = this.ibmi.client; + const client = this.ibmi.client!; const features = this.ibmi.remoteFeatures; const tmpobj = await tmpFile(); diff --git a/src/testing/configFile.ts b/src/testing/configFile.ts new file mode 100644 index 000000000..a6bf299a9 --- /dev/null +++ b/src/testing/configFile.ts @@ -0,0 +1,174 @@ +import assert from "assert"; +import { randomInt } from "crypto"; +import { posix } from "path"; +import tmp from 'tmp'; +import util, { TextDecoder } from 'util'; +import { RelativePattern, Uri, workspace} from "vscode"; +import { TestSuite } from "."; +import { Tools } from "../api/Tools"; +import { getMemberUri } from "../filesystems/qsys/QSysFs"; +import { instance } from "../instantiate"; +import { CommandResult } from "../typings"; +import { ConfigFile } from "../api/config/configFile"; +import IBMi from "../api/IBMi"; + +interface TestConfig { + strings: []; +} + +async function deleteFile(connection: IBMi, thePath: string) { + if (thePath.startsWith(`/`)) { + await connection.sendCommand({command: `rm -f ${thePath}`}); + } else if (workspace.workspaceFolders) { + const ws = workspace.workspaceFolders[0]; + if (ws) { + const relativeSearch = new RelativePattern(ws, `**/${thePath}`); + const configFiles = await workspace.findFiles(relativeSearch, null, 1); + if (configFiles.length > 0) { + workspace.fs.delete(configFiles[0]) + } + } + } +} + +function getTestConfigFile(connection: IBMi): ConfigFile { + const TestConfig = new ConfigFile(connection, `testing`, {strings: []}); + + TestConfig.hasServerFile = true; + TestConfig.mergeArrays = true; + TestConfig.validateAndCleanInPlace = (loadedConfig) => { + if (loadedConfig.strings) { + const hasNonString = loadedConfig.strings.some((str: unknown) => typeof str !== `string`); + if (hasNonString) { + throw new Error(`All strings must be strings.`); + } + } else { + throw new Error(`Strings array is required.`); + } + + return loadedConfig; + }; + + return TestConfig; +} + +export const ConfigFileSuite: TestSuite = { + name: `Config API tests`, + before: async () => { + const workspaceFolder = workspace.workspaceFolders ? workspace.workspaceFolders[0] : undefined; + assert.ok(workspaceFolder, "No workspace folder to work with"); + + const connection = instance.getConnection(); + + await connection?.sendCommand({command: `mkdir -p /etc/.vscode`}); + }, + + tests: [ + { + name: `Test no configs exist`, test: async () => { + const connection = instance.getConnection()!; + const testConfig = getTestConfigFile(connection); + const configs = testConfig.getPaths(); + + await Promise.all([deleteFile(connection, configs.workspace), deleteFile(connection, configs.server)]); + + assert.strictEqual(testConfig.getState().server, `not_loaded`) + await testConfig.loadFromServer(); + assert.strictEqual(testConfig.getState().server, `no_exist`) + + const baseValue = await testConfig.get(workspace.workspaceFolders![0]); + assert.deepStrictEqual(baseValue, {strings: []}); + }, + }, + { + name: `Test server config`, test: async () => { + const connection = instance.getConnection()!; + const testConfig = getTestConfigFile(connection); + const configs = testConfig.getPaths(); + + await Promise.all([deleteFile(connection, configs.workspace), deleteFile(connection, configs.server)]); + + const validContent = {strings: [`hello`, `world`]}; + + await connection.getContent().writeStreamfileRaw(configs.server, Buffer.from(JSON.stringify(validContent)), `utf8`); + + assert.strictEqual(testConfig.getState().server, `not_loaded`) + await testConfig.loadFromServer(); + assert.strictEqual(testConfig.getState().server, `ok`) + + const baseValue = await testConfig.get(workspace.workspaceFolders![0]); + assert.deepStrictEqual(baseValue, validContent); + }, + }, + { + name: `Test server config validation`, test: async () => { + const connection = instance.getConnection()!; + const testConfig = getTestConfigFile(connection); + const configs = testConfig.getPaths(); + + await Promise.all([deleteFile(connection, configs.workspace), deleteFile(connection, configs.server)]); + + const validContent = {strings: [`hello`, 5]}; + + await connection.getContent().writeStreamfileRaw(configs.server, Buffer.from(JSON.stringify(validContent)), `utf8`); + + assert.strictEqual(testConfig.getState().server, `not_loaded`) + await testConfig.loadFromServer(); + assert.strictEqual(testConfig.getState().server, `invalid`) + + const baseValue = await testConfig.get(); + assert.deepStrictEqual(baseValue, {strings: []}); + }, + }, + { + name: `Test workspace config`, test: async () => { + const connection = instance.getConnection()!; + const testConfig = getTestConfigFile(connection); + const configs = testConfig.getPaths(); + + await Promise.all([deleteFile(connection, configs.workspace), deleteFile(connection, configs.server)]); + + const ws = workspace.workspaceFolders![0]!; + const validContent = {strings: [`hello`, `mars`]}; + + const localFile = Uri.joinPath(ws.uri, configs.workspace); + await workspace.fs.writeFile(localFile, Buffer.from(JSON.stringify(validContent))); + + assert.strictEqual(testConfig.getState().server, `not_loaded`); + await testConfig.loadFromServer(); + assert.strictEqual(testConfig.getState().server, `no_exist`); + + const baseValue = await testConfig.get(ws); + assert.deepStrictEqual(baseValue, validContent); + }, + }, + { + name: `Test config merges`, test: async () => { + const connection = instance.getConnection()!; + const testConfig = getTestConfigFile(connection); + const configs = testConfig.getPaths(); + + await Promise.all([deleteFile(connection, configs.workspace), deleteFile(connection, configs.server)]); + + const ws = workspace.workspaceFolders![0]!; + const workspaceConfig = {strings: [`hello`, `mars`]}; + const serverConfig = {strings: [`hello`, `world`]}; + + const localFile = Uri.joinPath(ws.uri, configs.workspace); + await workspace.fs.writeFile(localFile, Buffer.from(JSON.stringify(workspaceConfig))); + + await connection.getContent().writeStreamfileRaw(configs.server, Buffer.from(JSON.stringify(serverConfig)), `utf8`); + + assert.strictEqual(testConfig.getState().server, `not_loaded`); + await testConfig.loadFromServer(); + assert.strictEqual(testConfig.getState().server, `ok`); + + const baseValue = await testConfig.get(ws); + assert.deepStrictEqual(baseValue, {strings: [...workspaceConfig.strings, ...serverConfig.strings]}); + + const secondRead = await testConfig.get(ws); + assert.deepStrictEqual(secondRead, {strings: [...workspaceConfig.strings, ...serverConfig.strings]}); + }, + }, + ] +}; diff --git a/src/testing/index.ts b/src/testing/index.ts index 6f5da3b1c..378ce9605 100644 --- a/src/testing/index.ts +++ b/src/testing/index.ts @@ -15,9 +15,11 @@ import { StorageSuite } from "./storage"; import { TestSuitesTreeProvider } from "./testCasesTree"; import { ToolsSuite } from "./tools"; import { Server } from "../typings"; +import { ConfigFileSuite } from "./configFile"; const suites: TestSuite[] = [ ActionSuite, + ConfigFileSuite, ConnectionSuite, ContentSuite, DebugSuite, From 4a4579bf15fdeb516c7edae8c7c5078f9094cf49 Mon Sep 17 00:00:00 2001 From: worksofliam Date: Tue, 14 Jan 2025 09:33:06 -0500 Subject: [PATCH 12/12] Additional test cases for merging Signed-off-by: worksofliam --- src/api/IBMiContent.ts | 10 +++++----- src/testing/configFile.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/src/api/IBMiContent.ts b/src/api/IBMiContent.ts index 7918f7af9..27c997e46 100644 --- a/src/api/IBMiContent.ts +++ b/src/api/IBMiContent.ts @@ -212,7 +212,7 @@ export default class IBMiContent { sourceFile = this.ibmi.upperCaseName(sourceFile); member = this.ibmi.upperCaseName(member); - const client = this.ibmi.client; + const client = this.ibmi.client!; const tmpobj = await tmpFile(); let retry = false; @@ -1094,19 +1094,19 @@ export default class IBMiContent { } async uploadFiles(files: { local: string | Uri, remote: string }[], options?: node_ssh.SSHPutFilesOptions) { - await this.ibmi.client.putFiles(files.map(f => { return { local: Tools.fileToPath(f.local), remote: f.remote } }), options); + await this.ibmi.client!.putFiles(files.map(f => { return { local: Tools.fileToPath(f.local), remote: f.remote } }), options); } async downloadFile(localFile: string | Uri, remoteFile: string) { - await this.ibmi.client.getFile(Tools.fileToPath(localFile), remoteFile); + await this.ibmi.client!.getFile(Tools.fileToPath(localFile), remoteFile); } async uploadDirectory(localDirectory: string | Uri, remoteDirectory: string, options?: node_ssh.SSHGetPutDirectoryOptions) { - await this.ibmi.client.putDirectory(Tools.fileToPath(localDirectory), remoteDirectory, options); + await this.ibmi.client!.putDirectory(Tools.fileToPath(localDirectory), remoteDirectory, options); } async downloadDirectory(localDirectory: string | Uri, remoteDirectory: string, options?: node_ssh.SSHGetPutDirectoryOptions) { - await this.ibmi.client.getDirectory(Tools.fileToPath(localDirectory), remoteDirectory, options); + await this.ibmi.client!.getDirectory(Tools.fileToPath(localDirectory), remoteDirectory, options); } } diff --git a/src/testing/configFile.ts b/src/testing/configFile.ts index a6bf299a9..38ac95e09 100644 --- a/src/testing/configFile.ts +++ b/src/testing/configFile.ts @@ -168,6 +168,20 @@ export const ConfigFileSuite: TestSuite = { const secondRead = await testConfig.get(ws); assert.deepStrictEqual(secondRead, {strings: [...workspaceConfig.strings, ...serverConfig.strings]}); + + testConfig.mergeArrays = false; + + // After merge arrays is disabled, the workspace config takes precedence over the server config + + const afterMergeA = await testConfig.get(ws); + assert.deepStrictEqual(afterMergeA, workspaceConfig); + + // But if we delete the workspace config, the server config will be used + + await workspace.fs.delete(localFile); + + const afterMergeB = await testConfig.get(ws); + assert.deepStrictEqual(afterMergeB, serverConfig); }, }, ]