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

jinsongzhou pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/amoro.git


The following commit(s) were added to refs/heads/master by this push:
     new 2bda3ab8e [AMORO-3226] Provider a better table explorer view (#4053)
2bda3ab8e is described below

commit 2bda3ab8e8359605cdf7361669fbdbcd86cebb7d
Author: nathan.ma <[email protected]>
AuthorDate: Thu Jan 22 11:02:06 2026 +0800

    [AMORO-3226] Provider a better table explorer view (#4053)
    
    * Provider a better table explorer view
    
    * chore: clean table explorer comments and dead code
    
    - Replace Chinese-only comments in new table explorer views with English 
equivalents to avoid non-ASCII comments in the codebase
    - Remove unused `hideTablesMenu` helper from tables view while keeping 
runtime behavior unchanged
    - Re-run Maven compile (`mvn -q -DskipTests compile`) to ensure Java 
modules still compile successfully after the cleanup
    
    Co-Authored-By: Aime <[email protected]>
    Change-Id: I618daa3eb9097ab9e9fe4d68f4581431cbac5bf7
    
    * optimize experience
    
    * fix(tables): keep previous table when switching to empty database
    
    ---------
    
    Co-authored-by: majin.nathan <[email protected]>
    Co-authored-by: Aime <[email protected]>
---
 amoro-web/src/components/Layout.vue                |  18 +-
 amoro-web/src/components/Sidebar.vue               |  64 +--
 .../src/views/tables/components/TableExplorer.vue  | 639 +++++++++++++++++++++
 amoro-web/src/views/tables/index.vue               | 226 ++++++--
 4 files changed, 838 insertions(+), 109 deletions(-)

diff --git a/amoro-web/src/components/Layout.vue 
b/amoro-web/src/components/Layout.vue
index 04bf83e43..65d3325e7 100644
--- a/amoro-web/src/components/Layout.vue
+++ b/amoro-web/src/components/Layout.vue
@@ -17,7 +17,8 @@
  / -->
 
 <script lang="ts">
-import { defineComponent } from 'vue'
+import { computed, defineComponent } from 'vue'
+import { useRoute } from 'vue-router'
 import SideBar from '@/components/Sidebar.vue'
 import TopBar from '@/components/Topbar.vue'
 
@@ -37,6 +38,14 @@ export default defineComponent({
       default: true,
     },
   },
+  setup() {
+    const route = useRoute()
+    const isTablesPage = computed(() => route.path.includes('/tables'))
+
+    return {
+      isTablesPage,
+    }
+  },
 })
 </script>
 
@@ -48,7 +57,7 @@ export default defineComponent({
       <!-- topbar -->
       <TopBar v-if="showTopBar" />
       <!-- content -->
-      <div class="content">
+      <div class="content" :class="{ 'content--workspace': isTablesPage }">
         <router-view />
       </div>
     </div>
@@ -66,11 +75,14 @@ export default defineComponent({
     flex: 1;
     flex-direction: column;
     transition: width 0.3s;
-    overflow: auto;
+    overflow: hidden;
     .content {
       height: calc(100% - 48px);
       overflow: auto;
     }
+    .content--workspace {
+      overflow: hidden;
+    }
   }
 }
 </style>
diff --git a/amoro-web/src/components/Sidebar.vue 
b/amoro-web/src/components/Sidebar.vue
index 04176cf8b..dc0b2bd0d 100644
--- a/amoro-web/src/components/Sidebar.vue
+++ b/amoro-web/src/components/Sidebar.vue
@@ -17,11 +17,10 @@
  / -->
 
 <script lang="ts">
-import { computed, defineComponent, nextTick, reactive, ref, toRefs, 
watchEffect, onMounted, onBeforeUnmount } from 'vue'
+import { computed, defineComponent, nextTick, reactive, toRefs, watchEffect } 
from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 import useStore from '@/store/index'
-import TableMenu from '@/components/tables-sub-menu/TablesMenu.vue'
 import { getQueryString } from '@/utils'
 
 interface MenuItem {
@@ -32,9 +31,6 @@ interface MenuItem {
 
 export default defineComponent({
   name: 'Sidebar',
-  components: {
-    TableMenu,
-  },
   setup() {
     const { t } = useI18n()
     const router = useRouter()
@@ -48,7 +44,6 @@ export default defineComponent({
     const hasToken = computed(() => {
       return !!(getQueryString('token') || '')
     })
-    const timer = ref(0)
     const menuList = computed(() => {
       const menu: MenuItem[] = [
         {
@@ -111,74 +106,28 @@ export default defineComponent({
     }
 
     const navClick = (item: MenuItem) => {
-      if (item.key === 'tables') {
-        nextTick(() => {
-          setCurMenu()
-        })
-        return
-      }
+      const targetPath = item.key === 'tables' ? '/tables' : `/${item.key}`
       router.replace({
-        path: `/${item.key}`,
+        path: targetPath,
       })
       nextTick(() => {
         setCurMenu()
       })
     }
 
-    const mouseenter = (item: MenuItem) => {
-      toggleTablesMenu(item.key === 'tables')
-    }
-
-    const goCreatePage = () => {
-      toggleTablesMenu(false)
-      router.push({
-        path: '/tables/create',
-      })
-    }
-
-    function toggleTablesMenu(flag = false) {
-      if (hasToken.value) {
-        return
-      }
-      timer.value && clearTimeout(timer.value)
-      const time = flag ? 0 : 200
-      timer.value = setTimeout(() => {
-        store.updateTablesMenu(flag)
-      }, time)
-    }
-
     const viewOverview = () => {
       router.push({
         path: '/overview',
       })
     }
 
-    const tableMenusRef = ref(null)
-    function handleClickOutside(event: Event) {
-      if (tableMenusRef?.value && !(tableMenusRef.value as 
any).contains(event.target)) {
-        toggleTablesMenu(false)
-      }
-    }
-
-    onBeforeUnmount(() => {
-      document.removeEventListener('click', handleClickOutside)
-    })
-
-    onMounted(() => {
-      document.addEventListener('click', handleClickOutside)
-    })
-
     return {
       ...toRefs(state),
       hasToken,
       menuList,
       toggleCollapsed,
       navClick,
-      mouseenter,
       store,
-      toggleTablesMenu,
-      tableMenusRef,
-      goCreatePage,
       viewOverview,
     }
   },
@@ -187,7 +136,7 @@ export default defineComponent({
 
 <template>
   <div :class="{ 'side-bar-collapsed': collapsed }" class="side-bar">
-    <div :class="{ 'logo-collapsed': collapsed }" class="logo g-flex-ae" 
@mouseenter="toggleTablesMenu(false)" @click="viewOverview">
+    <div :class="{ 'logo-collapsed': collapsed }" class="logo g-flex-ae" 
@click="viewOverview">
       <img src="../assets/images/logo1.svg" class="logo-img" alt="">
       <img v-show="!collapsed" src="../assets/images/arctic-dashboard1.svg" 
class="arctic-name" alt="">
     </div>
@@ -197,7 +146,7 @@ export default defineComponent({
       theme="dark"
       :inline-collapsed="collapsed"
     >
-      <a-menu-item v-for="item in menuList" :key="item.key" :class="{ 
'active-color': (store.isShowTablesMenu && item.key === 'tables'), 
'table-item-tab': item.key === 'tables' }" @click="navClick(item)" 
@mouseenter="mouseenter(item)">
+      <a-menu-item v-for="item in menuList" :key="item.key" :class="{ 
'active-color': (store.isShowTablesMenu && item.key === 'tables'), 
'table-item-tab': item.key === 'tables' }" @click="navClick(item)">
         <template #icon>
           <svg-icon :icon-class="item.icon" class="svg-icon" />
         </template>
@@ -208,9 +157,6 @@ export default defineComponent({
       <MenuUnfoldOutlined v-if="collapsed" />
       <MenuFoldOutlined v-else />
     </a-button>
-    <div ref="tableMenusRef" v-if="store.isShowTablesMenu && !hasToken" 
:class="{ 'collapsed-sub-menu': collapsed }" class="tables-menu-wrap" 
@click.self="toggleTablesMenu(false)" @mouseenter="toggleTablesMenu(true)">
-      <TableMenu @go-create-page="goCreatePage" />
-    </div>
   </div>
 </template>
 
diff --git a/amoro-web/src/views/tables/components/TableExplorer.vue 
b/amoro-web/src/views/tables/components/TableExplorer.vue
new file mode 100755
index 000000000..0e36851cc
--- /dev/null
+++ b/amoro-web/src/views/tables/components/TableExplorer.vue
@@ -0,0 +1,639 @@
+<!--
+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.
+/ -->
+
+<script setup lang="ts">
+import { computed, onBeforeMount, reactive, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { getCatalogList, getDatabaseList, getTableList } from 
'@/services/table.service'
+import type { ICatalogItem } from '@/types/common.type'
+
+// Node types: Catalog / Database / Table
+type NodeType = 'catalog' | 'database' | 'table'
+
+interface TableItem {
+  name: string
+  type: string
+}
+
+interface TreeNode {
+  key: string
+  title: string
+  isLeaf?: boolean
+  children?: TreeNode[]
+  // Custom fields
+  nodeType: NodeType
+  catalog: string
+  db?: string
+  table?: string
+  tableType?: string
+}
+
+interface StorageValue {
+  catalog?: string
+  database?: string
+  tableName?: string
+  type?: string
+}
+
+const router = useRouter()
+const route = useRoute()
+
+const storageTableKey = 'easylake-menu-catalog-db-table'
+const storageCataDBTable = JSON.parse(localStorage.getItem(storageTableKey) || 
'{}') as StorageValue
+const expandedKeysSessionKey = 'tables_expanded_keys'
+
+const state = reactive({
+  loading: false,
+  searchKey: '',
+  filterKey: '',
+  treeData: [] as TreeNode[],
+  expandedKeys: [] as string[],
+  selectedKeys: [] as string[],
+  // Cache
+  catalogList: [] as string[],
+  dbListByCatalog: {} as Record<string, string[]>,
+  tablesByCatalogDb: {} as Record<string, TableItem[]>,
+})
+
+function buildCatalogNode(catalog: string): TreeNode {
+  return {
+    key: `catalog:${catalog}`,
+    title: catalog,
+    isLeaf: false,
+    nodeType: 'catalog',
+    catalog,
+  }
+}
+
+function buildDatabaseNode(catalog: string, db: string): TreeNode {
+  return {
+    key: `catalog:${catalog}/db:${db}`,
+    title: db,
+    isLeaf: false,
+    nodeType: 'database',
+    catalog,
+    db,
+  }
+}
+
+function buildTableNode(catalog: string, db: string, table: TableItem): 
TreeNode {
+  return {
+    key: `catalog:${catalog}/db:${db}/table:${table.name}`,
+    title: table.name,
+    isLeaf: true,
+    nodeType: 'table',
+    catalog,
+    db,
+    table: table.name,
+    tableType: table.type,
+  }
+}
+
+function updateTreeNodeChildren(targetKey: string, children: TreeNode[]) {
+  const loop = (nodes: TreeNode[]): boolean => {
+    for (const node of nodes) {
+      if (node.key === targetKey) {
+        node.children = children
+        return true
+      }
+      if (node.children && node.children.length && loop(node.children)) {
+        return true
+      }
+    }
+    return false
+  }
+
+  loop(state.treeData)
+}
+
+async function initRootCatalogs() {
+  state.loading = true
+  try {
+    const res = await getCatalogList()
+    const catalogs = (res || []).map((item: ICatalogItem) => item.catalogName)
+    state.catalogList = catalogs
+    state.treeData = catalogs.map(catalog => buildCatalogNode(catalog))
+  }
+  finally {
+    state.loading = false
+  }
+}
+
+async function loadChildren(node: any) {
+  const data = node?.dataRef || node
+  if (!data) {
+    return
+  }
+
+  const nodeType = data.nodeType as NodeType
+  if (nodeType === 'catalog') {
+    const catalog = data.catalog as string
+    if (!catalog || state.dbListByCatalog[catalog]) {
+      return
+    }
+
+    state.loading = true
+    try {
+      const res = await getDatabaseList({ catalog, keywords: '' })
+      const dbs = (res || []) as string[]
+      state.dbListByCatalog[catalog] = dbs
+      if (!dbs.length) {
+        data.isLeaf = true
+        updateTreeNodeChildren(data.key as string, [])
+        return
+      }
+      data.isLeaf = false
+      const children = dbs.map(db => buildDatabaseNode(catalog, db))
+      updateTreeNodeChildren(data.key as string, children)
+    }
+    finally {
+      state.loading = false
+    }
+  }
+  else if (nodeType === 'database') {
+    const catalog = data.catalog as string
+    const db = data.db as string
+    if (!catalog || !db) {
+      return
+    }
+    const cacheKey = `${catalog}/${db}`
+    if (state.tablesByCatalogDb[cacheKey]) {
+      return
+    }
+
+    state.loading = true
+    try {
+      const res = await getTableList({ catalog, db, keywords: '' })
+      const tables = (res || []) as TableItem[]
+      state.tablesByCatalogDb[cacheKey] = tables
+      if (!tables.length) {
+        data.isLeaf = true
+        updateTreeNodeChildren(data.key as string, [])
+        return
+      }
+      data.isLeaf = false
+      const children = tables.map(table => buildTableNode(catalog, db, table))
+      updateTreeNodeChildren(data.key as string, children)
+    }
+    finally {
+      state.loading = false
+    }
+  }
+}
+
+function handleSelectTable(catalog: string, db: string, tableName: string, 
tableType: string) {
+  if (!catalog || !db || !tableName) {
+    return
+  }
+
+  const type = tableType || 'MIXED_ICEBERG'
+
+  localStorage.setItem(storageTableKey, JSON.stringify({
+    catalog,
+    database: db,
+    tableName,
+  }))
+
+  const path = type === 'HIVE' ? '/hive-tables' : '/tables'
+  const pathQuery = {
+    path,
+    query: {
+      catalog,
+      db,
+      table: tableName,
+      type,
+    },
+  }
+
+  if (route.path.includes('tables')) {
+    router.replace(pathQuery)
+  }
+  else {
+    router.push(pathQuery)
+  }
+}
+
+function handleTreeSelect(selectedKeys: (string | number)[], info: any) {
+  const node = info?.node
+  if (!node) {
+    return
+  }
+
+  const dataRef = (node.dataRef || node) as TreeNode | undefined
+  if (!dataRef) {
+    return
+  }
+
+  if (dataRef.nodeType !== 'table') {
+    if (!dataRef.isLeaf) {
+      toggleNodeExpand(dataRef)
+    }
+    return
+  }
+
+  state.selectedKeys = selectedKeys.map(key => String(key))
+
+  const catalog = (dataRef.catalog || '') as string
+  const db = (dataRef.db || '') as string
+  const tableName = (dataRef.table || dataRef.title || '') as string
+  const tableType = (dataRef.tableType || '') as string
+
+  if (catalog && db && tableName) {
+    handleSelectTable(catalog, db, tableName, tableType)
+  }
+}
+
+async function handleTreeExpand(expandedKeys: (string | number)[], info: any) {
+  state.expandedKeys = expandedKeys.map(key => String(key))
+  try {
+    sessionStorage.setItem(expandedKeysSessionKey, 
JSON.stringify(state.expandedKeys))
+  }
+  catch (e) {
+    // ignore sessionStorage write errors
+  }
+  const node = info?.node
+  if (!node || node.isLeaf || !info.expanded) {
+    return
+  }
+  await loadChildren(node)
+}
+
+async function toggleNodeExpand(dataRef: TreeNode) {
+  if (!dataRef || dataRef.isLeaf) {
+    return
+  }
+
+  const key = String(dataRef.key)
+  const hasExpanded = state.expandedKeys.includes(key)
+
+  let nextExpandedKeys: string[]
+  if (hasExpanded) {
+    nextExpandedKeys = state.expandedKeys.filter(item => item !== key)
+  }
+  else {
+    nextExpandedKeys = [...state.expandedKeys, key]
+    await loadChildren({ dataRef })
+  }
+
+  state.expandedKeys = nextExpandedKeys
+  try {
+    sessionStorage.setItem(expandedKeysSessionKey, 
JSON.stringify(state.expandedKeys))
+  }
+  catch (e) {
+    // ignore sessionStorage write errors
+  }
+}
+
+function getNodeIcon(node: TreeNode) {
+  if (node.nodeType === 'catalog') {
+    return 'catalogs'
+  }
+  if (node.nodeType === 'database') {
+    return 'database'
+  }
+  return 'tables'
+}
+
+function normalizeKeyword(raw: string) {
+  return raw.trim().toLowerCase()
+}
+
+function filterBySingleKeyword(nodes: TreeNode[], keyword: string, 
expandedSet: Set<string>): TreeNode[] {
+  const result: TreeNode[] = []
+
+  nodes.forEach((node) => {
+    const titleMatch = node.title.toLowerCase().includes(keyword)
+    let childrenMatches: TreeNode[] = []
+
+    if (node.children && node.children.length) {
+      childrenMatches = filterBySingleKeyword(node.children, keyword, 
expandedSet)
+    }
+
+    if (titleMatch || childrenMatches.length) {
+      const cloned: TreeNode = { ...node }
+      if (childrenMatches.length) {
+        cloned.children = childrenMatches
+        expandedSet.add(node.key)
+      }
+      result.push(cloned)
+    }
+  })
+
+  return result
+}
+
+function filterByHierarchical(nodes: TreeNode[], parts: string[], expandedSet: 
Set<string>): TreeNode[] {
+  const [catalogPart, dbPart, tablePart] = parts
+  const result: TreeNode[] = []
+
+  nodes.forEach((catalogNode) => {
+    if (catalogNode.nodeType !== 'catalog') {
+      return
+    }
+    if (!catalogNode.title.toLowerCase().includes(catalogPart)) {
+      return
+    }
+
+    const dbChildren = (catalogNode.children || []).filter(child => 
child.nodeType === 'database')
+    const matchedDbNodes: TreeNode[] = []
+
+    dbChildren.forEach((dbNode) => {
+      if (dbPart && !dbNode.title.toLowerCase().includes(dbPart)) {
+        return
+      }
+
+      if (!tablePart) {
+        const clonedDb: TreeNode = { ...dbNode }
+        clonedDb.children = dbNode.children
+        matchedDbNodes.push(clonedDb)
+        expandedSet.add(catalogNode.key)
+        expandedSet.add(dbNode.key)
+        return
+      }
+
+      const tableChildren = (dbNode.children || []).filter(child => 
child.title.toLowerCase().includes(tablePart))
+      if (tableChildren.length) {
+        const clonedDb: TreeNode = { ...dbNode, children: tableChildren }
+        matchedDbNodes.push(clonedDb)
+        expandedSet.add(catalogNode.key)
+        expandedSet.add(dbNode.key)
+      }
+    })
+
+    if (matchedDbNodes.length) {
+      const clonedCatalog: TreeNode = { ...catalogNode, children: 
matchedDbNodes }
+      result.push(clonedCatalog)
+    }
+  })
+
+  return result
+}
+
+function filterTree(source: TreeNode[], rawKeyword: string): { tree: 
TreeNode[], expandedKeys: string[] } {
+  const keyword = normalizeKeyword(rawKeyword)
+  if (!keyword) {
+    return {
+      tree: source,
+      expandedKeys: state.expandedKeys,
+    }
+  }
+
+  const expandedSet = new Set<string>()
+  const parts = keyword.split('.').map(p => p.trim()).filter(Boolean)
+
+  let filteredTree: TreeNode[] = []
+  if (parts.length > 1) {
+    filteredTree = filterByHierarchical(source, parts, expandedSet)
+  }
+  else {
+    filteredTree = filterBySingleKeyword(source, keyword, expandedSet)
+  }
+
+  return {
+    tree: filteredTree,
+    expandedKeys: Array.from(expandedSet),
+  }
+}
+
+let searchTimer: any = null
+
+watch(
+  () => state.searchKey,
+  (val) => {
+    if (searchTimer) {
+      clearTimeout(searchTimer)
+    }
+    searchTimer = setTimeout(() => {
+      state.filterKey = val || ''
+    }, 500)
+  },
+)
+
+const searchResult = computed(() => {
+  const keyword = normalizeKeyword(state.filterKey)
+  if (!keyword) {
+    return {
+      tree: state.treeData,
+      expandedKeys: state.expandedKeys,
+    }
+  }
+  return filterTree(state.treeData, keyword)
+})
+
+const displayTreeData = computed(() => searchResult.value.tree)
+const displayExpandedKeys = computed(() => searchResult.value.expandedKeys)
+
+onBeforeMount(async () => {
+  await initRootCatalogs()
+
+  let restoredExpandedKeys: string[] = []
+
+  try {
+    const stored = sessionStorage.getItem(expandedKeysSessionKey)
+    if (stored) {
+      const parsed = JSON.parse(stored)
+      if (Array.isArray(parsed)) {
+        restoredExpandedKeys = parsed.map((key: string | number) => 
String(key))
+      }
+    }
+  }
+  catch (e) {
+    // ignore sessionStorage read/parse errors
+  }
+
+  if (restoredExpandedKeys.length) {
+    const catalogKeys = restoredExpandedKeys.filter(key => 
key.startsWith('catalog:') && !key.includes('/db:'))
+    for (const catalogKey of catalogKeys) {
+      const catalogNode = state.treeData.find(node => node.key === catalogKey)
+      if (catalogNode) {
+        await loadChildren({ dataRef: catalogNode })
+      }
+    }
+
+    const dbKeys = restoredExpandedKeys.filter(key => key.includes('/db:') && 
!key.includes('/table:'))
+    for (const dbKey of dbKeys) {
+      const [catalogPart] = dbKey.split('/db:')
+      const catalogName = catalogPart.replace('catalog:', '')
+      const catalogNode = state.treeData.find(node => node.key === 
`catalog:${catalogName}`)
+      if (catalogNode && catalogNode.children && catalogNode.children.length) {
+        const dbNode = catalogNode.children.find((child: TreeNode) => 
child.key === dbKey)
+        if (dbNode) {
+          await loadChildren({ dataRef: dbNode })
+        }
+      }
+    }
+
+    state.expandedKeys = restoredExpandedKeys
+
+    // Select last visited table from route or local storage without 
auto-expanding tree
+    const query = route.query || {}
+    const queryCatalog = (query.catalog as string) || 
storageCataDBTable.catalog
+    const queryDb = (query.db as string) || storageCataDBTable.database
+    const queryTable = (query.table as string) || storageCataDBTable.tableName
+
+    if (queryCatalog && queryDb && queryTable) {
+      const tableKey = 
`catalog:${queryCatalog}/db:${queryDb}/table:${queryTable}`
+      state.selectedKeys = [tableKey]
+    }
+  }
+})
+</script>
+
+<template>
+  <div class="table-explorer">
+    <div class="table-explorer-header">
+      <a-input
+        v-model:value="state.searchKey"
+        placeholder="Search catalog.database.table"
+        allow-clear
+        class="search-input"
+      >
+        <template #prefix>
+          <SearchOutlined class="search-input-prefix-icon" />
+        </template>
+      </a-input>
+    </div>
+
+    <div class="table-explorer-body">
+      <a-tree
+        v-if="displayTreeData.length"
+        :tree-data="displayTreeData"
+        :expanded-keys="displayExpandedKeys"
+        :selected-keys="state.selectedKeys"
+        :show-line="{ showLeafIcon: false }"
+        :show-icon="false"
+        :auto-expand-parent="false"
+        block-node
+        @expand="handleTreeExpand"
+        @select="handleTreeSelect"
+      >
+        <template #title="{ dataRef }">
+          <span
+            class="tree-node-title"
+            :class="`node-${dataRef.nodeType}`"
+          >
+            <svg-icon
+              :icon-class="getNodeIcon(dataRef)"
+              class="tree-node-icon"
+            />
+            <span class="tree-node-text">
+              {{ dataRef.title }}
+            </span>
+          </span>
+        </template>
+      </a-tree>
+      <div v-else class="empty-placeholder">
+        <span>No results..</span>
+      </div>
+    </div>
+
+  </div>
+</template>
+
+<style lang="less" scoped>
+.table-explorer {
+  position: relative;
+  box-sizing: border-box;
+  width: 100%;
+  height: 100%;
+  padding: 0 0;
+  background-color: #fff;
+  font-size: 13px;
+  display: flex;
+  flex-direction: column;
+
+  .table-explorer-header {
+    padding: 0 12px;
+    margin-bottom: 8px;
+
+    .search-input {
+      width: 100%;
+
+      :deep(.ant-input-affix-wrapper) {
+        height: 24px;
+      }
+
+      :deep(.ant-input-prefix) {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        width: 24px;
+      }
+
+      :deep(.search-input-prefix-icon) {
+        font-size: 16px;
+        line-height: 24px;
+      }
+
+      :deep(.ant-input) {
+        line-height: 24px;
+        font-size: 14px;
+
+        &::placeholder {
+          font-size: 14px;
+          color: #ccc;
+        }
+      }
+    }
+  }
+
+  .table-explorer-body {
+    flex: 1;
+    min-height: 0;
+    overflow: auto;
+    padding: 0 12px;
+
+    :deep(.ant-tree) {
+      background-color: #fff;
+    }
+
+    :deep(.ant-tree-indent-unit) {
+      width: 24px;
+    }
+
+    :deep(.ant-tree-switcher-line-icon) {
+      color: #d9d9e3;
+      font-size: 12px;
+    }
+
+    .tree-node-title {
+      display: inline-flex;
+      align-items: center;
+
+      .tree-node-icon {
+        margin-right: 6px;
+      }
+    }
+
+    .empty-placeholder {
+      padding: 8px 4px;
+      color: #999;
+    }
+  }
+}
+
+.table-explorer-body .tree-node-title.node-catalog .tree-node-icon,
+.table-explorer-body .tree-node-title.node-database .tree-node-icon { 
transform: translateY(1px); }
+.table-explorer-body .tree-node-title.node-table .tree-node-icon { transform: 
none; }
+
+/* Shift database and table rows left by 2px: indent + switcher + content 
together */
+:deep(.ant-tree-treenode:has(.tree-node-title.node-database)),
+:deep(.ant-tree-treenode:has(.tree-node-title.node-table)) {
+  transform: translateX(-1px); // Adjust to -3 or -4 for a larger shift if 
needed
+}
+</style>
diff --git a/amoro-web/src/views/tables/index.vue 
b/amoro-web/src/views/tables/index.vue
index 8634a9cef..d8e5ffe2c 100644
--- a/amoro-web/src/views/tables/index.vue
+++ b/amoro-web/src/views/tables/index.vue
@@ -17,7 +17,7 @@ limitations under the License.
 / -->
 
 <script lang="ts">
-import { computed, defineComponent, nextTick, onMounted, reactive, ref, 
shallowReactive, toRefs, watch } from 'vue'
+import { computed, defineComponent, nextTick, onBeforeUnmount, onMounted, 
reactive, ref, shallowReactive, toRefs, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import UDetails from './components/Details.vue'
 import UFiles from './components/Files.vue'
@@ -25,6 +25,7 @@ import UOperations from './components/Operations.vue'
 import USnapshots from './components/Snapshots.vue'
 import UOptimizing from './components/Optimizing.vue'
 import UHealthScore from './components/HealthScoreDetails.vue'
+import TableExplorer from './components/TableExplorer.vue'
 import useStore from '@/store/index'
 import type { IBaseDetailInfo } from '@/types/common.type'
 
@@ -37,6 +38,7 @@ export default defineComponent({
     USnapshots,
     UOptimizing,
     UHealthScore,
+    TableExplorer,
   },
   setup() {
     const router = useRouter()
@@ -45,6 +47,60 @@ export default defineComponent({
 
     const detailRef = ref()
 
+    const SIDEBAR_WIDTH_STORAGE_KEY = 'tables_sidebar_width'
+    const SIDEBAR_MIN_WIDTH = 320
+    const SIDEBAR_MAX_WIDTH = 800
+    const sidebarWidth = ref(512)
+
+    let isResizing = false
+    let startX = 0
+    let startWidth = sidebarWidth.value
+
+    const clampSidebarWidth = (width: number) => {
+      if (width < SIDEBAR_MIN_WIDTH) {
+        return SIDEBAR_MIN_WIDTH
+      }
+      if (width > SIDEBAR_MAX_WIDTH) {
+        return SIDEBAR_MAX_WIDTH
+      }
+      return width
+    }
+
+    const initSidebarWidth = () => {
+      const stored = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)
+      const parsed = stored ? Number.parseInt(stored, 10) : Number.NaN
+      const base = Number.isFinite(parsed) ? clampSidebarWidth(parsed) : 512
+      sidebarWidth.value = base
+      startWidth = base
+    }
+
+    const onSidebarResize = (event: MouseEvent) => {
+      if (!isResizing) {
+        return
+      }
+      const deltaX = event.clientX - startX
+      const nextWidth = clampSidebarWidth(startWidth + deltaX)
+      sidebarWidth.value = nextWidth
+    }
+
+    const stopSidebarResize = () => {
+      if (!isResizing) {
+        return
+      }
+      isResizing = false
+      document.removeEventListener('mousemove', onSidebarResize)
+      document.removeEventListener('mouseup', stopSidebarResize)
+      localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, 
String(sidebarWidth.value))
+    }
+
+    const startSidebarResize = (event: MouseEvent) => {
+      isResizing = true
+      startX = event.clientX
+      startWidth = sidebarWidth.value
+      document.addEventListener('mousemove', onSidebarResize)
+      document.addEventListener('mouseup', stopSidebarResize)
+    }
+
     const tabConfigs = shallowReactive([
       { key: 'Snapshots', label: 'snapshots' },
       { key: 'Optimizing', label: 'optimizing' },
@@ -66,7 +122,7 @@ export default defineComponent({
         smallFileScore: 0,
         equalityDeleteScore: 0,
         positionalDeleteScore: 0,
-        comment: ''
+        comment: '',
       } as IBaseDetailInfo,
       detailLoaded: false,
     })
@@ -75,7 +131,9 @@ export default defineComponent({
       return state.baseInfo.tableType === 'ICEBERG'
     })
 
-    const setBaseDetailInfo = (baseInfo: IBaseDetailInfo  & { comment?: string 
}) => {
+    const hasSelectedTable = computed(() => !!(route.query?.catalog && 
route.query?.db && route.query?.table))
+
+    const setBaseDetailInfo = (baseInfo: IBaseDetailInfo & { comment?: string 
}) => {
       state.detailLoaded = true
       state.baseInfo = { ...baseInfo }
     }
@@ -86,10 +144,6 @@ export default defineComponent({
       router.replace({ query: { ...query } })
     }
 
-    const hideTablesMenu = () => {
-      store.updateTablesMenu(false)
-    }
-
     const goBack = () => {
       state.isSecondaryNav = false
       router.back()
@@ -116,25 +170,42 @@ export default defineComponent({
       },
     )
 
+    watch(
+      hasSelectedTable,
+      (value, oldVal) => {
+        if (value && !oldVal && detailRef.value) {
+          detailRef.value.getTableDetails()
+        }
+      },
+    )
+
     onMounted(() => {
+      initSidebarWidth()
       state.activeKey = (route.query?.tab as string) || 'Details'
       nextTick(() => {
-        if (detailRef.value) {
+        if (detailRef.value && hasSelectedTable.value) {
           detailRef.value.getTableDetails()
         }
       })
     })
 
+    onBeforeUnmount(() => {
+      document.removeEventListener('mousemove', onSidebarResize)
+      document.removeEventListener('mouseup', stopSidebarResize)
+    })
+
     return {
       ...toRefs(state),
       detailRef,
       tabConfigs,
       store,
       isIceberg,
+      hasSelectedTable,
       setBaseDetailInfo,
-      hideTablesMenu,
       goBack,
       onChangeTab,
+      sidebarWidth,
+      startSidebarResize,
     }
   },
 })
@@ -143,44 +214,56 @@ export default defineComponent({
 <template>
   <div class="tables-wrap">
     <div v-if="!isSecondaryNav" class="tables-content">
-      <div class="g-flex-jsb">
-        <div class="g-flex-col">
-          <div class="g-flex">
-            <span :title="baseInfo.tableName" class="table-name 
g-text-nowrap">{{ baseInfo.tableName }}</span>
-          </div>
-          <div v-if="baseInfo.comment" class="table-info g-flex-ac">
-            <p>{{ $t('Comment') }}: <span class="text-color">{{ 
baseInfo.comment }}</span></p>
+      <div
+        class="tables-sidebar"
+        :style="{ width: `${sidebarWidth}px`, flex: `0 0 ${sidebarWidth}px` }"
+      >
+        <TableExplorer />
+      </div>
+      <div class="tables-divider" aria-hidden="true" 
@mousedown="startSidebarResize" />
+      <div class="tables-main">
+        <template v-if="hasSelectedTable">
+          <div class="tables-main-header g-flex-jsb">
+            <div class="g-flex-col">
+              <div class="g-flex">
+                <span :title="baseInfo.tableName" class="table-name 
g-text-nowrap">{{ baseInfo.tableName }}</span>
+              </div>
+              <div v-if="baseInfo.comment" class="table-info g-flex-ac">
+                <p>{{ $t('Comment') }}: <span class="text-color">{{ 
baseInfo.comment }}</span></p>
+              </div>
+              <div class="table-info g-flex-ac">
+                <p>{{ $t('optimizingStatus') }}: <span class="text-color">{{ 
baseInfo.optimizingStatus }}</span></p>
+                <a-divider type="vertical" />
+                <p>{{ $t('records') }}: <span class="text-color">{{ 
baseInfo.records }}</span></p>
+                <a-divider type="vertical" />
+                <template v-if="!isIceberg">
+                  <p>{{ $t('createTime') }}: <span class="text-color">{{ 
baseInfo.createTime }}</span></p>
+                  <a-divider type="vertical" />
+                </template>
+                <p>{{ $t('tableFormat') }}: <span class="text-color">{{ 
baseInfo.tableFormat }}</span></p>
+                <a-divider type="vertical" />
+                <p>
+                  {{ $t('healthScore') }}:
+                  <UHealthScore :base-info="baseInfo" />
+                </p>
+              </div>
+            </div>
           </div>
-          <div class="table-info g-flex-ac">
-            <p>{{ $t('optimizingStatus') }}: <span class="text-color">{{ 
baseInfo.optimizingStatus }}</span></p>
-            <a-divider type="vertical" />
-            <p>{{ $t('records') }}: <span class="text-color">{{ 
baseInfo.records }}</span></p>
-            <a-divider type="vertical" />
-            <template v-if="!isIceberg">
-              <p>{{ $t('createTime') }}: <span class="text-color">{{ 
baseInfo.createTime }}</span></p>
-              <a-divider type="vertical" />
-            </template>
-            <p>{{ $t('tableFormat') }}: <span class="text-color">{{ 
baseInfo.tableFormat }}</span></p>
-            <a-divider type="vertical" />
-            <p>
-              {{ $t('healthScore') }}:
-              <UHealthScore :base-info="baseInfo" />
-            </p>
+          <div class="tables-main-body">
+            <a-tabs v-model:activeKey="activeKey" destroy-inactive-tab-pane 
@change="onChangeTab">
+              <a-tab-pane key="Details" :tab="$t('details')" force-render>
+                <UDetails ref="detailRef" 
@set-base-detail-info="setBaseDetailInfo" />
+              </a-tab-pane>
+              <a-tab-pane v-if="detailLoaded" key="Files" :tab="$t('files')">
+                <UFiles :has-partition="baseInfo.hasPartition" />
+              </a-tab-pane>
+              <a-tab-pane v-for="tab in tabConfigs" :key="tab.key" 
:tab="$t(tab.label)">
+                <component :is="`U${tab.key}`" />
+              </a-tab-pane>
+            </a-tabs>
           </div>
-        </div>
-      </div>
-      <div class="content">
-        <a-tabs v-model:activeKey="activeKey" destroy-inactive-tab-pane 
@change="onChangeTab">
-          <a-tab-pane key="Details" :tab="$t('details')" force-render>
-            <UDetails ref="detailRef" 
@set-base-detail-info="setBaseDetailInfo" />
-          </a-tab-pane>
-          <a-tab-pane v-if="detailLoaded" key="Files" :tab="$t('files')">
-            <UFiles :has-partition="baseInfo.hasPartition" />
-          </a-tab-pane>
-          <a-tab-pane v-for="tab in tabConfigs" :key="tab.key" 
:tab="$t(tab.label)">
-            <component :is="`U${tab.key}`" />
-          </a-tab-pane>
-        </a-tabs>
+        </template>
+        <div v-else class="empty-page" />
       </div>
     </div>
     <!-- Create table secondary page -->
@@ -193,10 +276,59 @@ export default defineComponent({
   font-size: 14px;
   border: 1px solid #e8e8f0;
   padding: 12px 0;
+  height: 100%;
   min-height: 100%;
 
-  .create-time {
-    margin-top: 12px;
+  .tables-content {
+    display: flex;
+    height: 100%;
+    align-items: stretch;
+  }
+
+  .tables-sidebar {
+    flex: 0 0 auto;
+    height: 100%;
+    background-color: #fff;
+    position: relative;
+  }
+
+  .tables-main {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    min-width: 0;
+    height: 100%;
+  }
+
+  .tables-divider {
+    position: relative;
+    flex: 0 0 8px;
+    width: 8px;
+    height: 100%;
+    cursor: col-resize;
+    z-index: 2; // Ensure divider is above sidebar and main content so drag 
area is not blocked
+  }
+
+  .tables-divider::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 50%;
+    width: 1px;
+    transform: translateX(-50%);
+    background: #e8e8f0;
+  }
+
+  .tables-main-body {
+    flex: 1;
+    min-height: 0;
+    padding: 0 24px 24px;
+    overflow: auto;
+  }
+
+  .tables-main .empty-page {
+    height: 100%;
   }
 
   .tables-menu-wrap {


Reply via email to