diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index c06ce6764..771f91f30 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -189,6 +189,58 @@ "enablement": "databricks.context.activated && databricks.context.loggedIn && databricks.feature.views.workspace && !databricks.context.remoteMode", "category": "Databricks" }, + { + "command": "databricks.unityCatalog.filter", + "title": "Filter", + "icon": "$(filter)", + "category": "Databricks" + }, + { + "command": "databricks.unityCatalog.refresh", + "title": "Refresh Unity Catalog view", + "icon": "$(refresh)", + "enablement": "databricks.context.activated && databricks.context.loggedIn", + "category": "Databricks" + }, + { + "command": "databricks.unityCatalog.copyStorageLocation", + "title": "Copy storage location", + "category": "Databricks" + }, + { + "command": "databricks.unityCatalog.copyViewSql", + "title": "Copy view SQL", + "category": "Databricks" + }, + { + "command": "databricks.unityCatalog.copyName", + "title": "Copy", + "category": "Databricks" + }, + { + "command": "databricks.unityCatalog.refreshNode", + "title": "Refresh", + "icon": "$(refresh)", + "category": "Databricks" + }, + { + "command": "databricks.unityCatalog.pin", + "title": "Add to Favorites", + "icon": "$(star-empty)", + "category": "Databricks" + }, + { + "command": "databricks.unityCatalog.unpin", + "title": "Remove from Favorites", + "icon": "$(star-full)", + "category": "Databricks" + }, + { + "command": "databricks.unityCatalog.openExternal", + "title": "Open in Databricks", + "icon": "$(link-external)", + "category": "Databricks" + }, { "command": "databricks.call", "title": "Call", @@ -452,6 +504,11 @@ "name": "Workspace explorer", "when": "databricks.feature.views.workspace && !databricks.context.remoteMode" }, + { + "id": "unityCatalogView", + "name": "Unity Catalog", + "when": "databricks.context.activated && databricks.context.loggedIn" + }, { "id": "databricksDocsView", "name": "Documentation", @@ -459,6 +516,96 @@ } ] }, + "colors": [ + { + "id": "databricks.unityCatalog.catalog", + "description": "Icon color for Unity Catalog catalog nodes", + "defaults": { + "dark": "#9B6CF7", + "light": "#6B2FD4", + "highContrast": "#C586C0", + "highContrastLight": "#6B2FD4" + } + }, + { + "id": "databricks.unityCatalog.schema", + "description": "Icon color for Unity Catalog schema nodes", + "defaults": { + "dark": "#0DB7C4", + "light": "#007A85", + "highContrast": "#4EC9B0", + "highContrastLight": "#007A85" + } + }, + { + "id": "databricks.unityCatalog.table", + "description": "Icon color for Unity Catalog table nodes", + "defaults": { + "dark": "#FF6B2C", + "light": "#C84B0A", + "highContrast": "#FF6B2C", + "highContrastLight": "#C84B0A" + } + }, + { + "id": "databricks.unityCatalog.volume", + "description": "Icon color for Unity Catalog volume nodes", + "defaults": { + "dark": "#4FC1E9", + "light": "#0E6FA0", + "highContrast": "#4FC1E9", + "highContrastLight": "#0E6FA0" + } + }, + { + "id": "databricks.unityCatalog.function", + "description": "Icon color for Unity Catalog function nodes", + "defaults": { + "dark": "#FFB347", + "light": "#A06000", + "highContrast": "#FFCA28", + "highContrastLight": "#A06000" + } + }, + { + "id": "databricks.unityCatalog.columnKey", + "description": "Icon color for Unity Catalog non-nullable (key) column nodes", + "defaults": { + "dark": "#F47C7C", + "light": "#C0392B", + "highContrast": "#F47C7C", + "highContrastLight": "#C0392B" + } + }, + { + "id": "databricks.unityCatalog.column", + "description": "Icon color for Unity Catalog nullable column nodes", + "defaults": { + "dark": "#8EAFC2", + "light": "#4A6B82", + "highContrast": "#8EAFC2", + "highContrastLight": "#4A6B82" + } + }, + { + "id": "databricks.unityCatalog.registeredModel", + "description": "Color for registered model nodes in the Unity Catalog view", + "defaults": { + "dark": "#C586C0", + "light": "#AF00DB", + "highContrast": "#C586C0" + } + }, + { + "id": "databricks.unityCatalog.modelVersion", + "description": "Color for model version nodes in the Unity Catalog view", + "defaults": { + "dark": "#B5CEA8", + "light": "#008000", + "highContrast": "#B5CEA8" + } + } + ], "viewsWelcome": [ { "view": "configurationView", @@ -536,6 +683,16 @@ "when": "view == workspaceFsView", "group": "navigation@1" }, + { + "command": "databricks.unityCatalog.filter", + "when": "view == unityCatalogView", + "group": "navigation@2" + }, + { + "command": "databricks.unityCatalog.refresh", + "when": "view == unityCatalogView", + "group": "navigation@1" + }, { "command": "databricks.bundle.refreshRemoteState", "when": "view == dabsResourceExplorerView && databricks.context.bundle.deploymentState == idle", @@ -592,6 +749,61 @@ "when": "viewItem =~ /^databricks.*\\.(has-url).*$/ && databricks.context.bundle.deploymentState == idle", "group": "navigation_2@0" }, + { + "command": "databricks.unityCatalog.openExternal", + "when": "view == unityCatalogView && viewItem =~ /\\.has-url/", + "group": "inline@1" + }, + { + "command": "databricks.unityCatalog.openExternal", + "when": "view == unityCatalogView && viewItem =~ /\\.has-url/", + "group": "navigation_2@0" + }, + { + "command": "databricks.unityCatalog.copyName", + "when": "view == unityCatalogView && viewItem =~ /unityCatalog/", + "group": "navigation_2@0" + }, + { + "command": "databricks.unityCatalog.copyStorageLocation", + "when": "view == unityCatalogView && viewItem =~ /\\.has-storage/", + "group": "navigation_2@1" + }, + { + "command": "databricks.unityCatalog.copyViewSql", + "when": "view == unityCatalogView && viewItem =~ /\\.is-view/", + "group": "navigation_2@2" + }, + { + "command": "databricks.unityCatalog.refreshNode", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.(?!column|table)/", + "group": "navigation_2@3" + }, + { + "command": "databricks.unityCatalog.refreshNode", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.(?!column|table)/", + "group": "inline@2" + }, + { + "command": "databricks.unityCatalog.pin", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.(catalog|schema|table|volume|function|registeredModel|modelVersion)/ && !(viewItem =~ /\\.is-pinned/)", + "group": "inline@3" + }, + { + "command": "databricks.unityCatalog.unpin", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.(catalog|schema|table|volume|function|registeredModel|modelVersion).*\\.is-pinned/", + "group": "inline@3" + }, + { + "command": "databricks.unityCatalog.pin", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.(catalog|schema|table|volume|function|registeredModel|modelVersion)/ && !(viewItem =~ /\\.is-pinned/)", + "group": "navigation_2@4" + }, + { + "command": "databricks.unityCatalog.unpin", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.(catalog|schema|table|volume|function|registeredModel|modelVersion).*\\.is-pinned/", + "group": "navigation_2@4" + }, { "command": "databricks.utils.goToDefinition", "when": "viewItem =~ /^databricks.*\\.(has-source-location).*$/", @@ -1163,7 +1375,7 @@ "version": "0.297.2" }, "scripts": { - "vscode:prepublish": "rm -rf out && yarn run package:compile && yarn run package:wrappers:write && yarn run package:jupyter-init-script:write && yarn run package:copy-webview-toolkit && yarn run generate-telemetry", + "vscode:prepublish": "rm -rf out && yarn run package:compile && yarn run package:wrappers:write && yarn run package:jupyter-init-script:write && yarn run package:copy-webview-toolkit && yarn run package:copy-markdown-it && yarn run package:copy-highlight && yarn run generate-telemetry", "package": "vsce package --baseContentUrl https://github.com/databricks/databricks-vscode/blob/${TAG:-main}/packages/databricks-vscode --baseImagesUrl https://raw.githubusercontent.com/databricks/databricks-vscode/${TAG:-main}/packages/databricks-vscode", "package:linux:x64": "./scripts/package-vsix.sh linux-x64", "package:linux:arm64": "./scripts/package-vsix.sh linux-arm64", @@ -1179,9 +1391,11 @@ "package:bundle-schema:write": "yarn package:cli:fetch && ts-node ./scripts/writeBundleSchema.ts ./bin/databricks ./src/bundle/BundleSchema.ts", "package:compile": "yarn run esbuild:base", "package:copy-webview-toolkit": "cp ./node_modules/@vscode/webview-ui-toolkit/dist/toolkit.js ./out/toolkit.js", + "package:copy-markdown-it": "cp ./node_modules/markdown-it/dist/markdown-it.min.js ./out/markdown-it.min.js", + "package:copy-highlight": "esbuild ./node_modules/highlight.js/lib/common.js --bundle --format=iife --global-name=hljs --outfile=out/highlight.min.js --minify", "esbuild:base": "esbuild ./src/extension.ts --bundle --outfile=out/extension.js --external:vscode --format=cjs --platform=node --sourcemap --target=es2019", "build": "yarn run package:wrappers:write && yarn run package:jupyter-init-script:write && tsc --build --force", - "watch": "yarn run package:wrappers:write && yarn run package:jupyter-init-script:write && yarn run package:copy-webview-toolkit && tsc --build --watch --verbose", + "watch": "yarn run package:wrappers:write && yarn run package:jupyter-init-script:write && yarn run package:copy-webview-toolkit && yarn run package:copy-markdown-it && yarn run package:copy-highlight && tsc --build --watch --verbose", "fix": "eslint src --ext ts --fix && prettier . --write", "test:lint": "eslint src --ext ts && prettier . -c", "test:unit": "yarn run build && node ./out/test/runTest.js", @@ -1207,7 +1421,9 @@ "add": "^2.0.6", "ansi-to-html": "^0.7.2", "bcryptjs": "^2.4.3", + "highlight.js": "^11.10.0", "lodash": "^4.17.21", + "markdown-it": "^12.3.2", "minimatch": "^10.0.1", "shell-quote": "^1.8.1", "triple-beam": "^1.4.1", diff --git a/packages/databricks-vscode/resources/dark/logo.svg b/packages/databricks-vscode/resources/dark/logo.svg index 0f2557032..7dce7f33f 100644 --- a/packages/databricks-vscode/resources/dark/logo.svg +++ b/packages/databricks-vscode/resources/dark/logo.svg @@ -1,4 +1,4 @@ - diff --git a/packages/databricks-vscode/resources/dark/unity-catalog/catalog-main.svg b/packages/databricks-vscode/resources/dark/unity-catalog/catalog-main.svg new file mode 100644 index 000000000..2d4fe1830 --- /dev/null +++ b/packages/databricks-vscode/resources/dark/unity-catalog/catalog-main.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/databricks-vscode/resources/dark/unity-catalog/catalog-samples.svg b/packages/databricks-vscode/resources/dark/unity-catalog/catalog-samples.svg new file mode 100644 index 000000000..7522e71b2 --- /dev/null +++ b/packages/databricks-vscode/resources/dark/unity-catalog/catalog-samples.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/databricks-vscode/resources/dark/unity-catalog/catalog-system.svg b/packages/databricks-vscode/resources/dark/unity-catalog/catalog-system.svg new file mode 100644 index 000000000..d96750331 --- /dev/null +++ b/packages/databricks-vscode/resources/dark/unity-catalog/catalog-system.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/databricks-vscode/resources/dark/unity-catalog/catalog.svg b/packages/databricks-vscode/resources/dark/unity-catalog/catalog.svg new file mode 100644 index 000000000..437a17db5 --- /dev/null +++ b/packages/databricks-vscode/resources/dark/unity-catalog/catalog.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/databricks-vscode/resources/dark/unity-catalog/column.svg b/packages/databricks-vscode/resources/dark/unity-catalog/column.svg new file mode 100644 index 000000000..f9160b735 --- /dev/null +++ b/packages/databricks-vscode/resources/dark/unity-catalog/column.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/databricks-vscode/resources/dark/unity-catalog/favorites.svg b/packages/databricks-vscode/resources/dark/unity-catalog/favorites.svg new file mode 100644 index 000000000..37983b89d --- /dev/null +++ b/packages/databricks-vscode/resources/dark/unity-catalog/favorites.svg @@ -0,0 +1 @@ + diff --git a/packages/databricks-vscode/resources/dark/unity-catalog/function.svg b/packages/databricks-vscode/resources/dark/unity-catalog/function.svg new file mode 100644 index 000000000..34ce800c5 --- /dev/null +++ b/packages/databricks-vscode/resources/dark/unity-catalog/function.svg @@ -0,0 +1 @@ + diff --git a/packages/databricks-vscode/resources/dark/unity-catalog/model-version.svg b/packages/databricks-vscode/resources/dark/unity-catalog/model-version.svg new file mode 100644 index 000000000..326ddd9c2 --- /dev/null +++ b/packages/databricks-vscode/resources/dark/unity-catalog/model-version.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/databricks-vscode/resources/dark/unity-catalog/no-data.svg b/packages/databricks-vscode/resources/dark/unity-catalog/no-data.svg new file mode 100644 index 000000000..f76b3be9e --- /dev/null +++ b/packages/databricks-vscode/resources/dark/unity-catalog/no-data.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/databricks-vscode/resources/dark/unity-catalog/registered-model.svg b/packages/databricks-vscode/resources/dark/unity-catalog/registered-model.svg new file mode 100644 index 000000000..b4e6cd34f --- /dev/null +++ b/packages/databricks-vscode/resources/dark/unity-catalog/registered-model.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/databricks-vscode/resources/dark/unity-catalog/schema.svg b/packages/databricks-vscode/resources/dark/unity-catalog/schema.svg new file mode 100644 index 000000000..ba1aa6a95 --- /dev/null +++ b/packages/databricks-vscode/resources/dark/unity-catalog/schema.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/databricks-vscode/resources/dark/unity-catalog/table.svg b/packages/databricks-vscode/resources/dark/unity-catalog/table.svg new file mode 100644 index 000000000..2fe6d5b75 --- /dev/null +++ b/packages/databricks-vscode/resources/dark/unity-catalog/table.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/databricks-vscode/resources/dark/unity-catalog/volume.svg b/packages/databricks-vscode/resources/dark/unity-catalog/volume.svg new file mode 100644 index 000000000..8ce80b198 --- /dev/null +++ b/packages/databricks-vscode/resources/dark/unity-catalog/volume.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/databricks-vscode/resources/light/logo.svg b/packages/databricks-vscode/resources/light/logo.svg index 6b1398df7..4e3903bc2 100644 --- a/packages/databricks-vscode/resources/light/logo.svg +++ b/packages/databricks-vscode/resources/light/logo.svg @@ -1,4 +1,4 @@ - diff --git a/packages/databricks-vscode/resources/light/unity-catalog/catalog-main.svg b/packages/databricks-vscode/resources/light/unity-catalog/catalog-main.svg new file mode 100644 index 000000000..c031c3f93 --- /dev/null +++ b/packages/databricks-vscode/resources/light/unity-catalog/catalog-main.svg @@ -0,0 +1 @@ + diff --git a/packages/databricks-vscode/resources/light/unity-catalog/catalog-samples.svg b/packages/databricks-vscode/resources/light/unity-catalog/catalog-samples.svg new file mode 100644 index 000000000..163227fb6 --- /dev/null +++ b/packages/databricks-vscode/resources/light/unity-catalog/catalog-samples.svg @@ -0,0 +1 @@ + diff --git a/packages/databricks-vscode/resources/light/unity-catalog/catalog-system.svg b/packages/databricks-vscode/resources/light/unity-catalog/catalog-system.svg new file mode 100644 index 000000000..fb5c25261 --- /dev/null +++ b/packages/databricks-vscode/resources/light/unity-catalog/catalog-system.svg @@ -0,0 +1 @@ + diff --git a/packages/databricks-vscode/resources/light/unity-catalog/catalog.svg b/packages/databricks-vscode/resources/light/unity-catalog/catalog.svg new file mode 100644 index 000000000..70cfbdfa9 --- /dev/null +++ b/packages/databricks-vscode/resources/light/unity-catalog/catalog.svg @@ -0,0 +1 @@ + diff --git a/packages/databricks-vscode/resources/light/unity-catalog/column.svg b/packages/databricks-vscode/resources/light/unity-catalog/column.svg new file mode 100644 index 000000000..438871a9e --- /dev/null +++ b/packages/databricks-vscode/resources/light/unity-catalog/column.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/databricks-vscode/resources/light/unity-catalog/favorites.svg b/packages/databricks-vscode/resources/light/unity-catalog/favorites.svg new file mode 100644 index 000000000..55e9a14e2 --- /dev/null +++ b/packages/databricks-vscode/resources/light/unity-catalog/favorites.svg @@ -0,0 +1 @@ + diff --git a/packages/databricks-vscode/resources/light/unity-catalog/function.svg b/packages/databricks-vscode/resources/light/unity-catalog/function.svg new file mode 100644 index 000000000..10a946b06 --- /dev/null +++ b/packages/databricks-vscode/resources/light/unity-catalog/function.svg @@ -0,0 +1 @@ + diff --git a/packages/databricks-vscode/resources/light/unity-catalog/model-version.svg b/packages/databricks-vscode/resources/light/unity-catalog/model-version.svg new file mode 100644 index 000000000..092522e7d --- /dev/null +++ b/packages/databricks-vscode/resources/light/unity-catalog/model-version.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/databricks-vscode/resources/light/unity-catalog/no-data.svg b/packages/databricks-vscode/resources/light/unity-catalog/no-data.svg new file mode 100644 index 000000000..a39e0d2fc --- /dev/null +++ b/packages/databricks-vscode/resources/light/unity-catalog/no-data.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/databricks-vscode/resources/light/unity-catalog/registered-model.svg b/packages/databricks-vscode/resources/light/unity-catalog/registered-model.svg new file mode 100644 index 000000000..dd9a4f4cf --- /dev/null +++ b/packages/databricks-vscode/resources/light/unity-catalog/registered-model.svg @@ -0,0 +1 @@ + diff --git a/packages/databricks-vscode/resources/light/unity-catalog/schema.svg b/packages/databricks-vscode/resources/light/unity-catalog/schema.svg new file mode 100644 index 000000000..cfc4410b6 --- /dev/null +++ b/packages/databricks-vscode/resources/light/unity-catalog/schema.svg @@ -0,0 +1 @@ + diff --git a/packages/databricks-vscode/resources/light/unity-catalog/table.svg b/packages/databricks-vscode/resources/light/unity-catalog/table.svg new file mode 100644 index 000000000..1b4dca569 --- /dev/null +++ b/packages/databricks-vscode/resources/light/unity-catalog/table.svg @@ -0,0 +1 @@ + diff --git a/packages/databricks-vscode/resources/light/unity-catalog/volume.svg b/packages/databricks-vscode/resources/light/unity-catalog/volume.svg new file mode 100644 index 000000000..8a2ab0211 --- /dev/null +++ b/packages/databricks-vscode/resources/light/unity-catalog/volume.svg @@ -0,0 +1 @@ + diff --git a/packages/databricks-vscode/resources/webview-ui/uc-detail.css b/packages/databricks-vscode/resources/webview-ui/uc-detail.css new file mode 100644 index 000000000..63a952fed --- /dev/null +++ b/packages/databricks-vscode/resources/webview-ui/uc-detail.css @@ -0,0 +1,899 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + color: var(--vscode-editor-foreground); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size, 13px); + font-weight: var(--vscode-font-weight, 400); + margin: 0; + padding: 0; + background: var( + --vscode-sideBar-background, + var(--vscode-editor-background) + ); + min-height: 100vh; +} + +/* ─── Loading ──────────────────────────────────── */ +#state-loading { + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + height: 100vh; + color: var(--vscode-descriptionForeground); + font-size: 0.85em; +} +body.loading #state-loading { + display: flex; +} + +/* ─── Content wrapper ──────────────────────────── */ +#state-content { + display: none; + animation: fadeUp 0.18s ease-out both; +} +body.content #state-content { + display: block; +} + +@keyframes fadeUp { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ─── Header ───────────────────────────────────── */ +#header { + display: flex; + align-items: flex-start; + flex-wrap: wrap; + gap: 12px; + padding: 18px 16px 14px; + border-bottom: 1px solid + var(--vscode-settings-dropdownListBorder, transparent); + background: var(--vscode-editor-background); +} + +#header-icon-wrap { + flex-shrink: 0; + width: 36px; + height: 36px; + border-radius: 8px; + background: var(--vscode-textLink-foreground); + display: flex; + align-items: center; + justify-content: center; + opacity: 0.9; +} +#header-icon-wrap svg { + width: 18px; + height: 18px; + fill: none; + stroke: var(--vscode-editor-background); + stroke-width: 1.75; + stroke-linecap: round; + stroke-linejoin: round; +} + +#header-icon { + width: 28px; + height: 28px; + object-fit: contain; +} + +#header-icon-wrap.has-custom { + background: transparent; + opacity: 1; +} + +#header-main { + display: flex; + align-items: flex-start; + gap: 12px; + flex: 1; + min-width: 160px; +} + +#header-text { + flex: 1; + min-width: 0; +} + +#header-name { + margin: 0 0 3px 0; + font-size: 1.05em; + font-weight: 700; + line-height: 1.3; + /* word-break: keep-all */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--vscode-editor-foreground); + letter-spacing: -0.01em; +} + +#header-breadcrumb { + font-size: 0.78em; + color: var(--vscode-descriptionForeground); + /* word-break: keep-all; */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--vscode-editor-font-family, monospace); + opacity: 0.85; + margin-bottom: 6px; +} + +#header-badges { + display: flex; + align-items: center; + gap: 5px; + flex-wrap: wrap; +} + +.kind-badge { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 0.7em; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 3px; + background: var(--vscode-textLink-foreground); + color: var(--vscode-editor-background); + opacity: 0.92; +} + +.status-badge { + display: inline-block; + font-size: 0.7em; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 3px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} + +/* ─── Sections ─────────────────────────────────── */ +.section { + margin: 0; + padding: 14px 16px 0; +} + +.section-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; +} + +.section-title { + font-size: 0.82em; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--vscode-descriptionForeground); +} + +.section-count { + font-size: 0.72em; + font-weight: 600; + padding: 1px 5px; + border-radius: 8px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + letter-spacing: 0; +} + +/* ─── Properties ───────────────────────────────── */ +.props-list { + display: flex; + flex-direction: column; + border: 1px solid var(--vscode-settings-dropdownListBorder, transparent); + border-radius: 5px; + overflow: hidden; + background: var(--vscode-editor-background); +} + +#extra-props-list .prop-row { + grid-template-columns: minmax(150px, 70%) 1fr; +} + +.prop-row { + display: grid; + grid-template-columns: minmax(130px, 45%) 1fr; + border-bottom: 1px solid + var(--vscode-settings-dropdownListBorder, transparent); + transition: background 0.1s; +} +.prop-row:last-child { + border-bottom: none; +} +.prop-row:hover { + background: var(--vscode-list-hoverBackground, rgba(128, 128, 128, 0.07)); +} + +.prop-label { + padding: 6px 10px 6px 10px; + font-size: 0.82em; + color: var(--vscode-descriptionForeground); + font-weight: 500; + word-break: break-word; + border-right: 1px solid + var(--vscode-settings-dropdownListBorder, transparent); + line-height: 1.5; +} + +.prop-value { + padding: 6px 10px; + font-size: 0.82em; + word-break: break-all; + line-height: 1.5; + cursor: default; + display: flex; + align-items: flex-start; +} + +.prop-value.is-path { + font-family: var(--vscode-editor-font-family, monospace); + font-size: 0.8em; + opacity: 0.9; +} + +/* ─── Comment ──────────────────────────────────── */ +.comment-block { + border-left: 3px solid var(--vscode-textLink-foreground); + border-left-color: var(--vscode-textLink-foreground); + border-radius: 0 4px 4px 0; + padding: 8px 14px; + background: var(--vscode-editor-background); + font-size: 0.85em; + color: var(--vscode-descriptionForeground); + line-height: 1.6; + border: 1px solid var(--vscode-settings-dropdownListBorder, transparent); + border-left-width: 3px; +} +.comment-block p { + margin: 0 0 0.6em 0; +} +.comment-block p:last-child { + margin-bottom: 0; +} +.comment-block h1, +.comment-block h2, +.comment-block h3, +.comment-block h4, +.comment-block h5, +.comment-block h6 { + margin: 0.7em 0 0.3em 0; + font-size: 1em; + font-weight: 700; + color: var(--vscode-editor-foreground); +} +.comment-block h1:first-child, +.comment-block h2:first-child, +.comment-block h3:first-child { + margin-top: 0; +} +.comment-block ul, +.comment-block ol { + margin: 0.3em 0 0.6em 0; + padding-left: 1.4em; +} +.comment-block li { + margin: 0.15em 0; +} +.comment-block code { + font-family: var(--vscode-editor-font-family, monospace); + font-size: 0.9em; + background: var(--vscode-textCodeBlock-background); + border: 1px solid var(--vscode-settings-dropdownListBorder, transparent); + border-radius: 3px; + padding: 0 4px; + font-style: normal; +} +.comment-block pre { + margin: 0.4em 0; + padding: 8px 12px; + background: var(--vscode-textCodeBlock-background); + border: 1px solid var(--vscode-settings-dropdownListBorder, transparent); + border-radius: 4px; + overflow-x: auto; + font-style: normal; +} +.comment-block pre code { + background: none; + border: none; + padding: 0; + font-size: 0.85em; +} +.comment-block a { + color: var(--vscode-textLink-foreground); + text-decoration: none; +} +.comment-block a:hover { + text-decoration: underline; +} +.comment-block strong { + font-weight: 700; + color: var(--vscode-editor-foreground); +} +.comment-block em { + font-style: italic; +} + +/* ─── Tables ───────────────────────────────────── */ +.table-wrap { + border: 1px solid var(--vscode-settings-dropdownListBorder, transparent); + border-radius: 5px; + overflow: auto; + background: var(--vscode-editor-background); +} + +table.data-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82em; +} + +table.data-table thead tr { + background: var( + --vscode-sideBar-background, + var(--vscode-editor-background) + ); +} + +table.data-table th { + text-align: left; + padding: 6px 10px; + font-size: 0.78em; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--vscode-descriptionForeground); + border-bottom: 1px solid + var(--vscode-settings-dropdownListBorder, transparent); + white-space: nowrap; +} + +table.data-table td { + padding: 5px 10px; + border-bottom: 1px solid + var(--vscode-settings-dropdownListBorder, transparent); + vertical-align: top; + word-break: break-word; + line-height: 1.45; +} + +table.data-table tbody tr:last-child td { + border-bottom: none; +} + +table.data-table tbody tr:hover { + background: var(--vscode-list-hoverBackground, rgba(128, 128, 128, 0.07)); +} + +/* row stripe */ +table.data-table tbody tr:nth-child(even) { + background: var( + --vscode-list-inactiveSelectionBackground, + rgba(128, 128, 128, 0.04) + ); +} +table.data-table tbody tr:nth-child(even):hover { + background: var(--vscode-list-hoverBackground, rgba(128, 128, 128, 0.07)); +} + +td.col-name { + font-weight: 600; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 0.95em; + white-space: nowrap; +} + +.child-link { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: var(--vscode-textLink-foreground); + font: inherit; + font-weight: 600; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 0.95em; + text-align: left; +} +.child-link:hover { + text-decoration: underline; + color: var(--vscode-textLink-activeForeground); +} + +td.col-comment { + color: var(--vscode-descriptionForeground); + font-style: italic; + font-size: 0.95em; +} + +/* Type chips */ +.type-chip { + display: inline-block; + padding: 1px 6px; + border-radius: 3px; + font-size: 0.85em; + font-family: var(--vscode-editor-font-family, monospace); + font-weight: 600; + background: var(--vscode-textCodeBlock-background); + color: var(--vscode-textLink-foreground); + border: 1px solid var(--vscode-settings-dropdownListBorder, transparent); + white-space: nowrap; +} + +/* ─── Code block ───────────────────────────────── */ +.code-wrap { + position: relative; + border: 1px solid var(--vscode-settings-dropdownListBorder, transparent); + border-radius: 5px; + overflow: hidden; + background: var(--vscode-textCodeBlock-background); +} + +.code-copy-btn { + position: absolute; + top: 7px; + right: 8px; + font-size: 0.72em; + font-weight: 600; + letter-spacing: 0.03em; + padding: 2px 8px; + border-radius: 3px; + border: 1px solid var(--vscode-settings-dropdownListBorder, transparent); + background: var(--vscode-editor-background); + color: var(--vscode-descriptionForeground); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} +.code-wrap:hover .code-copy-btn { + opacity: 1; +} +.code-copy-btn:hover { + color: var(--vscode-editor-foreground); +} + +pre.code-block { + margin: 0; + padding: 12px 14px; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 0.83em; + line-height: 1.6; + overflow-x: auto; + white-space: pre; + color: var(--vscode-editor-foreground); + tab-size: 2; +} + +/* ─── Actions ──────────────────────────────────── */ +#header-actions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + flex-shrink: 0; + align-self: flex-start; + padding: 0; +} + +.action-btn { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.82em; + font-family: var(--vscode-font-family); + font-weight: 500; + padding: 4px 10px; + border-radius: 3px; + border: 1px solid var(--vscode-settings-dropdownListBorder, transparent); + background: var(--vscode-editor-background); + color: var(--vscode-editor-foreground); + cursor: pointer; + transition: + background 0.12s, + border-color 0.12s; + text-decoration: none; +} +.action-btn:hover { + background: var(--vscode-list-hoverBackground, rgba(128, 128, 128, 0.1)); + border-color: var(--vscode-textLink-foreground); +} +.action-btn svg { + width: 13px; + height: 13px; + stroke: currentColor; + fill: none; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + flex-shrink: 0; +} +.action-btn.primary { + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border-color: transparent; +} +.action-btn.primary:hover { + background: var( + --vscode-button-hoverBackground, + var(--vscode-button-background) + ); + opacity: 0.92; +} + +/* ─── Section spacing tweaks ───────────────────── */ +.section + .section { + padding-top: 16px; +} + +/* ─── Tags ─────────────────────────────────────── */ +.tags-wrap { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.tag-chip { + border: 1px solid var(--vscode-settings-dropdownListBorder, transparent); + border-radius: 12px; + padding: 2px 8px; + font-size: 0.8em; + white-space: nowrap; +} +.tag-chip-key { + color: var(--vscode-descriptionForeground); +} +.tag-chip-value { + color: var(--vscode-textLink-foreground); +} + +/* ─── Monitor ──────────────────────────────────── */ +.monitor-card { + border: 1px solid var(--vscode-settings-dropdownListBorder, transparent); + border-radius: 5px; + overflow: hidden; + background: var(--vscode-editor-background); +} + +.monitor-status-row { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 10px; + border-bottom: 1px solid + var(--vscode-settings-dropdownListBorder, transparent); + font-size: 0.82em; +} + +.monitor-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.monitor-status-dot.active { + background: #3ca55c; +} +.monitor-status-dot.error { + background: #e05252; +} +.monitor-status-dot.pending { + background: #d4a017; +} + +/* ─── Constraints ──────────────────────────────── */ +.constraints-wrap { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.constraint-chip { + display: inline-block; + border: 1px solid var(--vscode-settings-dropdownListBorder, transparent); + border-radius: 4px; + padding: 2px 8px; + font-size: 0.8em; + font-family: var(--vscode-editor-font-family, monospace); + background: var(--vscode-textCodeBlock-background); +} +.constraint-chip-label { + font-weight: 700; + color: var(--vscode-textLink-foreground); + margin-right: 4px; +} + +/* ─── Tab bar ──────────────────────────────────── */ +#tab-bar { + display: flex; + align-items: flex-end; + gap: 0; + padding: 0 16px; + border-bottom: 1px solid + var(--vscode-settings-dropdownListBorder, transparent); + background: var(--vscode-editor-background); + overflow-x: auto; +} + +.tab-btn { + padding: 7px 14px 6px; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + background: transparent; + color: var(--vscode-descriptionForeground); + font-family: var(--vscode-font-family); + font-size: 0.83em; + cursor: pointer; + white-space: nowrap; + transition: + color 0.1s, + border-color 0.1s; +} +.tab-btn:hover { + color: var(--vscode-editor-foreground); +} +.tab-btn.active { + color: var(--vscode-editor-foreground); + border-bottom-color: var(--vscode-textLink-foreground); + font-weight: 600; +} +.tab-btn .tab-badge { + display: inline-block; + margin-left: 4px; + padding: 0 4px; + font-size: 0.75em; + border-radius: 8px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + vertical-align: middle; + line-height: 1.6; +} + +/* ─── Search bar ────────────────────────────────── */ +#search-bar { + padding: 8px 16px; + background: var(--vscode-editor-background); + border-bottom: 1px solid + var(--vscode-settings-dropdownListBorder, transparent); +} +#search-input-wrap { + position: relative; + display: flex; + align-items: center; + width: 100%; + max-width: 320px; +} +.search-icon { + position: absolute; + left: 8px; + width: 13px; + height: 13px; + stroke: var(--vscode-descriptionForeground); + fill: none; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; + pointer-events: none; + flex-shrink: 0; +} +#search-input { + width: 100%; + min-width: 0; + padding: 5px 26px 5px 28px; + background: var(--vscode-input-background, rgba(128, 128, 128, 0.1)); + border: 1px solid + var( + --vscode-input-border, + var(--vscode-settings-dropdownListBorder, transparent) + ); + border-radius: 4px; + color: var(--vscode-input-foreground, var(--vscode-editor-foreground)); + font-family: var(--vscode-font-family); + font-size: 0.83em; + outline: none; +} +#search-input::placeholder { + color: var( + --vscode-input-placeholderForeground, + var(--vscode-descriptionForeground) + ); +} +#search-input:focus { + border-color: var(--vscode-focusBorder, var(--vscode-textLink-foreground)); +} +#search-clear { + position: absolute; + right: 6px; + background: none; + border: none; + cursor: pointer; + color: var(--vscode-descriptionForeground); + font-size: 1.1em; + line-height: 1; + padding: 2px 4px; + display: none; +} +#search-clear:hover { + color: var(--vscode-editor-foreground); +} +.search-empty { + padding: 12px 10px; + font-size: 0.82em; + color: var(--vscode-descriptionForeground); + font-style: italic; + text-align: center; + display: none; +} + +/* ─── Tab panels ────────────────────────────────── */ +.tab-panel { + display: none; + padding-bottom: 40px; +} +.tab-panel.active { + display: block; + animation: fadeUp 0.15s ease-out both; +} + +/* Syntax highlighting (highlight.js) */ +/* VS Code sets data-vscode-theme-kind attribute on body, not a CSS class */ +body[data-vscode-theme-kind="vscode-dark"] .hljs-keyword, +body[data-vscode-theme-kind="vscode-dark"] .hljs-operator { + color: #569cd6; +} +body[data-vscode-theme-kind="vscode-dark"] .hljs-string { + color: #ce9178; +} +body[data-vscode-theme-kind="vscode-dark"] .hljs-comment { + color: #6a9955; + font-style: italic; +} +body[data-vscode-theme-kind="vscode-dark"] .hljs-number { + color: #b5cea8; +} +body[data-vscode-theme-kind="vscode-dark"] .hljs-built_in { + color: #4ec9b0; +} +body[data-vscode-theme-kind="vscode-dark"] .hljs-type { + color: #4ec9b0; +} +body[data-vscode-theme-kind="vscode-dark"] .hljs-title { + color: #dcdcaa; +} +body[data-vscode-theme-kind="vscode-dark"] .hljs-variable { + color: #9cdcfe; +} + +body[data-vscode-theme-kind="vscode-light"] .hljs-keyword, +body[data-vscode-theme-kind="vscode-light"] .hljs-operator { + color: #0000ff; +} +body[data-vscode-theme-kind="vscode-light"] .hljs-string { + color: #a31515; +} +body[data-vscode-theme-kind="vscode-light"] .hljs-comment { + color: #008000; + font-style: italic; +} +body[data-vscode-theme-kind="vscode-light"] .hljs-number { + color: #098658; +} +body[data-vscode-theme-kind="vscode-light"] .hljs-built_in { + color: #267f99; +} +body[data-vscode-theme-kind="vscode-light"] .hljs-type { + color: #267f99; +} +body[data-vscode-theme-kind="vscode-light"] .hljs-title { + color: #795e26; +} +body[data-vscode-theme-kind="vscode-light"] .hljs-variable { + color: #001080; +} + +/* ─── Narrow / split-view layout ──────────────── */ +/* + * Triggered when the detail panel is narrow (e.g. opened beside a notebook + * or query editor). Tighter spacing prevents unnecessary vertical scrolling + * and keeps the most important content above the fold. + */ +@media (max-width: 420px) { + #header { + padding: 12px 12px 10px; + gap: 8px; + } + + #header-main { + gap: 8px; + } + + #search-bar { + padding: 6px 12px; + } + + .section { + padding-left: 12px; + padding-right: 12px; + } + + .section + .section { + padding-top: 12px; + } + + .tab-btn { + padding: 7px 10px 6px; + } + + /* Narrower label column so values have more room */ + .prop-row { + grid-template-columns: minmax(80px, 38%) 1fr; + } + + #extra-props-list .prop-row { + grid-template-columns: minmax(100px, 55%) 1fr; + } + + /* Allow column names to wrap rather than forcing horizontal scroll */ + td.col-name { + white-space: normal; + word-break: break-word; + } + + /* Truncate long comments to a single line so rows stay compact */ + td.col-comment { + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +/* Very narrow: collapse action button labels to icon-only */ +@media (max-width: 480px) { + .btn-label { + display: none; + } + + .action-btn { + padding: 5px 7px; + } + + /* Reduce header icon to reclaim ~8px of horizontal space */ + #header-icon-wrap { + width: 28px; + height: 28px; + border-radius: 6px; + } + + #header-icon-wrap svg { + width: 14px; + height: 14px; + } + + #header-icon { + width: 22px; + height: 22px; + } +} diff --git a/packages/databricks-vscode/resources/webview-ui/uc-detail.html b/packages/databricks-vscode/resources/webview-ui/uc-detail.html new file mode 100644 index 000000000..6d7584aac --- /dev/null +++ b/packages/databricks-vscode/resources/webview-ui/uc-detail.html @@ -0,0 +1,283 @@ + + + + + + + + + + + + +
+ + Loading… +
+ + +
+ + + + +
+ + + + + +
+ + + + + +
+ + + + + + + + + + + + + +
+ + +
+
+
+ Definition +
+
+ +

+                    
+
+
+ + +
+ +
+
+ About this table +
+
+
+ + + + + + +
+ + +
+
+
+ Effective Permissions + +
+
+ + + + + + + + +
PrincipalPrivileges
+
+
+
+ + +
+
+
+ Quality Monitor +
+
+
+
+
+ + + + + diff --git a/packages/databricks-vscode/resources/webview-ui/uc-detail.js b/packages/databricks-vscode/resources/webview-ui/uc-detail.js new file mode 100644 index 000000000..7704f3582 --- /dev/null +++ b/packages/databricks-vscode/resources/webview-ui/uc-detail.js @@ -0,0 +1,796 @@ +const vscode = + typeof acquireVsCodeApi !== "undefined" ? acquireVsCodeApi() : null; + +/* ── SVG icon paths per kind ── */ +const KIND_SVGS = { + catalog: + '', + schema: '', + table: '', + volume: '', + function: + '', + registeredModel: + '', + modelVersion: + '', +}; + +const COPY_ICON_SVG = + `` + + `` + + ``; + +/* ── DOM helpers ── */ + +function setText(id, value) { + const el = document.getElementById(id); + if (el) el.textContent = value ?? ""; +} + +function show(id, visible) { + const el = document.getElementById(id); + if (el) el.style.display = visible ? "" : "none"; +} + +function formatDate(ts) { + if (!ts) return ""; + return new Date(ts).toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function addProp(list, label, value, opts = {}) { + if (value == null || value === "") return; + const row = document.createElement("div"); + row.className = "prop-row"; + + const labelEl = document.createElement("span"); + labelEl.className = "prop-label"; + labelEl.textContent = label; + + const valueEl = document.createElement("span"); + valueEl.className = "prop-value" + (opts.isPath ? " is-path" : ""); + valueEl.textContent = String(value); + + row.appendChild(labelEl); + row.appendChild(valueEl); + list.appendChild(row); +} + +function makeTypeChip(text) { + if (!text) return document.createTextNode(""); + const chip = document.createElement("span"); + chip.className = "type-chip"; + chip.textContent = text.toUpperCase(); + return chip; +} + +/* ── Table builders ── */ + +function buildColumns(rows) { + const tbody = document.getElementById("columns-body"); + tbody.innerHTML = ""; + setText("columns-count", String(rows.length)); + rows.forEach((col, i) => { + const tr = document.createElement("tr"); + + const tdIdx = document.createElement("td"); + tdIdx.style.color = "var(--vscode-descriptionForeground)"; + tdIdx.style.fontSize = "0.8em"; + tdIdx.style.paddingRight = "4px"; + tdIdx.style.width = "28px"; + tdIdx.textContent = String(i + 1); + + const tdName = document.createElement("td"); + tdName.className = "col-name"; + tdName.textContent = col.name ?? ""; + + const tdType = document.createElement("td"); + tdType.style.whiteSpace = "nowrap"; + tdType.appendChild(makeTypeChip(col.typeText ?? col.typeName ?? "")); + + const tdComment = document.createElement("td"); + tdComment.className = "col-comment"; + tdComment.textContent = col.comment ?? ""; + + tr.append(tdIdx, tdName, tdType, tdComment); + tbody.appendChild(tr); + }); +} + +function buildParams(rows) { + const tbody = document.getElementById("params-body"); + tbody.innerHTML = ""; + setText("params-count", String(rows.length)); + for (const param of rows) { + const tr = document.createElement("tr"); + + const tdName = document.createElement("td"); + tdName.className = "col-name"; + tdName.textContent = param.name ?? ""; + + const tdType = document.createElement("td"); + tdType.appendChild( + makeTypeChip(param.typeText ?? param.typeName ?? "") + ); + + const tdDefault = document.createElement("td"); + if (param.parameterDefault) { + const chip = document.createElement("span"); + chip.className = "type-chip"; + chip.style.color = "var(--vscode-descriptionForeground)"; + chip.textContent = param.parameterDefault; + tdDefault.appendChild(chip); + } + + const tdComment = document.createElement("td"); + tdComment.className = "col-comment"; + tdComment.textContent = param.comment ?? ""; + + tr.append(tdName, tdType, tdDefault, tdComment); + tbody.appendChild(tr); + } +} + +/* ── Markdown ── */ + +const md = window.markdownit({html: false, linkify: true, typographer: false}); + +const defaultLinkOpen = + md.renderer.rules.link_open || + ((tokens, idx, options, _env, self) => + self.renderToken(tokens, idx, options)); + +md.renderer.rules.link_open = (tokens, idx, options, env, self) => { + const href = tokens[idx].attrGet("href") ?? ""; + if (/^https?:\/\//i.test(href)) { + tokens[idx].attrSet("target", "_blank"); + tokens[idx].attrSet("rel", "noopener noreferrer"); + } else { + tokens[idx].attrSet("href", "#"); + } + return defaultLinkOpen(tokens, idx, options, env, self); +}; + +function renderMarkdown(text) { + return text ? md.render(text) : ""; +} + +/* ── Search / tabs ── */ + +function filterContent(query) { + const q = query.toLowerCase().trim(); + const panel = document.querySelector(".tab-panel.active"); + if (!panel) return; + + function filterSection(container, itemSelector) { + let visible = 0; + container.querySelectorAll(itemSelector).forEach((el) => { + const match = !q || el.textContent.toLowerCase().includes(q); + el.style.display = match ? "" : "none"; + if (match) visible++; + }); + let empty = container.querySelector(".search-empty"); + if (!empty) { + empty = document.createElement("div"); + empty.className = "search-empty"; + container.appendChild(empty); + } + empty.textContent = `No results for "${query}"`; + empty.style.display = visible === 0 && q ? "" : "none"; + } + + panel + .querySelectorAll(".table-wrap") + .forEach((wrap) => filterSection(wrap, "tbody tr")); + panel + .querySelectorAll(".props-list") + .forEach((list) => filterSection(list, ".prop-row")); + + panel.querySelectorAll(".tag-chip, .constraint-chip").forEach((chip) => { + chip.style.display = + !q || chip.textContent.toLowerCase().includes(q) ? "" : "none"; + }); +} + +function activateTab(tabId) { + document + .querySelectorAll(".tab-btn") + .forEach((btn) => + btn.classList.toggle("active", btn.dataset.tab === tabId) + ); + document + .querySelectorAll(".tab-panel") + .forEach((panel) => + panel.classList.toggle("active", panel.id === "tab-" + tabId) + ); + + const searchBar = document.getElementById("search-bar"); + if (searchBar) + searchBar.style.display = tabId === "definition" ? "none" : ""; + + const searchInput = document.getElementById("search-input"); + const searchClear = document.getElementById("search-clear"); + if (searchInput) searchInput.value = ""; + if (searchClear) searchClear.style.display = "none"; + + if (searchInput) filterContent(searchInput.value); +} + +function showTabBtn(tabId, visible) { + const btn = document.querySelector(`.tab-btn[data-tab="${tabId}"]`); + if (btn) btn.style.display = visible ? "" : "none"; +} + +function initTabs() { + document + .querySelectorAll(".tab-btn") + .forEach((btn) => + btn.addEventListener("click", () => activateTab(btn.dataset.tab)) + ); + + const input = document.getElementById("search-input"); + const clearBtn = document.getElementById("search-clear"); + + input.addEventListener("input", () => { + clearBtn.style.display = input.value ? "" : "none"; + filterContent(input.value); + }); + + clearBtn.addEventListener("click", () => { + input.value = ""; + clearBtn.style.display = "none"; + filterContent(""); + input.focus(); + }); +} + +/* ── renderNode helpers ── */ + +function getIconUri(kind, name) { + const uris = window.UC_ICON_URIS; + if (!uris) return null; + const theme = + document.body.getAttribute("data-vscode-theme-kind") === "vscode-light" + ? "light" + : "dark"; + const themeUris = uris[theme]; + if (!themeUris) return null; + if (kind === "catalog") { + const key = "catalog-" + name; + return themeUris[key] ?? themeUris["catalog"] ?? null; + } + return themeUris[kind] ?? null; +} + +function renderHeader(data) { + const img = document.getElementById("header-icon"); + const svg = document.getElementById("header-svg"); + const wrap = document.getElementById("header-icon-wrap"); + const uri = getIconUri(data.kind, data.name); + if (uri) { + img.src = uri; + img.style.display = ""; + svg.style.display = "none"; + wrap.classList.add("has-custom"); + } else { + svg.innerHTML = KIND_SVGS[data.kind] ?? ""; + svg.style.display = ""; + img.style.display = "none"; + wrap.classList.remove("has-custom"); + } + + setText("header-name", data.name ?? data.fullName ?? ""); + setText("header-kind-badge", data.kind); + + const fullName = data.fullName ?? ""; + const lastDot = fullName.lastIndexOf("."); + const breadcrumb = lastDot > 0 ? fullName.slice(0, lastDot) : ""; + const breadcrumbEl = document.getElementById("header-breadcrumb"); + breadcrumbEl.textContent = breadcrumb; + breadcrumbEl.style.display = breadcrumb ? "" : "none"; + + const statusBadge = document.getElementById("header-status-badge"); + statusBadge.textContent = data.status ?? ""; + statusBadge.style.display = data.status ? "" : "none"; +} + +function addCatalogProps(list, data) { + addProp(list, "Full name", data.fullName, {isPath: true}); + addProp(list, "Owner", data.owner); + addProp(list, "Type", data.catalogType); + addProp(list, "Isolation mode", data.isolationMode); + addProp(list, "Storage location", data.storageLocation, {isPath: true}); + addProp(list, "Connection", data.connectionName); + addProp(list, "Provider", data.providerName); + addProp(list, "Share", data.shareName); + addProp(list, "Created by", data.createdBy); + addProp(list, "Created at", formatDate(data.createdAt)); + addProp(list, "Updated by", data.updatedBy); + addProp(list, "Updated at", formatDate(data.updatedAt)); +} + +function addSchemaProps(list, data) { + addProp(list, "Full name", data.fullName, {isPath: true}); + addProp(list, "Owner", data.owner); + addProp(list, "Storage location", data.storageLocation, {isPath: true}); + addProp(list, "Created by", data.createdBy); + addProp(list, "Created at", formatDate(data.createdAt)); + addProp(list, "Updated by", data.updatedBy); + addProp(list, "Updated at", formatDate(data.updatedAt)); +} + +function addTableProps(list, data) { + addProp(list, "Full name", data.fullName, {isPath: true}); + addProp(list, "Owner", data.owner); + addProp(list, "Table type", data.tableType); + addProp(list, "Format", data.dataSourceFormat); + addProp(list, "Storage location", data.storageLocation, {isPath: true}); + addProp(list, "Created by", data.createdBy); + addProp(list, "Created at", formatDate(data.createdAt)); + addProp(list, "Updated by", data.updatedBy); + addProp(list, "Updated at", formatDate(data.updatedAt)); +} + +function addVolumeProps(list, data) { + addProp(list, "Full name", data.fullName, {isPath: true}); + addProp(list, "Owner", data.owner); + addProp(list, "Volume type", data.volumeType); + addProp(list, "Storage location", data.storageLocation, {isPath: true}); + addProp(list, "Created by", data.createdBy); + addProp(list, "Created at", formatDate(data.createdAt)); + addProp(list, "Updated by", data.updatedBy); + addProp(list, "Updated at", formatDate(data.updatedAt)); +} + +function addFunctionProps(list, data) { + addProp(list, "Full name", data.fullName, {isPath: true}); + addProp(list, "Owner", data.owner); + addProp(list, "Return type", data.fullDataType); + addProp(list, "Routine body", data.routineBody); + addProp(list, "Language", data.externalLanguage); + addProp( + list, + "Deterministic", + data.isDeterministic != null + ? data.isDeterministic + ? "Yes" + : "No" + : undefined + ); + addProp(list, "Created by", data.createdBy); + addProp(list, "Created at", formatDate(data.createdAt)); + addProp(list, "Updated by", data.updatedBy); + addProp(list, "Updated at", formatDate(data.updatedAt)); +} + +function addModelProps(list, data) { + addProp(list, "Full name", data.fullName, {isPath: true}); + addProp(list, "Owner", data.owner); + addProp(list, "Storage location", data.storageLocation, {isPath: true}); + if (data.aliases && data.aliases.length > 0) { + addProp( + list, + "Aliases", + data.aliases + .map( + (a) => + `${a.alias_name ?? ""}${ + a.version_num != null + ? " (v" + a.version_num + ")" + : "" + }` + ) + .join(", ") + ); + } + addProp(list, "Created at", formatDate(data.createdAt)); + addProp(list, "Updated at", formatDate(data.updatedAt)); +} + +function addModelVersionProps(list, data) { + addProp(list, "Full name", data.fullName, {isPath: true}); + addProp(list, "Version", data.version); + addProp(list, "Storage location", data.storageLocation, {isPath: true}); + addProp(list, "Created by", data.createdBy); + addProp(list, "Created at", formatDate(data.createdAt)); +} + +const KIND_PROPS = { + catalog: addCatalogProps, + schema: addSchemaProps, + table: addTableProps, + volume: addVolumeProps, + function: addFunctionProps, + registeredModel: addModelProps, + modelVersion: addModelVersionProps, +}; + +function renderKindProps(list, data) { + KIND_PROPS[data.kind]?.(list, data); + + const hasColumns = data.kind === "table" && data.columns?.length > 0; + if (hasColumns) buildColumns(data.columns); + show("section-columns", hasColumns); + + const hasParams = data.kind === "function" && data.inputParams?.length > 0; + if (hasParams) buildParams(data.inputParams); + show("section-params", hasParams); +} + +function renderComment(data) { + show("section-comment", !!data.comment); + if (data.comment) { + document.getElementById("comment-text").innerHTML = renderMarkdown( + data.comment + ); + } +} + +function renderDefinition(data) { + const definition = + data.kind === "table" + ? data.viewDefinition + : data.kind === "function" + ? data.routineDefinition + : undefined; + + if (definition) { + setText( + "sql-title", + data.kind === "function" ? "Routine Definition" : "View Definition" + ); + const pre = document.getElementById("sql-body"); + if (window.hljs) { + pre.innerHTML = hljs.highlightAuto(definition).value; + } else { + pre.textContent = definition; + } + document.getElementById("sql-copy-btn").onclick = () => { + if (!vscode) return; + vscode.postMessage({command: "copyText", text: definition}); + const btn = document.getElementById("sql-copy-btn"); + btn.textContent = "Copied!"; + setTimeout(() => { + btn.textContent = "Copy"; + }, 1500); + }; + } + showTabBtn("definition", !!definition); +} + +function renderActions(data) { + const copyBtn = document.getElementById("btn-copy"); + copyBtn.onclick = () => { + if (!vscode) return; + vscode.postMessage({ + command: "copyText", + text: data.fullName ?? data.name ?? "", + }); + copyBtn.textContent = "Copied!"; + setTimeout(() => { + copyBtn.innerHTML = COPY_ICON_SVG + "Copy full name"; + }, 1500); + }; + + const linkContainer = document.getElementById("link-external"); + linkContainer.innerHTML = ""; + if (data.exploreUrl) { + const a = document.createElement("a"); + a.className = "action-btn"; + a.href = data.exploreUrl; + a.innerHTML = + `` + + `` + + `Open in Databricks`; + linkContainer.appendChild(a); + } +} + +function resetForNewNode() { + show("section-tags", false); + show("section-extra-props", false); + show("section-constraints", false); + show("section-children", false); + showTabBtn("details", true); + showTabBtn("permissions", false); + showTabBtn("quality", false); + + const searchInput = document.getElementById("search-input"); + const searchClear = document.getElementById("search-clear"); + if (searchInput) searchInput.value = ""; + if (searchClear) searchClear.style.display = "none"; + + activateTab("overview"); +} + +/* ── renderEnrichments helpers ── */ + +function renderTags(tags) { + if (!tags?.length) return; + const body = document.getElementById("tags-body"); + body.textContent = ""; + for (const tag of tags) { + const chip = document.createElement("span"); + chip.className = "tag-chip"; + + const keySpan = document.createElement("span"); + keySpan.className = "tag-chip-key"; + keySpan.textContent = tag.key; + chip.appendChild(keySpan); + + if (tag.value) { + const valSpan = document.createElement("span"); + valSpan.className = "tag-chip-value"; + valSpan.textContent = tag.value; + chip.append(document.createTextNode(": "), valSpan); + } + body.appendChild(chip); + } + show("section-tags", true); +} + +function renderPermissions(permissions) { + if (!permissions?.length) return; + const tbody = document.getElementById("permissions-body"); + tbody.textContent = ""; + setText("permissions-count", String(permissions.length)); + for (const perm of permissions) { + const tr = document.createElement("tr"); + const tdPrincipal = document.createElement("td"); + tdPrincipal.className = "col-name"; + tdPrincipal.textContent = perm.principal; + const tdPrivs = document.createElement("td"); + tdPrivs.textContent = perm.privileges.join(", "); + tr.append(tdPrincipal, tdPrivs); + tbody.appendChild(tr); + } + showTabBtn("permissions", true); +} + +function renderMonitor(monitor) { + if (!monitor) return; + const body = document.getElementById("monitor-body"); + body.textContent = ""; + const card = document.createElement("div"); + card.className = "monitor-card"; + + const statusRow = document.createElement("div"); + statusRow.className = "monitor-status-row"; + + const dot = document.createElement("span"); + dot.className = "monitor-status-dot"; + const status = monitor.status ?? ""; + dot.classList.add( + status.includes("ACTIVE") + ? "active" + : status.includes("ERROR") || status.includes("FAILED") + ? "error" + : "pending" + ); + + const statusText = document.createElement("span"); + statusText.textContent = status.replace("MONITOR_STATUS_", ""); + statusRow.append(dot, statusText); + card.appendChild(statusRow); + + const propsList = document.createElement("div"); + propsList.className = "props-list"; + propsList.style.borderRadius = "0"; + propsList.style.border = "none"; + addProp(propsList, "Schedule", monitor.schedule); + addProp(propsList, "Drift metrics", monitor.driftMetricsTable, { + isPath: true, + }); + addProp(propsList, "Profile metrics", monitor.profileMetricsTable, { + isPath: true, + }); + addProp(propsList, "Failure", monitor.failureMsg); + if (propsList.children.length > 0) card.appendChild(propsList); + + body.appendChild(card); + showTabBtn("quality", true); +} + +function renderConstraints(constraints) { + if (!constraints?.length) return; + const body = document.getElementById("constraints-body"); + body.textContent = ""; + for (const constraint of constraints) { + const chip = document.createElement("span"); + chip.className = "constraint-chip"; + + const typeLabel = document.createElement("span"); + typeLabel.className = "constraint-chip-label"; + typeLabel.textContent = constraint.type.toUpperCase(); + chip.appendChild(typeLabel); + chip.appendChild( + document.createTextNode(constraint.columns.join(", ")) + ); + + if (constraint.type === "fk" && constraint.parentTable) { + chip.appendChild( + document.createTextNode(" → " + constraint.parentTable) + ); + if (constraint.parentColumns?.length) { + chip.appendChild( + document.createTextNode( + "." + constraint.parentColumns.join(", ") + ) + ); + } + } + body.appendChild(chip); + } + show("section-constraints", true); + showTabBtn("details", true); +} + +function renderChildren(enrichments) { + const children = enrichments.children; + if (!children?.length) return; + + const hasSubLabel = children.some((c) => c.subLabel); + const hasOwner = children.some((c) => c.owner); + const hasStatus = children.some((c) => c.status); + const hasCreatedBy = children.some((c) => c.createdBy); + const hasCreatedAt = children.some((c) => c.createdAt); + + const thead = document.getElementById("children-thead"); + thead.innerHTML = ""; + const headerRow = document.createElement("tr"); + const headers = ["Name"]; + if (hasSubLabel) headers.push("Type"); + if (hasOwner) headers.push("Owner"); + if (hasStatus) headers.push("Status"); + if (hasCreatedBy) headers.push("Created By"); + if (hasCreatedAt) headers.push("Created At"); + headers.forEach((h) => { + const th = document.createElement("th"); + th.textContent = h; + headerRow.appendChild(th); + }); + thead.appendChild(headerRow); + + const tbody = document.getElementById("children-body"); + tbody.innerHTML = ""; + setText("children-title", enrichments.childrenTitle ?? "Contents"); + setText("children-count", String(children.length)); + + for (const child of children) { + const tr = document.createElement("tr"); + + const tdName = document.createElement("td"); + tdName.className = "col-name"; + if (child.nodeData) { + const btn = document.createElement("button"); + btn.className = "child-link"; + btn.textContent = child.label; + btn.addEventListener("click", () => { + vscode?.postMessage({command: "navigate", nodeData: child.nodeData}); + }); + tdName.appendChild(btn); + } else { + tdName.textContent = child.label; + } + tr.appendChild(tdName); + + if (hasSubLabel) { + const td = document.createElement("td"); + td.style.whiteSpace = "nowrap"; + if (child.subLabel) td.appendChild(makeTypeChip(child.subLabel)); + tr.appendChild(td); + } + if (hasOwner) { + const td = document.createElement("td"); + td.textContent = child.owner ?? ""; + tr.appendChild(td); + } + if (hasStatus) { + const td = document.createElement("td"); + td.textContent = child.status ?? ""; + tr.appendChild(td); + } + if (hasCreatedBy) { + const td = document.createElement("td"); + td.textContent = child.createdBy ?? ""; + tr.appendChild(td); + } + if (hasCreatedAt) { + const td = document.createElement("td"); + td.textContent = child.createdAt ? formatDate(child.createdAt) : ""; + tr.appendChild(td); + } + + tbody.appendChild(tr); + } + + show("section-children", true); +} + +function renderCustomProperties(enrichments) { + const extraList = document.getElementById("extra-props-list"); + if (!extraList) return; + extraList.innerHTML = ""; + let hasExtra = false; + + if (enrichments.customProperties) { + for (const [key, value] of Object.entries( + enrichments.customProperties + )) { + addProp(extraList, key, value); + hasExtra = true; + } + } + if (enrichments.rowFilter) { + addProp(extraList, "Row filter", enrichments.rowFilter.functionName); + hasExtra = true; + } + if (enrichments.pipelineId) { + addProp(extraList, "Pipeline", enrichments.pipelineId); + hasExtra = true; + } + if (hasExtra) { + show("section-extra-props", true); + showTabBtn("details", true); + } +} + +/* ── Page controller ── */ + +const page = { + showLoading() { + document.body.className = "loading"; + }, + + renderNode(data) { + document.body.className = "content"; + + const KIND_LABEL = { + registeredModel: "model", + modelVersion: "model version", + }; + const titleEl = document.getElementById("section-properties-title"); + if (titleEl) { + titleEl.textContent = `About this ${ + KIND_LABEL[data.kind] ?? data.kind + }`; + } + + renderHeader(data); + + const propsList = document.getElementById("props-list"); + propsList.innerHTML = ""; + renderKindProps(propsList, data); + + renderComment(data); + renderDefinition(data); + resetForNewNode(); + renderActions(data); + }, + + renderEnrichments(enrichments) { + if (enrichments.columns?.length) { + buildColumns(enrichments.columns); + show("section-columns", true); + } + renderTags(enrichments.tags); + renderPermissions(enrichments.permissions); + renderMonitor(enrichments.monitor); + renderConstraints(enrichments.constraints); + renderCustomProperties(enrichments); + renderChildren(enrichments); + }, +}; + +document.addEventListener("DOMContentLoaded", initTabs); + +window.addEventListener("message", (e) => { + page[e.data.fn]?.(...e.data.args); +}); diff --git a/packages/databricks-vscode/src/extension.ts b/packages/databricks-vscode/src/extension.ts index a99488748..55de77eeb 100644 --- a/packages/databricks-vscode/src/extension.ts +++ b/packages/databricks-vscode/src/extension.ts @@ -1,6 +1,7 @@ import { commands, debug, + env, ExtensionContext, extensions, window, @@ -26,6 +27,7 @@ import { FileUtils, PackageJsonUtils, TerraformUtils, + UrlUtils, UtilsCommands, } from "./utils"; import {ConfigureAutocomplete} from "./language/ConfigureAutocomplete"; @@ -74,6 +76,11 @@ import {SyncCommands} from "./sync/SyncCommands"; import {CodeSynchronizer} from "./sync"; import {BundlePipelinesManager} from "./bundle/BundlePipelinesManager"; import {DocsViewTreeDataProvider} from "./ui/docs-view/DocsViewTreeDataProvider"; +import { + UnityCatalogTreeDataProvider, + UnityCatalogTreeNode, +} from "./ui/unity-catalog/UnityCatalogTreeDataProvider"; +import {registerDetailPanel} from "./ui/unity-catalog/registerDetailPanel"; // eslint-disable-next-line @typescript-eslint/no-var-requires const packageJson = require("../package.json"); @@ -405,6 +412,127 @@ export async function activate( ) ); + const unityCatalogTreeDataProvider = new UnityCatalogTreeDataProvider( + connectionManager, + stateStorage, + context.extensionPath + ); + const unityCatalogTreeView = window.createTreeView("unityCatalogView", { + treeDataProvider: unityCatalogTreeDataProvider, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + filterOnType: true, + } as any); + context.subscriptions.push( + unityCatalogTreeDataProvider, + unityCatalogTreeView, + telemetry.registerCommand( + "databricks.unityCatalog.refresh", + unityCatalogTreeDataProvider.refresh, + unityCatalogTreeDataProvider + ), + telemetry.registerCommand( + "databricks.unityCatalog.refreshNode", + (node: UnityCatalogTreeNode) => + unityCatalogTreeDataProvider.refreshNode(node) + ), + telemetry.registerCommand( + "databricks.unityCatalog.copyStorageLocation", + async (node: UnityCatalogTreeNode) => { + if ( + (node.kind === "table" || node.kind === "volume") && + node.storageLocation + ) { + await env.clipboard.writeText(node.storageLocation); + } + } + ), + telemetry.registerCommand( + "databricks.unityCatalog.copyViewSql", + async (node: UnityCatalogTreeNode) => { + if (node.kind === "table" && node.viewDefinition) { + await env.clipboard.writeText(node.viewDefinition); + } + } + ), + telemetry.registerCommand( + "databricks.unityCatalog.copyName", + async (node: UnityCatalogTreeNode) => { + if ( + node.kind === "error" || + node.kind === "empty" || + node.kind === "favorites" || + node.kind === "group" + ) { + return; + } + const text = node.kind === "column" ? node.name : node.fullName; + await env.clipboard.writeText(text); + window.showInformationMessage("Copied to clipboard"); + } + ), + telemetry.registerCommand( + "databricks.unityCatalog.openExternal", + async (node: UnityCatalogTreeNode) => { + if (node.kind === "error" || node.kind === "column") { + return; + } + const url = + unityCatalogTreeDataProvider.getNodeExploreUrl(node); + if (!url) { + window.showErrorMessage( + "Databricks: Can't open external link. No URL found." + ); + return; + } + await UrlUtils.openExternal(url); + } + ), + commands.registerCommand("databricks.unityCatalog.filter", async () => { + await commands.executeCommand("unityCatalogView.focus"); + await commands.executeCommand("list.find"); + }), + telemetry.registerCommand( + "databricks.unityCatalog.pin", + (node: UnityCatalogTreeNode) => { + if ( + node.kind === "catalog" || + node.kind === "schema" || + node.kind === "table" || + node.kind === "volume" || + node.kind === "function" || + node.kind === "registeredModel" || + node.kind === "modelVersion" + ) { + return unityCatalogTreeDataProvider.pin(node); + } + } + ), + telemetry.registerCommand( + "databricks.unityCatalog.unpin", + (node: UnityCatalogTreeNode) => { + if ( + node.kind === "catalog" || + node.kind === "schema" || + node.kind === "table" || + node.kind === "volume" || + node.kind === "function" || + node.kind === "registeredModel" || + node.kind === "modelVersion" + ) { + return unityCatalogTreeDataProvider.unpin(node); + } + } + ), + ...registerDetailPanel( + context.extensionUri, + connectionManager, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + unityCatalogTreeView as any, + unityCatalogTreeDataProvider, + telemetry + ) + ); + const configureAutocomplete = new ConfigureAutocomplete( context, stateStorage, diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogDetailPanel.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogDetailPanel.ts new file mode 100644 index 000000000..7fa0d3e5f --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogDetailPanel.ts @@ -0,0 +1,232 @@ +import {Disposable, Uri, ViewColumn, WebviewPanel, window, env} from "vscode"; +import * as fs from "node:fs/promises"; +import {UnityCatalogTreeNode} from "./types"; +import {NodeEnrichments} from "./detailLoader"; + +export class UnityCatalogDetailPanel implements Disposable { + private static readonly VIEW_TYPE = "databricks.unityCatalogDetail"; + private static instance: UnityCatalogDetailPanel | undefined; + private static navigationHandler: + | ((node: UnityCatalogTreeNode) => void) + | undefined; + + static setNavigationHandler( + handler: (node: UnityCatalogTreeNode) => void + ): void { + UnityCatalogDetailPanel.navigationHandler = handler; + } + + private constructor( + private panel: WebviewPanel, + private readonly webviewContent: string + ) { + panel.webview.html = webviewContent; + panel.webview.onDidReceiveMessage((msg) => { + if (msg.command === "copyText") { + env.clipboard.writeText(msg.text); + } + if ( + msg.command === "navigate" && + UnityCatalogDetailPanel.navigationHandler + ) { + UnityCatalogDetailPanel.navigationHandler(msg.nodeData); + } + }); + panel.onDidDispose(() => { + UnityCatalogDetailPanel.instance = undefined; + }); + } + + static async getOrCreate( + extensionUri: Uri + ): Promise { + if (UnityCatalogDetailPanel.instance) { + UnityCatalogDetailPanel.instance.panel.reveal(undefined, true); + return UnityCatalogDetailPanel.instance; + } + const panel = window.createWebviewPanel( + UnityCatalogDetailPanel.VIEW_TYPE, + "Unity Catalog", + {viewColumn: ViewColumn.Beside, preserveFocus: true}, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + Uri.joinPath(extensionUri, "resources"), + Uri.joinPath(extensionUri, "out"), + ], + } + ); + const content = await UnityCatalogDetailPanel.getWebviewContent( + panel, + extensionUri + ); + const instance = new UnityCatalogDetailPanel(panel, content); + UnityCatalogDetailPanel.instance = instance; + return instance; + } + + showNode( + node: Exclude< + UnityCatalogTreeNode, + {kind: "error" | "empty" | "column"} + >, + exploreUrl: string | undefined + ): void { + this.panel.title = UnityCatalogDetailPanel.titleFor(node); + this.panel.webview.postMessage({ + fn: "renderNode", + args: [{...node, exploreUrl}], + }); + } + + enrichNode(enrichments: NodeEnrichments): void { + this.panel.webview.postMessage({ + fn: "renderEnrichments", + args: [enrichments], + }); + } + + showLoading(title: string): void { + this.panel.title = title; + this.panel.webview.postMessage({fn: "showLoading", args: []}); + } + + dispose(): void { + this.panel.dispose(); + } + + private static titleFor(node: { + kind: string; + name?: string; + fullName?: string; + version?: number; + }): string { + const labels: Record = { + catalog: "Catalog", + schema: "Schema", + table: "Table", + volume: "Volume", + function: "Function", + registeredModel: "Model", + modelVersion: "Model Version", + }; + const label = labels[node.kind] ?? node.kind; + const name = + node.kind === "modelVersion" + ? `v${node.version}` + : node.name ?? node.fullName ?? ""; + return `${label}: ${name}`; + } + + private static getAssetUri( + panel: WebviewPanel, + extensionUri: Uri, + filename: string + ): Uri { + return panel.webview.asWebviewUri( + Uri.joinPath(extensionUri, "out", filename) + ); + } + + private static buildIconUriMap( + panel: WebviewPanel, + extensionUri: Uri + ): Record> { + const iconFiles: Array<{key: string; file: string}> = [ + {key: "catalog", file: "catalog.svg"}, + {key: "catalog-main", file: "catalog-main.svg"}, + {key: "catalog-samples", file: "catalog-samples.svg"}, + {key: "catalog-system", file: "catalog-system.svg"}, + {key: "schema", file: "schema.svg"}, + {key: "table", file: "table.svg"}, + {key: "volume", file: "volume.svg"}, + {key: "function", file: "function.svg"}, + {key: "registeredModel", file: "registered-model.svg"}, + {key: "modelVersion", file: "model-version.svg"}, + {key: "column", file: "column.svg"}, + ]; + const result: Record> = { + dark: {}, + light: {}, + }; + for (const theme of ["dark", "light"] as const) { + for (const {key, file} of iconFiles) { + result[theme][key] = panel.webview + .asWebviewUri( + Uri.joinPath( + extensionUri, + "resources", + theme, + "unity-catalog", + file + ) + ) + .toString(); + } + } + return result; + } + + private static async getWebviewContent( + panel: WebviewPanel, + extensionUri: Uri + ): Promise { + const webviewDir = Uri.joinPath( + extensionUri, + "resources", + "webview-ui" + ); + const [html, css, js] = await Promise.all([ + fs.readFile( + Uri.joinPath(webviewDir, "uc-detail.html").fsPath, + "utf8" + ), + fs.readFile( + Uri.joinPath(webviewDir, "uc-detail.css").fsPath, + "utf8" + ), + fs.readFile( + Uri.joinPath(webviewDir, "uc-detail.js").fsPath, + "utf8" + ), + ]); + const iconUris = UnityCatalogDetailPanel.buildIconUriMap( + panel, + extensionUri + ); + return html + .replace("", ``) + .replace( + "", + `` + ) + .replace("", ``) + .replace( + /src="[^"]*\/toolkit\.js"/g, + `src="${UnityCatalogDetailPanel.getAssetUri( + panel, + extensionUri, + "toolkit.js" + )}"` + ) + .replace( + /src="[^"]*\/markdown-it\.min\.js"/g, + `src="${UnityCatalogDetailPanel.getAssetUri( + panel, + extensionUri, + "markdown-it.min.js" + )}"` + ) + .replace( + /src="[^"]*\/highlight\.min\.js"/g, + `src="${UnityCatalogDetailPanel.getAssetUri( + panel, + extensionUri, + "highlight.min.js" + )}"` + ); + } +} diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts new file mode 100644 index 000000000..dd5d3d041 --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts @@ -0,0 +1,923 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import assert from "assert"; +import {anything, instance, mock, when} from "ts-mockito"; +import {Disposable} from "vscode"; +import {WorkspaceClient} from "@databricks/sdk-experimental"; +import { + CatalogsService, + FunctionsService, + ModelVersionsService, + RegisteredModelsService, + SchemasService, + TablesService, + VolumesService, +} from "@databricks/sdk-experimental/dist/apis/catalog/api"; +import { + ConnectionManager, + ConnectionState, +} from "../../configuration/ConnectionManager"; +import {resolveProviderResult} from "../../test/utils"; +import { + UnityCatalogTreeDataProvider, + UnityCatalogTreeItem, + UnityCatalogTreeNode, +} from "./UnityCatalogTreeDataProvider"; +import {StateStorage} from "../../vscode-objs/StateStorage"; + +describe(__filename, () => { + let disposables: Disposable[] = []; + let mockConnectionManager: ConnectionManager; + let stubStateStorage: StateStorage; + let mockWorkspaceClient: WorkspaceClient; + let mockCatalogs: CatalogsService; + let mockSchemas: SchemasService; + let mockTables: TablesService; + let mockVolumes: VolumesService; + let mockFunctions: FunctionsService; + let mockRegisteredModels: RegisteredModelsService; + let mockModelVersions: ModelVersionsService; + let onDidChangeStateHandler: (s: ConnectionState) => void; + + beforeEach(() => { + disposables = []; + onDidChangeStateHandler = () => {}; + stubStateStorage = { + get: () => [] as string[], + set: async () => {}, + onDidChange: () => ({dispose() {}}), + } as unknown as StateStorage; + + mockCatalogs = mock(CatalogsService); + when(mockCatalogs.list(anything())).thenCall(() => { + async function* impl() { + yield {name: "c_b", full_name: "c_b"}; + yield {name: "c_a", full_name: "c_a"}; + } + return impl(); + }); + + mockSchemas = mock(SchemasService); + when(mockSchemas.list(anything())).thenCall(() => { + async function* impl() { + yield {name: "s_b", full_name: "cat.s_b"}; + yield {name: "s_a", full_name: "cat.s_a"}; + } + return impl(); + }); + + mockTables = mock(TablesService); + when(mockTables.list(anything())).thenCall(() => { + async function* impl() { + yield { + name: "t1", + full_name: "cat.sch.t1", + table_type: "MANAGED", + data_source_format: "DELTA", + comment: "a test table", + owner: "alice", + columns: [ + { + name: "id", + type_text: "bigint", + nullable: false, + position: 0, + }, + { + name: "name", + type_text: "string", + nullable: true, + position: 1, + }, + ], + }; + } + return impl(); + }); + + mockVolumes = mock(VolumesService); + when(mockVolumes.list(anything())).thenCall(() => { + async function* impl() { + yield { + name: "v1", + full_name: "cat.sch.v1", + volume_type: "MANAGED", + }; + } + return impl(); + }); + + mockFunctions = mock(FunctionsService); + when(mockFunctions.list(anything())).thenCall(() => { + async function* impl() { + yield { + name: "f1", + catalog_name: "cat", + schema_name: "sch", + }; + } + return impl(); + }); + + mockRegisteredModels = mock(RegisteredModelsService); + when(mockRegisteredModels.list(anything())).thenCall(() => { + async function* impl() { + /* empty */ + } + return impl(); + }); + + mockModelVersions = mock(ModelVersionsService); + when(mockModelVersions.list(anything())).thenCall(() => { + async function* impl() { + /* empty */ + } + return impl(); + }); + + mockWorkspaceClient = mock(WorkspaceClient); + when(mockWorkspaceClient.catalogs).thenReturn(instance(mockCatalogs)); + when(mockWorkspaceClient.schemas).thenReturn(instance(mockSchemas)); + when(mockWorkspaceClient.tables).thenReturn(instance(mockTables)); + when(mockWorkspaceClient.volumes).thenReturn(instance(mockVolumes)); + when(mockWorkspaceClient.functions).thenReturn(instance(mockFunctions)); + when(mockWorkspaceClient.registeredModels).thenReturn( + instance(mockRegisteredModels) + ); + when(mockWorkspaceClient.modelVersions).thenReturn( + instance(mockModelVersions) + ); + + mockConnectionManager = mock(ConnectionManager); + when(mockConnectionManager.workspaceClient).thenReturn( + instance(mockWorkspaceClient) + ); + when(mockConnectionManager.onDidChangeState).thenReturn( + (cb: (s: ConnectionState) => void) => { + onDidChangeStateHandler = cb; + return {dispose() {}}; + } + ); + }); + + afterEach(() => { + disposables.forEach((d) => d.dispose()); + }); + + it("returns undefined when not connected", async () => { + when(mockConnectionManager.workspaceClient).thenReturn(undefined); + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const children = await resolveProviderResult(provider.getChildren()); + assert.strictEqual(children, undefined); + }); + + it("lists catalogs sorted by name", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const children = (await resolveProviderResult( + provider.getChildren() + )) as UnityCatalogTreeNode[]; + assert(children); + assert.strictEqual(children.length, 2); + const first = children[0]; + const second = children[1]; + assert.strictEqual(first.kind, "catalog"); + assert.strictEqual(second.kind, "catalog"); + if (first.kind !== "catalog" || second.kind !== "catalog") { + assert.fail("expected catalogs"); + } + assert.strictEqual(first.name, "c_a"); + assert.strictEqual(second.name, "c_b"); + }); + + it("lists schemas under a catalog", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const catalog: UnityCatalogTreeNode = { + kind: "catalog", + name: "cat", + fullName: "cat", + }; + const children = (await resolveProviderResult( + provider.getChildren(catalog) + )) as UnityCatalogTreeNode[]; + + assert(children); + assert.strictEqual(children.length, 2); + assert.strictEqual(children[0].kind, "schema"); + assert.strictEqual(children[0].name, "s_a"); + assert.strictEqual( + (children[0] as {catalogName: string}).catalogName, + "cat" + ); + }); + + it("lists tables, volumes, and functions under a schema", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const schema: UnityCatalogTreeNode = { + kind: "schema", + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + const groups = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + + assert(groups); + assert.strictEqual(groups.length, 3); + assert(groups.every((g) => g.kind === "group")); + const groupTypes = groups + .map( + (g) => + (g as Extract) + .groupType + ) + .sort(); + assert.deepStrictEqual(groupTypes, ["functions", "tables", "volumes"]); + + // Expand the tables group + const tablesGroup = groups.find( + (g) => g.kind === "group" && (g as any).groupType === "tables" + ) as UnityCatalogTreeNode; + const tableChildren = (await resolveProviderResult( + provider.getChildren(tablesGroup) + )) as UnityCatalogTreeNode[]; + assert(tableChildren && tableChildren.length === 1); + assert(tableChildren[0].kind === "table"); + assert.strictEqual((tableChildren[0] as any).name, "t1"); + + // Expand the volumes group + const volumesGroup = groups.find( + (g) => g.kind === "group" && (g as any).groupType === "volumes" + ) as UnityCatalogTreeNode; + const volumeChildren = (await resolveProviderResult( + provider.getChildren(volumesGroup) + )) as UnityCatalogTreeNode[]; + assert(volumeChildren && volumeChildren.length === 1); + assert(volumeChildren[0].kind === "volume"); + assert.strictEqual((volumeChildren[0] as any).name, "v1"); + + // Expand the functions group + const functionsGroup = groups.find( + (g) => g.kind === "group" && (g as any).groupType === "functions" + ) as UnityCatalogTreeNode; + const fnChildren = (await resolveProviderResult( + provider.getChildren(functionsGroup) + )) as UnityCatalogTreeNode[]; + assert(fnChildren && fnChildren.length === 1); + assert(fnChildren[0].kind === "function"); + assert.strictEqual((fnChildren[0] as any).name, "f1"); + assert.strictEqual((fnChildren[0] as any).fullName, "cat.sch.f1"); + }); + + it("fires onDidChangeTreeData when connection state changes", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + let count = 0; + disposables.push( + provider.onDidChangeTreeData(() => { + count += 1; + }) + ); + + assert.strictEqual(count, 0); + onDidChangeStateHandler("CONNECTED"); + assert.strictEqual(count, 1); + }); + + it("getTreeItem sets url when host is available", async () => { + const stubManager = { + onDidChangeState: () => ({dispose() {}}), + databricksWorkspace: { + host: new URL("https://adb-123.azuredatabricks.net/"), + }, + } as unknown as ConnectionManager; + + const provider = new UnityCatalogTreeDataProvider( + stubManager, + stubStateStorage + ); + disposables.push(provider); + + const catalog: UnityCatalogTreeNode = { + kind: "catalog", + name: "cat", + fullName: "cat", + }; + const item = provider.getTreeItem(catalog) as UnityCatalogTreeItem; + + assert(item.url, "url should be set"); + assert( + item.url!.includes("explore/data/cat"), + `url should contain explore/data/cat, got: ${item.url}` + ); + assert( + item.contextValue?.endsWith(".has-url"), + `contextValue should end with .has-url, got: ${item.contextValue}` + ); + assert.strictEqual(item.copyText, "cat"); + }); + + it("getTreeItem omits url when no host", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const catalog: UnityCatalogTreeNode = { + kind: "catalog", + name: "cat", + fullName: "cat", + }; + const item = provider.getTreeItem(catalog) as UnityCatalogTreeItem; + + assert.strictEqual(item.url, undefined); + assert.strictEqual(item.contextValue, "unityCatalog.catalog"); + }); + + it("getTreeItem for function node", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const fn: UnityCatalogTreeNode = { + kind: "function", + catalogName: "cat", + schemaName: "sch", + name: "f1", + fullName: "cat.sch.f1", + }; + const item = provider.getTreeItem(fn) as UnityCatalogTreeItem; + + assert.strictEqual(item.label, "f1"); + assert.strictEqual(item.copyText, "cat.sch.f1"); + assert( + item.contextValue === "unityCatalog.function" || + item.contextValue === "unityCatalog.function.has-url" + ); + }); + + it("table node carries enriched fields", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const schema: UnityCatalogTreeNode = { + kind: "schema", + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + const groups = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + + const tablesGroup = groups.find( + (g) => g.kind === "group" && (g as any).groupType === "tables" + ) as UnityCatalogTreeNode; + assert(tablesGroup, "expected a tables group"); + const tableChildren = (await resolveProviderResult( + provider.getChildren(tablesGroup) + )) as UnityCatalogTreeNode[]; + + const table = tableChildren.find((c) => c.kind === "table"); + assert(table && table.kind === "table"); + assert.strictEqual(table.dataSourceFormat, "DELTA"); + assert.strictEqual(table.comment, "a test table"); + assert.strictEqual(table.owner, "alice"); + assert(table.columns && table.columns.length === 2); + assert.strictEqual(table.columns[0].name, "id"); + assert.strictEqual(table.columns[0].typeText, "bigint"); + assert.strictEqual(table.columns[0].nullable, false); + }); + + it("getChildren for table with columns returns sorted column nodes", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const tableNode: UnityCatalogTreeNode = { + kind: "table", + catalogName: "cat", + schemaName: "sch", + name: "t1", + fullName: "cat.sch.t1", + columns: [ + {name: "b_col", typeText: "string", position: 1}, + {name: "a_col", typeText: "bigint", position: 0}, + ], + }; + const children = (await resolveProviderResult( + provider.getChildren(tableNode) + )) as UnityCatalogTreeNode[]; + + assert(children); + assert.strictEqual(children.length, 2); + assert.strictEqual(children[0].kind, "column"); + if (children[0].kind === "column") { + assert.strictEqual(children[0].name, "a_col"); + } + assert.strictEqual(children[1].kind, "column"); + if (children[1].kind === "column") { + assert.strictEqual(children[1].name, "b_col"); + } + }); + + it("getChildren for table without columns returns undefined", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const tableNode: UnityCatalogTreeNode = { + kind: "table", + catalogName: "cat", + schemaName: "sch", + name: "t1", + fullName: "cat.sch.t1", + columns: [], + }; + const children = await resolveProviderResult( + provider.getChildren(tableNode) + ); + assert.strictEqual(children, undefined); + }); + + it("getTreeItem for EXTERNAL table with storage has has-storage in contextValue", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const tableNode: UnityCatalogTreeNode = { + kind: "table", + catalogName: "cat", + schemaName: "sch", + name: "ext", + fullName: "cat.sch.ext", + tableType: "EXTERNAL", + storageLocation: "s3://bucket/path", + }; + const item = provider.getTreeItem(tableNode) as UnityCatalogTreeItem; + assert( + item.contextValue?.includes("has-storage"), + `expected has-storage in contextValue, got: ${item.contextValue}` + ); + assert.strictEqual(item.storageLocation, "s3://bucket/path"); + }); + + it("getTreeItem for VIEW table with view_definition has is-view in contextValue", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const tableNode: UnityCatalogTreeNode = { + kind: "table", + catalogName: "cat", + schemaName: "sch", + name: "vw", + fullName: "cat.sch.vw", + tableType: "VIEW", + viewDefinition: "SELECT 1", + }; + const item = provider.getTreeItem(tableNode) as UnityCatalogTreeItem; + assert( + item.contextValue?.includes("is-view"), + `expected is-view in contextValue, got: ${item.contextValue}` + ); + assert.strictEqual(item.viewDefinition, "SELECT 1"); + }); + + it("volume node carries volumeType and shows EXTERNAL label suffix", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const volNode: UnityCatalogTreeNode = { + kind: "volume", + catalogName: "cat", + schemaName: "sch", + name: "ev", + fullName: "cat.sch.ev", + volumeType: "EXTERNAL", + storageLocation: "s3://bucket/vol", + }; + const item = provider.getTreeItem(volNode) as UnityCatalogTreeItem; + assert.strictEqual(item.label, "ev (EXTERNAL)"); + assert( + item.contextValue?.includes("has-storage"), + `expected has-storage in contextValue, got: ${item.contextValue}` + ); + }); + + it("catalog node carries comment", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const catNode: UnityCatalogTreeNode = { + kind: "catalog", + name: "cat", + fullName: "cat", + comment: "my catalog", + }; + const item = provider.getTreeItem(catNode) as UnityCatalogTreeItem; + assert.strictEqual(item.label, "cat"); + }); + + it("returns error when functions API throws", async () => { + when(mockFunctions.list(anything())).thenCall(() => { + async function* impl(): AsyncGenerator { + throw new Error("functions API unavailable"); + // eslint-disable-next-line no-unreachable + yield undefined as never; + } + return impl(); + }); + + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const schema: UnityCatalogTreeNode = { + kind: "schema", + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + const children = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + + assert(children); + // allSettled: tables (t1) and volumes (v1) succeed; functions errors + // groups appear first, error node surfaces at schema level + const groupNodes = children.filter((c) => c.kind === "group"); + const errorNodes = children.filter((c) => c.kind === "error"); + assert.strictEqual(groupNodes.length, 2); // tables, volumes + assert.strictEqual(errorNodes.length, 1); + }); + + it("lists registered models under a schema", async () => { + when(mockRegisteredModels.list(anything())).thenCall(() => { + async function* impl() { + yield {name: "m1", full_name: "cat.sch.m1"}; + } + return impl(); + }); + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + const schema: UnityCatalogTreeNode = { + kind: "schema", + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + const groups = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + + const modelsGroup = groups.find( + (g) => g.kind === "group" && (g as any).groupType === "models" + ) as UnityCatalogTreeNode; + assert(modelsGroup, "expected a models group"); + const modelChildren = (await resolveProviderResult( + provider.getChildren(modelsGroup) + )) as UnityCatalogTreeNode[]; + + const model = modelChildren.find((c) => c.kind === "registeredModel"); + assert(model && model.kind === "registeredModel"); + assert.strictEqual(model.name, "m1"); + assert.strictEqual(model.fullName, "cat.sch.m1"); + }); + + it("lists model versions for a registered model, sorted descending", async () => { + when(mockModelVersions.list(anything())).thenCall(() => { + async function* impl() { + yield {version: 1}; + yield {version: 3}; + yield {version: 2}; + } + return impl(); + }); + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + const modelNode: UnityCatalogTreeNode = { + kind: "registeredModel", + catalogName: "cat", + schemaName: "sch", + name: "m1", + fullName: "cat.sch.m1", + }; + const children = (await resolveProviderResult( + provider.getChildren(modelNode) + )) as UnityCatalogTreeNode[]; + assert(children); + assert.strictEqual(children.length, 3); + assert.strictEqual(children[0].kind, "modelVersion"); + if (children[0].kind === "modelVersion") { + assert.strictEqual(children[0].version, 3); + assert.strictEqual((children[2] as any).version, 1); + } + }); + + it("pin adds schema to favorites and fires tree change", async () => { + const storageMap = new Map([ + ["databricks.unityCatalog.favorites", []], + ]); + const spyStorage = { + get: (key: string) => storageMap.get(key) ?? [], + set: async (key: string, val: unknown) => { + storageMap.set(key, val); + }, + onDidChange: () => ({dispose() {}}), + } as unknown as StateStorage; + const p = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + spyStorage + ); + disposables.push(p); + let fired = 0; + disposables.push( + p.onDidChangeTreeData(() => { + fired++; + }) + ); + const schema = { + kind: "schema" as const, + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + await p.pin(schema); + assert( + (storageMap.get("databricks.unityCatalog.favorites") as any[]).some( + (f) => f.fullName === "cat.sch" + ) + ); + assert.strictEqual(fired, 1); + }); + + it("unpin removes schema from favorites and fires tree change", async () => { + const storageMap = new Map([ + [ + "databricks.unityCatalog.favorites", + [ + { + kind: "schema", + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }, + { + kind: "schema", + catalogName: "cat", + name: "other", + fullName: "cat.other", + }, + ], + ], + ]); + const spyStorage = { + get: (key: string) => storageMap.get(key) ?? [], + set: async (key: string, val: unknown) => { + storageMap.set(key, val); + }, + onDidChange: () => ({dispose() {}}), + } as unknown as StateStorage; + const p = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + spyStorage + ); + disposables.push(p); + let fired = 0; + disposables.push( + p.onDidChangeTreeData(() => { + fired++; + }) + ); + const schema = { + kind: "schema" as const, + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + await p.unpin(schema); + const favorites = storageMap.get( + "databricks.unityCatalog.favorites" + ) as any[]; + assert(!favorites.some((f) => f.fullName === "cat.sch")); + assert(favorites.some((f) => f.fullName === "cat.other")); + assert(fired >= 1); + }); + + it("group getChildren returns cached members for that type", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const schema: UnityCatalogTreeNode = { + kind: "schema", + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + // Populate the cache by loading schema children first + await resolveProviderResult(provider.getChildren(schema)); + + const tablesGroup: UnityCatalogTreeNode = { + kind: "group", + groupType: "tables", + catalogName: "cat", + schemaName: "sch", + schemaFullName: "cat.sch", + count: 1, + }; + const tableChildren = (await resolveProviderResult( + provider.getChildren(tablesGroup) + )) as UnityCatalogTreeNode[]; + + assert(tableChildren); + assert.strictEqual(tableChildren.length, 1); + assert.strictEqual(tableChildren[0].kind, "table"); + assert.strictEqual((tableChildren[0] as any).name, "t1"); + }); + + it("groups with zero members are omitted", async () => { + // No registered models → models group should not appear + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const schema: UnityCatalogTreeNode = { + kind: "schema", + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + const groups = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + + assert(groups); + const groupTypes = groups + .filter((g) => g.kind === "group") + .map((g) => (g as any).groupType); + assert( + !groupTypes.includes("models"), + "models group should be absent when no models exist" + ); + assert(groupTypes.includes("tables")); + assert(groupTypes.includes("volumes")); + assert(groupTypes.includes("functions")); + }); + + it("no grouping when schema has only one type of child", async () => { + // Only tables, no volumes or functions + when(mockVolumes.list(anything())).thenCall(() => { + async function* impl() { + /* empty */ + } + return impl(); + }); + when(mockFunctions.list(anything())).thenCall(() => { + async function* impl() { + /* empty */ + } + return impl(); + }); + + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const schema: UnityCatalogTreeNode = { + kind: "schema", + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + const children = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + + assert(children); + assert( + children.every((c) => c.kind !== "group"), + "should not return group nodes when only one type present" + ); + assert.strictEqual(children.length, 1); + assert.strictEqual(children[0].kind, "table"); + }); + + it("group node label includes child count", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const schema: UnityCatalogTreeNode = { + kind: "schema", + catalogName: "cat", + name: "sch", + fullName: "cat.sch", + }; + const groups = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + + const tablesGroup = groups.find( + (g) => g.kind === "group" && (g as any).groupType === "tables" + ) as UnityCatalogTreeNode; + assert(tablesGroup, "expected tables group"); + const item = provider.getTreeItem(tablesGroup); + assert.strictEqual(item.label, "Tables (1)"); + }); + + it("owned schema sorts before unowned", async () => { + const noFavStorage = { + get: () => [], + set: async () => {}, + onDidChange: () => ({dispose() {}}), + } as unknown as StateStorage; + when(mockSchemas.list(anything())).thenCall(() => { + async function* impl() { + yield {name: "s_c", full_name: "cat.s_c", owner: "carol"}; + yield {name: "s_b", full_name: "cat.s_b", owner: "bob"}; + yield {name: "s_a", full_name: "cat.s_a", owner: "alice"}; // owned + } + return impl(); + }); + const stubManager = { + onDidChangeState: () => ({dispose() {}}), + workspaceClient: instance(mockWorkspaceClient), + databricksWorkspace: {user: {userName: "alice"}}, + } as unknown as ConnectionManager; + const p = new UnityCatalogTreeDataProvider(stubManager, noFavStorage); + disposables.push(p); + const catalog: UnityCatalogTreeNode = { + kind: "catalog", + name: "cat", + fullName: "cat", + }; + const children = (await resolveProviderResult( + p.getChildren(catalog) + )) as UnityCatalogTreeNode[]; + assert.strictEqual((children[0] as any).name, "s_a"); // owned first + assert.strictEqual((children[1] as any).name, "s_b"); // alphabetical + assert.strictEqual((children[2] as any).name, "s_c"); // alphabetical + }); +}); diff --git a/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts new file mode 100644 index 000000000..c9463e333 --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -0,0 +1,386 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {Disposable, EventEmitter, TreeDataProvider} from "vscode"; +import {ConnectionManager} from "../../configuration/ConnectionManager"; +import {buildTreeItem} from "./nodeRenderer"; +import { + PinnableNodeKind, + StoredFavoriteNode, + UnityCatalogTreeItem, + UnityCatalogTreeNode, +} from "./types"; +import {StateStorage} from "../../vscode-objs/StateStorage"; +import { + loadCatalogs, + loadSchemas, + loadSchemaChildren, + loadModelVersions, +} from "./loaders"; + +export type { + ColumnData, + PinnableNodeKind, + StoredFavoriteNode, + UnityCatalogTreeItem, + UnityCatalogTreeNode, +} from "./types"; + +export class UnityCatalogTreeDataProvider + implements TreeDataProvider, Disposable +{ + private readonly _onDidChangeTreeData = new EventEmitter< + UnityCatalogTreeNode | undefined | void + >(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + private readonly disposables: Disposable[] = []; + private readonly childrenCache = new Map(); + private readonly favoritesRootNode: UnityCatalogTreeNode = { + kind: "favorites", + }; + private catalogsCache: UnityCatalogTreeNode[] | undefined = undefined; + + constructor( + private readonly connectionManager: ConnectionManager, + private readonly stateStorage: StateStorage, + private readonly extensionPath: string = "" + ) { + this.disposables.push( + this.connectionManager.onDidChangeState(() => { + this.catalogsCache = undefined; + this._onDidChangeTreeData.fire(undefined); + }) + ); + } + + private getFavorites(): StoredFavoriteNode[] { + return this.stateStorage.get("databricks.unityCatalog.favorites") ?? []; + } + + private fireFavoritesChanged( + treeNeedsFullRefresh: boolean, + node: Extract + ): void { + if (treeNeedsFullRefresh) { + this._onDidChangeTreeData.fire(undefined); + } else { + this._onDidChangeTreeData.fire(this.favoritesRootNode); + this._onDidChangeTreeData.fire(node); + } + } + + private async getChildrenCached( + key: string, + loader: () => Promise + ): Promise { + const cached = this.childrenCache.get(key); + if (cached) { + return cached; + } + const result = await loader(); + this.childrenCache.set(key, result); + return result; + } + + private getExploreUrl(path: string): string | undefined { + const host = this.connectionManager.databricksWorkspace?.host; + if (!host) { + return undefined; + } + return `${host.toString()}explore/data/${path}`; + } + + getNodeExploreUrl(node: UnityCatalogTreeNode): string | undefined { + if ( + node.kind === "error" || + node.kind === "column" || + node.kind === "empty" || + node.kind === "favorites" || + node.kind === "group" + ) { + return undefined; + } + const fullNamePath = node.fullName.replaceAll(".", "/"); + let path = fullNamePath; + switch (node.kind) { + case "registeredModel": + path = `models/${fullNamePath}`; + break; + case "modelVersion": + path = `models/${fullNamePath}/version/${node.version}`; + break; + case "function": + path = `functions/${fullNamePath}`; + break; + } + return this.getExploreUrl(path); + } + + getTreeItem(element: UnityCatalogTreeNode): UnityCatalogTreeItem { + if (element.kind === "favorites") { + return buildTreeItem(element, undefined, false, this.extensionPath); + } + const favorites = this.getFavorites(); + const isPinned = + "fullName" in element && + favorites.some( + (f) => + favoriteKey(f) === + favoriteKey(element as StoredFavoriteNode) + ); + return buildTreeItem( + element, + this.getNodeExploreUrl(element), + isPinned, + this.extensionPath + ); + } + + async getChildren( + element?: UnityCatalogTreeNode + ): Promise { + const client = this.connectionManager.workspaceClient; + if (!client) { + return undefined; + } + + const currentUser = this.connectionManager.databricksWorkspace?.user; + + if (!element) { + if (!this.catalogsCache) { + this.catalogsCache = await loadCatalogs(client, currentUser); + } + const favorites = this.getFavorites(); + return favorites.length > 0 + ? [this.favoritesRootNode, ...this.catalogsCache] + : [...this.catalogsCache]; + } + + if (element.kind === "favorites") { + const favorites = this.getFavorites(); + return favorites.length > 0 + ? (favorites as UnityCatalogTreeNode[]) + : [{kind: "empty", message: "No favorites yet"}]; + } + + if (element.kind === "error") { + return undefined; + } + + if (element.kind === "catalog") { + return this.getChildrenCached(element.fullName, () => + loadSchemas(client, element.name, currentUser) + ); + } + + if (element.kind === "schema") { + const groupedCached = this.childrenCache.get( + `${element.fullName}/.grouped` + ); + if (groupedCached) { + return groupedCached; + } + + const flat = await loadSchemaChildren( + client, + element.catalogName, + element.name + ); + + // Preserve flat list for getLoadedChildren() (used by detail panel) + this.childrenCache.set(element.fullName, flat); + + if (flat.length === 1 && flat[0].kind === "empty") { + this.childrenCache.set(`${element.fullName}/.grouped`, flat); + return flat; + } + + // Partition by type + const tables = flat.filter((n) => n.kind === "table"); + const volumes = flat.filter((n) => n.kind === "volume"); + const functions = flat.filter((n) => n.kind === "function"); + const models = flat.filter((n) => n.kind === "registeredModel"); + const errors = flat.filter((n) => n.kind === "error"); + + const groupTypes = [ + {key: "tables", items: tables}, + {key: "volumes", items: volumes}, + {key: "functions", items: functions}, + {key: "models", items: models}, + ] as const; + + for (const {key, items} of groupTypes) { + this.childrenCache.set(`${element.fullName}/.${key}`, items); + } + + const typedArrays = [tables, volumes, functions, models]; + const nonEmptyCount = typedArrays.filter( + (arr) => arr.length > 0 + ).length; + + // Only group when there are multiple types of children + if (nonEmptyCount <= 1) { + const result = [ + ...tables, + ...volumes, + ...functions, + ...models, + ...errors, + ]; + this.childrenCache.set(`${element.fullName}/.grouped`, result); + return result; + } + + const base = { + kind: "group" as const, + catalogName: element.catalogName, + schemaName: element.name, + schemaFullName: element.fullName, + }; + + const groups: UnityCatalogTreeNode[] = []; + for (const {key, items} of groupTypes) { + if (items.length > 0) { + groups.push({...base, groupType: key, count: items.length}); + } + } + + const result = [...groups, ...errors]; + this.childrenCache.set(`${element.fullName}/.grouped`, result); + return result; + } + + if (element.kind === "group") { + return this.childrenCache.get( + `${element.schemaFullName}/.${element.groupType}` + ); + } + + if (element.kind === "registeredModel") { + return this.getChildrenCached(element.fullName, () => + loadModelVersions(client, element) + ); + } + + if (element.kind === "table") { + let columns = element.columns; + if (!columns?.length) { + try { + const t = await client.tables.get({ + full_name: element.fullName, + }); + columns = (t.columns ?? []).map((col) => ({ + name: col.name!, + typeName: col.type_name, + typeText: col.type_text, + comment: col.comment, + nullable: col.nullable, + position: col.position, + })); + } catch { + return undefined; + } + } + if (!columns.length) { + return undefined; + } + return [...columns] + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) + .map((col) => ({ + kind: "column" as const, + tableFullName: element.fullName, + name: col.name, + typeName: col.typeName, + typeText: col.typeText, + comment: col.comment, + nullable: col.nullable, + position: col.position, + })); + } + + return undefined; + } + + async pin( + node: Extract + ): Promise { + const favorites = this.getFavorites(); + const nodeAsStored = node as StoredFavoriteNode; + if ( + favorites.some((f) => favoriteKey(f) === favoriteKey(nodeAsStored)) + ) { + return; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stored: StoredFavoriteNode = {...node} as StoredFavoriteNode; + if ("columns" in stored) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (stored as any).columns; + } + + const wasEmpty = favorites.length === 0; + await this.stateStorage.set("databricks.unityCatalog.favorites", [ + ...favorites, + stored, + ]); + + this.fireFavoritesChanged(wasEmpty, node); + } + + async unpin( + node: Extract + ): Promise { + const favorites = this.getFavorites(); + const nodeAsStored = node as StoredFavoriteNode; + const updated = favorites.filter( + (f) => favoriteKey(f) !== favoriteKey(nodeAsStored) + ); + if (updated.length === favorites.length) { + return; + } + + await this.stateStorage.set( + "databricks.unityCatalog.favorites", + updated + ); + + this.fireFavoritesChanged(updated.length === 0, node); + } + + getLoadedChildren(key: string): UnityCatalogTreeNode[] | undefined { + return this.childrenCache.get(key); + } + + refresh(): void { + this.childrenCache.clear(); + this.catalogsCache = undefined; + this._onDidChangeTreeData.fire(undefined); + } + + refreshNode(element: UnityCatalogTreeNode): void { + if ("fullName" in element) { + this.childrenCache.delete(element.fullName); + if (element.kind === "schema") { + for (const g of [ + "tables", + "volumes", + "functions", + "models", + "grouped", + ]) { + this.childrenCache.delete(`${element.fullName}/.${g}`); + } + } + } + this._onDidChangeTreeData.fire(element); + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} + +function favoriteKey(node: StoredFavoriteNode): string { + return node.kind === "modelVersion" + ? `${node.fullName}@v${node.version}` + : node.fullName; +} diff --git a/packages/databricks-vscode/src/ui/unity-catalog/detailLoader.ts b/packages/databricks-vscode/src/ui/unity-catalog/detailLoader.ts new file mode 100644 index 000000000..f98e0b6b1 --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/detailLoader.ts @@ -0,0 +1,430 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {ApiError} from "@databricks/sdk-experimental"; +import {ConnectionManager} from "../../configuration/ConnectionManager"; +import {UnityCatalogTreeNode} from "./types"; +import {drainAsyncIterable} from "./utils"; + +type Client = NonNullable; + +export interface MonitorSummary { + status: string; + dashboardId?: string; + schedule?: string; + driftMetricsTable?: string; + profileMetricsTable?: string; + failureMsg?: string; +} + +export interface ConstraintSummary { + type: "pk" | "fk"; + name?: string; + columns: string[]; + parentTable?: string; + parentColumns?: string[]; +} + +export interface ChildItem { + label: string; + subLabel?: string; + owner?: string; + status?: string; + createdBy?: string; + createdAt?: number; + nodeData?: Extract; +} + +export interface NodeEnrichments { + tags?: Array<{key: string; value?: string}>; + permissions?: Array<{principal: string; privileges: string[]}>; + monitor?: MonitorSummary | null; + constraints?: ConstraintSummary[]; + customProperties?: Record; + rowFilter?: {functionName: string; usingColumns: string[]}; + pipelineId?: string; + children?: ChildItem[]; + childrenTitle?: string; + columns?: Array<{ + name: string; + typeName?: string; + typeText?: string; + comment?: string; + nullable?: boolean; + position?: number; + }>; +} + +async function loadChildrenForNode( + client: Client, + node: Exclude< + UnityCatalogTreeNode, + {kind: "error" | "empty" | "column" | "modelVersion" | "favorites"} + >, + cachedChildren?: UnityCatalogTreeNode[] +): Promise<{title: string; items: ChildItem[]} | null> { + if (node.kind === "catalog") { + if (cachedChildren) { + return { + title: "Schemas", + items: cachedChildren + .filter( + ( + n + ): n is Extract< + UnityCatalogTreeNode, + {kind: "schema"} + > => n.kind === "schema" + ) + .map((n) => ({ + label: n.name, + owner: n.owner, + createdAt: n.createdAt, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + }; + } + const rows = await drainAsyncIterable( + client.schemas.list({catalog_name: node.name}) + ); + return { + title: "Schemas", + items: rows + .filter((s) => s.name) + .map((s) => ({ + label: s.name!, + owner: s.owner, + createdAt: s.created_at, + })) + .sort((a, b) => a.label.localeCompare(b.label)), + }; + } + if (node.kind === "schema") { + if (cachedChildren) { + type SchemaChild = Extract< + UnityCatalogTreeNode, + {kind: "table" | "volume" | "function" | "registeredModel"} + >; + const subLabelByKind: Partial> = + { + volume: "VOLUME", + function: "FUNCTION", + registeredModel: "MODEL", + }; + const items: ChildItem[] = cachedChildren + .filter( + (n): n is SchemaChild => + n.kind === "table" || + n.kind === "volume" || + n.kind === "function" || + n.kind === "registeredModel" + ) + .map( + (n): ChildItem => ({ + label: n.name, + subLabel: + n.kind === "table" + ? n.tableType ?? "TABLE" + : subLabelByKind[n.kind], + owner: n.owner, + createdAt: n.createdAt, + }) + ) + .sort((a, b) => a.label.localeCompare(b.label)); + return {title: "Contents", items}; + } + const [tables, volumes, functions, models] = await Promise.allSettled([ + drainAsyncIterable( + client.tables.list({ + catalog_name: node.catalogName, + schema_name: node.name, + }) + ), + drainAsyncIterable( + client.volumes.list({ + catalog_name: node.catalogName, + schema_name: node.name, + }) + ), + drainAsyncIterable( + client.functions.list({ + catalog_name: node.catalogName, + schema_name: node.name, + }) + ), + drainAsyncIterable( + client.registeredModels.list({ + catalog_name: node.catalogName, + schema_name: node.name, + }) + ), + ]); + const items: ChildItem[] = [ + ...(tables.status === "fulfilled" + ? tables.value + .filter((t) => t.name) + .map((t) => ({ + label: t.name!, + subLabel: t.table_type ?? "TABLE", + owner: t.owner, + createdAt: t.created_at, + })) + : []), + ...(volumes.status === "fulfilled" + ? volumes.value + .filter((v) => v.name) + .map((v) => ({ + label: v.name!, + subLabel: "VOLUME", + owner: v.owner, + createdAt: v.created_at, + })) + : []), + ...(functions.status === "fulfilled" + ? functions.value + .filter((f) => f.name) + .map((f) => ({ + label: f.name!, + subLabel: "FUNCTION", + owner: f.owner, + createdAt: f.created_at, + })) + : []), + ...(models.status === "fulfilled" + ? models.value + .filter((m) => m.name) + .map((m) => ({ + label: m.name!, + subLabel: "MODEL", + owner: m.owner, + createdAt: m.created_at, + })) + : []), + ].sort((a, b) => a.label.localeCompare(b.label)); + return {title: "Contents", items}; + } + if (node.kind === "registeredModel") { + if (cachedChildren) { + return { + title: "Versions", + items: cachedChildren + .filter( + ( + n + ): n is Extract< + UnityCatalogTreeNode, + {kind: "modelVersion"} + > => n.kind === "modelVersion" + ) + .map((n) => ({ + label: `v${n.version}`, + status: n.status, + createdBy: n.createdBy, + createdAt: n.createdAt, + nodeData: n, + })) + .sort( + (a, b) => + parseInt(b.label.slice(1)) - + parseInt(a.label.slice(1)) + ), + }; + } + const rows = await drainAsyncIterable( + client.modelVersions.list({full_name: node.fullName}) + ); + return { + title: "Versions", + items: rows + .filter((v) => v.version !== undefined) + .map((v) => ({ + label: `v${v.version}`, + status: v.status, + createdBy: v.created_by, + createdAt: v.created_at, + nodeData: { + kind: "modelVersion" as const, + catalogName: node.catalogName, + schemaName: node.schemaName, + modelName: node.name, + fullName: node.fullName, + version: v.version!, + comment: v.comment, + status: v.status, + storageLocation: v.storage_location, + createdBy: v.created_by, + createdAt: v.created_at, + }, + })) + .sort((a, b) => { + const va = parseInt(a.label.slice(1)); + const vb = parseInt(b.label.slice(1)); + return vb - va; + }), + }; + } + return null; +} + +const SECURABLE_TYPE: Partial> = { + catalog: "CATALOG", + schema: "SCHEMA", + table: "TABLE", + volume: "VOLUME", + function: "FUNCTION", + registeredModel: "FUNCTION", +}; + +const TAG_ENTITY_TYPE: Partial> = { + catalog: "catalogs", + schema: "schemas", + table: "tables", + volume: "volumes", +}; + +export async function loadNodeEnrichments( + client: Client, + node: Exclude< + UnityCatalogTreeNode, + {kind: "error" | "empty" | "column" | "modelVersion" | "favorites" | "group"} + >, + cachedChildren?: UnityCatalogTreeNode[] +): Promise { + const tagEntityType = TAG_ENTITY_TYPE[node.kind]; + const securableType = SECURABLE_TYPE[node.kind]; + + const [ + tagsResult, + permissionsResult, + tableDetailResult, + monitorResult, + childrenResult, + ] = await Promise.allSettled([ + tagEntityType + ? drainAsyncIterable( + client.entityTagAssignments.list({ + entity_name: node.fullName, + entity_type: tagEntityType, + }) + ) + : Promise.reject(new Error("not applicable")), + securableType + ? client.grants.getEffective({ + full_name: node.fullName, + securable_type: securableType, + }) + : Promise.reject(new Error("not applicable")), + node.kind === "table" + ? client.tables.get({full_name: node.fullName}) + : Promise.reject(new Error("not applicable")), + node.kind === "table" + ? client.qualityMonitors.get({table_name: node.fullName}) + : Promise.reject(new Error("not applicable")), + loadChildrenForNode(client, node, cachedChildren), + ]); + + const enrichments: NodeEnrichments = {}; + + if ( + node.kind === "table" && + node.customProperties && + Object.keys(node.customProperties).length > 0 + ) { + enrichments.customProperties = node.customProperties; + } + + if (tagsResult.status === "fulfilled") { + enrichments.tags = tagsResult.value.map((t) => ({ + key: t.tag_key, + value: t.tag_value, + })); + } + + if (permissionsResult.status === "fulfilled") { + enrichments.permissions = ( + permissionsResult.value.privilege_assignments ?? [] + ).map((a) => ({ + principal: a.principal ?? "", + privileges: (a.privileges ?? []) + .map((p) => p.privilege ?? "") + .filter(Boolean), + })); + } + + if (tableDetailResult.status === "fulfilled") { + const t = tableDetailResult.value; + if ( + node.kind === "table" && + !node.columns?.length && + t.columns?.length + ) { + enrichments.columns = t.columns + .filter((c) => c.name) + .map((c) => ({ + name: c.name!, + typeName: c.type_name ? String(c.type_name) : undefined, + typeText: c.type_text, + comment: c.comment, + nullable: c.nullable, + position: c.position, + })) + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + } + if (t.table_constraints && t.table_constraints.length > 0) { + enrichments.constraints = t.table_constraints + .map((c): ConstraintSummary | null => { + if (c.primary_key_constraint) { + return { + type: "pk", + name: c.primary_key_constraint.name, + columns: c.primary_key_constraint.child_columns, + }; + } + if (c.foreign_key_constraint) { + return { + type: "fk", + name: c.foreign_key_constraint.name, + columns: c.foreign_key_constraint.child_columns, + parentTable: c.foreign_key_constraint.parent_table, + parentColumns: + c.foreign_key_constraint.parent_columns, + }; + } + return null; + }) + .filter((c): c is ConstraintSummary => c !== null); + } + if (t.row_filter) { + enrichments.rowFilter = { + functionName: t.row_filter.function_name, + usingColumns: t.row_filter.input_column_names, + }; + } + if (t.pipeline_id) { + enrichments.pipelineId = t.pipeline_id; + } + } + + if (monitorResult.status === "fulfilled") { + const m = monitorResult.value; + enrichments.monitor = { + status: m.status, + dashboardId: m.dashboard_id, + schedule: m.schedule?.quartz_cron_expression, + driftMetricsTable: m.drift_metrics_table_name, + profileMetricsTable: m.profile_metrics_table_name, + failureMsg: m.latest_monitor_failure_msg, + }; + } else if ( + monitorResult.status === "rejected" && + monitorResult.reason instanceof ApiError && + monitorResult.reason.statusCode === 404 + ) { + enrichments.monitor = null; + } + + if (childrenResult.status === "fulfilled" && childrenResult.value) { + enrichments.childrenTitle = childrenResult.value.title; + enrichments.children = childrenResult.value.items; + } + + return enrichments; +} diff --git a/packages/databricks-vscode/src/ui/unity-catalog/loaders.ts b/packages/databricks-vscode/src/ui/unity-catalog/loaders.ts new file mode 100644 index 000000000..47f687d03 --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/loaders.ts @@ -0,0 +1,322 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {ApiError, logging, type iam} from "@databricks/sdk-experimental"; +import {ConnectionManager} from "../../configuration/ConnectionManager"; +import {Loggers} from "../../logger"; +import {UnityCatalogTreeNode} from "./types"; +import {drainAsyncIterable, isOwnedByUser} from "./utils"; + +const logger = logging.NamedLogger.getOrCreate(Loggers.Extension); + +type Client = NonNullable; + +function compareOwnedFirst( + a: {owned?: boolean; name: string}, + b: {owned?: boolean; name: string} +): number { + if (a.owned && !b.owned) { + return -1; + } + if (!a.owned && b.owned) { + return 1; + } + return a.name.localeCompare(b.name); +} + +function nodeName(n: UnityCatalogTreeNode): string { + return (n as {name?: string}).name ?? ""; +} + +function emptyNode(message: string): UnityCatalogTreeNode[] { + return [{kind: "empty", message}]; +} + +function errorNode(e: unknown, resource: string): UnityCatalogTreeNode[] { + const message = + e instanceof ApiError + ? `Failed to load ${resource}: ${e.message}` + : `Failed to load ${resource}`; + logger.error(`Unity Catalog: ${message}`, e); + return [{kind: "error", message}]; +} + +export async function loadCatalogs( + client: Client, + currentUser: iam.User | undefined +): Promise { + try { + const rows = await drainAsyncIterable(client.catalogs.list({})); + const result = rows + .filter((c) => c.name) + .map((c) => ({ + kind: "catalog" as const, + name: c.name!, + fullName: c.full_name ?? c.name!, + comment: c.comment, + owner: c.owner, + owned: isOwnedByUser(c.owner, currentUser), + catalogType: c.catalog_type, + isolationMode: c.isolation_mode, + storageLocation: c.storage_location, + createdAt: c.created_at, + createdBy: c.created_by, + updatedAt: c.updated_at, + updatedBy: c.updated_by, + connectionName: c.connection_name, + providerName: c.provider_name, + shareName: c.share_name, + })) + .sort(compareOwnedFirst); + return result.length > 0 ? result : emptyNode("No catalogs found"); + } catch (e) { + return errorNode(e, "catalogs"); + } +} + +export async function loadSchemas( + client: Client, + catalogName: string, + currentUser: iam.User | undefined +): Promise { + try { + const rows = await drainAsyncIterable( + client.schemas.list({catalog_name: catalogName}) + ); + const result = rows + .filter((s) => s.name) + .map((s) => ({ + kind: "schema" as const, + catalogName, + name: s.name!, + fullName: s.full_name ?? `${catalogName}.${s.name}`, + comment: s.comment, + owner: s.owner, + owned: isOwnedByUser(s.owner, currentUser), + storageLocation: s.storage_location, + createdAt: s.created_at, + createdBy: s.created_by, + updatedAt: s.updated_at, + updatedBy: s.updated_by, + })) + .sort(compareOwnedFirst); + return result.length > 0 ? result : emptyNode("No schemas"); + } catch (e) { + return errorNode(e, "schemas"); + } +} + +export async function loadSchemaChildren( + client: Client, + catalogName: string, + schemaName: string +): Promise { + const [tablesResult, volumesResult, functionsResult, modelsResult] = + await Promise.allSettled([ + drainAsyncIterable( + client.tables.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + drainAsyncIterable( + client.volumes.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + drainAsyncIterable( + client.functions.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + drainAsyncIterable( + client.registeredModels.list({ + catalog_name: catalogName, + schema_name: schemaName, + }) + ), + ]); + + const tableNodes: UnityCatalogTreeNode[] = + tablesResult.status === "fulfilled" + ? tablesResult.value + .filter((t) => t.name) + .map((t) => ({ + kind: "table" as const, + catalogName, + schemaName, + name: t.name!, + fullName: + t.full_name ?? + `${catalogName}.${schemaName}.${t.name}`, + tableType: t.table_type, + comment: t.comment, + dataSourceFormat: t.data_source_format, + storageLocation: t.storage_location, + viewDefinition: t.view_definition, + owner: t.owner, + createdBy: t.created_by, + createdAt: t.created_at, + updatedAt: t.updated_at, + updatedBy: t.updated_by, + columns: (t.columns ?? []).map((col) => ({ + name: col.name!, + typeName: col.type_name, + typeText: col.type_text, + comment: col.comment, + nullable: col.nullable, + position: col.position, + })), + customProperties: t.properties, + })) + : []; + + const volumeNodes: UnityCatalogTreeNode[] = + volumesResult.status === "fulfilled" + ? volumesResult.value + .filter((v) => v.name) + .map((v) => ({ + kind: "volume" as const, + catalogName, + schemaName, + name: v.name!, + fullName: + v.full_name ?? + `${catalogName}.${schemaName}.${v.name}`, + volumeType: v.volume_type, + storageLocation: v.storage_location, + comment: v.comment, + owner: v.owner, + createdAt: v.created_at, + createdBy: v.created_by, + updatedAt: v.updated_at, + updatedBy: v.updated_by, + })) + : []; + + const functionNodes: UnityCatalogTreeNode[] = + functionsResult.status === "fulfilled" + ? functionsResult.value + .filter((f) => f.name) + .map((f) => ({ + kind: "function" as const, + catalogName, + schemaName, + name: f.name!, + fullName: `${catalogName}.${schemaName}.${f.name}`, + comment: f.comment, + owner: f.owner, + routineBody: f.routine_body, + routineDefinition: f.routine_definition, + fullDataType: f.full_data_type, + externalLanguage: f.external_language, + isDeterministic: f.is_deterministic, + inputParams: (f.input_params?.parameters ?? []).map( + (p) => ({ + name: p.name, + typeName: p.type_name + ? String(p.type_name) + : undefined, + typeText: p.type_text, + comment: p.comment, + parameterDefault: p.parameter_default, + }) + ), + createdAt: f.created_at, + createdBy: f.created_by, + updatedAt: f.updated_at, + updatedBy: f.updated_by, + })) + : []; + + const modelNodes: UnityCatalogTreeNode[] = + modelsResult.status === "fulfilled" + ? modelsResult.value + .filter((m) => m.name) + .map((m) => ({ + kind: "registeredModel" as const, + catalogName, + schemaName, + name: m.name!, + fullName: + m.full_name ?? + `${catalogName}.${schemaName}.${m.name}`, + comment: m.comment, + owner: m.owner, + storageLocation: m.storage_location, + aliases: m.aliases?.map((a) => ({ + alias_name: a.alias_name, + version_num: a.version_num, + })), + createdAt: m.created_at, + updatedAt: m.updated_at, + })) + : []; + + const errNodes: UnityCatalogTreeNode[] = ( + [ + [tablesResult, "tables"], + [volumesResult, "volumes"], + [functionsResult, "functions"], + [modelsResult, "registered models"], + ] as const + ).flatMap(([result, label]) => + result.status === "rejected" ? errorNode(result.reason, label) : [] + ); + + const kindOrder = { + table: 0, + volume: 1, + function: 2, + registeredModel: 3, + } as Record; + const contentNodes = [ + ...tableNodes, + ...volumeNodes, + ...functionNodes, + ...modelNodes, + ]; + if (contentNodes.length === 0 && errNodes.length === 0) { + return emptyNode("No data"); + } + return [ + ...contentNodes.sort((a, b) => { + const c = nodeName(a).localeCompare(nodeName(b)); + if (c !== 0) { + return c; + } + return (kindOrder[a.kind] ?? 0) - (kindOrder[b.kind] ?? 0); + }), + ...errNodes, + ]; +} + +export async function loadModelVersions( + client: Client, + model: Extract +): Promise { + try { + const rows = await drainAsyncIterable( + client.modelVersions.list({full_name: model.fullName}) + ); + const nodes = rows + .filter((v) => v.version !== undefined) + .map((v) => ({ + kind: "modelVersion" as const, + catalogName: model.catalogName, + schemaName: model.schemaName, + modelName: model.name, + fullName: model.fullName, + version: v.version!, + comment: v.comment, + status: v.status, + storageLocation: v.storage_location, + createdAt: v.created_at, + createdBy: v.created_by, + })) + .sort((a, b) => b.version - a.version); + return nodes.length > 0 ? nodes : emptyNode("No versions"); + } catch (e) { + return errorNode(e, "model versions"); + } +} diff --git a/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts new file mode 100644 index 000000000..59e69f9c7 --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts @@ -0,0 +1,459 @@ +import * as path from "path"; +import { + MarkdownString, + ThemeColor, + ThemeIcon, + TreeItemCollapsibleState, +} from "vscode"; +import {UnityCatalogTreeNode, UnityCatalogTreeItem} from "./types"; +import {formatTs} from "./utils"; + +function ucIconPath( + extensionPath: string, + file: string +): {dark: string; light: string} { + return { + dark: path.join( + extensionPath, + "resources", + "dark", + "unity-catalog", + file + ), + light: path.join( + extensionPath, + "resources", + "light", + "unity-catalog", + file + ), + }; +} + +function catalogIconFile(name: string): string { + switch (name) { + case "main": + return "catalog-main.svg"; + case "samples": + return "catalog-samples.svg"; + case "system": + return "catalog-system.svg"; + default: + return "catalog.svg"; + } +} + +function pinnedOwnedDescription( + isPinned: boolean, + owned: boolean | undefined +): string | undefined { + if (isPinned && owned) return "★ · yours"; + if (isPinned) return "★"; + if (owned) return "yours"; + return undefined; +} + +function starDescription( + isPinned: boolean, + detail: string | undefined +): string | undefined { + if (!isPinned) return detail; + return detail ? `★ · ${detail}` : "★"; +} + +function withPin(base: string, isPinned: boolean): string { + return isPinned ? `${base}.is-pinned` : base; +} + +export function buildTreeItem( + node: UnityCatalogTreeNode, + exploreUrl: string | undefined, + isPinned: boolean = false, + extensionPath: string = "" +): UnityCatalogTreeItem { + switch (node.kind) { + case "error": + return renderError(node); + case "empty": + return renderEmpty(node, extensionPath); + case "favorites": + return renderFavorites(extensionPath); + case "catalog": + return renderCatalog(node, exploreUrl, isPinned, extensionPath); + case "schema": + return renderSchema(node, exploreUrl, isPinned, extensionPath); + case "table": + return renderTable(node, exploreUrl, isPinned, extensionPath); + case "volume": + return renderVolume(node, exploreUrl, isPinned, extensionPath); + case "function": + return renderFunction(node, exploreUrl, isPinned, extensionPath); + case "registeredModel": + return renderRegisteredModel( + node, + exploreUrl, + isPinned, + extensionPath + ); + case "modelVersion": + return renderModelVersion( + node, + exploreUrl, + isPinned, + extensionPath + ); + case "column": + return renderColumn(node, extensionPath); + case "group": + return renderGroup(node, extensionPath); + } +} + +function renderFavorites(extensionPath: string = ""): UnityCatalogTreeItem { + return { + label: "Favorites", + iconPath: ucIconPath(extensionPath, "favorites.svg"), + contextValue: "unityCatalog.favorites", + collapsibleState: TreeItemCollapsibleState.Expanded, + }; +} + +function renderError( + node: Extract +): UnityCatalogTreeItem { + return { + label: node.message, + iconPath: new ThemeIcon( + "error", + new ThemeColor("notificationsErrorIcon.foreground") + ), + collapsibleState: TreeItemCollapsibleState.None, + }; +} + +function renderEmpty( + node: Extract, + extensionPath: string = "" +): UnityCatalogTreeItem { + return { + label: node.message, + iconPath: ucIconPath(extensionPath, "no-data.svg"), + collapsibleState: TreeItemCollapsibleState.None, + }; +} + +function renderCatalog( + node: Extract, + exploreUrl: string | undefined, + isPinned: boolean = false, + extensionPath: string = "" +): UnityCatalogTreeItem { + const tt = new MarkdownString(`**${node.fullName}**`); + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + const baseContextValue = exploreUrl + ? "unityCatalog.catalog.has-url" + : "unityCatalog.catalog"; + return { + label: node.name, + description: pinnedOwnedDescription(isPinned, node.owned), + tooltip: tt, + iconPath: ucIconPath(extensionPath, catalogIconFile(node.name)), + contextValue: withPin(baseContextValue, isPinned), + collapsibleState: TreeItemCollapsibleState.Collapsed, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderSchema( + node: Extract, + exploreUrl: string | undefined, + isPinned: boolean = false, + extensionPath: string = "" +): UnityCatalogTreeItem { + const tt = new MarkdownString(`**${node.fullName}**`); + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + const baseContextValue = exploreUrl + ? "unityCatalog.schema.has-url" + : "unityCatalog.schema"; + return { + label: node.name, + description: pinnedOwnedDescription(isPinned, node.owned), + tooltip: tt, + iconPath: ucIconPath(extensionPath, "schema.svg"), + contextValue: withPin(baseContextValue, isPinned), + collapsibleState: TreeItemCollapsibleState.Collapsed, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderTable( + node: Extract, + exploreUrl: string | undefined, + isPinned: boolean = false, + extensionPath: string = "" +): UnityCatalogTreeItem { + const typeSuffix = + node.tableType && node.tableType !== "MANAGED" + ? ` (${node.tableType})` + : ""; + const flags = ["unityCatalog.table"]; + if (exploreUrl) { + flags.push("has-url"); + } + if (node.storageLocation) { + flags.push("has-storage"); + } + const isView = + node.tableType === "VIEW" || node.tableType === "MATERIALIZED_VIEW"; + if (isView && node.viewDefinition) { + flags.push("is-view"); + } + if (isPinned) { + flags.push("is-pinned"); + } + + const tt = new MarkdownString(`**${node.fullName}**`); + if (node.tableType) { + tt.appendMarkdown(`\n\n*Type:* ${node.tableType}`); + } + if (node.dataSourceFormat) { + tt.appendMarkdown(` · *Format:* ${node.dataSourceFormat}`); + } + if (node.owner) { + tt.appendMarkdown(`\n\n*Owner:* ${node.owner}`); + } + if (node.createdBy) { + tt.appendMarkdown(` · *Created by:* ${node.createdBy}`); + } + const cAt = formatTs(node.createdAt); + const uAt = formatTs(node.updatedAt); + if (cAt) { + tt.appendMarkdown(`\n\n*Created:* ${cAt}`); + } + if (uAt) { + tt.appendMarkdown(` *Updated:* ${uAt}`); + } + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + + // columns===undefined means not yet fetched (e.g. stored favorite); treat as expandable + const hasColumns = node.columns === undefined || node.columns.length > 0; + const tableDescription = starDescription(isPinned, node.dataSourceFormat); + return { + label: `${node.name}${typeSuffix}`, + description: tableDescription, + tooltip: tt, + iconPath: ucIconPath(extensionPath, "table.svg"), + contextValue: flags.join("."), + collapsibleState: hasColumns + ? TreeItemCollapsibleState.Collapsed + : TreeItemCollapsibleState.None, + url: exploreUrl, + copyText: node.fullName, + storageLocation: node.storageLocation, + viewDefinition: node.viewDefinition, + }; +} + +function renderVolume( + node: Extract, + exploreUrl: string | undefined, + isPinned: boolean = false, + extensionPath: string = "" +): UnityCatalogTreeItem { + const isExternal = + node.volumeType !== undefined && node.volumeType !== "MANAGED"; + const label = isExternal ? `${node.name} (${node.volumeType})` : node.name; + const flags = ["unityCatalog.volume"]; + if (exploreUrl) { + flags.push("has-url"); + } + if (node.storageLocation) { + flags.push("has-storage"); + } + if (isPinned) { + flags.push("is-pinned"); + } + const tt = new MarkdownString(`**${node.fullName}**`); + if (node.volumeType) { + tt.appendMarkdown(`\n\n*Type:* ${node.volumeType}`); + } + if (node.owner) { + tt.appendMarkdown(`\n\n*Owner:* ${node.owner}`); + } + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + return { + label, + description: isPinned ? "★" : undefined, + tooltip: tt, + iconPath: ucIconPath(extensionPath, "volume.svg"), + contextValue: flags.join("."), + collapsibleState: TreeItemCollapsibleState.None, + url: exploreUrl, + copyText: node.fullName, + storageLocation: node.storageLocation, + }; +} + +function renderFunction( + node: Extract, + exploreUrl: string | undefined, + isPinned: boolean = false, + extensionPath: string = "" +): UnityCatalogTreeItem { + const baseContextValue = exploreUrl + ? "unityCatalog.function.has-url" + : "unityCatalog.function"; + return { + label: node.name, + description: isPinned ? "★" : undefined, + tooltip: node.fullName, + iconPath: ucIconPath(extensionPath, "function.svg"), + contextValue: withPin(baseContextValue, isPinned), + collapsibleState: TreeItemCollapsibleState.None, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderRegisteredModel( + node: Extract, + exploreUrl: string | undefined, + isPinned: boolean = false, + extensionPath: string = "" +): UnityCatalogTreeItem { + const tt = new MarkdownString(`**${node.fullName}**`); + if (node.owner) { + tt.appendMarkdown(`\n\n*Owner:* ${node.owner}`); + } + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + if (node.aliases && node.aliases.length > 0) { + const aliasList = node.aliases + .filter((a) => a.alias_name) + .map((a) => + a.version_num !== undefined + ? `${a.alias_name} → v${a.version_num}` + : a.alias_name! + ) + .join(", "); + if (aliasList) { + tt.appendMarkdown(`\n\n*Aliases:* ${aliasList}`); + } + } + const cAt = formatTs(node.createdAt); + const uAt = formatTs(node.updatedAt); + if (cAt) { + tt.appendMarkdown(`\n\n*Created:* ${cAt}`); + } + if (uAt) { + tt.appendMarkdown(` *Updated:* ${uAt}`); + } + const baseContextValue = exploreUrl + ? "unityCatalog.registeredModel.has-url" + : "unityCatalog.registeredModel"; + return { + label: node.name, + description: isPinned ? "★" : undefined, + tooltip: tt, + iconPath: ucIconPath(extensionPath, "registered-model.svg"), + contextValue: withPin(baseContextValue, isPinned), + collapsibleState: TreeItemCollapsibleState.Collapsed, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderModelVersion( + node: Extract, + exploreUrl: string | undefined, + isPinned: boolean = false, + extensionPath: string = "" +): UnityCatalogTreeItem { + const tt = new MarkdownString(`**v${node.version}**`); + if (node.status) { + tt.appendMarkdown(`\n\n*Status:* ${node.status}`); + } + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + if (node.createdBy) { + tt.appendMarkdown(`\n\n*Created by:* ${node.createdBy}`); + } + const cAt = formatTs(node.createdAt); + if (cAt) { + tt.appendMarkdown(`\n\n*Created:* ${cAt}`); + } + const statusDetail = + node.status && node.status !== "READY" ? node.status : undefined; + const baseContextValue = exploreUrl + ? "unityCatalog.modelVersion.has-url" + : "unityCatalog.modelVersion"; + return { + label: `v${node.version}`, + description: starDescription(isPinned, statusDetail), + tooltip: tt, + iconPath: ucIconPath(extensionPath, "model-version.svg"), + contextValue: withPin(baseContextValue, isPinned), + collapsibleState: TreeItemCollapsibleState.None, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderGroup( + node: Extract, + extensionPath: string +): UnityCatalogTreeItem { + const labels = { + tables: "Tables", + volumes: "Volumes", + functions: "Functions", + models: "Models", + }; + const iconFiles = { + tables: "table.svg", + volumes: "volume.svg", + functions: "function.svg", + models: "registered-model.svg", + }; + return { + label: `${labels[node.groupType]} (${node.count})`, + iconPath: ucIconPath(extensionPath, iconFiles[node.groupType]), + contextValue: `unityCatalog.group.${node.groupType}`, + collapsibleState: TreeItemCollapsibleState.Collapsed, + }; +} + +function renderColumn( + node: Extract, + extensionPath: string +): UnityCatalogTreeItem { + const typeLabel = node.typeText ?? node.typeName ?? ""; + const tt = new MarkdownString(`**${node.name}** \`${typeLabel}\``); + if (node.nullable === false) { + tt.appendMarkdown(" *(not null)*"); + } + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + return { + label: node.name, + description: typeLabel, + tooltip: tt, + iconPath: ucIconPath(extensionPath, "column.svg"), + contextValue: "unityCatalog.column", + collapsibleState: TreeItemCollapsibleState.None, + copyText: node.name, + }; +} diff --git a/packages/databricks-vscode/src/ui/unity-catalog/registerDetailPanel.ts b/packages/databricks-vscode/src/ui/unity-catalog/registerDetailPanel.ts new file mode 100644 index 000000000..e1ad1f5bb --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/registerDetailPanel.ts @@ -0,0 +1,86 @@ +import {Disposable, TreeView, Uri} from "vscode"; +import {Telemetry} from "../../telemetry"; +import {ConnectionManager} from "../../configuration/ConnectionManager"; +import { + UnityCatalogTreeDataProvider, + UnityCatalogTreeNode, +} from "./UnityCatalogTreeDataProvider"; +import {UnityCatalogDetailPanel} from "./UnityCatalogDetailPanel"; +import {loadNodeEnrichments} from "./detailLoader"; + +type DetailableNode = Exclude< + UnityCatalogTreeNode, + {kind: "error" | "empty" | "column" | "favorites" | "group"} +>; + +function isDetailable( + node: UnityCatalogTreeNode | undefined +): node is DetailableNode { + return ( + !!node && + node.kind !== "error" && + node.kind !== "empty" && + node.kind !== "column" && + node.kind !== "favorites" && + node.kind !== "group" + ); +} + +export function registerDetailPanel( + extensionUri: Uri, + connectionManager: ConnectionManager, + treeView: TreeView, + treeDataProvider: UnityCatalogTreeDataProvider, + telemetry: Telemetry +): Disposable[] { + let enrichmentGeneration = 0; + + async function showDetail(node: UnityCatalogTreeNode) { + if (!isDetailable(node)) { + return; + } + const generation = ++enrichmentGeneration; + const panel = await UnityCatalogDetailPanel.getOrCreate(extensionUri); + panel.showNode(node, treeDataProvider.getNodeExploreUrl(node)); + if (node.kind !== "modelVersion") { + const client = connectionManager.workspaceClient; + if (client) { + const cachedChildren = + node.kind === "catalog" || + node.kind === "schema" || + node.kind === "registeredModel" + ? treeDataProvider.getLoadedChildren(node.fullName) + : undefined; + loadNodeEnrichments(client, node, cachedChildren) + .then((enrichments) => { + if (generation !== enrichmentGeneration) { + return; + } + panel.enrichNode(enrichments); + }) + .catch(() => { + /* silently ignore enrichment errors */ + }); + } + } + } + + UnityCatalogDetailPanel.setNavigationHandler(showDetail); + + return [ + telemetry.registerCommand( + "databricks.unityCatalog.showDetail", + showDetail + ), + treeView.onDidChangeSelection(async (event) => { + const node = event.selection[0] as UnityCatalogTreeNode; + if (!isDetailable(node)) { + return; + } + const panel = + await UnityCatalogDetailPanel.getOrCreate(extensionUri); + panel.showLoading(node.fullName); + await showDetail(node); + }), + ]; +} diff --git a/packages/databricks-vscode/src/ui/unity-catalog/types.ts b/packages/databricks-vscode/src/ui/unity-catalog/types.ts new file mode 100644 index 000000000..ca234fc09 --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/types.ts @@ -0,0 +1,175 @@ +import {TreeItem} from "vscode"; + +export interface ColumnData { + name: string; + typeName?: string; + typeText?: string; + comment?: string; + nullable?: boolean; + position?: number; +} + +export interface FunctionParameterInfo { + name: string; + typeName?: string; + typeText?: string; + comment?: string; + parameterDefault?: string; +} + +export type UnityCatalogTreeNode = + | { + kind: "catalog"; + name: string; + fullName: string; + comment?: string; + owner?: string; + owned?: boolean; + catalogType?: string; + isolationMode?: string; + storageLocation?: string; + createdAt?: number; + createdBy?: string; + updatedAt?: number; + updatedBy?: string; + connectionName?: string; + providerName?: string; + shareName?: string; + } + | { + kind: "schema"; + catalogName: string; + name: string; + fullName: string; + comment?: string; + owner?: string; + owned?: boolean; + storageLocation?: string; + createdAt?: number; + createdBy?: string; + updatedAt?: number; + updatedBy?: string; + } + | { + kind: "table"; + catalogName: string; + schemaName: string; + name: string; + fullName: string; + tableType?: string; + comment?: string; + dataSourceFormat?: string; + storageLocation?: string; + viewDefinition?: string; + owner?: string; + createdBy?: string; + createdAt?: number; + updatedAt?: number; + updatedBy?: string; + columns?: ColumnData[]; + customProperties?: Record; + } + | { + kind: "volume"; + catalogName: string; + schemaName: string; + name: string; + fullName: string; + volumeType?: string; + storageLocation?: string; + comment?: string; + owner?: string; + createdAt?: number; + createdBy?: string; + updatedAt?: number; + updatedBy?: string; + } + | { + kind: "function"; + catalogName: string; + schemaName: string; + name: string; + fullName: string; + comment?: string; + owner?: string; + routineBody?: string; + routineDefinition?: string; + fullDataType?: string; + externalLanguage?: string; + isDeterministic?: boolean; + inputParams?: FunctionParameterInfo[]; + createdAt?: number; + createdBy?: string; + updatedAt?: number; + updatedBy?: string; + } + | { + kind: "registeredModel"; + catalogName: string; + schemaName: string; + name: string; + fullName: string; + comment?: string; + owner?: string; + storageLocation?: string; + aliases?: Array<{alias_name?: string; version_num?: number}>; + createdAt?: number; + updatedAt?: number; + } + | { + kind: "modelVersion"; + catalogName: string; + schemaName: string; + modelName: string; + fullName: string; + version: number; + comment?: string; + status?: string; + storageLocation?: string; + createdAt?: number; + createdBy?: string; + } + | { + kind: "column"; + tableFullName: string; + name: string; + typeName?: string; + typeText?: string; + comment?: string; + nullable?: boolean; + position?: number; + } + | { + kind: "group"; + groupType: "tables" | "volumes" | "functions" | "models"; + catalogName: string; + schemaName: string; + schemaFullName: string; + count: number; + } + | {kind: "error"; message: string} + | {kind: "empty"; message: string} + | {kind: "favorites"}; + +export type PinnableNodeKind = + | "catalog" + | "schema" + | "table" + | "volume" + | "function" + | "registeredModel" + | "modelVersion"; + +export type StoredFavoriteNode = { + [K in PinnableNodeKind]: Omit< + Extract, + "columns" + >; +}[PinnableNodeKind]; + +export interface UnityCatalogTreeItem extends TreeItem { + url?: string; + copyText?: string; + storageLocation?: string; + viewDefinition?: string; +} diff --git a/packages/databricks-vscode/src/ui/unity-catalog/utils.ts b/packages/databricks-vscode/src/ui/unity-catalog/utils.ts new file mode 100644 index 000000000..9cdc8160b --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/utils.ts @@ -0,0 +1,34 @@ +import {type iam} from "@databricks/sdk-experimental"; + +export async function drainAsyncIterable( + iter: AsyncIterable +): Promise { + const out: T[] = []; + for await (const item of iter) { + out.push(item); + } + return out; +} + +export function isOwnedByUser( + owner: string | undefined, + user: iam.User | undefined +): boolean { + if (!owner || !user) { + return false; + } + if (owner === user.userName) { + return true; + } + // TODO: Check if user is owner through group? like: return (user.groups ?? []).some((g) => g.display === owner); + return false; +} + +export function formatTs(ms: number | undefined): string | undefined { + if (ms === undefined) { + return undefined; + } + return ( + new Date(ms).toISOString().replace("T", " ").substring(0, 19) + " UTC" + ); +} diff --git a/packages/databricks-vscode/src/vscode-objs/StateStorage.ts b/packages/databricks-vscode/src/vscode-objs/StateStorage.ts index 3c716599b..b262322f9 100644 --- a/packages/databricks-vscode/src/vscode-objs/StateStorage.ts +++ b/packages/databricks-vscode/src/vscode-objs/StateStorage.ts @@ -3,6 +3,7 @@ import {EventEmitter, ExtensionContext, Event} from "vscode"; import {OverrideableConfigState} from "../configuration/models/OverrideableConfigModel"; import {Mutex} from "../locking"; import lodash from "lodash"; +import {StoredFavoriteNode} from "../ui/unity-catalog/types"; /* eslint-disable @typescript-eslint/naming-convention */ type KeyInfo = { @@ -66,6 +67,16 @@ const StorageConfigurations = { location: "workspace", }), + "databricks.unityCatalog.pinnedSchemas": withType()({ + location: "workspace", + defaultValue: [], + }), + + "databricks.unityCatalog.favorites": withType()({ + location: "workspace", + defaultValue: [], + }), + "databricks.lastInstalledExtensionVersion": withType()({ location: "global", defaultValue: "0.0.0", diff --git a/yarn.lock b/yarn.lock index a84415b39..25c514080 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3785,7 +3785,9 @@ __metadata: extract-zip: ^2.0.1 fs-extra: ^11.2.0 glob: ^10.3.10 + highlight.js: ^11.10.0 lodash: ^4.17.21 + markdown-it: ^12.3.2 minimatch: ^10.0.1 mocha: ^10.2.0 mock-require: ^3.0.3 @@ -6037,6 +6039,13 @@ __metadata: languageName: node linkType: hard +"highlight.js@npm:^11.10.0": + version: 11.11.1 + resolution: "highlight.js@npm:11.11.1" + checksum: 841ddd329a92be123a61ef4051698f824c3575deef588fbb810bd638f1575ddc96b7bdd925b97c05a29276bb119dfcb8e5bcb0acb31c86249371bf046e131d72 + languageName: node + linkType: hard + "hosted-git-info@npm:^4.0.2": version: 4.1.0 resolution: "hosted-git-info@npm:4.1.0"