1072 lines
41 KiB
TypeScript
1072 lines
41 KiB
TypeScript
import {EventHandler, ReactEventHandler, Registry} from "tc-shared/events";
|
|
import {useEffect, useRef, useState} from "react";
|
|
import {FileType} from "tc-shared/file/FileManager";
|
|
import * as ppt from "tc-backend/ppt";
|
|
import {SpecialKey} from "tc-shared/PPTListener";
|
|
import {createErrorModal} from "tc-shared/ui/elements/Modal";
|
|
import {tra} from "tc-shared/i18n/localize";
|
|
import {network} from "tc-shared/ui/frames/chat";
|
|
import {Table, TableColumn, TableRow, TableRowElement} from "tc-shared/ui/react-elements/Table";
|
|
import {ReactComponentBase} from "tc-shared/ui/react-elements/ReactComponentBase";
|
|
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
|
import * as Moment from "moment";
|
|
import {MenuEntryType, spawn_context_menu} from "tc-shared/ui/elements/ContextMenu";
|
|
import {BoxedInputField} from "tc-shared/ui/react-elements/InputField";
|
|
import {
|
|
FileBrowserEvents,
|
|
FileTransferUrlMediaType,
|
|
ListedFileInfo,
|
|
TransferStatus
|
|
} from "tc-shared/ui/modal/transfer/ModalFileTransfer";
|
|
import * as log from "tc-shared/log";
|
|
import {LogCategory} from "tc-shared/log";
|
|
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
|
|
import React = require("react");
|
|
|
|
const cssStyle = require("./ModalFileTransfer.scss");
|
|
|
|
interface NavigationBarProperties {
|
|
currentPath: string;
|
|
events: Registry<FileBrowserEvents>;
|
|
}
|
|
|
|
interface NavigationBarState {
|
|
currentPath: string;
|
|
|
|
state: "editing" | "navigating" | "normal";
|
|
}
|
|
|
|
const ArrowRight = () => <div className={cssStyle.arrow}><div className={cssStyle.inner} /></div>;
|
|
|
|
const NavigationEntry = (props: { events: Registry<FileBrowserEvents>, path: string, name: string }) => {
|
|
const [dragHovered, setDragHovered] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if(!dragHovered)
|
|
return;
|
|
|
|
const dragListener = () => setDragHovered(false);
|
|
props.events.on("notify_drag_ended", dragListener);
|
|
return () => props.events.off("notify_drag_ended", dragListener);
|
|
});
|
|
|
|
return (
|
|
<a
|
|
className={(props.name.length > 9 ? cssStyle.pathShrink : "") + " " + (dragHovered ? cssStyle.hovered : "")}
|
|
title={props.name}
|
|
onClick={() => props.events.fire("action_navigate_to", { path: props.path })}
|
|
onDragOver={event => {
|
|
const types = event.dataTransfer.types;
|
|
if(types.length !== 1)
|
|
return;
|
|
|
|
if(types[0] === FileTransferUrlMediaType) {
|
|
/* TODO: Detect if its remote move or internal move */
|
|
event.dataTransfer.effectAllowed = "move";
|
|
} else if(types[0] === "Files") {
|
|
event.dataTransfer.effectAllowed = "copy";
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
setDragHovered(true);
|
|
}}
|
|
onDragLeave={() => setDragHovered(false)}
|
|
onDrop={event => {
|
|
const types = event.dataTransfer.types;
|
|
if(types.length !== 1)
|
|
return;
|
|
|
|
/* TODO: Fix this code duplicate! */
|
|
if(types[0] === FileTransferUrlMediaType) {
|
|
/* TODO: If cross move upload! */
|
|
const fileUrls = event.dataTransfer.getData(FileTransferUrlMediaType).split("&").map(e => decodeURIComponent(e));
|
|
for(const fileUrl of fileUrls) {
|
|
const name = fileUrl.split("/").last();
|
|
const oldPath = fileUrl.split("/").slice(0, -1).join("/") + "/";
|
|
|
|
props.events.fire("action_rename_file", {
|
|
newPath: props.path + "/",
|
|
oldPath: oldPath,
|
|
oldName: name,
|
|
newName: name
|
|
});
|
|
}
|
|
} else if(types[0] === "Files") {
|
|
props.events.fire("action_start_upload", { path: props.path, mode: "files", files: [...event.dataTransfer.files] });
|
|
} else {
|
|
log.warn(LogCategory.FILE_TRANSFER, tr("Received an unknown drop media type (%o)"), types);
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
}}
|
|
>{props.name}</a>
|
|
);
|
|
};
|
|
|
|
@ReactEventHandler(e => e.props.events)
|
|
export class NavigationBar extends ReactComponentBase<NavigationBarProperties, NavigationBarState> {
|
|
private refRendered = React.createRef<HTMLInputElement>();
|
|
private refInput = React.createRef<BoxedInputField>();
|
|
private ignoreBlur = false;
|
|
private lastSucceededPath = "";
|
|
|
|
protected defaultState(): NavigationBarState {
|
|
return {
|
|
currentPath: this.props.currentPath,
|
|
state: "normal",
|
|
}
|
|
}
|
|
|
|
render() {
|
|
let input;
|
|
let path = this.state.currentPath;
|
|
if(!path.endsWith("/"))
|
|
path += "/";
|
|
|
|
if(this.state.state === "editing") {
|
|
input = (
|
|
<BoxedInputField key={"nav-editing"}
|
|
ref={this.refInput}
|
|
defaultValue={path}
|
|
leftIcon={() =>
|
|
<div key={"left-icon"} className={cssStyle.directoryIcon + " " + cssStyle.containerIcon}>
|
|
<div className={"icon_em client-file_home"} />
|
|
</div>
|
|
}
|
|
|
|
rightIcon={() =>
|
|
<div key={"right-icon"} className={cssStyle.refreshIcon + " " + cssStyle.containerIcon}>
|
|
<div className={"icon_em client-refresh"} />
|
|
</div>
|
|
}
|
|
|
|
onChange={path => this.onPathEntered(path)}
|
|
onBlur={() => this.onInputPathBluer()}
|
|
/>
|
|
);
|
|
} else if(this.state.state === "navigating" || this.state.state === "normal") {
|
|
input = (
|
|
<BoxedInputField key={"nav-rendered"}
|
|
ref={this.refInput}
|
|
leftIcon={() =>
|
|
<div key={"left-icon"} className={cssStyle.directoryIcon + " " + cssStyle.containerIcon} onClick={event => this.onPathClicked(event, -1)}>
|
|
<div className={"icon_em client-file_home"} />
|
|
</div>
|
|
}
|
|
|
|
rightIcon={() =>
|
|
<div
|
|
key={"right-icon"}
|
|
className={cssStyle.refreshIcon + " " + (this.state.state === "normal" ? cssStyle.enabled : "") + " " + cssStyle.containerIcon}
|
|
onClick={() => this.onButtonRefreshClicked()} >
|
|
<div className={"icon_em client-refresh"} />
|
|
</div>
|
|
}
|
|
|
|
inputBox={() =>
|
|
<div key={"custom-input"} className={cssStyle.containerPath} ref={this.refRendered}>
|
|
{this.state.currentPath.split("/").filter(e => !!e).map((e, index, arr) => [
|
|
<ArrowRight key={"arrow-right-" + index + "-" + e} />,
|
|
<NavigationEntry key={"de-" + index + "-" + e} path={"/" + arr.slice(0, index + 1).join("/") + "/"} name={e} events={this.props.events} />
|
|
])}
|
|
</div>
|
|
}
|
|
|
|
editable={this.state.state === "normal"}
|
|
onFocus={() => this.onRenderedPathClicked()}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <div className={cssStyle.navigation}>{input}</div>;
|
|
}
|
|
|
|
componentDidUpdate(prevProps: Readonly<NavigationBarProperties>, prevState: Readonly<NavigationBarState>, snapshot?: any): void {
|
|
setTimeout(() => {
|
|
if(this.refRendered.current)
|
|
this.refRendered.current.scrollLeft = 999999;
|
|
}, 10);
|
|
}
|
|
|
|
private onPathClicked(event: React.MouseEvent, index: number) {
|
|
let path;
|
|
if(index === -1)
|
|
path = "/";
|
|
else
|
|
path = "/" + this.state.currentPath.split("/").filter(e => !!e).slice(0, index + 1).join("/") + "/";
|
|
this.props.events.fire("action_navigate_to", { path: path });
|
|
|
|
event.stopPropagation();
|
|
}
|
|
|
|
private onRenderedPathClicked() {
|
|
if(this.state.state !== "normal")
|
|
return;
|
|
|
|
this.setState({
|
|
state: "editing"
|
|
}, () => this.refInput.current?.focusInput());
|
|
}
|
|
|
|
private onInputPathBluer() {
|
|
if(this.state.state !== "editing" || this.ignoreBlur)
|
|
return;
|
|
|
|
this.setState({
|
|
state: "normal"
|
|
});
|
|
}
|
|
|
|
private onPathEntered(newPath: string) {
|
|
if(newPath === this.state.currentPath)
|
|
return;
|
|
|
|
this.ignoreBlur = true;
|
|
this.props.events.fire("action_navigate_to", { path: newPath });
|
|
this.setState({
|
|
currentPath: newPath
|
|
});
|
|
}
|
|
|
|
private onButtonRefreshClicked() {
|
|
if(this.state.state !== "normal")
|
|
return;
|
|
|
|
this.props.events.fire("action_navigate_to", { path: this.state.currentPath });
|
|
}
|
|
|
|
@EventHandler<FileBrowserEvents>("action_navigate_to")
|
|
private handleNavigateBegin() {
|
|
this.setState({
|
|
state: "navigating"
|
|
}, () => this.ignoreBlur = false);
|
|
}
|
|
|
|
@EventHandler<FileBrowserEvents>("action_navigate_to_result")
|
|
private handleNavigateResult(event: FileBrowserEvents["action_navigate_to_result"]) {
|
|
if(event.status === "success")
|
|
this.lastSucceededPath = event.path;
|
|
|
|
this.setState({
|
|
state: "normal",
|
|
currentPath: this.lastSucceededPath
|
|
});
|
|
|
|
if(event.status !== "success") {
|
|
if(event.status === "timeout") {
|
|
createErrorModal(tr("Failed to enter path"), tra("Failed to enter given path.{:br:}Action resulted in a timeout.")).open();
|
|
} else {
|
|
createErrorModal(tr("Failed to enter path"), tra("Failed to enter given path:{:br:}{0}", event.error)).open();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
interface FileListTableProperties {
|
|
currentPath: string;
|
|
events: Registry<FileBrowserEvents>;
|
|
}
|
|
|
|
interface FileListTableState {
|
|
state: "querying" | "normal" | "error" | "query-timeout" | "no-permissions" | "invalid-password";
|
|
errorMessage?: string;
|
|
}
|
|
|
|
const FileName = (props: { path: string, events: Registry<FileBrowserEvents>, file: ListedFileInfo }) => {
|
|
const [editing, setEditing] = useState(props.file.mode === "create");
|
|
const [fileName, setFileName] = useState(props.file.name);
|
|
const refInput = useRef<HTMLInputElement>();
|
|
|
|
let icon;
|
|
if(props.file.type === FileType.FILE) {
|
|
icon = <img key={"nicon"} src={"img/icon_file_text.svg"} alt={tr("File")} draggable={false} />;
|
|
} else {
|
|
switch (props.file.mode) {
|
|
case "normal":
|
|
icon = <img key={"nicon"} src={"img/icon_folder.svg"} alt={tr("Directory icon")} title={tr("Directory")} draggable={false} />;
|
|
break;
|
|
|
|
case "create":
|
|
case "creating":
|
|
case "empty":
|
|
icon = <img key={"nicon"} src={"img/icon_folder_empty.svg"} alt={tr("Empty directory icon")} title={tr("Empty directory")} draggable={false} />;
|
|
break;
|
|
|
|
case "password":
|
|
icon = <img key={"nicon"} src={"img/icon_folder_password.svg"} alt={tr("Directory password protected")} title={tr("Password protected directory")} draggable={false} />;
|
|
break;
|
|
|
|
default:
|
|
throw tr("Invalid directory state");
|
|
}
|
|
}
|
|
|
|
let name;
|
|
if(editing && props.file.mode !== "creating" && props.file.mode !== "uploading") {
|
|
name = <input
|
|
ref={refInput}
|
|
defaultValue={fileName}
|
|
|
|
onBlur={event => {
|
|
let name = event.target.value;
|
|
setEditing(false);
|
|
|
|
if(props.file.mode === "create") {
|
|
name = name || props.file.name;
|
|
|
|
props.events.fire("action_create_directory", {
|
|
path: props.path,
|
|
name: name
|
|
});
|
|
setFileName(name);
|
|
props.file.name = name;
|
|
props.file.mode = "creating";
|
|
} else {
|
|
if(name.length > 0 && name !== props.file.name) {
|
|
props.events.fire("action_rename_file", { oldName: props.file.name, newName: name, oldPath: props.path, newPath: props.path });
|
|
setFileName(name);
|
|
}
|
|
}
|
|
}}
|
|
|
|
onKeyPress={event => {
|
|
if(event.key === "Enter") {
|
|
event.currentTarget.blur();
|
|
return;
|
|
} else if(event.key === "/") {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}}
|
|
|
|
onPaste={event => {
|
|
const input = event.currentTarget;
|
|
setTimeout(() => {
|
|
input.value = input.value.replace("/", "");
|
|
});
|
|
}}
|
|
|
|
draggable={false}
|
|
/>;
|
|
} else {
|
|
name = <a key={"name"} onDoubleClick={event => {
|
|
if(props.file.virtual || props.file.mode === "creating" || props.file.mode === "uploading")
|
|
return;
|
|
|
|
if(!ppt.key_pressed(SpecialKey.SHIFT))
|
|
return;
|
|
|
|
event.stopPropagation();
|
|
props.events.fire("action_select_files", { mode: "exclusive", files: [{ name: props.file.name, type: props.file.type }]});
|
|
props.events.fire("action_start_rename", {
|
|
path: props.path,
|
|
name: props.file.name
|
|
});
|
|
}}>{fileName}</a>;
|
|
}
|
|
|
|
props.events.reactUse("action_start_rename", event => setEditing(event.name === props.file.name && event.path === props.path));
|
|
props.events.reactUse("action_rename_file_result", event => {
|
|
if(event.oldPath !== props.path || event.oldName !== props.file.name)
|
|
return;
|
|
|
|
if(event.status === "no-changes")
|
|
return;
|
|
|
|
if(event.status === "success") {
|
|
props.file.name = event.newName;
|
|
} else {
|
|
setFileName(props.file.name);
|
|
if(event.status === "timeout") {
|
|
createErrorModal(tr("Failed to rename file"), tra("Failed to rename file.{:br:}Action resulted in a timeout.")).open();
|
|
} else {
|
|
createErrorModal(tr("Failed to rename file"), tra("Failed to rename file:{:br:}{0}", event.error)).open();
|
|
}
|
|
}
|
|
});
|
|
useEffect(() => {
|
|
refInput.current?.focus();
|
|
});
|
|
|
|
return <>{icon} {name}</>;
|
|
};
|
|
|
|
const FileSize = (props: { path: string, events: Registry<FileBrowserEvents>, file: ListedFileInfo }) => {
|
|
const [ size, setSize ] = useState(-1);
|
|
|
|
props.events.reactUse("notify_transfer_status", event => {
|
|
if(event.id !== props.file.transfer?.id)
|
|
return;
|
|
|
|
if(props.file.transfer?.direction !== "upload")
|
|
return;
|
|
|
|
switch (event.status) {
|
|
case "pending":
|
|
setSize(0);
|
|
break;
|
|
|
|
case "finished":
|
|
case "none":
|
|
setSize(-1);
|
|
break;
|
|
}
|
|
});
|
|
|
|
props.events.reactUse("notify_transfer_progress", event => {
|
|
if(event.id !== props.file.transfer?.id)
|
|
return;
|
|
|
|
if(props.file.transfer?.direction !== "upload")
|
|
return;
|
|
|
|
setSize(event.fileSize);
|
|
});
|
|
|
|
if(size < 0 && (props.file.size < 0 || typeof props.file.size === "undefined"))
|
|
return <a key={"size-invalid"}><Translatable>unknown</Translatable></a>;
|
|
return <a key={"size"}>{network.format_bytes(size >= 0 ? size : props.file.size, { unit: "B", time: "", exact: false })}</a>;
|
|
};
|
|
|
|
const FileTransferIndicator = (props: { file: ListedFileInfo, events: Registry<FileBrowserEvents> }) => {
|
|
const [transferStatus, setTransferStatus] = useState<TransferStatus>(props.file.transfer?.status || "none");
|
|
const [transferProgress, setTransferProgress] = useState(props.file.transfer?.percent | 0);
|
|
|
|
props.events.reactUse("notify_transfer_start", event => {
|
|
if(event.path !== props.file.path || event.name !== props.file.name)
|
|
return;
|
|
|
|
setTransferStatus("pending");
|
|
});
|
|
|
|
props.events.reactUse("notify_transfer_status", event => {
|
|
if(event.id !== props.file.transfer?.id)
|
|
return;
|
|
|
|
setTransferStatus(event.status);
|
|
if(event.status === "finished" || event.status === "errored")
|
|
setTransferProgress(100);
|
|
});
|
|
|
|
props.events.reactUse("notify_transfer_progress", event => {
|
|
if(event.id !== props.file.transfer?.id)
|
|
return;
|
|
|
|
setTransferProgress(event.progress);
|
|
setTransferStatus(event.status);
|
|
});
|
|
|
|
/* reset the status after two seconds */
|
|
useEffect(() => {
|
|
if(transferStatus !== "finished" && transferStatus !== "errored")
|
|
return;
|
|
|
|
const id = setTimeout(() => {
|
|
setTransferStatus("none");
|
|
}, 3 * 1000);
|
|
return () => clearTimeout(id);
|
|
});
|
|
|
|
if(!props.file.transfer)
|
|
return null;
|
|
|
|
let color;
|
|
switch (transferStatus) {
|
|
case "pending":
|
|
case "transferring":
|
|
color = cssStyle.blue;
|
|
break;
|
|
|
|
case "errored":
|
|
color = cssStyle.red;
|
|
break;
|
|
|
|
case "finished":
|
|
color = cssStyle.green;
|
|
break;
|
|
|
|
case "none":
|
|
color = cssStyle.hidden;
|
|
break;
|
|
}
|
|
return (
|
|
<div className={cssStyle.indicator + " " + color} style={{ right: ((1 - transferProgress) * 100) + "%"}}>
|
|
<div className={cssStyle.status} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const FileListEntry = (props: { row: TableRow<ListedFileInfo>, columns: TableColumn[], events: Registry<FileBrowserEvents> }) => {
|
|
const file = props.row.userData;
|
|
const [hidden, setHidden] = useState(false);
|
|
const [selected, setSelected] = useState(false);
|
|
const [dropHovered, setDropHovered] = useState(false);
|
|
|
|
const onDoubleClicked = () => {
|
|
if(file.type === FileType.DIRECTORY) {
|
|
if(file.mode === "creating" || file.mode === "create")
|
|
return;
|
|
|
|
props.events.fire("action_navigate_to", {
|
|
path: file.path + file.name + "/"
|
|
});
|
|
} else {
|
|
if(file.mode === "uploading" || file.virtual)
|
|
return;
|
|
|
|
props.events.fire("action_start_download", {
|
|
files: [{
|
|
path: file.path,
|
|
name: file.name
|
|
}]
|
|
});
|
|
}
|
|
};
|
|
|
|
props.events.reactUse("action_select_files", event => {
|
|
const contains = event.files.findIndex(e => e.name === file.name && e.type === file.type) !== -1;
|
|
if(event.mode === "toggle" && contains)
|
|
setSelected(!selected);
|
|
else if(event.mode === "exclusive") {
|
|
setSelected(contains);
|
|
}
|
|
});
|
|
|
|
props.events.reactUse("notify_drag_ended", () => setDropHovered(false), dropHovered);
|
|
|
|
props.events.reactUse("action_delete_file_result", event => {
|
|
event.results.forEach(e => {
|
|
if(e.status !== "success")
|
|
return;
|
|
|
|
if(e.path !== file.path || e.name !== file.name)
|
|
return;
|
|
|
|
setHidden(true);
|
|
});
|
|
}, !hidden);
|
|
|
|
if(hidden)
|
|
return null;
|
|
|
|
return (
|
|
<TableRowElement
|
|
className={cssStyle.directoryEntry + " " + (selected ? cssStyle.selected : "") + " " + (dropHovered ? cssStyle.hovered : "")}
|
|
rowData={props.row}
|
|
columns={props.columns}
|
|
onDoubleClick={onDoubleClicked}
|
|
|
|
onClick={() => props.events.fire("action_select_files", { files: [{ name: file.name, type: file.type }], mode: ppt.key_pressed(SpecialKey.SHIFT) ? "toggle" : "exclusive" })}
|
|
onContextMenu={e => {
|
|
if(!selected) {
|
|
if(!(e.target instanceof HTMLDivElement)) {
|
|
/* explicitly clicked on one file */
|
|
props.events.fire("action_select_files", { files: [{ name: file.name, type: file.type }], mode: ppt.key_pressed(SpecialKey.SHIFT) ? "toggle" : "exclusive" });
|
|
} else {
|
|
props.events.fire("action_select_files", { files: [], mode: "exclusive" });
|
|
}
|
|
}
|
|
|
|
props.events.fire("action_selection_context_menu", {
|
|
pageX: e.pageX,
|
|
pageY: e.pageY
|
|
});
|
|
e.preventDefault();
|
|
}}
|
|
draggable={!props.row.userData.virtual}
|
|
onDragStart={event => {
|
|
if(!selected) {
|
|
setSelected(true);
|
|
props.events.fire("action_select_files", { files: [{ name: file.name, type: file.type }], mode: "exclusive" });
|
|
}
|
|
props.events.fire("notify_drag_started", { event: event.nativeEvent });
|
|
}}
|
|
|
|
onDragOver={event => {
|
|
const types = event.dataTransfer.types;
|
|
if(types.length !== 1)
|
|
return;
|
|
|
|
if(props.row.userData.type !== FileType.DIRECTORY) {
|
|
event.stopPropagation();
|
|
return;
|
|
}
|
|
|
|
if(types[0] === FileTransferUrlMediaType) {
|
|
/* TODO: Detect if its remote move or internal move */
|
|
event.dataTransfer.effectAllowed = "move";
|
|
} else if(types[0] === "Files") {
|
|
event.dataTransfer.effectAllowed = "copy";
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
setDropHovered(true);
|
|
}}
|
|
|
|
onDragLeave={() => setDropHovered(false)}
|
|
onDragEnd={() => props.events.fire("notify_drag_ended")}
|
|
|
|
x-drag-upload-path={props.row.userData.type === FileType.DIRECTORY ? props.row.userData.path + props.row.userData.name + "/" : undefined}
|
|
>
|
|
<FileTransferIndicator events={props.events} file={props.row.userData} />
|
|
</TableRowElement>
|
|
);
|
|
};
|
|
|
|
@ReactEventHandler(e => e.props.events)
|
|
export class FileBrowser extends ReactComponentBase<FileListTableProperties, FileListTableState> {
|
|
private refTable = React.createRef<Table>();
|
|
private currentPath: string;
|
|
private fileList: ListedFileInfo[];
|
|
private selection: { name: string, type: FileType }[] = [];
|
|
|
|
protected defaultState(): FileListTableState {
|
|
return {
|
|
state: "querying"
|
|
};
|
|
}
|
|
|
|
render() {
|
|
let rows: TableRow[] = [];
|
|
|
|
let overlay, overlayOnly;
|
|
if(this.state.state === "querying") {
|
|
overlayOnly = true;
|
|
overlay = () => (
|
|
<div key={"loading"} className={cssStyle.overlay}>
|
|
<a><Translatable>loading</Translatable><LoadingDots maxDots={3} /></a>
|
|
</div>
|
|
);
|
|
} else if(this.state.state === "error") {
|
|
overlayOnly = true;
|
|
overlay = () => (
|
|
<div key={"query-error"} className={cssStyle.overlay + " " + cssStyle.overlayError}>
|
|
<a><Translatable>Failed to query directory:</Translatable><br />{this.state.errorMessage}</a>
|
|
</div>
|
|
);
|
|
} else if(this.state.state === "query-timeout") {
|
|
overlayOnly = true;
|
|
overlay = () => (
|
|
<div key={"query-timeout"} className={cssStyle.overlay + " " + cssStyle.overlayError}>
|
|
<a><Translatable>Directory query timed out.</Translatable><br /><Translatable>Please try again.</Translatable></a>
|
|
</div>
|
|
);
|
|
} else if(this.state.state === "no-permissions") {
|
|
overlayOnly = true;
|
|
overlay = () => (
|
|
<div key={"no-permissions"} className={cssStyle.overlay + " " + cssStyle.overlayError}>
|
|
<a><Translatable>Directory query failed on permission</Translatable><br />{this.state.errorMessage}</a>
|
|
</div>
|
|
);
|
|
} else if(this.state.state === "invalid-password") {
|
|
overlayOnly = true;
|
|
overlay = () => (
|
|
<div key={"invalid-password"} className={cssStyle.overlay + " " + cssStyle.overlayError}>
|
|
<a><Translatable>Directory query failed because it is password protected</Translatable></a>
|
|
</div>
|
|
);
|
|
} else if(this.state.state === "normal") {
|
|
if(this.fileList.length === 0) {
|
|
overlayOnly = true;
|
|
overlay = () => (
|
|
<div key={"no-files"} className={cssStyle.overlayEmptyFolder}>
|
|
<a><Translatable>This folder is empty.</Translatable></a>
|
|
</div>
|
|
);
|
|
} else {
|
|
const directories = this.fileList.filter(e => e.type === FileType.DIRECTORY);
|
|
const files = this.fileList.filter(e => e.type === FileType.FILE);
|
|
|
|
for(const directory of directories.sort((a, b) => a.name > b.name ? 1 : -1)) {
|
|
rows.push({
|
|
columns: {
|
|
"name": () => <FileName path={this.currentPath} events={this.props.events} file={directory} />,
|
|
"type": () => <a key={"type"}><Translatable>Directory</Translatable></a>,
|
|
"change-date": () => directory.datetime ? <a>{Moment(directory.datetime).format("DD/MM/YYYY HH:mm")}</a> : undefined
|
|
},
|
|
className: cssStyle.directoryEntry,
|
|
userData: directory
|
|
})
|
|
}
|
|
|
|
for(const file of files.sort((a, b) => a.name > b.name ? 1 : -1)) {
|
|
rows.push({
|
|
columns: {
|
|
"name": () => <FileName path={this.currentPath} events={this.props.events} file={file} />,
|
|
"size": () => <FileSize path={this.currentPath} events={this.props.events} file={file} /> ,
|
|
"type": () => <a key={"type"}><Translatable>File</Translatable></a>,
|
|
"change-date": () => file.datetime ? <a key={"date"}>{Moment(file.datetime).format("DD/MM/YYYY HH:mm")}</a> : undefined
|
|
},
|
|
className: cssStyle.directoryEntry,
|
|
userData: file
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Table
|
|
ref={this.refTable}
|
|
className={cssStyle.fileTable}
|
|
bodyClassName={cssStyle.body}
|
|
headerClassName={cssStyle.header}
|
|
columns={[
|
|
{ name: "name", header: () => [
|
|
<a key={"name-name"}><Translatable>Name</Translatable></a>, <div key={"seperator-name"} className={cssStyle.seperator} />
|
|
], width: 80, className: cssStyle.columnName },
|
|
{ name: "type", header: () => [
|
|
<a key={"name-type"}><Translatable>Type</Translatable></a>, <div key={"seperator-type"} className={cssStyle.seperator} />
|
|
], fixedWidth: "8em", className: cssStyle.columnType },
|
|
{ name: "size", header: () => [
|
|
<a key={"name-size"}><Translatable>Size</Translatable></a>, <div key={"seperator-size"} className={cssStyle.seperator} />
|
|
], fixedWidth: "8em", className: cssStyle.columnSize },
|
|
{ name: "change-date", header: () => [
|
|
<a key={"name-date"}><Translatable>Last changed</Translatable></a>, <div key={"seperator-date"} className={cssStyle.seperator} />
|
|
], fixedWidth: "8em", className: cssStyle.columnChanged },
|
|
]}
|
|
rows={rows}
|
|
|
|
bodyOverlayOnly={overlayOnly}
|
|
bodyOverlay={overlay}
|
|
|
|
hiddenColumns={["type"]}
|
|
|
|
onHeaderContextMenu={e => this.onHeaderContextMenu(e)}
|
|
onBodyContextMenu={e => this.onBodyContextMenu(e)}
|
|
onDrop={e => this.onDrop(e)}
|
|
onDragOver={event => {
|
|
const types = event.dataTransfer.types;
|
|
if(types.length !== 1)
|
|
return;
|
|
|
|
if(types[0] === FileTransferUrlMediaType) {
|
|
/* TODO: Detect if its remote move or internal move */
|
|
event.dataTransfer.effectAllowed = "move";
|
|
} else if(types[0] === "Files") {
|
|
event.dataTransfer.effectAllowed = "copy";
|
|
} else {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
}}
|
|
|
|
renderRow={(row: TableRow<ListedFileInfo>, columns, uniqueId) => <FileListEntry columns={columns} row={row} key={uniqueId} events={this.props.events} />}
|
|
/>
|
|
);
|
|
}
|
|
|
|
componentDidMount(): void {
|
|
this.selection = [];
|
|
this.currentPath = this.props.currentPath;
|
|
this.props.events.fire("query_files", {
|
|
path: this.currentPath
|
|
});
|
|
}
|
|
|
|
private onDrop(event: React.DragEvent) {
|
|
const types = event.dataTransfer.types;
|
|
if(types.length !== 1)
|
|
return;
|
|
|
|
event.stopPropagation();
|
|
let targetPath;
|
|
{
|
|
let currentTarget = event.target as HTMLElement;
|
|
while(currentTarget && !currentTarget.hasAttribute("x-drag-upload-path"))
|
|
currentTarget = currentTarget.parentElement;
|
|
targetPath = currentTarget?.getAttribute("x-drag-upload-path") || this.currentPath;
|
|
console.log("Target: %o %s", currentTarget, targetPath);
|
|
}
|
|
|
|
if(types[0] === FileTransferUrlMediaType) {
|
|
/* TODO: If cross move upload! */
|
|
console.error(event.dataTransfer.getData(FileTransferUrlMediaType));
|
|
const fileUrls = event.dataTransfer.getData(FileTransferUrlMediaType).split("&").map(e => decodeURIComponent(e));
|
|
for(const fileUrl of fileUrls) {
|
|
const name = fileUrl.split("/").last();
|
|
const oldPath = fileUrl.split("/").slice(0, -1).join("/") + "/";
|
|
|
|
this.props.events.fire("action_rename_file", {
|
|
newPath: targetPath,
|
|
oldPath: oldPath,
|
|
oldName: name,
|
|
newName: name
|
|
});
|
|
}
|
|
} else if(types[0] === "Files") {
|
|
this.props.events.fire("action_start_upload", { path: targetPath, mode: "files", files: [...event.dataTransfer.files] });
|
|
} else {
|
|
log.warn(LogCategory.FILE_TRANSFER, tr("Received an unknown drop media type (%o)"), types);
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
}
|
|
|
|
private onHeaderContextMenu(event: React.MouseEvent) {
|
|
event.preventDefault();
|
|
|
|
const table = this.refTable.current;
|
|
spawn_context_menu(event.pageX, event.pageY, {
|
|
type: MenuEntryType.CHECKBOX,
|
|
name: tr("Size"),
|
|
checkbox_checked: table.state.hiddenColumns.findIndex(e => e === "size") === -1,
|
|
callback: () => {
|
|
table.state.hiddenColumns.toggle("size");
|
|
table.forceUpdate();
|
|
}
|
|
}, {
|
|
type: MenuEntryType.CHECKBOX,
|
|
name: tr("Type"),
|
|
checkbox_checked: table.state.hiddenColumns.findIndex(e => e === "type") === -1,
|
|
callback: () => {
|
|
table.state.hiddenColumns.toggle("type");
|
|
table.forceUpdate();
|
|
}
|
|
}, {
|
|
type: MenuEntryType.CHECKBOX,
|
|
name: tr("Last changed"),
|
|
checkbox_checked: table.state.hiddenColumns.findIndex(e => e === "change-date") === -1,
|
|
callback: () => {
|
|
table.state.hiddenColumns.toggle("change-date");
|
|
table.forceUpdate();
|
|
}
|
|
})
|
|
}
|
|
|
|
private onBodyContextMenu(event: React.MouseEvent) {
|
|
if(event.isDefaultPrevented()) return;
|
|
event.preventDefault();
|
|
|
|
this.props.events.fire("action_select_files", { mode: "exclusive", files: [] });
|
|
this.props.events.fire("action_selection_context_menu", { pageY: event.pageY, pageX: event.pageX });
|
|
}
|
|
|
|
@EventHandler<FileBrowserEvents>("action_navigate_to_result")
|
|
private handleNavigationResult(event: FileBrowserEvents["action_navigate_to_result"]) {
|
|
if(event.status !== "success")
|
|
return;
|
|
|
|
this.currentPath = event.path;
|
|
this.selection = [];
|
|
this.props.events.fire("query_files", {
|
|
path: event.path
|
|
});
|
|
}
|
|
|
|
@EventHandler<FileBrowserEvents>("query_files")
|
|
private handleQueryFiles(event: FileBrowserEvents["query_files"]) {
|
|
if(event.path !== this.currentPath)
|
|
return;
|
|
|
|
this.setState({
|
|
state: "querying"
|
|
});
|
|
}
|
|
|
|
@EventHandler<FileBrowserEvents>("query_files_result")
|
|
private handleQueryFilesResult(event: FileBrowserEvents["query_files_result"]) {
|
|
if(event.status === "timeout") {
|
|
this.setState({
|
|
state: "query-timeout"
|
|
});
|
|
} else if(event.status === "error") {
|
|
this.setState({
|
|
state: "error",
|
|
errorMessage: event.error || tr("unknown query error")
|
|
});
|
|
} else if(event.status === "success") {
|
|
this.fileList = event.files;
|
|
this.setState({
|
|
state: "normal"
|
|
});
|
|
} else if(event.status === "no-permissions") {
|
|
this.setState({
|
|
state: "no-permissions",
|
|
errorMessage: event.error || tr("unknown")
|
|
});
|
|
} else if(event.status === "invalid-password") {
|
|
this.setState({
|
|
state: "invalid-password"
|
|
});
|
|
} else {
|
|
this.setState({
|
|
state: "error",
|
|
errorMessage: tr("invalid query result state")
|
|
});
|
|
}
|
|
}
|
|
|
|
@EventHandler<FileBrowserEvents>("action_delete_file_result")
|
|
private handleActionDeleteResult(event: FileBrowserEvents["action_delete_file_result"]) {
|
|
event.results.forEach(e => {
|
|
const index = this.fileList.findIndex(e1 => e1.name === e.name && e1.path === e.path);
|
|
if(index === -1)
|
|
return;
|
|
|
|
if(e.status === "success")
|
|
this.fileList.splice(index, 1);
|
|
});
|
|
|
|
event.results.forEach(e => {
|
|
if(e.status === "success")
|
|
return;
|
|
|
|
createErrorModal(tr("Failed to delete entry"), tra("Failed to delete \"{0}\":{:br:}{1}", e.name, e.error || tr("Unknown error"))).open();
|
|
});
|
|
}
|
|
|
|
@EventHandler<FileBrowserEvents>("action_start_create_directory")
|
|
private handleActionFileCreateBegin(event: FileBrowserEvents["action_start_create_directory"]) {
|
|
let index = 0;
|
|
while(this.fileList.find(e => e.name === (event.defaultName + (index > 0 ? " (" + index + ")" : ""))))
|
|
index++;
|
|
|
|
const name = event.defaultName + (index > 0 ? " (" + index + ")" : "");
|
|
this.fileList.push({
|
|
name: name,
|
|
path: this.currentPath,
|
|
type: FileType.DIRECTORY,
|
|
size: 0,
|
|
datetime: Date.now(),
|
|
virtual: false,
|
|
mode: "create"
|
|
});
|
|
|
|
/* fire_async because our children have to render first in order to have the row selected! */
|
|
this.forceUpdate(() => this.props.events.fire_async("action_select_files", { files: [{ name: name, type: FileType.DIRECTORY }] , mode: "exclusive" }));
|
|
}
|
|
|
|
@EventHandler<FileBrowserEvents>("action_create_directory_result")
|
|
private handleActionFileCreateResult(event: FileBrowserEvents["action_create_directory_result"]) {
|
|
let fileIndex = this.fileList.slice().reverse().findIndex(e => e.path === event.path && e.name === event.name);
|
|
if(fileIndex === -1)
|
|
return;
|
|
fileIndex = this.fileList.length - fileIndex - 1;
|
|
|
|
const file = this.fileList[fileIndex];
|
|
if(event.status === "success") {
|
|
if(file.mode === "creating")
|
|
file.mode = "empty";
|
|
return;
|
|
} else if(file.mode !== "creating")
|
|
return;
|
|
|
|
this.fileList.splice(fileIndex, 1);
|
|
this.forceUpdate();
|
|
|
|
if(event.status === "timeout") {
|
|
createErrorModal(tr("Failed to create directory"), tra("Failed to create directory.{:br:}Action resulted in a timeout.")).open();
|
|
} else {
|
|
createErrorModal(tr("Failed to create directory"), tra("Failed to create directory:{:br:}{0}", event.error)).open();
|
|
}
|
|
}
|
|
|
|
@EventHandler<FileBrowserEvents>("action_select_files")
|
|
private handleActionSelectFiles(event: FileBrowserEvents["action_select_files"]) {
|
|
if(event.mode === "exclusive") {
|
|
this.selection = event.files.slice(0);
|
|
} else if(event.mode === "toggle") {
|
|
event.files.forEach(e => {
|
|
const index = this.selection.map(e => e.name).findIndex(b => b === e.name);
|
|
if(index === -1)
|
|
this.selection.push(e);
|
|
else
|
|
this.selection.splice(index);
|
|
});
|
|
}
|
|
}
|
|
|
|
@EventHandler<FileBrowserEvents>("notify_drag_started")
|
|
private handleNotifyDragStarted(event: FileBrowserEvents["notify_drag_started"]) {
|
|
if(this.selection.length === 0) {
|
|
event.event.preventDefault();
|
|
return;
|
|
} else {
|
|
const url = this.selection.map(e => encodeURIComponent(this.currentPath + e.name)).join("&");
|
|
event.event.dataTransfer.setData(FileTransferUrlMediaType, url);
|
|
}
|
|
}
|
|
|
|
@EventHandler<FileBrowserEvents>("action_rename_file_result")
|
|
private handleFileRenameResult(event: FileBrowserEvents["action_rename_file_result"]) {
|
|
if(event.oldPath !== this.currentPath && event.newPath !== this.currentPath)
|
|
return;
|
|
|
|
if(event.status !== "success")
|
|
return;
|
|
|
|
if(event.oldPath === event.newPath) {
|
|
const index = this.selection.findIndex(e => e.name === event.oldName);
|
|
if(index !== -1)
|
|
this.selection[index].name = event.newName;
|
|
} else {
|
|
/* re query files, because list has changed */
|
|
this.props.events.fire("query_files", { path: this.currentPath });
|
|
}
|
|
}
|
|
|
|
@EventHandler<FileBrowserEvents>("notify_transfer_start")
|
|
private handleTransferStart(event: FileBrowserEvents["notify_transfer_start"]) {
|
|
if(event.path !== this.currentPath)
|
|
return;
|
|
|
|
let entry = this.fileList.find(e => e.name === event.name);
|
|
if(!entry) {
|
|
if(event.mode !== "upload") {
|
|
log.warn(LogCategory.FILE_TRANSFER, tr("Having file download start notification for current path, but target file is unknown (%s%s)"), event.path, event.name);
|
|
return;
|
|
}
|
|
|
|
entry = {
|
|
name: event.name,
|
|
path: event.path,
|
|
|
|
type: FileType.FILE,
|
|
mode: "uploading",
|
|
virtual: true,
|
|
datetime: Date.now(),
|
|
size: -1
|
|
};
|
|
this.fileList.push(entry);
|
|
this.forceUpdate();
|
|
}
|
|
|
|
entry.transfer = {
|
|
status: "pending",
|
|
direction: event.mode,
|
|
id: event.id,
|
|
percent: 0
|
|
};
|
|
}
|
|
|
|
@EventHandler<FileBrowserEvents>("notify_transfer_status")
|
|
private handleTransferStatus(event: FileBrowserEvents["notify_transfer_status"]) {
|
|
const index = this.fileList.findIndex(e => e.transfer?.id === event.id);
|
|
if(index === -1)
|
|
return;
|
|
|
|
let element = this.fileList[index];
|
|
if(event.status === "errored") {
|
|
if(element.mode === "uploading") {
|
|
/* re query files, because we don't know what the server did with the errored upload */
|
|
this.props.events.fire("query_files", { path: this.currentPath });
|
|
return;
|
|
}
|
|
} else {
|
|
element.transfer.status = event.status;
|
|
if(element.mode === "uploading" && event.status === "finished") {
|
|
/* upload finished, the element rerenders already with the correct values */
|
|
element.size = event.fileSize;
|
|
element.mode = "normal";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|