From 0d60e18c6cc94702076618406b295a3ff010ec2c Mon Sep 17 00:00:00 2001 From: mikebender Date: Thu, 27 Jun 2024 17:31:46 -0400 Subject: [PATCH 1/5] WIP need to figure out why the tests are failing --- .../src/storage/grpc/GrpcFileStorage.ts | 13 +- .../storage/grpc/GrpcFileStorageTable.test.ts | 224 +++++++++++------- .../src/storage/grpc/GrpcFileStorageTable.ts | 15 +- .../src/storage/grpc/GrpcLayoutStorage.ts | 12 +- packages/code-studio/.env | 5 - packages/code-studio/src/main/AppInit.tsx | 11 +- packages/embed-widget/src/App.tsx | 4 +- 7 files changed, 184 insertions(+), 100 deletions(-) diff --git a/packages/app-utils/src/storage/grpc/GrpcFileStorage.ts b/packages/app-utils/src/storage/grpc/GrpcFileStorage.ts index 9eee39be2e..b8ad4f303d 100644 --- a/packages/app-utils/src/storage/grpc/GrpcFileStorage.ts +++ b/packages/app-utils/src/storage/grpc/GrpcFileStorage.ts @@ -25,6 +25,8 @@ export class GrpcFileStorage implements FileStorage { private readonly root: string; + private readonly separator: string; + /** * FileStorage implementation using gRPC * @param storageService Storage service to use @@ -33,11 +35,13 @@ export class GrpcFileStorage implements FileStorage { constructor( dh: typeof DhType, storageService: DhType.storage.StorageService, - root = '' + root = '', + separator = '/' ) { this.dh = dh; this.storageService = storageService; this.root = root; + this.separator = separator; } private removeRoot(filename: string): string { @@ -60,7 +64,12 @@ export class GrpcFileStorage implements FileStorage { } async getTable(): Promise { - const table = new GrpcFileStorageTable(this.storageService, this.root); + const table = new GrpcFileStorageTable( + this.storageService, + this.root, + this.root, + this.separator + ); this.tables.push(table); return table; } diff --git a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts index c7bc4e8b1d..318b144d3f 100644 --- a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts +++ b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts @@ -3,8 +3,12 @@ import type { dh } from '@deephaven/jsapi-types'; import GrpcFileStorageTable from './GrpcFileStorageTable'; let storageService: dh.storage.StorageService; -function makeTable(baseRoot = '', root = ''): GrpcFileStorageTable { - return new GrpcFileStorageTable(storageService, baseRoot, root); +function makeTable( + baseRoot = '', + root = '', + separator = '' +): GrpcFileStorageTable { + return new GrpcFileStorageTable(storageService, baseRoot, root, separator); } function makeStorageService(): dh.storage.StorageService { @@ -40,10 +44,14 @@ it('Does not get contents until a viewport is set', () => { }); describe('directory expansion tests', () => { - function makeFile(name: string, path = ''): dh.storage.ItemDetails { + function makeFile( + name: string, + path = '', + separator = '/' + ): dh.storage.ItemDetails { return { basename: name, - filename: `${path}/${name}`, + filename: `${path}${separator}${name}`, dirname: path, etag: '', size: 1, @@ -51,8 +59,12 @@ describe('directory expansion tests', () => { }; } - function makeDirectory(name: string, path = ''): dh.storage.ItemDetails { - const file = makeFile(name, path); + function makeDirectory( + name: string, + path = '', + separator = '/' + ): dh.storage.ItemDetails { + const file = makeFile(name, path, separator); file.type = 'directory'; return file; } @@ -60,18 +72,19 @@ describe('directory expansion tests', () => { function makeDirectoryContents( path = '/', numDirs = 3, - numFiles = 2 + numFiles = 2, + separator = '/' ): Array { const results = [] as dh.storage.ItemDetails[]; for (let i = 0; i < numDirs; i += 1) { const name = `dir${i}`; - results.push(makeDirectory(name, path)); + results.push(makeDirectory(name, path, separator)); } for (let i = 0; i < numFiles; i += 1) { const name = `file${i}`; - results.push(makeFile(name, path)); + results.push(makeFile(name, path, separator)); } return results; @@ -89,82 +102,127 @@ describe('directory expansion tests', () => { storageService.listItems = jest.fn(async path => { const depth = path.length > 0 ? FileUtils.getDepth(path) + 1 : 1; const dirContents = makeDirectoryContents(path, 5 - depth, 10 - depth); - // console.log('dirContents for ', path, dirContents); + console.log('dirContents for ', path, dirContents); return dirContents; }); }); - it('expands multiple directories correctly', async () => { - const table = makeTable(); - const handleUpdate = jest.fn(); - table.onUpdate(handleUpdate); - table.setViewport({ top: 0, bottom: 5 }); - - jest.runAllTimers(); - - await table.getViewportData(); - expect(handleUpdate).toHaveBeenCalledWith({ - offset: 0, - items: [ - expectItem(makeDirectory('dir0')), - expectItem(makeDirectory('dir1')), - expectItem(makeDirectory('dir2')), - expectItem(makeDirectory('dir3')), - expectItem(makeFile('file0')), - ], - }); - handleUpdate.mockReset(); - - table.setExpanded('/dir1/', true); - - jest.runAllTimers(); - - await table.getViewportData(); - expect(handleUpdate).toHaveBeenCalledWith({ - offset: 0, - items: [ - expectItem(makeDirectory('dir0')), - expectItem(makeDirectory('dir1')), - expectItem(makeDirectory('dir0', '/dir1')), - expectItem(makeDirectory('dir1', '/dir1')), - expectItem(makeDirectory('dir2', '/dir1')), - ], - }); - handleUpdate.mockReset(); - - table.setExpanded('/dir1/dir1/', true); - - jest.runAllTimers(); - - await table.getViewportData(); - expect(handleUpdate).toHaveBeenCalledWith({ - offset: 0, - items: expect.arrayContaining([ - expectItem(makeDirectory('dir0')), - expectItem(makeDirectory('dir1')), - expectItem(makeDirectory('dir0', '/dir1')), - expectItem(makeDirectory('dir1', '/dir1')), - expectItem(makeDirectory('dir0', '/dir1/dir1')), - ]), - }); - handleUpdate.mockReset(); - - // Now collapse it all - table.setExpanded('/dir1/', false); - - jest.runAllTimers(); - - await table.getViewportData(); - expect(handleUpdate).toHaveBeenCalledWith({ - offset: 0, - items: expect.arrayContaining([ - expectItem(makeDirectory('dir0')), - expectItem(makeDirectory('dir1')), - expectItem(makeDirectory('dir2')), - expectItem(makeDirectory('dir3')), - expectItem(makeFile('file0')), - ]), - }); - handleUpdate.mockReset(); - }); + it.each(['/'])( + `expands multiple directories correctly with '%s' separator`, + async separator => { + console.log('Creating a new table'); + const table = makeTable(); + const handleUpdate = jest.fn(); + table.onUpdate(handleUpdate); + table.setViewport({ top: 0, bottom: 5 }); + + jest.runAllTimers(); + + await table.getViewportData(); + expect(handleUpdate).toHaveBeenCalledWith({ + offset: 0, + items: [ + expectItem(makeDirectory('dir0', '', separator)), + expectItem(makeDirectory('dir1', '', separator)), + expectItem(makeDirectory('dir2', '', separator)), + expectItem(makeDirectory('dir3', '', separator)), + expectItem(makeFile('file0', '', separator)), + ], + }); + handleUpdate.mockReset(); + + table.setExpanded(`${separator}dir1${separator}`, true); + + jest.runAllTimers(); + + await table.getViewportData(); + expect(handleUpdate).toHaveBeenCalledWith({ + offset: 0, + items: [ + expectItem(makeDirectory('dir0', '', separator)), + expectItem(makeDirectory('dir1', '', separator)), + expectItem( + makeDirectory( + 'dir0', + makeDirectory('dir1', '', separator).filename, + separator + ) + ), + expectItem( + makeDirectory( + 'dir1', + makeDirectory('dir1', '', separator).filename, + separator + ) + ), + expectItem( + makeDirectory( + 'dir2', + makeDirectory('dir1', '', separator).filename, + separator + ) + ), + ], + }); + handleUpdate.mockReset(); + + table.setExpanded(`${separator}dir1${separator}dir1${separator}`, true); + + jest.runAllTimers(); + + await table.getViewportData(); + expect(handleUpdate).toHaveBeenCalledWith({ + offset: 0, + items: expect.arrayContaining([ + expectItem(makeDirectory('dir0', '', separator)), + expectItem(makeDirectory('dir1', '', separator)), + expectItem( + makeDirectory( + 'dir0', + makeDirectory('dir1', '', separator).filename, + separator + ) + ), + expectItem( + makeDirectory( + 'dir1', + makeDirectory('dir1', '', separator).filename, + separator + ) + ), + expectItem( + makeDirectory( + 'dir0', + makeDirectory( + 'dir1', + makeDirectory('dir1', '', separator).filename, + separator + ).filename, + separator + ) + ), + ]), + }); + handleUpdate.mockReset(); + + // Now collapse it all + table.setExpanded(`${separator}dir1${separator}`, false); + + jest.runAllTimers(); + + await table.getViewportData(); + expect(handleUpdate).toHaveBeenCalledWith({ + offset: 0, + items: expect.arrayContaining([ + expectItem(makeDirectory('dir0', '', separator)), + expectItem(makeDirectory('dir1', '', separator)), + expectItem(makeDirectory('dir2', '', separator)), + expectItem(makeDirectory('dir3', '', separator)), + expectItem(makeFile('file0', '', separator)), + ]), + }); + table.collapseAll(); + handleUpdate.mockReset(); + } + ); }); diff --git a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts index ef56f52ea2..212320d31a 100644 --- a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts +++ b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts @@ -37,6 +37,8 @@ export class GrpcFileStorageTable implements FileStorageTable { private readonly root: string; + private readonly separator: string; + private currentSize = 0; private currentViewport?: StorageTableViewport; @@ -58,16 +60,19 @@ export class GrpcFileStorageTable implements FileStorageTable { /** * @param storageService The storage service to use * @param baseRoot Base root for the service - * @param root The root path for this storage table + * @param root Root path for this storage table + * @param separator Separator used in the paths */ constructor( storageService: dh.storage.StorageService, baseRoot = '', - root = baseRoot + root = baseRoot, + separator = '/' ) { this.storageService = storageService; this.baseRoot = baseRoot; this.root = root; + this.separator = separator; if (root === baseRoot) { this.doRefresh = debounce( this.doRefresh.bind(this), @@ -100,7 +105,7 @@ export class GrpcFileStorageTable implements FileStorageTable { } setExpanded(path: string, expanded: boolean): void { - const paths = path.split('/'); + const paths = path.split(this.separator); let nextPath = paths.shift(); if (nextPath === undefined || nextPath === '') { nextPath = paths.shift(); @@ -108,13 +113,13 @@ export class GrpcFileStorageTable implements FileStorageTable { if (nextPath === undefined || nextPath === '') { throw new Error(`Invalid path: ${path}`); } - const remainingPath = paths.join('/'); + const remainingPath = paths.join(this.separator); if (expanded) { if (!this.childTables.has(nextPath)) { const childTable = new GrpcFileStorageTable( this.storageService, this.baseRoot, - `${this.root}/${nextPath}` + `${this.root}${this.separator}${nextPath}` ); this.childTables.set(nextPath, childTable); } diff --git a/packages/app-utils/src/storage/grpc/GrpcLayoutStorage.ts b/packages/app-utils/src/storage/grpc/GrpcLayoutStorage.ts index 049488271b..a5ee91847d 100644 --- a/packages/app-utils/src/storage/grpc/GrpcLayoutStorage.ts +++ b/packages/app-utils/src/storage/grpc/GrpcLayoutStorage.ts @@ -6,14 +6,22 @@ export class GrpcLayoutStorage implements LayoutStorage { readonly root: string; + private readonly separator: string; + /** * * @param storageService The gRPC storage service to use * @param root The root path where the layouts are stored + * @param separator The separator used in the paths */ - constructor(storageService: dh.storage.StorageService, root = '') { + constructor( + storageService: dh.storage.StorageService, + root = '', + separator = '/' + ) { this.storageService = storageService; this.root = root; + this.separator = separator; } async getLayouts(): Promise { @@ -24,7 +32,7 @@ export class GrpcLayoutStorage implements LayoutStorage { async getLayout(name: string): Promise { const fileContents = await this.storageService.loadFile( - `${this.root}/${name}` + `${this.root}${this.separator}${name}` ); const content = await fileContents.text(); diff --git a/packages/code-studio/.env b/packages/code-studio/.env index ec2f12c62c..caefab6560 100644 --- a/packages/code-studio/.env +++ b/packages/code-studio/.env @@ -14,11 +14,6 @@ VITE_CORE_API_NAME=dh-core.js # Like the CORE_API_URL, we assume this is served up as a sibling of the code studio VITE_MODULE_PLUGINS_URL=../js-plugins -# Path for notebooks and layouts storage on the gRPCStorageService -# Note these are not URLs, these are file system paths on the server in the gRPCStorageService -VITE_STORAGE_PATH_NOTEBOOKS=/notebooks -VITE_STORAGE_PATH_LAYOUTS=/layouts - # Any routes we define here VITE_ROUTE_NOTEBOOKS=notebooks/ diff --git a/packages/code-studio/src/main/AppInit.tsx b/packages/code-studio/src/main/AppInit.tsx index b785e88f6c..317e99d7de 100644 --- a/packages/code-studio/src/main/AppInit.tsx +++ b/packages/code-studio/src/main/AppInit.tsx @@ -77,15 +77,22 @@ function AppInit(): JSX.Element { sessionDetails ); + const fileSeparator = serverConfig.get('file.separator') ?? '/'; + const notebookRoot = + serverConfig.get('web.storage.notebook.directory') ?? ''; + const layoutRoot = + serverConfig.get('web.storage.layout.directory') ?? ''; const storageService = client.getStorageService(); const layoutStorage = new GrpcLayoutStorage( storageService, - import.meta.env.VITE_STORAGE_PATH_LAYOUTS ?? '' + layoutRoot, + fileSeparator ); const fileStorage = new GrpcFileStorage( api, storageService, - import.meta.env.VITE_STORAGE_PATH_NOTEBOOKS ?? '' + notebookRoot, + fileSeparator ); const workspaceStorage = new LocalWorkspaceStorage(layoutStorage); diff --git a/packages/embed-widget/src/App.tsx b/packages/embed-widget/src/App.tsx index dd377d2d27..47f95f0736 100644 --- a/packages/embed-widget/src/App.tsx +++ b/packages/embed-widget/src/App.tsx @@ -95,9 +95,11 @@ function App(): JSX.Element { sessionDetails ); const storageService = client.getStorageService(); + const layoutRoot = + serverConfig.get('web.storage.layout.directory') ?? ''; const layoutStorage = new GrpcLayoutStorage( storageService, - import.meta.env.VITE_STORAGE_PATH_LAYOUTS ?? '' + layoutRoot ); const workspaceStorage = new LocalWorkspaceStorage(layoutStorage); const loadedWorkspace = await workspaceStorage.load({ From 2ca00f5195143a6445119e9cc58da1d8ee9ae1a0 Mon Sep 17 00:00:00 2001 From: mikebender Date: Fri, 28 Jun 2024 10:05:16 -0400 Subject: [PATCH 2/5] WIP debuggin --- .../app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts | 5 ++++- packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts index 318b144d3f..38f4975688 100644 --- a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts +++ b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts @@ -131,7 +131,10 @@ describe('directory expansion tests', () => { }); handleUpdate.mockReset(); - table.setExpanded(`${separator}dir1${separator}`, true); + const dirPath = `${separator}dir1${separator}`; + console.log(`Expanding ${dirPath}`); + table.setExpanded(dirPath, true); + console.log('Done expansion'); jest.runAllTimers(); diff --git a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts index 212320d31a..3e671a854d 100644 --- a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts +++ b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts @@ -114,6 +114,7 @@ export class GrpcFileStorageTable implements FileStorageTable { throw new Error(`Invalid path: ${path}`); } const remainingPath = paths.join(this.separator); + console.log('setExpanded', nextPath, remainingPath, expanded); if (expanded) { if (!this.childTables.has(nextPath)) { const childTable = new GrpcFileStorageTable( From 3af27a12770d4357dd3ddd96b4f27474fc87a28e Mon Sep 17 00:00:00 2001 From: mikebender Date: Fri, 28 Jun 2024 10:10:41 -0400 Subject: [PATCH 3/5] Fix up the tests - Forgot to pass the separator in when creating a storage table --- .../src/storage/grpc/GrpcFileStorageTable.test.ts | 8 ++------ .../app-utils/src/storage/grpc/GrpcFileStorageTable.ts | 1 - 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts index 38f4975688..6f58eaca71 100644 --- a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts +++ b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts @@ -6,7 +6,7 @@ let storageService: dh.storage.StorageService; function makeTable( baseRoot = '', root = '', - separator = '' + separator = '/' ): GrpcFileStorageTable { return new GrpcFileStorageTable(storageService, baseRoot, root, separator); } @@ -102,7 +102,6 @@ describe('directory expansion tests', () => { storageService.listItems = jest.fn(async path => { const depth = path.length > 0 ? FileUtils.getDepth(path) + 1 : 1; const dirContents = makeDirectoryContents(path, 5 - depth, 10 - depth); - console.log('dirContents for ', path, dirContents); return dirContents; }); }); @@ -110,8 +109,7 @@ describe('directory expansion tests', () => { it.each(['/'])( `expands multiple directories correctly with '%s' separator`, async separator => { - console.log('Creating a new table'); - const table = makeTable(); + const table = makeTable('', '', separator); const handleUpdate = jest.fn(); table.onUpdate(handleUpdate); table.setViewport({ top: 0, bottom: 5 }); @@ -132,9 +130,7 @@ describe('directory expansion tests', () => { handleUpdate.mockReset(); const dirPath = `${separator}dir1${separator}`; - console.log(`Expanding ${dirPath}`); table.setExpanded(dirPath, true); - console.log('Done expansion'); jest.runAllTimers(); diff --git a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts index 3e671a854d..212320d31a 100644 --- a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts +++ b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts @@ -114,7 +114,6 @@ export class GrpcFileStorageTable implements FileStorageTable { throw new Error(`Invalid path: ${path}`); } const remainingPath = paths.join(this.separator); - console.log('setExpanded', nextPath, remainingPath, expanded); if (expanded) { if (!this.childTables.has(nextPath)) { const childTable = new GrpcFileStorageTable( From a3373aa178f59859b75ddb534ecb0339768fa299 Mon Sep 17 00:00:00 2001 From: mikebender Date: Thu, 4 Jul 2024 17:12:58 -0400 Subject: [PATCH 4/5] TODO: Need to fix the failing tests. --- .../src/storage/grpc/GrpcFileStorage.ts | 2 +- .../src/storage/grpc/GrpcFileStorageTable.ts | 2 +- packages/file-explorer/src/FileExplorer.tsx | 11 ++-- packages/file-explorer/src/FileList.tsx | 17 +++--- packages/file-explorer/src/FileListItem.tsx | 4 +- packages/file-explorer/src/FileStorage.ts | 8 +++ packages/file-explorer/src/FileUtils.ts | 54 +++++++++++-------- packages/file-explorer/src/NewItemModal.tsx | 19 +++---- 8 files changed, 71 insertions(+), 46 deletions(-) diff --git a/packages/app-utils/src/storage/grpc/GrpcFileStorage.ts b/packages/app-utils/src/storage/grpc/GrpcFileStorage.ts index b8ad4f303d..87cc471130 100644 --- a/packages/app-utils/src/storage/grpc/GrpcFileStorage.ts +++ b/packages/app-utils/src/storage/grpc/GrpcFileStorage.ts @@ -25,7 +25,7 @@ export class GrpcFileStorage implements FileStorage { private readonly root: string; - private readonly separator: string; + public readonly separator: string; /** * FileStorage implementation using gRPC diff --git a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts index 212320d31a..2cad0ca672 100644 --- a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts +++ b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts @@ -37,7 +37,7 @@ export class GrpcFileStorageTable implements FileStorageTable { private readonly root: string; - private readonly separator: string; + public readonly separator: string; private currentSize = 0; diff --git a/packages/file-explorer/src/FileExplorer.tsx b/packages/file-explorer/src/FileExplorer.tsx index 57342249ac..3d2ccf29d3 100644 --- a/packages/file-explorer/src/FileExplorer.tsx +++ b/packages/file-explorer/src/FileExplorer.tsx @@ -51,6 +51,7 @@ export function FileExplorer(props: FileExplorerProps): JSX.Element { onSelectionChange, rowHeight = DEFAULT_ROW_HEIGHT, } = props; + const { separator } = storage; const [itemsToDelete, setItemsToDelete] = useState([]); const [table, setTable] = useState(); @@ -156,18 +157,18 @@ export function FileExplorer(props: FileExplorerProps): JSX.Element { (item: FileStorageItem, newName: string) => { let name = item.filename; const isDir = isDirectory(item); - if (isDir && !name.endsWith('/')) { - name = `${name}/`; + if (isDir && !name.endsWith(separator)) { + name = `${name}${separator}`; } let destination = `${FileUtils.getParent(name)}${newName}`; - if (isDir && !destination.endsWith('/')) { - destination = `${destination}/`; + if (isDir && !destination.endsWith(separator)) { + destination = `${destination}${separator}`; } log.debug2('handleRename', name, destination); storage.moveFile(name, destination).catch(handleError); onRename(name, destination); }, - [handleError, onRename, storage] + [handleError, onRename, separator, storage] ); const handleValidateRename = useCallback( diff --git a/packages/file-explorer/src/FileList.tsx b/packages/file-explorer/src/FileList.tsx index 908101c891..62cad2baaf 100644 --- a/packages/file-explorer/src/FileList.tsx +++ b/packages/file-explorer/src/FileList.tsx @@ -85,6 +85,7 @@ export function FileList(props: FileListProps): JSX.Element { const itemList = useRef>(null); const fileList = useRef(null); + const { separator } = table; const getItems = useCallback( (ranges: Range[]): FileStorageItem[] => { @@ -258,13 +259,13 @@ export function FileList(props: FileListProps): JSX.Element { log.debug2('handleListDragOver', e); setDropTargetItem({ type: 'directory', - filename: '/', - basename: '/', - id: '/', + filename: separator, + basename: separator, + id: separator, }); } }, - [] + [separator] ); const handleListDrop = useCallback( @@ -334,14 +335,14 @@ export function FileList(props: FileListProps): JSX.Element { const { focusedPath } = props; useEffect(() => { if (focusedPath !== undefined) { - if (focusedPath === '/') { + if (focusedPath === separator) { table.collapseAll(); } else { table.setExpanded(focusedPath, false); table.setExpanded(focusedPath, true); } } - }, [table, focusedPath]); + }, [table, focusedPath, separator]); useEffect( function updateTableViewport() { @@ -397,7 +398,7 @@ export function FileList(props: FileListProps): JSX.Element { if ( dropTargetItem != null && isDirectory(dropTargetItem) && - dropTargetItem.filename !== '/' + dropTargetItem.filename !== separator ) { const timeout = setTimeout(() => { if (!dropTargetItem.isExpanded) { @@ -407,7 +408,7 @@ export function FileList(props: FileListProps): JSX.Element { return () => clearTimeout(timeout); } }, - [dropTargetItem, table] + [dropTargetItem, separator, table] ); const renderWrapper = useCallback( diff --git a/packages/file-explorer/src/FileListItem.tsx b/packages/file-explorer/src/FileListItem.tsx index 71714049fc..ea647ba038 100644 --- a/packages/file-explorer/src/FileListItem.tsx +++ b/packages/file-explorer/src/FileListItem.tsx @@ -33,6 +33,7 @@ export type FileListRenderItemProps = RenderItemProps & { draggedItems?: FileStorageItem[]; isDragInProgress: boolean; isDropTargetValid: boolean; + separator: string; onDragStart: (index: number, e: React.DragEvent) => void; onDragOver: (index: number, e: React.DragEvent) => void; @@ -54,6 +55,7 @@ export function FileListItem(props: FileListRenderItemProps): JSX.Element { onDragOver, onDragEnd, onDrop, + separator, } = props; const isDragged = @@ -73,7 +75,7 @@ export function FileListItem(props: FileListRenderItemProps): JSX.Element { isDragInProgress && !isDropTargetValid && dropTargetPath === itemPath; const icon = getItemIcon(item); - const depth = FileUtils.getDepth(item.filename); + const depth = FileUtils.getDepth(item.filename, separator); const depthLines = Array(depth) .fill(null) .map((value, index) => ( diff --git a/packages/file-explorer/src/FileStorage.ts b/packages/file-explorer/src/FileStorage.ts index f2370bb877..2491fc7e3d 100644 --- a/packages/file-explorer/src/FileStorage.ts +++ b/packages/file-explorer/src/FileStorage.ts @@ -52,6 +52,11 @@ export interface FileStorageTable extends StorageTable { * Collapses all directories */ collapseAll: () => void; + + /** + * Separator used in this file system + */ + readonly separator: string; } /** @@ -108,6 +113,9 @@ export interface FileStorage { * @param name The full directory path */ createDirectory: (name: string) => Promise; + + /** Retrieve the separator used with this file system */ + readonly separator: string; } export default FileStorage; diff --git a/packages/file-explorer/src/FileUtils.ts b/packages/file-explorer/src/FileUtils.ts index 6acaae7f58..3d031452f0 100644 --- a/packages/file-explorer/src/FileUtils.ts +++ b/packages/file-explorer/src/FileUtils.ts @@ -46,9 +46,11 @@ export class FileUtils { /** * Get the depth (how many directories deep it is) of the provided filename with path. * @param name The full file name to get the depth of + * @param separator The separator to use for the path + * @returns The depth of the file */ - static getDepth(name: string): number { - if (!FileUtils.hasPath(name)) { + static getDepth(name: string, separator: string): number { + if (!FileUtils.hasPath(name, separator)) { throw new Error(`Invalid path provided: ${name}`); } const matches = name.match(/\//g) ?? []; @@ -72,10 +74,11 @@ export class FileUtils { /** * Get the base name portion of the file, eg '/foo/bar.txt' => 'bar.txt' * @param name The full name including path of the file + * @param separator The separator to use for the path * @returns Just the file name part of the file */ - static getBaseName(name: string): string { - return name.split('/').pop() ?? ''; + static getBaseName(name: string, separator: string): string { + return name.split(separator).pop() ?? ''; } /** @@ -137,59 +140,66 @@ export class FileUtils { /** * Pop the last part of the filename component to return the parent path * @param name The file name to get the parent path of + * @param separator The separator to use for the path + * @returns The parent path of the file */ - static getParent(name: string): string { - if (!FileUtils.hasPath(name)) { + static getParent(name: string, separator: string): string { + if (!FileUtils.hasPath(name, separator)) { throw new Error(`Invalid name provided: ${name}`); } - const parts = name.split('/'); + const parts = name.split(separator); while (parts.pop() === ''); if (parts.length === 0) { throw new Error(`No parent for path provided: ${name}`); } - return `${parts.join('/')}/`; + return `${parts.join(separator)}${separator}`; } /** * Get the path name portion of the file * @param name The full path with or without filename to get the path of + * @param separator The separator to use for the path * @returns Just the path with out the file name part, including trailing slash */ - static getPath(name: string): string { - if (!FileUtils.hasPath(name)) { + static getPath(name: string, separator: string): string { + if (!FileUtils.hasPath(name, separator)) { throw new Error(`Invalid filename provided: ${name}`); } - const parts = name.split('/'); + const parts = name.split(separator); parts.pop(); - return `${parts.join('/')}/`; + return `${parts.join(separator)}${separator}`; } /** * Check if a given file name includes the full path * @param name The file name to check + * @param separator The separator to use for the path * @returns True if it's a full path, false otherwise */ - static hasPath(name: string): boolean { - return name.startsWith('/'); + static hasPath(name: string, separator: string): boolean { + return name.startsWith(separator); } /** * Check a given file name is a path * @param name The file name to check + * @param separator The separator to use for the path * @returns True if it's a full path, false otherwise */ - static isPath(name: string): boolean { - return name.startsWith('/') && name.endsWith('/'); + static isPath(name: string, separator: string): boolean { + return name.startsWith(separator) && name.endsWith(separator); } /** * Turns a directory file name into a path. Basically ensures there's a trailing slash * @param name The directory name to make a path + * @param separator The separator to use for the path + * @returns The directory name as a path */ - static makePath(name: string): string { - if (!name.endsWith('/')) { - return `${name}/`; + static makePath(name: string, separator: string): string { + if (!name.endsWith(separator)) { + return `${name}${separator}`; } return name; } @@ -246,9 +256,11 @@ export class FileUtils { * Reduce the provided paths to the minimum selection. * Removes any nested files or directories if a parent is already selected. * @param paths The paths to reduce + * @param separator The separator to use for the paths + * @returns The reduced paths */ - static reducePaths(paths: string[]): string[] { - const folders = paths.filter(path => FileUtils.isPath(path)); + static reducePaths(paths: string[], separator: string): string[] { + const folders = paths.filter(path => FileUtils.isPath(path, separator)); return paths.filter( path => !folders.some(folder => path !== folder && path.startsWith(folder)) diff --git a/packages/file-explorer/src/NewItemModal.tsx b/packages/file-explorer/src/NewItemModal.tsx index eb30acaf73..f7209edbb3 100644 --- a/packages/file-explorer/src/NewItemModal.tsx +++ b/packages/file-explorer/src/NewItemModal.tsx @@ -55,7 +55,7 @@ class NewItemModal extends PureComponent { static propTypes = { isOpen: PropTypes.bool, title: PropTypes.string.isRequired, - defaultValue: PropTypes.string, + defaultValue: PropTypes.string.isRequired, type: PropTypes.oneOf(['file', 'directory']).isRequired, onSubmit: PropTypes.func, onCancel: PropTypes.func, @@ -66,7 +66,6 @@ class NewItemModal extends PureComponent { static defaultProps = { isOpen: false, - defaultValue: '/', notifyOnExtensionChange: false, placeholder: '', onSubmit: (name: string, isOverwrite?: boolean): void => undefined, @@ -102,11 +101,11 @@ class NewItemModal extends PureComponent { this.handleExtensionChangeConfirm.bind(this); this.handleBreadcrumbSelect = this.handleBreadcrumbSelect.bind(this); - const { defaultValue } = props; + const { defaultValue, storage } = props; const path = FileUtils.hasPath(defaultValue) ? FileUtils.getPath(defaultValue) - : '/'; + : storage.separator; this.state = { isSubmitting: false, @@ -164,10 +163,10 @@ class NewItemModal extends PureComponent { private isInitialLoad = true; resetValue(): void { - const { defaultValue } = this.props as NewItemModalProps; + const { defaultValue, storage } = this.props as NewItemModalProps; const path = FileUtils.hasPath(defaultValue) ? FileUtils.getPath(defaultValue) - : '/'; + : storage.separator; this.setState({ path, value: FileUtils.getBaseName(defaultValue), @@ -392,14 +391,16 @@ class NewItemModal extends PureComponent { } renderPathButtons(path: string): React.ReactNode { - const pathAsList = path.split('/'); + const { storage } = this.props; + const { separator } = storage; + const pathAsList = path.split(separator); pathAsList.pop(); return pathAsList.map((basename, index) => { let directoryPath = ''; for (let i = 0; i < index; i += 1) { - directoryPath += `${pathAsList[i]}/`; + directoryPath += `${pathAsList[i]}${separator}`; } - directoryPath += `${basename}/`; + directoryPath += `${basename}${separator}`; return ( From e69d64aa5d1862bc1cf768d4793d92454e33192d Mon Sep 17 00:00:00 2001 From: mikebender Date: Fri, 5 Jul 2024 09:58:32 -0400 Subject: [PATCH 5/5] Add the separator to all the file operations - Added a context to pass down the FileStorage and file separator - Cleaned up all the types and the tests --- package-lock.json | 2 + .../src/storage/grpc/GrpcFileStorage.ts | 8 +- .../storage/grpc/GrpcFileStorageTable.test.ts | 226 ++++++-------- .../src/storage/grpc/GrpcFileStorageTable.ts | 6 +- packages/code-studio/src/main/AppInit.tsx | 13 +- .../src/ConsolePlugin.tsx | 17 +- .../src/panels/FileExplorerPanel.test.tsx | 25 +- .../src/panels/FileExplorerPanel.tsx | 13 +- .../src/panels/MockFileStorage.ts | 9 +- .../src/panels/MockFileStorageTable.ts | 5 +- .../src/panels/NotebookPanel.tsx | 46 ++- packages/file-explorer/package.json | 1 + .../file-explorer/src/FileExplorer.test.tsx | 32 +- packages/file-explorer/src/FileExplorer.tsx | 100 +++--- packages/file-explorer/src/FileList.test.tsx | 104 ++++--- packages/file-explorer/src/FileList.tsx | 12 +- .../src/FileListContainer.test.tsx | 9 + .../file-explorer/src/FileListContainer.tsx | 6 +- packages/file-explorer/src/FileListItem.tsx | 17 +- packages/file-explorer/src/FileListUtils.ts | 18 +- .../file-explorer/src/FileStorageContext.ts | 6 + packages/file-explorer/src/FileUtils.test.ts | 290 ++++++++++-------- packages/file-explorer/src/FileUtils.ts | 12 +- packages/file-explorer/src/NewItemModal.tsx | 34 +- packages/file-explorer/src/index.ts | 3 + .../file-explorer/src/useFileSeparator.ts | 11 + packages/file-explorer/src/useFileStorage.ts | 16 + 27 files changed, 578 insertions(+), 463 deletions(-) create mode 100644 packages/file-explorer/src/FileStorageContext.ts create mode 100644 packages/file-explorer/src/useFileSeparator.ts create mode 100644 packages/file-explorer/src/useFileStorage.ts diff --git a/package-lock.json b/package-lock.json index e404c13a05..8c17b294e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29813,6 +29813,7 @@ "@deephaven/components": "file:../components", "@deephaven/icons": "file:../icons", "@deephaven/log": "file:../log", + "@deephaven/react-hooks": "file:../react-hooks", "@deephaven/storage": "file:../storage", "@deephaven/utils": "file:../utils", "@fortawesome/fontawesome-svg-core": "^6.2.1", @@ -32099,6 +32100,7 @@ "@deephaven/icons": "file:../icons", "@deephaven/log": "file:../log", "@deephaven/mocks": "file:../mocks", + "@deephaven/react-hooks": "file:../react-hooks", "@deephaven/storage": "file:../storage", "@deephaven/utils": "file:../utils", "@fortawesome/fontawesome-svg-core": "^6.2.1", diff --git a/packages/app-utils/src/storage/grpc/GrpcFileStorage.ts b/packages/app-utils/src/storage/grpc/GrpcFileStorage.ts index 87cc471130..e70fc5f6a3 100644 --- a/packages/app-utils/src/storage/grpc/GrpcFileStorage.ts +++ b/packages/app-utils/src/storage/grpc/GrpcFileStorage.ts @@ -59,7 +59,7 @@ export class GrpcFileStorage implements FileStorage { type: 'directory', id: path, filename: path, - basename: FileUtils.getBaseName(path), + basename: FileUtils.getBaseName(path, this.separator), }; } @@ -90,7 +90,7 @@ export class GrpcFileStorage implements FileStorage { const content = await fileContents.text(); return { filename: name, - basename: FileUtils.getBaseName(name), + basename: FileUtils.getBaseName(name, this.separator), content, }; } @@ -120,8 +120,8 @@ export class GrpcFileStorage implements FileStorage { async info(name: string): Promise { const allItems = await this.storageService.listItems( - this.addRoot(FileUtils.getPath(name)), - FileUtils.getBaseName(name) + this.addRoot(FileUtils.getPath(name, this.separator)), + FileUtils.getBaseName(name, this.separator) ); if (allItems.length === 0) { throw new FileNotFoundError(); diff --git a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts index 6f58eaca71..9847cfc39b 100644 --- a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts +++ b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.test.ts @@ -43,12 +43,8 @@ it('Does not get contents until a viewport is set', () => { expect(storageService.listItems).toHaveBeenCalled(); }); -describe('directory expansion tests', () => { - function makeFile( - name: string, - path = '', - separator = '/' - ): dh.storage.ItemDetails { +describe.each(['/', `\\`])('directory expansion tests %s', separator => { + function makeFile(name: string, path = ''): dh.storage.ItemDetails { return { basename: name, filename: `${path}${separator}${name}`, @@ -59,32 +55,27 @@ describe('directory expansion tests', () => { }; } - function makeDirectory( - name: string, - path = '', - separator = '/' - ): dh.storage.ItemDetails { - const file = makeFile(name, path, separator); + function makeDirectory(name: string, path = ''): dh.storage.ItemDetails { + const file = makeFile(name, path); file.type = 'directory'; return file; } function makeDirectoryContents( - path = '/', + path = separator, numDirs = 3, - numFiles = 2, - separator = '/' + numFiles = 2 ): Array { const results = [] as dh.storage.ItemDetails[]; for (let i = 0; i < numDirs; i += 1) { const name = `dir${i}`; - results.push(makeDirectory(name, path, separator)); + results.push(makeDirectory(name, path)); } for (let i = 0; i < numFiles; i += 1) { const name = `file${i}`; - results.push(makeFile(name, path, separator)); + results.push(makeFile(name, path)); } return results; @@ -100,128 +91,91 @@ describe('directory expansion tests', () => { beforeEach(() => { storageService.listItems = jest.fn(async path => { - const depth = path.length > 0 ? FileUtils.getDepth(path) + 1 : 1; + const depth = + path.length > 0 ? FileUtils.getDepth(path, separator) + 1 : 1; const dirContents = makeDirectoryContents(path, 5 - depth, 10 - depth); return dirContents; }); }); - it.each(['/'])( - `expands multiple directories correctly with '%s' separator`, - async separator => { - const table = makeTable('', '', separator); - const handleUpdate = jest.fn(); - table.onUpdate(handleUpdate); - table.setViewport({ top: 0, bottom: 5 }); - - jest.runAllTimers(); - - await table.getViewportData(); - expect(handleUpdate).toHaveBeenCalledWith({ - offset: 0, - items: [ - expectItem(makeDirectory('dir0', '', separator)), - expectItem(makeDirectory('dir1', '', separator)), - expectItem(makeDirectory('dir2', '', separator)), - expectItem(makeDirectory('dir3', '', separator)), - expectItem(makeFile('file0', '', separator)), - ], - }); - handleUpdate.mockReset(); - - const dirPath = `${separator}dir1${separator}`; - table.setExpanded(dirPath, true); - - jest.runAllTimers(); - - await table.getViewportData(); - expect(handleUpdate).toHaveBeenCalledWith({ - offset: 0, - items: [ - expectItem(makeDirectory('dir0', '', separator)), - expectItem(makeDirectory('dir1', '', separator)), - expectItem( - makeDirectory( - 'dir0', - makeDirectory('dir1', '', separator).filename, - separator - ) - ), - expectItem( - makeDirectory( - 'dir1', - makeDirectory('dir1', '', separator).filename, - separator - ) - ), - expectItem( - makeDirectory( - 'dir2', - makeDirectory('dir1', '', separator).filename, - separator - ) - ), - ], - }); - handleUpdate.mockReset(); - - table.setExpanded(`${separator}dir1${separator}dir1${separator}`, true); - - jest.runAllTimers(); - - await table.getViewportData(); - expect(handleUpdate).toHaveBeenCalledWith({ - offset: 0, - items: expect.arrayContaining([ - expectItem(makeDirectory('dir0', '', separator)), - expectItem(makeDirectory('dir1', '', separator)), - expectItem( - makeDirectory( - 'dir0', - makeDirectory('dir1', '', separator).filename, - separator - ) - ), - expectItem( - makeDirectory( - 'dir1', - makeDirectory('dir1', '', separator).filename, - separator - ) - ), - expectItem( - makeDirectory( - 'dir0', - makeDirectory( - 'dir1', - makeDirectory('dir1', '', separator).filename, - separator - ).filename, - separator - ) - ), - ]), - }); - handleUpdate.mockReset(); - - // Now collapse it all - table.setExpanded(`${separator}dir1${separator}`, false); - - jest.runAllTimers(); - - await table.getViewportData(); - expect(handleUpdate).toHaveBeenCalledWith({ - offset: 0, - items: expect.arrayContaining([ - expectItem(makeDirectory('dir0', '', separator)), - expectItem(makeDirectory('dir1', '', separator)), - expectItem(makeDirectory('dir2', '', separator)), - expectItem(makeDirectory('dir3', '', separator)), - expectItem(makeFile('file0', '', separator)), - ]), - }); - table.collapseAll(); - handleUpdate.mockReset(); - } - ); + it(`expands multiple directories correctly`, async () => { + const table = makeTable('', '', separator); + const handleUpdate = jest.fn(); + table.onUpdate(handleUpdate); + table.setViewport({ top: 0, bottom: 5 }); + + jest.runAllTimers(); + + await table.getViewportData(); + expect(handleUpdate).toHaveBeenCalledWith({ + offset: 0, + items: [ + expectItem(makeDirectory('dir0', '')), + expectItem(makeDirectory('dir1', '')), + expectItem(makeDirectory('dir2', '')), + expectItem(makeDirectory('dir3', '')), + expectItem(makeFile('file0', '')), + ], + }); + handleUpdate.mockReset(); + + const dirPath = `${separator}dir1${separator}`; + table.setExpanded(dirPath, true); + + jest.runAllTimers(); + + await table.getViewportData(); + expect(handleUpdate).toHaveBeenCalledWith({ + offset: 0, + items: [ + expectItem(makeDirectory('dir0', '')), + expectItem(makeDirectory('dir1', '')), + expectItem(makeDirectory('dir0', makeDirectory('dir1', '').filename)), + expectItem(makeDirectory('dir1', makeDirectory('dir1', '').filename)), + expectItem(makeDirectory('dir2', makeDirectory('dir1', '').filename)), + ], + }); + handleUpdate.mockReset(); + + table.setExpanded(`${separator}dir1${separator}dir1${separator}`, true); + + jest.runAllTimers(); + + await table.getViewportData(); + expect(handleUpdate).toHaveBeenCalledWith({ + offset: 0, + items: expect.arrayContaining([ + expectItem(makeDirectory('dir0', '')), + expectItem(makeDirectory('dir1', '')), + expectItem(makeDirectory('dir0', makeDirectory('dir1', '').filename)), + expectItem(makeDirectory('dir1', makeDirectory('dir1', '').filename)), + expectItem( + makeDirectory( + 'dir0', + makeDirectory('dir1', makeDirectory('dir1', '').filename).filename + ) + ), + ]), + }); + handleUpdate.mockReset(); + + // Now collapse it all + table.setExpanded(`${separator}dir1${separator}`, false); + + jest.runAllTimers(); + + await table.getViewportData(); + expect(handleUpdate).toHaveBeenCalledWith({ + offset: 0, + items: expect.arrayContaining([ + expectItem(makeDirectory('dir0', '')), + expectItem(makeDirectory('dir1', '')), + expectItem(makeDirectory('dir2', '')), + expectItem(makeDirectory('dir3', '')), + expectItem(makeFile('file0', '')), + ]), + }); + table.collapseAll(); + handleUpdate.mockReset(); + }); }); diff --git a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts index 2cad0ca672..408c8d20ce 100644 --- a/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts +++ b/packages/app-utils/src/storage/grpc/GrpcFileStorageTable.ts @@ -119,7 +119,8 @@ export class GrpcFileStorageTable implements FileStorageTable { const childTable = new GrpcFileStorageTable( this.storageService, this.baseRoot, - `${this.root}${this.separator}${nextPath}` + `${this.root}${this.separator}${nextPath}`, + this.separator ); this.childTables.set(nextPath, childTable); } @@ -269,7 +270,8 @@ export class GrpcFileStorageTable implements FileStorageTable { const item = items[i]; const { basename, filename } = item; if ( - filename === this.removeBaseRoot(`${this.root}/${basename}`) && + filename === + this.removeBaseRoot(`${this.root}${this.separator}${basename}`) && item.type === 'directory' ) { const childTable = this.childTables.get(basename); diff --git a/packages/code-studio/src/main/AppInit.tsx b/packages/code-studio/src/main/AppInit.tsx index 317e99d7de..321b5a3088 100644 --- a/packages/code-studio/src/main/AppInit.tsx +++ b/packages/code-studio/src/main/AppInit.tsx @@ -13,6 +13,7 @@ import { } from '@deephaven/dashboard-core-plugins'; import { useApi, useClient } from '@deephaven/jsapi-bootstrap'; import { getSessionDetails, loadSessionWrapper } from '@deephaven/jsapi-utils'; +import { FileStorage, FileStorageContext } from '@deephaven/file-explorer'; import Log from '@deephaven/log'; import { PouchCommandHistoryStorage } from '@deephaven/pouch-storage'; import { @@ -28,6 +29,7 @@ import { setWorkspaceStorage, setServerConfigValues, setUser, + getFileStorage, } from '@deephaven/redux'; import { LocalWorkspaceStorage, @@ -54,6 +56,9 @@ function AppInit(): JSX.Element { const serverConfig = useServerConfig(); const user = useUser(); const workspace = useSelector(getWorkspace); + const fileStorage = useSelector( + getFileStorage + ) as FileStorage | null; const dispatch = useDispatch(); // General error means the app is dead and is unlikely to recover @@ -88,7 +93,7 @@ function AppInit(): JSX.Element { layoutRoot, fileSeparator ); - const fileStorage = new GrpcFileStorage( + const grpcFileStorage = new GrpcFileStorage( api, storageService, notebookRoot, @@ -129,7 +134,7 @@ function AppInit(): JSX.Element { dispatch(setServerConfigValues(serverConfig)); dispatch(setCommandHistoryStorage(commandHistoryStorage)); dispatch(setDashboardData(DEFAULT_DASHBOARD_ID, dashboardData)); - dispatch(setFileStorage(fileStorage)); + dispatch(setFileStorage(grpcFileStorage)); dispatch(setLayoutStorage(layoutStorage)); dispatch(setDashboardConnection(DEFAULT_DASHBOARD_ID, connection)); if (sessionWrapper !== undefined) { @@ -160,14 +165,14 @@ function AppInit(): JSX.Element { const errorMessage = error != null ? `${error}` : null; return ( - <> + {isLoaded && } - + ); } diff --git a/packages/dashboard-core-plugins/src/ConsolePlugin.tsx b/packages/dashboard-core-plugins/src/ConsolePlugin.tsx index 0aad73effb..cbf70ad7b4 100644 --- a/packages/dashboard-core-plugins/src/ConsolePlugin.tsx +++ b/packages/dashboard-core-plugins/src/ConsolePlugin.tsx @@ -10,7 +10,7 @@ import { useListener, usePanelRegistration, } from '@deephaven/dashboard'; -import { FileUtils } from '@deephaven/file-explorer'; +import { FileUtils, useFileSeparator } from '@deephaven/file-explorer'; import { CloseOptions, isComponent } from '@deephaven/golden-layout'; import Log from '@deephaven/log'; import { useCallback, useRef, useState } from 'react'; @@ -73,6 +73,7 @@ export function ConsolePlugin( const [previewFileMap, setPreviewFileMap] = useState( new Map() ); + const separator = useFileSeparator(); const dispatch = useDispatch(); @@ -204,7 +205,7 @@ export function ConsolePlugin( return; } - renamePanel(panelId, FileUtils.getBaseName(newName)); + renamePanel(panelId, FileUtils.getBaseName(newName, separator)); }, [ openFileMap, @@ -214,6 +215,7 @@ export function ConsolePlugin( addPreviewFileMapEntry, deleteOpenFileMapEntry, deletePreviewFileMapEntry, + separator, ] ); @@ -314,10 +316,13 @@ export function ConsolePlugin( [layout.root, openFileMap, previewFileMap, unregisterFilePanel] ); - const getNotebookTitle = useCallback(fileMetadata => { - const { itemName } = fileMetadata; - return FileUtils.getBaseName(itemName); - }, []); + const getNotebookTitle = useCallback( + fileMetadata => { + const { itemName } = fileMetadata; + return FileUtils.getBaseName(itemName, separator); + }, + [separator] + ); const fileIsOpen = useCallback( (fileMetadata: FileMetadata) => { diff --git a/packages/dashboard-core-plugins/src/panels/FileExplorerPanel.test.tsx b/packages/dashboard-core-plugins/src/panels/FileExplorerPanel.test.tsx index 81dfa0d14a..754f2c6784 100644 --- a/packages/dashboard-core-plugins/src/panels/FileExplorerPanel.test.tsx +++ b/packages/dashboard-core-plugins/src/panels/FileExplorerPanel.test.tsx @@ -9,11 +9,12 @@ import { import userEvent from '@testing-library/user-event'; import { DirectoryStorageItem, + FileStorageContext, FileStorageItem, } from '@deephaven/file-explorer'; import type { Container } from '@deephaven/golden-layout'; import { TestUtils } from '@deephaven/utils'; -import { FileExplorerPanel, FileExplorerPanelProps } from './FileExplorerPanel'; +import { FileExplorerPanel } from './FileExplorerPanel'; import MockFileStorage from './MockFileStorage'; function makeFileName(index = 0): string { @@ -81,16 +82,18 @@ const container: Partial = { off: () => undefined, }; -function makeContainer({ fileStorage }: Partial = {}) { +function makeContainer({ fileStorage }: { fileStorage: MockFileStorage }) { return render( - + + + ); } @@ -134,7 +137,7 @@ describe('selects and expands directory for NewItemModal correctly', () => { files = makeFiles(); files.push(makeNested([2], 2)); files.push(makeNested([0, 3], 4)); - items = dirs.concat(files); + items = [...dirs, ...files]; const fileStorage = new MockFileStorage(items); makeContainer({ fileStorage }); diff --git a/packages/dashboard-core-plugins/src/panels/FileExplorerPanel.tsx b/packages/dashboard-core-plugins/src/panels/FileExplorerPanel.tsx index 47b0d251f5..f99f8ea4e4 100644 --- a/packages/dashboard-core-plugins/src/panels/FileExplorerPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/FileExplorerPanel.tsx @@ -109,13 +109,13 @@ export class FileExplorerPanel extends React.Component< this.handleShow = this.handleShow.bind(this); this.handleSelectionChange = this.handleSelectionChange.bind(this); - const { session, language } = props; + const { session, fileStorage, language } = props; this.state = { isShown: false, language, session, showCreateFolder: false, - focusedFilePath: '/', + focusedFilePath: fileStorage.separator, }; } @@ -183,12 +183,14 @@ export class FileExplorerPanel extends React.Component< } handleSelectionChange(selectedItems: FileStorageItem[]): void { - let path = '/'; + const { fileStorage } = this.props; + const { separator } = fileStorage; + let path = separator; if (selectedItems.length === 1) { if (selectedItems[0].type === 'directory') { - path = FileUtils.makePath(selectedItems[0].filename); + path = FileUtils.makePath(selectedItems[0].filename, separator); } else { - path = FileUtils.getPath(selectedItems[0].filename); + path = FileUtils.getPath(selectedItems[0].filename, separator); } } this.setState({ focusedFilePath: path }); @@ -271,7 +273,6 @@ export class FileExplorerPanel extends React.Component< {isShown && ( { - return new MockFileStorageTable(this.items); + return new MockFileStorageTable(this.items, this.separator); } /* eslint-disable class-methods-use-this */ @@ -40,7 +43,7 @@ export class MockFileStorage implements FileStorage { for (let i = 0; i < this.items.length; i += 1) { if (this.items[i].filename === name) { this.items[i].filename = newName; - this.items[i].basename = FileUtils.getBaseName(newName); + this.items[i].basename = FileUtils.getBaseName(newName, this.separator); this.items[i].id = newName; break; } diff --git a/packages/dashboard-core-plugins/src/panels/MockFileStorageTable.ts b/packages/dashboard-core-plugins/src/panels/MockFileStorageTable.ts index 9f0c09f1e9..1d2f6ed9cc 100644 --- a/packages/dashboard-core-plugins/src/panels/MockFileStorageTable.ts +++ b/packages/dashboard-core-plugins/src/panels/MockFileStorageTable.ts @@ -19,8 +19,11 @@ export class MockFileStorageTable implements FileStorageTable { private onUpdateCallback?: ViewportUpdateCallback; - constructor(items: FileStorageItem[]) { + public readonly separator: string; + + constructor(items: FileStorageItem[], separator = '/') { this.items = items; + this.separator = separator; } /* eslint-disable class-methods-use-this */ diff --git a/packages/dashboard-core-plugins/src/panels/NotebookPanel.tsx b/packages/dashboard-core-plugins/src/panels/NotebookPanel.tsx index 35e68a26f6..bc09187962 100644 --- a/packages/dashboard-core-plugins/src/panels/NotebookPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/NotebookPanel.tsx @@ -162,8 +162,11 @@ class NotebookPanel extends Component { defaultNotebookSettings: { isMinimapEnabled: true }, }; - static languageFromFileName(fileName: string): string | null { - const extension = FileUtils.getExtension(fileName).toLowerCase(); + static languageFromFileName( + fileName: string, + separator: string + ): string | null { + const extension = FileUtils.getExtension(fileName, separator).toLowerCase(); switch (extension) { case 'py': case 'python': @@ -238,7 +241,13 @@ class NotebookPanel extends Component { this.tabInitOnce = false; - const { isDashboardActive, session, sessionLanguage, panelState } = props; + const { + isDashboardActive, + session, + sessionLanguage, + panelState, + fileStorage, + } = props; let settings: editor.IStandaloneEditorConstructionOptions = { value: '', @@ -258,7 +267,8 @@ class NotebookPanel extends Component { // Not showing the unsaved indicator for null file id and editor content === '', // may need to implement some other indication that this notebook has never been saved const hasFileId = - fileMetadata != null && FileUtils.hasPath(fileMetadata.itemName); + fileMetadata != null && + FileUtils.hasPath(fileMetadata.itemName, fileStorage.separator); // Unsaved if file id != null and content != null // OR file id is null AND content is not null or '' @@ -463,6 +473,7 @@ class NotebookPanel extends Component { assertNotNull(fileMetadata); const { id } = fileMetadata; const { fileStorage } = this.props; + const { separator } = fileStorage; this.pending .add(fileStorage.loadFile(id)) .then(loadedFile => { @@ -472,11 +483,12 @@ class NotebookPanel extends Component { if (itemName !== prevItemName) { const { metadata } = this.props; const { id: tabId } = metadata; - this.renameTab(tabId, FileUtils.getBaseName(itemName)); + this.renameTab(tabId, FileUtils.getBaseName(itemName, separator)); } const updatedSettings = { ...settings, - language: NotebookPanel.languageFromFileName(itemName) ?? '', + language: + NotebookPanel.languageFromFileName(itemName, separator) ?? '', }; if (settings.value == null) { updatedSettings.value = loadedFile.content; @@ -500,7 +512,9 @@ class NotebookPanel extends Component { */ save(): boolean { const { fileMetadata } = this.state; - if (fileMetadata && FileUtils.hasPath(fileMetadata.itemName)) { + const { fileStorage } = this.props; + const { separator } = fileStorage; + if (fileMetadata && FileUtils.hasPath(fileMetadata.itemName, separator)) { const content = this.getNotebookValue(); if (content !== undefined) { this.saveContent(fileMetadata.itemName, content); @@ -717,6 +731,7 @@ class NotebookPanel extends Component { async handleDeleteConfirm(): Promise { const { fileStorage, glContainer, glEventHub } = this.props; const { fileMetadata } = this.state; + const { separator } = fileStorage; log.debug('handleDeleteConfirm', fileMetadata?.itemName); this.setState({ showDeleteModal: false }); @@ -726,7 +741,7 @@ class NotebookPanel extends Component { } if ( - FileUtils.hasPath(fileMetadata.itemName) && + FileUtils.hasPath(fileMetadata.itemName, separator) && // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions (await FileUtils.fileExists(fileStorage, fileMetadata.itemName)) ) { @@ -890,8 +905,10 @@ class NotebookPanel extends Component { handleSaveSuccess(file: File): void { const { fileStorage } = this.props; + const { separator } = fileStorage; const fileMetadata = { id: file.filename, itemName: file.filename }; - const language = NotebookPanel.languageFromFileName(file.filename) ?? ''; + const language = + NotebookPanel.languageFromFileName(file.filename, separator) ?? ''; this.setState(state => { const { fileMetadata: oldMetadata } = state; const settings = { @@ -901,7 +918,7 @@ class NotebookPanel extends Component { log.debug('handleSaveSuccess', fileMetadata, oldMetadata, settings); if ( oldMetadata && - FileUtils.hasPath(oldMetadata.itemName) && + FileUtils.hasPath(oldMetadata.itemName, separator) && oldMetadata.itemName !== fileMetadata.itemName ) { log.debug('handleSaveSuccess deleting old file', oldMetadata.itemName); @@ -950,10 +967,15 @@ class NotebookPanel extends Component { showSaveAsModal: false, }); - if (FileUtils.getBaseName(prevItemName) !== FileUtils.getBaseName(name)) { + const { fileStorage } = this.props; + const { separator } = fileStorage; + if ( + FileUtils.getBaseName(prevItemName, separator) !== + FileUtils.getBaseName(name, separator) + ) { const { metadata } = this.props; const { id: tabId } = metadata; - this.renameTab(tabId, FileUtils.getBaseName(name)); + this.renameTab(tabId, FileUtils.getBaseName(name, separator)); } this.saveContent(name, content); diff --git a/packages/file-explorer/package.json b/packages/file-explorer/package.json index f01351e535..c726291d60 100644 --- a/packages/file-explorer/package.json +++ b/packages/file-explorer/package.json @@ -25,6 +25,7 @@ "@deephaven/components": "file:../components", "@deephaven/icons": "file:../icons", "@deephaven/log": "file:../log", + "@deephaven/react-hooks": "file:../react-hooks", "@deephaven/storage": "file:../storage", "@deephaven/utils": "file:../utils", "@fortawesome/fontawesome-svg-core": "^6.2.1", diff --git a/packages/file-explorer/src/FileExplorer.test.tsx b/packages/file-explorer/src/FileExplorer.test.tsx index e3a4f4d35e..d920f8d405 100644 --- a/packages/file-explorer/src/FileExplorer.test.tsx +++ b/packages/file-explorer/src/FileExplorer.test.tsx @@ -14,29 +14,25 @@ import FileStorage, { } from './FileStorage'; import FileExplorer, { FileExplorerProps } from './FileExplorer'; import { makeDirectories, makeFiles, makeNested } from './FileTestUtils'; +import useFileStorage from './useFileStorage'; + +const { asMock, createMockProxy } = TestUtils; +jest.mock('./useFileStorage'); function makeMockFileStorage(): FileStorage { - return { - getTable: jest.fn(), - saveFile: jest.fn(), - loadFile: jest.fn(), - deleteFile: jest.fn(), - moveFile: jest.fn(), - info: jest.fn(), - createDirectory: jest.fn(), - }; + return createMockProxy({ + separator: '/', + }); } function renderFileExplorer({ - storage, onSelect = jest.fn(), onRename = jest.fn(), onDelete = jest.fn(), -}: Partial & Pick) { +}: Partial = {}) { return render( <> { - renderFileExplorer({ storage: makeMockFileStorage() }); + asMock(useFileStorage).mockReturnValue(makeMockFileStorage()); + renderFileExplorer(); }); it('mounts properly and shows file list', async () => { @@ -55,7 +52,9 @@ it('mounts properly and shows file list', async () => { const storage = new MockFileStorage(files); - renderFileExplorer({ storage }); + asMock(useFileStorage).mockReturnValue(storage); + + renderFileExplorer(); const foundItems = await screen.findAllByRole('listitem'); expect(foundItems).toHaveLength(files.length); @@ -88,10 +87,11 @@ describe('context menu actions work properly', () => { files = makeFiles(); files.push(makeNested([2], 2)); files.push(makeNested([0, 3], 4)); - items = dirs.concat(files); + items = [...dirs, ...files]; const fileStorage = new MockFileStorage(items); - renderFileExplorer({ storage: fileStorage, onRename, onDelete }); + asMock(useFileStorage).mockReturnValue(fileStorage); + renderFileExplorer({ onRename, onDelete }); const foundItems = await screen.findAllByRole('listitem'); expect(foundItems).toHaveLength(items.length); }); diff --git a/packages/file-explorer/src/FileExplorer.tsx b/packages/file-explorer/src/FileExplorer.tsx index 3d2ccf29d3..7a6dfd9698 100644 --- a/packages/file-explorer/src/FileExplorer.tsx +++ b/packages/file-explorer/src/FileExplorer.tsx @@ -3,22 +3,19 @@ import Log from '@deephaven/log'; import { CancelablePromise, PromiseUtils } from '@deephaven/utils'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { DEFAULT_ROW_HEIGHT } from './FileListUtils'; -import FileStorage, { - FileStorageItem, - FileStorageTable, - isDirectory, -} from './FileStorage'; +import { FileStorageItem, FileStorageTable, isDirectory } from './FileStorage'; import './FileExplorer.scss'; import FileListContainer from './FileListContainer'; import FileUtils from './FileUtils'; import FileExistsError from './FileExistsError'; import FileNotFoundError from './FileNotFoundError'; +import FileStorageContext from './FileStorageContext'; +import useFileStorage from './useFileStorage'; +import useFileSeparator from './useFileSeparator'; const log = Log.module('FileExplorer'); export interface FileExplorerProps { - storage: FileStorage; - isMultiSelect?: boolean; focusedPath?: string; @@ -39,7 +36,6 @@ export interface FileExplorerProps { */ export function FileExplorer(props: FileExplorerProps): JSX.Element { const { - storage, isMultiSelect = false, focusedPath, onCopy = () => undefined, @@ -51,7 +47,8 @@ export function FileExplorer(props: FileExplorerProps): JSX.Element { onSelectionChange, rowHeight = DEFAULT_ROW_HEIGHT, } = props; - const { separator } = storage; + const storage = useFileStorage(); + const separator = useFileSeparator(); const [itemsToDelete, setItemsToDelete] = useState([]); const [table, setTable] = useState(); @@ -114,12 +111,14 @@ export function FileExplorer(props: FileExplorerProps): JSX.Element { log.debug('handleDeleteConfirm', itemsToDelete); itemsToDelete.forEach(file => storage.deleteFile( - isDirectory(file) ? FileUtils.makePath(file.filename) : file.filename + isDirectory(file) + ? FileUtils.makePath(file.filename, separator) + : file.filename ) ); onDelete(itemsToDelete); setItemsToDelete([]); - }, [itemsToDelete, onDelete, storage]); + }, [itemsToDelete, onDelete, separator, storage]); const handleDeleteCancel = useCallback(() => { log.debug('handleDeleteCancel'); @@ -130,16 +129,20 @@ export function FileExplorer(props: FileExplorerProps): JSX.Element { (files: FileStorageItem[], path: string) => { const filesToMove = FileUtils.reducePaths( files.map(file => - isDirectory(file) ? FileUtils.makePath(file.filename) : file.filename - ) + isDirectory(file) + ? FileUtils.makePath(file.filename, separator) + : file.filename + ), + separator ); filesToMove.forEach(file => { - const newFile = FileUtils.isPath(file) + const newFile = FileUtils.isPath(file, separator) ? `${path}${FileUtils.getBaseName( - file.substring(0, file.length - 1) - )}/` - : `${path}${FileUtils.getBaseName(file)}`; + file.substring(0, file.length - 1), + separator + )}${separator}` + : `${path}${FileUtils.getBaseName(file, separator)}`; storage .moveFile(file, newFile) .then(() => { @@ -150,7 +153,7 @@ export function FileExplorer(props: FileExplorerProps): JSX.Element { .catch(handleError); }); }, - [handleError, onRename, storage] + [handleError, onRename, separator, storage] ); const handleRename = useCallback( @@ -160,7 +163,7 @@ export function FileExplorer(props: FileExplorerProps): JSX.Element { if (isDir && !name.endsWith(separator)) { name = `${name}${separator}`; } - let destination = `${FileUtils.getParent(name)}${newName}`; + let destination = `${FileUtils.getParent(name, separator)}${newName}`; if (isDir && !destination.endsWith(separator)) { destination = `${destination}${separator}`; } @@ -179,7 +182,10 @@ export function FileExplorer(props: FileExplorerProps): JSX.Element { } FileUtils.validateName(newName); - const newValue = `${FileUtils.getPath(renameItem.filename)}${newName}`; + const newValue = `${FileUtils.getPath( + renameItem.filename, + separator + )}${newName}`; try { const fileInfo = await storage.info(newValue); throw new FileExistsError(fileInfo); @@ -190,7 +196,7 @@ export function FileExplorer(props: FileExplorerProps): JSX.Element { // The file does not exist, fine to save at that path } }, - [storage] + [separator, storage] ); const isDeleteConfirmationShown = itemsToDelete.length > 0; @@ -203,32 +209,34 @@ export function FileExplorer(props: FileExplorerProps): JSX.Element { return (
- {table && ( - + {table && ( + + )} + - )} - +
); } diff --git a/packages/file-explorer/src/FileList.test.tsx b/packages/file-explorer/src/FileList.test.tsx index 811c10e7a4..3a1a48703b 100644 --- a/packages/file-explorer/src/FileList.test.tsx +++ b/packages/file-explorer/src/FileList.test.tsx @@ -18,6 +18,9 @@ import { makeNested, } from './FileTestUtils'; import { getMoveOperation } from './FileListUtils'; +import useFileStorage from './useFileStorage'; + +jest.mock('./useFileStorage'); const renderFileList = ({ table = {} as FileStorageTable, @@ -30,54 +33,77 @@ const renderFileList = ({ ); -describe('getMoveOperation', () => { - it('succeeds if moving files from root to within a directory', () => { - const targetPath = '/target/'; - const targetDirectory = makeDirectory('target'); - const targetItem = makeFile('targetItem', targetPath); - const draggedItems = [makeFile('foo.txt'), makeFile('bar.txt')]; - expect(getMoveOperation(draggedItems, targetItem)).toEqual({ - files: draggedItems, - targetPath, - }); - expect(getMoveOperation(draggedItems, targetDirectory)).toEqual({ - files: draggedItems, - targetPath, +describe.each(['/', '\\'])( + `getMoveOperation with separator '%s'`, + separator => { + it('succeeds if moving files from root to within a directory', () => { + const targetPath = `${separator}target${separator}`; + const targetDirectory = makeDirectory('target', separator); + const targetItem = makeFile('targetItem', targetPath); + const draggedItems = [ + makeFile('foo.txt', separator), + makeFile('bar.txt', separator), + ]; + expect(getMoveOperation(draggedItems, targetItem, separator)).toEqual({ + files: draggedItems, + targetPath, + }); + expect( + getMoveOperation(draggedItems, targetDirectory, separator) + ).toEqual({ + files: draggedItems, + targetPath, + }); }); - }); - it('succeeds moving files from directory into root', () => { - const targetPath = '/'; - const targetItem = makeFile('targetItem', targetPath); - const path = '/baz/'; - const draggedItems = [makeFile('foo.txt', path), makeFile('bar.txt', path)]; - expect(getMoveOperation(draggedItems, targetItem)).toEqual({ - files: draggedItems, - targetPath, + it('succeeds moving files from directory into root', () => { + const targetPath = separator; + const targetItem = makeFile('targetItem', targetPath); + const path = `${separator}baz${separator}`; + const draggedItems = [ + makeFile('foo.txt', path), + makeFile('bar.txt', path), + ]; + expect(getMoveOperation(draggedItems, targetItem, separator)).toEqual({ + files: draggedItems, + targetPath, + }); }); - }); - it('fails if no items selected to move', () => { - expect(() => getMoveOperation([], makeFile('foo.txt'))).toThrow(); - }); + it('fails if no items selected to move', () => { + expect(() => + getMoveOperation([], makeFile('foo.txt', separator), separator) + ).toThrow(); + }); - it('fails if trying to move files within same directory', () => { - const path = '/baz/'; - const targetItem = makeFile('targetItem', path); - const draggedItems = [makeFile('foo.txt', path), makeFile('bar.txt')]; - expect(() => getMoveOperation(draggedItems, targetItem)).toThrow(); - }); + it('fails if trying to move files within same directory', () => { + const path = `${separator}baz${separator}`; + const targetItem = makeFile('targetItem', path); + const draggedItems = [ + makeFile('foo.txt', path), + makeFile('bar.txt', separator), + ]; + expect(() => + getMoveOperation(draggedItems, targetItem, separator) + ).toThrow(); + }); - it('fails to move a directory into a child directory', () => { - expect(() => - getMoveOperation([makeDirectory('foo')], makeDirectory('bar', '/foo/')) - ).toThrow(); - }); -}); + it('fails to move a directory into a child directory', () => { + expect(() => + getMoveOperation( + [makeDirectory('foo', separator)], + makeDirectory('bar', `${separator}foo${separator}`), + separator + ) + ).toThrow(); + }); + } +); it('mounts properly and shows file list', async () => { const files = makeFiles(); const fileStorage = new MockFileStorage(files); + TestUtils.asMock(useFileStorage).mockReturnValue(fileStorage); const table = await fileStorage.getTable(); renderFileList({ table }); @@ -97,7 +123,7 @@ describe('mouse actions', () => { files = makeFiles(); files.push(makeNested([2], 2)); files.push(makeNested([0, 3], 4)); - items = dirs.concat(files); + items = [...dirs, ...files]; }); it('selects the item upon left click', async () => { diff --git a/packages/file-explorer/src/FileList.tsx b/packages/file-explorer/src/FileList.tsx index 62cad2baaf..25537a180e 100644 --- a/packages/file-explorer/src/FileList.tsx +++ b/packages/file-explorer/src/FileList.tsx @@ -13,6 +13,7 @@ import { FileStorageItem, FileStorageTable, isDirectory } from './FileStorage'; import './FileList.scss'; import { DEFAULT_ROW_HEIGHT, getMoveOperation } from './FileListUtils'; import { FileListItem, FileListRenderItemProps } from './FileListItem'; +import useFileSeparator from './useFileSeparator'; const log = Log.module('FileList'); @@ -85,7 +86,7 @@ export function FileList(props: FileListProps): JSX.Element { const itemList = useRef>(null); const fileList = useRef(null); - const { separator } = table; + const separator = useFileSeparator(); const getItems = useCallback( (ranges: Range[]): FileStorageItem[] => { @@ -154,7 +155,8 @@ export function FileList(props: FileListProps): JSX.Element { try { const { files, targetPath } = getMoveOperation( draggedItems, - dropTargetItem + dropTargetItem, + separator ); onMove?.(files, targetPath); if (itemIndex != null) { @@ -165,7 +167,7 @@ export function FileList(props: FileListProps): JSX.Element { log.error('Unable to complete move', err); } }, - [draggedItems, dropTargetItem, onMove] + [draggedItems, dropTargetItem, onMove, separator] ); const handleSelect = useCallback( @@ -323,14 +325,14 @@ export function FileList(props: FileListProps): JSX.Element { } try { - getMoveOperation(draggedItems, dropTargetItem); + getMoveOperation(draggedItems, dropTargetItem, separator); log.debug('handleValidateDropTarget true'); return true; } catch (e) { log.debug('handleValidateDropTarget false'); return false; } - }, [draggedItems, dropTargetItem]); + }, [draggedItems, dropTargetItem, separator]); const { focusedPath } = props; useEffect(() => { diff --git a/packages/file-explorer/src/FileListContainer.test.tsx b/packages/file-explorer/src/FileListContainer.test.tsx index c248443499..52ad2e2e54 100644 --- a/packages/file-explorer/src/FileListContainer.test.tsx +++ b/packages/file-explorer/src/FileListContainer.test.tsx @@ -7,6 +7,13 @@ import { ContextMenuRoot } from '@deephaven/components'; import { FileStorageItem, FileStorageTable } from './FileStorage'; import FileListContainer, { FileListContainerProps } from './FileListContainer'; import { makeFiles } from './FileTestUtils'; +import useFileStorage from './useFileStorage'; + +jest.mock('./useFileStorage'); + +// jest.mock('./useFileStorage', () => ({ +// useFileStorage: jest.fn(() => new MockFileStorage([])), +// })); const renderFileListContainer = async ({ table = {} as FileStorageTable, @@ -37,6 +44,7 @@ const renderFileListContainer = async ({ it('mounts properly and shows file list', async () => { const files = makeFiles(); const fileStorage = new MockFileStorage(files); + TestUtils.asMock(useFileStorage).mockReturnValue(fileStorage); const table = await fileStorage.getTable(); renderFileListContainer({ table }); @@ -98,6 +106,7 @@ describe('renders correct context menu actions', () => { user = userEvent.setup(); files = makeFiles(); const fileStorage = new MockFileStorage(files); + TestUtils.asMock(useFileStorage).mockReturnValue(fileStorage); table = await fileStorage.getTable(); }); diff --git a/packages/file-explorer/src/FileListContainer.tsx b/packages/file-explorer/src/FileListContainer.tsx index fab8338aaa..bb03d14f51 100644 --- a/packages/file-explorer/src/FileListContainer.tsx +++ b/packages/file-explorer/src/FileListContainer.tsx @@ -9,6 +9,7 @@ import SHORTCUTS from './FileExplorerShortcuts'; import './FileExplorer.scss'; import FileUtils from './FileUtils'; import FileListItemEditor from './FileListItemEditor'; +import useFileSeparator from './useFileSeparator'; export interface FileListContainerProps { showContextMenu?: boolean; @@ -54,6 +55,7 @@ export function FileListContainer(props: FileListContainerProps): JSX.Element { const [renameItem, setRenameItem] = useState(); const [selectedItems, setSelectedItems] = useState([] as FileStorageItem[]); const [focusedItem, setFocusedItem] = useState(); + const separator = useFileSeparator(); const handleSelectionChange = useCallback( newSelectedItems => { @@ -85,9 +87,9 @@ export function FileListContainer(props: FileListContainerProps): JSX.Element { const handleNewFolderAction = useCallback(() => { if (focusedItem) { - onCreateFolder?.(FileUtils.getPath(focusedItem.filename)); + onCreateFolder?.(FileUtils.getPath(focusedItem.filename, separator)); } - }, [focusedItem, onCreateFolder]); + }, [focusedItem, onCreateFolder, separator]); const handleRenameAction = useCallback(() => { if (focusedItem) { diff --git a/packages/file-explorer/src/FileListItem.tsx b/packages/file-explorer/src/FileListItem.tsx index ea647ba038..d270abc466 100644 --- a/packages/file-explorer/src/FileListItem.tsx +++ b/packages/file-explorer/src/FileListItem.tsx @@ -8,17 +8,19 @@ import { FileStorageItem, isDirectory } from './FileStorage'; import './FileList.scss'; import FileUtils, { MIME_TYPE } from './FileUtils'; import { getPathFromItem } from './FileListUtils'; +import useFileSeparator from './useFileSeparator'; /** * Get the icon definition for a file or folder item * @param item Item to get the icon for + * @param separator The separator to use for the path * @returns Icon definition to pass in the FontAwesomeIcon icon prop */ -function getItemIcon(item: FileStorageItem): IconDefinition { +function getItemIcon(item: FileStorageItem, separator: string): IconDefinition { if (isDirectory(item)) { return item.isExpanded ? vsFolderOpened : vsFolder; } - const mimeType = FileUtils.getMimeType(item.basename); + const mimeType = FileUtils.getMimeType(item.basename, separator); switch (mimeType) { case MIME_TYPE.PYTHON: return dhPython; @@ -33,7 +35,6 @@ export type FileListRenderItemProps = RenderItemProps & { draggedItems?: FileStorageItem[]; isDragInProgress: boolean; isDropTargetValid: boolean; - separator: string; onDragStart: (index: number, e: React.DragEvent) => void; onDragOver: (index: number, e: React.DragEvent) => void; @@ -55,14 +56,16 @@ export function FileListItem(props: FileListRenderItemProps): JSX.Element { onDragOver, onDragEnd, onDrop, - separator, } = props; + const separator = useFileSeparator(); const isDragged = draggedItems?.some(draggedItem => draggedItem.id === item.id) ?? false; - const itemPath = getPathFromItem(item); + const itemPath = getPathFromItem(item, separator); const dropTargetPath = - isDragInProgress && dropTargetItem ? getPathFromItem(dropTargetItem) : null; + isDragInProgress && dropTargetItem + ? getPathFromItem(dropTargetItem, separator) + : null; const isExactDropTarget = isDragInProgress && @@ -74,7 +77,7 @@ export function FileListItem(props: FileListRenderItemProps): JSX.Element { const isInvalidDropTarget = isDragInProgress && !isDropTargetValid && dropTargetPath === itemPath; - const icon = getItemIcon(item); + const icon = getItemIcon(item, separator); const depth = FileUtils.getDepth(item.filename, separator); const depthLines = Array(depth) .fill(null) diff --git a/packages/file-explorer/src/FileListUtils.ts b/packages/file-explorer/src/FileListUtils.ts index e13ca2ac4c..48b7e87e3c 100644 --- a/packages/file-explorer/src/FileListUtils.ts +++ b/packages/file-explorer/src/FileListUtils.ts @@ -4,10 +4,13 @@ import FileUtils from './FileUtils'; export const DEFAULT_ROW_HEIGHT = 26; -export function getPathFromItem(file: FileStorageItem): string { +export function getPathFromItem( + file: FileStorageItem, + separator: string +): string { return isDirectory(file) - ? FileUtils.makePath(file.filename) - : FileUtils.getPath(file.filename); + ? FileUtils.makePath(file.filename, separator) + : FileUtils.getPath(file.filename, separator); } /** @@ -15,16 +18,17 @@ export function getPathFromItem(file: FileStorageItem): string { */ export function getMoveOperation( draggedItems: FileStorageItem[], - targetItem: FileStorageItem + targetItem: FileStorageItem, + separator: string ): { files: FileStorageItem[]; targetPath: string } { if (draggedItems.length === 0 || targetItem == null) { throw new Error('No items to move'); } - const targetPath = getPathFromItem(targetItem); + const targetPath = getPathFromItem(targetItem, separator); if ( draggedItems.some( - ({ filename }) => FileUtils.getPath(filename) === targetPath + ({ filename }) => FileUtils.getPath(filename, separator) === targetPath ) ) { // Cannot drop if target is one of the dragged items is already in the target folder @@ -34,7 +38,7 @@ export function getMoveOperation( draggedItems.some( item => isDirectory(item) && - targetPath.startsWith(FileUtils.makePath(item.filename)) + targetPath.startsWith(FileUtils.makePath(item.filename, separator)) ) ) { // Cannot drop if target is a child of one of the directories being moved diff --git a/packages/file-explorer/src/FileStorageContext.ts b/packages/file-explorer/src/FileStorageContext.ts new file mode 100644 index 0000000000..55ff36b2a3 --- /dev/null +++ b/packages/file-explorer/src/FileStorageContext.ts @@ -0,0 +1,6 @@ +import { createContext } from 'react'; +import { type FileStorage } from './FileStorage'; + +export const FileStorageContext = createContext(null); + +export default FileStorageContext; diff --git a/packages/file-explorer/src/FileUtils.test.ts b/packages/file-explorer/src/FileUtils.test.ts index 2cce028009..12c9be50ae 100644 --- a/packages/file-explorer/src/FileUtils.test.ts +++ b/packages/file-explorer/src/FileUtils.test.ts @@ -57,155 +57,173 @@ describe('replaceExtension', () => { }); }); -it('gets extension', () => { - function testName(name: string, expectedExtension: string) { - expect(FileUtils.getExtension(name)).toBe(expectedExtension); - } - testName('noext', ''); - testName('plain.txt', 'txt'); - testName('file.py', 'py'); - testName('file.tar.gz', 'gz'); - testName('/foo/bar/', ''); - testName('/foo/bar/baz.txt', 'txt'); - testName('/foo.bar/baz.txt', 'txt'); - testName('/foo.bar/baz', ''); -}); - -it('gets the path', () => { - function testName(name: string, expectedPath: string) { - expect(FileUtils.getPath(name)).toBe(expectedPath); - } - - function testError(name: string) { - expect(() => FileUtils.getPath(name)).toThrow(); - } - - testName('/', '/'); - testName('/foo.txt', '/'); - testName('/foo/', '/foo/'); - testName('/foo/bar.txt', '/foo/'); - testName('/foo/bar/baz.txt', '/foo/bar/'); - - testError('nopath.txt'); - testError('nopath'); - testError('invalid/path'); -}); - -it('makePath handles names with or without trailing slash', () => { - const DEFAULT_DIR = 'DEFAULT_DIR'; - const DEFAULT_PATH = `${DEFAULT_DIR}/`; - expect(FileUtils.makePath(DEFAULT_DIR)).toBe(DEFAULT_PATH); - expect(FileUtils.makePath(DEFAULT_PATH)).toBe(DEFAULT_PATH); -}); - -it('gets the parent', () => { - function testName(name: string, expectedPath: string) { - expect(FileUtils.getParent(name)).toBe(expectedPath); - } - - function testError(name: string) { - expect(() => FileUtils.getParent(name)).toThrow(); - } - - testName('/foo.txt', '/'); - testName('/foo/', '/'); - testName('/foo/bar.txt', '/foo/'); - testName('/foo/bar/', '/foo/'); - testName('/foo/bar/baz.txt', '/foo/bar/'); - - testError('/'); - testError('nopath.txt'); - testError('nopath'); - testError('invalid/path'); -}); - -it('gets the filename', () => { - function testName(name: string, expectedName: string) { - expect(FileUtils.getBaseName(name)).toBe(expectedName); +describe.each(['/', '\\'])('FileUtils with separator %s', separator => { + function normalizeName(name: string): string { + return name.replace(/\//g, separator); } - testName('/', ''); - testName('/foo.txt', 'foo.txt'); - testName('/foo/', ''); - testName('/foo/bar.txt', 'bar.txt'); - testName('/foo/bar/baz.txt', 'baz.txt'); -}); - -it('gets the depth', () => { - function testName(name: string, expectedDepth: number) { - expect(FileUtils.getDepth(name)).toBe(expectedDepth); - } - - function testError(name: string) { - expect(() => FileUtils.getDepth(name)).toThrow(); - } - - testName('/', 0); - testName('/foo.txt', 0); - testName('/foo/bar.txt', 1); - testName('/foo/bar/baz.txt', 2); + it('gets extension', () => { + function testName(name: string, expectedExtension: string) { + expect(FileUtils.getExtension(normalizeName(name), separator)).toBe( + expectedExtension + ); + } + testName('noext', ''); + testName('plain.txt', 'txt'); + testName('file.py', 'py'); + testName('file.tar.gz', 'gz'); + testName('/foo/bar/', ''); + testName('/foo/bar/baz.txt', 'txt'); + testName('/foo.bar/baz.txt', 'txt'); + testName('/foo.bar/baz', ''); + }); - testError(''); - testError('invalid/file'); - testError('nopath'); - testError('nopath.txt'); -}); + it('gets the path', () => { + function testName(name: string, expectedPath: string) { + expect(FileUtils.getPath(normalizeName(name), separator)).toBe( + normalizeName(expectedPath) + ); + } + + function testError(name: string) { + expect(() => FileUtils.getPath(name, separator)).toThrow(); + } + + testName('/', '/'); + testName('/foo.txt', '/'); + testName('/foo/', '/foo/'); + testName('/foo/bar.txt', '/foo/'); + testName('/foo/bar/baz.txt', '/foo/bar/'); + + testError('nopath.txt'); + testError('nopath'); + testError('invalid/path'); + }); -describe('validateItemName for files', () => { - it('rejects for invalid names', () => { - testInvalidFileName(''); - testInvalidFileName('test/test'); - testInvalidFileName('test\\test'); - testInvalidFileName('test\0test'); + it('makePath handles names with or without trailing slash', () => { + const DEFAULT_DIR = 'DEFAULT_DIR'; + const DEFAULT_PATH = `${DEFAULT_DIR}${separator}`; + expect(FileUtils.makePath(DEFAULT_DIR, separator)).toBe(DEFAULT_PATH); + expect(FileUtils.makePath(DEFAULT_PATH, separator)).toBe(DEFAULT_PATH); }); - it('rejects with correct error messages', () => { - // Prints invalid char only once for all matches - testInvalidFileNameMessage( - '/test/test/', - 'Invalid characters in name: "/"' - ); - // Prints all invalid chars - testInvalidFileNameMessage( - '\\test\\test/', - 'Invalid characters in name: "\\", "/"' - ); - // Prints "null" for invisible "\0" char - testInvalidFileNameMessage( - '\0test\0test', - 'Invalid characters in name: "null"' - ); + it('gets the parent', () => { + function testName(name: string, expectedPath: string) { + expect(FileUtils.getParent(normalizeName(name), separator)).toBe( + normalizeName(expectedPath) + ); + } + + function testError(name: string) { + expect(() => FileUtils.getParent(name, separator)).toThrow(); + } + + testName('/foo.txt', '/'); + testName('/foo/', '/'); + testName('/foo/bar.txt', '/foo/'); + testName('/foo/bar/', '/foo/'); + testName('/foo/bar/baz.txt', '/foo/bar/'); + + testError('/'); + testError('nopath.txt'); + testError('nopath'); + testError('invalid/path'); }); - it('rejects for reserved names', () => { - testInvalidFileName('.'); - testInvalidFileName('..'); + it('gets the filename', () => { + function testName(name: string, expectedName: string) { + expect(FileUtils.getBaseName(normalizeName(name), separator)).toBe( + expectedName + ); + } + + testName('/', ''); + testName('/foo.txt', 'foo.txt'); + testName('/foo/', ''); + testName('/foo/bar.txt', 'bar.txt'); + testName('/foo/bar/baz.txt', 'baz.txt'); }); - it('resolves for valid name', () => { - testValidFileName('Currencies $ € 円 ₽ £ ₤'); - testValidFileName('Special chars , . * % # @ ↹ <>'); - testValidFileName('Iñtërnâtiônàližætiøn'); - testValidFileName('♈ ♉ ♊ ♋ ♌ ♍ ♎ ♏ ♐ ♑ ♒ ♓'); - testValidFileName('name.extension'); - testValidFileName('name-no-extension'); - testValidFileName('.extension-no-name'); + it('gets the depth', () => { + function testName(name: string, expectedDepth: number) { + expect(FileUtils.getDepth(normalizeName(name), separator)).toBe( + expectedDepth + ); + } + + function testError(name: string) { + expect(() => FileUtils.getDepth(name, separator)).toThrow(); + } + + testName('/', 0); + testName('/foo.txt', 0); + testName('/foo/bar.txt', 1); + testName('/foo/bar/baz.txt', 2); + + testError(''); + testError('invalid/file'); + testError('nopath'); + testError('nopath.txt'); }); -}); -it('reduces a selection', () => { - function testReduction(paths: string[], expectedPaths: string[]) { - expect(FileUtils.reducePaths(paths)).toEqual(expectedPaths); - } + describe('validateItemName for files', () => { + it('rejects for invalid names', () => { + testInvalidFileName(''); + testInvalidFileName('test/test'); + testInvalidFileName('test\\test'); + testInvalidFileName('test\0test'); + }); + + it('rejects with correct error messages', () => { + // Prints invalid char only once for all matches + testInvalidFileNameMessage( + '/test/test/', + 'Invalid characters in name: "/"' + ); + // Prints all invalid chars + testInvalidFileNameMessage( + '\\test\\test/', + 'Invalid characters in name: "\\", "/"' + ); + // Prints "null" for invisible "\0" char + testInvalidFileNameMessage( + '\0test\0test', + 'Invalid characters in name: "null"' + ); + }); + + it('rejects for reserved names', () => { + testInvalidFileName('.'); + testInvalidFileName('..'); + }); + + it('resolves for valid name', () => { + testValidFileName('Currencies $ € 円 ₽ £ ₤'); + testValidFileName('Special chars , . * % # @ ↹ <>'); + testValidFileName('Iñtërnâtiônàližætiøn'); + testValidFileName('♈ ♉ ♊ ♋ ♌ ♍ ♎ ♏ ♐ ♑ ♒ ♓'); + testValidFileName('name.extension'); + testValidFileName('name-no-extension'); + testValidFileName('.extension-no-name'); + }); + }); - testReduction([], []); - testReduction(['/'], ['/']); - testReduction(['/', '/foo/', '/foo/bar.txt'], ['/']); - testReduction(['/foo/', '/foo/bar.txt'], ['/foo/']); - testReduction( - ['/foo/baz.txt', '/foo/bar.txt'], - ['/foo/baz.txt', '/foo/bar.txt'] - ); + it('reduces a selection', () => { + function testReduction(paths: string[], expectedPaths: string[]) { + expect( + FileUtils.reducePaths(paths.map(normalizeName), separator) + ).toEqual(expectedPaths.map(normalizeName)); + } + + testReduction([], []); + testReduction(['/'], ['/']); + testReduction(['/', '/foo/', '/foo/bar.txt'], ['/']); + testReduction(['/foo/', '/foo/bar.txt'], ['/foo/']); + testReduction( + ['/foo/baz.txt', '/foo/bar.txt'], + ['/foo/baz.txt', '/foo/bar.txt'] + ); + }); }); describe('removeRoot', () => { diff --git a/packages/file-explorer/src/FileUtils.ts b/packages/file-explorer/src/FileUtils.ts index 3d031452f0..159c0e91c8 100644 --- a/packages/file-explorer/src/FileUtils.ts +++ b/packages/file-explorer/src/FileUtils.ts @@ -53,7 +53,7 @@ export class FileUtils { if (!FileUtils.hasPath(name, separator)) { throw new Error(`Invalid path provided: ${name}`); } - const matches = name.match(/\//g) ?? []; + const matches = name.match(new RegExp(`\\${separator}`, 'g')) ?? []; return matches.length - 1; } @@ -61,10 +61,11 @@ export class FileUtils { * Get just the extension of file name. * Note that it just returns the last extension, so 'example.tar.gz' would just return 'gz'. * @param name The file name with or without path to get the extension of + * @param separator The separator to use for the path * @returns The file extension */ - static getExtension(name: string): string { - const components = this.getBaseName(name).split('.'); + static getExtension(name: string, separator: string): string { + const components = this.getBaseName(name, separator).split('.'); if (components.length > 1) { return components.pop() ?? ''; } @@ -117,10 +118,11 @@ export class FileUtils { /** * Return a MIME type for the provided file * @param name The file name to get the type for + * @param separator The separator to use for the path * @returns A known MIME type if recognized */ - static getMimeType(name: string): MIME_TYPE { - const extension = this.getExtension(name).toLowerCase(); + static getMimeType(name: string, separator: string): MIME_TYPE { + const extension = this.getExtension(name, separator).toLowerCase(); switch (extension) { case 'groovy': return MIME_TYPE.GROOVY; diff --git a/packages/file-explorer/src/NewItemModal.tsx b/packages/file-explorer/src/NewItemModal.tsx index f7209edbb3..89392761fb 100644 --- a/packages/file-explorer/src/NewItemModal.tsx +++ b/packages/file-explorer/src/NewItemModal.tsx @@ -102,18 +102,19 @@ class NewItemModal extends PureComponent { this.handleBreadcrumbSelect = this.handleBreadcrumbSelect.bind(this); const { defaultValue, storage } = props; + const { separator } = storage; - const path = FileUtils.hasPath(defaultValue) - ? FileUtils.getPath(defaultValue) - : storage.separator; + const path = FileUtils.hasPath(defaultValue, separator) + ? FileUtils.getPath(defaultValue, separator) + : separator; this.state = { isSubmitting: false, path, - prevExtension: FileUtils.getExtension(defaultValue), + prevExtension: FileUtils.getExtension(defaultValue, separator), showExtensionChangeModal: false, showOverwriteModal: false, - value: FileUtils.getBaseName(defaultValue), + value: FileUtils.getBaseName(defaultValue, separator), }; } @@ -164,14 +165,15 @@ class NewItemModal extends PureComponent { resetValue(): void { const { defaultValue, storage } = this.props as NewItemModalProps; - const path = FileUtils.hasPath(defaultValue) - ? FileUtils.getPath(defaultValue) + const { separator } = storage; + const path = FileUtils.hasPath(defaultValue, separator) + ? FileUtils.getPath(defaultValue, separator) : storage.separator; this.setState({ path, - value: FileUtils.getBaseName(defaultValue), + value: FileUtils.getBaseName(defaultValue, separator), validationError: undefined, - prevExtension: FileUtils.getExtension(defaultValue), + prevExtension: FileUtils.getExtension(defaultValue, separator), isSubmitting: false, }); } @@ -239,13 +241,15 @@ class NewItemModal extends PureComponent { } handleSelect(item: FileStorageItem): void { + const { storage } = this.props; + const { separator } = storage; log.debug('handleSelect', item); if (item.type === 'directory') { - this.setState({ path: FileUtils.makePath(item.filename) }); + this.setState({ path: FileUtils.makePath(item.filename, separator) }); } else { // Use selected item name and folder and focus the input const value = item.basename; - const path = FileUtils.getPath(item.filename); + const path = FileUtils.getPath(item.filename, separator); this.setState({ value, path }, () => { this.focusRenameInput(); }); @@ -337,9 +341,10 @@ class NewItemModal extends PureComponent { submitModal(skipExtensionCheck = false): void { this.setState(({ prevExtension, value, path }) => { - const { notifyOnExtensionChange, type } = this.props; + const { notifyOnExtensionChange, storage, type } = this.props; + const { separator } = storage; log.debug('submitModal', prevExtension, value); - const newExtension = FileUtils.getExtension(value); + const newExtension = FileUtils.getExtension(value, separator); if ( notifyOnExtensionChange && !skipExtensionCheck && @@ -425,7 +430,7 @@ class NewItemModal extends PureComponent { } render(): React.ReactNode { - const { storage, isOpen, onCancel, placeholder, title, type } = this.props; + const { isOpen, onCancel, placeholder, title, type } = this.props; const { isSubmitting, path, @@ -486,7 +491,6 @@ class NewItemModal extends PureComponent {
diff --git a/packages/file-explorer/src/index.ts b/packages/file-explorer/src/index.ts index 270532e952..f07ed54060 100644 --- a/packages/file-explorer/src/index.ts +++ b/packages/file-explorer/src/index.ts @@ -6,11 +6,14 @@ export * from './FileList'; export * from './FileListItem'; export * from './FileListUtils'; export * from './FileStorage'; +export * from './FileStorageContext'; export { default as FileExistsError } from './FileExistsError'; export { default as FileNotFoundError } from './FileNotFoundError'; export { default as SHORTCUTS } from './FileExplorerShortcuts'; export { default as NewItemModal } from './NewItemModal'; export { default as FileExplorerToolbar } from './FileExplorerToolbar'; export { default as FileUtils } from './FileUtils'; +export { default as useFileSeparator } from './useFileSeparator'; +export { default as useFileStorage } from './useFileStorage'; export default FileExplorer; diff --git a/packages/file-explorer/src/useFileSeparator.ts b/packages/file-explorer/src/useFileSeparator.ts new file mode 100644 index 0000000000..62be8a62e1 --- /dev/null +++ b/packages/file-explorer/src/useFileSeparator.ts @@ -0,0 +1,11 @@ +import useFileStorage from './useFileStorage'; + +/** + * Get the file separator used in this file system + * @returns File separator used in this file system + */ +function useFileSeparator(): string { + return useFileStorage().separator; +} + +export default useFileSeparator; diff --git a/packages/file-explorer/src/useFileStorage.ts b/packages/file-explorer/src/useFileStorage.ts new file mode 100644 index 0000000000..28778382a4 --- /dev/null +++ b/packages/file-explorer/src/useFileStorage.ts @@ -0,0 +1,16 @@ +import { useContextOrThrow } from '@deephaven/react-hooks'; +import FileStorage from './FileStorage'; +import FileStorageContext from './FileStorageContext'; + +/** + * Hook to get the FileStorage instance from the context. Throws if no provider available. + * @returns FileStorage instance + */ +function useFileStorage(): FileStorage { + return useContextOrThrow( + FileStorageContext, + 'No FileStorageContext available. Was code wrapped in a provider?' + ); +} + +export default useFileStorage;