diff --git a/client/src/components/LibraryNavigator/LibraryDataProvider.ts b/client/src/components/LibraryNavigator/LibraryDataProvider.ts index 999d32d68..0a2eb0c9b 100644 --- a/client/src/components/LibraryNavigator/LibraryDataProvider.ts +++ b/client/src/components/LibraryNavigator/LibraryDataProvider.ts @@ -66,6 +66,7 @@ class LibraryDataProvider registerAPI("getColumns", model.fetchColumns.bind(model)); registerAPI("getTables", model.getTables.bind(model)); registerAPI("getLibraries", model.getLibraries.bind(model)); + registerAPI("getTableInfo", model.getTableInfo.bind(model)); } public getSubscriptions(): Disposable[] { @@ -178,6 +179,14 @@ class LibraryDataProvider this.model.useAdapter(libraryAdapter); this._onDidChangeTreeData.fire(undefined); } + + public async getTableInfo(item: LibraryItem) { + return await this.model.getTableInfo(item); + } + + public async fetchColumns(item: LibraryItem) { + return await this.model.fetchColumns(item); + } } export default LibraryDataProvider; diff --git a/client/src/components/LibraryNavigator/LibraryModel.ts b/client/src/components/LibraryNavigator/LibraryModel.ts index 05a690389..e0b1f6d6d 100644 --- a/client/src/components/LibraryNavigator/LibraryModel.ts +++ b/client/src/components/LibraryNavigator/LibraryModel.ts @@ -126,6 +126,14 @@ class LibraryModel { } } + public async getTableInfo(item: LibraryItem) { + await this.libraryAdapter.setup(); + if (this.libraryAdapter.getTableInfo) { + return await this.libraryAdapter.getTableInfo(item); + } + throw new Error("Table properties not supported for this connection type"); + } + public async getChildren(item?: LibraryItem): Promise { if (!this.libraryAdapter) { return []; diff --git a/client/src/components/LibraryNavigator/index.ts b/client/src/components/LibraryNavigator/index.ts index 221483059..c7ad4dac4 100644 --- a/client/src/components/LibraryNavigator/index.ts +++ b/client/src/components/LibraryNavigator/index.ts @@ -17,6 +17,7 @@ import * as path from "path"; import { profileConfig } from "../../commands/profile"; import { Column } from "../../connection/rest/api/compute"; import DataViewer from "../../panels/DataViewer"; +import TablePropertiesViewer from "../../panels/TablePropertiesViewer"; import { WebViewManager } from "../../panels/WebviewManager"; import { SubscriptionProvider } from "../SubscriptionProvider"; import LibraryAdapterFactory from "./LibraryAdapterFactory"; @@ -101,6 +102,30 @@ class LibraryNavigator implements SubscriptionProvider { ); }, ), + commands.registerCommand( + "SAS.showTableProperties", + async (item: LibraryItem) => { + try { + const tableInfo = await this.libraryDataProvider.getTableInfo(item); + const columns = await this.libraryDataProvider.fetchColumns(item); + + this.webviewManager.render( + new TablePropertiesViewer( + this.extensionUri, + item.uid, + tableInfo, + columns, + false, // Show properties tab + ), + `properties-${item.uid}`, + ); + } catch (error) { + window.showErrorMessage( + `Failed to load table properties: ${error.message}`, + ); + } + }, + ), commands.registerCommand("SAS.collapseAllLibraries", () => { commands.executeCommand( "workbench.actions.treeView.librarydataprovider.collapseAll", diff --git a/client/src/components/LibraryNavigator/types.ts b/client/src/components/LibraryNavigator/types.ts index b67d567e1..591d33787 100644 --- a/client/src/components/LibraryNavigator/types.ts +++ b/client/src/components/LibraryNavigator/types.ts @@ -1,6 +1,6 @@ // Copyright © 2023, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ColumnCollection } from "../../connection/rest/api/compute"; +import { ColumnCollection, TableInfo } from "../../connection/rest/api/compute"; export const LibraryType = "library"; export const TableType = "table"; @@ -56,5 +56,6 @@ export interface LibraryAdapter { items: LibraryItem[]; count: number; }>; + getTableInfo?(item: LibraryItem): Promise; setup(): Promise; } diff --git a/client/src/connection/itc/ItcLibraryAdapter.ts b/client/src/connection/itc/ItcLibraryAdapter.ts index 6739672fa..6880cd73b 100644 --- a/client/src/connection/itc/ItcLibraryAdapter.ts +++ b/client/src/connection/itc/ItcLibraryAdapter.ts @@ -11,7 +11,7 @@ import { TableData, TableRow, } from "../../components/LibraryNavigator/types"; -import { ColumnCollection } from "../rest/api/compute"; +import type { ColumnCollection, TableInfo } from "../rest/api/compute"; import { getColumnIconType } from "../util"; import { executeRawCode, runCode } from "./CodeRunner"; import { Config } from "./types"; @@ -50,7 +50,8 @@ class ItcLibraryAdapter implements LibraryAdapter { $runner.GetColumns("${item.library}", "${item.name}") `; const output = await executeRawCode(code); - const columns = JSON.parse(output).map((column) => ({ + const rawColumns = JSON.parse(output); + const columns = rawColumns.map((column) => ({ ...column, type: getColumnIconType(column), })); @@ -127,7 +128,6 @@ class ItcLibraryAdapter implements LibraryAdapter { const { rows } = await this.getRows(item, start, limit); rows.unshift(columns); - // Fetching csv doesn't rely on count. Instead, we get the count // upfront via getTableRowCount return { rows, count: -1 }; @@ -196,6 +196,57 @@ class ItcLibraryAdapter implements LibraryAdapter { } } + public async getTableInfo(item: LibraryItem): Promise { + try { + // Use the PowerShell GetTableInfo function which queries sashelp.vtable + const code = ` + $runner.GetTableInfo("${item.library}", "${item.name}") + `; + const output = await executeRawCode(code); + const tableInfo = JSON.parse(output); + + return { + name: tableInfo.name || item.name, + libref: tableInfo.libref || item.library, + type: tableInfo.type || "DATA", + label: tableInfo.label || "", + engine: "", // Not available in sashelp.vtable for SAS 9.4 + extendedType: tableInfo.extendedType || "", + rowCount: tableInfo.rowCount || 0, + columnCount: tableInfo.columnCount || 0, + logicalRecordCount: tableInfo.rowCount || 0, + physicalRecordCount: tableInfo.rowCount || 0, + recordLength: 0, // Not available in vtable + bookmarkLength: 0, // Not available in vtable + compressionRoutine: tableInfo.compressionRoutine || "", + encoding: "", // Not available in vtable + creationTimeStamp: tableInfo.creationTimeStamp || "", + modifiedTimeStamp: tableInfo.modifiedTimeStamp || "", + }; + } catch (error) { + console.warn("Failed to get table info:", error); + // If anything fails, return basic info + return { + name: item.name, + libref: item.library, + type: "DATA", + label: "", + engine: "", + extendedType: "", + rowCount: 0, + columnCount: 0, + logicalRecordCount: 0, + physicalRecordCount: 0, + recordLength: 0, + bookmarkLength: 0, + compressionRoutine: "", + encoding: "", + creationTimeStamp: "", + modifiedTimeStamp: "", + }; + } + } + protected async executionHandler( callback: () => Promise, ): Promise { diff --git a/client/src/connection/itc/script.ts b/client/src/connection/itc/script.ts index 1df0e64d7..420941f44 100644 --- a/client/src/connection/itc/script.ts +++ b/client/src/connection/itc/script.ts @@ -295,9 +295,10 @@ class SASRunner{ $objRecordSet = New-Object -comobject ADODB.Recordset $objRecordSet.ActiveConnection = $this.dataConnection $query = @" - select name, type, format + select name, type, format, label, length, varnum from sashelp.vcolumn - where libname='$libname' and memname='$memname'; + where libname='$libname' and memname='$memname' + order by varnum; "@ $objRecordSet.Open( $query, @@ -318,6 +319,9 @@ class SASRunner{ name = $rows[0, $i] type = $rows[1, $i] format = $rows[2, $i] + label = $rows[3, $i] + length = $rows[4, $i] + varnum = $rows[5, $i] } $parsedRows += $parsedRow } @@ -580,6 +584,43 @@ class SASRunner{ Write-Host $(ConvertTo-Json -Depth 10 -InputObject $result -Compress) } + [void]GetTableInfo([string]$libname, [string]$memname) { + $objRecordSet = New-Object -comobject ADODB.Recordset + $objRecordSet.ActiveConnection = $this.dataConnection + $query = @" + select memname, memtype, crdate, modate, nobs, nvar, compress, + memlabel, typemem, filesize, delobs + from sashelp.vtable + where libname='$libname' and memname='$memname'; +"@ + $objRecordSet.Open( + $query, + [System.Reflection.Missing]::Value, # Use the active connection + 2, # adOpenDynamic + 1, # adLockReadOnly + 1 # adCmdText + ) + + $result = New-Object psobject + if (-not $objRecordSet.EOF) { + $result | Add-Member -MemberType NoteProperty -Name "name" -Value $objRecordSet.Fields.Item(0).Value + $result | Add-Member -MemberType NoteProperty -Name "type" -Value $objRecordSet.Fields.Item(1).Value + $result | Add-Member -MemberType NoteProperty -Name "creationTimeStamp" -Value $objRecordSet.Fields.Item(2).Value + $result | Add-Member -MemberType NoteProperty -Name "modifiedTimeStamp" -Value $objRecordSet.Fields.Item(3).Value + $result | Add-Member -MemberType NoteProperty -Name "rowCount" -Value $objRecordSet.Fields.Item(4).Value + $result | Add-Member -MemberType NoteProperty -Name "columnCount" -Value $objRecordSet.Fields.Item(5).Value + $result | Add-Member -MemberType NoteProperty -Name "compressionRoutine" -Value $objRecordSet.Fields.Item(6).Value + $result | Add-Member -MemberType NoteProperty -Name "label" -Value $objRecordSet.Fields.Item(7).Value + $result | Add-Member -MemberType NoteProperty -Name "extendedType" -Value $objRecordSet.Fields.Item(8).Value + $result | Add-Member -MemberType NoteProperty -Name "fileSize" -Value $objRecordSet.Fields.Item(9).Value + $result | Add-Member -MemberType NoteProperty -Name "deletedObs" -Value $objRecordSet.Fields.Item(10).Value + $result | Add-Member -MemberType NoteProperty -Name "libref" -Value $libname + } + $objRecordSet.Close() + + Write-Host $(ConvertTo-Json -Depth 10 -InputObject $result -Compress) + } + [void]GetTables([string]$libname) { $objRecordSet = New-Object -comobject ADODB.Recordset $objRecordSet.ActiveConnection = $this.dataConnection diff --git a/client/src/connection/rest/RestLibraryAdapter.ts b/client/src/connection/rest/RestLibraryAdapter.ts index b29b065a9..26f24dbb3 100644 --- a/client/src/connection/rest/RestLibraryAdapter.ts +++ b/client/src/connection/rest/RestLibraryAdapter.ts @@ -9,7 +9,12 @@ import { TableData, } from "../../components/LibraryNavigator/types"; import { appendSessionLogFn } from "../../components/logViewer"; -import { ColumnCollection, DataAccessApi, RowCollection } from "./api/compute"; +import { + ColumnCollection, + DataAccessApi, + RowCollection, + TableInfo, +} from "./api/compute"; import { getApiConfig } from "./common"; const requestOptions = { @@ -137,6 +142,23 @@ class RestLibraryAdapter implements LibraryAdapter { return { rowCount: response.data.rowCount, maxNumberOfRowsToRead: 1000 }; } + public async getTableInfo(item: LibraryItem): Promise { + await this.setup(); + const response = await this.retryOnFail( + async () => + await this.dataAccessApi.getTable( + { + sessionId: this.sessionId, + libref: item.library || "", + tableName: item.name, + }, + { headers: { Accept: "application/json" } }, + ), + ); + + return response.data; + } + private async retryOnFail( callbackFn: () => Promise>, ): Promise> { diff --git a/client/src/panels/TablePropertiesViewer.ts b/client/src/panels/TablePropertiesViewer.ts new file mode 100644 index 000000000..6f73c1794 --- /dev/null +++ b/client/src/panels/TablePropertiesViewer.ts @@ -0,0 +1,226 @@ +// Copyright © 2024, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { Uri, l10n } from "vscode"; + +import { Column } from "../connection/rest/api/compute"; +import { TableInfo } from "../connection/rest/api/compute"; +import { WebView } from "./WebviewManager"; + +class TablePropertiesViewer extends WebView { + constructor( + private readonly extensionUri: Uri, + private readonly tableName: string, + private readonly tableInfo: TableInfo, + private readonly columns: Column[], + private readonly showColumns: boolean = false, + ) { + super(); + } + + public render(): WebView { + const policies = [ + `default-src 'none';`, + `font-src ${this.panel.webview.cspSource} data:;`, + `img-src ${this.panel.webview.cspSource} data:;`, + `script-src ${this.panel.webview.cspSource};`, + `style-src ${this.panel.webview.cspSource};`, + ]; + this.panel.webview.html = this.getContent(policies); + return this; + } + + public processMessage(): void { + // No messages to process for this static viewer + } + + public getContent(policies: string[]): string { + return ` + + + + + + + + ${l10n.t("Table Properties")} + + +
+

${l10n.t("Table: {tableName}", { tableName: this.tableName })}

+ +
+ + +
+ +
+ ${this.generatePropertiesContent()} +
+ +
+ ${this.generateColumnsContent()} +
+
+ + + + + `; + } + + private generatePropertiesContent(): string { + const formatValue = (value: unknown): string => { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "number") { + return value.toLocaleString(); + } + return String(value); + }; + + const formatDate = (value: unknown): string => { + if (!value) { + return ""; + } + try { + return new Date(String(value)).toLocaleString(); + } catch { + return String(value); + } + }; + + return ` +
${l10n.t("General Information")}
+ + + + + + + + + + + + + + + + + + + + + + + + + +
${l10n.t("Name")}${formatValue(this.tableInfo.name)}
${l10n.t("Library")}${formatValue(this.tableInfo.libref)}
${l10n.t("Type")}${formatValue(this.tableInfo.type)}
${l10n.t("Label")}${formatValue(this.tableInfo.label)}
${l10n.t("Engine")}${formatValue(this.tableInfo.engine)}
${l10n.t("Extended Type")}${formatValue(this.tableInfo.extendedType)}
+ +
${l10n.t("Size Information")}
+ + + + + + + + + + + + + + + + + + + + + +
${l10n.t("Number of Rows")}${formatValue(this.tableInfo.rowCount)}
${l10n.t("Number of Columns")}${formatValue(this.tableInfo.columnCount)}
${l10n.t("Logical Record Count")}${formatValue(this.tableInfo.logicalRecordCount)}
${l10n.t("Physical Record Count")}${formatValue(this.tableInfo.physicalRecordCount)}
${l10n.t("Record Length")}${formatValue(this.tableInfo.recordLength)}
+ +
${l10n.t("Technical Information")}
+ + + + + + + + + + + + + + + + + + + + + +
${l10n.t("Created")}${formatDate(this.tableInfo.creationTimeStamp)}
${l10n.t("Modified")}${formatDate(this.tableInfo.modifiedTimeStamp)}
${l10n.t("Compression")}${formatValue(this.tableInfo.compressionRoutine)}
${l10n.t("Character Encoding")}${formatValue(this.tableInfo.encoding)}
${l10n.t("Bookmark Length")}${formatValue(this.tableInfo.bookmarkLength)}
+ `; + } + + private generateColumnsContent(): string { + const formatValue = (value: unknown): string => { + if (value === null || value === undefined) { + return ""; + } + return String(value); + }; + + const columnsRows = this.columns + .map( + (column, index) => ` + + ${index + 1} + ${formatValue(column.name)} + ${formatValue(column.type)} + ${formatValue(column.length)} + ${formatValue(column.format?.name)} + ${formatValue(column.informat?.name)} + ${formatValue(column.label)} + + `, + ) + .join(""); + + return ` +
${l10n.t("Columns ({count})", { count: this.columns.length })}
+ + + + + + + + + + + + + + ${columnsRows} + +
${l10n.t("#")}${l10n.t("Name")}${l10n.t("Type")}${l10n.t("Length")}${l10n.t("Format")}${l10n.t("Informat")}${l10n.t("Label")}
+ `; + } +} + +export default TablePropertiesViewer; diff --git a/client/src/webview/TablePropertiesViewer.css b/client/src/webview/TablePropertiesViewer.css new file mode 100644 index 000000000..611a0511e --- /dev/null +++ b/client/src/webview/TablePropertiesViewer.css @@ -0,0 +1,83 @@ +body { + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + font-weight: var(--vscode-font-weight); + color: var(--vscode-foreground); + background-color: var(--vscode-editor-background); + margin: 0; + padding: 16px; +} + +.container { + max-width: 1200px; + margin: 0 auto; +} + +.tabs { + display: flex; + border-bottom: 1px solid var(--vscode-panel-border); + margin-bottom: 16px; +} + +.tab { + padding: 10px 20px; + cursor: pointer; + border: none; + background: none; + color: var(--vscode-foreground); + border-bottom: 2px solid transparent; + font-family: inherit; + font-size: inherit; +} + +.tab:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.tab.active { + border-bottom-color: var(--vscode-focusBorder); + color: var(--vscode-tab-activeForeground); +} + +.tab-content { + display: none; + padding: 20px 0; +} + +.tab-content.active { + display: block; +} + +.properties-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 16px; +} + +.properties-table th, +.properties-table td { + border: 1px solid var(--vscode-panel-border); + padding: 8px 12px; + text-align: left; +} + +.properties-table th { + background-color: var(--vscode-list-hoverBackground); + font-weight: bold; +} + +.properties-table tr:nth-child(even) { + background-color: var(--vscode-list-hoverBackground); +} + +.property-label { + font-weight: bold; + min-width: 200px; +} + +.section-title { + font-size: 1.2em; + font-weight: bold; + margin: 20px 0 10px 0; + color: var(--vscode-foreground); +} diff --git a/client/src/webview/TablePropertiesViewer.ts b/client/src/webview/TablePropertiesViewer.ts new file mode 100644 index 000000000..dc7acc759 --- /dev/null +++ b/client/src/webview/TablePropertiesViewer.ts @@ -0,0 +1,37 @@ +// Copyright © 2025, SAS Institute Inc., Cary, NC, USA. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import "./TablePropertiesViewer.css"; + +function showTab(tabName: string, clickedTab?: HTMLElement): void { + const contents = document.querySelectorAll(".tab-content"); + contents.forEach((content) => content.classList.remove("active")); + + const tabs = document.querySelectorAll(".tab"); + tabs.forEach((tab) => tab.classList.remove("active")); + + const selectedContent = document.getElementById(tabName); + if (selectedContent) { + selectedContent.classList.add("active"); + } + + if (clickedTab) { + clickedTab.classList.add("active"); + } +} + +document.addEventListener("DOMContentLoaded", () => { + const tabButtons = document.querySelectorAll(".tab"); + tabButtons.forEach((button) => { + button.addEventListener("click", (event) => { + const target = event.currentTarget; + if (target instanceof HTMLElement) { + const tabName = target.getAttribute("data-tab"); + if (tabName) { + showTab(tabName, target); + } + } + }); + }); +}); + +export {}; diff --git a/package.json b/package.json index 398f43af8..a6bbf3f4f 100644 --- a/package.json +++ b/package.json @@ -837,6 +837,11 @@ "title": "%commands.SAS.download%", "category": "SAS" }, + { + "command": "SAS.showTableProperties", + "title": "%commands.SAS.showTableProperties%", + "category": "SAS" + }, { "command": "SAS.notebook.new", "shortTitle": "%commands.SAS.notebook.new.short%", @@ -955,6 +960,11 @@ "when": "viewItem =~ /table-/ && view == librarydataprovider", "group": "download@0" }, + { + "command": "SAS.showTableProperties", + "when": "viewItem =~ /table-/ && view == librarydataprovider", + "group": "properties@0" + }, { "command": "SAS.content.addFolderResource", "when": "viewItem =~ /createChild/ && view == contentdataprovider", @@ -1204,6 +1214,10 @@ "when": "false", "command": "SAS.downloadTable" }, + { + "when": "false", + "command": "SAS.showTableProperties" + }, { "when": "false", "command": "SAS.content.downloadResource" diff --git a/package.nls.json b/package.nls.json index ca96dd07c..6d5e8cd92 100644 --- a/package.nls.json +++ b/package.nls.json @@ -12,6 +12,7 @@ "commands.SAS.deleteResource": "Delete", "commands.SAS.deleteTable": "Delete", "commands.SAS.download": "Download", + "commands.SAS.showTableProperties": "Properties", "commands.SAS.emptyRecycleBin": "Empty Recycle Bin", "commands.SAS.file.new": "New SAS File", "commands.SAS.file.new.short": "SAS File", diff --git a/tools/build.mjs b/tools/build.mjs index 65b35f30d..025784f64 100644 --- a/tools/build.mjs +++ b/tools/build.mjs @@ -49,6 +49,8 @@ const browserBuildOptions = { format: "esm", entryPoints: { "./client/dist/webview/DataViewer": "./client/src/webview/DataViewer.tsx", + "./client/dist/webview/TablePropertiesViewer": + "./client/src/webview/TablePropertiesViewer.ts", "./client/dist/notebook/LogRenderer": "./client/src/components/notebook/renderers/LogRenderer.ts", "./client/dist/notebook/HTMLRenderer":