import { Breadcrumb, concatStyleSets, ContextualMenu, ContextualMenuItem, DetailsListLayoutMode, Dialog, DirectionalHint, IBreadcrumbItem, IColumn, Icon, IContextualMenuItem, IDetailsHeaderProps, IDetailsList, IRenderFunction, Layer, MarqueeSelection, mergeStyleSets, Overlay, ProgressIndicator, ScrollToMode, Selection, ShimmeredDetailsList, Stack, StackItem } from '@fluentui/react'; import { FileIconType, getFileTypeIconProps, initializeFileTypeIcons } from '@fluentui/react-file-type-icons'; import { useConst } from '@fluentui/react-hooks'; import { AdbSyncEntryResponse, AdbSyncMaxPacketSize, LinuxFileType } from '@yume-chan/adb'; import { makeAutoObservable, observable, reaction, runInAction } from "mobx"; import { observer } from "mobx-react-lite"; import { NextPage } from "next"; import Head from "next/head"; import Router, { useRouter } from "next/router"; import path from 'path'; import React, { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { CommandBar, ErrorDialogContext } from '../components'; import { global } from '../state'; import { asyncEffect, chunkFile, formatSize, formatSpeed, pickFile, RouteStackProps, useSpeed } from '../utils'; let StreamSaver: typeof import('streamsaver'); if (typeof window !== 'undefined') { StreamSaver = require('streamsaver'); StreamSaver.mitm = 'streamsaver/mitm.html'; } initializeFileTypeIcons(); interface ListItem extends AdbSyncEntryResponse { key: string; } function toListItem(item: AdbSyncEntryResponse): ListItem { return { ...item, key: item.name! }; } const classNames = mergeStyleSets({ name: { cursor: 'pointer', '&:hover': { textDecoration: 'underline', }, }, }); const renderDetailsHeader: IRenderFunction = (props?, defaultRender?) => { if (!props || !defaultRender) { return null; } return defaultRender({ ...props, styles: concatStyleSets(props.styles, { root: { paddingTop: 0 } }) }); }; function createReadableStreamFromBufferIterator( iterator: AsyncIterator ): ReadableStream { return new ReadableStream({ async pull(controller) { const { desiredSize } = controller; if (!desiredSize || desiredSize < 0) { return; } let written = 0; while (written < desiredSize) { const result = await iterator.next(); if (result.done) { controller.close(); return; } controller.enqueue(new Uint8Array(result.value)); written += result.value.byteLength; } }, }); } function compareCaseInsensitively(a: string, b: string) { let result = a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()); if (result !== 0) { return result; } else { return a.localeCompare(b); } } class FileManagerState { path = '/'; loading = false; items: ListItem[] = []; sortKey: keyof ListItem = 'name'; sortDescending = false; startItemIndexInView = 0; constructor() { makeAutoObservable(this, { items: observable.shallow, }); reaction( () => global.device, () => this.loadFiles(), { fireImmediately: true }, ); } get breadcrumbItems(): IBreadcrumbItem[] { let part = ''; const list: IBreadcrumbItem[] = this.path.split('/').filter(Boolean).map(segment => { part += '/' + segment; return { key: part, text: segment, onClick: (e, item) => { if (!item) { return; } this.pushPathQuery(item.key); }, }; }); list.unshift({ key: '/', text: 'Device', onClick: () => this.pushPathQuery('/'), }); list[list.length - 1].isCurrentItem = true; delete list[list.length - 1].onClick; return list; } pushPathQuery(path: string) { Router.push({ query: { ...Router.query, path } }); } get sortedList() { const list = this.items.slice(); list.sort((a, b) => { const aIsFile = a.type === LinuxFileType.File ? 1 : 0; const bIsFile = b.type === LinuxFileType.File ? 1 : 0; let result: number; if (aIsFile !== bIsFile) { result = aIsFile - bIsFile; } else { const aSortKey = a[this.sortKey]!; const bSortKey = b[this.sortKey]!; if (aSortKey === bSortKey) { result = compareCaseInsensitively(a.name!, b.name!); } else if (typeof aSortKey === 'string') { result = compareCaseInsensitively(aSortKey, bSortKey as string); } else { result = aSortKey < bSortKey ? -1 : 1; } } if (this.sortDescending) { result *= -1; } return result; }); return list; } get columns(): IColumn[] { const list: IColumn[] = [ { key: 'type', name: 'File Type', iconName: 'Page', isIconOnly: true, minWidth: 20, maxWidth: 20, isCollapsible: true, onRender(item: AdbSyncEntryResponse) { switch (item.type) { case LinuxFileType.Link: return ; case LinuxFileType.Directory: return ; case LinuxFileType.File: return ; default: return ; } } }, { key: 'name', name: 'Name', minWidth: 0, isRowHeader: true, onRender(item: AdbSyncEntryResponse) { return ( {item.name} ); } }, { key: 'permission', name: 'Permission', minWidth: 0, isCollapsible: true, onRender(item: AdbSyncEntryResponse) { return `${(item.mode >> 6 & 0b100).toString(8)}${(item.mode >> 3 & 0b100).toString(8)}${(item.mode & 0b100).toString(8)}`; } }, { key: 'size', name: 'Size', minWidth: 0, isCollapsible: true, onRender(item: AdbSyncEntryResponse) { if (item.type === LinuxFileType.File) { return formatSize(item.size); } return ''; } }, { key: 'mtime', name: 'Last Modified Time', minWidth: 150, isCollapsible: true, onRender(item: AdbSyncEntryResponse) { return new Date(item.mtime * 1000).toLocaleString(); }, } ]; for (const item of list) { item.onColumnClick = (e, column) => { if (this.sortKey === column.key) { runInAction(() => this.sortDescending = !this.sortDescending); } else { runInAction(() => { this.sortKey = column.key as keyof ListItem; this.sortDescending = false; }); } }; if (item.key === this.sortKey) { item.isSorted = true; item.isSortedDescending = this.sortDescending; } } return list; } changeDirectory(path: string) { if (this.path === path) { return; } this.path = path; this.loadFiles(); } loadFiles = asyncEffect(async (signal) => { const currentPath = this.path; runInAction(() => this.items = []); if (!global.device) { return; } runInAction(() => this.loading = true); const sync = await global.device.sync(); const items: ListItem[] = []; const linkItems: AdbSyncEntryResponse[] = []; const intervalId = setInterval(() => { if (signal.aborted) { return; } runInAction(() => this.items = items.slice()); }, 1000); try { for await (const entry of sync.opendir(currentPath)) { if (signal.aborted) { return; } if (entry.name === '.' || entry.name === '..') { continue; } if (entry.type === LinuxFileType.Link) { linkItems.push(entry); } else { items.push(toListItem(entry)); } } for (const entry of linkItems) { if (signal.aborted) { return; } if (!await sync.isDirectory(path.resolve(currentPath, entry.name!))) { entry.mode = (LinuxFileType.File << 12) | entry.permission; entry.size = 0; } items.push(toListItem(entry)); } if (signal.aborted) { return; } runInAction(() => this.items = items); } finally { if (!signal.aborted) { runInAction(() => this.loading = false); } clearInterval(intervalId); sync.dispose(); } }); } const state = new FileManagerState(); const FileManager: NextPage = (): JSX.Element | null => { const router = useRouter(); useEffect(() => { let pathQuery = router.query.path; if (!pathQuery) { router.replace({ query: { ...router.query, path: state.path } }); return; } if (Array.isArray(pathQuery)) { pathQuery = pathQuery[0]; } state.changeDirectory(pathQuery); }, [router.query.path]); const { show: showErrorDialog } = useContext(ErrorDialogContext); const listRef = useRef(null); useLayoutEffect(() => { const list = listRef.current; return () => { state.startItemIndexInView = list?.getStartItemIndexInView() ?? 0; }; }, []); const scrolledRef = useRef(false); const handleListUpdate = useCallback((list?: IDetailsList) => { if (!scrolledRef.current) { console.log('scroll', state.startItemIndexInView); list?.scrollToIndex(state.startItemIndexInView, undefined, ScrollToMode.top); scrolledRef.current = true; } }, []); const [previewUrl, setPreviewUrl] = useState(); const previewImage = useCallback(async (path: string) => { const sync = await global.device!.sync(); try { const readableStream = createReadableStreamFromBufferIterator(sync.read(path)); const response = new Response(readableStream); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); setPreviewUrl(url); } finally { sync.dispose(); } }, []); const hidePreview = useCallback(() => { setPreviewUrl(undefined); }, []); const handleItemInvoked = useCallback((item: AdbSyncEntryResponse) => { switch (item.type) { case LinuxFileType.Link: case LinuxFileType.Directory: state.pushPathQuery(path.resolve(state.path!, item.name!)); break; case LinuxFileType.File: switch (path.extname(item.name!)) { case '.jpg': case '.png': case '.svg': case '.gif': previewImage(path.resolve(state.path!, item.name!)); break; } break; } }, [previewImage]); const [selectedItems, setSelectedItems] = useState([]); const selection = useConst(() => new Selection({ onSelectionChanged() { const selectedItems = selection.getSelection() as ListItem[]; setSelectedItems(selectedItems); }, })); const [uploading, setUploading] = useState(false); const [uploadPath, setUploadPath] = useState(''); const [uploadedSize, setUploadedSize] = useState(0); const [uploadTotalSize, setUploadTotalSize] = useState(0); const [debouncedUploadedSize, uploadSpeed] = useSpeed(uploadedSize, uploadTotalSize); const upload = useCallback(async (file: File) => { const sync = await global.device!.sync(); try { const itemPath = path.resolve(state.path!, file.name); setUploading(true); setUploadPath(file.name); setUploadTotalSize(file.size); await sync.write( itemPath, chunkFile(file, AdbSyncMaxPacketSize), (LinuxFileType.File << 12) | 0o666, file.lastModified / 1000, setUploadedSize, ); } catch (e) { showErrorDialog(e instanceof Error ? e.message : `${e}`); } finally { sync.dispose(); state.loadFiles(); setUploading(false); } }, [showErrorDialog]); const [menuItems, setMenuItems] = useState([]); useEffect(() => { let result: IContextualMenuItem[] = []; switch (selectedItems.length) { case 0: result.push({ key: 'upload', text: 'Upload', iconProps: { iconName: 'Upload' }, disabled: !global.device, onClick() { (async () => { const files = await pickFile({ multiple: true }); for (let i = 0; i < files.length; i++) { const file = files.item(i)!; await upload(file); } })(); return false; } }); break; case 1: if (selectedItems[0].type === LinuxFileType.File) { result.push({ key: 'download', text: 'Download', iconProps: { iconName: 'Download' }, onClick() { (async () => { const sync = await global.device!.sync(); try { const itemPath = path.resolve(state.path, selectedItems[0].name!); const readableStream = createReadableStreamFromBufferIterator(sync.read(itemPath)); const writeableStream = StreamSaver!.createWriteStream(selectedItems[0].name!, { size: selectedItems[0].size, }); await readableStream.pipeTo(writeableStream); } catch (e) { showErrorDialog(e instanceof Error ? e.message : `${e}`); } finally { sync.dispose(); } })(); return false; }, }); } default: result.push({ key: 'delete', text: 'Delete', iconProps: { iconName: 'Delete' }, onClick() { (async () => { try { for (const item of selectedItems) { const output = await global.device!.rm(path.resolve(state.path, item.name!)); if (output) { showErrorDialog(output); return; } } } catch (e) { showErrorDialog(e instanceof Error ? e.message : `${e}`); } finally { state.loadFiles(); } })(); return false; } }); break; } setMenuItems(result); }, [selectedItems, upload, showErrorDialog]); const [contextMenuTarget, setContextMenuTarget] = useState(); const showContextMenu = useCallback(( _item?: AdbSyncEntryResponse, _index?: number, e?: Event ) => { if (!e) { return false; } if (menuItems.length) { setContextMenuTarget(e as MouseEvent); } return false; }, [menuItems]); const hideContextMenu = useCallback(() => { setContextMenuTarget(undefined); }, []); return ( File Manager - WebADB {previewUrl && (
)}
); }; export default observer(FileManager);