From 8c8e2579edcb2026f5cf0fa8834e985432dafe68 Mon Sep 17 00:00:00 2001 From: Sanjula Ganepola Date: Mon, 19 Jan 2026 15:10:07 -0500 Subject: [PATCH 01/13] Add object filter description to profile editor Signed-off-by: Sanjula Ganepola --- src/editors/connectionProfileEditor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editors/connectionProfileEditor.ts b/src/editors/connectionProfileEditor.ts index 75f567162..a9d0904a6 100644 --- a/src/editors/connectionProfileEditor.ts +++ b/src/editors/connectionProfileEditor.ts @@ -31,7 +31,7 @@ export function editConnectionProfile(profile: ConnectionProfile, doAfterSave?: .addInput("setLibraryListCommand", l10n.t("Library List Command"), l10n.t("Library List Command can be used to set your library list based on the result of a command like CHGLIBL, or your own command that sets the library list.
Commands should be as explicit as possible.
When refering to commands and objects, both should be qualified with a library.
Put ? in front of the command to prompt it before execution."), { default: profile.setLibraryListCommand }) .addHorizontalRule() .addHeading(l10n.t("Object filters"), 3) - .addParagraph(objectFilters.length ? `` : l10n.t("None")) + .addParagraph(objectFilters.length ? `` : l10n.t("None")) .addHorizontalRule() .addHeading(l10n.t("IFS shortcuts"), 3) .addParagraph(ifsShortcuts.length ? `` : l10n.t("None")) From 5868d4fe6fba770267989323f198a10821536264 Mon Sep 17 00:00:00 2001 From: Sanjula Ganepola Date: Mon, 19 Jan 2026 15:23:34 -0500 Subject: [PATCH 02/13] Fix action JSON schema Signed-off-by: Sanjula Ganepola --- schemas/actions.json | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/schemas/actions.json b/schemas/actions.json index d1c3940da..a85125072 100644 --- a/schemas/actions.json +++ b/schemas/actions.json @@ -8,16 +8,15 @@ "$defs": { "code4iAction": { "type": "object", + "title": "Action", + "description": "A single Action.", + "required": [ + "name", + "command", + "environment", + "extensions" + ], "properties": { - "type": "object", - "title": "Action", - "description": "A single Action.", - "required": [ - "name", - "command", - "environment", - "extensions" - ], "name": { "type": "string", "title": "Name", @@ -29,6 +28,17 @@ "description": "The command that will be run when executing this Action.", "default": "" }, + "type": { + "type": "string", + "title": "File system type", + "description": "File system which supports this Action.", + "default": "ile", + "enum": [ + "file", + "member", + "streamfile" + ] + }, "environment": { "type": "string", "title": "Command Environment", From 81cbda4161422c36007ecb15b80c1e81034dd266 Mon Sep 17 00:00:00 2001 From: Sanjula Ganepola Date: Mon, 19 Jan 2026 16:08:03 -0500 Subject: [PATCH 03/13] Add profiles JSON schema Signed-off-by: Sanjula Ganepola --- package.json | 17 ++++-- schemas/actions.json | 2 +- schemas/profiles.json | 138 ++++++++++++++++++++++++++++++++++++++++++ schemas/settings.json | 2 + 4 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 schemas/profiles.json diff --git a/package.json b/package.json index 1763d21d7..cbb509009 100644 --- a/package.json +++ b/package.json @@ -47,10 +47,17 @@ "jsonValidation": [ { "fileMatch": [ - ".vscode/actions.json" + ".vscode/actions.json", + "/etc/vscode/actions.json" ], "url": "./schemas/actions.json" }, + { + "fileMatch": [ + "/etc/vscode/profiles.json" + ], + "url": "./schemas/profiles.json" + }, { "fileMatch": [ "/etc/vscode/settings.json" @@ -91,7 +98,7 @@ "properties": { "name": { "type": "string", - "description": "Connection name" + "description": "Filter name" }, "library": { "type": "string", @@ -129,7 +136,7 @@ } }, "default": [], - "description": "List of filters for Object List" + "description": "List of filters shown in the Object Browser" }, "libraryList": { "type": "array", @@ -154,7 +161,7 @@ "additionalProperties": true }, "default": [], - "description": "A collection of connection settings to easily switch between them on this system." + "description": "A collection of library lists, filters, and custom variables to easily switch between on this system." }, "ifsShortcuts": { "type": "array", @@ -163,7 +170,7 @@ "title": "Path to directory" }, "default": [], - "description": "List of directories shown in IFS Browser" + "description": "List of directories shown in the IFS Browser" }, "autoSortIFSShortcuts": { "type": "boolean", diff --git a/schemas/actions.json b/schemas/actions.json index a85125072..ebd450e6b 100644 --- a/schemas/actions.json +++ b/schemas/actions.json @@ -1,7 +1,7 @@ { "type": "array", "title": "Actions", - "description": "List of Actions that apply to this Workspace. Actions may be used to run commands on the remote system.", + "description": "List of Code for IBM i Actions which may be used to run commands on the remote system.", "items": { "$ref": "#/$defs/code4iAction" }, diff --git a/schemas/profiles.json b/schemas/profiles.json new file mode 100644 index 000000000..20502544a --- /dev/null +++ b/schemas/profiles.json @@ -0,0 +1,138 @@ +{ + "type": "array", + "title": "Profiles", + "description": "List of Code for IBM i Profiles which may be used to have collections of library lists, filters, and custom variables to easily switch between on this system", + "items": { + "$ref": "#/$defs/code4iProfile" + }, + "$defs": { + "code4iProfile": { + "type": "object", + "title": "Profile", + "description": "A single profile.", + "required": [ + "name", + "currentLibrary", + "libraryList", + "objectFilters", + "ifsShortcuts", + "customVariables" + ], + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "Profile name" + }, + "currentLibrary": { + "type": "string", + "title": "Current Library", + "description": "Library used as the current library and &CURLIB variable when running Actions.", + "default": "" + }, + "libraryList": { + "type": "array", + "title": "Library List", + "description": "Library list used when running Actions.", + "default": [], + "items": { + "type": "string", + "title": "Library" + } + }, + "objectFilters": { + "type": "array", + "title": "Object Filters", + "description": "List of filters shown in the Object Browser.", + "default": [], + "items": { + "type": "object", + "required": [ + "name", + "library", + "object", + "types", + "member", + "memberType" + ], + "properties": { + "name": { + "type": "string", + "description": "Filter name" + }, + "library": { + "type": "string", + "description": "Library filter", + "maxLength": 10 + }, + "object": { + "type": "string", + "description": "Object filter", + "maxLength": 10 + }, + "types": { + "type": "array", + "description": "Object types filter", + "items": { + "type": "string", + "description": "Object type. Usually starts with an asterisk." + }, + "default": [ + "*ALL" + ] + }, + "member": { + "type": "string", + "description": "Member filter", + "maxLength": 10, + "default": "*" + }, + "memberType": { + "type": "string", + "description": "Member type filter", + "maxLength": 10, + "default": "*" + } + } + } + }, + "ifsShortcuts": { + "type": "array", + "title": "IFS Shortcuts", + "description": "List of directories shown in the IFS Browser.", + "default": [], + "items": { + "type": "string", + "title": "Library" + } + }, + "customVariables": { + "type": "array", + "title": "Custom Variables", + "description": "Custom variables used when running Actions.", + "default": [], + "items": { + "type": "object", + "description": "Variable", + "properties": { + "name": { + "type": "string", + "description": "Variable name. Will be forced uppercase." + }, + "value": { + "type": "string", + "description": "Variable value" + } + } + } + }, + "setLibraryListCommand": { + "type": "string", + "title": "Set Library List Command", + "description": "Library List Command can be used to set your library list based on the result of a command like `CHGLIBL`, or your own command that sets the library list. Commands should be as explicit as possible. When refering to commands and objects, both should be qualified with a library. Put `?` in front of the command to prompt it before execution." + } + }, + "additionalProperties": true + } + } +} \ No newline at end of file diff --git a/schemas/settings.json b/schemas/settings.json index f349b6f51..7b50d96bc 100644 --- a/schemas/settings.json +++ b/schemas/settings.json @@ -117,6 +117,8 @@ "type": "object", "properties": { "codefori": { + "title": "Connection settings", + "description": "Set of Code for IBM i connection settings.", "$ref": "#/definitions/CodeForI" } } From f0422834153a4da5e5752e5f5b57f9f9e006e673 Mon Sep 17 00:00:00 2001 From: Sanjula Ganepola Date: Mon, 19 Jan 2026 16:09:05 -0500 Subject: [PATCH 04/13] Rename to configFile and add action/profile configs Signed-off-by: Sanjula Ganepola --- src/api/IBMi.ts | 14 +++++++++----- .../configuration/{serverFile.ts => configFile.ts} | 13 ++++--------- 2 files changed, 13 insertions(+), 14 deletions(-) rename src/api/configuration/{serverFile.ts => configFile.ts} (83%) diff --git a/src/api/IBMi.ts b/src/api/IBMi.ts index 052bde6da..98cdb7764 100644 --- a/src/api/IBMi.ts +++ b/src/api/IBMi.ts @@ -14,10 +14,10 @@ import { ComponentManager, ComponentSearchProps } from "./components/manager"; import * as configVars from './configVars'; import { DebugConfiguration } from "./configuration/DebugConfiguration"; import { ConnectionManager } from './configuration/config/ConnectionManager'; -import { ConnectionConfig, RemoteConfigFile } from './configuration/config/types'; -import { ConfigFile } from './configuration/serverFile'; +import { ConnectionConfig, ConnectionProfile, RemoteConfigFile } from './configuration/config/types'; +import { ConfigFile } from './configuration/configFile'; import { CachedServerSettings, CodeForIStorage } from './configuration/storage/CodeForIStorage'; -import { AspInfo, CommandData, CommandResult, ConnectionData, EditorPath, IBMiMember, RemoteCommand, WrapResult } from './types'; +import { Action, AspInfo, CommandData, CommandResult, ConnectionData, EditorPath, IBMiMember, RemoteCommand, WrapResult } from './types'; export interface MemberParts extends IBMiMember { basename: string @@ -76,6 +76,8 @@ interface ConnectionOptions { interface ConnectionConfigFiles { settings: ConfigFile; + profiles: ConfigFile + actions: ConfigFile [key: string]: ConfigFile; } @@ -97,7 +99,9 @@ export default class IBMi { private componentManager = new ComponentManager(this); private configFiles: ConnectionConfigFiles = { - settings: new ConfigFile(this, `settings`, {}) + settings: new ConfigFile(this, `settings`, {}), + profiles: new ConfigFile(this, `profiles`, []), + actions: new ConfigFile(this, `actions`, []) }; /** @@ -188,7 +192,7 @@ export default class IBMi { async loadRemoteConfigs() { for (const configFile in this.configFiles) { const currentConfig = this.configFiles[configFile as keyof ConnectionConfigFiles]; - + currentConfig.reset(); try { diff --git a/src/api/configuration/serverFile.ts b/src/api/configuration/configFile.ts similarity index 83% rename from src/api/configuration/serverFile.ts rename to src/api/configuration/configFile.ts index debafb15d..d94007556 100644 --- a/src/api/configuration/serverFile.ts +++ b/src/api/configuration/configFile.ts @@ -2,22 +2,17 @@ import path from "path"; 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`|`invalid`|`ok`; - -interface LoadResult { - server: ConfigResult; -} +type ConfigResult = `not_loaded` | `no_exist` | `failed_to_parse` | `invalid` | `ok`; export class ConfigFile { private state: ConfigResult = `not_loaded`; private basename: string; private serverFile: string; - private serverData: T|undefined; + private serverData: T | undefined; - public validateData: ((loadedConfig: any) => T)|undefined; + public validateData: ((loadedConfig: any) => T) | undefined; constructor(private connection: IBMi, configId: string, readonly fallback: T) { this.basename = configId + `.json`; @@ -31,7 +26,7 @@ export class ConfigFile { } async loadFromServer() { - let serverConfig: any|undefined; + let serverConfig: any | undefined; this.state = `no_exist`; From bbb7964f2a0937bfd3f4a3860dd151bb55f5f9eb Mon Sep 17 00:00:00 2001 From: Sanjula Ganepola Date: Sun, 8 Feb 2026 21:29:42 -0500 Subject: [PATCH 05/13] Add support for server profiles Signed-off-by: Sanjula Ganepola --- package.json | 6 +- src/api/IBMi.ts | 5 +- src/api/configuration/config/types.ts | 38 ++- src/api/configuration/configFile.ts | 44 ++- src/api/connectionProfiles.ts | 225 ++++++++++++--- src/editors/connectionProfileEditor.ts | 16 +- src/ui/views/environment/actions.ts | 6 +- .../views/environment/connectionProfiles.ts | 68 ++++- src/ui/views/environment/environmentView.ts | 259 +++++++++++++----- 9 files changed, 497 insertions(+), 170 deletions(-) diff --git a/package.json b/package.json index cbb509009..b72bed2a5 100644 --- a/package.json +++ b/package.json @@ -3153,8 +3153,8 @@ }, { "command": "code-for-ibmi.environment.profile.unload", - "when": "view === environmentView && viewItem =~ /^profilesNode/ && code-for-ibmi:activeProfile", - "group": "inline@03" + "when": "view === environmentView && viewItem =~ /^profileItem_active/", + "group": "inline@01" }, { "command": "code-for-ibmi.environment.profile.activate", @@ -3254,4 +3254,4 @@ "halcyontechltd.vscode-ibmi-walkthroughs", "vscode.git" ] -} +} \ No newline at end of file diff --git a/src/api/IBMi.ts b/src/api/IBMi.ts index 98cdb7764..cbb5ccbdd 100644 --- a/src/api/IBMi.ts +++ b/src/api/IBMi.ts @@ -189,8 +189,9 @@ export default class IBMi { return configFile; } - async loadRemoteConfigs() { - for (const configFile in this.configFiles) { + async loadRemoteConfigs(configKeys?: (keyof ConnectionConfigFiles)[]) { + configKeys = configKeys ?? Object.keys(this.configFiles); + for (const configFile of configKeys) { const currentConfig = this.configFiles[configFile as keyof ConnectionConfigFiles]; currentConfig.reset(); diff --git a/src/api/configuration/config/types.ts b/src/api/configuration/config/types.ts index 538403693..20c414ce8 100644 --- a/src/api/configuration/config/types.ts +++ b/src/api/configuration/config/types.ts @@ -26,14 +26,16 @@ export interface ConnectionConfig extends ConnectionProfile { debugSepPort: string; debugUpdateProductionFiles: boolean; debugEnableDebugTracing: boolean; - debugIgnoreCertificateErrors:boolean; + debugIgnoreCertificateErrors: boolean; readOnlyMode: boolean; quickConnect: boolean; defaultDeploymentMethod: DeploymentMethod | ''; protectedPaths: string[]; showHiddenFiles: boolean; lastDownloadLocation: string; - currentProfile?: string + currentProfile?: string; + currentProfileType?: ProfileType; + currentProfileLastKnownUpdate?: number; [name: string]: any; } @@ -57,15 +59,31 @@ export interface CustomVariable { value: string } +export type AnyConnectionProfile = | LocalConnectionProfile | ServerConnectionProfile + +export type ProfileType = 'local' | 'server'; + +export type ProfileState = 'In-sync' | 'Modified' | 'Outdated' | 'Conflict'; + +export interface LocalConnectionProfile extends ConnectionProfile { + type: 'local'; +} + +export interface ServerConnectionProfile extends ConnectionProfile { + type: 'server'; + state: ProfileState; +} + export interface ConnectionProfile { - name: string - homeDirectory: string - currentLibrary: string - libraryList: string[] - objectFilters: ObjectFilters[] - ifsShortcuts: string[] - customVariables: CustomVariable[] - setLibraryListCommand?: string + name: string; + homeDirectory?: string; + currentLibrary: string; + libraryList: string[]; + objectFilters: ObjectFilters[]; + ifsShortcuts: string[]; + customVariables: CustomVariable[]; + setLibraryListCommand?: string; + lastUpdated?: number; } export interface StoredConnection { diff --git a/src/api/configuration/configFile.ts b/src/api/configuration/configFile.ts index d94007556..a5526777b 100644 --- a/src/api/configuration/configFile.ts +++ b/src/api/configuration/configFile.ts @@ -12,11 +12,13 @@ export class ConfigFile { private serverFile: string; private serverData: T | undefined; - public validateData: ((loadedConfig: any) => T) | undefined; + // Should throw an error if loaded config is invalid + private validateData: ((loadedConfig: T) => T) | undefined; - constructor(private connection: IBMi, configId: string, readonly fallback: T) { + constructor(private connection: IBMi, configId: string, readonly fallback: T, validateData?: ((loadedConfig: T) => T)) { this.basename = configId + `.json`; this.serverFile = path.posix.join(SERVER_ROOT, this.basename); + this.validateData = validateData; } getPaths() { @@ -26,31 +28,43 @@ export class ConfigFile { } async loadFromServer() { - let serverConfig: any | undefined; - this.state = `no_exist`; const isAvailable = await this.connection.getContent().testStreamFile(this.serverFile, `r`); if (isAvailable) { const content = await this.connection.getContent().downloadStreamfileRaw(this.serverFile); try { - serverConfig = JSON.parse(content.toString()); + const serverConfig: T = JSON.parse(content.toString()); this.state = `ok`; + + if (this.validateData) { + try { + this.serverData = this.validateData(serverConfig); + } catch (e) { + this.state = `invalid`; + this.serverData = undefined; + } + } else { + this.serverData = serverConfig; + } } catch (e: any) { this.state = `failed_to_parse`; } + } + } - if (this.validateData) { - // Should throw an error. - try { - this.serverData = this.validateData(serverConfig); - } catch (e) { - this.state = `invalid`; - this.serverData = undefined; - } - } else { - this.serverData = serverConfig; + async writeToServer(newConfig: T): Promise { + try { + const content = this.connection.getContent(); + if (this.state === `no_exist`) { + await content.createStreamFile(this.serverFile); } + + content.writeStreamfileRaw(this.serverFile, JSON.stringify(newConfig, null, 4)); + await this.loadFromServer(); + return true; + } catch (e: any) { + return false; } } diff --git a/src/api/connectionProfiles.ts b/src/api/connectionProfiles.ts index 32cd15f4a..a82349603 100644 --- a/src/api/connectionProfiles.ts +++ b/src/api/connectionProfiles.ts @@ -1,76 +1,213 @@ import { l10n } from "vscode"; import { instance } from "../instantiate"; import IBMi from "./IBMi"; -import { ConnectionProfile } from "./types"; +import { AnyConnectionProfile, LocalConnectionProfile, ServerConnectionProfile, ConnectionConfig, ConnectionProfile, ProfileType } from "./configuration/config/types"; -export async function updateConnectionProfile(profile: ConnectionProfile, options?: { newName?: string, delete?: boolean }) { - const config = instance.getConnection()?.getConfig(); - if (config) { - const profiles = config.connectionProfiles; - const index = profiles.findIndex(p => p.name === profile.name); +export async function updateConnectionProfile(profile: AnyConnectionProfile, options?: { newName?: string, delete?: boolean, modifiedConfig?: ConnectionConfig, }) { + const connection = instance.getConnection(); + if (connection) { + const config = options?.modifiedConfig || connection.getConfig(); + const isServerProfile = profile.type === 'server'; + + const { localProfiles, serverProfiles } = await getConnectionProfilesInGroups(); + const profiles = isServerProfile ? serverProfiles : localProfiles; + let now: number | undefined; + let oldName: string | undefined + const index = profiles.findIndex(p => p.name === profile.name); if (options?.delete) { if (index < 0) { throw new Error(l10n.t("Profile {0} not found for deletion.", profile.name)); } profiles.splice(index, 1); - } - else { - profile.name = options?.newName || profile.name; + } else { + if (options?.newName) { + oldName = profile.name; + profile.name = options?.newName + } else { + profile.name = profile.name; + } + if (isServerProfile) { + now = Date.now(); + profile.lastUpdated = now; + } profiles[index < 0 ? profiles.length : index] = profile; } - if (isActiveProfile(profile)) { - //Only update the setLibraryListCommand in the current config since the editor is the only place it can be changed + if (isActiveProfile(profile, oldName)) { + // Only update the setLibraryListCommand in the current config since the editor is the only place it can be changed config.setLibraryListCommand = profile.setLibraryListCommand; + + if (options?.newName) { + config.currentProfile = profile.name; + } + + if (now) { + config.currentProfileLastKnownUpdate = now; + } } - await IBMi.connectionManager.update(config); + if (isServerProfile) { + // Map internal server profile type to connection profile + const serverProfiles: ConnectionProfile[] = (profiles as ServerConnectionProfile[]).map(({ type, state, homeDirectory, ...profile }) => profile); + + const profilesConfigFile = connection.getConfigFile(`profiles`); + await profilesConfigFile.writeToServer(serverProfiles); + await IBMi.connectionManager.update(config); + } else { + // Map internal local profile type to connection profile + const localProfiles: ConnectionProfile[] = (profiles as LocalConnectionProfile[]).map(({ type, ...profile }) => profile); + + config.connectionProfiles = localProfiles; + await IBMi.connectionManager.update(config); + } } } /** - * @returns ann arry of {@link ConnectionProfile} stored in the config; except the default profile (with a blank name), only used internally + * @returns an arry of local (stored in the config) and system (stored in /etc/vscode/profiles.json) {@link ConnectionProfile}; + * except the default profile (with a blank name), only used internally */ -export function getConnectionProfiles() { - const config = instance.getConnection()?.getConfig(); - if (config) { - return config.connectionProfiles.filter(profile => Boolean(profile.name)); - } - else { - throw new Error(l10n.t("Not connected to an IBM i")); - } +export async function getAllConnectionProfiles() { + const { localProfiles, serverProfiles } = await getConnectionProfilesInGroups(); + return [...localProfiles, ...serverProfiles]; } -export function getConnectionProfile(profileName: string) { - return getConnectionProfiles().filter(p => p.name === profileName).at(0); -} +export async function getConnectionProfilesInGroups() { + const connection = instance.getConnection(); + if (connection) { + const config = connection.getConfig(); + const rawLocalProfiles: ConnectionProfile[] = config.connectionProfiles.filter(profile => Boolean(profile.name)); + + // Map connection profiles to internal local profile type + const localProfiles: LocalConnectionProfile[] = rawLocalProfiles.map(rawlocalProfile => ({ + ...rawlocalProfile, + type: 'local' as const + })); + + // Get server profiles + const profilesConfigFile = connection.getConfigFile(`profiles`); + const rawServerProfiles: ConnectionProfile[] = await profilesConfigFile.get(); -export function getDefaultProfile() { - const config = instance.getConnection()?.getConfig(); - if (config) { - let defaultProfile = config.connectionProfiles.filter(profile => !profile.name).at(0); - if (!defaultProfile) { - defaultProfile = { - name: '', - homeDirectory: '', - ifsShortcuts: [], - currentLibrary: '', - objectFilters: [], - customVariables: [], - libraryList: [] + // Get current profile + const currentProfileName = config.currentProfile; + const currentProfileType = config.currentProfileType; + + // Map connection profiles to internal server profile type + const serverProfiles: ServerConnectionProfile[] = rawServerProfiles.map(rawServerProfile => { + const profileLastUpdated = rawServerProfile.lastUpdated || 0; + + let state: ServerConnectionProfile['state']; + if (currentProfileType === 'server' && currentProfileName === rawServerProfile.name) { + // Current server profile, so compare the local version against the server version + const localVersionOfServerProfile: ConnectionProfile = { + name: currentProfileName, + currentLibrary: config.currentLibrary, + libraryList: config.libraryList, + objectFilters: config.objectFilters, + ifsShortcuts: config.ifsShortcuts, + customVariables: config.customVariables, + setLibraryListCommand: config.setLibraryListCommand + }; + const isInSync = isProfileInSync(rawServerProfile, localVersionOfServerProfile); + const lastKnownUpdate = config.currentProfileLastKnownUpdate || 0; + const isOutdated = profileLastUpdated > lastKnownUpdate; + + if (isOutdated && !isInSync) { + state = 'Conflict'; + } else if (isOutdated && isInSync) { + state = 'Outdated'; + } else if (!isOutdated && !isInSync) { + state = 'Modified'; + } else { + state = 'In-sync'; + } + } else { + // Not current server profile, so it's in sync + state = 'In-sync'; + } + + return { + ...rawServerProfile, + type: 'server' as const, + state, + lastUpdated: profileLastUpdated }; + }); - config.connectionProfiles.push(defaultProfile); + // Check if current profile is a server profile that no longer exists in the remote file + if (currentProfileName && currentProfileType === 'server') { + const profileExistsOnServer = serverProfiles.some(p => p.name === currentProfileName); + if (!profileExistsOnServer) { + // Add the missing profile with Conflict state + const localVersionOfServerProfile: ServerConnectionProfile = { + name: currentProfileName, + type: 'server' as const, + currentLibrary: config.currentLibrary, + libraryList: config.libraryList, + objectFilters: config.objectFilters, + ifsShortcuts: config.ifsShortcuts, + customVariables: config.customVariables, + setLibraryListCommand: config.setLibraryListCommand, + state: 'Conflict', + lastUpdated: 0 + }; + + serverProfiles.push(localVersionOfServerProfile); + } } - return defaultProfile; - } - else { + return { + localProfiles, + serverProfiles + }; + } else { throw new Error(l10n.t("Not connected to an IBM i")); } } +export function isProfileInSync(profile1: ConnectionProfile, profile2: ConnectionProfile): boolean { + return ( + profile1.currentLibrary === profile2.currentLibrary && + JSON.stringify(profile1.libraryList) === JSON.stringify(profile2.libraryList) && + JSON.stringify(profile1.objectFilters) === JSON.stringify(profile2.objectFilters) && + JSON.stringify(profile1.ifsShortcuts) === JSON.stringify(profile2.ifsShortcuts) && + JSON.stringify(profile1.customVariables) === JSON.stringify(profile2.customVariables) && + profile1.setLibraryListCommand === profile2.setLibraryListCommand + ); +} + +export async function getConnectionProfile(profileName: string, type: ProfileType) { + const { localProfiles, serverProfiles } = await getConnectionProfilesInGroups(); + if (type === 'local') { + return localProfiles.find(p => p.name === profileName); + } else { + return serverProfiles.find(p => p.name === profileName); + } +} + +export function getDefaultProfile(config: ConnectionConfig): LocalConnectionProfile { + let defaultProfile = config.connectionProfiles.filter(profile => !profile.name).at(0); + if (!defaultProfile) { + defaultProfile = { + name: '', + homeDirectory: '', + ifsShortcuts: [], + currentLibrary: '', + objectFilters: [], + customVariables: [], + libraryList: [] + }; + + config.connectionProfiles.push(defaultProfile); + } + + return { + ...defaultProfile, + type: 'local' + }; +} + export function assignProfile(fromProfile: ConnectionProfile, toProfile: ConnectionProfile) { toProfile.homeDirectory = fromProfile.homeDirectory; toProfile.currentLibrary = fromProfile.currentLibrary; @@ -86,6 +223,8 @@ export function cloneProfile(fromProfile: ConnectionProfile, newName: string): C return assignProfile(fromProfile, { name: newName } as ConnectionProfile); } -export function isActiveProfile(profile: ConnectionProfile) { - return instance.getConnection()?.getConfig().currentProfile === profile.name; +export function isActiveProfile(profile: AnyConnectionProfile, oldName?: string) { + const connection = instance.getConnection(); + const config = connection?.getConfig(); + return config?.currentProfile === (oldName || profile.name) && config?.currentProfileType === profile.type; } \ No newline at end of file diff --git a/src/editors/connectionProfileEditor.ts b/src/editors/connectionProfileEditor.ts index a9d0904a6..37b12a9b6 100644 --- a/src/editors/connectionProfileEditor.ts +++ b/src/editors/connectionProfileEditor.ts @@ -1,7 +1,7 @@ import vscode, { l10n } from "vscode"; import { isActiveProfile, updateConnectionProfile } from "../api/connectionProfiles"; import { instance } from "../instantiate"; -import { ConnectionProfile } from "../typings"; +import { AnyConnectionProfile } from "../typings"; import { CustomEditor } from "./customEditorProvider"; type ConnectionProfileData = { @@ -11,20 +11,20 @@ type ConnectionProfileData = { setLibraryListCommand: string } -const editedProfiles: Set = new Set; +const editedProfiles: Set<{ name: string, type: string }> = new Set; -export function isProfileEdited(profile: ConnectionProfile) { - return editedProfiles.has(profile.name); +export function isProfileEdited(profile: AnyConnectionProfile) { + return editedProfiles.has({ name: profile.name, type: profile.type }); } -export function editConnectionProfile(profile: ConnectionProfile, doAfterSave?: () => Thenable) { +export function editConnectionProfile(profile: AnyConnectionProfile, doAfterSave?: () => Thenable) { const activeProfile = isActiveProfile(profile); const config = instance.getConnection()?.getConfig(); const objectFilters = (activeProfile && config ? config : profile).objectFilters; const ifsShortcuts = (activeProfile && config ? config : profile).ifsShortcuts; const customVariables = (activeProfile && config ? config : profile).customVariables; - new CustomEditor(`${profile.name}.profile`, data => save(profile, data).then(doAfterSave), () => editedProfiles.delete(profile.name)) + new CustomEditor(`${profile.name}.profile`, data => save(profile, data).then(doAfterSave), () => editedProfiles.delete({ name: profile.name, type: profile.type })) .addInput("homeDirectory", l10n.t("Home Directory"), '', { minlength: 1, default: profile.homeDirectory, readonly: activeProfile }) .addInput("currentLibrary", l10n.t("Current Library"), '', { minlength: 1, maxlength: 10, default: profile.currentLibrary, readonly: activeProfile }) .addInput("libraryList", l10n.t("Library List"), l10n.t("A comma-separated list of libraries."), { default: profile.libraryList.join(","), readonly: activeProfile }) @@ -40,10 +40,10 @@ export function editConnectionProfile(profile: ConnectionProfile, doAfterSave?: .addParagraph(customVariables.length ? `
    ${customVariables.map(variable => `
  • &${variable.name}: ${variable.value}
  • `).join('')}
` : l10n.t("None")) .open(); - editedProfiles.add(profile.name); + editedProfiles.add({ name: profile.name, type: profile.type }); } -async function save(profile: ConnectionProfile, data: ConnectionProfileData) { +async function save(profile: AnyConnectionProfile, data: ConnectionProfileData) { const content = instance.getConnection()?.getContent(); if (content) { profile.homeDirectory = data.homeDirectory.trim(); diff --git a/src/ui/views/environment/actions.ts b/src/ui/views/environment/actions.ts index c487e7a8a..1144bff18 100644 --- a/src/ui/views/environment/actions.ts +++ b/src/ui/views/environment/actions.ts @@ -139,7 +139,7 @@ export class ActionItem extends EnvironmentItem { static matchedColor = "charts.yellow"; static canRunColor = "charts.blue"; static matchedCanRunColor = "charts.green"; - static context = `actionItem`; + static contextValue = `actionItem`; private context: ActionContext = {} @@ -165,11 +165,11 @@ export class ActionItem extends EnvironmentItem { this.description = this.context.matched ? l10n.t("search match") : undefined; this.tooltip = this.action.command; this.resourceUri = vscode.Uri.from({ - scheme: ActionItem.context, + scheme: ActionItem.contextValue, authority: this.action.name, query: stringify({ matched: this.context.matched || undefined, canRun: this.context.canRun || undefined }) }); - this.contextValue = `${ActionItem.context}${this.context.canRun ? "_canrun" : ""}${this.context.matched ? '_matched' : ''}`; + this.contextValue = `${ActionItem.contextValue}${this.context.canRun ? "_canrun" : ""}${this.context.matched ? '_matched' : ''}`; } } diff --git a/src/ui/views/environment/connectionProfiles.ts b/src/ui/views/environment/connectionProfiles.ts index 66c4bfa9b..263cb4e98 100644 --- a/src/ui/views/environment/connectionProfiles.ts +++ b/src/ui/views/environment/connectionProfiles.ts @@ -1,7 +1,8 @@ -import vscode, { l10n } from "vscode"; -import { getConnectionProfiles } from "../../../api/connectionProfiles"; +import vscode, { FileDecoration, l10n, window } from "vscode"; +import { stringify } from "querystring"; +import { getConnectionProfilesInGroups } from "../../../api/connectionProfiles"; import { instance } from "../../../instantiate"; -import { ConnectionProfile } from "../../../typings"; +import { AnyConnectionProfile, ProfileState, ProfileType } from "../../../api/configuration/config/types"; import { VscodeTools } from "../../Tools"; import { EnvironmentItem } from "./environmentItem"; @@ -22,25 +23,47 @@ export class ProfilesNode extends EnvironmentItem { this.contextValue = "profilesNode"; } - getChildren() { - const currentProfile = instance.getConnection()?.getConfig().currentProfile; - return getConnectionProfiles() + async getChildren() { + const connection = instance.getConnection(); + const config = connection?.getConfig(); + const currentProfile = config?.currentProfile; + const currentProfileType = config?.currentProfileType ?? `local`; + const { localProfiles, serverProfiles } = await getConnectionProfilesInGroups(); + const localProfileItems = localProfiles .sort((p1, p2) => p1.name.localeCompare(p2.name)) - .map(profile => new ProfileItem(this, profile, profile.name === currentProfile)); + .map(profile => new ProfileItem(this, profile, profile.name === currentProfile && profile.type === currentProfileType)); + const serverProfileItems = serverProfiles + .sort((p1, p2) => p1.name.localeCompare(p2.name)) + .map(profile => new ProfileItem(this, profile, profile.name === currentProfile && profile.type === currentProfileType)); + return [...localProfileItems, ...serverProfileItems]; } } export class ProfileItem extends EnvironmentItem { - static contextValue = `profileItem`; static activeColor = "charts.green"; + static modifiedColor = "charts.blue"; + static outdatedColor = "charts.yellow"; + static conflictColor = "charts.red"; + static contextValue = `profileItem`; - constructor(parent: EnvironmentItem, readonly profile: ConnectionProfile, active: boolean) { - super(profile.name, { parent, icon: "person", color: active ? ProfileItem.activeColor : undefined }); + constructor(parent: EnvironmentItem, readonly profile: AnyConnectionProfile, active: boolean) { + const state = profile.type === 'server' ? profile.state : undefined; + const icon = profile.type === 'server' ? `vm` : `person`; + const color = ProfileItem.getColor(active, profile.type, state); + super(profile.name, { parent, icon: icon, color }); - this.contextValue = `${ProfileItem.contextValue}${active ? '_active' : ''}${profile.setLibraryListCommand ? '_command' : ''}`; + this.contextValue = `${ProfileItem.contextValue}${active ? '_active' : ''}${profile.setLibraryListCommand ? '_command' : ''}${profile.type === 'server' ? `_${profile.state}` : ''}`; this.description = active ? l10n.t(`Active profile`) : ``; - this.resourceUri = vscode.Uri.from({ scheme: this.contextValue, authority: profile.name, query: active ? "active" : "" }); - this.tooltip = VscodeTools.profileToToolTip(profile) + if (active && profile.type === 'server') { + this.description = this.description ? `${this.description} (${profile.state})` : `(${profile.state})`; + } + + this.resourceUri = vscode.Uri.from({ + scheme: ProfileItem.contextValue, + authority: profile.name, + query: stringify({ active: active || undefined, type: profile.type, state: state }) + }); + this.tooltip = VscodeTools.profileToToolTip(profile); this.command = { title: "Edit connection profile", @@ -48,4 +71,23 @@ export class ProfileItem extends EnvironmentItem { arguments: [this.profile] } } + + static getColor(active: boolean, type: ProfileType, state?: ProfileState): string | undefined { + if (active) { + if (type === `server` && state) { + switch (state) { + case 'In-sync': + return ProfileItem.activeColor; + case 'Modified': + return ProfileItem.modifiedColor; + case 'Outdated': + return ProfileItem.outdatedColor; + case 'Conflict': + return ProfileItem.conflictColor; + } + } + + return ProfileItem.activeColor; + } + } } \ No newline at end of file diff --git a/src/ui/views/environment/environmentView.ts b/src/ui/views/environment/environmentView.ts index f618070fb..46b5727fe 100644 --- a/src/ui/views/environment/environmentView.ts +++ b/src/ui/views/environment/environmentView.ts @@ -1,18 +1,20 @@ import { parse as parseQuery } from "querystring"; -import vscode, { l10n, QuickPickItem } from 'vscode'; +import vscode, { l10n, QuickPickItem, ThemeIcon } from 'vscode'; import { getActions, updateAction } from '../../../api/actions'; import { GetNewLibl } from '../../../api/components/getNewLibl'; -import { assignProfile, cloneProfile, getConnectionProfile, getConnectionProfiles, getDefaultProfile, updateConnectionProfile } from '../../../api/connectionProfiles'; +import { assignProfile, cloneProfile, getConnectionProfile, getAllConnectionProfiles, getDefaultProfile, updateConnectionProfile, isProfileInSync } from '../../../api/connectionProfiles'; import IBMi from '../../../api/IBMi'; import { editAction, isActionEdited } from '../../../editors/actionEditor'; import { editConnectionProfile, isProfileEdited } from '../../../editors/connectionProfileEditor'; import { instance } from '../../../instantiate'; -import { Action, ActionEnvironment, BrowserItem, ConnectionProfile, CustomVariable, FocusOptions } from '../../../typings'; +import { Action, ActionEnvironment, AnyConnectionProfile, BrowserItem, ConnectionConfig, ConnectionProfile, CustomVariable, FocusOptions, ProfileState, ProfileType } from '../../../typings'; import { uriToActionTarget } from '../../actions'; import { ActionItem, Actions, ActionsNode, ActionTypeNode } from './actions'; import { ConnectionProfiles, ProfileItem, ProfilesNode } from './connectionProfiles'; import { CustomVariableItem, CustomVariables, CustomVariablesNode } from './customVariables'; +import * as path from 'path'; +import { onCodeForIBMiConfigurationChange } from "../../../config/Configuration"; export function initializeEnvironmentView(context: vscode.ExtensionContext) { const environmentView = new EnvironmentView(); @@ -23,7 +25,6 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { }); const updateUIContext = async (profileName?: string) => { - await vscode.commands.executeCommand(`setContext`, "code-for-ibmi:activeProfile", profileName); environmentTreeViewer.description = profileName ? l10n.t("Current profile: {0}", profileName) : l10n.t("No active profile"); vscode.commands.executeCommand("code-for-ibmi.updateConnectedBar"); }; @@ -39,10 +40,14 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { vscode.window.onDidChangeActiveTextEditor(async editor => environmentView.actionsNode?.activeEditorChanged(editor)), vscode.window.registerFileDecorationProvider({ provideFileDecoration(uri: vscode.Uri, token: vscode.CancellationToken): vscode.ProviderResult { - if (uri.scheme.startsWith(ProfileItem.contextValue) && uri.query === "active") { - return { color: new vscode.ThemeColor(ProfileItem.activeColor) }; - } - else if (uri.scheme === ActionItem.context) { + if (uri.scheme.startsWith(ProfileItem.contextValue)) { + const query = parseQuery(uri.query); + const active = query.active ? true : false; + const color = ProfileItem.getColor(active, query.type as ProfileType, query.state as ProfileState); + if (color) { + return { color: new vscode.ThemeColor(color) }; + } + } else if (uri.scheme === ActionItem.contextValue) { const query = parseQuery(uri.query); if (query.matched && query.canRun) { return { color: new vscode.ThemeColor(ActionItem.matchedCanRunColor) }; @@ -57,7 +62,17 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { } }), - vscode.commands.registerCommand("code-for-ibmi.environment.refresh", () => environmentView.refresh()), + vscode.commands.registerCommand("code-for-ibmi.environment.refresh", async () => { + const connection = instance.getConnection(); + if (connection) { + const config = connection.getConfig(); + if (config.currentProfile && config.currentProfileType === `server`) { + await connection.loadRemoteConfigs([`profiles`]); + } + } + + environmentView.refresh(); + }), vscode.commands.registerCommand("code-for-ibmi.environment.refresh.item", (item: BrowserItem) => environmentView.refresh(item)), vscode.commands.registerCommand("code-for-ibmi.environment.reveal", (item: BrowserItem, options?: FocusOptions) => environmentTreeViewer.reveal(item, options)), @@ -166,7 +181,6 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { const variable = { name, value: from?.value } as CustomVariable; if (from) { await CustomVariables.update(variable); - environmentView.refresh(variablesNode); } else { vscode.commands.executeCommand("code-for-ibmi.environment.variable.edit", variable, variablesNode); } @@ -177,7 +191,6 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { if (value !== undefined) { variable.value = value; await CustomVariables.update(variable); - environmentView.refresh(variablesNode); } }), vscode.commands.registerCommand("code-for-ibmi.environment.variable.rename", async (variableItem: CustomVariableItem) => { @@ -191,7 +204,6 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { if (newName) { await CustomVariables.update(variable, { newName }); - environmentView.refresh(variableItem.parent); } }), vscode.commands.registerCommand("code-for-ibmi.environment.variable.copy", async (variableItem: CustomVariableItem) => { @@ -201,12 +213,11 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { const variable = variableItem.customVariable; if (await vscode.window.showInformationMessage(l10n.t("Do you really want to delete Custom Variable '{0}' ?", variable.name), { modal: true }, l10n.t("Yes"))) { await CustomVariables.update(variable, { delete: true }); - environmentView.refresh(variableItem.parent); } }), vscode.commands.registerCommand("code-for-ibmi.environment.profile.create", async (node?: ProfilesNode, from?: ConnectionProfile) => { - const existingNames = getConnectionProfiles().map(profile => profile.name); + const existingNames = (await getAllConnectionProfiles()).map(profile => profile.name); const name = await vscode.window.showInputBox({ title: l10n.t("Enter new profile name"), @@ -214,12 +225,55 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { value: from?.name, validateInput: name => ConnectionProfiles.validateName(name, existingNames) }); + if (!name) { + return; + } - if (name) { - const connection = instance.getConnection(); - const homeDirectory = connection?.getConfig().homeDirectory || `/home/${connection?.currentUser || 'QPGMR'}`; //QPGMR case should not happen, but better be safe here - const profile: ConnectionProfile = from ? cloneProfile(from, name) : { + const connection = instance.getConnection(); + const localConfigFilePath = process.env.APPDATA ? path.join(process.env.APPDATA, 'Code', 'User', 'settings.json') : undefined; + const profilesConfigFile = connection?.getConfigFile(`profiles`); + const profilesConfigFilePath = profilesConfigFile?.getPaths().server; + const locationItems = [ + { label: `Local`, description: `Stored on this PC (for your use only)`, detail: localConfigFilePath, iconPath: new ThemeIcon(`person`) }, + { label: `Server`, description: `Stored on IBM i (to be used amongst your team)`, detail: profilesConfigFilePath, iconPath: new ThemeIcon(`vm`) } + ]; + const type = await vscode.window.showQuickPick(locationItems, { + title: `Select what type of profile this is`, + placeHolder: `Profile type` + }); + if (!type) { + return; + } + const isServerProfile = type.label === `Local` ? false : true; + + const homeDirectory = connection?.getConfig().homeDirectory || `/home/${connection?.currentUser || 'QPGMR'}`; //QPGMR case should not happen, but better be safe here + let profile: AnyConnectionProfile; + if (from) { + // Copy existing profile + const clone = cloneProfile(from, name); + profile = isServerProfile ? { + ...clone, + type: `server`, + state: `In-sync`, + } : { + ...clone, + type: `local`, + } + } else { + // Create new profile + profile = isServerProfile ? { name, + type: `server`, + state: `In-sync`, + homeDirectory, + currentLibrary: 'QGPL', + libraryList: ["QGPL", "QTEMP"], + customVariables: [], + ifsShortcuts: [homeDirectory], + objectFilters: [], + } : { + name, + type: `local`, homeDirectory, currentLibrary: 'QGPL', libraryList: ["QGPL", "QTEMP"], @@ -227,19 +281,18 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { ifsShortcuts: [homeDirectory], objectFilters: [], }; - await updateConnectionProfile(profile); - environmentView.refresh(environmentView.profilesNode); - if (!from) { - vscode.commands.executeCommand("code-for-ibmi.environment.profile.edit", profile); - } - else { - vscode.window.showInformationMessage(l10n.t("Created connection Profile '{0}'.", profile.name), l10n.t("Activate profile {0}", profile.name)) - .then(doSwitch => { - if (doSwitch) { - vscode.commands.executeCommand("code-for-ibmi.environment.profile.activate", profile); - } - }) - } + } + + await updateConnectionProfile(profile); + if (!from) { + vscode.commands.executeCommand("code-for-ibmi.environment.profile.edit", profile); + } else { + vscode.window.showInformationMessage(l10n.t("Created {0} connection profile '{1}'.", isServerProfile ? l10n.t("server") : "local", profile.name), l10n.t("Activate profile {1}", profile.name)) + .then(doSwitch => { + if (doSwitch) { + vscode.commands.executeCommand("code-for-ibmi.environment.profile.activate", profile); + } + }) } }), vscode.commands.registerCommand("code-for-ibmi.environment.profile.fromCurrent", async (profilesNode: ProfilesNode) => { @@ -250,18 +303,19 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { vscode.commands.executeCommand("code-for-ibmi.environment.profile.create", undefined, current); } }), - vscode.commands.registerCommand("code-for-ibmi.environment.profile.edit", async (profile: ConnectionProfile) => { - editConnectionProfile(profile, async () => environmentView.refresh(environmentView.profilesNode)) + vscode.commands.registerCommand("code-for-ibmi.environment.profile.edit", async (profile: AnyConnectionProfile) => { + editConnectionProfile(profile) }), vscode.commands.registerCommand("code-for-ibmi.environment.profile.rename", async (item: ProfileItem) => { if (isProfileEdited(item.profile)) { - vscode.window.showWarningMessage(l10n.t("Profile {0} is being edited. Please close its editor first.", item.profile.name)); + vscode.window.showWarningMessage(l10n.t("{0} profile {1} is being edited. Please close its editor first.", item.profile.type === `server` ? l10n.t("Server") : "Local", item.profile.name)); } else { const currentName = item.profile.name; - const existingNames = getConnectionProfiles().map(profile => profile.name).filter(name => name !== currentName); + const currentType = item.profile.type; + const existingNames = (await getAllConnectionProfiles()).map(profile => profile.name).filter(name => name !== currentName); const newName = await vscode.window.showInputBox({ - title: l10n.t('Enter Profile {0} new name', item.profile.name), + title: l10n.t('Enter {0} profile {1} new name', item.profile.type === `server` ? l10n.t("server") : "local", item.profile.name), placeHolder: l10n.t("Profile name..."), validateInput: name => ConnectionProfiles.validateName(name, existingNames) }); @@ -269,12 +323,9 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { if (newName) { await updateConnectionProfile(item.profile, { newName }); const config = instance.getConnection()?.getConfig(); - if (config?.currentProfile === currentName) { - config.currentProfile = newName; - await IBMi.connectionManager.update(config); + if (config?.currentProfile === currentName && config?.currentProfileType === currentType) { updateUIContext(newName); } - environmentView.refresh(environmentView.profilesNode); } } }), @@ -283,59 +334,68 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { }), vscode.commands.registerCommand("code-for-ibmi.environment.profile.delete", async (item: ProfileItem) => { if (isProfileEdited(item.profile)) { - vscode.window.showWarningMessage(l10n.t("Profile {0} is being edited. Please close its editor first.", item.profile.name)); + vscode.window.showWarningMessage(l10n.t("{0} profile {1} is being edited. Please close its editor first.", item.profile.type === `server` ? l10n.t("Server") : "Local", item.profile.name)); } - else if (await vscode.window.showInformationMessage(l10n.t("Do you really want to delete profile '{0}' ?", item.profile.name), { modal: true }, l10n.t("Yes"))) { + else if (await vscode.window.showInformationMessage(l10n.t("Do you really want to delete {0} profile '{1}' ?", item.profile.type === `server` ? l10n.t("server") : l10n.t("local"), item.profile.name), { modal: true }, l10n.t("Yes"))) { await updateConnectionProfile(item.profile, { delete: true }); - environmentView.refresh(environmentView.profilesNode); } }), - vscode.commands.registerCommand("code-for-ibmi.environment.profile.activate", async (item: ProfileItem | ConnectionProfile) => { + vscode.commands.registerCommand("code-for-ibmi.environment.profile.activate", async (item: ProfileItem | AnyConnectionProfile) => { const connection = instance.getConnection(); const storage = instance.getStorage(); if (connection && storage) { const profile = "profile" in item ? item.profile : item; const config = connection.getConfig(); - const profileToBackup = config.currentProfile ? getConnectionProfile(config.currentProfile) : getDefaultProfile(); + const profileToBackup = config.currentProfile ? + await getConnectionProfile(config.currentProfile, config.currentProfileType || 'local') : + getDefaultProfile(config); if (isProfileEdited(profile)) { - vscode.window.showWarningMessage(l10n.t("Profile {0} is being edited. Please close its editor before activating it.", profile.name)); + vscode.window.showWarningMessage(l10n.t("{0} profile {1} is being edited. Please close its editor before activating it.", config.currentProfileType === `server` ? l10n.t("Server") : "Local", profile.name)); return; - } - else if (profileToBackup && isProfileEdited(profileToBackup)) { - vscode.window.showWarningMessage(l10n.t("Profile {0} is being edited. Please close its editor before unloading it.", profileToBackup.name)); + } else if (profileToBackup && isProfileEdited(profileToBackup)) { + vscode.window.showWarningMessage(l10n.t("{0} profile {1} is being edited. Please close its editor before unloading it.", config.currentProfileType === `server` ? l10n.t("Server") : "Local", profileToBackup.name)); return; } + // Back up previous profile if (profileToBackup) { assignProfile(config, profileToBackup); } + + // Activate new profile assignProfile(profile, config); - config.currentProfile = profile.name || undefined; - await IBMi.connectionManager.update(config); + config.currentProfile = profile.name || ""; + config.currentProfileType = profile.type; + config.currentProfileLastKnownUpdate = profile.type === 'server' ? profile.lastUpdated : undefined; + + if (profileToBackup) { + await updateConnectionProfile(profileToBackup, { modifiedConfig: config }); + } else { + await IBMi.connectionManager.update(config); + } await Promise.all([ vscode.commands.executeCommand(`code-for-ibmi.refreshLibraryListView`), vscode.commands.executeCommand(`code-for-ibmi.refreshIFSBrowser`), vscode.commands.executeCommand(`code-for-ibmi.refreshObjectBrowser`) ]); - environmentView.refresh(); if (profile.name && profile.setLibraryListCommand) { await vscode.commands.executeCommand("code-for-ibmi.environment.profile.runLiblistCommand", profile); } await updateUIContext(profile.name); - vscode.window.showInformationMessage(config.currentProfile ? l10n.t(`Switched to profile "{0}".`, profile.name) : l10n.t("Active profile unloaded")); + vscode.window.showInformationMessage(config.currentProfile ? l10n.t(`Switched to {0} profile "{1}".`, config.isSystemProfile ? l10n.t("system") : "local", profile.name) : l10n.t("Active profile unloaded")); } }), - vscode.commands.registerCommand("code-for-ibmi.environment.profile.runLiblistCommand", async (profileItem?: ProfileItem | ConnectionProfile) => { + vscode.commands.registerCommand("code-for-ibmi.environment.profile.runLiblistCommand", async (item: ProfileItem | ConnectionProfile) => { const connection = instance.getConnection(); const storage = instance.getStorage(); if (connection && storage) { + const profile = "profile" in item ? item.profile : item; const config = connection.getConfig(); - const profile = profileItem && ("profile" in profileItem ? profileItem?.profile : profileItem) || getConnectionProfile(config.get); if (profile?.setLibraryListCommand) { const command = profile.setLibraryListCommand.startsWith(`?`) ? @@ -365,26 +425,20 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { } }), vscode.commands.registerCommand("code-for-ibmi.environment.profile.unload", async () => { - vscode.commands.executeCommand("code-for-ibmi.environment.profile.activate", getDefaultProfile()); - }) - ); + const connection = instance.getConnection(); + if (connection) { + const config = connection.getConfig(); + vscode.commands.executeCommand("code-for-ibmi.environment.profile.activate", getDefaultProfile(config)); + } + }), - instance.subscribe(context, 'connected', 'Update environment view description', async () => { - const config = instance.getConnection()?.getConfig(); - const storage = instance.getStorage(); - if (config && storage) { - //Retrieve and clear old value for last used profile - const deprecatedLastProfile = storage.getLastProfile(); - if (deprecatedLastProfile) { - if (deprecatedLastProfile.toLocaleLowerCase() !== 'default') { - config.currentProfile = deprecatedLastProfile; - await IBMi.connectionManager.update(config); - } - await storage.clearDeprecatedLastProfile(); + onCodeForIBMiConfigurationChange("connectionSettings", async () => { + const connection = instance.getConnection(); + if (connection) { + environmentView.refresh(); } - updateUIContext(config.currentProfile); - } - }); + }) + ); } class EnvironmentView implements vscode.TreeDataProvider { @@ -392,6 +446,65 @@ class EnvironmentView implements vscode.TreeDataProvider { readonly onDidChangeTreeData = this.emitter.event; readonly actionsNode = new ActionsNode(); readonly profilesNode = new ProfilesNode(); + private static instance?: EnvironmentView; + private configChangeCallbacks: Map void>> = new Map(); + + constructor() { + EnvironmentView.instance = this; + } + + /** + * Register a callback to be invoked when specific configuration keys change + * @param keys Array of configuration keys to watch + * @param callback Function to call when any of the watched keys change + * @returns Disposable to unregister the callback + */ + static registerConfigChangeListener( + keys: string[], + callback: (key: string, oldValue: any, newValue: any) => void + ): vscode.Disposable { + if (!EnvironmentView.instance) { + throw new Error('EnvironmentView not initialized'); + } + + for (const key of keys) { + if (!EnvironmentView.instance.configChangeCallbacks.has(key)) { + EnvironmentView.instance.configChangeCallbacks.set(key, new Set()); + } + EnvironmentView.instance.configChangeCallbacks.get(key)!.add(callback); + } + + return new vscode.Disposable(() => { + if (EnvironmentView.instance) { + for (const key of keys) { + EnvironmentView.instance.configChangeCallbacks.get(key)?.delete(callback); + } + } + }); + } + + /** + * Notify registered callbacks about configuration changes + * @param changes Map of changed keys with their old and new values + */ + static notifyConfigChanges(changes: Map) { + if (!EnvironmentView.instance) { + return; + } + + for (const [key, { oldValue, newValue }] of changes) { + const callbacks = EnvironmentView.instance.configChangeCallbacks.get(key); + if (callbacks) { + for (const callback of callbacks) { + try { + callback(key, oldValue, newValue); + } catch (error) { + console.error(`Error in config change callback for key '${key}':`, error); + } + } + } + } + } refresh(target?: BrowserItem) { this.emitter.fire(target); From 3b8cf2ded1b07c39580ed39dd89ddd2019944aae Mon Sep 17 00:00:00 2001 From: Sanjula Ganepola Date: Mon, 9 Feb 2026 17:20:09 -0500 Subject: [PATCH 06/13] Fixes to profile updates and check state again before updating remote file Signed-off-by: Sanjula Ganepola --- package-lock.json | 16 +- package.json | 75 ++- src/api/IBMiContent.ts | 21 +- src/api/configuration/config/types.ts | 2 +- src/api/configuration/configFile.ts | 9 +- src/api/connectionProfiles.ts | 20 +- src/editors/connectionProfileEditor.ts | 14 +- .../views/environment/connectionProfiles.ts | 13 +- src/ui/views/environment/environmentView.ts | 530 +++++++++++++----- 9 files changed, 511 insertions(+), 189 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9f5bd4313..6bebecb69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "csv": "^6.2.1", "escape-string-regexp": "^5.0.0", "ignore": "^5.1.9", + "jsonc-parser": "^3.3.1", "node-ssh": "^13.1.0", "tar": "^6.2.1", "tmp": "^0.2.1", @@ -1359,7 +1360,6 @@ "version": "7.4.1", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1386,7 +1386,6 @@ "version": "6.12.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1529,7 +1528,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001646", "electron-to-chromium": "^1.5.4", @@ -3409,6 +3407,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/kind-of": { "version": "6.0.3", "dev": true, @@ -4355,7 +4359,6 @@ "version": "5.31.6", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -4527,7 +4530,6 @@ "version": "4.8.2", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4590,7 +4592,6 @@ "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -4810,7 +4811,6 @@ "version": "5.94.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.5", "@webassemblyjs/ast": "^1.12.1", @@ -4856,7 +4856,6 @@ "version": "4.10.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", @@ -4931,7 +4930,6 @@ "version": "8.12.1", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, diff --git a/package.json b/package.json index b72bed2a5..08e16c126 100644 --- a/package.json +++ b/package.json @@ -1764,6 +1764,30 @@ "category": "IBM i", "title": "Unload active profile", "icon": "$(sign-out)" + }, + { + "command": "code-for-ibmi.resolveProfile.saveChangeToServer", + "enablement": "code-for-ibmi:connected", + "category": "IBM i", + "title": "Save Changes To Server" + }, + { + "command": "code-for-ibmi.resolveProfile.discardChangesAndSyncWithServer", + "enablement": "code-for-ibmi:connected", + "category": "IBM i", + "title": "Discard Changes and Sync with Server" + }, + { + "command": "code-for-ibmi.resolveProfile.inspectChanges", + "enablement": "code-for-ibmi:connected", + "category": "IBM i", + "title": "Inspect Changes" + }, + { + "command": "code-for-ibmi.resolveProfile.overwriteChangesToServer", + "enablement": "code-for-ibmi:connected", + "category": "IBM i", + "title": "Overwrite Changes to Server" } ], "customEditors": [ @@ -1960,6 +1984,11 @@ { "id": "code-for-ibmi.compareWithLocal", "label": "Compare with" + }, + { + "id": "code-for-ibmi.resolveProfile", + "label": "Resolve...", + "icon": "$(edit-sparkle)" } ], "menus": { @@ -2050,6 +2079,28 @@ "when": "!explorerResourceIsFolder" } ], + "code-for-ibmi.resolveProfile": [ + { + "command": "code-for-ibmi.resolveProfile.saveChangeToServer", + "when": "viewItem =~ /^profileItem_active.*_Modified/", + "group": "00_resolve@01" + }, + { + "command": "code-for-ibmi.resolveProfile.discardChangesAndSyncWithServer", + "when": "viewItem =~ /^profileItem_active.*_(Modified|Out of Sync)/", + "group": "00_resolve@02" + }, + { + "command": "code-for-ibmi.resolveProfile.inspectChanges", + "when": "viewItem =~ /^profileItem_active.*_(Modified|Out of Sync)/", + "group": "00_resolve@03" + }, + { + "command": "code-for-ibmi.resolveProfile.overwriteChangesToServer", + "when": "viewItem =~ /^profileItem_active.*_Out of Sync/", + "group": "00_resolve@01" + } + ], "commandPalette": [ { "command": "code-for-ibmi.testing.connectWithFixture", @@ -2514,6 +2565,22 @@ { "command": "code-for-ibmi.environment.profile.unload", "when": "never" + }, + { + "command": "code-for-ibmi.resolveProfile.saveChangeToServer", + "when": "never" + }, + { + "command": "code-for-ibmi.resolveProfile.discardChangesAndSyncWithServer", + "when": "never" + }, + { + "command": "code-for-ibmi.resolveProfile.inspectChanges", + "when": "never" + }, + { + "command": "code-for-ibmi.resolveProfile.overwriteChangesToServer", + "when": "never" } ], "view/title": [ @@ -3154,7 +3221,7 @@ { "command": "code-for-ibmi.environment.profile.unload", "when": "view === environmentView && viewItem =~ /^profileItem_active/", - "group": "inline@01" + "group": "inline@03" }, { "command": "code-for-ibmi.environment.profile.activate", @@ -3166,6 +3233,11 @@ "when": "view === environmentView && viewItem =~ /^profileItem_active_command/", "group": "inline@01" }, + { + "submenu": "code-for-ibmi.resolveProfile", + "when": "view === environmentView && viewItem =~ /^profileItem_active.*_(Modified|Out of Sync)/", + "group": "inline@02" + }, { "command": "code-for-ibmi.environment.profile.rename", "when": "view === environmentView && viewItem =~ /^profileItem/", @@ -3244,6 +3316,7 @@ "csv": "^6.2.1", "escape-string-regexp": "^5.0.0", "ignore": "^5.1.9", + "jsonc-parser": "^3.3.1", "node-ssh": "^13.1.0", "tar": "^6.2.1", "tmp": "^0.2.1", diff --git a/src/api/IBMiContent.ts b/src/api/IBMiContent.ts index c6fbd6eec..04d1c24d3 100644 --- a/src/api/IBMiContent.ts +++ b/src/api/IBMiContent.ts @@ -1120,9 +1120,24 @@ export default class IBMiContent { * @param path the full path to the streamfile * @throws an Error if the file could not be correctly created */ - async createStreamFile(path: string) { - path = Tools.escapePath(path); - const result = (await this.ibmi.sendCommand({ command: `echo "" > ${path} && ${this.ibmi.remoteFeatures.attr} ${path} CCSID=1208` })); + async createStreamFile(posixPath: string, createParents: boolean = false) { + posixPath = Tools.escapePath(posixPath); + + const commands: string[] = []; + + if (createParents) { + const dir = path.posix.dirname(posixPath); + if (dir && dir !== '/') { + commands.push(`mkdir -p ${dir}`); + } + } + + commands.push(`echo "" > ${posixPath}`); + commands.push(`${this.ibmi.remoteFeatures.attr} ${posixPath} CCSID=1208`); + + const result = await this.ibmi.sendCommand({ + command: commands.join(' && ') + }); if (result.code !== 0) { throw new Error(result.stderr); } diff --git a/src/api/configuration/config/types.ts b/src/api/configuration/config/types.ts index 20c414ce8..b808e6de3 100644 --- a/src/api/configuration/config/types.ts +++ b/src/api/configuration/config/types.ts @@ -63,7 +63,7 @@ export type AnyConnectionProfile = | LocalConnectionProfile | ServerConnectionPr export type ProfileType = 'local' | 'server'; -export type ProfileState = 'In-sync' | 'Modified' | 'Outdated' | 'Conflict'; +export type ProfileState = 'In Sync' | 'Modified' | 'Out of Sync'; export interface LocalConnectionProfile extends ConnectionProfile { type: 'local'; diff --git a/src/api/configuration/configFile.ts b/src/api/configuration/configFile.ts index a5526777b..6195e74f7 100644 --- a/src/api/configuration/configFile.ts +++ b/src/api/configuration/configFile.ts @@ -30,11 +30,12 @@ export class ConfigFile { async loadFromServer() { this.state = `no_exist`; - const isAvailable = await this.connection.getContent().testStreamFile(this.serverFile, `r`); + const content = this.connection.getContent(); + const isAvailable = await content.testStreamFile(this.serverFile, `r`); if (isAvailable) { - const content = await this.connection.getContent().downloadStreamfileRaw(this.serverFile); + const fileContent = await content.downloadStreamfileRaw(this.serverFile); try { - const serverConfig: T = JSON.parse(content.toString()); + const serverConfig: T = JSON.parse(fileContent.toString()); this.state = `ok`; if (this.validateData) { @@ -57,7 +58,7 @@ export class ConfigFile { try { const content = this.connection.getContent(); if (this.state === `no_exist`) { - await content.createStreamFile(this.serverFile); + await content.createStreamFile(this.serverFile, true); } content.writeStreamfileRaw(this.serverFile, JSON.stringify(newConfig, null, 4)); diff --git a/src/api/connectionProfiles.ts b/src/api/connectionProfiles.ts index a82349603..13996cf82 100644 --- a/src/api/connectionProfiles.ts +++ b/src/api/connectionProfiles.ts @@ -50,14 +50,12 @@ export async function updateConnectionProfile(profile: AnyConnectionProfile, opt if (isServerProfile) { // Map internal server profile type to connection profile const serverProfiles: ConnectionProfile[] = (profiles as ServerConnectionProfile[]).map(({ type, state, homeDirectory, ...profile }) => profile); - const profilesConfigFile = connection.getConfigFile(`profiles`); await profilesConfigFile.writeToServer(serverProfiles); await IBMi.connectionManager.update(config); } else { // Map internal local profile type to connection profile const localProfiles: ConnectionProfile[] = (profiles as LocalConnectionProfile[]).map(({ type, ...profile }) => profile); - config.connectionProfiles = localProfiles; await IBMi.connectionManager.update(config); } @@ -114,17 +112,15 @@ export async function getConnectionProfilesInGroups() { const isOutdated = profileLastUpdated > lastKnownUpdate; if (isOutdated && !isInSync) { - state = 'Conflict'; - } else if (isOutdated && isInSync) { - state = 'Outdated'; + state = 'Out of Sync'; } else if (!isOutdated && !isInSync) { state = 'Modified'; } else { - state = 'In-sync'; + state = 'In Sync'; } } else { // Not current server profile, so it's in sync - state = 'In-sync'; + state = 'In Sync'; } return { @@ -139,7 +135,7 @@ export async function getConnectionProfilesInGroups() { if (currentProfileName && currentProfileType === 'server') { const profileExistsOnServer = serverProfiles.some(p => p.name === currentProfileName); if (!profileExistsOnServer) { - // Add the missing profile with Conflict state + // Add the missing profile with out of sync state const localVersionOfServerProfile: ServerConnectionProfile = { name: currentProfileName, type: 'server' as const, @@ -149,7 +145,7 @@ export async function getConnectionProfilesInGroups() { ifsShortcuts: config.ifsShortcuts, customVariables: config.customVariables, setLibraryListCommand: config.setLibraryListCommand, - state: 'Conflict', + state: 'Out of Sync', lastUpdated: 0 }; @@ -209,7 +205,11 @@ export function getDefaultProfile(config: ConnectionConfig): LocalConnectionProf } export function assignProfile(fromProfile: ConnectionProfile, toProfile: ConnectionProfile) { - toProfile.homeDirectory = fromProfile.homeDirectory; + if (fromProfile.homeDirectory) { + // Home directory will be undefined when assigning from a server profile + toProfile.homeDirectory = fromProfile.homeDirectory; + } + toProfile.currentLibrary = fromProfile.currentLibrary; toProfile.libraryList = fromProfile.libraryList; toProfile.objectFilters = fromProfile.objectFilters; diff --git a/src/editors/connectionProfileEditor.ts b/src/editors/connectionProfileEditor.ts index 37b12a9b6..55bec9b68 100644 --- a/src/editors/connectionProfileEditor.ts +++ b/src/editors/connectionProfileEditor.ts @@ -3,6 +3,7 @@ import { isActiveProfile, updateConnectionProfile } from "../api/connectionProfi import { instance } from "../instantiate"; import { AnyConnectionProfile } from "../typings"; import { CustomEditor } from "./customEditorProvider"; +import { verifyLatestServerProfileState } from "../ui/views/environment/environmentView"; type ConnectionProfileData = { homeDirectory: string @@ -24,8 +25,12 @@ export function editConnectionProfile(profile: AnyConnectionProfile, doAfterSave const ifsShortcuts = (activeProfile && config ? config : profile).ifsShortcuts; const customVariables = (activeProfile && config ? config : profile).customVariables; - new CustomEditor(`${profile.name}.profile`, data => save(profile, data).then(doAfterSave), () => editedProfiles.delete({ name: profile.name, type: profile.type })) - .addInput("homeDirectory", l10n.t("Home Directory"), '', { minlength: 1, default: profile.homeDirectory, readonly: activeProfile }) + const profileEditor = new CustomEditor(`${profile.name}.profile`, data => save(profile, data).then(doAfterSave), () => editedProfiles.delete({ name: profile.name, type: profile.type })); + if (profile.type === `local`) { + profileEditor + .addInput("homeDirectory", l10n.t("Home Directory"), '', { minlength: 1, default: profile.homeDirectory, readonly: activeProfile }) + } + profileEditor .addInput("currentLibrary", l10n.t("Current Library"), '', { minlength: 1, maxlength: 10, default: profile.currentLibrary, readonly: activeProfile }) .addInput("libraryList", l10n.t("Library List"), l10n.t("A comma-separated list of libraries."), { default: profile.libraryList.join(","), readonly: activeProfile }) .addInput("setLibraryListCommand", l10n.t("Library List Command"), l10n.t("Library List Command can be used to set your library list based on the result of a command like CHGLIBL, or your own command that sets the library list.
Commands should be as explicit as possible.
When refering to commands and objects, both should be qualified with a library.
Put ? in front of the command to prompt it before execution."), { default: profile.setLibraryListCommand }) @@ -69,6 +74,9 @@ async function save(profile: AnyConnectionProfile, data: ConnectionProfileData) } profile.libraryList = libraryList; - await updateConnectionProfile(profile); + const canProceed = await verifyLatestServerProfileState(profile); + if (canProceed) { + await updateConnectionProfile(profile); + } } } \ No newline at end of file diff --git a/src/ui/views/environment/connectionProfiles.ts b/src/ui/views/environment/connectionProfiles.ts index 263cb4e98..cb913ea8f 100644 --- a/src/ui/views/environment/connectionProfiles.ts +++ b/src/ui/views/environment/connectionProfiles.ts @@ -42,11 +42,10 @@ export class ProfilesNode extends EnvironmentItem { export class ProfileItem extends EnvironmentItem { static activeColor = "charts.green"; static modifiedColor = "charts.blue"; - static outdatedColor = "charts.yellow"; - static conflictColor = "charts.red"; + static outOfSyncColor = "charts.yellow"; static contextValue = `profileItem`; - constructor(parent: EnvironmentItem, readonly profile: AnyConnectionProfile, active: boolean) { + constructor(parent: EnvironmentItem, readonly profile: AnyConnectionProfile, readonly active: boolean) { const state = profile.type === 'server' ? profile.state : undefined; const icon = profile.type === 'server' ? `vm` : `person`; const color = ProfileItem.getColor(active, profile.type, state); @@ -76,14 +75,12 @@ export class ProfileItem extends EnvironmentItem { if (active) { if (type === `server` && state) { switch (state) { - case 'In-sync': + case 'In Sync': return ProfileItem.activeColor; case 'Modified': return ProfileItem.modifiedColor; - case 'Outdated': - return ProfileItem.outdatedColor; - case 'Conflict': - return ProfileItem.conflictColor; + case 'Out of Sync': + return ProfileItem.outOfSyncColor; } } diff --git a/src/ui/views/environment/environmentView.ts b/src/ui/views/environment/environmentView.ts index 46b5727fe..6b9331406 100644 --- a/src/ui/views/environment/environmentView.ts +++ b/src/ui/views/environment/environmentView.ts @@ -1,20 +1,21 @@ import { parse as parseQuery } from "querystring"; -import vscode, { l10n, QuickPickItem, ThemeIcon } from 'vscode'; +import vscode, { l10n, QuickPickItem, ThemeIcon, window } from 'vscode'; import { getActions, updateAction } from '../../../api/actions'; import { GetNewLibl } from '../../../api/components/getNewLibl'; -import { assignProfile, cloneProfile, getConnectionProfile, getAllConnectionProfiles, getDefaultProfile, updateConnectionProfile, isProfileInSync } from '../../../api/connectionProfiles'; +import { assignProfile, cloneProfile, getConnectionProfile, getAllConnectionProfiles, getDefaultProfile, updateConnectionProfile, isActiveProfile } from '../../../api/connectionProfiles'; import IBMi from '../../../api/IBMi'; import { editAction, isActionEdited } from '../../../editors/actionEditor'; import { editConnectionProfile, isProfileEdited } from '../../../editors/connectionProfileEditor'; import { instance } from '../../../instantiate'; -import { Action, ActionEnvironment, AnyConnectionProfile, BrowserItem, ConnectionConfig, ConnectionProfile, CustomVariable, FocusOptions, ProfileState, ProfileType } from '../../../typings'; +import { Action, ActionEnvironment, AnyConnectionProfile, BrowserItem, ConnectionConfig, ConnectionProfile, CustomVariable, FocusOptions, ProfileState, ProfileType, ServerConnectionProfile } from '../../../typings'; import { uriToActionTarget } from '../../actions'; import { ActionItem, Actions, ActionsNode, ActionTypeNode } from './actions'; import { ConnectionProfiles, ProfileItem, ProfilesNode } from './connectionProfiles'; import { CustomVariableItem, CustomVariables, CustomVariablesNode } from './customVariables'; import * as path from 'path'; import { onCodeForIBMiConfigurationChange } from "../../../config/Configuration"; +import { modify } from 'jsonc-parser'; export function initializeEnvironmentView(context: vscode.ExtensionContext) { const environmentView = new EnvironmentView(); @@ -63,15 +64,14 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { }), vscode.commands.registerCommand("code-for-ibmi.environment.refresh", async () => { - const connection = instance.getConnection(); - if (connection) { - const config = connection.getConfig(); - if (config.currentProfile && config.currentProfileType === `server`) { + await vscode.window.withProgress({ location: { viewId: `environmentView` } }, async (progress) => { + const connection = instance.getConnection(); + if (connection) { await connection.loadRemoteConfigs([`profiles`]); } - } - environmentView.refresh(); + environmentView.refresh(); + }); }), vscode.commands.registerCommand("code-for-ibmi.environment.refresh.item", (item: BrowserItem) => environmentView.refresh(item)), vscode.commands.registerCommand("code-for-ibmi.environment.reveal", (item: BrowserItem, options?: FocusOptions) => environmentTreeViewer.reveal(item, options)), @@ -217,83 +217,89 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { }), vscode.commands.registerCommand("code-for-ibmi.environment.profile.create", async (node?: ProfilesNode, from?: ConnectionProfile) => { - const existingNames = (await getAllConnectionProfiles()).map(profile => profile.name); + await vscode.window.withProgress({ location: { viewId: `environmentView` } }, async (progress) => { + const existingNames = (await getAllConnectionProfiles()).map(profile => profile.name); - const name = await vscode.window.showInputBox({ - title: l10n.t("Enter new profile name"), - placeHolder: l10n.t("Profile name..."), - value: from?.name, - validateInput: name => ConnectionProfiles.validateName(name, existingNames) - }); - if (!name) { - return; - } + const name = await vscode.window.showInputBox({ + title: l10n.t("Enter new profile name"), + placeHolder: l10n.t("Profile name..."), + value: from?.name, + validateInput: name => ConnectionProfiles.validateName(name, existingNames) + }); + if (!name) { + return; + } - const connection = instance.getConnection(); - const localConfigFilePath = process.env.APPDATA ? path.join(process.env.APPDATA, 'Code', 'User', 'settings.json') : undefined; - const profilesConfigFile = connection?.getConfigFile(`profiles`); - const profilesConfigFilePath = profilesConfigFile?.getPaths().server; - const locationItems = [ - { label: `Local`, description: `Stored on this PC (for your use only)`, detail: localConfigFilePath, iconPath: new ThemeIcon(`person`) }, - { label: `Server`, description: `Stored on IBM i (to be used amongst your team)`, detail: profilesConfigFilePath, iconPath: new ThemeIcon(`vm`) } - ]; - const type = await vscode.window.showQuickPick(locationItems, { - title: `Select what type of profile this is`, - placeHolder: `Profile type` - }); - if (!type) { - return; - } - const isServerProfile = type.label === `Local` ? false : true; - - const homeDirectory = connection?.getConfig().homeDirectory || `/home/${connection?.currentUser || 'QPGMR'}`; //QPGMR case should not happen, but better be safe here - let profile: AnyConnectionProfile; - if (from) { - // Copy existing profile - const clone = cloneProfile(from, name); - profile = isServerProfile ? { - ...clone, - type: `server`, - state: `In-sync`, - } : { - ...clone, - type: `local`, + const connection = instance.getConnection(); + const localConfigFilePath = process.env.APPDATA ? path.join(process.env.APPDATA, 'Code', 'User', 'settings.json') : undefined; + const profilesConfigFile = connection?.getConfigFile(`profiles`); + const profilesConfigFilePath = profilesConfigFile?.getPaths().server; + const locationItems = [ + { label: `Local`, description: `Stored on this PC (for your use only)`, detail: localConfigFilePath, iconPath: new ThemeIcon(`person`) }, + { label: `Server`, description: `Stored on IBM i (to be used amongst your team)`, detail: profilesConfigFilePath, iconPath: new ThemeIcon(`vm`) } + ]; + const type = await vscode.window.showQuickPick(locationItems, { + title: `Select what type of profile this is`, + placeHolder: `Profile type` + }); + if (!type) { + return; } - } else { - // Create new profile - profile = isServerProfile ? { - name, - type: `server`, - state: `In-sync`, - homeDirectory, - currentLibrary: 'QGPL', - libraryList: ["QGPL", "QTEMP"], - customVariables: [], - ifsShortcuts: [homeDirectory], - objectFilters: [], - } : { - name, - type: `local`, - homeDirectory, - currentLibrary: 'QGPL', - libraryList: ["QGPL", "QTEMP"], - customVariables: [], - ifsShortcuts: [homeDirectory], - objectFilters: [], - }; - } + const isServerProfile = type.label === `Local` ? false : true; - await updateConnectionProfile(profile); - if (!from) { - vscode.commands.executeCommand("code-for-ibmi.environment.profile.edit", profile); - } else { - vscode.window.showInformationMessage(l10n.t("Created {0} connection profile '{1}'.", isServerProfile ? l10n.t("server") : "local", profile.name), l10n.t("Activate profile {1}", profile.name)) - .then(doSwitch => { - if (doSwitch) { - vscode.commands.executeCommand("code-for-ibmi.environment.profile.activate", profile); - } - }) - } + const homeDirectory = connection?.getConfig().homeDirectory || `/home/${connection?.currentUser || 'QPGMR'}`; //QPGMR case should not happen, but better be safe here + let profile: AnyConnectionProfile; + if (from) { + // Copy existing profile + const { homeDirectory, ...clone } = cloneProfile(from, name); + profile = isServerProfile ? { + ...clone, + type: `server`, + state: `In Sync`, + } : { + ...clone, + type: `local`, + homeDirectory, + } + } else { + // Create new profile + profile = isServerProfile ? { + name, + type: `server`, + state: `In Sync`, + currentLibrary: 'QGPL', + libraryList: ["QGPL", "QTEMP"], + customVariables: [], + ifsShortcuts: [homeDirectory], + objectFilters: [], + } : { + name, + type: `local`, + homeDirectory, + currentLibrary: 'QGPL', + libraryList: ["QGPL", "QTEMP"], + customVariables: [], + ifsShortcuts: [homeDirectory], + objectFilters: [], + }; + } + + await updateConnectionProfile(profile); + + // Do an explicit refresh as creation / copy of a profile doesn't impact the active profile so onCodeForIBMiConfigurationChange is not called + environmentView.refresh(); + + if (!from) { + vscode.commands.executeCommand("code-for-ibmi.environment.profile.edit", profile); + } else { + vscode.window.showInformationMessage(l10n.t("Created {0} profile '{1}'.", isServerProfile ? l10n.t("server") : "local", profile.name), l10n.t("Activate profile")) + .then(doSwitch => { + if (doSwitch) { + vscode.commands.executeCommand("code-for-ibmi.environment.profile.activate", profile); + } + }) + } + }); }), vscode.commands.registerCommand("code-for-ibmi.environment.profile.fromCurrent", async (profilesNode: ProfilesNode) => { const config = instance.getConnection()?.getConfig(); @@ -307,87 +313,106 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { editConnectionProfile(profile) }), vscode.commands.registerCommand("code-for-ibmi.environment.profile.rename", async (item: ProfileItem) => { - if (isProfileEdited(item.profile)) { - vscode.window.showWarningMessage(l10n.t("{0} profile {1} is being edited. Please close its editor first.", item.profile.type === `server` ? l10n.t("Server") : "Local", item.profile.name)); - } - else { - const currentName = item.profile.name; - const currentType = item.profile.type; - const existingNames = (await getAllConnectionProfiles()).map(profile => profile.name).filter(name => name !== currentName); - const newName = await vscode.window.showInputBox({ - title: l10n.t('Enter {0} profile {1} new name', item.profile.type === `server` ? l10n.t("server") : "local", item.profile.name), - placeHolder: l10n.t("Profile name..."), - validateInput: name => ConnectionProfiles.validateName(name, existingNames) - }); - - if (newName) { - await updateConnectionProfile(item.profile, { newName }); - const config = instance.getConnection()?.getConfig(); - if (config?.currentProfile === currentName && config?.currentProfileType === currentType) { - updateUIContext(newName); + await vscode.window.withProgress({ location: { viewId: `environmentView` } }, async (progress) => { + if (isProfileEdited(item.profile)) { + vscode.window.showWarningMessage(l10n.t("{0} profile {1} is being edited. Please close its editor first.", item.profile.type === `server` ? l10n.t("Server") : "Local", item.profile.name)); + } + else { + const currentName = item.profile.name; + const currentType = item.profile.type; + const existingNames = (await getAllConnectionProfiles()).map(profile => profile.name).filter(name => name !== currentName); + const newName = await vscode.window.showInputBox({ + title: l10n.t('Enter {0} profile {1} new name', item.profile.type === `server` ? l10n.t("server") : "local", item.profile.name), + placeHolder: l10n.t("Profile name..."), + validateInput: name => ConnectionProfiles.validateName(name, existingNames) + }); + + if (newName) { + const canProceed = await verifyLatestServerProfileState(item.profile); + if (canProceed) { + await updateConnectionProfile(item.profile, { newName }); + const config = instance.getConnection()?.getConfig(); + if (config?.currentProfile === currentName && config?.currentProfileType === currentType) { + updateUIContext(newName); + } + } } } - } + }); }), vscode.commands.registerCommand("code-for-ibmi.environment.profile.copy", async (item: ProfileItem) => { vscode.commands.executeCommand("code-for-ibmi.environment.profile.create", undefined, item.profile); }), vscode.commands.registerCommand("code-for-ibmi.environment.profile.delete", async (item: ProfileItem) => { - if (isProfileEdited(item.profile)) { - vscode.window.showWarningMessage(l10n.t("{0} profile {1} is being edited. Please close its editor first.", item.profile.type === `server` ? l10n.t("Server") : "Local", item.profile.name)); - } - else if (await vscode.window.showInformationMessage(l10n.t("Do you really want to delete {0} profile '{1}' ?", item.profile.type === `server` ? l10n.t("server") : l10n.t("local"), item.profile.name), { modal: true }, l10n.t("Yes"))) { - await updateConnectionProfile(item.profile, { delete: true }); - } + await vscode.window.withProgress({ location: { viewId: `environmentView` } }, async (progress) => { + if (isProfileEdited(item.profile)) { + vscode.window.showWarningMessage(l10n.t("{0} profile {1} is being edited. Please close its editor first.", item.profile.type === `server` ? l10n.t("Server") : "Local", item.profile.name)); + } + else if (await vscode.window.showInformationMessage(l10n.t("Do you really want to delete {0} profile '{1}' ?", item.profile.type === `server` ? l10n.t("server") : l10n.t("local"), item.profile.name), { modal: true }, l10n.t("Yes"))) { + const canProceed = await verifyLatestServerProfileState(item.profile); + if (canProceed) { + await updateConnectionProfile(item.profile, { delete: true }); + + // Do an explicit refresh as creation / copy of a profile doesn't impact the active profile so onCodeForIBMiConfigurationChange is not called + environmentView.refresh(); + } + } + }); }), vscode.commands.registerCommand("code-for-ibmi.environment.profile.activate", async (item: ProfileItem | AnyConnectionProfile) => { - const connection = instance.getConnection(); - const storage = instance.getStorage(); - if (connection && storage) { - const profile = "profile" in item ? item.profile : item; - const config = connection.getConfig(); - const profileToBackup = config.currentProfile ? - await getConnectionProfile(config.currentProfile, config.currentProfileType || 'local') : - getDefaultProfile(config); + await vscode.window.withProgress({ location: { viewId: `environmentView` } }, async (progress) => { + const connection = instance.getConnection(); + const storage = instance.getStorage(); + if (connection && storage) { + const profile = "profile" in item ? item.profile : item; + const config = connection.getConfig(); + const profileToBackup = config.currentProfile ? + await getConnectionProfile(config.currentProfile, config.currentProfileType || 'local') : + getDefaultProfile(config); + + if (isProfileEdited(profile)) { + vscode.window.showWarningMessage(l10n.t("{0} profile {1} is being edited. Please close its editor before activating it.", config.currentProfileType === `server` ? l10n.t("Server") : "Local", profile.name)); + return; + } else if (profileToBackup && isProfileEdited(profileToBackup)) { + vscode.window.showWarningMessage(l10n.t("{0} profile {1} is being edited. Please close its editor before unloading it.", config.currentProfileType === `server` ? l10n.t("Server") : "Local", profileToBackup.name)); + return; + } - if (isProfileEdited(profile)) { - vscode.window.showWarningMessage(l10n.t("{0} profile {1} is being edited. Please close its editor before activating it.", config.currentProfileType === `server` ? l10n.t("Server") : "Local", profile.name)); - return; - } else if (profileToBackup && isProfileEdited(profileToBackup)) { - vscode.window.showWarningMessage(l10n.t("{0} profile {1} is being edited. Please close its editor before unloading it.", config.currentProfileType === `server` ? l10n.t("Server") : "Local", profileToBackup.name)); - return; - } + // Back up previous profile + if (profileToBackup) { + const canProceed = await verifyLatestServerProfileState(profileToBackup, { ensureInSync: true }); + if (!canProceed) { + return; + } + assignProfile(config, profileToBackup); + } - // Back up previous profile - if (profileToBackup) { - assignProfile(config, profileToBackup); - } + // Activate new profile + assignProfile(profile, config); + config.currentProfile = profile.name || ""; + config.currentProfileType = profile.type; + config.currentProfileLastKnownUpdate = profile.type === 'server' ? profile.lastUpdated : undefined; - // Activate new profile - assignProfile(profile, config); - config.currentProfile = profile.name || ""; - config.currentProfileType = profile.type; - config.currentProfileLastKnownUpdate = profile.type === 'server' ? profile.lastUpdated : undefined; + if (profileToBackup) { + await updateConnectionProfile(profileToBackup, { modifiedConfig: config }); + } else { + await IBMi.connectionManager.update(config); + } - if (profileToBackup) { - await updateConnectionProfile(profileToBackup, { modifiedConfig: config }); - } else { - await IBMi.connectionManager.update(config); - } + await Promise.all([ + vscode.commands.executeCommand(`code-for-ibmi.refreshLibraryListView`), + vscode.commands.executeCommand(`code-for-ibmi.refreshIFSBrowser`), + vscode.commands.executeCommand(`code-for-ibmi.refreshObjectBrowser`) + ]); - await Promise.all([ - vscode.commands.executeCommand(`code-for-ibmi.refreshLibraryListView`), - vscode.commands.executeCommand(`code-for-ibmi.refreshIFSBrowser`), - vscode.commands.executeCommand(`code-for-ibmi.refreshObjectBrowser`) - ]); + if (profile.name && profile.setLibraryListCommand) { + await vscode.commands.executeCommand("code-for-ibmi.environment.profile.runLiblistCommand", profile); + } - if (profile.name && profile.setLibraryListCommand) { - await vscode.commands.executeCommand("code-for-ibmi.environment.profile.runLiblistCommand", profile); + await updateUIContext(profile.name); + vscode.window.showInformationMessage(config.currentProfile ? l10n.t(`Switched to {0} profile '{1}'.`, profile.type === `server` ? l10n.t("server") : "local", profile.name) : l10n.t("Active profile unloaded")); } - - await updateUIContext(profile.name); - vscode.window.showInformationMessage(config.currentProfile ? l10n.t(`Switched to {0} profile "{1}".`, config.isSystemProfile ? l10n.t("system") : "local", profile.name) : l10n.t("Active profile unloaded")); - } + }); }), vscode.commands.registerCommand("code-for-ibmi.environment.profile.runLiblistCommand", async (item: ProfileItem | ConnectionProfile) => { @@ -424,13 +449,179 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { } } }), - vscode.commands.registerCommand("code-for-ibmi.environment.profile.unload", async () => { + vscode.commands.registerCommand("code-for-ibmi.environment.profile.unload", async (item: ProfileItem) => { const connection = instance.getConnection(); if (connection) { - const config = connection.getConfig(); - vscode.commands.executeCommand("code-for-ibmi.environment.profile.activate", getDefaultProfile(config)); + const canProceed = await verifyLatestServerProfileState(item.profile, { ensureInSync: true }); + if (canProceed) { + const config = connection.getConfig(); + vscode.commands.executeCommand("code-for-ibmi.environment.profile.activate", getDefaultProfile(config)); + } } }), + vscode.commands.registerCommand("code-for-ibmi.resolveProfile.saveChangeToServer", async (item: ProfileItem, overwrite: boolean = false) => { + await vscode.window.withProgress({ location: { viewId: `environmentView` } }, async (progress) => { + const connection = instance.getConnection(); + if (connection) { + const canProceed = await verifyLatestServerProfileState(item.profile); + if (canProceed) { + const config = connection.getConfig(); + const profile = item.profile; + assignProfile(config, profile); + await updateConnectionProfile(profile); + if (overwrite) { + vscode.window.showInformationMessage(l10n.t("Saved changes to server profile '{0}'.", item.profile.name)); + } else { + vscode.window.showInformationMessage(l10n.t("Overwrote server profile '{0}' with local changes.", item.profile.name)); + } + } + } + }); + }), + vscode.commands.registerCommand("code-for-ibmi.resolveProfile.discardChangesAndSyncWithServer", async (item: ProfileItem) => { + await vscode.window.withProgress({ location: { viewId: `environmentView` } }, async (progress) => { + const connection = instance.getConnection(); + if (connection) { + const canProceed = await verifyLatestServerProfileState(item.profile); + if (canProceed) { + const config = connection.getConfig(); + const profile = item.profile; + assignProfile(profile, config); + config.currentProfileLastKnownUpdate = profile.type === 'server' ? profile.lastUpdated : undefined; + + await IBMi.connectionManager.update(config); + + await Promise.all([ + vscode.commands.executeCommand(`code-for-ibmi.refreshLibraryListView`), + vscode.commands.executeCommand(`code-for-ibmi.refreshIFSBrowser`), + vscode.commands.executeCommand(`code-for-ibmi.refreshObjectBrowser`) + ]); + + vscode.window.showInformationMessage(l10n.t("Discarded local changes and synced with server profile '{0}'.", item.profile.name)); + } + } + }); + }), + vscode.commands.registerCommand("code-for-ibmi.resolveProfile.inspectChanges", async (item: ProfileItem) => { + await vscode.window.withProgress({ location: { viewId: `environmentView` } }, async (progress) => { + const connection = instance.getConnection(); + if (connection) { + const canProceed = await verifyLatestServerProfileState(item.profile); + if (canProceed) { + const config = connection.getConfig(); + const profile = item.profile; + + // Get the profiles config file path + const profilesConfigFile = connection.getConfigFile(`profiles`); + const serverPath = profilesConfigFile.getPaths().server; + const serverUri = vscode.Uri.from({ scheme: 'streamfile', path: serverPath }); + + // Open the server profiles file + let textDocument: vscode.TextDocument; + try { + textDocument = await vscode.workspace.openTextDocument(serverUri); + } catch (error: any) { + vscode.window.showErrorMessage(l10n.t("Failed to open server profiles file: {0}", error.message)); + return; + } + + const content = textDocument.getText(); + let profiles: ConnectionProfile[]; + try { + profiles = JSON.parse(content); + } catch (error: any) { + vscode.window.showErrorMessage(l10n.t("Failed to parse server profiles file: {0}", error.message)); + return; + } + + const profileIndex = profiles.findIndex(p => p.name === item.profile.name); + if (profileIndex === -1) { + vscode.window.showErrorMessage(l10n.t("Profile '{0}' not found in server profiles.", item.profile.name)); + return; + } + + // Build workspace edit with individual field changes using jsonc-parser + const edit = new vscode.WorkspaceEdit(); + + // Helper to create edit for a field + const createFieldEdit = (fieldName: string, newValue: any, label: string) => { + const path = [profileIndex, fieldName]; + const edits = modify(content, path, newValue, { formattingOptions: { tabSize: 4, insertSpaces: true } }); + + for (const jsonEdit of edits) { + const startPos = textDocument.positionAt(jsonEdit.offset); + const endPos = textDocument.positionAt(jsonEdit.offset + jsonEdit.length); + + edit.replace( + serverUri, + new vscode.Range(startPos, endPos), + jsonEdit.content, + { + label, + needsConfirmation: true, + iconPath: new vscode.ThemeIcon('edit') + } + ); + } + }; + + // Create edits for each changed field + if (config.currentLibrary !== profile.currentLibrary) { + createFieldEdit( + 'currentLibrary', + config.currentLibrary, + l10n.t("Current Library: {0} → {1}", profile.currentLibrary, config.currentLibrary) + ); + } + + if (JSON.stringify(config.libraryList) !== JSON.stringify(profile.libraryList)) { + createFieldEdit( + 'libraryList', + config.libraryList, + l10n.t("Library List: {0} items → {1} items", profile.libraryList.length, config.libraryList.length) + ); + } + + if (JSON.stringify(config.objectFilters) !== JSON.stringify(profile.objectFilters)) { + createFieldEdit( + 'objectFilters', + config.objectFilters, + l10n.t("Object Filters: {0} items → {1} items", profile.objectFilters.length, config.objectFilters.length) + ); + } + + if (JSON.stringify(config.ifsShortcuts) !== JSON.stringify(profile.ifsShortcuts)) { + createFieldEdit( + 'ifsShortcuts', + config.ifsShortcuts, + l10n.t("IFS Shortcuts: {0} items → {1} items", profile.ifsShortcuts.length, config.ifsShortcuts.length) + ); + } + + if (JSON.stringify(config.customVariables) !== JSON.stringify(profile.customVariables)) { + createFieldEdit( + 'customVariables', + config.customVariables, + l10n.t("Custom Variables: {0} items → {1} items", profile.customVariables.length, config.customVariables.length) + ); + } + + if (config.setLibraryListCommand !== profile.setLibraryListCommand) { + createFieldEdit( + 'setLibraryListCommand', + config.setLibraryListCommand, + l10n.t("Library List Command: {0} → {1}", profile.setLibraryListCommand || l10n.t("(none)"), config.setLibraryListCommand || l10n.t("(none)")) + ); + } + + await vscode.workspace.applyEdit(edit, { isRefactoring: true }); + } + } + }); + }), + vscode.commands.registerCommand("code-for-ibmi.resolveProfile.overwriteChangesToServer", async (item: ProfileItem) => { + vscode.commands.executeCommand("code-for-ibmi.resolveProfile.saveChangeToServer", item, true); + }), onCodeForIBMiConfigurationChange("connectionSettings", async () => { const connection = instance.getConnection(); @@ -441,6 +632,45 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { ); } +export async function verifyLatestServerProfileState(profile: AnyConnectionProfile, options: { ensureInSync: boolean } = { ensureInSync: false }): Promise { + if (profile.type === `server`) { + const isActive = isActiveProfile(profile); + if (isActive) { + // Get current profile state + const currentState: ProfileState = profile.state; + + // Reload server profiles in case another user changed them before the last fetch + await vscode.commands.executeCommand("code-for-ibmi.environment.refresh"); + + // Get updated profile state + let updatedState: ProfileState; + const updatedServerProfile = await getConnectionProfile(profile.name, profile.type); + if (updatedServerProfile) { + updatedState = (updatedServerProfile as ServerConnectionProfile).state; + } else { + updatedState = "Out of Sync"; + } + + if (currentState !== updatedState) { + window.showErrorMessage(l10n.t("Server Profile {0} state changed to \"{1}\" after fetching the latest server profiles. Please try again.", profile.name, updatedState)); + return false; + } + + if (options.ensureInSync) { + if (updatedState === `Modified`) { + window.showErrorMessage(l10n.t("Server Profile {0} has been modified. Resolve this before proceeding.", profile.name, updatedState)); + return false; + } else if (updatedState === `Out of Sync`) { + window.showErrorMessage(l10n.t("Server Profile {0} is out of sync. Resolve this before proceeding.", profile.name, updatedState)); + return false; + } + } + } + } + + return true; +} + class EnvironmentView implements vscode.TreeDataProvider { private readonly emitter = new vscode.EventEmitter(); readonly onDidChangeTreeData = this.emitter.event; From 1ccacad16b1ee3610ae18da3d9be4040a007718c Mon Sep 17 00:00:00 2001 From: Sanjula Ganepola Date: Mon, 9 Feb 2026 17:33:08 -0500 Subject: [PATCH 07/13] Fix default profile being lost Signed-off-by: Sanjula Ganepola --- src/api/connectionProfiles.ts | 2 +- src/ui/views/environment/connectionProfiles.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/api/connectionProfiles.ts b/src/api/connectionProfiles.ts index 13996cf82..3f7d4428f 100644 --- a/src/api/connectionProfiles.ts +++ b/src/api/connectionProfiles.ts @@ -75,7 +75,7 @@ export async function getConnectionProfilesInGroups() { const connection = instance.getConnection(); if (connection) { const config = connection.getConfig(); - const rawLocalProfiles: ConnectionProfile[] = config.connectionProfiles.filter(profile => Boolean(profile.name)); + const rawLocalProfiles: ConnectionProfile[] = config.connectionProfiles; // Map connection profiles to internal local profile type const localProfiles: LocalConnectionProfile[] = rawLocalProfiles.map(rawlocalProfile => ({ diff --git a/src/ui/views/environment/connectionProfiles.ts b/src/ui/views/environment/connectionProfiles.ts index cb913ea8f..e84df26ac 100644 --- a/src/ui/views/environment/connectionProfiles.ts +++ b/src/ui/views/environment/connectionProfiles.ts @@ -1,4 +1,4 @@ -import vscode, { FileDecoration, l10n, window } from "vscode"; +import vscode, { l10n } from "vscode"; import { stringify } from "querystring"; import { getConnectionProfilesInGroups } from "../../../api/connectionProfiles"; import { instance } from "../../../instantiate"; @@ -30,6 +30,7 @@ export class ProfilesNode extends EnvironmentItem { const currentProfileType = config?.currentProfileType ?? `local`; const { localProfiles, serverProfiles } = await getConnectionProfilesInGroups(); const localProfileItems = localProfiles + .filter(profile => Boolean(profile.name)) .sort((p1, p2) => p1.name.localeCompare(p2.name)) .map(profile => new ProfileItem(this, profile, profile.name === currentProfile && profile.type === currentProfileType)); const serverProfileItems = serverProfiles From 6ead95e609b3c43ff6e251b6b08b2534bc03d15f Mon Sep 17 00:00:00 2001 From: Sanjula Ganepola Date: Mon, 9 Feb 2026 17:55:18 -0500 Subject: [PATCH 08/13] Fix updating of setLibraryListCommand and ensure homeDirectory not saved to server profile Signed-off-by: Sanjula Ganepola --- src/editors/connectionProfileEditor.ts | 59 +++++++++++++++----------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/src/editors/connectionProfileEditor.ts b/src/editors/connectionProfileEditor.ts index 55bec9b68..9989328c7 100644 --- a/src/editors/connectionProfileEditor.ts +++ b/src/editors/connectionProfileEditor.ts @@ -4,6 +4,7 @@ import { instance } from "../instantiate"; import { AnyConnectionProfile } from "../typings"; import { CustomEditor } from "./customEditorProvider"; import { verifyLatestServerProfileState } from "../ui/views/environment/environmentView"; +import IBMi from "../api/IBMi"; type ConnectionProfileData = { homeDirectory: string @@ -49,34 +50,44 @@ export function editConnectionProfile(profile: AnyConnectionProfile, doAfterSave } async function save(profile: AnyConnectionProfile, data: ConnectionProfileData) { - const content = instance.getConnection()?.getContent(); - if (content) { - profile.homeDirectory = data.homeDirectory.trim(); - profile.setLibraryListCommand = data.setLibraryListCommand.trim(); - - data.currentLibrary = data.currentLibrary.trim(); - if (data.currentLibrary) { - if (await content.checkObject({ library: "QSYS", name: data.currentLibrary, type: "*LIB" })) { - profile.currentLibrary = data.currentLibrary; + const connection = instance.getConnection(); + if (connection) { + const content = connection.getContent(); + const config = connection.getConfig(); + const isActive = isActiveProfile(profile); + if (isActive && profile.type === `server`) { + config.setLibraryListCommand = data.setLibraryListCommand.trim(); + await IBMi.connectionManager.update(config); + } else { + if (profile.type === `local`) { + profile.homeDirectory = data.homeDirectory.trim(); } - else { - throw new Error(l10n.t("Current library {0} is invalid", data.currentLibrary)); + profile.setLibraryListCommand = data.setLibraryListCommand.trim(); + + data.currentLibrary = data.currentLibrary.trim(); + if (data.currentLibrary) { + if (await content.checkObject({ library: "QSYS", name: data.currentLibrary, type: "*LIB" })) { + profile.currentLibrary = data.currentLibrary; + } + else { + throw new Error(l10n.t("Current library {0} is invalid", data.currentLibrary)); + } } - } - const libraryList = data.libraryList.split(',').map(library => library.trim()); - const badLibraries = await content.validateLibraryList(libraryList); - if (badLibraries.length && !await vscode.window.showWarningMessage(l10n.t("The following libraries are invalid. Do you still want to save that profile?"), { - modal: true, - detail: badLibraries.sort().map(library => `- ${library}`).join("\n") - }, l10n.t("Yes"))) { - throw new Error(l10n.t("Save aborted")); - } - profile.libraryList = libraryList; + const libraryList = data.libraryList.split(',').map(library => library.trim()); + const badLibraries = await content.validateLibraryList(libraryList); + if (badLibraries.length && !await vscode.window.showWarningMessage(l10n.t("The following libraries are invalid. Do you still want to save that profile?"), { + modal: true, + detail: badLibraries.sort().map(library => `- ${library}`).join("\n") + }, l10n.t("Yes"))) { + throw new Error(l10n.t("Save aborted")); + } + profile.libraryList = libraryList; - const canProceed = await verifyLatestServerProfileState(profile); - if (canProceed) { - await updateConnectionProfile(profile); + const canProceed = await verifyLatestServerProfileState(profile); + if (canProceed) { + await updateConnectionProfile(profile); + } } } } \ No newline at end of file From 5ec5e1474cf634368f688a85bef3543c0f9624e6 Mon Sep 17 00:00:00 2001 From: Sanjula Ganepola Date: Mon, 9 Feb 2026 18:15:34 -0500 Subject: [PATCH 09/13] Remove command to inspect changes Signed-off-by: Sanjula Ganepola --- package.json | 15 --- src/ui/views/environment/environmentView.ts | 117 -------------------- 2 files changed, 132 deletions(-) diff --git a/package.json b/package.json index f563be078..ba7d7aa77 100644 --- a/package.json +++ b/package.json @@ -1799,12 +1799,6 @@ "category": "IBM i", "title": "Discard Changes and Sync with Server" }, - { - "command": "code-for-ibmi.resolveProfile.inspectChanges", - "enablement": "code-for-ibmi:connected", - "category": "IBM i", - "title": "Inspect Changes" - }, { "command": "code-for-ibmi.resolveProfile.overwriteChangesToServer", "enablement": "code-for-ibmi:connected", @@ -2112,11 +2106,6 @@ "when": "viewItem =~ /^profileItem_active.*_(Modified|Out of Sync)/", "group": "00_resolve@02" }, - { - "command": "code-for-ibmi.resolveProfile.inspectChanges", - "when": "viewItem =~ /^profileItem_active.*_(Modified|Out of Sync)/", - "group": "00_resolve@03" - }, { "command": "code-for-ibmi.resolveProfile.overwriteChangesToServer", "when": "viewItem =~ /^profileItem_active.*_Out of Sync/", @@ -2596,10 +2585,6 @@ "command": "code-for-ibmi.resolveProfile.discardChangesAndSyncWithServer", "when": "never" }, - { - "command": "code-for-ibmi.resolveProfile.inspectChanges", - "when": "never" - }, { "command": "code-for-ibmi.resolveProfile.overwriteChangesToServer", "when": "never" diff --git a/src/ui/views/environment/environmentView.ts b/src/ui/views/environment/environmentView.ts index 6b9331406..157de912d 100644 --- a/src/ui/views/environment/environmentView.ts +++ b/src/ui/views/environment/environmentView.ts @@ -502,123 +502,6 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { } }); }), - vscode.commands.registerCommand("code-for-ibmi.resolveProfile.inspectChanges", async (item: ProfileItem) => { - await vscode.window.withProgress({ location: { viewId: `environmentView` } }, async (progress) => { - const connection = instance.getConnection(); - if (connection) { - const canProceed = await verifyLatestServerProfileState(item.profile); - if (canProceed) { - const config = connection.getConfig(); - const profile = item.profile; - - // Get the profiles config file path - const profilesConfigFile = connection.getConfigFile(`profiles`); - const serverPath = profilesConfigFile.getPaths().server; - const serverUri = vscode.Uri.from({ scheme: 'streamfile', path: serverPath }); - - // Open the server profiles file - let textDocument: vscode.TextDocument; - try { - textDocument = await vscode.workspace.openTextDocument(serverUri); - } catch (error: any) { - vscode.window.showErrorMessage(l10n.t("Failed to open server profiles file: {0}", error.message)); - return; - } - - const content = textDocument.getText(); - let profiles: ConnectionProfile[]; - try { - profiles = JSON.parse(content); - } catch (error: any) { - vscode.window.showErrorMessage(l10n.t("Failed to parse server profiles file: {0}", error.message)); - return; - } - - const profileIndex = profiles.findIndex(p => p.name === item.profile.name); - if (profileIndex === -1) { - vscode.window.showErrorMessage(l10n.t("Profile '{0}' not found in server profiles.", item.profile.name)); - return; - } - - // Build workspace edit with individual field changes using jsonc-parser - const edit = new vscode.WorkspaceEdit(); - - // Helper to create edit for a field - const createFieldEdit = (fieldName: string, newValue: any, label: string) => { - const path = [profileIndex, fieldName]; - const edits = modify(content, path, newValue, { formattingOptions: { tabSize: 4, insertSpaces: true } }); - - for (const jsonEdit of edits) { - const startPos = textDocument.positionAt(jsonEdit.offset); - const endPos = textDocument.positionAt(jsonEdit.offset + jsonEdit.length); - - edit.replace( - serverUri, - new vscode.Range(startPos, endPos), - jsonEdit.content, - { - label, - needsConfirmation: true, - iconPath: new vscode.ThemeIcon('edit') - } - ); - } - }; - - // Create edits for each changed field - if (config.currentLibrary !== profile.currentLibrary) { - createFieldEdit( - 'currentLibrary', - config.currentLibrary, - l10n.t("Current Library: {0} → {1}", profile.currentLibrary, config.currentLibrary) - ); - } - - if (JSON.stringify(config.libraryList) !== JSON.stringify(profile.libraryList)) { - createFieldEdit( - 'libraryList', - config.libraryList, - l10n.t("Library List: {0} items → {1} items", profile.libraryList.length, config.libraryList.length) - ); - } - - if (JSON.stringify(config.objectFilters) !== JSON.stringify(profile.objectFilters)) { - createFieldEdit( - 'objectFilters', - config.objectFilters, - l10n.t("Object Filters: {0} items → {1} items", profile.objectFilters.length, config.objectFilters.length) - ); - } - - if (JSON.stringify(config.ifsShortcuts) !== JSON.stringify(profile.ifsShortcuts)) { - createFieldEdit( - 'ifsShortcuts', - config.ifsShortcuts, - l10n.t("IFS Shortcuts: {0} items → {1} items", profile.ifsShortcuts.length, config.ifsShortcuts.length) - ); - } - - if (JSON.stringify(config.customVariables) !== JSON.stringify(profile.customVariables)) { - createFieldEdit( - 'customVariables', - config.customVariables, - l10n.t("Custom Variables: {0} items → {1} items", profile.customVariables.length, config.customVariables.length) - ); - } - - if (config.setLibraryListCommand !== profile.setLibraryListCommand) { - createFieldEdit( - 'setLibraryListCommand', - config.setLibraryListCommand, - l10n.t("Library List Command: {0} → {1}", profile.setLibraryListCommand || l10n.t("(none)"), config.setLibraryListCommand || l10n.t("(none)")) - ); - } - - await vscode.workspace.applyEdit(edit, { isRefactoring: true }); - } - } - }); - }), vscode.commands.registerCommand("code-for-ibmi.resolveProfile.overwriteChangesToServer", async (item: ProfileItem) => { vscode.commands.executeCommand("code-for-ibmi.resolveProfile.saveChangeToServer", item, true); }), From 6af7072111726f8449cbf288b17410a34f84907b Mon Sep 17 00:00:00 2001 From: Sanjula Ganepola Date: Mon, 9 Feb 2026 19:07:12 -0500 Subject: [PATCH 10/13] Remove actions config file Signed-off-by: Sanjula Ganepola --- src/api/IBMi.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/api/IBMi.ts b/src/api/IBMi.ts index 8ccf5210b..12b645298 100644 --- a/src/api/IBMi.ts +++ b/src/api/IBMi.ts @@ -67,7 +67,6 @@ interface ConnectionOptions { interface ConnectionConfigFiles { settings: ConfigFile; profiles: ConfigFile - actions: ConfigFile [key: string]: ConfigFile; } @@ -88,7 +87,6 @@ export default class IBMi { private configFiles: ConnectionConfigFiles = { settings: new ConfigFile(this, `settings`, {}), profiles: new ConfigFile(this, `profiles`, []), - actions: new ConfigFile(this, `actions`, []) }; /** From 741ef24cffe1320d09966bfe79cf451720c54342 Mon Sep 17 00:00:00 2001 From: Sanjula Ganepola Date: Mon, 9 Feb 2026 19:10:41 -0500 Subject: [PATCH 11/13] Remove unuesd code Signed-off-by: Sanjula Ganepola --- src/ui/views/environment/environmentView.ts | 59 --------------------- 1 file changed, 59 deletions(-) diff --git a/src/ui/views/environment/environmentView.ts b/src/ui/views/environment/environmentView.ts index 157de912d..6497ae9f0 100644 --- a/src/ui/views/environment/environmentView.ts +++ b/src/ui/views/environment/environmentView.ts @@ -559,65 +559,6 @@ class EnvironmentView implements vscode.TreeDataProvider { readonly onDidChangeTreeData = this.emitter.event; readonly actionsNode = new ActionsNode(); readonly profilesNode = new ProfilesNode(); - private static instance?: EnvironmentView; - private configChangeCallbacks: Map void>> = new Map(); - - constructor() { - EnvironmentView.instance = this; - } - - /** - * Register a callback to be invoked when specific configuration keys change - * @param keys Array of configuration keys to watch - * @param callback Function to call when any of the watched keys change - * @returns Disposable to unregister the callback - */ - static registerConfigChangeListener( - keys: string[], - callback: (key: string, oldValue: any, newValue: any) => void - ): vscode.Disposable { - if (!EnvironmentView.instance) { - throw new Error('EnvironmentView not initialized'); - } - - for (const key of keys) { - if (!EnvironmentView.instance.configChangeCallbacks.has(key)) { - EnvironmentView.instance.configChangeCallbacks.set(key, new Set()); - } - EnvironmentView.instance.configChangeCallbacks.get(key)!.add(callback); - } - - return new vscode.Disposable(() => { - if (EnvironmentView.instance) { - for (const key of keys) { - EnvironmentView.instance.configChangeCallbacks.get(key)?.delete(callback); - } - } - }); - } - - /** - * Notify registered callbacks about configuration changes - * @param changes Map of changed keys with their old and new values - */ - static notifyConfigChanges(changes: Map) { - if (!EnvironmentView.instance) { - return; - } - - for (const [key, { oldValue, newValue }] of changes) { - const callbacks = EnvironmentView.instance.configChangeCallbacks.get(key); - if (callbacks) { - for (const callback of callbacks) { - try { - callback(key, oldValue, newValue); - } catch (error) { - console.error(`Error in config change callback for key '${key}':`, error); - } - } - } - } - } refresh(target?: BrowserItem) { this.emitter.fire(target); From 50e417235e334eb4c46e23ec11caefe24d42976f Mon Sep 17 00:00:00 2001 From: Sanjula Ganepola Date: Mon, 9 Feb 2026 22:35:28 -0500 Subject: [PATCH 12/13] Set active profile status on environemtn view Signed-off-by: Sanjula Ganepola --- src/ui/views/environment/connectionProfiles.ts | 2 +- src/ui/views/environment/environmentView.ts | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/ui/views/environment/connectionProfiles.ts b/src/ui/views/environment/connectionProfiles.ts index e84df26ac..e0f117b2b 100644 --- a/src/ui/views/environment/connectionProfiles.ts +++ b/src/ui/views/environment/connectionProfiles.ts @@ -53,7 +53,7 @@ export class ProfileItem extends EnvironmentItem { super(profile.name, { parent, icon: icon, color }); this.contextValue = `${ProfileItem.contextValue}${active ? '_active' : ''}${profile.setLibraryListCommand ? '_command' : ''}${profile.type === 'server' ? `_${profile.state}` : ''}`; - this.description = active ? l10n.t(`Active profile`) : ``; + this.description = active ? l10n.t(`Active`) : ``; if (active && profile.type === 'server') { this.description = this.description ? `${this.description} (${profile.state})` : `(${profile.state})`; } diff --git a/src/ui/views/environment/environmentView.ts b/src/ui/views/environment/environmentView.ts index 6497ae9f0..2c67f3094 100644 --- a/src/ui/views/environment/environmentView.ts +++ b/src/ui/views/environment/environmentView.ts @@ -35,6 +35,18 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { localActionsWatcher.onDidChange(() => environmentView.actionsNode?.forceRefresh()); localActionsWatcher.onDidDelete(() => environmentView.actionsNode?.forceRefresh()); + + instance.subscribe(context, "connected", "Update active profile status on environment view", () => { + const connection = instance.getConnection(); + if (connection) { + const config = connection.getConfig(); + const currentProfile = config.currentProfile; + if (currentProfile) { + updateUIContext(currentProfile); + } + } + }); + context.subscriptions.push( environmentTreeViewer, localActionsWatcher, @@ -285,7 +297,7 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { } await updateConnectionProfile(profile); - + // Do an explicit refresh as creation / copy of a profile doesn't impact the active profile so onCodeForIBMiConfigurationChange is not called environmentView.refresh(); From 18591359b39c14c9d3457aebf70e324e6b77a92b Mon Sep 17 00:00:00 2001 From: Sanjula Ganepola Date: Tue, 10 Feb 2026 09:23:07 -0500 Subject: [PATCH 13/13] Fix no active profile set at the start Signed-off-by: Sanjula Ganepola --- src/ui/views/environment/environmentView.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ui/views/environment/environmentView.ts b/src/ui/views/environment/environmentView.ts index 2c67f3094..da191fe22 100644 --- a/src/ui/views/environment/environmentView.ts +++ b/src/ui/views/environment/environmentView.ts @@ -41,9 +41,7 @@ export function initializeEnvironmentView(context: vscode.ExtensionContext) { if (connection) { const config = connection.getConfig(); const currentProfile = config.currentProfile; - if (currentProfile) { - updateUIContext(currentProfile); - } + updateUIContext(currentProfile); } });