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…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Name |
+ Type |
+ Comment |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Name |
+ Type |
+ Default |
+ Comment |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Principal |
+ Privileges |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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",