From 1cf64b4cdb3531b7cfe8e09a132965c68d6adcdc Mon Sep 17 00:00:00 2001 From: Andrea Buzzi <155985472+buzzia2001@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:57:25 +0200 Subject: [PATCH 1/3] Change the behavior of the views --- src/views/html/dataTable.ts | 43 ++++++- src/views/jobManager/selfCodes/sqlErrorLog.ts | 61 +++------- src/views/results/index.ts | 8 +- src/views/results/resultSetPanelProvider.ts | 66 +++++++--- src/views/schemaBrowser/advisedIndexes.ts | 114 ++++++++---------- src/views/schemaBrowser/indexCreation.ts | 12 +- src/views/schemaBrowser/mti.ts | 87 +++++-------- src/views/schemaBrowser/statements.ts | 2 +- 8 files changed, 195 insertions(+), 198 deletions(-) diff --git a/src/views/html/dataTable.ts b/src/views/html/dataTable.ts index f7134447..4da7a400 100644 --- a/src/views/html/dataTable.ts +++ b/src/views/html/dataTable.ts @@ -309,7 +309,13 @@ export function registerDataTable(options: DataTableOptions, handlers: Dat function rowActionContext(post: (message: any) => void, options: DataTableOptions, row: T): DataTableRowActionContext { return { removeRow: () => { - if (options.rows.includes(row)) { + const index = options.rows.indexOf(row); + if (index < 0) return; + + if (options.streaming) { + options.rows.splice(index, 1); + post({ command: `removeRow`, row: index }); + } else { updateDataTableRows(post, options, options.rows.filter(candidate => candidate !== row)); } }, @@ -1140,6 +1146,36 @@ export function renderDataTable(options: DataTableOptions, tableId = ``): MODEL.rows.push(...wireRows); } + function emptyStreamingText() { + return state.appliedQuery.trim() ? MODEL.emptyMessage : (MODEL.noRowsMessage || "No rows returned."); + } + + // Rows are append-only, so a row's index is also its position on screen + function removeStreamingRow(index) { + const rowEls = grid.querySelectorAll(":scope > .dt-row"); + if (!rowEls[index]) return; + rowEls[index].remove(); + MODEL.rows.splice(index, 1); + state.zebraCount = MODEL.rows.length; + + for (let i = index; i < MODEL.rows.length; i++) { + const rowEl = rowEls[i + 1]; + MODEL.rows[i].i = i; + rowEl.dataset.i = String(i); + rowEl.className = "dt-row " + (i % 2 ? "even" : "odd"); + if (rowEl.dataset.vscodeContext) { + const context = JSON.parse(rowEl.dataset.vscodeContext); + context.dtRow = i; + rowEl.dataset.vscodeContext = JSON.stringify(context); + } + } + + updateStreamingStatus({}); + if (MODEL.rows.length) fetchIfSentinelVisible(); + else if (state.noMoreRows) showStreamingMessage(emptyStreamingText(), true); + else if (!state.isFetching) requestFetch(state.allRows); + } + function requestFetch(allRows) { state.isFetching = true; if (MODEL.rows.length) el("dtStatus").textContent = "Loaded " + MODEL.rows.length + " rows. Fetching more…"; @@ -1484,13 +1520,16 @@ export function renderDataTable(options: DataTableOptions, tableId = ``): const affected = typeof data.updateCount === "number" && data.updateCount >= 0 ? data.updateCount : 0; showStreamingMessage("Statement executed with no result set returned. Rows affected: " + affected, false); } else { - showStreamingMessage(state.appliedQuery.trim() ? MODEL.emptyMessage : "No rows returned.", true); + showStreamingMessage(emptyStreamingText(), true); } } else { hideStreamingMessage(); fetchIfSentinelVisible(); } break; + case "removeRow": + removeStreamingRow(data.row); + break; case "cellResponse": if (data.id) handleCellResponse(data.id, data.success === true); break; diff --git a/src/views/jobManager/selfCodes/sqlErrorLog.ts b/src/views/jobManager/selfCodes/sqlErrorLog.ts index a9126dee..452eb183 100644 --- a/src/views/jobManager/selfCodes/sqlErrorLog.ts +++ b/src/views/jobManager/selfCodes/sqlErrorLog.ts @@ -1,10 +1,11 @@ +import { ColumnMetaData } from "@ibm/mapepire-js"; import * as vscode from "vscode"; import { getInstance } from "../../../base"; import { JobManager } from "../../../config"; import Statement from "../../../database/statement"; -import { DataTableColumn, DataTableHandlers, DataTableOptions } from "../../html/dataTable"; -import { showDataTable, showDataTableError, showDataTableLoading } from "../../results"; -import { formatTimestamp, listFooter, LoadStats, prettyColumnTitle } from "../../schemaBrowser/indexCreation"; +import { DataTableColumn } from "../../html/dataTable"; +import { showListing } from "../../results"; +import { formatTimestamp, isNumericType, prettyColumnTitle } from "../../schemaBrowser/indexCreation"; /** A row from `QSYS2.SQL_ERROR_LOG` */ interface SqlErrorLogEntry { @@ -61,21 +62,18 @@ const LEADING_COLUMNS = [`LOGGED_TIME`, `LOGGED_SQLCODE`, `LOGGED_SQLSTATE`, `NU const HIDDEN_COLUMNS = new Set([`INITIAL_STACK`]); -function sqlErrorLogColumns(entries: SqlErrorLogEntry[]): DataTableColumn[] { - const first = entries[0]; - if (!first) return []; - - const available = Object.keys(first).filter(column => !HIDDEN_COLUMNS.has(column)); +function sqlErrorLogColumns(columnMetaData: ColumnMetaData[]): DataTableColumn[] { + const available = columnMetaData.filter(column => !HIDDEN_COLUMNS.has(column.name)); const ordered = [ - ...LEADING_COLUMNS.filter(column => available.includes(column)), - ...available.filter(column => !LEADING_COLUMNS.includes(column)), + ...LEADING_COLUMNS.flatMap(name => available.filter(column => column.name === name)), + ...available.filter(column => !LEADING_COLUMNS.includes(column.name)), ]; return ordered.map(column => ({ - id: column, - title: prettyColumnTitle(column), - value: (entry: SqlErrorLogEntry) => formatColumnValue(entry, column), - align: typeof first[column] === `number` ? `right` : `left`, + id: column.name, + title: prettyColumnTitle(column.name), + value: (entry: SqlErrorLogEntry) => formatColumnValue(entry, column.name), + align: isNumericType(column.type) ? `right` : `left`, })); } @@ -142,46 +140,21 @@ export async function viewOtherSelfLogs(): Promise { if (!scope) return; const target = scopeTarget(scope); - const sql = sqlErrorLogStatement(scope); - const stats: LoadStats = { executionTimeMs: 0 }; - const load = async () => { - const startTime = performance.now(); - const entries = await JobManager.runSQL(sql); - stats.executionTimeMs = performance.now() - startTime; - stats.jobId = JobManager.getSelection()?.job.id; - return entries; - }; - let entries: SqlErrorLogEntry[]; - - try { - await showDataTableLoading(`Fetching the SQL error log for ${target}...`); - entries = await load(); - } catch (e: any) { - showDataTableError(e.message); - return; - } - const options: DataTableOptions = { - title: `SQL Error Log for ${target}`, - subtitle: (shown, total) => listFooter({ one: `logged error`, many: `logged errors` }, shown, total, stats), - columns: sqlErrorLogColumns(entries), - rows: entries, + await showListing(sqlErrorLogStatement(scope), { + heading: `SQL Error Log for ${target}`, + columns: sqlErrorLogColumns, + loadingText: `Fetching the SQL error log for ${target}...`, searchPlaceholder: `Search logged errors…`, emptyMessage: `No logged errors match the search.`, noRowsMessage: `No errors were logged by SELF for ${target}.`, actions: [ { id: SQL_ERROR_LOG_ACTIONS.openStatement, primary: true, when: entry => statementText(entry) !== undefined }, ], - }; - - const handlers: DataTableHandlers = { onAction: async (actionId, entry) => { if (actionId === SQL_ERROR_LOG_ACTIONS.openStatement) { await openStatement(entry); } }, - }; - - showDataTable(options, handlers, { sql, reload: load, columns: sqlErrorLogColumns }) - .catch(e => vscode.window.showErrorMessage(`Could not show the SQL error log: ${e?.message ?? e}`)); + }); } diff --git a/src/views/results/index.ts b/src/views/results/index.ts index 63937e8f..f7694f84 100644 --- a/src/views/results/index.ts +++ b/src/views/results/index.ts @@ -22,7 +22,7 @@ import { DoveResultsView, ExplainTreeItem } from "./explain/doveResultsView"; import { DoveTreeDecorationProvider } from "./explain/doveTreeDecorationProvider"; import { ExplainTree } from "./explain/nodes"; import { DataTableHandlers, DataTableOptions } from "../html/dataTable"; -import { DataTableExtras, ResultSetPanelProvider, SqlParameter } from "./resultSetPanelProvider"; +import { DataTableExtras, ResultSetListing, ResultSetPanelProvider, SqlParameter } from "./resultSetPanelProvider"; export type StatementQualifier = "statement" | "bind" | "update" | "explain" | "onlyexplain" | "json" | "csv" | "md" | "cl" | "sql" | "rpg" | "udtf"; @@ -89,6 +89,12 @@ export function showDataTable(options: DataTableOptions, handlers?: DataTa return resultSetProvider.showDataTable(options, handlers, extras); } +/** Run a statement in the Results view and show its rows as a listing (MTIs, advised indexes, …) */ +export async function showListing(sql: string, listing: ResultSetListing): Promise { + await resultSetProvider.ensureActivation(); + await resultSetProvider.setScrolling({ basicSelect: sql, title: listing.heading, listing }); +} + /** Loading placeholder in the Results view while a data table listing is fetched */ export function showDataTableLoading(text: string): Promise { return resultSetProvider.showDataTableLoading(text); diff --git a/src/views/results/resultSetPanelProvider.ts b/src/views/results/resultSetPanelProvider.ts index 89d970e4..887e5d91 100644 --- a/src/views/results/resultSetPanelProvider.ts +++ b/src/views/results/resultSetPanelProvider.ts @@ -1,6 +1,6 @@ import { CancellationToken, WebviewPanel, WebviewView, WebviewViewProvider, WebviewViewResolveContext, commands, env, window } from "vscode"; -import { QueryResult } from "@ibm/mapepire-js"; +import { ColumnMetaData, QueryResult } from "@ibm/mapepire-js"; import { Query } from "@ibm/mapepire-js/dist/src/query"; import { openResultSetPanel, setCancelButtonVisibility } from "."; import { JobManager } from "../../config"; @@ -14,6 +14,7 @@ import { DataTableColumn, DataTableHandlers, DataTableOptions, + DataTableRowAction, UpdatableInfo, appendDataTableRows, handleDataTableMessage, @@ -42,6 +43,20 @@ export interface ScrollerOptions { withCancel?: boolean; ref?: ObjectRef; title?: string; + listing?: ResultSetListing; +} + +/** A result set shown with its own columns and row actions (MTIs, advised indexes, …). Its rows are objects keyed by column name. */ +export interface ResultSetListing { + heading: string; + /** Column ids must be the SQL column names */ + columns: (columnMetaData: ColumnMetaData[]) => DataTableColumn[]; + actions?: DataTableRowAction[]; + onAction?: DataTableHandlers[`onAction`]; + loadingText?: string; + searchPlaceholder?: string; + emptyMessage?: string; + noRowsMessage?: string; } /** Toolbar actions of a data table listing */ @@ -126,6 +141,11 @@ function buildResultColumns(columnMetaData: any[], columnHeadings: string): Data })); } +function buildListingColumns(listing: ResultSetListing, columnMetaData: ColumnMetaData[]): DataTableColumn[] { + const unsortable = new Set(columnMetaData.filter(c => UNSORTABLE_TYPES.has(String(c.type).toUpperCase())).map(c => c.name)); + return listing.columns(columnMetaData).map(column => unsortable.has(column.id) ? { ...column, sortable: false } : column); +} + /** Types a "search all columns" `CAST(... AS VARCHAR(...))` can't meaningfully apply to */ const SEARCH_EXCLUDED_TYPES = new Set([ `BLOB`, `CLOB`, `DBCLOB`, `NCLOB`, `VARBIN`, `VARBINARY`, `BINARY`, `GRAPHIC`, `VARGRAPHIC`, `ROWID`, `DATALINK`, `XML`, `SQLXML`, @@ -141,8 +161,8 @@ function escapeLikeText(text: string): string { return text.replace(/\\/g, `\\\\`).replace(/%/g, `\\%`).replace(/_/g, `\\_`); } -/** SQL sent for the session's sort/search state */ -function buildQueryText(session: ResultSetSession): { sql: string; params: SqlParameter[] } { +/** SQL sent for the session's sort/search state. `inlineSearch` writes the searched text as a literal instead of binding it. */ +function buildQueryText(session: ResultSetSession, inlineSearch = false): { sql: string; params: SqlParameter[] } { const baseParams = session.options.parameters ?? []; if (!session.sort && !session.search) { return { sql: session.baseSelect, params: baseParams }; @@ -152,20 +172,23 @@ function buildQueryText(session: ResultSetSession): { sql: string; params: SqlPa let sql = `SELECT * FROM (${session.baseSelect}) AS "DTQ"`; if (session.search && session.columnMetaData) { - const searchable = session.columnMetaData.filter((c: any) => !SEARCH_EXCLUDED_TYPES.has(String(c.type).toUpperCase())); + // A listing can hide columns + const shown = new Set(session.dtOptions.columns.map(c => c.id)); + const searchable = session.columnMetaData.filter((c: any) => shown.has(c.name) && !SEARCH_EXCLUDED_TYPES.has(String(c.type).toUpperCase())); if (searchable.length > 0) { const likeText = `%${escapeLikeText(session.search)}%`; + const pattern = inlineSearch ? `'${likeText.replace(/'/g, `''`)}'` : `?`; // Cast to CCSID 37 const clauses = searchable.map((c: any) => { - params.push(likeText); - return `UPPER(CAST(${quoteIdent(c.name)} AS VARCHAR(1024) CCSID 37)) LIKE UPPER(CAST(? AS VARCHAR(1024) CCSID 37)) ESCAPE '\\'`; + if (!inlineSearch) params.push(likeText); + return `UPPER(CAST(${quoteIdent(c.name)} AS VARCHAR(1024) CCSID 37)) LIKE UPPER(CAST(${pattern} AS VARCHAR(1024) CCSID 37)) ESCAPE '\\'`; }); sql += ` WHERE ${clauses.join(` OR `)}`; } } if (session.sort) { - const index = session.dtOptions.columns.findIndex(c => c.id === session.sort!.columnId); + const index = (session.columnMetaData ?? []).findIndex((c: any) => c.name === session.sort!.columnId); if (index >= 0) { sql += ` ORDER BY ${index + 1} ${session.sort.direction === `desc` ? `DESC` : `ASC`}`; } @@ -256,7 +279,9 @@ export class ResultSetPanelProvider implements WebviewViewProvider { } async copySql() { - const sql = this.tableSession ? this.tableSession.extras.sql : this.lastScrollerOptions?.basicSelect; + const session = this.session; + const current = session && (session.sort || session.search) ? buildQueryText(session, true).sql : undefined; + const sql = this.tableSession ? this.tableSession.extras.sql : current ?? this.lastScrollerOptions?.basicSelect; if (sql) { await env.clipboard.writeText(sql); window.setStatusBarMessage(`SQL statement copied to clipboard`, 3000); @@ -341,7 +366,7 @@ export class ResultSetPanelProvider implements WebviewViewProvider { /** Update the result table column headings based on the configuration setting */ async updateHeader() { const session = this.session; - if (this._view && session?.columnMetaData) { + if (this._view && session?.columnMetaData && !session.options.listing) { const columns = buildResultColumns(session.columnMetaData, Configuration.get(`resultsets.columnHeadings`) || 'Name'); updateDataTableColumns(msg => this._view?.webview.postMessage(msg), session.dtOptions, columns); } @@ -465,16 +490,20 @@ export class ResultSetPanelProvider implements WebviewViewProvider { const carried = carriedQuery !== undefined; const carriedRows = carried ? from.dtOptions.rows : []; const inEditor = this.host === `panel`; + const listing = from.options.listing; const session: ResultSetSession = { ...from, dtOptions: { - title: from.options.basicSelect.replace(/\s+/g, ` `).trim(), + title: listing?.heading ?? from.options.basicSelect.replace(/\s+/g, ` `).trim(), columns: carried ? from.dtOptions.columns : [], rows: [], + actions: listing?.actions, search: from.serverQuery, + searchPlaceholder: listing?.searchPlaceholder, initialQuery: from.search, - emptyMessage: `No rows match the search.`, + emptyMessage: listing?.emptyMessage ?? `No rows match the search.`, + noRowsMessage: listing?.noRowsMessage, sort: from.sort, streaming: true, serverQuery: from.serverQuery, @@ -482,7 +511,7 @@ export class ResultSetPanelProvider implements WebviewViewProvider { updatable: from.updatable, resizable: true, collapsedInitialWidth: Configuration.get(`collapsedResultSet`) ? `200px` : undefined, - loadingText: from.options.isCL ? `Running CL command...` : `Running statement...`, + loadingText: listing?.loadingText ?? (from.options.isCL ? `Running CL command...` : `Running statement...`), }, }; const dtOptions = session.dtOptions; @@ -522,7 +551,7 @@ export class ResultSetPanelProvider implements WebviewViewProvider { let query = this.currentQuery; if (query === undefined) { const { sql, params } = buildQueryText(session); - const prepared = await JobManager.getPagingStatement(sql, { parameters: params, isClCommand: options.isCL, isTerseResults: true }); + const prepared = await JobManager.getPagingStatement(sql, { parameters: params, isClCommand: options.isCL, isTerseResults: !listing }); // Superseded by a restart while preparing if (myEpoch !== this.queryEpoch) { prepared.close(); @@ -566,7 +595,9 @@ export class ResultSetPanelProvider implements WebviewViewProvider { // Statements without a result set have no columns const columnMetaData = queryResults.metadata?.columns; if (!columnsSent && columnMetaData) { - columns = buildResultColumns(columnMetaData, Configuration.get(`resultsets.columnHeadings`) || 'Name'); + columns = listing + ? buildListingColumns(listing, columnMetaData) + : buildResultColumns(columnMetaData, Configuration.get(`resultsets.columnHeadings`) || 'Name'); columnsSent = true; session.columnMetaData = columnMetaData; } @@ -622,13 +653,16 @@ export class ResultSetPanelProvider implements WebviewViewProvider { }, onOpenInEditor: () => this.moveResultSetToEditor(session), + + onAction: listing?.onAction, }; - this.setRouter(message => handleDataTableMessage(message, dtOptions, handlers, post)); + const registration = listing?.actions?.length ? registerDataTable(dtOptions, handlers, post) : undefined; + this.setRouter(message => handleDataTableMessage(message, dtOptions, handlers, post), registration); this.session = session; if (this._view) { - this._view.webview.html = renderDataTable(dtOptions); + this._view.webview.html = renderDataTable(dtOptions, registration?.id); this.loadingState = false; if (carried) { diff --git a/src/views/schemaBrowser/advisedIndexes.ts b/src/views/schemaBrowser/advisedIndexes.ts index eab8752f..fdcd8acf 100644 --- a/src/views/schemaBrowser/advisedIndexes.ts +++ b/src/views/schemaBrowser/advisedIndexes.ts @@ -1,10 +1,11 @@ +import { ColumnMetaData } from "@ibm/mapepire-js"; import * as vscode from "vscode"; import { JobManager } from "../../config"; import Statement from "../../database/statement"; -import { DataTableColumn, DataTableHandlers, DataTableOptions } from "../html/dataTable"; -import { showDataTable, showDataTableError, showDataTableLoading } from "../results"; +import { DataTableColumn } from "../html/dataTable"; +import { showListing } from "../results"; import { coversAdvice, hasSameKeys, RawAdvice } from "./adviceCoverage"; -import { createIndex, formatTimestamp, IndexCreation, IndexTarget, listFooter, LoadStats, prettyColumnTitle, qualifiedTable, showCreateIndexStatement } from "./indexCreation"; +import { createIndex, formatTimestamp, IndexCreation, IndexTarget, isNumericType, prettyColumnTitle, qualifiedTable, showCreateIndexStatement } from "./indexCreation"; import { getAdvisedIndexesStatement } from "./statements"; /** A row from `qsys2.condidxa` */ @@ -103,8 +104,22 @@ function removeAdviceStatement(advice: AdvisedIndex, raw: RawAdvisedIndex[]): st const REMOVE = `Remove`; +function isUsable(advice: AdvisedIndex): boolean { + return Boolean(advice.TABLE_SCHEMA && advice.TABLE_NAME && advice.KEY_COLUMNS_ADVISED); +} + +/** Read again, since the list only holds the pages of advice fetched so far */ +async function otherAdviceFor(advice: AdvisedIndex): Promise { + const advised = await JobManager.runSQL(getAdvisedIndexesStatement(advice.TABLE_SCHEMA, advice.TABLE_NAME)); + const self = advised.findIndex(other => other.KEY_COLUMNS_ADVISED === advice.KEY_COLUMNS_ADVISED + && other.PARTITION_NAME === advice.PARTITION_NAME + && sameKind(other, advice)); + + return advised.filter((other, index) => index !== self && isUsable(other)); +} + /** @returns whether the advice was removed */ -async function removeAdvice(advice: AdvisedIndex, others: AdvisedIndex[]): Promise { +async function removeAdvice(advice: AdvisedIndex): Promise { const choice = await vscode.window.showWarningMessage( `Remove this advised index over ${qualifiedTable(indexTarget(advice))} from the Index Advisor?`, { modal: true, detail: `Key columns: ${advice.KEY_COLUMNS_ADVISED.trim()}\n\nNo index is created. The advice comes back if this index is advised again.` }, @@ -114,7 +129,7 @@ async function removeAdvice(advice: AdvisedIndex, others: AdvisedIndex[]): Promi if (choice !== REMOVE) return false; try { - const raw = await rawAdviceFor(advice, others); + const raw = await rawAdviceFor(advice, await otherAdviceFor(advice)); if (raw.length === 0) { vscode.window.showWarningMessage(`No Index Advisor entries were found for this advice, so nothing was removed. Refresh the list to see its current content.`); return false; @@ -163,89 +178,54 @@ const LEADING_COLUMNS = [`TABLE_NAME`, `KEY_COLUMNS_ADVISED`, `INDEX_TYPE`]; const HIDDEN_COLUMNS = new Set([`SYSTEM_TABLE_SCHEMA`, `SYSTEM_TABLE_NAME`]); -/** @returns whether any advised index was found */ -export async function pickAdvisedIndexAction(schema: string, table?: string, onIndexCreated?: () => void): Promise { +export function pickAdvisedIndexAction(schema: string, table?: string, onIndexCreated?: () => void): Promise { const specificTable = table && table !== `*ALL` ? table : undefined; const target = schema === `*ALL` ? `all libraries` : specificTable ? `${Statement.delimName(schema)}.${Statement.delimName(specificTable)}` : Statement.delimName(schema); - const sql = getAdvisedIndexesStatement(schema, table); - const stats: LoadStats = { executionTimeMs: 0 }; - const load = async () => { - const startTime = performance.now(); - const advised = await JobManager.runSQL(sql); - stats.executionTimeMs = performance.now() - startTime; - stats.jobId = JobManager.getSelection()?.job.id; - return advised.filter(advice => advice.TABLE_SCHEMA && advice.TABLE_NAME && advice.KEY_COLUMNS_ADVISED); - }; - let usable: AdvisedIndex[]; - - try { - await showDataTableLoading(`Fetching advised indexes for ${target}...`); - usable = await load(); - } catch (e: any) { - showDataTableError(e.message); - return false; - } - - openAdvisedIndexesWebview(target, usable, sql, load, stats, onIndexCreated); - return usable.length > 0; -} - -/** Taken from the first row, since every row shares them */ -function advisedIndexColumns(advised: AdvisedIndex[]): DataTableColumn[] { - const first = advised[0]; - if (!first) return []; - - const available = Object.keys(first).filter(column => !HIDDEN_COLUMNS.has(column)); - const ordered = [ - ...LEADING_COLUMNS.filter(column => available.includes(column)), - ...available.filter(column => !LEADING_COLUMNS.includes(column)), - ]; - - return ordered.map(column => ({ - id: column, - title: prettyColumnTitle(column), - value: (advice: AdvisedIndex) => formatColumnValue(advice, column), - align: typeof first[column] === `number` ? `right` : `left`, - })); -} -function openAdvisedIndexesWebview(target: string, advised: AdvisedIndex[], sql: string, reload: () => Promise, stats: LoadStats, onIndexCreated?: () => void) { - const options: DataTableOptions = { - title: `Advised indexes for ${target}`, - subtitle: (shown, total) => listFooter({ one: `advised index`, many: `advised indexes` }, shown, total, stats), - columns: advisedIndexColumns(advised), - rows: advised, + return showListing(getAdvisedIndexesStatement(schema, table), { + heading: `Advised indexes for ${target}`, + columns: advisedIndexColumns, + loadingText: `Fetching advised indexes for ${target}...`, searchPlaceholder: `Search advised indexes…`, emptyMessage: `No advised indexes match the search.`, noRowsMessage: `No advised indexes found for ${target}.`, actions: [ - { id: ADVISED_INDEX_ACTIONS.createIndex }, - { id: ADVISED_INDEX_ACTIONS.showStatement }, - { id: ADVISED_INDEX_ACTIONS.remove, destructive: true }, + { id: ADVISED_INDEX_ACTIONS.createIndex, when: isUsable }, + { id: ADVISED_INDEX_ACTIONS.showStatement, when: isUsable }, + { id: ADVISED_INDEX_ACTIONS.remove, when: isUsable, destructive: true }, ], - }; - - const handlers: DataTableHandlers = { - onAction: async (actionId, advice, table) => { + onAction: async (actionId, advice, list) => { if (actionId === ADVISED_INDEX_ACTIONS.createIndex) { if (await createIndex(await indexCreation(advice))) { - table.removeRow(); + list.removeRow(); onIndexCreated?.(); } } else if (actionId === ADVISED_INDEX_ACTIONS.showStatement) { await showCreateIndexStatement(await indexCreation(advice)); } else if (actionId === ADVISED_INDEX_ACTIONS.remove) { - if (await removeAdvice(advice, options.rows.filter(row => row !== advice))) { - table.removeRow(); + if (await removeAdvice(advice)) { + list.removeRow(); } } }, - }; + }); +} - showDataTable(options, handlers, { sql, reload, columns: advisedIndexColumns }) - .catch(e => vscode.window.showErrorMessage(`Could not show the advised indexes: ${e?.message ?? e}`)); +function advisedIndexColumns(columnMetaData: ColumnMetaData[]): DataTableColumn[] { + const available = columnMetaData.filter(column => !HIDDEN_COLUMNS.has(column.name)); + const ordered = [ + ...LEADING_COLUMNS.flatMap(name => available.filter(column => column.name === name)), + ...available.filter(column => !LEADING_COLUMNS.includes(column.name)), + ]; + + return ordered.map(column => ({ + id: column.name, + title: prettyColumnTitle(column.name), + value: (advice: AdvisedIndex) => formatColumnValue(advice, column.name), + align: isNumericType(column.type) ? `right` : `left`, + })); } diff --git a/src/views/schemaBrowser/indexCreation.ts b/src/views/schemaBrowser/indexCreation.ts index b4d75557..c24649ef 100644 --- a/src/views/schemaBrowser/indexCreation.ts +++ b/src/views/schemaBrowser/indexCreation.ts @@ -257,14 +257,8 @@ export function formatTimestamp(raw: string): string { return match ? `${match[1]} ${match[2]}:${match[3]}` : raw; } -export interface LoadStats { - executionTimeMs: number; - jobId?: string; -} +const NUMERIC_TYPES = new Set([`SMALLINT`, `INTEGER`, `BIGINT`, `DECIMAL`, `NUMERIC`, `REAL`, `FLOAT`, `DOUBLE`, `DECFLOAT`]); -/** Same wording as a query result set's footer */ -export function listFooter(noun: { one: string, many: string }, shown: number, total: number, stats: LoadStats): string { - const matching = shown === total ? `` : ` ${shown} match the search.`; - const job = stats.jobId ? ` ${stats.jobId}` : ``; - return `Loaded ${total} ${total === 1 ? noun.one : noun.many} in ${Math.round(stats.executionTimeMs)}ms.${matching} End of data.${job}`; +export function isNumericType(type: string): boolean { + return NUMERIC_TYPES.has(type.toUpperCase()); } diff --git a/src/views/schemaBrowser/mti.ts b/src/views/schemaBrowser/mti.ts index 1711b661..da9ac25c 100644 --- a/src/views/schemaBrowser/mti.ts +++ b/src/views/schemaBrowser/mti.ts @@ -1,9 +1,8 @@ -import * as vscode from "vscode"; -import { JobManager } from "../../config"; +import { ColumnMetaData } from "@ibm/mapepire-js"; import Statement from "../../database/statement"; -import { DataTableColumn, DataTableHandlers, DataTableOptions } from "../html/dataTable"; -import { showDataTable, showDataTableError, showDataTableLoading } from "../results"; -import { createIndex, formatKilobytes, formatTimestamp, IndexCreation, listFooter, LoadStats, prettyColumnTitle, qualifiedTable, showCreateIndexStatement } from "./indexCreation"; +import { DataTableColumn } from "../html/dataTable"; +import { showListing } from "../results"; +import { createIndex, formatKilobytes, formatTimestamp, IndexCreation, prettyColumnTitle, qualifiedTable, showCreateIndexStatement } from "./indexCreation"; import { getMTIStatement } from "./statements"; /** A row from `select * from table(qsys2.mti_info(...))`. Columns vary across IBM i releases (see `isSparse`), so only the fields actually used here are typed. */ @@ -92,72 +91,35 @@ function formatColumnValue(mti: MTIInfo, column: string): string { /** Internal SQE job identifiers, and native library/file names already shown via TABLE_SCHEMA/TABLE_NAME */ const HIDDEN_COLUMNS = new Set([`JOB_NAME`, `JOB_USER`, `JOB_NUMBER`, `LIBRARY_NAME`, `FILE_NAME`]); +function isUsable(mti: MTIInfo): boolean { + return Boolean(mti.TABLE_SCHEMA && mti.TABLE_NAME && mti.KEY_DEFINITION); +} + /** - * Fetch the MTIs for a schema (or a single table within it) and open a table listing them with - * "Create Index..." and "Show Statement" actions on every row. + * List the MTIs for a schema (or a single table within it) in the "Db2 for i" result panel, + * with "Create Index..." and "Show Statement" actions on every row. * * @param onIndexCreated called once an index is created or submitted, to refresh the caller's tree - * @returns whether any MTI was found */ -export async function pickMTIAction(schema: string, table?: string, onIndexCreated?: () => void): Promise { +export function pickMTIAction(schema: string, table?: string, onIndexCreated?: () => void): Promise { const specificTable = table && table !== `*ALL` ? table : undefined; const target = schema === `*ALL` ? `all libraries` : specificTable ? `${Statement.delimName(schema)}.${Statement.delimName(specificTable)}` : Statement.delimName(schema); - const sql = getMTIStatement(schema, table); - const stats: LoadStats = { executionTimeMs: 0 }; - const load = async () => { - const startTime = performance.now(); - const mtis = await JobManager.runSQL(sql); - stats.executionTimeMs = performance.now() - startTime; - stats.jobId = JobManager.getSelection()?.job.id; - return mtis.filter(mti => mti.TABLE_SCHEMA && mti.TABLE_NAME && mti.KEY_DEFINITION); - }; - let usable: MTIInfo[]; - - try { - await showDataTableLoading(`Fetching MTIs for ${target}...`); - usable = await load(); - } catch (e: any) { - showDataTableError(e.message); - return false; - } - - openMTIWebview(target, usable, sql, load, stats, onIndexCreated); - return usable.length > 0; -} - -/** Taken from the first row, since every row shares them */ -function mtiColumns(mtis: MTIInfo[]): DataTableColumn[] { - return mtis.length === 0 ? [] : Object.keys(mtis[0]) - .filter(column => !HIDDEN_COLUMNS.has(column)) - .map(column => ({ - id: column, - title: prettyColumnTitle(column), - value: (mti: MTIInfo) => formatColumnValue(mti, column), - align: [`MTI_SIZE`, `KEYS`].includes(column) ? `right` : `left`, - })); -} -/** Show the MTI list in the "Db2 for i" result panel */ -function openMTIWebview(target: string, mtis: MTIInfo[], sql: string, reload: () => Promise, stats: LoadStats, onIndexCreated?: () => void) { - const options: DataTableOptions = { - title: `MTIs for ${target}`, - subtitle: (shown, total) => listFooter({ one: `MTI`, many: `MTIs` }, shown, total, stats), - columns: mtiColumns(mtis), - rows: mtis, + return showListing(getMTIStatement(schema, table), { + heading: `MTIs for ${target}`, + columns: mtiColumns, + loadingText: `Fetching MTIs for ${target}...`, searchPlaceholder: `Search MTIs…`, emptyMessage: `No MTIs match the search.`, noRowsMessage: `No MTIs found for ${target}.`, actions: [ - { id: MTI_ACTIONS.createIndex }, - { id: MTI_ACTIONS.showStatement }, + { id: MTI_ACTIONS.createIndex, when: isUsable }, + { id: MTI_ACTIONS.showStatement, when: isUsable }, ], - }; - - const handlers: DataTableHandlers = { onAction: async (actionId, mti) => { if (actionId === MTI_ACTIONS.createIndex) { if (await createIndex(indexCreation(mti))) { @@ -167,8 +129,17 @@ function openMTIWebview(target: string, mtis: MTIInfo[], sql: string, reload: () await showCreateIndexStatement(indexCreation(mti)); } }, - }; + }); +} - showDataTable(options, handlers, { sql, reload, columns: mtiColumns }) - .catch(e => vscode.window.showErrorMessage(`Could not show the MTI list: ${e?.message ?? e}`)); +function mtiColumns(columnMetaData: ColumnMetaData[]): DataTableColumn[] { + return columnMetaData + .map(column => column.name) + .filter(column => !HIDDEN_COLUMNS.has(column)) + .map(column => ({ + id: column, + title: prettyColumnTitle(column), + value: (mti: MTIInfo) => formatColumnValue(mti, column), + align: [`MTI_SIZE`, `KEYS`].includes(column) ? `right` : `left`, + })); } diff --git a/src/views/schemaBrowser/statements.ts b/src/views/schemaBrowser/statements.ts index f5d11a96..869116ef 100644 --- a/src/views/schemaBrowser/statements.ts +++ b/src/views/schemaBrowser/statements.ts @@ -269,7 +269,7 @@ export function getAdvisedIndexesStatement(schema: string, name?: string) { `select KEY_COLUMNS_ADVISED, Times_Advised, Most_Expensive_Query, Average_Query_Estimate,`, ` Last_Advised, MTI_USED_FOR_STATS, LAST_MTI_USED_FOR_STATS, Table_Size, MTI_USED, MTI_CREATED,`, ` LAST_MTI_USED, System_Table_Schema, Estimated_Creation_Time, Logical_Page_Size, INDEX_TYPE,`, - ` TABLE_NAME, TABLE_SCHEMA, SYSTEM_TABLE_NAME, PARTITION_NAME, LOGICAL_PAGE_SIZE,`, + ` TABLE_NAME, TABLE_SCHEMA, SYSTEM_TABLE_NAME, PARTITION_NAME,`, ` NLSS_TABLE_NAME, NLSS_TABLE_SCHEMA, MAX_ROW`, `from qsys2.condidxa`, ...(conditions.length ? [`where ${conditions.join(` and `)}`] : []), From cbbb19e42884e9940b32ffc56adeb2dc695bd438 Mon Sep 17 00:00:00 2001 From: Andrea Buzzi <155985472+buzzia2001@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:15:09 +0200 Subject: [PATCH 2/3] text in code completion --- src/language/providers/completionProvider.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/language/providers/completionProvider.ts b/src/language/providers/completionProvider.ts index 31b9b36b..86bedaf6 100644 --- a/src/language/providers/completionProvider.ts +++ b/src/language/providers/completionProvider.ts @@ -64,6 +64,7 @@ function getColumnAttributes(column: TableColumn, useSystemName: boolean): strin `Column: ${shownName}`, `${useSystemName ? `SQL name` : `System name`}: ${alternateName}`, `Type: ${prepareParamType(column)}`, + ...(column.COLUMN_TEXT?.trim() ? [`Text: ${column.COLUMN_TEXT.trim()}`] : []), `HAS_DEFAULT: ${column.HAS_DEFAULT}`, `IS_IDENTITY: ${column.IS_IDENTITY}`, `IS_NULLABLE: ${column.IS_NULLABLE}`, @@ -81,7 +82,7 @@ function getAllColumns(name: string, schema: string, items: CompletionItem[]) { ); allCols.sortText = 'a@allCols'; - allCols.insertText = items.map(item => item.label).join(", "); + allCols.insertText = items.map(item => typeof item.label === `string` ? item.label : item.label.label).join(", "); return allCols; } @@ -123,15 +124,21 @@ async function getObjectColumns( return []; } - completionItems = columns.map((i) => - createCompletionItem( + completionItems = columns.map((i) => { + const item = createCompletionItem( Statement.prettyName(useSystemNamesInColumn ? i.SYSTEM_COLUMN_NAME : i.COLUMN_NAME), CompletionItemKind.Field, getColumnAttributes(i, useSystemNamesInColumn), `Schema: ${schema}\nTable: ${name}\n`, `a@objectcolumn` - ) - ); + ); + + const text = i.COLUMN_TEXT?.trim(); + if (text) { + item.label = { label: item.label as string, description: text }; + } + return item; + }); } const allCols = getAllColumns(name, schema, completionItems); From 78347f3ab5334c0aabaf0a4d061b9ecb0341a7e8 Mon Sep 17 00:00:00 2001 From: Andrea Buzzi <155985472+buzzia2001@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:48:22 +0200 Subject: [PATCH 3/3] update empty cell --- src/views/html/dataTable.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/views/html/dataTable.ts b/src/views/html/dataTable.ts index 4da7a400..88495db5 100644 --- a/src/views/html/dataTable.ts +++ b/src/views/html/dataTable.ts @@ -501,6 +501,8 @@ export function renderDataTable(options: DataTableOptions, tableId = ``): color: var(--dt-null-color); } + /* Keeps an empty value tall enough to show the caret and the edit outline */ + .dt-hoverable { min-height: 1lh; } .dt-hoverable[contenteditable="true"] { outline: 1px solid var(--dt-accent); outline-offset: -1px; @@ -1300,10 +1302,11 @@ export function renderDataTable(options: DataTableOptions, tableId = ``): }; grid.addEventListener("click", (e) => { - const hoverable = e.target.closest(".dt-hoverable"); - if (!hoverable) return; - const cellDiv = hoverable.closest(".dt-c"); + // Resolve from the cell: an empty value leaves nothing of .dt-hoverable to click on + const cellDiv = e.target.closest(".dt-c"); if (!cellDiv) return; + const hoverable = cellDiv.querySelector(".dt-hoverable"); + if (!hoverable) return; const chosenColumn = cellDiv.dataset.col; if (!chosenColumn || chosenColumn === "RRN") return; const chosenColumnDetail = updateTable.columns.find((c) => c.name === chosenColumn);