@datalayer/core
Version:
[](https://datalayer.io)
263 lines (262 loc) • 12.7 kB
JavaScript
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
/*
* Copyright (c) 2023-2025 Datalayer, Inc.
* Distributed under the terms of the Modified BSD License.
*/
import { useCallback, useEffect, useState } from 'react';
import { PathExt } from '@jupyterlab/coreutils';
import { PromiseDelegate } from '@lumino/coreutils';
import { ActionList, ActionMenu, Heading, IconButton, Spinner, TreeView, } from '@primer/react';
import { Box } from '@datalayer/primer-addons';
import { Blankslate, Dialog } from '@primer/react/experimental';
import { CounterClockWiseIcon } from '@datalayer/icons-react';
import { useIsMounted } from 'usehooks-ts';
import { useToast } from '../../hooks';
import { UploadIconButton } from '../buttons';
import { DirectoryItem, TreeItem, modelToView, } from './ContentsItems';
/**
* The maximum upload size (in bytes) for notebook version < 5.1.0
*/
export const LARGE_FILE_SIZE = 15 * 1024 * 1024;
/**
* The size (in bytes) of the biggest chunk we should upload at once.
*/
export const CHUNK_SIZE = 1024 * 1024;
/**
* Storage browser component.
*/
export function ContentsBrowser(props) {
const { contents, localContents, documentRegistry } = props;
const isMounted = useIsMounted();
const { trackAsyncTask } = useToast();
const [children, setChildren] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [selectedItem, setSelectedItem] = useState(null);
const [contextMenuAnchor, setContextMenuAnchor] = useState(null);
const [openDeleteConfirmation, setOpenDeleteConfirmation] = useState(false);
const [copyToLocalConfirmation, setCopyToLocalConfirmation] = useState(false);
const refresh = useCallback(() => {
contents
.get('')
.then(model => {
setIsLoading(false);
setChildren(modelToView(model.content, documentRegistry));
})
.catch(reason => {
setIsLoading(false);
console.error(`Failed to fetch folder '' content for manager ${contents.serverSettings.appUrl}.`, reason);
});
}, [contents]);
useEffect(() => {
refresh();
}, [refresh]);
const upload = useCallback(
/**
* @param file File to upload
*/
async (file) => {
const checkIsMounted = () => {
if (!isMounted()) {
return Promise.reject(`Failed to upload ${file.name}; StorageBrowser component is unmounted.`);
}
};
checkIsMounted();
const chunked = file.size > CHUNK_SIZE;
const currentDirectory = selectedItem
? selectedItem.type === 'directory'
? selectedItem.path
: PathExt.dirname(selectedItem.path)
: '';
const path = currentDirectory
? PathExt.join(currentDirectory, file.name)
: file.name;
const name = file.name;
const type = 'file';
const format = 'base64';
const uploadChunk = async (blob, chunk) => {
checkIsMounted();
const reader = new FileReader();
reader.readAsDataURL(blob);
await new Promise((resolve, reject) => {
reader.onload = resolve;
reader.onerror = event => reject(`Failed to upload "${file.name}":` + event);
});
checkIsMounted();
// remove header https://stackoverflow.com/a/24289420/907060
const content = reader.result.split(',')[1];
const model = {
type,
format,
name,
chunk,
content,
};
return await contents.save(path, model);
};
const toastOptions = {
error: {
message: reason => {
const msg = `Failed to upload ${file.name}.`;
console.error(msg, reason);
return msg;
},
},
pending: { message: `Uploading ${file.name}…` },
success: { message: () => `${file.name} uploaded.` },
};
if (chunked) {
const task = new PromiseDelegate();
trackAsyncTask(task.promise, toastOptions);
try {
let finalModel;
for (let start = 0; !finalModel; start += CHUNK_SIZE) {
const end = start + CHUNK_SIZE;
const lastChunk = end >= file.size;
const chunk = lastChunk ? -1 : end / CHUNK_SIZE;
const currentModel = await uploadChunk(file.slice(start, end), chunk);
if (lastChunk) {
finalModel = currentModel;
task.resolve(finalModel);
}
}
if (selectedItem) {
selectedItem.refresh();
}
else {
refresh();
}
return finalModel;
}
catch (error) {
task.reject(error);
throw error;
}
}
else {
const task = uploadChunk(file);
trackAsyncTask(task, toastOptions);
task.then(() => {
if (selectedItem) {
selectedItem.refresh();
}
else {
refresh();
}
});
return task;
}
}, [contents, selectedItem, refresh]);
const onContextMenu = useCallback((ref) => {
if (contextMenuAnchor === ref) {
setContextMenuAnchor(null);
}
else {
setContextMenuAnchor(ref);
}
}, [contextMenuAnchor]);
const onSelectDelete = useCallback(() => {
setOpenDeleteConfirmation(true);
}, []);
const deleteItem = useCallback(() => {
if (selectedItem) {
const task = contents.delete(selectedItem.path);
trackAsyncTask(task, {
success: { message: () => `${selectedItem.path} deleted.` },
pending: { message: `Deleting ${selectedItem.path}…` },
error: {
message: reason => {
const msg = `Failed to delete ${selectedItem.path}.`;
console.error(msg, reason);
return msg;
},
},
});
task.finally(() => {
selectedItem.refresh();
});
}
setOpenDeleteConfirmation(false);
}, [contents, selectedItem]);
const onSelectCopyToLocal = useCallback(() => {
setCopyToLocalConfirmation(true);
}, []);
const copyToLocal = useCallback(() => {
if (selectedItem && localContents) {
contents.get(selectedItem.path).then(model => {
const copyTask = localContents?.save(model.path, model);
trackAsyncTask(copyTask, {
success: { message: () => `${selectedItem.path} copied to local.` },
pending: { message: `Copying to local ${selectedItem.path}…` },
error: {
message: reason => {
const msg = `Failed to copy to local ${selectedItem.path}.`;
console.error(msg, reason);
return msg;
},
},
});
copyTask.finally(() => {
selectedItem.refresh();
});
setCopyToLocalConfirmation(false);
});
}
}, [localContents, selectedItem]);
const onSelect = useCallback((item, refresh) => {
setSelectedItem(item.path === selectedItem?.path ? null : { ...item, refresh });
}, [selectedItem]);
return (_jsxs(Box, { sx: { display: 'grid', gridTemplateAreas: `"header" "content"` }, children: [_jsxs(Box, { sx: { gridArea: 'header', display: 'flex', alignItems: 'center' }, children: [_jsx(Heading, { as: "h4", sx: {
fontSize: 'var(--text-title-size-small)',
lineHeight: 'var(--text-title-lineHeight-medium)',
fontWeight: 'var(--text-title-weight-medium)',
flex: '1 1 auto',
}, children: "Contents Browser" }), _jsxs(Box, { children: [_jsx(IconButton, { variant: "invisible", "aria-label": 'Refresh contents browser.', title: 'Refresh contents browser.', icon: CounterClockWiseIcon, onClick: refresh }), _jsx(UploadIconButton, { label: 'Upload a file', multiple: true, upload: upload })] })] }), isLoading ? (_jsx(Box, { sx: {
gridArea: 'content',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '40px',
height: '100vh',
}, children: _jsx(Spinner, {}) })) : (_jsx(Box, { sx: { gridArea: 'content' }, children: children ? (_jsxs(_Fragment, { children: [_jsx(TreeView, { children: children?.map(child => {
return child.type === 'directory' ? (_jsx(DirectoryItem, { item: child, contents: contents, current: selectedItem, documentRegistry: documentRegistry, onContextMenu: onContextMenu, onSelect: onSelect }, child.name)) : (_jsx(TreeItem, { item: child, current: selectedItem?.path === child.path, onSelect: item => {
onSelect(item, refresh);
}, onContextMenu: onContextMenu }, child.name));
}) }), _jsx(ActionMenu, { anchorRef: contextMenuAnchor ?? undefined, open: contextMenuAnchor?.current !== null, onOpenChange: () => {
setContextMenuAnchor(null);
}, children: _jsx(ActionMenu.Overlay, { children: _jsxs(ActionList, { children: [_jsx(ActionList.Item, { title: "Delete the active item.", onSelect: onSelectDelete, children: "Delete\u2026" }), localContents && (_jsx(ActionList.Item, { title: "Copy the active item to the local drive.", onSelect: onSelectCopyToLocal, children: "Copy to local drive\u2026" }))] }) }) }), openDeleteConfirmation && (_jsx(Dialog, { title: _jsx("span", { style: { color: 'var(--fgColor-default)' }, children: "Confirm deletion" }), onClose: () => {
setOpenDeleteConfirmation(false);
}, footerButtons: [
{
buttonType: 'default',
content: 'Cancel',
onClick: () => {
setOpenDeleteConfirmation(false);
},
},
{
buttonType: 'danger',
content: 'Delete',
onClick: () => {
deleteItem();
},
},
], children: `Are you sure you want to delete ${selectedItem?.path}?` })), copyToLocalConfirmation && (_jsx(Dialog, { title: _jsx("span", { style: { color: 'var(--fgColor-default)' }, children: "Confirm copy to local" }), onClose: () => {
setCopyToLocalConfirmation(false);
}, footerButtons: [
{
buttonType: 'default',
content: 'Cancel',
onClick: () => {
setCopyToLocalConfirmation(false);
},
},
{
buttonType: 'danger',
content: 'Copy to local',
onClick: () => {
copyToLocal();
},
},
], children: `Are you sure you want to copy to local ${selectedItem?.path}?` }))] })) : (_jsx(Blankslate, { children: _jsx(Blankslate.Heading, { children: "No contents" }) })) }))] }));
}
export default ContentsBrowser;