diff --git a/src/content/docs/dev/api.md b/src/content/docs/dev/api.md index fd778c5..f8f4333 100644 --- a/src/content/docs/dev/api.md +++ b/src/content/docs/dev/api.md @@ -2,6 +2,9 @@ title: API sidebar: order: 3 + badge: + text: Updated + variant: success --- It is possible to write VS Code extensions that are based on Code for IBM i. That means your extension can use the connection that the user creates in your extension. This is not an extension tutorial, but an intro on how to access the APIs available within Code for IBM i. @@ -67,6 +70,269 @@ export function getInstance(): Instance|undefined { } ``` +# API surface + +The object returned by `getExtension('halcyontechltd.code-for-ibmi').exports` implements the `CodeForIBMi` interface. Beside `instance`, it exposes a number of helpers that are used by Code for IBM i itself, so extensions built on top of it can render, search, deploy and configure things exactly the same way. + +| Member | Type | Description | +|--------|------|-------------| +| `instance` | `Instance` | The connection: connect/disconnect, current `IBMi` connection, storage and event subscriptions. | +| `customUI` | `() => CustomUI` | Builder for webview forms and panels. | +| `customEditor` | `(target, onSave, onClosed?) => CustomEditor` | Builder for a custom editor (a webview opened as an editor tab, with save support). | +| `evfeventParser` | `(lines: string[]) => Map` | Parses the content of an `EVFEVENT` member into errors, grouped by file. | +| `tools` | `typeof VscodeTools` | VS Code side utilities: tooltips, HTML escaping, document/tab lookup, path helpers. | +| `frontendTables` | `typeof FrontendTables` | HTML generators for the tables used in the webviews (list tables and detail tables). | +| `viewSettings` | `typeof ViewSettings` | Reads the settings that shape the shared views (page size, auto refresh interval). | +| `deployTools` | `typeof DeployTools` | Deployment of a workspace folder to the IFS. | +| `actionTools` | `typeof ActionTools` | Reads and updates the Actions (local and connection ones). | +| `componentRegistry` | `ComponentRegistry` | Register your own components so they get installed and checked on connect. | +| `connectionManager` | `ConnectionManager` | Stored connections and global/connection settings. | +| `searchTools` | `typeof SearchTools` | Search in source members and in the IFS. | +| `onCodeForIBMiConfigurationChange` | `(props, todo) => Disposable` | Subscribe to changes of one or more `code-for-ibmi.*` settings. | + +All the examples below assume the `loadBase()` / `getInstance()` helpers shown above. + +## `instance` + +The entry point to the connection. Use `getConnection()` to get the current `IBMi` object (it returns `undefined` when there is no connection), and `subscribe` to react to connection events. + +```ts +const instance = getInstance(); + +const connection = instance.getConnection(); +if (connection) { + const config = connection.getConfig(); + const content = connection.getContent(); +} + +// Each context/name couple must be unique. +instance.subscribe(context, `connected`, `Refresh my view`, () => myView.refresh()); +``` + +The available events are `connected`, `disconnected`, `deployLocation` and `deploy`. See [API Examples](../examples/) for more. + +:::note +`Instance#getConfig`, `Instance#getContent` and `Instance#onEvent` are deprecated: use `IBMi#getConfig`, `IBMi#getContent` and `Instance#subscribe` instead. +::: + +## `customUI` + +Creates a `CustomUI`, the builder used to produce the webview forms of Code for IBM i (the Settings page, the login page, and so on). Fields are added with the `add*` methods and the page is opened with `loadPage`, which resolves when the user submits it. + +```ts +const base = loadBase(); + +const page = await base.customUI() + .addHeading(`My tool`, 2) + .addInput(`library`, `Library`, `The library to work on`, { default: `QGPL` }) + .addSelect(`mode`, `Mode`, [ + { text: `Read only`, description: `Do not change anything`, value: `read` }, + { text: `Read/write`, description: `Apply the changes`, value: `write` } + ]) + .addButtons({ id: `apply`, label: `Apply`, requiresValidation: true }) + .loadPage<{ library: string, mode: string }>(`My tool`); + +if (page) { + page.panel.dispose(); + if (page.data) { + // page.data.library, page.data.mode + } +} +``` + +`loadPage` returns `undefined` when a page with the same title is already open: in that case the existing panel simply gets the focus. + +## `customEditor` + +Same builder as `customUI` (it extends the same HTML builder), but the result is opened as an editor tab instead of a webview panel, and the data is pushed back through a callback whenever the user saves. + +```ts +const editor = base.customEditor( + `my-extension.myThing`, // target: identifies what is being edited + async data => await save(data), + () => console.log(`closed`) +); + +editor.addInput(`text`, `Text`); +editor.open(); +``` + +## `evfeventParser` + +Turns the lines of an `EVFEVENT` member into a map of `FileError[]`, keyed by the file the errors belong to. Useful when you run your own compilation and want to show the errors the way Code for IBM i does. + +```ts +const content = await connection.getContent().downloadMemberContent(library, `EVFEVENT`, sourceName); +const errors = base.evfeventParser(content.split(`\n`)); +``` + +## `tools` + +`VscodeTools` groups the helpers that need the VS Code namespace, plus the re-exported path/string helpers of the core `Tools`: + +| Function | Description | +|----------|-------------| +| `getGitAPI()` | Get VS Code's [Git extension API](https://github.com/microsoft/vscode/tree/main/extensions/git#api), when available. | +| `escapeHtml(html)` | Escapes a string before injecting it in a webview. | +| `generateTooltipHtmlTable(header, rows)` | Builds the HTML table used in the tree items tooltips. | +| `objectToToolTip(path, object)`, `memberToToolTip`, `ifsFileToToolTip`, `filterToToolTip`, `profileToToolTip` | Ready-made tooltips for the standard IBM i entities. | +| `findExistingDocument(uri)`, `findExistingDocumentUri(uri)`, `findExistingDocumentByName(nameAndExt)`, `findUriTabs(uri)` | Look up documents and tabs already opened. | +| `areEquivalentUris(a, b)` | Compares two URIs ignoring the query part. | +| `withContext(context, task)` | Runs `task` while a `when` clause context is set to `true`. | +| `md5Hash(file)`, `parseStatusBarColor(color)`, `includesCaseInsensitive(haystack, needle)` | Misc. helpers. | +| `qualifyPath`, `unqualifyPath`, `escapePath`, `parseQSysPath`, `normalizePath`, `resolvePath`, `fixWindowsPath`, `fileToPath`, `ensureFullPath` | Path helpers re-exported from the core `Tools`. | +| `distinct`, `capitalize`, `makeid`, `sanitizeObjNamesForPase`, `parseMessages`, `parseAttrDate` | String/array helpers re-exported from the core `Tools`. | + +## `frontendTables` + +Generates the HTML of the tables used in the Code for IBM i webviews, so an extension can render lists and detail pages with the same look, theming and behaviour. + +* `generateFastTable(options)` – a full page with a data table. Supports sticky header, collapsible columns (shown in a modal), a search bar and pagination. +* `generateFastTableUpdate(options)` – builds the message to post to the webview to replace the rows of a table already on screen, without rebuilding the page. +* `generateDetailTable(options)` – a key/value detail page, with optional action buttons. + +```ts +const html = base.frontendTables.generateFastTable({ + title: `Members`, + subtitle: `${members.length} members`, + columns: [ + { title: `Name`, getValue: m => m.name, width: `1fr` }, + { title: `Type`, getValue: m => m.extension, width: `1fr` }, + { title: `Text`, getValue: m => m.text, width: `3fr` } + ], + data: members, + enableSearch: true, + enablePagination: true, + tableId: `members` +}); + +panel.webview.html = html; +``` + +Search and pagination are server-side: the webview posts a `search` or `paginate` message (carrying `tableId`, `searchTerm`, `page` and `itemsPerPage`) and expects the extension to answer with the payload built by `generateFastTableUpdate`: + +```ts +panel.webview.onDidReceiveMessage(async message => { + if (message.command === `search` || message.command === `paginate`) { + const rows = await fetchPage(message.searchTerm, message.page, message.itemsPerPage); + panel.webview.postMessage(base.frontendTables.generateFastTableUpdate({ + columns, + data: rows.items, + totalItems: rows.total, + currentPage: message.page, + tableId: `members` // must be the same id the table was rendered with + })); + } +}); +``` + +:::caution +A table that has to be updated must be rendered with an explicit `tableId`: updates carrying a different id are discarded by every table on the page. Tables without a `tableId` get an auto-generated one, which is fine for display-only tables. +::: + +## `viewSettings` + +Reads the settings that shape the shared views, applying the same defaults and bounds as Code for IBM i. Reading the settings directly, or clamping them on your side, is what makes a table render pages of one size while its query fetches another. + +| Function | Setting | Description | +|----------|---------|-------------| +| `getItemsPerPage()` | `code-for-ibmi.tables.itemsPerPage` | Page size for the paginated tables. Defaults to `50` and is never lower than `30`. Use it for your own `LIMIT`/`OFFSET` too. | +| `getAutoRefreshInterval()` | `code-for-ibmi.views.autoRefreshInterval` | Auto refresh interval **in milliseconds** (the setting is in seconds). Returns `0` when auto refresh is disabled. | + +```ts +const pageSize = base.viewSettings.getItemsPerPage(); +const rows = await connection.runSQL(`select * from ${table} limit ${pageSize} offset ${page * pageSize}`); + +const interval = base.viewSettings.getAutoRefreshInterval(); +if (interval > 0) { + timer = setInterval(() => refresh(), interval); +} +``` + +## `deployTools` + +Deploys a workspace folder to its remote directory on the IFS. + +| Function | Description | +|----------|-------------| +| `launchDeploy(workspaceIndex?, method?, selectedFiles?)` | Runs the deployment interactively (prompting for what is missing) and returns the remote directory and workspace id. | +| `deploy(parameters)` | Runs a deployment described by a `DeploymentParameters` object. | +| `getRemoteDeployDirectory(workspaceFolder)` | The remote directory currently associated with the folder. | +| `setDeployLocation(node, workspaceFolder?, value?, method?, selectedFiles?)` | Sets the remote directory of a folder. | +| `getDeployChangedFiles`, `getDeployGitFiles`, `getDeployCompareFiles`, `getDeployAllFiles` | The file lists behind each deployment method. | +| `launchActionsSetup(workspaceFolder?)` | Creates the `.vscode/actions.json` file from a template. | +| `getDefaultIgnoreRules(workspaceFolder)`, `buildPossibleDeploymentDirectory(workspace)` | Ignore rules and default remote directory. | + +## `actionTools` + +Reads and writes the Actions, both the local ones (`.vscode/actions.json` of a workspace folder) and the ones stored in the connection settings. + +```ts +// All the actions available for a workspace folder (local + connection) +const actions = await base.actionTools.getActions(workspaceFolder); + +// Only the ones stored in the connection settings +const connectionActions = base.actionTools.getConnectionActions(); + +// Create, rename or delete an action +await base.actionTools.updateAction(action, workspaceFolder, { newName: `New name` }); +await base.actionTools.updateAction(action, workspaceFolder, { delete: true }); +``` + +## `componentRegistry` + +Registers an `IBMiComponent` provided by your extension. Registered components are installed and checked by Code for IBM i on every connection, and can then be retrieved from the connection. + +```ts +base.componentRegistry.registerComponent(context, new MyComponent()); + +// later, once connected +const component = await connection.getComponent(MyComponent.ID); +``` + +## `connectionManager` + +Access to the stored connections and to the settings. + +```ts +// Stored connections +const connections = base.connectionManager.getAll(); +const found = base.connectionManager.getByName(`My IBM i`); + +// Settings +const itemsPerPage = base.connectionManager.get(`tables.itemsPerPage`); +await base.connectionManager.set(`myKey`, myValue); + +// Per connection settings +const settings = base.connectionManager.getConnectionSettings(); +``` + +## `searchTools` + +The search used by the *Search* view, usable on your own selections. + +| Function | Description | +|----------|-------------| +| `searchMembers(connection, library, sourceFile, searchTerm, members, readOnly?)` | Searches a term in source members. `members` is either a generic name (e.g. `QRPGLESRC`, `*`) or a list of `IBMiMember`. | +| `searchIFS(connection, path, searchTerm)` | Searches a term in the content of the files under a directory. | +| `findIFS(connection, path, findTerm)` | Finds files by name under a directory. | + +```ts +const results = await base.searchTools.searchMembers(connection, `MYLIB`, `QRPGLESRC`, `EXEC SQL`, `*`); +``` + +## `onCodeForIBMiConfigurationChange` + +Subscribes to the change of one or more `code-for-ibmi.*` settings (the prefix is added for you). Returns a `Disposable`, so it can be pushed to your subscriptions. + +```ts +context.subscriptions.push( + base.onCodeForIBMiConfigurationChange([`tables.itemsPerPage`, `views.autoRefreshInterval`], () => { + myView.refresh(); + }) +); +``` + ## Outside of VS Code **This is not production ready**. diff --git a/src/content/docs/dev/examples.mdx b/src/content/docs/dev/examples.mdx index 90c2b9a..e67505d 100644 --- a/src/content/docs/dev/examples.mdx +++ b/src/content/docs/dev/examples.mdx @@ -2,22 +2,30 @@ title: API Examples sidebar: order: 3 + badge: + text: Updated + variant: success --- import { Aside, Icon } from '@astrojs/starlight/components'; ## Event listener -The Code for IBM i API provides an event listener. This allows your extension to fire an event when something happens in Code for IBM i. +The Code for IBM i API provides an event listener. This allows your extension to react when something happens in Code for IBM i. ```ts const instance = getInstance(); -instance.onEvent(`connected`, () => { +// Each context/name couple must be unique. +instance.subscribe(context, `connected`, `Log the connection`, () => { console.log(`It connected!`); }); ``` +:::note +`Instance#onEvent` is deprecated: use `Instance#subscribe` instead. +::: + ### Available events | ID | Event | @@ -29,17 +37,23 @@ instance.onEvent(`connected`, () => { ## Running commands with the user library list -Code for IBM i ships an API (via VS Code command) that can be used by an extension to execute a remote command on the IBM i. +`IBMi#runCommand` can be used by an extension to execute a remote command on the IBM i. It has a parameter which is an object with some properties. When executing a command in the `ile` or `qsh` environment, it will use the library list from the current connection. ```ts -interface CommandInfo { +interface RemoteCommand { + command: string; /** describes what environment the command will be executed. Is optional and defaults to `ile` */ environment?: `pase`|`ile`|`qsh`; /** set this as the working directory for the command when it is executed. Is optional and defaults to the users working directory in Code for IBM i. */ cwd?: string; - command: string; + /** the variables made available to the command; also accepts `&LIBL` and `&CURLIB` in the `ile` environment */ + env?: Record; + /** when `true`, the command is run without the user library list */ + noLibList?: boolean; + /** when `true`, the spooled files produced by the command are returned in the result */ + getSpooledFiles?: boolean; } ``` @@ -54,7 +68,7 @@ interface CommandResult { ``` ```ts -const rows = await instance.getConnection().runCommand({ +const result = await instance.getConnection().runCommand({ command: `WRKACTJOB`, environment: `ile` }); @@ -63,12 +77,12 @@ const rows = await instance.getConnection().runCommand({ You can also provide a custom library list and current library when executing a command in the `ile` environment: ```ts -const detail: CommandResult = { +const detail: RemoteCommand = { environment: `ile`, command: `CRTBNDRPG...`, env: { // Space delimited library list - '&LIBL': 'LIBA LIBB LIBC' + '&LIBL': 'LIBA LIBB LIBC', '&CURLIB': 'LIBD' } } @@ -140,6 +154,490 @@ if (connected) { } ``` +# Real world examples + +The snippets below are taken from two extensions that are built on top of Code for IBM i and use most of the [API surface](../api/): + +* [Db2 for IBM i](../../extensions/db2i/) — [codefori/vscode-db2i](https://github.com/codefori/vscode-db2i) +* [IBM i File System](../../extensions/ibmi-fs/) — [codefori/vscode-ibmi-fs](https://github.com/codefori/vscode-ibmi-fs) + +## Activating the base extension and registering components + +Db2 for IBM i activates Code for IBM i itself (instead of assuming it is already active) and immediately registers its own components, so they get installed and checked on every connection. + +```ts +// vscode-db2i, src/base.ts +let baseExtension: CodeForIBMi; + +export async function loadBase(context: ExtensionContext) { + const code4iExtension = extensions.getExtension(`halcyontechltd.code-for-ibmi`); + if (code4iExtension) { + baseExtension = code4iExtension.isActive ? code4iExtension.exports : await code4iExtension.activate(); + + const componentRegistry = baseExtension.componentRegistry; + componentRegistry.registerComponent(context, new ValidateStatementComponent()); + componentRegistry.registerComponent(context, new CheckStatementComponent()); + } + else { + // This cannot happen since the dependency is in package.json + throw new Error(`${context.extension.id} requires halcyontechltd.code-for-ibmi extension`); + } +} + +export function getBase(): CodeForIBMi { + return baseExtension; +} + +export function getInstance(): Instance { + return baseExtension.instance; +} +``` + +A component describes how it is detected on the system and how it is installed. Code for IBM i calls `getRemoteState` on connect and `update` when the component is missing or out of date. + +```ts +// vscode-db2i, src/connection/components/checkStatement.ts +export class CheckStatementComponent implements IBMiComponent { + static ID = "CheckStatementComponent"; + private static readonly VERSION = 1; + + getIdentification() { + return { name: CheckStatementComponent.ID, version: CheckStatementComponent.VERSION, signature: `QSYS/QSQCHKS` }; + } + + async getRemoteState(connection: IBMi, installDirectory: string): Promise { + const remoteSignature = await connection.getContent().getSQLRoutineSignature(library, functionName, `PROCEDURE`); + return { status: remoteSignature ? "Installed" : "NotInstalled", remoteSignature }; + } + + async update(connection: IBMi, installDirectory: string): Promise { + return connection.withTempDirectory(async tempDir => { + const srcPath = getVSCodeTools().ensureFullPath(posix.join(tempDir, `sqlchecker.sql`), connection.getConfig().homeDirectory); + await connection.getContent().writeStreamfileRaw(srcPath, this.getSource(library, functionName, version)); + + const result = await connection.runCommand({ + command: `QSYS/RUNSQLSTM SRCSTMF('${srcPath}') COMMIT(*NONE) NAMING(*SYS) DFTRDBCOL(${library})`, + cwd: `/`, + noLibList: true, + getSpooledFiles: true + }); + + if (result.code !== 0) { + throw Error(result.stderr || result.stdout); + } + + return this.getRemoteState(connection, installDirectory); + }); + } +} +``` + +Once connected, the component is retrieved from the connection with `connection.getComponent(CheckStatementComponent.ID)`. + +## Running remote commands + +Both extensions use `IBMi#runCommand` for everything that has no SQL equivalent. The pattern is always the same: check there is a connection, build the `RemoteCommand`, then branch on `CommandResult.code` — `0` means the command completed. In the `ile` environment `stderr` carries the job log messages produced by the command, which is what these extensions show to the user when something fails. + +```ts +// vscode-ibmi-fs, src/commonOperations.ts +export const holdJob = async (jobId: JobIdentifier, showConfirmation: boolean = true): Promise => { + const connection = getInstance()?.getConnection(); + + if (!connection) { + vscode.window.showErrorMessage(vscode.l10n.t("Not connected to IBM i")); + return false; + } + + if (showConfirmation) { + const confirmed = await vscode.window.showWarningMessage( + vscode.l10n.t("Are you sure you want to hold job {0}?", jobId.job), + { modal: true }, + vscode.l10n.t("Hold job") + ); + if (!confirmed) { + return false; + } + } + + try { + const cmdrun: CommandResult = await connection.runCommand({ + command: `QSYS/HLDJOB JOB(${jobId.job})`, + environment: `ile` + }); + + if (cmdrun.code === 0) { + vscode.window.showInformationMessage(vscode.l10n.t("Job held.")); + return true; + } else { + vscode.window.showErrorMessage(vscode.l10n.t("Unable to hold selected job:\n{0}", String(cmdrun.stderr))); + return false; + } + } catch (error) { + vscode.window.showErrorMessage(vscode.l10n.t("Error holding job: {0}", String(error))); + return false; + } +}; +``` + + + +### Reading the output of a command + +A CL command that only writes a spooled file produces no `stdout` on its own: set `getSpooledFiles: true` and the spooled files are returned in `stdout`. IBM i File System uses this to get the library list of a job, since `DSPJOB` has no `OUTFILE` for `OPTION(*LIBL)`. + +```ts +// vscode-ibmi-fs, src/views/wrkjob.ts +const liblspl = await connection.runCommand({ + command: `QSYS/DSPJOB JOB(${jobName}) OPTION(*LIBL)`, + environment: `ile`, + getSpooledFiles: true, +}); + +if (!liblspl || !liblspl.stdout) { + return []; +} + +// Parse library list from spool output +// The output has fixed-width columns, so we parse by position +const lines = liblspl.stdout.split('\n'); +const libraries: LibraryEntry[] = []; + +for (const line of lines) { + const library = line.substring(3, 13).trim(); + const type = line.substring(15, 18).trim(); + const asp = line.substring(26, 36).trim(); + const description = line.substring(38, 90).trim(); + + // Skip separator lines + if (!library || library.startsWith('*')) { + continue; + } + + if (type === 'SYS' || type === 'CUR' || type === 'USR') { + libraries.push({ library, type, asp, description }); + } +} +``` + +### Chaining commands and using a temporary directory + +Several CL commands separated by a newline are executed in the same `ile` call, which keeps `QTEMP` and the job for the whole sequence. Combined with `withTempDirectory` — which removes the directory when the callback resolves — this copies a spooled file to the IFS and opens it with the `streamfile` file system. + +```ts +// vscode-ibmi-fs, src/commonOperations.ts +return connection.withTempDirectory(async (tempDir): Promise => { + const tempSourcePath = posix.join(tempDir, `${spoolId.job.replaceAll('/', '-')}_${spoolId.spoolname}_${spoolId.nbr}.txt`); + + const result = await connection.runCommand({ + command: `QSYS/CPYSPLF FILE(${spoolId.spoolname}) JOB(${spoolId.job}) SPLNBR(${spoolId.nbr}) TOFILE(*TOSTMF) TOSTMF('${tempSourcePath}') + QSYS/CPY OBJ('${tempSourcePath}') TOOBJ('${tempSourcePath}') TOCCSID(1208) DTAFMT(*TEXT) REPLACE(*YES)`, + environment: 'ile' + }); + + if (result.code === 0) { + const uri = vscode.Uri.parse(tempSourcePath).with({ scheme: `streamfile` }); + await vscode.commands.executeCommand(`vscode.open`, uri, { preview: false, preserveFocus: false }); + return true; + } else { + vscode.window.showErrorMessage(vscode.l10n.t("Error opening spool: {0}", String(result.stderr))); + return false; + } +}); +``` + +### The `pase` environment + +`environment: 'pase'` runs the command in the shell, which is the simplest way to do file operations on the IFS. Here it is used in a `finally` so the temporary file is removed even when the download fails. + +```ts +// vscode-ibmi-fs, src/commonOperations.ts +try { + await connection.client.getFile(saveLocation.fsPath, tempRemotePath); +} catch (error) { + result.successful = false; + result.error = String(error); +} finally { + // Clean up temporary file on IBM i + await connection.runCommand({ + command: `rm -f ${tempRemotePath}`, + environment: `pase` + }); +} +``` + +Db2 for IBM i uses the two environments side by side to execute the cells of a notebook: the `cl` cells go to `ile` with the spooled files, the `shellscript` cells to `pase`. + +```ts +// vscode-db2i, src/notebooks/Controller.ts +case `cl`: + try { + const command = await connection.runCommand({ + command: cell.document.getText(), + environment: `ile`, + getSpooledFiles: true + }); + + if (command.stdout) { + items.push(vscode.NotebookCellOutputItem.text([`\`\`\``, command.stdout, `\`\`\``].join(`\n`), `text/markdown`)); + } + + if (command.stderr) { + items.push(vscode.NotebookCellOutputItem.text([`\`\`\``, command.stderr, `\`\`\``].join(`\n`), `text/markdown`)); + } + } catch (e) { + items.push( + vscode.NotebookCellOutputItem.stderr(`Failed to run command. Are you connected?`), + vscode.NotebookCellOutputItem.stderr(e instanceof Error ? e.message : String(e)) + ); + } + break; + +case `shellscript`: + const command = await connection.runCommand({ + command: cell.document.getText(), + environment: `pase` + }); +``` + + + +## Reacting to connection events + +Each `context`/`name` couple must be unique, so a view can subscribe once and be cleaned up with the extension context. + +```ts +// vscode-db2i, src/extension.ts +instance.subscribe(context, `connected`, `db2i-connected`, () => { + DbCache.resetCache(); + onCode4iConnect().then(async () => { + schemaBrowser.clearCacheAndRefresh(); + exampleBrowser.refresh(); + queryHistory.refresh(); + }); +}); + +// vscode-db2i, src/config.ts +getInstance().subscribe(context, `disconnected`, `db2i-disconnect`, async () => { + JobManagerView.setVisible(false); + await JobManager.endAll(true); + updateStatusBar(); +}); +``` + +## Reacting to setting changes + +```ts +// vscode-ibmi-fs, src/extension.ts +const base = loadBase(); +if (base) { + context.subscriptions.push( + base.onCodeForIBMiConfigurationChange("connectionSettings", () => updateFsActionsStatusBar()) + ); +} +``` + +## Reading the shared view settings + +IBM i File System reads the page size and the auto refresh interval through the base extension instead of reading the settings itself, so defaults and bounds stay in one place. + +```ts +// vscode-ibmi-fs, src/config.ts +export function getItemsPerPage(): number { + return loadBase()!.viewSettings.getItemsPerPage(); +} + +export function getAutoRefreshInterval(): number { + return loadBase()!.viewSettings.getAutoRefreshInterval(); +} +``` + + + +The interval is returned in milliseconds and is `0` when auto refresh is disabled: + +```ts +// vscode-ibmi-fs, src/views/wrkactjob.ts +const autoRefreshInterval = getAutoRefreshInterval(); +let autoRefreshTimer: NodeJS.Timeout | undefined; +// Guards against a tick starting while the previous query is still running +let refreshing = false; + +if (autoRefreshInterval > 0) { + autoRefreshTimer = setInterval(() => refresh(true), autoRefreshInterval); +} + +panel.onDidDispose(() => { + if (autoRefreshTimer) { + clearInterval(autoRefreshTimer); + autoRefreshTimer = undefined; + } +}); +``` + +## Rendering a searchable table + +`generateFastTable` builds the whole page, `generateFastTableUpdate` builds the message that replaces the rows of a table already on screen. Both need the same `tableId`. + +```ts +// vscode-ibmi-fs, src/views/wrkactjob.ts +const ACTJOB_TABLE_ID = 'wrkactjob-jobs'; + +const jobColumns: FastTableColumn[] = [ + { title: vscode.l10n.t("Subsystem"), width: "1fr", getValue: e => e.subsystem }, + { title: vscode.l10n.t("Job"), width: "1.5fr", getValue: e => e.job }, + { title: vscode.l10n.t("User"), width: "0.7fr", getValue: e => e.user }, + { title: vscode.l10n.t("Status"), width: "0.5fr", getValue: e => e.status }, + { + title: vscode.l10n.t("Actions"), + width: "2fr", + getValue: e => { + const arg = encodeURIComponent(JSON.stringify(e)); + return `${vscode.l10n.t("Details")}`; + } + } +]; + +panel.webview.html = generatePage(generateFastTable({ + title: vscode.l10n.t("Work with Active Jobs"), + subtitle: vscode.l10n.t("Total Active Jobs: {0}", String(activeJobs.length)), + columns: jobColumns, + data: activeJobs, + stickyHeader: true, + emptyMessage: vscode.l10n.t("No active jobs found."), + enableSearch: true, + searchPlaceholder: vscode.l10n.t("Search jobs..."), + searchTerm: searchTerm, + tableId: ACTJOB_TABLE_ID +})); +``` + +The search is server-side: the webview posts a `search` message and waits for an answer. Note that a failed query must be answered too, otherwise the table keeps spinning until its own safety timeout. + +```ts +// vscode-ibmi-fs, src/views/wrkactjob.ts +const postTableUpdate = async () => { + await panel.webview.postMessage(generateFastTableUpdate({ + columns: jobColumns, + data: activeJobs, + totalItems: activeJobs.length, + currentPage: 1, + subtitle: vscode.l10n.t("Total Active Jobs: {0}", String(activeJobs.length)), + tableId: ACTJOB_TABLE_ID + })); +}; + +panel.webview.onDidReceiveMessage(async message => { + if (message.command === 'search') { + if (message.searchTerm !== undefined) { + searchTerm = message.searchTerm; + } + + try { + const newJobs = await fetchActiveJobs(searchTerm); + if (newJobs) { + activeJobs = newJobs; + } + await postTableUpdate(); + } catch (error) { + // The webview spins its busy indicator until an answer arrives + vscode.window.showErrorMessage(vscode.l10n.t("Failed to load active jobs: {0}", String(error))); + await panel.webview.postMessage({ command: 'updateTableFailed', tableId: ACTJOB_TABLE_ID }); + } + return; + } + + // ...handle the `action:` hrefs posted by the buttons of the Actions column +}); +``` + +The rows themselves come from a plain `runSQL` on an IBM i service: + +```ts +const connection = getInstance()?.getConnection(); +const rows = await connection.runSQL(` + SELECT JOB_NAME, AUTHORIZATION_NAME, JOB_STATUS, SUBSYSTEM + FROM TABLE(QSYS2.ACTIVE_JOB_INFO(DETAILED_INFO => 'NONE', RESET_STATISTICS => 'NO')) x + ORDER BY x.SUBSYSTEM ASC +`); +``` + + + +## Rendering a detail page + +Object views use `generateDetailTable` for the key/value pages. `codeColumns` renders a value in a monospaced block. + +```ts +// vscode-ibmi-fs, src/types/dataArea.ts +generateHTML(): string { + return generateDetailTable({ + title: vscode.l10n.t("Data Area: {0}/{1}", this.library, this.name), + subtitle: vscode.l10n.t("Data Area Information"), + columns: this.columns, + data: this.dta, + codeColumns: ['DATA_AREA_VALUE'] + }); +} +``` + +## Building a form with `customUI` + +Db2 for IBM i builds the *Copy to file* prompt with the same builder Code for IBM i uses for its own pages. + +```ts +// vscode-db2i, src/views/schemaBrowser/copyUI.ts +export function getCopyUi() { + return getBase()!.customUI() + .addInput('toFile', 'To File', 'Name', { minlength: 1, maxlength: 10 }) + .addInput('toLib', 'Library', 'Name', { default: '*LIBL', minlength: 1, maxlength: 10 }) + .addInput('fromMbr', 'From member', 'Name, generic*, *FIRST, *ALL', { default: '*FIRST' }) + .addSelect('mbrOpt', 'Replace or add records', [ + { text: '*NONE', description: '*NONE', value: '*NONE' }, + { text: '*ADD', description: '*ADD', value: '*ADD' }, + { text: '*REPLACE', description: '*REPLACE', value: '*REPLACE' }, + { text: '*UPDADD', description: '*UPDADD', value: '*UPDADD' }, + ]) + .addButtons( + { id: 'copy', label: 'Copy', requiresValidation: true }, + { id: 'cancel', label: 'Cancel' } + ); +} +``` + # VS Code integration ## Right click options @@ -157,19 +655,18 @@ You would register a command as you'd normally expect, but expect a parameter fo ```ts context.subscriptions.push( // `node` is the object passed in directly from the IFS Browser. - vscode.commands.registerCommand(`code-for-ibmi.deleteIFS`, async (node) => { + vscode.commands.registerCommand(`your-extension.deleteIFS`, async (node) => { if (node) { //Running from right click let result = await vscode.window.showWarningMessage(`Are you sure you want to delete ${node.path}?`, `Yes`, `Cancel`); if (result === `Yes`) { - // directory using the connection API. const connection = instance.getConnection(); try { - // Run a pase command - await vscode.commands.executeCommand(`code-for-ibmi.runCommand`, { - command: `rm -rf "${node.path}`, + // Run a pase command with the connection API + await connection.runCommand({ + command: `rm -rf "${node.path}"`, environment: `pase`, }); @@ -188,11 +685,15 @@ context.subscriptions.push( ); ``` +:::caution +Register your commands under your own extension's identifier: `code-for-ibmi.*` is reserved by Code for IBM i, and registering an identifier that already exists throws an error. +::: + Following that, we need to register the command so it has a label. We do this in `package.json` ```json { - "command": "code-for-ibmi.deleteIFS", + "command": "your-extension.deleteIFS", "title": "Delete object", "category": "Your extension" } @@ -204,10 +705,10 @@ Finally, we add it to a context menu: "menus": { "view/item/context": [ { - "command": "code-for-ibmi.deleteIFS", + "command": "your-extension.deleteIFS", "when": "view == ifsBrowser", "group": "yourext@1" - }, + } ] } ``` @@ -216,8 +717,17 @@ Finally, we add it to a context menu: * `view` can be `ifsBrowser` or `objectBrowser`. * `viewItem` can be different depending on the view: - * for `ifsBrowser`, it can be `directory` or `streamfile` - * for `objectBrowser`, it can be `member` (source member), `object` (any object), `SPF` (source file) or `filter`. + * for `ifsBrowser`, it can be `directory`, `streamfile` or `shortcut` + * for `objectBrowser`, it can be `member` (source member), `object.` or `object..` (any object, e.g. `object.pgm.rpgle`), `SPF` (source file) or `filter`. + +Items that are read only or protected get a `_readonly` or `_protected` suffix (e.g. `member_readonly`, `directory_protected`), so use a regular expression when you want to match an item whatever its state: + +```json +{ + "command": "your-extension.deleteIFS", + "when": "view == ifsBrowser && viewItem =~ /^streamfile/" +} +``` This allows your extension to provide commands for specific types of objects or specific items in the treeview. @@ -225,7 +735,7 @@ This allows your extension to provide commands for specific types of objects or ## Views -Code for IBM i provides a context so you can control when a command, view, etc, can work. `code-for-ibmi.connected` can and should be used if your view depends on a connection. For example +Code for IBM i provides a context so you can control when a command, view, etc, can work. `code-for-ibmi:connected` can and should be used if your view depends on a connection. For example This will show a welcome view when there is no connection: @@ -252,10 +762,6 @@ This will show a view when there is a connection: # FAQs -## Getting the temporary library - -Please remember that you cannot use `QTEMP` between commands since each command runs in a new job. Please refer to `instance.getConfig().tempLibrary` for the user temporary library. - ## Is there a connection? You can use `instance.getConnection()` to determine if there is a connection: @@ -302,6 +808,8 @@ If you refer to the **Views** section, you can make it so the view is only shown See the following code bases for large examples of extensions that use Code for IBM i: +* [Db2 for IBM i](https://github.com/codefori/vscode-db2i) +* [IBM i File System](https://github.com/codefori/vscode-ibmi-fs) * [VS Code extension to manage IBM i IWS services](https://github.com/codefori/vscode-ibmi-iws) * [Git for IBM i extension](https://github.com/codefori/git-client-ibmi)