Skip to content

Commit 3cfc87f

Browse files
committed
feat(FR-1010): Implement folder explorer (#3933)
resolves #3682(FR-1010) <!-- replace NNN, MMM with the GitHub issue number and the corresponding Jira issue number. --> ### Implement basic file explorer component **Changes:** - Breadcrumb - navigate down by clicking folder name, navigate by clicking breadcrumb. - each breadcrumb exclude home directory has dropdown to show sibling folders. - > The function to store the current path as a query string is not yet implemented, I'll implement it as a separate stack. - Action - Create directory - You can create a new directory in the folder you are currently viewing - Upload file/folders - You can upload a file/folder in the folder you are currently viewing - > Overwrite warnings, upload progress display, and stop or restart function will be implement as a separate stack. - Delete files/folders - You can delete a single file/folder via the delete button in the Controls column, or multiple folders/files via checkboxes. - Download files/folders - You can download files/folders via the download button in the Controls column **How to test:** - You can test the existing explorer and behavior at the same time by turning the comments for backend-ai-folder-explorer and action items inside LegacyFolderExplorer into code. ![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/lSyr8xXz1wdXALkJKzVx/64c4430a-ded6-426f-84d2-1377b567de23.png) <!-- Please precisely, concisely, and concretely describe what this PR changes, the rationale behind codes, and how it affects the users and other developers. --> **Checklist:** (if applicable) - [ ] Documentation - [ ] Minium required manager version - [ ] Specific setting for review (eg., KB link, endpoint or how to setup) - [ ] Minimum requirements to check during review - [ ] Test case(s) to demonstrate the difference of before/after
1 parent e5eff0e commit 3cfc87f

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+1843
-67
lines changed

packages/backend.ai-ui/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,17 @@
7070
"@dnd-kit/modifiers": "^7.0.0",
7171
"@dnd-kit/sortable": "^8.0.0",
7272
"@dnd-kit/utilities": "^3.2.2",
73+
"@ant-design/icons": "^6.0.0",
7374
"ahooks": "^3.8.4",
7475
"big.js": "^7.0.1",
7576
"classnames": "^2.5.1",
7677
"dayjs": "^1.11.13",
7778
"lodash": "^4.17.21",
7879
"lucide-react": "^0.484.0",
7980
"react-resizable": "^3.0.5",
80-
"react-copy-to-clipboard": "^5.1.0"
81+
"react-copy-to-clipboard": "^5.1.0",
82+
"tus-js-client": "^4.1.0",
83+
"@tanstack/react-query": "^5.69.0"
8184
},
8285
"devDependencies": {
8386
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
import { BAIFileExplorerFragment$key } from '../../../__generated__/BAIFileExplorerFragment.graphql';
2+
import {
3+
convertToDecimalUnit,
4+
filterOutEmpty,
5+
localeCompare,
6+
} from '../../../helper';
7+
import BAIFlex from '../../BAIFlex';
8+
import BAIUnmountAfterClose from '../../BAIUnmountAfterClose';
9+
import { BAITable } from '../../Table';
10+
import useConnectedBAIClient from '../../provider/BAIClientProvider/hooks/useConnectedBAIClient';
11+
import { VFolderFile } from '../../provider/BAIClientProvider/types';
12+
import DeleteSelectedItemsModal from './DeleteSelectedItemsModal';
13+
import ExplorerActionControls from './ExplorerActionControls';
14+
import FileItemControls from './FileItemControls';
15+
import { useSearchVFolderFiles } from './hooks';
16+
import { FileOutlined, FolderOutlined, HomeOutlined } from '@ant-design/icons';
17+
import {
18+
Breadcrumb,
19+
Skeleton,
20+
TableColumnsType,
21+
theme,
22+
Typography,
23+
} from 'antd';
24+
import { createStyles } from 'antd-style';
25+
import { ItemType } from 'antd/es/breadcrumb/Breadcrumb';
26+
import dayjs from 'dayjs';
27+
import _ from 'lodash';
28+
import { createContext, Suspense, useEffect, useMemo, useState } from 'react';
29+
import { useTranslation } from 'react-i18next';
30+
import { graphql, useFragment } from 'react-relay';
31+
32+
const useStyles = createStyles(({ css, token }) => ({
33+
hover: css`
34+
text-decoration: none;
35+
/* color: ${token.colorLink}; */
36+
37+
&:hover {
38+
/* color: ${token.colorLinkHover}; */
39+
text-decoration: underline;
40+
}
41+
`,
42+
}));
43+
44+
export const FolderInfoContext = createContext<{
45+
targetVFolderId: string;
46+
currentPath: string;
47+
}>({
48+
targetVFolderId: '',
49+
currentPath: '.',
50+
});
51+
52+
export interface BAIFileExplorerProps {
53+
vfolderNodeFrgmt?: BAIFileExplorerFragment$key | null;
54+
targetVFolderId: string;
55+
}
56+
57+
const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
58+
vfolderNodeFrgmt,
59+
targetVFolderId,
60+
}) => {
61+
const { t } = useTranslation();
62+
const { token } = theme.useToken();
63+
const { styles } = useStyles();
64+
const [selectedItems, setSelectedItems] = useState<Array<VFolderFile>>([]);
65+
const [selectedSingleItem, setSelectedSingleItem] =
66+
useState<VFolderFile | null>(null);
67+
const baiClient = useConnectedBAIClient();
68+
const isDirectorySizeVisible = baiClient?._config?.isDirectorySizeVisible;
69+
70+
const {
71+
files,
72+
directoryTree,
73+
isFetching,
74+
currentPath,
75+
navigateDown,
76+
navigateToPath,
77+
refetch,
78+
} = useSearchVFolderFiles(targetVFolderId);
79+
80+
const [fetchedFilesCache, setFetchedFilesCache] = useState<
81+
Array<VFolderFile>
82+
>([]);
83+
84+
useEffect(() => {
85+
if (!_.isNil(files?.items)) {
86+
setFetchedFilesCache(files.items);
87+
}
88+
}, [files]);
89+
90+
const vFolderNode = useFragment(
91+
graphql`
92+
fragment BAIFileExplorerFragment on VirtualFolderNode {
93+
...FileItemControlsFragment
94+
}
95+
`,
96+
vfolderNodeFrgmt,
97+
);
98+
99+
const breadCrumbItems: Array<ItemType> = useMemo(() => {
100+
const pathParts = currentPath === '.' ? [] : currentPath.split('/');
101+
102+
const items: Array<ItemType> = [
103+
{
104+
title: <HomeOutlined />,
105+
href: '',
106+
onClick: () => {
107+
navigateToPath('.');
108+
setSelectedItems([]);
109+
},
110+
},
111+
];
112+
113+
_.forEach(pathParts, (part, index) => {
114+
const navigatePath = pathParts.slice(0, index + 1).join('/');
115+
const parentPath =
116+
index === 0 ? '.' : pathParts.slice(0, index).join('/');
117+
const parentFolders =
118+
directoryTree[parentPath]?.filter(
119+
(item) => item.type === 'DIRECTORY',
120+
) || [];
121+
122+
const menuItems = parentFolders.map((dir) => ({
123+
key: dir.name,
124+
label: (
125+
<BAIFlex
126+
align="center"
127+
gap="xxs"
128+
onClick={() => {
129+
const newPath =
130+
parentPath === '.' ? dir.name : `${parentPath}/${dir.name}`;
131+
navigateToPath(newPath);
132+
setSelectedItems([]);
133+
}}
134+
>
135+
<FolderOutlined />
136+
{dir.name}
137+
</BAIFlex>
138+
),
139+
}));
140+
141+
items.push({
142+
title: part,
143+
href: '',
144+
onClick: () => {
145+
navigateToPath(navigatePath);
146+
setSelectedItems([]);
147+
},
148+
menu: menuItems.length > 1 ? { items: menuItems } : undefined,
149+
});
150+
});
151+
152+
return items;
153+
// eslint-disable-next-line react-hooks/exhaustive-deps
154+
}, [currentPath, directoryTree]);
155+
156+
const tableColumns: TableColumnsType<VFolderFile> = filterOutEmpty([
157+
{
158+
title: t('comp:FileExplorer.Name'),
159+
dataIndex: 'name',
160+
sorter: (a, b) => localeCompare(a.name, b.name),
161+
fixed: 'left',
162+
render: (name, record) =>
163+
record?.type === 'DIRECTORY' ? (
164+
// FIXME: need to implement BAILink into backend.ai-ui and use it here
165+
<Typography.Link
166+
className={styles.hover}
167+
onClick={(e) => {
168+
e.stopPropagation();
169+
navigateDown(name);
170+
setSelectedItems([]);
171+
}}
172+
style={{ display: 'block', width: 'fit-content' }} // To prevent conflicts with the click event of onRow.
173+
>
174+
<BAIFlex gap="xs">
175+
<FolderOutlined />
176+
<Typography.Text
177+
ellipsis={{
178+
tooltip: name,
179+
}}
180+
style={{ maxWidth: 200, color: token.colorLink }}
181+
>
182+
{name}
183+
</Typography.Text>
184+
</BAIFlex>
185+
</Typography.Link>
186+
) : (
187+
<BAIFlex gap="xs">
188+
<FileOutlined />
189+
<Typography.Text
190+
ellipsis={{
191+
tooltip: name,
192+
}}
193+
style={{ maxWidth: 200 }}
194+
>
195+
{name}
196+
</Typography.Text>
197+
</BAIFlex>
198+
),
199+
},
200+
{
201+
title: t('comp:FileExplorer.Controls'),
202+
fixed: 'left',
203+
render: (_, record) => {
204+
return (
205+
<Suspense fallback={<Skeleton.Button size="small" active />}>
206+
<FileItemControls
207+
vfolderNodeFrgmt={vFolderNode}
208+
selectedItem={record}
209+
onClickDelete={() => {
210+
setSelectedSingleItem(record);
211+
}}
212+
/>
213+
</Suspense>
214+
);
215+
},
216+
},
217+
{
218+
title: t('comp:FileExplorer.Size'),
219+
dataIndex: 'size',
220+
sorter: (a, b) => localeCompare(a.type, b.type),
221+
render: (size, record) => {
222+
if (record.type === 'DIRECTORY' && !isDirectorySizeVisible) {
223+
return '-';
224+
}
225+
return size === 0
226+
? '-'
227+
: convertToDecimalUnit(size, 'auto')?.displayValue;
228+
},
229+
},
230+
{
231+
title: t('comp:FileExplorer.CreatedAt'),
232+
dataIndex: 'created',
233+
sorter: (a, b) => localeCompare(a.created, b.created),
234+
render: (createdAt) => dayjs(createdAt).format('lll'),
235+
},
236+
{
237+
title: t('comp:FileExplorer.ModifiedAt'),
238+
dataIndex: 'modified',
239+
sorter: (a, b) => localeCompare(a.modified, b.modified),
240+
render: (modifiedAt) => dayjs(modifiedAt).format('lll'),
241+
},
242+
]);
243+
244+
return (
245+
<FolderInfoContext.Provider value={{ targetVFolderId, currentPath }}>
246+
<BAIFlex direction="column" align="stretch" gap="md">
247+
<BAIFlex align="center" justify="between">
248+
<Breadcrumb items={breadCrumbItems} />
249+
<ExplorerActionControls
250+
selectedFiles={selectedItems}
251+
onRequestClose={(
252+
success: boolean,
253+
modifiedItems?: Array<VFolderFile>,
254+
) => {
255+
if (success) {
256+
modifiedItems &&
257+
setSelectedItems((prev) =>
258+
_.filter(
259+
prev,
260+
(item: VFolderFile) =>
261+
!_.includes(
262+
_.map(
263+
modifiedItems,
264+
(modifiedItem) => modifiedItem.name,
265+
),
266+
item.name,
267+
),
268+
),
269+
);
270+
refetch();
271+
}
272+
}}
273+
/>
274+
</BAIFlex>
275+
276+
<BAITable
277+
rowKey="name"
278+
bordered
279+
scroll={{ x: 'max-content' }}
280+
dataSource={fetchedFilesCache}
281+
columns={tableColumns}
282+
loading={files?.items !== fetchedFilesCache || isFetching}
283+
pagination={false}
284+
rowSelection={{
285+
type: 'checkbox',
286+
selectedRowKeys: _.map(selectedItems, 'name'),
287+
onChange: (selectedRowKeys) => {
288+
setSelectedItems(
289+
fetchedFilesCache?.filter((file) =>
290+
selectedRowKeys.includes(file.name),
291+
) || [],
292+
);
293+
},
294+
}}
295+
onRow={(record) => ({
296+
onClick: () => {
297+
const isSelected = selectedItems.some(
298+
(item) => item.name === record.name,
299+
);
300+
if (isSelected) {
301+
setSelectedItems(
302+
selectedItems.filter((item) => item.name !== record.name),
303+
);
304+
} else {
305+
setSelectedItems([...selectedItems, record]);
306+
}
307+
},
308+
})}
309+
/>
310+
</BAIFlex>
311+
<BAIUnmountAfterClose>
312+
<DeleteSelectedItemsModal
313+
open={!!selectedSingleItem}
314+
selectedFiles={selectedSingleItem ? [selectedSingleItem] : []}
315+
onRequestClose={(success: boolean) => {
316+
if (success) {
317+
setSelectedItems((prev) =>
318+
prev.filter((item) => item.name !== selectedSingleItem?.name),
319+
);
320+
refetch();
321+
}
322+
setSelectedSingleItem(null);
323+
}}
324+
/>
325+
</BAIUnmountAfterClose>
326+
</FolderInfoContext.Provider>
327+
);
328+
};
329+
330+
export default BAIFileExplorer;

0 commit comments

Comments
 (0)