webview-ui/src/manualTest/components/FilePicker.tsx (233 lines of code) (raw):
import { FormEvent, useState } from "react";
import { Dialog } from "../../components/Dialog";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons";
import styles from "./FilePicker.module.css";
import {
Directory,
FileOrDirectory,
asPathParts,
asPathString,
findFileSystemItem,
isDirectory,
matchesFilter,
} from "../utilities/testFileSystemUtils";
import {
FileFilters,
OpenFileOptions,
OpenFileResult,
SaveFileOptions,
SaveFileResult,
} from "../../../../src/webview-contract/webviewDefinitions/shared/fileSystemTypes";
import { Maybe, hasValue, isNothing, just, nothing } from "../../utilities/maybe";
type ChangeEvent = Event | FormEvent<HTMLElement>;
export type FilePickerProps = {
shown: boolean;
rootDir: Directory;
isSaving: boolean;
options: SaveFileOptions | OpenFileOptions;
closeRequested: (result: SaveFileResult | OpenFileResult | null) => void;
};
export function FilePicker(props: FilePickerProps) {
const initialState = getInitialState(props);
const [newItem, setNewItem] = useState<FileOrDirectory | null>(initialState.newItem);
const [existingItems, setExistingItems] = useState<FileOrDirectory[]>(
initialState.existingItem ? [initialState.existingItem] : [],
);
const selectedItems = newItem ? [...existingItems, newItem] : existingItems;
const suggestedFilename = getSuggestedName(props);
const mustExist = !props.isSaving;
const isDirectory = !props.isSaving && (props.options as OpenFileOptions).type === "directory";
const canSelectMany = (!props.isSaving && (props.options as OpenFileOptions).canSelectMany) || false;
let treeSelectedItems: FileOrDirectory[];
if (canSelectMany) {
treeSelectedItems = selectedItems;
} else if (newItem) {
// Select the parent directory
treeSelectedItems = [findFileSystemItem(props.rootDir, newItem.path)!];
} else {
treeSelectedItems = existingItems;
}
function handleItemSelectionChange(item: FileOrDirectory) {
if (item.type === "directory" && props.isSaving && suggestedFilename) {
const itemPath = [...item.path, suggestedFilename];
const updatedExistingItem = findFileSystemItem(props.rootDir, itemPath);
setExistingItems(updatedExistingItem ? [updatedExistingItem] : []);
if (!updatedExistingItem) {
setNewItem(createNewItem(props, [item], suggestedFilename));
}
} else {
if (existingItems.includes(item)) {
setExistingItems(existingItems.filter((i) => i !== item));
} else if (canSelectMany) {
setExistingItems([...existingItems, item]);
} else {
setExistingItems([item]);
}
}
}
function handleFilenameChange(e: ChangeEvent) {
const input = e.currentTarget as HTMLInputElement;
const filename = input.value.trim();
if (filename) {
const updatedNewItem = createNewItem(props, selectedItems, filename);
setNewItem(updatedNewItem);
const existingItem = findFileSystemItem(props.rootDir, [...updatedNewItem.path, updatedNewItem.name]);
setExistingItems(existingItem ? [existingItem] : []);
} else {
setNewItem(null);
}
}
function validate(): Maybe<SaveFileResult | OpenFileResult> {
if (!props.isSaving) {
const itemsOfType = existingItems.filter((item) => item.type === (props.options as OpenFileOptions).type);
if (itemsOfType.length === 0) return nothing();
const paths = itemsOfType.map((item) => `/${item.path.join("/")}/${item.name}`);
return just({ paths } as OpenFileResult);
}
if (selectedItems.length !== 1) return nothing();
const selectedItem = selectedItems[0];
return just({
path: asPathString(selectedItem),
exists: selectedItem !== newItem,
});
}
function handleSubmit(e: FormEvent) {
e.preventDefault();
const result = validate();
if (hasValue(result)) {
props.closeRequested(result.value);
}
}
return (
<Dialog isShown={props.shown} onCancel={() => props.closeRequested(null)}>
<h2 className={styles.title}>{props.options.title}</h2>
<form onSubmit={handleSubmit}>
<hr style={{ marginBottom: "0.5rem" }} />
<FileSystemNodes
items={[props.rootDir]}
filters={props.options.filters || {}}
directoriesOnly={!props.isSaving && (props.options as OpenFileOptions).type === "directory"}
canSelectMany={canSelectMany}
handleItemSelectionChange={handleItemSelectionChange}
selectedItems={treeSelectedItems}
/>
<div className={styles.inputContainer}>
<label className={styles.label} htmlFor="filename-input">
{isDirectory ? "Directory:" : "File:"}
</label>
{(!canSelectMany || selectedItems.length <= 1) && (
<input
type="text"
id="filename-input"
className={styles.control}
value={selectedItems.length === 1 ? selectedItems[0].name : ""}
readOnly={mustExist}
onInput={handleFilenameChange}
/>
)}
</div>
<div className={styles.buttonContainer}>
<button type="submit" disabled={isNothing(validate())}>
{props.options.buttonLabel || "Select"}
</button>
<button className="secondary-button" onClick={() => props.closeRequested(null)}>
Cancel
</button>
</div>
</form>
</Dialog>
);
}
type InitialState = {
newItem: FileOrDirectory | null;
existingItem: FileOrDirectory | null;
};
function getSuggestedName(props: FilePickerProps): string | null {
const defaultPathParts = props.options.defaultPath ? asPathParts(props.options.defaultPath) : [];
return defaultPathParts.length > 0 ? defaultPathParts[defaultPathParts.length - 1] : null;
}
function getInitialState(props: FilePickerProps): InitialState {
const defaultPathParts = props.options.defaultPath ? asPathParts(props.options.defaultPath) : [];
const suggestedName = getSuggestedName(props);
const canCreateNewItem = props.isSaving && suggestedName ? true : false;
const newItem = canCreateNewItem ? createNewItem(props, [], suggestedName!) : null;
const startPathParts = defaultPathParts.length > 0 ? defaultPathParts : props.rootDir.path;
const existingItem = findFileSystemItem(props.rootDir, startPathParts);
return { newItem, existingItem };
}
function createNewItem(props: FilePickerProps, selectedItems: FileOrDirectory[], filename: string): FileOrDirectory {
const defaultPathParts = props.options.defaultPath ? asPathParts(props.options.defaultPath) : [];
let path = defaultPathParts || props.rootDir.path;
if (selectedItems.length === 1) {
const selectedItem = selectedItems[0];
path = selectedItem.type === "directory" ? [...selectedItem.path, selectedItem.name] : selectedItem.path;
}
return {
name: filename,
type: "file",
path,
};
}
type FileSystemNodesProps = {
items: FileOrDirectory[];
filters: FileFilters;
directoriesOnly: boolean;
canSelectMany: boolean;
handleItemSelectionChange: (item: FileOrDirectory) => void;
selectedItems: FileOrDirectory[];
};
function FileSystemNodes(props: FileSystemNodesProps) {
return (
<ul className={styles.nodeList}>
{props.items.map((item) => (
<FileSystemNode
key={`${item.path.join("/")}/${item.name}`}
item={item}
filters={props.filters}
directoriesOnly={props.directoriesOnly}
canSelectMany={props.canSelectMany}
selectedItems={props.selectedItems}
handleItemSelectionChange={props.handleItemSelectionChange}
/>
))}
</ul>
);
}
type FileSystemNodeProps = {
item: FileOrDirectory;
filters: FileFilters;
directoriesOnly: boolean;
canSelectMany: boolean;
handleItemSelectionChange: (item: FileOrDirectory) => void;
selectedItems: FileOrDirectory[];
};
function FileSystemNode(props: FileSystemNodeProps) {
const [expanded, setExpanded] = useState(false);
function handleToggleExpand() {
setExpanded(isDirectory(props.item) && !expanded);
}
function ignoreClick(e: Event | FormEvent<HTMLElement>) {
e.stopPropagation();
}
const isSelected = props.selectedItems.includes(props.item);
return (
<>
<li>
{isDirectory(props.item) && (
<FontAwesomeIcon
icon={expanded ? faChevronDown : faChevronRight}
className={styles.expander}
onClick={handleToggleExpand}
/>
)}
{props.canSelectMany && !isDirectory(props.item) && (
<input
type="checkbox"
checked={isSelected}
onClick={ignoreClick}
onChange={() => props.handleItemSelectionChange(props.item)}
style={{
margin: "0rem 0.5rem 0rem .5rem",
position: "relative",
top: ".125rem",
display: "inline",
}}
/>
)}
<span onClick={() => props.handleItemSelectionChange(props.item)} className={styles.itemSpan}>
{props.item.name}
</span>
{expanded && isDirectory(props.item) && (
<FileSystemNodes
items={props.item.contents.filter((item) =>
matchesFilter(item, props.filters, props.directoriesOnly),
)}
filters={props.filters}
directoriesOnly={props.directoriesOnly}
canSelectMany={props.canSelectMany}
handleItemSelectionChange={props.handleItemSelectionChange}
selectedItems={props.selectedItems}
/>
)}
</li>
</>
);
}