diff --git a/packages/databricks-vscode/package.json b/packages/databricks-vscode/package.json index 3e4793bec..6de406365 100644 --- a/packages/databricks-vscode/package.json +++ b/packages/databricks-vscode/package.json @@ -187,6 +187,58 @@ "enablement": "databricks.context.activated && databricks.context.loggedIn && databricks.feature.views.workspace", "category": "Databricks" }, + { + "command": "databricks.unityCatalog.filter", + "title": "Filter", + "icon": "$(search)", + "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.pinSchema", + "title": "Pin Schema", + "icon": "$(star-empty)", + "category": "Databricks" + }, + { + "command": "databricks.unityCatalog.unpinSchema", + "title": "Unpin Schema", + "icon": "$(star-full)", + "category": "Databricks" + }, + { + "command": "databricks.unityCatalog.openExternal", + "title": "Open in Databricks", + "icon": "$(link-external)", + "category": "Databricks" + }, { "command": "databricks.call", "title": "Call", @@ -448,12 +500,107 @@ "name": "Workspace explorer", "when": "databricks.feature.views.workspace" }, + { + "id": "unityCatalogView", + "name": "Unity Catalog", + "when": "databricks.context.activated && databricks.context.loggedIn" + }, { "id": "databricksDocsView", "name": "Documentation" } ] }, + "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", @@ -531,6 +678,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", @@ -587,6 +744,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)/", + "group": "navigation_2@3" + }, + { + "command": "databricks.unityCatalog.refreshNode", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.(?!column)/", + "group": "inline@2" + }, + { + "command": "databricks.unityCatalog.pinSchema", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.schema/ && !(viewItem =~ /\\.is-pinned/)", + "group": "inline@3" + }, + { + "command": "databricks.unityCatalog.unpinSchema", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.schema.*\\.is-pinned/", + "group": "inline@3" + }, + { + "command": "databricks.unityCatalog.pinSchema", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.schema/ && !(viewItem =~ /\\.is-pinned/)", + "group": "navigation_2@4" + }, + { + "command": "databricks.unityCatalog.unpinSchema", + "when": "view == unityCatalogView && viewItem =~ /^unityCatalog\\.schema.*\\.is-pinned/", + "group": "navigation_2@4" + }, { "command": "databricks.utils.goToDefinition", "when": "viewItem =~ /^databricks.*\\.(has-source-location).*$/", @@ -1153,7 +1365,7 @@ "version": "0.295.0" }, "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 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", @@ -1169,9 +1381,10 @@ "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", "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 && 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", @@ -1198,6 +1411,7 @@ "ansi-to-html": "^0.7.2", "bcryptjs": "^2.4.3", "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/webview-ui/uc-detail.css b/packages/databricks-vscode/resources/webview-ui/uc-detail.css new file mode 100644 index 000000000..a24663489 --- /dev/null +++ b/packages/databricks-vscode/resources/webview-ui/uc-detail.css @@ -0,0 +1,749 @@ + *, + *::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; + 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-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: break-all; + color: var(--vscode-editor-foreground); + letter-spacing: -0.01em; + } + + #header-breadcrumb { + font-size: 0.78em; + color: var(--vscode-descriptionForeground); + word-break: break-all; + 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.72em; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--vscode-descriptionForeground); + } + + .section-count { + font-size: 0.68em; + 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: 200px 1fr; + } + + .prop-row { + display: grid; + grid-template-columns: 130px 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: hidden; + 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; + } + + 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; + justify-content: flex-end; + gap: 6px; + flex-wrap: wrap; + padding: 12px 16px; + } + + .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); + display: flex; + justify-content: flex-end; + } + #search-input-wrap { + position: relative; + display: flex; + align-items: center; + width: 220px; + } + .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; + } 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..7ebae07ee --- /dev/null +++ b/packages/databricks-vscode/resources/webview-ui/uc-detail.html @@ -0,0 +1,256 @@ + + + + + + + + + + + +
+ + Loading… +
+ + +
+ + + + +
+ + +
+ + +
+ + + + + +
+ + + + + +
+
+
+ Properties +
+
+
+ + + + + + + + + + + +
+ + +
+
+
+ Definition +
+
+ +

+                    
+
+
+ + +
+ + + + + +
+ + +
+
+
+ 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..3c0e8be2e --- /dev/null +++ b/packages/databricks-vscode/resources/webview-ui/uc-detail.js @@ -0,0 +1,658 @@ +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"); + 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 renderHeader(data) { + document.getElementById("header-svg").innerHTML = + KIND_SVGS[data.kind] ?? ""; + + 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" + ); + setText("sql-body", 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); + showTabBtn("details", false); + 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 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"; + + renderHeader(data); + + const propsList = document.getElementById("props-list"); + propsList.innerHTML = ""; + renderKindProps(propsList, data); + + renderComment(data); + renderDefinition(data); + resetForNewNode(); + renderActions(data); + }, + + renderEnrichments(enrichments) { + renderTags(enrichments.tags); + renderPermissions(enrichments.permissions); + renderMonitor(enrichments.monitor); + renderConstraints(enrichments.constraints); + renderCustomProperties(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 4713b02eb..ee7e0b7f8 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"); @@ -370,6 +377,105 @@ export async function activate( ) ); + const unityCatalogTreeDataProvider = new UnityCatalogTreeDataProvider( + connectionManager, + stateStorage + ); + 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") { + 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.pinSchema", + (node: UnityCatalogTreeNode) => { + if (node.kind === "schema") { + return unityCatalogTreeDataProvider.pinSchema(node); + } + } + ), + telemetry.registerCommand( + "databricks.unityCatalog.unpinSchema", + (node: UnityCatalogTreeNode) => { + if (node.kind === "schema") { + return unityCatalogTreeDataProvider.unpinSchema(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..e3c696933 --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogDetailPanel.ts @@ -0,0 +1,129 @@ +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 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); + } + }); + 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} + ); + 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 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"), + ]); + return html + .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")}"` + ); + } +} 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..3ffc16a18 --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.test.ts @@ -0,0 +1,750 @@ +/* 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 children = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + + assert(children); + assert.strictEqual(children.length, 3); + const kinds = children.map((c) => c.kind).sort(); + assert.deepStrictEqual(kinds, ["function", "table", "volume"]); + + const table = children.find((c) => c.kind === "table"); + assert(table && table.kind === "table"); + assert.strictEqual(table.name, "t1"); + + const volume = children.find((c) => c.kind === "volume"); + assert(volume && volume.kind === "volume"); + assert.strictEqual(volume.name, "v1"); + + const fn = children.find((c) => c.kind === "function"); + assert(fn && fn.kind === "function"); + assert.strictEqual(fn.name, "f1"); + assert.strictEqual(fn.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 children = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + + const table = children.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 non-nullable column uses symbol-key icon", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const col: UnityCatalogTreeNode = { + kind: "column", + tableFullName: "cat.sch.t1", + name: "id", + typeText: "bigint", + nullable: false, + }; + const item = provider.getTreeItem(col) as UnityCatalogTreeItem; + assert.strictEqual(item.label, "id"); + assert.strictEqual(item.description, "bigint"); + const icon = item.iconPath as {id: string}; + assert.strictEqual(icon.id, "symbol-key"); + }); + + it("getTreeItem for nullable column uses symbol-field icon", async () => { + const provider = new UnityCatalogTreeDataProvider( + instance(mockConnectionManager), + stubStateStorage + ); + disposables.push(provider); + + const col: UnityCatalogTreeNode = { + kind: "column", + tableFullName: "cat.sch.t1", + name: "val", + typeText: "string", + nullable: true, + }; + const item = provider.getTreeItem(col) as UnityCatalogTreeItem; + const icon = item.iconPath as {id: string}; + assert.strictEqual(icon.id, "symbol-field"); + }); + + 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) still succeed; only functions errors + assert.strictEqual(children.length, 3); + assert.notStrictEqual(children[0].kind, "error"); + assert.strictEqual(children[children.length - 1].kind, "error"); + }); + + 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 children = (await resolveProviderResult( + provider.getChildren(schema) + )) as UnityCatalogTreeNode[]; + const model = children.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("pinSchema adds fullName to stateStorage and fires tree change", async () => { + const stored: string[] = []; + const spyStorage = { + get: () => stored, + set: async (_key: string, val: string[]) => { + stored.length = 0; + stored.push(...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.pinSchema(schema); + assert(stored.includes("cat.sch")); + assert.strictEqual(fired, 1); + }); + + it("unpinSchema removes fullName from stateStorage and fires tree change", async () => { + const stored: string[] = ["cat.sch", "cat.other"]; + const spyStorage = { + get: () => [...stored], + set: async (_key: string, val: string[]) => { + stored.length = 0; + stored.push(...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.unpinSchema(schema); + assert(!stored.includes("cat.sch")); + assert(stored.includes("cat.other")); + assert.strictEqual(fired, 1); + }); + + it("pinned schema sorts before owned, owned before unowned", async () => { + const pinnedStorage = { + get: () => ["cat.s_b"], + 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"}; // pinned + 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, pinnedStorage); + 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_b"); // pinned first + assert.strictEqual((children[1] as any).name, "s_a"); // owned second + assert.strictEqual((children[2] as any).name, "s_c"); // unowned last + }); +}); 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..7ae7ec6e7 --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/UnityCatalogTreeDataProvider.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {Disposable, EventEmitter, TreeDataProvider} from "vscode"; +import {ConnectionManager} from "../../configuration/ConnectionManager"; +import {buildTreeItem} from "./nodeRenderer"; +import {UnityCatalogTreeItem, UnityCatalogTreeNode} from "./types"; +import {StateStorage} from "../../vscode-objs/StateStorage"; +import { + loadCatalogs, + loadSchemas, + loadSchemaChildren, + loadModelVersions, +} from "./loaders"; + +export type { + ColumnData, + 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[] = []; + + constructor( + private readonly connectionManager: ConnectionManager, + private readonly stateStorage: StateStorage + ) { + this.disposables.push( + this.connectionManager.onDidChangeState(() => { + this._onDidChangeTreeData.fire(undefined); + }) + ); + } + + 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" + ) { + 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 { + return buildTreeItem(element, this.getNodeExploreUrl(element)); + } + + async getChildren( + element?: UnityCatalogTreeNode + ): Promise { + const client = this.connectionManager.workspaceClient; + if (!client) { + return undefined; + } + + const currentUser = + this.connectionManager.databricksWorkspace?.user; + + if (!element) { + return loadCatalogs(client, currentUser); + } + + if (element.kind === "error") { + return undefined; + } + + if (element.kind === "catalog") { + const pinned = new Set( + this.stateStorage.get( + "databricks.unityCatalog.pinnedSchemas" + ) ?? [] + ); + return loadSchemas(client, element.name, currentUser, pinned); + } + + if (element.kind === "schema") { + return loadSchemaChildren( + client, + element.catalogName, + element.name + ); + } + + if (element.kind === "registeredModel") { + return loadModelVersions(client, element); + } + + if (element.kind === "table") { + if (!element.columns?.length) { + return undefined; + } + return [...element.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 pinSchema( + node: Extract + ): Promise { + const current = + this.stateStorage.get("databricks.unityCatalog.pinnedSchemas") ?? + []; + if (!current.includes(node.fullName)) { + await this.stateStorage.set( + "databricks.unityCatalog.pinnedSchemas", + [...current, node.fullName] + ); + } + this._onDidChangeTreeData.fire(undefined); + } + + async unpinSchema( + node: Extract + ): Promise { + const current = + this.stateStorage.get("databricks.unityCatalog.pinnedSchemas") ?? + []; + await this.stateStorage.set( + "databricks.unityCatalog.pinnedSchemas", + current.filter((n) => n !== node.fullName) + ); + this._onDidChangeTreeData.fire(undefined); + } + + refresh(): void { + this._onDidChangeTreeData.fire(undefined); + } + + refreshNode(element: UnityCatalogTreeNode): void { + this._onDidChangeTreeData.fire(element); + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} 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..16ddfb2dc --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/detailLoader.ts @@ -0,0 +1,170 @@ +/* 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 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; +} + +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"} + > +): Promise { + const tagEntityType = TAG_ENTITY_TYPE[node.kind]; + const securableType = SECURABLE_TYPE[node.kind]; + + const [tagsResult, permissionsResult, tableDetailResult, monitorResult] = + 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")), + ]); + + 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 (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; + } + + 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..e930db68c --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/loaders.ts @@ -0,0 +1,343 @@ +/* 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 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((a, b) => { + if (a.owned && !b.owned) { + return -1; + } + if (!a.owned && b.owned) { + return 1; + } + return a.name.localeCompare(b.name); + }); + 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, + pinnedSchemas: Set +): Promise { + try { + const rows = await drainAsyncIterable( + client.schemas.list({catalog_name: catalogName}) + ); + const result = rows + .filter((s) => s.name) + .map((s) => { + const fullName = s.full_name ?? `${catalogName}.${s.name}`; + return { + kind: "schema" as const, + catalogName, + name: s.name!, + fullName, + comment: s.comment, + owner: s.owner, + pinned: pinnedSchemas.has(fullName), + 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((a, b) => { + const rank = (n: typeof a) => (n.pinned ? 0 : n.owned ? 1 : 2); + const r = rank(a) - rank(b); + if (r !== 0) { + return r; + } + return a.name.localeCompare(b.name); + }); + 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.status === "rejected" + ? errorNode(tablesResult.reason, "tables") + : []), + ...(volumesResult.status === "rejected" + ? errorNode(volumesResult.reason, "volumes") + : []), + ...(functionsResult.status === "rejected" + ? errorNode(functionsResult.reason, "functions") + : []), + ...(modelsResult.status === "rejected" + ? errorNode(modelsResult.reason, "registered models") + : []), + ]; + + 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 items"); + } + return [ + ...contentNodes.sort((a, b) => { + const an = + a.kind === "table" || + a.kind === "volume" || + a.kind === "function" || + a.kind === "registeredModel" + ? a.name + : ""; + const bn = + b.kind === "table" || + b.kind === "volume" || + b.kind === "function" || + b.kind === "registeredModel" + ? b.name + : ""; + const c = an.localeCompare(bn); + 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..6d86f558a --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/nodeRenderer.ts @@ -0,0 +1,364 @@ +import { + MarkdownString, + ThemeColor, + ThemeIcon, + TreeItemCollapsibleState, +} from "vscode"; +import {UnityCatalogTreeNode, UnityCatalogTreeItem} from "./types"; +import {formatTs} from "./utils"; + +export function buildTreeItem( + node: UnityCatalogTreeNode, + exploreUrl: string | undefined +): UnityCatalogTreeItem { + switch (node.kind) { + case "error": + return renderError(node); + case "empty": + return renderEmpty(node); + case "catalog": + return renderCatalog(node, exploreUrl); + case "schema": + return renderSchema(node, exploreUrl); + case "table": + return renderTable(node, exploreUrl); + case "volume": + return renderVolume(node, exploreUrl); + case "function": + return renderFunction(node, exploreUrl); + case "registeredModel": + return renderRegisteredModel(node, exploreUrl); + case "modelVersion": + return renderModelVersion(node, exploreUrl); + case "column": + return renderColumn(node); + } +} + +function renderError( + node: Extract +): UnityCatalogTreeItem { + return { + label: node.message, + iconPath: new ThemeIcon( + "error", + new ThemeColor("notificationsErrorIcon.foreground") + ), + collapsibleState: TreeItemCollapsibleState.None, + }; +} + +function renderEmpty( + node: Extract +): UnityCatalogTreeItem { + return { + label: node.message, + iconPath: new ThemeIcon( + "info", + new ThemeColor("descriptionForeground") + ), + collapsibleState: TreeItemCollapsibleState.None, + }; +} + +function renderCatalog( + node: Extract, + exploreUrl: string | undefined +): UnityCatalogTreeItem { + const tt = new MarkdownString(`**${node.fullName}**`); + if (node.comment) { + tt.appendMarkdown(`\n\n${node.comment}`); + } + return { + label: node.name, + description: node.owned ? "yours" : undefined, + tooltip: tt, + iconPath: new ThemeIcon( + "library", + new ThemeColor("databricks.unityCatalog.catalog") + ), + contextValue: exploreUrl + ? "unityCatalog.catalog.has-url" + : "unityCatalog.catalog", + collapsibleState: TreeItemCollapsibleState.Collapsed, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderSchema( + node: Extract, + exploreUrl: string | undefined +): 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"; + let description: string | undefined; + if (node.pinned && node.owned) { + description = "★ · yours"; + } else if (node.pinned) { + description = "★"; + } else if (node.owned) { + description = "yours"; + } + return { + label: node.name, + description, + tooltip: tt, + iconPath: new ThemeIcon( + "folder-library", + new ThemeColor("databricks.unityCatalog.schema") + ), + contextValue: node.pinned + ? baseContextValue + ".is-pinned" + : baseContextValue, + collapsibleState: TreeItemCollapsibleState.Collapsed, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderTable( + node: Extract, + exploreUrl: string | undefined +): 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"); + } + + 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}`); + } + + const hasColumns = (node.columns?.length ?? 0) > 0; + return { + label: `${node.name}${typeSuffix}`, + description: node.dataSourceFormat, + tooltip: tt, + iconPath: new ThemeIcon( + "table", + new ThemeColor("databricks.unityCatalog.table") + ), + 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 +): 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"); + } + 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, + tooltip: tt, + iconPath: new ThemeIcon( + "package", + new ThemeColor("databricks.unityCatalog.volume") + ), + contextValue: flags.join("."), + collapsibleState: TreeItemCollapsibleState.None, + url: exploreUrl, + copyText: node.fullName, + storageLocation: node.storageLocation, + }; +} + +function renderFunction( + node: Extract, + exploreUrl: string | undefined +): UnityCatalogTreeItem { + return { + label: node.name, + tooltip: node.fullName, + iconPath: new ThemeIcon( + "symbol-function", + new ThemeColor("databricks.unityCatalog.function") + ), + contextValue: exploreUrl + ? "unityCatalog.function.has-url" + : "unityCatalog.function", + collapsibleState: TreeItemCollapsibleState.None, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderRegisteredModel( + node: Extract, + exploreUrl: string | undefined +): 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}`); + } + return { + label: node.name, + tooltip: tt, + iconPath: new ThemeIcon( + "beaker", + new ThemeColor("databricks.unityCatalog.registeredModel") + ), + contextValue: exploreUrl + ? "unityCatalog.registeredModel.has-url" + : "unityCatalog.registeredModel", + collapsibleState: TreeItemCollapsibleState.Collapsed, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderModelVersion( + node: Extract, + exploreUrl: string | undefined +): 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}`); + } + return { + label: `v${node.version}`, + description: + node.status && node.status !== "READY" ? node.status : undefined, + tooltip: tt, + iconPath: new ThemeIcon( + "tag", + new ThemeColor("databricks.unityCatalog.modelVersion") + ), + contextValue: exploreUrl + ? "unityCatalog.modelVersion.has-url" + : "unityCatalog.modelVersion", + collapsibleState: TreeItemCollapsibleState.None, + url: exploreUrl, + copyText: node.fullName, + }; +} + +function renderColumn( + node: Extract +): UnityCatalogTreeItem { + const icon = + node.nullable === false + ? new ThemeIcon( + "symbol-key", + new ThemeColor("databricks.unityCatalog.columnKey") + ) + : new ThemeIcon( + "symbol-field", + new ThemeColor("databricks.unityCatalog.column") + ); + 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: icon, + 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..f0d6c9f3d --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/registerDetailPanel.ts @@ -0,0 +1,66 @@ +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"; + +export function registerDetailPanel( + extensionUri: Uri, + connectionManager: ConnectionManager, + treeView: TreeView, + treeDataProvider: UnityCatalogTreeDataProvider, + telemetry: Telemetry +): Disposable[] { + async function showDetail(node: UnityCatalogTreeNode) { + if ( + !node || + node.kind === "error" || + node.kind === "empty" || + node.kind === "column" + ) { + return; + } + const panel = await UnityCatalogDetailPanel.getOrCreate(extensionUri); + panel.showNode(node, treeDataProvider.getNodeExploreUrl(node)); + if (node.kind !== "modelVersion") { + const client = connectionManager.workspaceClient; + if (client) { + loadNodeEnrichments(client, node) + .then((enrichments) => panel.enrichNode(enrichments)) + .catch(() => { + /* silently ignore enrichment errors */ + }); + } + } + } + + return [ + telemetry.registerCommand( + "databricks.unityCatalog.showDetail", + showDetail + ), + treeView.onDidChangeSelection(async (event) => { + const node = event.selection[0] as UnityCatalogTreeNode; + if ( + !node || + node.kind === "error" || + node.kind === "empty" || + node.kind === "column" + ) { + return; + } + const panel = + await UnityCatalogDetailPanel.getOrCreate(extensionUri); + panel.showLoading( + node.kind === "modelVersion" + ? node.fullName + : node.fullName ?? node.name ?? "" + ); + 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..ebac50709 --- /dev/null +++ b/packages/databricks-vscode/src/ui/unity-catalog/types.ts @@ -0,0 +1,151 @@ +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; + pinned?: boolean; + 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: "error"; message: string} + | {kind: "empty"; message: string}; + +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..a32b72033 100644 --- a/packages/databricks-vscode/src/vscode-objs/StateStorage.ts +++ b/packages/databricks-vscode/src/vscode-objs/StateStorage.ts @@ -66,6 +66,11 @@ const StorageConfigurations = { location: "workspace", }), + "databricks.unityCatalog.pinnedSchemas": withType()({ + location: "workspace", + defaultValue: [], + }), + "databricks.lastInstalledExtensionVersion": withType()({ location: "global", defaultValue: "0.0.0",