This is an automated email from the ASF dual-hosted git repository.

mchades pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new c10caff9b0 [#11157] feat(web-v2): Add view support in relational 
catalog UI (#11160)
c10caff9b0 is described below

commit c10caff9b08743e67c3f463bcd0a345d84a5467e
Author: Bharath Krishna <[email protected]>
AuthorDate: Thu May 21 05:11:30 2026 -0700

    [#11157] feat(web-v2): Add view support in relational catalog UI (#11160)
    
    ### What changes were proposed in this pull request?
    
    Add view support to the web-v2 UI for relational catalogs. This
    includes:
    
    - **API client** (`lib/api/views/index.js`): `getViewsApi` and
    `getViewDetailsApi` functions with proper `encodeURIComponent` encoding
    - **Redux store** (`lib/store/metalakes/index.js`): `fetchViews` and
    `getViewDetails` async thunks, `views: []` state, tree node merging
    helpers (`mergeWithViewNodes`), views fetched alongside tables/functions
    in `setIntoTreeNodeWithFetch`
    - **Tree sidebar** (`TreeComponent.js`): View nodes rendered with
    `mdi:eye-outline` icon
    - **Routing** (`page.js`, `RightContent.js`): View data fetching at
    schema level, view details loading at entity level, `ViewDetailsPage`
    dynamic import
    - **View details page** (`ViewDetailsPage.js`): Header with metadata,
    three tabs (Columns with search/filter/resize/pagination, SQL with
    Descriptions, Properties)
    - **Schema details page** (`SchemaDetailsPage.js`): Views tab added for
    relational catalogs with search and table listing (no Create View button
    — deferred to #11158)
    
    ### Why are the changes needed?
    
    Relational catalogs (e.g., Iceberg, Hive) support views, but the web-v2
    UI had no way to browse or inspect them. Users could only see tables and
    functions. This PR adds first-class view browsing support.
    
    Fix: #11157
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes:
    1. Views now appear in the tree sidebar under relational catalog schemas
    2. Clicking a view shows its details (columns, SQL definition)
    3. Schema detail page has a new "Views" tab listing all views in the
    schema
    
    ### How was this patch tested?
    
    Manual testing against a local Gravitino server (1.3.0-SNAPSHOT) with an
    Iceberg catalog:
    1. Created a metalake, catalog, and schema
    2. Verified views appear in the tree sidebar with eye icon
    3. Verified Views tab appears on schema detail page for relational
    catalogs
    4. Verified view details page renders columns, SQL, and properties tabs
    5. Verified search filtering works in both Views tab and Columns tab
    6. Verified URL routing works correctly for direct navigation to views
    
    ---------
    
    Co-authored-by: Qian Xia <[email protected]>
---
 web-v2/web/package.json                            |   2 +
 web-v2/web/pnpm-lock.yaml                          |  67 ++++
 web-v2/web/src/app/catalogs/TreeComponent.js       |   9 +-
 web-v2/web/src/app/catalogs/page.js                |  24 +-
 .../src/app/catalogs/rightContent/RightContent.js  |  10 +
 .../entitiesContent/SchemaDetailsPage.js           |  98 +++++-
 .../entitiesContent/ViewDetailsPage.js             | 353 +++++++++++++++++++++
 web-v2/web/src/config/index.js                     |  10 +
 web-v2/web/src/lib/api/views/index.js              |  53 ++++
 web-v2/web/src/lib/store/metalakes/index.js        | 142 ++++++++-
 web-v2/web/src/lib/styles/antdStyles/globals.css   |  31 +-
 11 files changed, 790 insertions(+), 9 deletions(-)

diff --git a/web-v2/web/package.json b/web-v2/web/package.json
index 38a1b4e758..9de4e80b29 100644
--- a/web-v2/web/package.json
+++ b/web-v2/web/package.json
@@ -43,6 +43,8 @@
     "react-hot-toast": "^2.4.1",
     "react-redux": "^8.1.3",
     "react-use": "^17.6.0",
+    "sql-formatter": "^15.8.0",
+    "sql-highlight": "^6.1.0",
     "tailwind-merge": "^3.3.1",
     "use-resize-observer": "^9.1.0",
     "yup": "^1.4.0"
diff --git a/web-v2/web/pnpm-lock.yaml b/web-v2/web/pnpm-lock.yaml
index 4ae9698d11..5ded364179 100644
--- a/web-v2/web/pnpm-lock.yaml
+++ b/web-v2/web/pnpm-lock.yaml
@@ -89,6 +89,12 @@ importers:
       react-use:
         specifier: ^17.6.0
         version: 17.6.0([email protected]([email protected]))([email protected])
+      sql-formatter:
+        specifier: ^15.8.0
+        version: 15.8.0
+      sql-highlight:
+        specifier: ^6.1.0
+        version: 6.1.0
       tailwind-merge:
         specifier: ^3.3.1
         version: 3.3.1
@@ -1425,6 +1431,9 @@ packages:
     resolution: {integrity: 
sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
     engines: {node: '>=16'}
 
+  [email protected]:
+    resolution: {integrity: 
sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
+
   [email protected]:
     resolution: {integrity: 
sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
     engines: {node: '>= 6'}
@@ -1557,6 +1566,9 @@ packages:
   [email protected]:
     resolution: {integrity: 
sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
 
+  [email protected]:
+    resolution: {integrity: 
sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
+
   [email protected]:
     resolution: {integrity: 
sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
 
@@ -2367,6 +2379,9 @@ packages:
     resolution: {integrity: 
sha512-YTefgdpKKFgoTDbEUqXqgUJct2OG6/4hs4XWLsxcHkDLj/x/V8WmKIRppPnXP5feQ7d1vuYWSp3qKkxfwaFaxA==}
     engines: {node: '>=18.0.0'}
 
+  [email protected]:
+    resolution: {integrity: 
sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
+
   [email protected]:
     resolution: {integrity: 
sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
     engines: {node: '>=10'}
@@ -2396,6 +2411,10 @@ packages:
   [email protected]:
     resolution: {integrity: 
sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
 
+  [email protected]:
+    resolution: {integrity: 
sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
+    hasBin: true
+
   [email protected]:
     resolution: {integrity: 
sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
     peerDependencies:
@@ -2645,6 +2664,13 @@ packages:
   [email protected]:
     resolution: {integrity: 
sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
 
+  [email protected]:
+    resolution: {integrity: 
sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
+
+  [email protected]:
+    resolution: {integrity: 
sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
+    engines: {node: '>=0.12'}
+
   [email protected]:
     resolution: {integrity: 
sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==}
     peerDependencies:
@@ -2999,6 +3025,10 @@ packages:
     resolution: {integrity: 
sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
     hasBin: true
 
+  [email protected]:
+    resolution: {integrity: 
sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
+    engines: {node: '>=0.12'}
+
   [email protected]:
     resolution: {integrity: 
sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
     engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@@ -3125,6 +3155,14 @@ packages:
     resolution: {integrity: 
sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
     engines: {node: '>=0.10.0'}
 
+  [email protected]:
+    resolution: {integrity: 
sha512-HnjdRHlSsO4Ap2erB5YXAvWggrnk/S4TezUn8zmpq9J/hEKn9+6gGaqiKPyDtI10Xf4zJmHYPREGjMjZmmP1fg==}
+    hasBin: true
+
+  [email protected]:
+    resolution: {integrity: 
sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==}
+    engines: {node: '>=14'}
+
   [email protected]:
     resolution: {integrity: 
sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
 
@@ -4868,6 +4906,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}
@@ -4991,6 +5031,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]:
@@ -5987,6 +6029,8 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]: {}
@@ -6016,6 +6060,13 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    dependencies:
+      commander: 2.20.3
+      moo: 0.5.3
+      railroad-diagrams: 1.0.0
+      randexp: 0.4.6
+
   [email protected]([email protected]([email protected]))([email protected]):
     dependencies:
       react: 18.3.1
@@ -6250,6 +6301,13 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]: {}
+
+  [email protected]:
+    dependencies:
+      discontinuous-range: 1.0.0
+      ret: 0.1.15
+
   [email protected]([email protected]([email protected]))([email protected]):
     dependencies:
       '@babel/runtime': 7.28.4
@@ -6713,6 +6771,8 @@ snapshots:
       path-parse: 1.0.7
       supports-preserve-symlinks-flag: 1.0.0
 
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]:
@@ -6873,6 +6933,13 @@ snapshots:
 
   [email protected]: {}
 
+  [email protected]:
+    dependencies:
+      argparse: 2.0.1
+      nearley: 2.20.1
+
+  [email protected]: {}
+
   [email protected]: {}
 
   [email protected]:
diff --git a/web-v2/web/src/app/catalogs/TreeComponent.js 
b/web-v2/web/src/app/catalogs/TreeComponent.js
index ab7ed1e12b..1d826a3e18 100644
--- a/web-v2/web/src/app/catalogs/TreeComponent.js
+++ b/web-v2/web/src/app/catalogs/TreeComponent.js
@@ -302,6 +302,12 @@ export const TreeComponent = forwardRef(function 
TreeComponent(props, ref) {
             <Icons.iconify icon='material-symbols:function' 
className='my-icon-small' />
           </span>
         )
+      case 'view':
+        return (
+          <span role='img' aria-label='view' className='anticon anticon-frown'>
+            <Icons.iconify icon='ic:outline-table-view' 
className='my-icon-small' />
+          </span>
+        )
       default:
         return <></>
     }
@@ -323,11 +329,12 @@ export const TreeComponent = forwardRef(function 
TreeComponent(props, ref) {
       const catalogType = searchParams.get('catalogType')
       const schema = searchParams.get('schema')
       const table = searchParams.get('table')
+      const view = searchParams.get('view')
       const fileset = searchParams.get('fileset')
       const topic = searchParams.get('topic')
       const model = searchParams.get('model')
       const func = searchParams.get('function')
-      const entity = table || fileset || topic || model || func
+      const entity = table || view || fileset || topic || model || func
 
       if (!metalake) {
         dispatch(setExpanded([]))
diff --git a/web-v2/web/src/app/catalogs/page.js 
b/web-v2/web/src/app/catalogs/page.js
index e4af25ad55..2f4abfd744 100644
--- a/web-v2/web/src/app/catalogs/page.js
+++ b/web-v2/web/src/app/catalogs/page.js
@@ -40,7 +40,9 @@ import {
   fetchModels,
   fetchModelVersions,
   fetchFunctions,
+  fetchViews,
   getFunctionDetails,
+  getViewDetails,
   getMetalakeDetails,
   getCatalogDetails,
   getSchemaDetails,
@@ -69,7 +71,18 @@ const CatalogsListPage = () => {
   const treeRef = useRef()
 
   const buildNodePath = routeParams => {
-    const keys = ['metalake', 'catalog', 'catalogType', 'schema', 'table', 
'fileset', 'topic', 'model', 'function']
+    const keys = [
+      'metalake',
+      'catalog',
+      'catalogType',
+      'schema',
+      'table',
+      'fileset',
+      'topic',
+      'model',
+      'function',
+      'view'
+    ]
 
     return keys.map(key => (routeParams[key] ? `{{${routeParams[key]}}}` : 
'')).join('')
   }
@@ -85,6 +98,7 @@ const CatalogsListPage = () => {
       topic: searchParams.get('topic'),
       model: searchParams.get('model'),
       function: searchParams.get('function'),
+      view: searchParams.get('view'),
       version: searchParams.get('version')
     }
     async function fetchDependsData() {
@@ -99,6 +113,7 @@ const CatalogsListPage = () => {
           topic,
           model,
           function: func,
+          view,
           version
         } = routeParams
 
@@ -156,6 +171,7 @@ const CatalogsListPage = () => {
           switch (catalogType) {
             case 'relational':
               dispatch(fetchTables({ init: true, page: 'schemas', metalake, 
catalog, schema }))
+              dispatch(fetchViews({ init: true, metalake, catalog, schema }))
               break
             case 'fileset':
               dispatch(fetchFilesets({ init: true, page: 'schemas', metalake, 
catalog, schema }))
@@ -310,6 +326,12 @@ const CatalogsListPage = () => {
             await dispatch(getFunctionDetails({ init: true, metalake, catalog, 
schema, functionName: func }))
             await dispatch(setActivatedDetailsLoading(false))
           }
+          if (view) {
+            store.views.length === 0 && (await dispatch(fetchViews({ init: 
true, metalake, catalog, schema })))
+            await dispatch(setActivatedDetailsLoading(true))
+            await dispatch(getViewDetails({ init: true, metalake, catalog, 
schema, view }))
+            await dispatch(setActivatedDetailsLoading(false))
+          }
         }
         if (paramsSize === 6 && version) {
           if (!store.catalogs.length) {
diff --git a/web-v2/web/src/app/catalogs/rightContent/RightContent.js 
b/web-v2/web/src/app/catalogs/rightContent/RightContent.js
index 5b9d881709..ef5639e407 100644
--- a/web-v2/web/src/app/catalogs/rightContent/RightContent.js
+++ b/web-v2/web/src/app/catalogs/rightContent/RightContent.js
@@ -62,6 +62,11 @@ const FunctionDetailsPage = dynamic(() => 
import('./entitiesContent/FunctionDeta
   ssr: false
 })
 
+const ViewDetailsPage = dynamic(() => 
import('./entitiesContent/ViewDetailsPage'), {
+  loading: () => <Loading height={'200px'} />,
+  ssr: false
+})
+
 const RightContent = () => {
   const searchParams = useSearchParams()
 
@@ -70,9 +75,11 @@ const RightContent = () => {
   const catalog = searchParams.get('catalog')
   const schema = searchParams.get('schema')
   const functionName = searchParams.get('function')
+  const viewName = searchParams.get('view')
 
   const entity =
     functionName ||
+    viewName ||
     searchParams.get('table') ||
     searchParams.get('fileset') ||
     searchParams.get('topic') ||
@@ -92,6 +99,9 @@ const RightContent = () => {
       if (functionName) {
         return <FunctionDetailsPage />
       }
+      if (viewName) {
+        return <ViewDetailsPage />
+      }
       switch (catalogType) {
         case 'relational':
           return (
diff --git 
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js 
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js
index 32b2a48471..cc73dc6bee 100644
--- 
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js
+++ 
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/SchemaDetailsPage.js
@@ -22,7 +22,21 @@
 import { useContext, useEffect, useMemo, useState } from 'react'
 import dynamic from 'next/dynamic'
 import { ExclamationCircleFilled, PlusOutlined } from '@ant-design/icons'
-import { Breadcrumb, Button, Divider, Flex, Input, Popover, Space, Spin, 
Table, Tabs, Tooltip, Typography } from 'antd'
+import {
+  Breadcrumb,
+  Button,
+  Divider,
+  Flex,
+  Input,
+  Modal,
+  Popover,
+  Space,
+  Spin,
+  Table,
+  Tabs,
+  Tooltip,
+  Typography
+} from 'antd'
 import { useAntdColumnResize } from 'react-antd-column-resize'
 import useResizeObserver from 'use-resize-observer'
 import { useAppSelector, useAppDispatch } from '@/lib/hooks/useStore'
@@ -41,6 +55,7 @@ import {
   deleteModel,
   deleteTopic,
   deleteTable,
+  deleteView,
   getTableDetails,
   getCurrentEntityOwner
 } from '@/lib/store/metalakes'
@@ -126,11 +141,13 @@ export default function SchemaDetailsPage() {
           anthEnable
             ? [
                 { label: 'Tables', key: 'Tables' },
+                { label: 'Views', key: 'Views' },
                 { label: 'Functions', key: 'Functions' },
                 { label: 'Associated Roles', key: 'Associated Roles' }
               ]
             : [
                 { label: 'Tables', key: 'Tables' },
+                { label: 'Views', key: 'Views' },
                 { label: 'Functions', key: 'Functions' }
               ]
         )
@@ -218,6 +235,18 @@ export default function SchemaDetailsPage() {
       }
     })
 
+  const viewData = [...(store.views || [])]
+    .filter(c => {
+      if (search === '') return true
+
+      return c.name.includes(search)
+    })
+    .map(entity => ({
+      ...entity,
+      key: entity.name,
+      children: undefined
+    }))
+
   const tagContent = (
     <div>
       <Tags readOnly={true} metadataObjectType={'schema'} 
metadataObjectFullName={`${catalog}.${schema}`} />
@@ -360,7 +389,11 @@ export default function SchemaDetailsPage() {
               )
               break
             case 'relational':
-              await dispatch(deleteTable({ metalake: currentMetalake, catalog, 
catalogType, schema, table: entity }))
+              if (type === 'view') {
+                await dispatch(deleteView({ metalake: currentMetalake, 
catalog, schema, view: entity }))
+              } else {
+                await dispatch(deleteTable({ metalake: currentMetalake, 
catalog, catalogType, schema, table: entity }))
+              }
               break
             case 'model':
               await dispatch(deleteModel({ metalake: currentMetalake, catalog, 
catalogType, schema, model: entity }))
@@ -441,10 +474,53 @@ export default function SchemaDetailsPage() {
     [nameCol, entityType, store.tableLoading, anthEnable, 
catalogData?.provider]
   )
 
+  const viewColumns = useMemo(
+    () => [
+      {
+        title: 'View Name',
+        dataIndex: 'name',
+        key: 'name',
+        width: 300,
+        ellipsis: true,
+        sorter: (a, b) => 
a?.name.toLowerCase().localeCompare(b?.name.toLowerCase()),
+        render: name => (
+          <Link
+            data-refer={`view-link-${name}`}
+            
href={`/catalogs?metalake=${encodeURIComponent(currentMetalake)}&catalogType=${catalogType}&catalog=${encodeURIComponent(catalog)}&schema=${encodeURIComponent(schema)}&view=${encodeURIComponent(name)}`}
+          >
+            {name}
+          </Link>
+        )
+      },
+      {
+        title: 'Actions',
+        dataIndex: 'action',
+        key: 'action',
+        width: 100,
+        render: (_, record) => (
+          <a data-refer={`delete-view-${record.name}`}>
+            <Tooltip title='Delete'>
+              <Icons.Trash2Icon className='size-4' onClick={() => 
showDeleteConfirm(Modal, record, 'view')} />
+            </Tooltip>
+          </a>
+        )
+      }
+    ],
+    [currentMetalake, catalogType, catalog, schema, catalogData?.provider]
+  )
+
   const { resizableColumns, components, tableWidth } = useAntdColumnResize(() 
=> {
     return { columns, minWidth: 100 }
   }, [columns])
 
+  const {
+    resizableColumns: viewResizableColumns,
+    components: viewComponents,
+    tableWidth: viewTableWidth
+  } = useAntdColumnResize(() => {
+    return { columns: viewColumns, minWidth: 100 }
+  }, [viewColumns])
+
   return (
     <div ref={ref}>
       <Spin spinning={store.activatedDetailsLoading}>
@@ -544,6 +620,24 @@ export default function SchemaDetailsPage() {
         />
       ) : tabKey === 'Functions' ? (
         <Functions metalake={currentMetalake} catalog={catalog} 
schema={schema} />
+      ) : tabKey === 'Views' ? (
+        <>
+          <Flex justify='flex-end' className='mb-4'>
+            <div className='flex w-1/3 gap-4'>
+              <Search name='searchViewInput' placeholder='Search...' 
value={search} onChange={onSearchTable} />
+            </div>
+          </Flex>
+          <Table
+            data-refer='view-list-grid'
+            size='small'
+            style={{ maxHeight: 'calc(100vh - 30rem)' }}
+            scroll={{ x: viewTableWidth, y: 'calc(100vh - 37rem)' }}
+            dataSource={viewData}
+            pagination={{ position: ['bottomCenter'], showSizeChanger: true }}
+            columns={viewResizableColumns}
+            components={viewComponents}
+          />
+        </>
       ) : (
         <>
           <Flex justify='flex-end' className='mb-4'>
diff --git 
a/web-v2/web/src/app/catalogs/rightContent/entitiesContent/ViewDetailsPage.js 
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/ViewDetailsPage.js
new file mode 100644
index 0000000000..ac497fd6b4
--- /dev/null
+++ 
b/web-v2/web/src/app/catalogs/rightContent/entitiesContent/ViewDetailsPage.js
@@ -0,0 +1,353 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+'use client'
+
+import { useEffect, useMemo, useState } from 'react'
+import {
+  Descriptions,
+  Divider,
+  Flex,
+  Input,
+  Popover,
+  Space,
+  Segmented,
+  Spin,
+  Table,
+  Tabs,
+  Tag,
+  Tooltip,
+  Typography,
+  message
+} from 'antd'
+import { useAntdColumnResize } from 'react-antd-column-resize'
+import useResizeObserver from 'use-resize-observer'
+import { format } from 'sql-formatter'
+import { highlight } from 'sql-highlight'
+import Icons from '@/components/Icons'
+import { ColumnTypeColorEnum, sqlFormatterDialectMap } from '@/config'
+import { useAppSelector } from '@/lib/hooks/useStore'
+import { useSearchParams } from 'next/navigation'
+import { formatToDateTime, isValidDate } from '@/lib/utils/date'
+import PropertiesContent from '@/components/PropertiesContent'
+
+const { Title, Paragraph } = Typography
+const { Search } = Input
+
+export default function ViewDetailsPage() {
+  const searchParams = useSearchParams()
+  const viewName = searchParams.get('view')
+  const store = useAppSelector(state => state.metalakes)
+  const viewData = store.activatedDetails
+  const [search, setSearch] = useState('')
+  const [tabKey, setTabKey] = useState('Columns')
+  const [activeSqlKey, setActiveSqlKey] = useState(null)
+  const [messageApi, contextHolder] = message.useMessage()
+  const { ref, width } = useResizeObserver()
+
+  const tableData = viewData?.columns
+    ?.filter(c => {
+      if (search === '') return true
+
+      return c.name.includes(search)
+    })
+    .map(col => ({
+      ...col,
+      key: col.name,
+      type: col.type
+    }))
+
+  const columnTypeFilters = viewData?.columns
+    ?.map(column => (typeof column.type === 'string' ? column.type : 
column.type.type))
+    .filter((value, index, self) => self.indexOf(value) === index)
+    .map(t => ({
+      text: t,
+      value: t
+    }))
+
+  const columnTypeColor = type => {
+    const formatType = typeof type === 'string' ? type.replace(/\(.*\)/, '') : 
'objectType'
+
+    return ColumnTypeColorEnum[formatType]
+  }
+
+  const representations = viewData?.representations || []
+  const properties = viewData?.properties
+  const defaultCatalog = viewData?.defaultCatalog
+  const defaultSchema = viewData?.defaultSchema
+  const createdAt = viewData?.audit?.createTime
+  const createdAtText = !createdAt || !isValidDate(createdAt) ? '-' : 
formatToDateTime(createdAt)
+
+  const formatSql = (sql, dialect) => {
+    if (!sql) {
+      return '-'
+    }
+
+    const normalizedDialect = (dialect || 'sql').toLowerCase()
+    const mappedDialect = sqlFormatterDialectMap[normalizedDialect]
+
+    try {
+      if (mappedDialect) {
+        return format(sql, {
+          language: mappedDialect,
+          keywordCase: 'upper'
+        })
+      }
+
+      return format(sql, { keywordCase: 'upper' })
+    } catch {
+      return sql
+    }
+  }
+
+  const onCopySql = async sql => {
+    try {
+      await navigator.clipboard.writeText(sql)
+      messageApi.success('SQL copied')
+    } catch {
+      messageApi.error('Failed to copy SQL')
+    }
+  }
+
+  const highlightSql = sql => {
+    try {
+      return highlight(sql, { html: true })
+    } catch {
+      return sql
+    }
+  }
+
+  const sqlSegmentItems = useMemo(
+    () =>
+      representations.map((rep, index) => {
+        const formattedSql = formatSql(rep?.sql, rep?.dialect)
+
+        return {
+          key: `sql-${index}-${rep?.dialect || 'unknown'}`,
+          label: rep?.dialect || `dialect-${index + 1}`,
+          formattedSql
+        }
+      }),
+    [representations]
+  )
+
+  useEffect(() => {
+    if (sqlSegmentItems.length === 0) {
+      setActiveSqlKey(null)
+
+      return
+    }
+
+    if (!sqlSegmentItems.some(item => item.key === activeSqlKey)) {
+      setActiveSqlKey(sqlSegmentItems[0].key)
+    }
+  }, [sqlSegmentItems, activeSqlKey])
+
+  const activeSqlItem =
+    sqlSegmentItems.find(item => item.key === activeSqlKey) || 
(sqlSegmentItems.length > 0 ? sqlSegmentItems[0] : null)
+
+  const tabOptions = [
+    { label: 'Columns', key: 'Columns' },
+    { label: 'SQL', key: 'SQL' }
+  ]
+
+  const onChangeTab = key => {
+    setTabKey(key)
+  }
+
+  const onSearchColumn = e => {
+    const { value } = e.target
+    setSearch(value)
+  }
+
+  const columns = useMemo(
+    () => [
+      {
+        title: 'Column Name',
+        dataIndex: 'name',
+        key: 'name',
+        width: 300,
+        ellipsis: true,
+        sorter: (a, b) => 
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
+        render: name => <span>{name}</span>
+      },
+      {
+        title: 'Data Type',
+        dataIndex: 'type',
+        key: 'type',
+        width: 200,
+        ellipsis: true,
+        filters: columnTypeFilters,
+        onFilter: (value, record) =>
+          typeof record.type === 'string' ? record.type.indexOf(value) === 0 : 
record.type.type.indexOf(value) === 0,
+        render: type => <Tag color={columnTypeColor(type)}>{typeof type === 
'string' ? type : type.type}</Tag>
+      },
+      {
+        title: 'Nullable',
+        dataIndex: 'nullable',
+        key: 'nullable',
+        width: 100,
+        render: nullable => <span>{nullable ? 'Yes' : 'No'}</span>
+      },
+      {
+        title: 'Comment',
+        dataIndex: 'comment',
+        key: 'comment',
+        ellipsis: true,
+        render: comment => <span>{comment || '-'}</span>
+      }
+    ],
+    [viewData]
+  )
+
+  const { resizableColumns, components, tableWidth } = useAntdColumnResize(() 
=> {
+    return { columns, minWidth: 100 }
+  }, [columns])
+
+  return (
+    <>
+      {contextHolder}
+      <Spin spinning={store.activatedDetailsLoading}>
+        <Flex className='mb-2' gap='small' align='flex-start' ref={ref}>
+          <div className='size-8'>
+            <Icons.iconify icon='ic:outline-table-view' 
className='my-icon-large' />
+          </div>
+          <div className='grow-1 relative bottom-1'>
+            <Title level={3} style={{ marginBottom: '0.125rem' }}>
+              <span
+                title={viewName}
+                className='min-w-10 truncate'
+                style={{ maxWidth: `calc(${width}px - 56px)`, display: 
'inherit' }}
+              >
+                {viewName}
+              </span>
+            </Title>
+            <Paragraph
+              type='secondary'
+              className='truncate'
+              title={viewData?.comment}
+              style={{ marginBottom: 0, maxWidth: `calc(${width}px - 56px)` }}
+            >
+              {viewData?.comment}
+            </Paragraph>
+          </div>
+        </Flex>
+        <Space split={<Divider type='vertical' />} wrap={true} 
className='mb-2'>
+          {viewData?.audit?.creator && (
+            <Space size={4}>
+              <Tooltip title='Creator' placement='top'>
+                <Icons.User className='size-4' color='grey' />
+              </Tooltip>
+              <span>{viewData.audit.creator}</span>
+            </Space>
+          )}
+          <Space size={4}>
+            <Tooltip title='Created' placement='top'>
+              <Icons.iconify icon='mdi:clock-outline' className='size-4' 
props={{ color: 'grey' }} />
+            </Tooltip>
+            <span>{createdAtText}</span>
+          </Space>
+          {properties && Object.keys(properties).length > 0 && (
+            <Space size={4}>
+              <Tooltip title='Properties' placement='top'>
+                <Icons.TableProperties className='size-4' color='grey' />
+              </Tooltip>
+              <Popover
+                placement='bottom'
+                title={<span>Properties</span>}
+                content={<PropertiesContent properties={properties} />}
+              >
+                <a 
className='text-defaultPrimary'>{Object.keys(properties).length}</a>
+              </Popover>
+            </Space>
+          )}
+        </Space>
+      </Spin>
+      <Tabs data-refer='details-tabs' defaultActiveKey={tabKey} 
onChange={onChangeTab} items={tabOptions} />
+      {tabKey === 'Columns' && (
+        <>
+          <Flex justify='flex-end' className='mb-4'>
+            <div className='flex w-1/3 gap-4'>
+              <Search name='searchColumnInput' placeholder='Search...' 
value={search} onChange={onSearchColumn} />
+            </div>
+          </Flex>
+          <Spin spinning={store.activatedDetailsLoading}>
+            <Table
+              data-refer='view-columns-grid'
+              size='small'
+              style={{ maxHeight: 'calc(100vh - 30rem)' }}
+              scroll={{ x: tableWidth, y: 'calc(100vh - 37rem)' }}
+              dataSource={tableData}
+              pagination={{ position: ['bottomCenter'], showSizeChanger: true 
}}
+              columns={resizableColumns}
+              components={components}
+            />
+          </Spin>
+        </>
+      )}
+      {tabKey === 'SQL' && (
+        <div className='mt-2'>
+          {(defaultCatalog || defaultSchema) && (
+            <Descriptions
+              layout='horizontal'
+              column={1}
+              size='small'
+              bordered
+              className='mb-4'
+              labelStyle={{ width: '20%' }}
+            >
+              {defaultCatalog && <Descriptions.Item label='Default 
Catalog'>{defaultCatalog}</Descriptions.Item>}
+              {defaultSchema && <Descriptions.Item label='Default 
Schema'>{defaultSchema}</Descriptions.Item>}
+            </Descriptions>
+          )}
+          {representations.length > 0 ? (
+            <>
+              <Segmented
+                className='mb-3'
+                size='small'
+                options={sqlSegmentItems.map(item => ({ label: item.label, 
value: item.key }))}
+                value={activeSqlItem?.key}
+                onChange={setActiveSqlKey}
+              />
+              {activeSqlItem && (
+                <div className='relative rounded border border-borderColor 
p-3'>
+                  <Tooltip title='Copy SQL' placement='top'>
+                    <button
+                      type='button'
+                      className='absolute right-3 top-3 inline-flex 
cursor-pointer items-center rounded border border-borderColor bg-transparent 
p-1 text-textSecondary transition-colors hover:text-defaultPrimary'
+                      onClick={() => onCopySql(activeSqlItem.formattedSql)}
+                    >
+                      <Icons.Copy className='size-4' />
+                    </button>
+                  </Tooltip>
+                  <pre
+                    className='m-0 whitespace-pre-wrap break-all font-mono 
text-sm leading-6'
+                    dangerouslySetInnerHTML={{ __html: 
highlightSql(activeSqlItem.formattedSql) }}
+                  />
+                </div>
+              )}
+            </>
+          ) : (
+            <Paragraph type='secondary'>No SQL representations 
available.</Paragraph>
+          )}
+        </div>
+      )}
+    </>
+  )
+}
diff --git a/web-v2/web/src/config/index.js b/web-v2/web/src/config/index.js
index 93f46f9fe6..8ed51c8d53 100644
--- a/web-v2/web/src/config/index.js
+++ b/web-v2/web/src/config/index.js
@@ -270,6 +270,16 @@ export const distributionInfoMap = {
   }
 }
 
+export const sqlFormatterDialectMap = {
+  mysql: 'mysql',
+  postgresql: 'postgresql',
+  postgres: 'postgresql',
+  spark: 'spark',
+  trino: 'trino',
+  hive: 'spark',
+  sql: 'sql'
+}
+
 export const partitionInfoMap = {
   hive: ['identity'],
   'jdbc-doris': ['range', 'list'],
diff --git a/web-v2/web/src/lib/api/views/index.js 
b/web-v2/web/src/lib/api/views/index.js
new file mode 100644
index 0000000000..bcb9154466
--- /dev/null
+++ b/web-v2/web/src/lib/api/views/index.js
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { defHttp } from '@/lib/utils/axios'
+
+const Apis = {
+  GET: ({ metalake, catalog, schema }) =>
+    
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(
+      catalog
+    )}/schemas/${encodeURIComponent(schema)}/views`,
+  GET_DETAIL: ({ metalake, catalog, schema, view }) =>
+    
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(
+      catalog
+    
)}/schemas/${encodeURIComponent(schema)}/views/${encodeURIComponent(view)}`,
+  DELETE: ({ metalake, catalog, schema, view }) =>
+    
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(
+      catalog
+    )}/schemas/${encodeURIComponent(schema)}/views/${encodeURIComponent(view)}`
+}
+
+export const getViewsApi = params => {
+  return defHttp.get({
+    url: `${Apis.GET(params)}`
+  })
+}
+
+export const getViewDetailsApi = ({ metalake, catalog, schema, view }) => {
+  return defHttp.get({
+    url: `${Apis.GET_DETAIL({ metalake, catalog, schema, view })}`
+  })
+}
+
+export const deleteViewApi = ({ metalake, catalog, schema, view }) => {
+  return defHttp.delete({
+    url: `${Apis.DELETE({ metalake, catalog, schema, view })}`
+  })
+}
diff --git a/web-v2/web/src/lib/store/metalakes/index.js 
b/web-v2/web/src/lib/store/metalakes/index.js
index 78cc3719d0..a7e5bbe3ba 100644
--- a/web-v2/web/src/lib/store/metalakes/index.js
+++ b/web-v2/web/src/lib/store/metalakes/index.js
@@ -78,6 +78,7 @@ import {
   deleteVersionApi
 } from '@/lib/api/models'
 import { getFunctionsApi, getFunctionDetailsApi } from '@/lib/api/functions'
+import { getViewsApi, getViewDetailsApi, deleteViewApi } from '@/lib/api/views'
 
 const remapExpandedAndLoadedNodes = ({ getState, mapNode }) => {
   const expandedNodes = getState().metalakes.expandedNodes.map(mapNode)
@@ -92,8 +93,17 @@ const remapExpandedAndLoadedNodes = ({ getState, mapNode }) 
=> {
 const mergeWithFunctionNodes = ({ tree, key, entities }) => {
   const existingNode = findInTree(tree, 'key', key)
   const functions = existingNode?.children?.filter(child => child?.node === 
'function') || []
+  const views = existingNode?.children?.filter(child => child?.node === 
'view') || []
 
-  return _.uniqBy([...entities, ...functions], 'key')
+  return _.uniqBy([...entities, ...functions, ...views], 'key')
+}
+
+const mergeWithViewNodes = ({ tree, key, entities }) => {
+  const existingNode = findInTree(tree, 'key', key)
+  const tables = existingNode?.children?.filter(child => child?.node === 
'table') || []
+  const functions = existingNode?.children?.filter(child => child?.node === 
'function') || []
+
+  return _.uniqBy([...tables, ...functions, ...entities], 'key')
 }
 
 export const fetchMetalakes = createAsyncThunk('appMetalakes/fetchMetalakes', 
async (params, { getState }) => {
@@ -284,9 +294,12 @@ export const setIntoTreeNodeWithFetch = createAsyncThunk(
           break
       }
 
-      const [funcResult, entityResult] = await Promise.allSettled([
+      const viewsPromise = type === 'relational' ? getViewsApi({ metalake, 
catalog, schema }) : Promise.resolve(null)
+
+      const [funcResult, entityResult, viewResult] = await Promise.allSettled([
         getFunctionsApi({ metalake, catalog, schema, details: false }),
-        entityPromise
+        entityPromise,
+        viewsPromise
       ])
 
       const functions =
@@ -308,6 +321,23 @@ export const setIntoTreeNodeWithFetch = createAsyncThunk(
             })
           : []
 
+      const views =
+        viewResult.status === 'fulfilled' && viewResult.value
+          ? (viewResult.value?.identifiers || []).map(viewItem => {
+              return {
+                ...viewItem,
+                node: 'view',
+                id: 
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${viewItem.name}}}`,
+                key: 
`{{${metalake}}}{{${catalog}}}{{${type}}}{{${schema}}}{{${viewItem.name}}}`,
+                path: `?${new URLSearchParams({ metalake, catalog, 
catalogType: type, schema, view: viewItem.name }).toString()}`,
+                name: viewItem.name,
+                title: viewItem.name,
+                isLeaf: true,
+                children: []
+              }
+            })
+          : []
+
       let entities = []
       if (entityResult.status === 'fulfilled' && entityResult.value) {
         switch (type) {
@@ -382,7 +412,7 @@ export const setIntoTreeNodeWithFetch = createAsyncThunk(
         }
       }
 
-      result.data = [...entities, ...functions]
+      result.data = [...entities, ...functions, ...views]
     }
 
     return result
@@ -2147,6 +2177,84 @@ export const getFunctionDetails = createAsyncThunk(
   }
 )
 
+export const fetchViews = createAsyncThunk(
+  'appMetalakes/fetchViews',
+  async ({ init, metalake, catalog, schema }, { getState, dispatch }) => {
+    const [err, res] = await to(getViewsApi({ metalake, catalog, schema }))
+
+    if (init && (err || !res)) {
+      throw new Error(err)
+    }
+
+    const { identifiers = [] } = res || {}
+
+    const views = identifiers.map(viewItem => {
+      return {
+        ...viewItem,
+        node: 'view',
+        id: 
`{{${metalake}}}{{${catalog}}}{{${'relational'}}}{{${schema}}}{{${viewItem.name}}}`,
+        key: 
`{{${metalake}}}{{${catalog}}}{{${'relational'}}}{{${schema}}}{{${viewItem.name}}}`,
+        path: `?${new URLSearchParams({
+          metalake,
+          catalog,
+          catalogType: 'relational',
+          schema,
+          view: viewItem.name
+        }).toString()}`,
+        name: viewItem.name,
+        title: viewItem.name,
+        isLeaf: true,
+        children: []
+      }
+    })
+
+    if (
+      init &&
+      
getState().metalakes.loadedNodes.includes(`{{${metalake}}}{{${catalog}}}{{${'relational'}}}{{${schema}}}`)
+    ) {
+      const schemaKey = 
`{{${metalake}}}{{${catalog}}}{{${'relational'}}}{{${schema}}}`
+      dispatch(
+        setIntoTreeNodes({
+          key: schemaKey,
+          data: mergeWithViewNodes({ tree: getState().metalakes.metalakeTree, 
key: schemaKey, entities: views })
+        })
+      )
+    }
+
+    return { views, init }
+  }
+)
+
+export const getViewDetails = createAsyncThunk(
+  'appMetalakes/getViewDetails',
+  async ({ init, metalake, catalog, schema, view }) => {
+    const [err, res] = await to(getViewDetailsApi({ metalake, catalog, schema, 
view }))
+
+    if (err || !res) {
+      throw new Error(err)
+    }
+
+    return { view: res.view || res, init }
+  }
+)
+
+export const deleteView = createAsyncThunk(
+  'appMetalakes/deleteView',
+  async ({ metalake, catalog, schema, view }, { dispatch }) => {
+    await dispatch(setTableLoading(true))
+    const [err, res] = await to(deleteViewApi({ metalake, catalog, schema, 
view }))
+    await dispatch(setTableLoading(false))
+
+    if (err || !res) {
+      throw new Error(err)
+    }
+
+    await dispatch(fetchViews({ metalake, catalog, schema, init: true }))
+
+    return res
+  }
+)
+
 export const appMetalakesSlice = createSlice({
   name: 'appMetalakes',
   initialState: {
@@ -2158,6 +2266,7 @@ export const appMetalakesSlice = createSlice({
     schemas: [],
     tables: [],
     functions: [],
+    views: [],
     columns: [],
     filesets: [],
     topics: [],
@@ -2222,6 +2331,7 @@ export const appMetalakesSlice = createSlice({
       state.schemas = []
       state.tables = []
       state.functions = []
+      state.views = []
       state.columns = []
       state.filesets = []
       state.topics = []
@@ -2504,6 +2614,7 @@ export const appMetalakesSlice = createSlice({
       state.catalogs = []
       state.schemas = []
       state.tables = []
+      state.views = []
       state.columns = []
       state.filesets = []
       state.topics = []
@@ -2861,6 +2972,29 @@ export const appMetalakesSlice = createSlice({
         toast.error(action.error.message)
       }
     })
+    builder.addCase(fetchViews.fulfilled, (state, action) => {
+      state.views = action.payload.views
+    })
+    builder.addCase(fetchViews.rejected, (state, action) => {
+      if (!action.error.message.includes('CanceledError')) {
+        toast.error(action.error.message)
+      }
+    })
+    builder.addCase(getViewDetails.fulfilled, (state, action) => {
+      if (action.payload.init) {
+        state.activatedDetails = action.payload.view
+      }
+    })
+    builder.addCase(getViewDetails.rejected, (state, action) => {
+      if (!action.error.message.includes('CanceledError')) {
+        toast.error(action.error.message)
+      }
+    })
+    builder.addCase(deleteView.rejected, (state, action) => {
+      if (!action.error.message.includes('CanceledError')) {
+        toast.error(action.error.message)
+      }
+    })
     builder.addCase(getVersionDetails.rejected, (state, action) => {
       if (!action.error.message.includes('CanceledError')) {
         toast.error(action.error.message)
diff --git a/web-v2/web/src/lib/styles/antdStyles/globals.css 
b/web-v2/web/src/lib/styles/antdStyles/globals.css
index 31d4f3a4b5..9d913d685f 100644
--- a/web-v2/web/src/lib/styles/antdStyles/globals.css
+++ b/web-v2/web/src/lib/styles/antdStyles/globals.css
@@ -52,7 +52,8 @@
 }
 
 .ant-tabs-content-holder {
-  width: 0;
+  min-width: 0;
+  width: 100%;
 }
 
 .ant-steps-item-active {
@@ -158,3 +159,31 @@
   overflow: hidden;
   text-overflow: ellipsis;
 }
+
+.sql-hl-keyword {
+  @apply font-semibold text-defaultPrimary;
+}
+
+.sql-hl-function {
+  color: #7c3aed;
+}
+
+.sql-hl-string {
+  color: #166534;
+}
+
+.sql-hl-number {
+  color: #1d4ed8;
+}
+
+.sql-hl-identifier {
+  color: inherit;
+}
+
+.sql-hl-special {
+  color: #6b7280;
+}
+
+.sql-hl-bracket {
+  color: #6b7280;
+}


Reply via email to