diff --git a/.changeset/blue-pugs-roll.md b/.changeset/blue-pugs-roll.md new file mode 100644 index 00000000..c8ad1acb --- /dev/null +++ b/.changeset/blue-pugs-roll.md @@ -0,0 +1,32 @@ +--- +'@difizen/libro-cofine-editor-contribution': patch +'@difizen/libro-cofine-editor-core': patch +'@difizen/libro-search-code-cell': patch +'@difizen/libro-cofine-textmate': patch +'@difizen/libro-cofine-editor': patch +'@difizen/libro-markdown-cell': patch +'@difizen/libro-shared-model': patch +'@difizen/libro-code-editor': patch +'@difizen/libro-prompt-cell': patch +'@difizen/libro-virtualized': patch +'@difizen/libro-codemirror': patch +'@difizen/libro-rendermime': patch +'@difizen/libro-code-cell': patch +'@difizen/libro-markdown': patch +'@difizen/libro-raw-cell': patch +'@difizen/libro-terminal': patch +'@difizen/libro-jupyter': patch +'@difizen/libro-common': patch +'@difizen/libro-kernel': patch +'@difizen/libro-output': patch +'@difizen/libro-search': patch +'@difizen/libro-widget': patch +'@difizen/libro-core': patch +'@difizen/libro-l10n': patch +'@difizen/libro-lab': patch +'@difizen/libro-lsp': patch +'@difizen/libro-toc': patch +'@difizen/libro-docs': patch +--- + +File uploads can now be triggered by right-clicking on the file tree. diff --git a/packages/libro-jupyter/src/file/file-command.tsx b/packages/libro-jupyter/src/file/file-command.tsx index 3b6c2e97..42b0c5f0 100644 --- a/packages/libro-jupyter/src/file/file-command.tsx +++ b/packages/libro-jupyter/src/file/file-command.tsx @@ -79,6 +79,10 @@ export const FileCommands = { id: 'fileTree.command.download', label: '下载', }, + UPLOAD: { + id: 'fileTree.command.upload', + label: '上传', + }, }; export const FileTreeContextMenuPath: MenuPath = ['file-tree-context-menu']; export const FileTreeContextMenuGroups: Record = { @@ -105,6 +109,7 @@ export class FileCommandContribution lastAction: 'COPY' | 'CUT'; lastActionNode: FileStatNode; allowDownload = false; + allowUpload = false; constructor(@inject(ViewManager) viewManager: ViewManager) { this.viewManager = viewManager; @@ -180,6 +185,11 @@ export class FileCommandContribution command: FileCommands.DOWNLOAD.id, order: 'h', }); + menu.registerMenuAction(FileTreeContextMenuGroups['extra'], { + id: FileCommands.UPLOAD.id, + command: FileCommands.UPLOAD.id, + order: 'i', + }); } registerCommands(command: CommandRegistry): void { command.registerCommand(FileCommands.OPEN_FILE, { @@ -356,6 +366,11 @@ export class FileCommandContribution this.contentsManager .getDownloadUrl(path) .then((url) => { + const urlObj = new URL(url); + if (urlObj.origin !== location.origin) { + // not same origin + return; + } const element = document.createElement('a'); element.href = url; element.download = ''; @@ -368,7 +383,36 @@ export class FileCommandContribution .catch(console.error); }, isVisible: (data) => { - return this.allowDownload && FileStatNode.is(data); + return this.allowDownload && FileStatNode.is(data) && data.fileStat.isFile; + }, + }); + + command.registerCommand(FileCommands.UPLOAD, { + execute: (data, view) => { + if (!this.allowUpload) { + return; + } + if (!view || !(view instanceof FileView)) { + return; + } + if (!data || data instanceof FileView) { + return view.uploadSubmit(); + } + if (FileStatNode.is(data) && data.fileStat.isDirectory) { + return view.uploadSubmit(data.uri.path.toString()); + } + }, + isVisible: (data, view) => { + if (!this.allowUpload) { + return false; + } + if (!view || !(view instanceof FileView)) { + return false; + } + if (!data || data instanceof FileView) { + return true; + } + return FileStatNode.is(data) && data.fileStat.isDirectory; }, }); } diff --git a/packages/libro-jupyter/src/file/file-view/index.tsx b/packages/libro-jupyter/src/file/file-view/index.tsx index d56a0567..c7f9175d 100644 --- a/packages/libro-jupyter/src/file/file-view/index.tsx +++ b/packages/libro-jupyter/src/file/file-view/index.tsx @@ -1,4 +1,6 @@ import { ExclamationCircleFilled, FolderFilled } from '@ant-design/icons'; +import { ContentsManager } from '@difizen/libro-kernel'; +import type { IContentsModel } from '@difizen/libro-kernel'; import type { TreeNode, ViewOpenHandler } from '@difizen/mana-app'; import { FileTreeViewFactory } from '@difizen/mana-app'; import { @@ -39,7 +41,9 @@ const noVerifyFileType = ['.ipynb', '.py']; @view(FileTreeViewFactory, FileTreeModule) export class FileView extends FileTreeView { @inject(OpenerService) protected openService: OpenerService; + @inject(ContentsManager) protected contentsManager: ContentsManager; @inject(CommandRegistry) protected command: CommandRegistry; + uploadInput?: HTMLInputElement; override id = FileTreeViewFactory; override className = 'libro-jupyter-file-tree'; @@ -64,6 +68,92 @@ export class FileView extends FileTreeView { this.toDispose.push(this.model.onOpenNode(this.openNode)); } + override onViewMount(): void { + super.onViewMount?.(); + if (!this.container?.current) { + return; + } + const container = this.container.current; + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.onclick = this.onInputClicked; + input.onchange = this.onInputChanged; + input.style.display = 'none'; + container.appendChild(input); + this.uploadInput = input; + } + + uploadSubmit = (basePath?: string) => { + if (this.uploadInput) { + this.uploadInput.setAttribute('data-path', basePath || ''); + this.uploadInput.click(); + } + }; + /** + * Perform the actual upload. + */ + protected async doUpload(file: File, basePath: string): Promise { + // Gather the file model parameters. + let path = basePath; + path = path ? path + '/' + file.name : file.name; + const name = file.name; + const type = 'file'; + const format = 'base64'; + + const uploadInner = async (blob: Blob, chunk?: number): Promise => { + 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); + }); + + // remove header https://stackoverflow.com/a/24289420/907060 + const content = (reader.result as string).split(',')[1]; + + const model: Partial = { + type, + format, + name, + chunk, + content, + }; + return await this.contentsManager.save(path, model); + }; + + return await uploadInner(file); + } + + onInputChanged = () => { + if (!this.uploadInput) { + return; + } + let path = this.uploadInput.getAttribute('data-path') || ''; + if (!path) { + path = this.model.location?.path.toString() || ''; + } + if (!path) { + return; + } + const files = Array.prototype.slice.call(this.uploadInput.files) as File[]; + const pending = files.map((file) => this.doUpload(file, path)); + Promise.all(pending) + .then(() => { + this.model.refresh(); + return; + }) + .catch((error) => { + console.error('Upload Error:', error); + }); + }; + + onInputClicked = () => { + if (this.uploadInput) { + this.uploadInput.value = ''; + } + }; + openNode = async (treeNode: TreeNode) => { if (FileStatNode.is(treeNode) && !treeNode.fileStat.isDirectory) { if (