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

jshao 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 cfc55ae053 [#7477] feat(web): Implement FilesetView component for file 
browser interface (#7846)
cfc55ae053 is described below

commit cfc55ae0536e4cdfd36033ac1c68077d053ff347
Author: Kyle Lin <[email protected]>
AuthorDate: Thu Aug 14 11:38:16 2025 +0800

    [#7477] feat(web): Implement FilesetView component for file browser 
interface (#7846)
    
    ### What changes were proposed in this pull request?
    
    Implements the `FilesetView.js` component to provide a file browser
    interface for viewing and navigating fileset in the Gravitino Web UI.
    
    ### Why are the changes needed?
    
    - Fixes #7477
    - Parent: #6860
    
    ### Does this PR introduce any user-facing change?
    
    add the UI to view files and directories within filesets.
    
    <img width="1012" height="532" alt="7477-1"
    
src="https://github.com/user-attachments/assets/48b21c89-861a-45ca-9f18-38ece4fb3243";
    />
    
    User can use navigation for easy path traversal.
    
    <img width="1013" height="531" alt="7477-2"
    
src="https://github.com/user-attachments/assets/cae899c1-181b-4fd2-aaab-3270f2d31221";
    />
    
    ### How was this patch tested?
    
    - `./gradlew build`
    - User test
---
 .../integration/test/web/ui/CatalogsPageTest.java  |   4 +-
 .../test/web/ui/pages/CatalogsPage.java            |  16 ++
 .../rightContent/tabsContent/TabsContent.js        |  15 +-
 .../tabsContent/filesetView/FilesetView.js         | 200 +++++++++++++++++++++
 web/web/src/lib/api/filesets/index.js              |  16 ++
 web/web/src/lib/store/metalakes/index.js           |  37 ++++
 6 files changed, 286 insertions(+), 2 deletions(-)

diff --git 
a/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/CatalogsPageTest.java
 
b/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/CatalogsPageTest.java
index bf9a996c49..9f7e4df89c 100644
--- 
a/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/CatalogsPageTest.java
+++ 
b/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/CatalogsPageTest.java
@@ -667,7 +667,9 @@ public class CatalogsPageTest extends BaseWebIT {
             SCHEMA_NAME_FILESET,
             FILESET_NAME);
     catalogsPage.clickTreeNode(filesetNode);
-    // 5. verify show tab details
+    // 5. verify Files tab is shown by default, then switch to Details and 
verify details content
+    Assertions.assertTrue(catalogsPage.verifyShowFilesContent());
+    clickAndWait(catalogsPage.tabDetailsBtn);
     Assertions.assertTrue(catalogsPage.verifyShowDetailsContent());
     Assertions.assertTrue(
         catalogsPage.verifyShowPropertiesItemInList(
diff --git 
a/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/pages/CatalogsPage.java
 
b/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/pages/CatalogsPage.java
index 0c004f4a6a..5ac01e1e7a 100644
--- 
a/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/pages/CatalogsPage.java
+++ 
b/web/integration-test/src/test/java/org/apache/gravitino/integration/test/web/ui/pages/CatalogsPage.java
@@ -134,6 +134,12 @@ public class CatalogsPage extends BaseWebIT {
   @FindBy(xpath = "//div[@data-refer='tab-details-panel']")
   public WebElement tabDetailsContent;
 
+  @FindBy(xpath = "//button[@data-refer='tab-files']")
+  public WebElement tabFilesBtn;
+
+  @FindBy(xpath = "//div[@data-refer='tab-files-panel']")
+  public WebElement tabFilesContent;
+
   @FindBy(xpath = "//div[@data-refer='details-drawer']")
   public WebElement detailsDrawer;
 
@@ -574,6 +580,16 @@ public class CatalogsPage extends BaseWebIT {
     }
   }
 
+  public boolean verifyShowFilesContent() {
+    try {
+      String files = tabFilesContent.getAttribute("hidden");
+      return Objects.equals(files, null);
+    } catch (Exception e) {
+      LOG.error(e.getMessage(), e);
+      return false;
+    }
+  }
+
   public boolean verifyShowCatalogDetails(String name, String 
hiveMetastoreUris)
       throws InterruptedException {
     try {
diff --git 
a/web/web/src/app/metalakes/metalake/rightContent/tabsContent/TabsContent.js 
b/web/web/src/app/metalakes/metalake/rightContent/tabsContent/TabsContent.js
index 55f2db690f..36f478aeda 100644
--- a/web/web/src/app/metalakes/metalake/rightContent/tabsContent/TabsContent.js
+++ b/web/web/src/app/metalakes/metalake/rightContent/tabsContent/TabsContent.js
@@ -34,6 +34,7 @@ import { useAppSelector } from '@/lib/hooks/useStore'
 import { useSearchParams } from 'next/navigation'
 import TableView from './tableView/TableView'
 import DetailsView from './detailsView/DetailsView'
+import FilesetView from './filesetView/FilesetView'
 
 import Icon from '@/components/Icon'
 
@@ -89,6 +90,7 @@ const TabsContent = () => {
   const isNotNeedTableTab =
     (type && ['fileset', 'messaging'].includes(type) && paramsSize === 5) ||
     (paramsSize === 6 && searchParams.get('version'))
+  const isFilesetFilesView = type === 'fileset' && paramsSize === 5
   const isShowTableProps = paramsSize === 5 && !['fileset', 
'messaging'].includes(type)
 
   const handleChangeTab = (event, newValue) => {
@@ -133,7 +135,9 @@ const TabsContent = () => {
   }
 
   useEffect(() => {
-    if (isNotNeedTableTab) {
+    if (isFilesetFilesView) {
+      setTab('files')
+    } else if (isNotNeedTableTab) {
       setTab('details')
     } else {
       setTab('table')
@@ -159,6 +163,9 @@ const TabsContent = () => {
           {!isNotNeedTableTab ? (
             <CustomTab icon='mdi:list-box-outline' label={tableTitle} 
value='table' data-refer='tab-table' />
           ) : null}
+          {isFilesetFilesView && (
+            <CustomTab icon='mdi:folder-multiple' label='Files' value='files' 
data-refer='tab-files' />
+          )}
           <CustomTab icon='mdi:clipboard-text-outline' label='Details' 
value='details' data-refer='tab-details' />
         </TabList>
         {isShowTableProps && (
@@ -309,6 +316,12 @@ const TabsContent = () => {
         </CustomTabPanel>
       ) : null}
 
+      {isFilesetFilesView && (
+        <CustomTabPanel value='files' data-refer='tab-files-panel'>
+          <FilesetView />
+        </CustomTabPanel>
+      )}
+
       <CustomTabPanel value='details' data-refer='tab-details-panel'>
         <DetailsView />
       </CustomTabPanel>
diff --git 
a/web/web/src/app/metalakes/metalake/rightContent/tabsContent/filesetView/FilesetView.js
 
b/web/web/src/app/metalakes/metalake/rightContent/tabsContent/filesetView/FilesetView.js
new file mode 100644
index 0000000000..a16dc1f272
--- /dev/null
+++ 
b/web/web/src/app/metalakes/metalake/rightContent/tabsContent/filesetView/FilesetView.js
@@ -0,0 +1,200 @@
+/*
+ * 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 { useState, useEffect } from 'react'
+import { useSearchParams } from 'next/navigation'
+import { useAppDispatch, useAppSelector } from '@/lib/hooks/useStore'
+import { getFilesetFiles } from '@/lib/store/metalakes'
+
+import {
+  Box,
+  Typography,
+  Breadcrumbs,
+  Link,
+  Paper,
+  Table,
+  TableBody,
+  TableCell,
+  TableContainer,
+  TableHead,
+  TableRow,
+  IconButton,
+  Tooltip,
+  Chip
+} from '@mui/material'
+import Icon from '@/components/Icon'
+
+const FilesetView = () => {
+  const dispatch = useAppDispatch()
+  const searchParams = useSearchParams()
+  const store = useAppSelector(state => state.metalakes)
+
+  const metalake = searchParams.get('metalake')
+  const catalog = searchParams.get('catalog')
+  const schema = searchParams.get('schema')
+  const fileset = searchParams.get('fileset')
+
+  const [currentPath, setCurrentPath] = useState('/')
+  const [pathHistory, setPathHistory] = useState(['/'])
+
+  useEffect(() => {
+    if (metalake && catalog && schema && fileset) {
+      dispatch(
+        getFilesetFiles({
+          metalake,
+          catalog,
+          schema,
+          fileset,
+          subPath: currentPath
+        })
+      )
+    }
+  }, [dispatch, metalake, catalog, schema, fileset, currentPath])
+
+  const handlePathClick = path => {
+    setCurrentPath(path)
+    if (!pathHistory.includes(path)) {
+      setPathHistory([...pathHistory, path])
+    }
+  }
+
+  const handleFolderClick = file => {
+    if (file.isDir) {
+      const newPath = currentPath === '/' ? `/${file.name}` : 
`${currentPath}/${file.name}`
+      handlePathClick(newPath)
+    }
+  }
+
+  const handleBackClick = () => {
+    if (pathHistory.length > 1) {
+      const newHistory = pathHistory.slice(0, -1)
+      const newPath = newHistory[newHistory.length - 1]
+      setPathHistory(newHistory)
+      setCurrentPath(newPath)
+    }
+  }
+
+  const formatFileSize = size => {
+    if (size === 0) return '0 B'
+    const k = 1024
+    const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
+    const i = Math.floor(Math.log(size) / Math.log(k))
+
+    return parseFloat((size / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
+  }
+
+  const formatDate = timestamp => {
+    return new Date(timestamp).toLocaleString()
+  }
+
+  const renderBreadcrumbs = () => {
+    const pathParts = currentPath.split('/').filter(part => part !== '')
+    const breadcrumbItems = ['/']
+
+    pathParts.forEach((part, index) => {
+      const path = '/' + pathParts.slice(0, index + 1).join('/')
+      breadcrumbItems.push(path)
+    })
+
+    return (
+      <Breadcrumbs aria-label='file path'>
+        {breadcrumbItems.map((path, index) => (
+          <Link
+            key={path}
+            color='inherit'
+            href='#'
+            onClick={e => {
+              e.preventDefault()
+              handlePathClick(path)
+            }}
+            sx={{ cursor: 'pointer' }}
+          >
+            {index === 0 ? 'Root' : path.split('/').pop()}
+          </Link>
+        ))}
+      </Breadcrumbs>
+    )
+  }
+
+  return (
+    <Box sx={{ p: 3, height: '100%', overflow: 'auto' }}>
+      <Box sx={{ mb: 2, display: 'flex', gap: 1, alignItems: 'center' }}>
+        <Tooltip title='Go Back'>
+          <IconButton onClick={handleBackClick} disabled={pathHistory.length 
<= 1}>
+            <Icon icon='mdi:arrow-left' />
+          </IconButton>
+        </Tooltip>
+        {renderBreadcrumbs()}
+      </Box>
+
+      <TableContainer component={Paper}>
+        <Table>
+          <TableHead>
+            <TableRow>
+              <TableCell>Name</TableCell>
+              <TableCell>Type</TableCell>
+              <TableCell>Size</TableCell>
+              <TableCell>Last Modified</TableCell>
+            </TableRow>
+          </TableHead>
+          <TableBody>
+            {store.filesetFiles && store.filesetFiles.length > 0 ? (
+              store.filesetFiles.map((file, index) => (
+                <TableRow
+                  key={index}
+                  hover
+                  sx={{ cursor: file.isDir ? 'pointer' : 'default' }}
+                  onClick={() => handleFolderClick(file)}
+                >
+                  <TableCell>
+                    <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 
}}>
+                      <Icon icon={file.isDir ? 'mdi:folder' : 'mdi:file'} 
color='#666' />
+                      <Typography>{file.name}</Typography>
+                    </Box>
+                  </TableCell>
+                  <TableCell>
+                    <Chip
+                      label={file.isDir ? 'Directory' : 'File'}
+                      size='small'
+                      color={file.isDir ? 'primary' : 'default'}
+                    />
+                  </TableCell>
+                  <TableCell>{file.isDir ? '-' : formatFileSize(file.size || 
0)}</TableCell>
+                  <TableCell>{file.lastModified ? 
formatDate(file.lastModified) : '-'}</TableCell>
+                </TableRow>
+              ))
+            ) : (
+              <TableRow>
+                <TableCell colSpan={4} align='center'>
+                  <Typography variant='body2' color='text.secondary'>
+                    No files found in this directory
+                  </Typography>
+                </TableCell>
+              </TableRow>
+            )}
+          </TableBody>
+        </Table>
+      </TableContainer>
+    </Box>
+  )
+}
+
+export default FilesetView
diff --git a/web/web/src/lib/api/filesets/index.js 
b/web/web/src/lib/api/filesets/index.js
index bae492a11d..43039736b9 100644
--- a/web/web/src/lib/api/filesets/index.js
+++ b/web/web/src/lib/api/filesets/index.js
@@ -28,6 +28,16 @@ const Apis = {
     
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(
       catalog
     
)}/schemas/${encodeURIComponent(schema)}/filesets/${encodeURIComponent(fileset)}`,
+  LIST_FILES: ({ metalake, catalog, schema, fileset, subPath, locationName }) 
=> {
+    const params = new URLSearchParams()
+    if (subPath) params.append('sub_path', subPath)
+    if (locationName) params.append('location_name', locationName)
+    const queryString = params.toString()
+
+    return 
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(
+      catalog
+    
)}/schemas/${encodeURIComponent(schema)}/filesets/${encodeURIComponent(fileset)}/files${queryString
 ? `?${queryString}` : ''}`
+  },
   CREATE: ({ metalake, catalog, schema }) =>
     
`/api/metalakes/${encodeURIComponent(metalake)}/catalogs/${encodeURIComponent(catalog)}/schemas/${encodeURIComponent(schema)}/filesets`,
   UPDATE: ({ metalake, catalog, schema, fileset }) =>
@@ -48,6 +58,12 @@ export const getFilesetDetailsApi = ({ metalake, catalog, 
schema, fileset }) =>
   })
 }
 
+export const listFilesetFilesApi = ({ metalake, catalog, schema, fileset, 
subPath = '/', locationName }) => {
+  return defHttp.get({
+    url: `${Apis.LIST_FILES({ metalake, catalog, schema, fileset, subPath, 
locationName })}`
+  })
+}
+
 export const createFilesetApi = ({ metalake, catalog, schema, data }) => {
   return defHttp.post({ url: `${Apis.CREATE({ metalake, catalog, schema })}`, 
data })
 }
diff --git a/web/web/src/lib/store/metalakes/index.js 
b/web/web/src/lib/store/metalakes/index.js
index 2862ca96d5..a0c5cf3ddb 100644
--- a/web/web/src/lib/store/metalakes/index.js
+++ b/web/web/src/lib/store/metalakes/index.js
@@ -50,6 +50,7 @@ import { getTablesApi, getTableDetailsApi, createTableApi, 
updateTableApi, delet
 import {
   getFilesetsApi,
   getFilesetDetailsApi,
+  listFilesetFilesApi,
   createFilesetApi,
   updateFilesetApi,
   deleteFilesetApi
@@ -1047,6 +1048,32 @@ export const deleteFileset = createAsyncThunk(
   }
 )
 
+export const getFilesetFiles = createAsyncThunk(
+  'appMetalakes/getFilesetFiles',
+  async ({ metalake, catalog, schema, fileset, subPath = '/', locationName }, 
{ getState, dispatch }) => {
+    dispatch(setTableLoading(true))
+    const [err, res] = await to(listFilesetFilesApi({ metalake, catalog, 
schema, fileset, subPath, locationName }))
+    dispatch(setTableLoading(false))
+
+    if (err || !res) {
+      dispatch(resetTableData())
+      throw new Error(err)
+    }
+
+    const { files = [] } = res
+
+    dispatch(
+      setExpandedNodes([
+        `{{${metalake}}}`,
+        `{{${metalake}}}{{${catalog}}}{{${'fileset'}}}`,
+        `{{${metalake}}}{{${catalog}}}{{${'fileset'}}}{{${schema}}}`
+      ])
+    )
+
+    return { files, subPath, locationName }
+  }
+)
+
 export const fetchTopics = createAsyncThunk(
   'appMetalakes/fetchTopics',
   async ({ init, page, metalake, catalog, schema }, { getState, dispatch }) => 
{
@@ -1465,6 +1492,7 @@ export const appMetalakesSlice = createSlice({
     tables: [],
     columns: [],
     filesets: [],
+    filesetFiles: [],
     topics: [],
     models: [],
     versions: [],
@@ -1749,6 +1777,15 @@ export const appMetalakesSlice = createSlice({
         toast.error(action.error.message)
       }
     })
+    builder.addCase(getFilesetFiles.fulfilled, (state, action) => {
+      state.filesetFiles = action.payload.files
+      state.tableData = action.payload.files
+    })
+    builder.addCase(getFilesetFiles.rejected, (state, action) => {
+      if (!action.error.message.includes('CanceledError')) {
+        toast.error(action.error.message)
+      }
+    })
     builder.addCase(fetchTopics.fulfilled, (state, action) => {
       state.topics = action.payload.topics
       if (action.payload.init) {

Reply via email to