Improve File Explorer performance (#7337)

* changes

* add changeset

* changes

* changes

* add changeset

* changes

* changes

* changes

* changes

* changes

* Update gradio/components/file_explorer.py

Co-authored-by: Abubakar Abid <abubakar@huggingface.co>

* Update gradio/components/file_explorer.py

Co-authored-by: Abubakar Abid <abubakar@huggingface.co>

* Update demo/file_explorer_component_events/run.py

Co-authored-by: Abubakar Abid <abubakar@huggingface.co>

* changes

* changes

* changes

---------

Co-authored-by: Ali Abid <aliabid94@gmail.com>
Co-authored-by: gradio-pr-bot <gradio-pr-bot@users.noreply.github.com>
Co-authored-by: Abubakar Abid <abubakar@huggingface.co>
This commit is contained in:
aliabid94 2024-02-13 16:51:47 -06:00 committed by GitHub
parent 547517b74e
commit 65437ce832
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 244 additions and 742 deletions

View File

@ -0,0 +1,6 @@
---
"@gradio/fileexplorer": patch
"gradio": patch
---
fix:Improve File Explorer performance

View File

@ -1 +1 @@
{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: file_explorer"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "from pathlib import Path\n", "\n", "current_file_path = Path(__file__).resolve()\n", "relative_path = \"path/to/file\"\n", "absolute_path = (current_file_path.parent / \"..\" / \"..\" / \"gradio\").resolve()\n", "\n", "\n", "def get_file_content(file):\n", " return (file,)\n", "\n", "\n", "with gr.Blocks() as demo:\n", " gr.Markdown('### `FileExplorer` to `FileExplorer` -- `file_count=\"multiple\"`')\n", " submit_btn = gr.Button(\"Select\")\n", " with gr.Row():\n", " file = gr.FileExplorer(\n", " glob=\"**/{components,themes}/*.py\",\n", " # value=[\"themes/utils\"],\n", " root=absolute_path,\n", " ignore_glob=\"**/__init__.py\",\n", " )\n", "\n", " file2 = gr.FileExplorer(\n", " glob=\"**/{components,themes}/**/*.py\",\n", " root=absolute_path,\n", " ignore_glob=\"**/__init__.py\",\n", " )\n", " submit_btn.click(lambda x: x, file, file2)\n", "\n", " gr.Markdown(\"---\")\n", " gr.Markdown('### `FileExplorer` to `Code` -- `file_count=\"single\"`')\n", " with gr.Group():\n", " with gr.Row():\n", " file_3 = gr.FileExplorer(\n", " scale=1,\n", " glob=\"**/{components,themes}/**/*.py\",\n", " value=[\"themes/utils\"],\n", " file_count=\"single\",\n", " root=absolute_path,\n", " ignore_glob=\"**/__init__.py\",\n", " elem_id=\"file\",\n", " )\n", "\n", " code = gr.Code(lines=30, scale=2, language=\"python\")\n", "\n", " file_3.change(get_file_content, file_3, code)\n", "\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()\n"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: file_explorer"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "from pathlib import Path\n", "\n", "current_file_path = Path(__file__).resolve()\n", "relative_path = \"path/to/file\"\n", "absolute_path = (current_file_path.parent / \"..\" / \"..\" / \"gradio\").resolve()\n", "\n", "\n", "def get_file_content(file):\n", " return (file,)\n", "\n", "\n", "with gr.Blocks() as demo:\n", " gr.Markdown('### `FileExplorer` to `FileExplorer` -- `file_count=\"multiple\"`')\n", " submit_btn = gr.Button(\"Select\")\n", " with gr.Row():\n", " file = gr.FileExplorer(\n", " glob=\"**/components/*.py\",\n", " # value=[\"themes/utils\"],\n", " root=absolute_path,\n", " ignore_glob=\"**/__init__.py\",\n", " )\n", "\n", " file2 = gr.FileExplorer(\n", " glob=\"**/components/**/*.py\",\n", " root=absolute_path,\n", " ignore_glob=\"**/__init__.py\",\n", " )\n", " submit_btn.click(lambda x: x, file, file2)\n", "\n", " gr.Markdown(\"---\")\n", " gr.Markdown('### `FileExplorer` to `Code` -- `file_count=\"single\"`')\n", " with gr.Group():\n", " with gr.Row():\n", " file_3 = gr.FileExplorer(\n", " scale=1,\n", " glob=\"**/components/**/*.py\",\n", " value=[\"themes/utils\"],\n", " file_count=\"single\",\n", " root=absolute_path,\n", " ignore_glob=\"**/__init__.py\",\n", " elem_id=\"file\",\n", " )\n", "\n", " code = gr.Code(lines=30, scale=2, language=\"python\")\n", "\n", " file_3.change(get_file_content, file_3, code)\n", "\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()\n"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}

View File

@ -15,14 +15,14 @@ with gr.Blocks() as demo:
submit_btn = gr.Button("Select")
with gr.Row():
file = gr.FileExplorer(
glob="**/{components,themes}/*.py",
glob="**/components/*.py",
# value=["themes/utils"],
root=absolute_path,
ignore_glob="**/__init__.py",
)
file2 = gr.FileExplorer(
glob="**/{components,themes}/**/*.py",
glob="**/components/**/*.py",
root=absolute_path,
ignore_glob="**/__init__.py",
)
@ -34,7 +34,7 @@ with gr.Blocks() as demo:
with gr.Row():
file_3 = gr.FileExplorer(
scale=1,
glob="**/{components,themes}/**/*.py",
glob="**/components/**/*.py",
value=["themes/utils"],
file_count="single",
root=absolute_path,

View File

@ -1 +1 @@
{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: file_explorer_component_events"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["# Downloading files from the demo repo\n", "import os\n", "os.mkdir('dir1')\n", "!wget -q -O dir1/bar.txt https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir1/bar.txt\n", "!wget -q -O dir1/foo.txt https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir1/foo.txt\n", "os.mkdir('dir2')\n", "!wget -q -O dir2/baz.png https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir2/baz.png\n", "!wget -q -O dir2/foo.png https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir2/foo.png\n", "os.mkdir('dir3')\n", "!wget -q -O dir3/dir3_foo.txt https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir3/dir3_foo.txt\n", "!wget -q -O dir3/dir4 https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir3/dir4"]}, {"cell_type": "code", "execution_count": null, "id": "44380577570523278879349135829904343037", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "from pathlib import Path\n", "\n", "base_root = Path(__file__).parent.resolve()\n", "\n", "with gr.Blocks() as demo:\n", " with gr.Row():\n", " dd = gr.Dropdown(label=\"Select File Explorer Root\",\n", " value=str(base_root / \"dir1\"),\n", " choices=[str(base_root / \"dir1\"), str(base_root / \"dir2\"),\n", " str(base_root / \"dir3\")])\n", " with gr.Group():\n", " dir_only_glob = gr.Checkbox(label=\"Show only directories\", value=False)\n", " ignore_dir_in_glob = gr.Checkbox(label=\"Ignore directories in glob\", value=False)\n", "\n", " fe = gr.FileExplorer(root=str(base_root / \"dir1\"),\n", " glob=\"**/*\", interactive=True)\n", " textbox = gr.Textbox(label=\"Selected Directory\")\n", " run = gr.Button(\"Run\")\n", " \n", " dir_only_glob.select(lambda s: gr.FileExplorer(glob=\"**/\" if s else \"**/*.*\",\n", " file_count=\"multiple\",\n", " root=str(base_root / \"dir3\")) ,\n", " inputs=[dir_only_glob], outputs=[fe])\n", " ignore_dir_in_glob.select(lambda s: gr.FileExplorer(glob=\"**/*\",\n", " ignore_glob=\"**/\",\n", " root=str(base_root / \"dir3\")),\n", " inputs=[ignore_dir_in_glob], outputs=[fe]) \n", "\n", " dd.select(lambda s: gr.FileExplorer(root=s), inputs=[dd], outputs=[fe])\n", " run.click(lambda s: \",\".join(s) if isinstance(s, list) else s, inputs=[fe], outputs=[textbox])\n", "\n", " with gr.Row():\n", " a = gr.Textbox(elem_id=\"input-box\")\n", " a.change(lambda x: x, inputs=[a])\n", "\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}
{"cells": [{"cell_type": "markdown", "id": "302934307671667531413257853548643485645", "metadata": {}, "source": ["# Gradio Demo: file_explorer_component_events"]}, {"cell_type": "code", "execution_count": null, "id": "272996653310673477252411125948039410165", "metadata": {}, "outputs": [], "source": ["!pip install -q gradio "]}, {"cell_type": "code", "execution_count": null, "id": "288918539441861185822528903084949547379", "metadata": {}, "outputs": [], "source": ["# Downloading files from the demo repo\n", "import os\n", "os.mkdir('dir1')\n", "!wget -q -O dir1/bar.txt https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir1/bar.txt\n", "!wget -q -O dir1/foo.txt https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir1/foo.txt\n", "os.mkdir('dir2')\n", "!wget -q -O dir2/baz.png https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir2/baz.png\n", "!wget -q -O dir2/foo.png https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir2/foo.png\n", "os.mkdir('dir3')\n", "!wget -q -O dir3/dir3_bar.log https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir3/dir3_bar.log\n", "!wget -q -O dir3/dir3_foo.txt https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir3/dir3_foo.txt\n", "!wget -q -O dir3/dir4 https://github.com/gradio-app/gradio/raw/main/demo/file_explorer_component_events/dir3/dir4"]}, {"cell_type": "code", "execution_count": null, "id": "44380577570523278879349135829904343037", "metadata": {}, "outputs": [], "source": ["import gradio as gr\n", "from pathlib import Path\n", "\n", "base_root = Path(__file__).parent.resolve()\n", "\n", "with gr.Blocks() as demo:\n", " with gr.Row():\n", " dd = gr.Dropdown(label=\"Select File Explorer Root\",\n", " value=str(base_root / \"dir1\"),\n", " choices=[str(base_root / \"dir1\"), str(base_root / \"dir2\"),\n", " str(base_root / \"dir3\")])\n", " with gr.Group():\n", " txt_only_glob = gr.Checkbox(label=\"Show only text files\", value=False)\n", " ignore_txt_in_glob = gr.Checkbox(label=\"Ignore text files in glob\", value=False)\n", "\n", " fe = gr.FileExplorer(root_dir=str(base_root / \"dir1\"),\n", " glob=\"**/*\", interactive=True)\n", " textbox = gr.Textbox(label=\"Selected Directory\")\n", " run = gr.Button(\"Run\")\n", " \n", " txt_only_glob.select(lambda s: gr.FileExplorer(glob=\"*.txt\" if s else \"*\") ,\n", " inputs=[txt_only_glob], outputs=[fe])\n", " ignore_txt_in_glob.select(lambda s: gr.FileExplorer(ignore_glob=\"*.txt\" if s else None),\n", " inputs=[ignore_txt_in_glob], outputs=[fe]) \n", "\n", " dd.select(lambda s: gr.FileExplorer(root=s), inputs=[dd], outputs=[fe])\n", " run.click(lambda s: \",\".join(s) if isinstance(s, list) else s, inputs=[fe], outputs=[textbox])\n", "\n", " with gr.Row():\n", " a = gr.Textbox(elem_id=\"input-box\")\n", " a.change(lambda x: x, inputs=[a])\n", "\n", "\n", "if __name__ == \"__main__\":\n", " demo.launch()"]}], "metadata": {}, "nbformat": 4, "nbformat_minor": 5}

View File

@ -10,22 +10,18 @@ with gr.Blocks() as demo:
choices=[str(base_root / "dir1"), str(base_root / "dir2"),
str(base_root / "dir3")])
with gr.Group():
dir_only_glob = gr.Checkbox(label="Show only directories", value=False)
ignore_dir_in_glob = gr.Checkbox(label="Ignore directories in glob", value=False)
txt_only_glob = gr.Checkbox(label="Show only text files", value=False)
ignore_txt_in_glob = gr.Checkbox(label="Ignore text files in glob", value=False)
fe = gr.FileExplorer(root=str(base_root / "dir1"),
fe = gr.FileExplorer(root_dir=str(base_root / "dir1"),
glob="**/*", interactive=True)
textbox = gr.Textbox(label="Selected Directory")
run = gr.Button("Run")
dir_only_glob.select(lambda s: gr.FileExplorer(glob="**/" if s else "**/*.*",
file_count="multiple",
root=str(base_root / "dir3")) ,
inputs=[dir_only_glob], outputs=[fe])
ignore_dir_in_glob.select(lambda s: gr.FileExplorer(glob="**/*",
ignore_glob="**/",
root=str(base_root / "dir3")),
inputs=[ignore_dir_in_glob], outputs=[fe])
txt_only_glob.select(lambda s: gr.FileExplorer(glob="*.txt" if s else "*") ,
inputs=[txt_only_glob], outputs=[fe])
ignore_txt_in_glob.select(lambda s: gr.FileExplorer(ignore_glob="*.txt" if s else None),
inputs=[ignore_txt_in_glob], outputs=[fe])
dd.select(lambda s: gr.FileExplorer(root=s), inputs=[dd], outputs=[fe])
run.click(lambda s: ",".join(s) if isinstance(s, list) else s, inputs=[fe], outputs=[textbox])

View File

@ -2,9 +2,8 @@
from __future__ import annotations
import itertools
import fnmatch
import os
import re
import warnings
from pathlib import Path
from typing import Any, Callable, List, Literal
@ -33,7 +32,7 @@ class FileExplorer(Component):
def __init__(
self,
glob: str = "**/*.*",
glob: str = "**/*",
*,
value: str | list[str] | Callable | None = None,
file_count: Literal["single", "multiple"] = "multiple",
@ -59,7 +58,7 @@ class FileExplorer(Component):
value: The file (or list of files, depending on the `file_count` parameter) to show as "selected" when the component is first loaded. If a callable is provided, it will be called when the app loads to set the initial value of the component. If not provided, no files are shown as selected.
file_count: Whether to allow single or multiple files to be selected. If "single", the component will return a single absolute file path as a string. If "multiple", the component will return a list of absolute file paths as a list of strings.
root_dir: Path to root directory to select files from. If not provided, defaults to current working directory.
ignore_glob: The glob-tyle pattern that will be used to exclude files from the list. For example, "*.py" will exclude all .py files from the list. See the Python glob documentation at https://docs.python.org/3/library/glob.html for more information.
ignore_glob: The glob-style, case-sensitive pattern that will be used to exclude files from the list. For example, "*.py" will exclude all .py files from the list. See the Python glob documentation at https://docs.python.org/3/library/glob.html for more information.
label: The label for this component. Appears above the component and is also used as the header if there are a table of examples for this component. If None and used in a `gr.Interface`, the label will be the name of the parameter this component is assigned to.
every: If `value` is a callable, run the function 'every' number of seconds while the client connection is open. Has no effect otherwise.sed (e.g. to cancel it) via this component's .load_event attribute.
show_label: if True, will display label.
@ -82,10 +81,10 @@ class FileExplorer(Component):
self.root_dir = os.path.abspath(root_dir)
self.glob = glob
self.ignore_glob = ignore_glob
valid_file_count = ["single", "multiple", "directory"]
valid_file_count = ["single", "multiple"]
if file_count not in valid_file_count:
raise ValueError(
f"Invalid value for parameter `type`: {type}. Please choose from one of: {valid_file_count}"
f"Invalid value for parameter `file_count`: {file_count}. Please choose from one of: {valid_file_count}"
)
self.file_count = file_count
self.height = height
@ -156,80 +155,40 @@ class FileExplorer(Component):
return FileExplorerData(root=root)
@server
def ls(self, _=None) -> list[dict[str, str]] | None:
def ls(self, subdirectory: list | None = None) -> list[dict[str, str]] | None:
"""
Returns:
tuple of list of files in directory, then list of folders in directory
a list of dictionaries, where each dictionary represents a file or subdirectory in the given subdirectory
"""
if subdirectory is None:
subdirectory = []
def expand_braces(text, seen=None):
if seen is None:
seen = set()
full_subdir_path = self._safe_join(subdirectory)
spans = [m.span() for m in re.finditer("{[^{}]*}", text)][::-1]
alts = [text[start + 1 : stop - 1].split(",") for start, stop in spans]
try:
subdir_items = sorted(os.listdir(full_subdir_path))
except FileNotFoundError:
return []
if len(spans) == 0:
if text not in seen:
yield text
seen.add(text)
files, folders = [], []
for item in subdir_items:
full_path = os.path.join(full_subdir_path, item)
is_file = not os.path.isdir(full_path)
valid_by_glob = fnmatch.fnmatch(full_path, self.glob)
if is_file and not valid_by_glob:
continue
if self.ignore_glob and fnmatch.fnmatch(full_path, self.ignore_glob):
continue
target = files if is_file else folders
target.append(
{
"name": item,
"type": "file" if is_file else "folder",
"valid": valid_by_glob,
}
)
else:
for combo in itertools.product(*alts):
replaced = list(text)
for (start, stop), replacement in zip(spans, combo):
replaced[start:stop] = replacement
yield from expand_braces("".join(replaced), seen)
def make_tree(files):
tree = []
for file in files:
parts = file.split(os.path.sep)
make_node(parts, tree)
return tree
def make_node(parts, tree):
_tree = tree
for i in range(len(parts)):
if _tree is None:
continue
if i == len(parts) - 1:
type = "file"
_tree.append({"path": parts[i], "type": type, "children": None})
continue
type = "folder"
j = next(
(index for (index, v) in enumerate(_tree) if v["path"] == parts[i]),
None,
)
if j is not None:
_tree = _tree[j]["children"]
else:
_tree.append({"path": parts[i], "type": type, "children": []})
_tree = _tree[-1]["children"]
files: list[Path] = []
for result in expand_braces(self.glob):
files += list(Path(self.root_dir).resolve().glob(result))
files = [f for f in files if f != Path(self.root_dir).resolve()]
ignore_files = []
if self.ignore_glob:
for result in expand_braces(self.ignore_glob):
ignore_files += list(Path(self.root_dir).resolve().glob(result))
files = list(set(files) - set(ignore_files))
files_with_sep = []
for f in files:
file = str(f.relative_to(self.root_dir))
if f.is_dir():
file += os.path.sep
files_with_sep.append(file)
tree = make_tree(files_with_sep)
return tree
return folders + files
def _safe_join(self, folders):
combined_path = os.path.join(self.root_dir, *folders)

View File

@ -52,9 +52,9 @@ test("File Explorer correctly displays both directories and files. Directories i
await page
.locator("li")
.filter({ hasText: "dir4 dir5 dir7 . dir_4_foo.txt" })
.filter({ hasText: "dir4 . dir5 dir7 dir_4_bar.log dir_4_foo.txt" })
.getByRole("checkbox")
.nth(3)
.first()
.check();
await page
@ -97,20 +97,21 @@ test("File Explorer selects all children when top level directory is selected.",
const directory_paths_displayed = async () => {
const value = await page.getByLabel("Selected Directory").inputValue();
const files_and_dirs = value.split(",");
expect(files_and_dirs.length).toBe(6);
expect(files_and_dirs.length).toBe(7);
};
await expect(directory_paths_displayed).toPass();
});
test("File Explorer correctly displays only directories and properly adds it to the value", async ({
page
}) => {
test("File Explorer correctly displays only text files", async ({ page }) => {
const check = page.getByRole("checkbox", {
name: "Show only directories",
name: "Show only text files",
exact: true
});
await check.click();
await page.getByLabel("Select File Explorer Root").click();
await page.getByLabel(new RegExp("/dir3$"), { exact: true }).first().click();
await page
.locator("span")
.filter({ hasText: "dir4" })
@ -118,25 +119,27 @@ test("File Explorer correctly displays only directories and properly adds it to
.check();
await page.getByRole("button", { name: "Run" }).click();
const directory_paths_displayed = async () => {
const text_files_displayed = async () => {
const value = await page.getByLabel("Selected Directory").inputValue();
const dirs = value.split(",");
expect(dirs.some((f) => f.endsWith("dir4"))).toBeTruthy();
expect(dirs.some((f) => f.endsWith("dir5"))).toBeTruthy();
expect(dirs.some((f) => f.endsWith("dir7"))).toBeTruthy();
expect(dirs.length).toBe(3);
expect(dirs.every((d) => d.endsWith(".txt"))).toBeTruthy();
};
await expect(directory_paths_displayed).toPass();
await expect(text_files_displayed).toPass();
});
test("File Explorer correctly excludes directories when ignore_glob is '**/'.", async ({
test("File Explorer correctly excludes text files when ignore_glob is '*.txt'.", async ({
page
}) => {
const check = page.getByRole("checkbox", {
name: "Ignore directories in glob",
name: "Ignore text files in glob",
exact: true
});
await check.click();
await page.getByLabel("Select File Explorer Root").click();
await page.getByLabel(new RegExp("/dir3$"), { exact: true }).first().click();
await page
.locator("span")
.filter({ hasText: "dir4" })
@ -147,10 +150,9 @@ test("File Explorer correctly excludes directories when ignore_glob is '**/'.",
const only_files_displayed = async () => {
const value = await page.getByLabel("Selected Directory").inputValue();
const files = value.split(",");
expect(files.length).toBe(3);
expect(files.some((f) => f.endsWith("dir_4_foo.txt"))).toBeTruthy();
expect(files.some((f) => f.endsWith("dir5_foo.txt"))).toBeTruthy();
expect(files.some((f) => f.endsWith("dir7_foo.txt"))).toBeTruthy();
expect(files.length).toBe(4);
expect(files.some((f) => f.endsWith(".log"))).toBeTruthy();
expect(files.some((f) => f.endsWith(".txt"))).toBeFalsy();
};
await expect(only_files_displayed).toPass();
});

View File

@ -3,6 +3,7 @@
<script lang="ts">
import type { Gradio, SelectData } from "@gradio/utils";
import { File } from "@gradio/icons";
import type { FileNode } from "./shared/types";
import { Block, BlockLabel } from "@gradio/atoms";
import DirectoryExplorer from "./shared/DirectoryExplorer.svelte";
@ -20,11 +21,9 @@
export let show_label: boolean;
export let height: number | undefined = undefined;
export let file_count: "single" | "multiple" = "multiple";
export let root_dir: string;
export let glob: string;
export let ignore_glob: string;
export let root_dir: string;
export let loading_status: LoadingStatus;
export let container = true;
export let scale: number | null = null;
@ -33,9 +32,11 @@
change: never;
}>;
export let server: {
ls: (path: string[]) => Promise<[string[], string[]]>;
ls: (path: string[]) => Promise<FileNode[]>;
};
export let interactive: boolean;
$: rerender_key = [root_dir, glob, ignore_glob];
</script>
<Block
@ -62,14 +63,13 @@
label={label || "FileExplorer"}
float={false}
/>
<DirectoryExplorer
bind:value
{file_count}
{server}
{interactive}
{root_dir}
{glob}
{ignore_glob}
on:change={() => gradio.dispatch("change")}
/>
{#key rerender_key}
<DirectoryExplorer
bind:value
{file_count}
{interactive}
ls_fn={server.ls}
on:change={() => gradio.dispatch("change")}
/>
{/key}
</Block>

View File

@ -7,10 +7,8 @@
<input
bind:checked={value}
on:input={() => dispatch("change", !value)}
type="checkbox"
on:click={() => dispatch("change", value)}
on:keydown={({ key }) =>
(key === " " || key === "Enter") && dispatch("change", value)}
{disabled}
class:disabled={disabled && !value}
/>
@ -47,14 +45,6 @@
}
.disabled {
cursor: not-allowed;
border-color: var(--checkbox-border-color-hover);
background-color: var(--checkbox-background-color-hover);
}
input:disabled:checked,
input:disabled:checked:hover,
.disabled:checked:focus {
opacity: 0.8 !important;
cursor: not-allowed;
}

View File

@ -1,72 +1,65 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { dequal } from "dequal";
import FileTree from "./FileTree.svelte";
import { make_fs_store } from "./utils";
import { File } from "@gradio/icons";
import { Empty } from "@gradio/atoms";
import type { FileNode } from "./types";
export let glob: string;
export let ignore_glob: string;
export let root_dir: string;
export let interactive: boolean;
export let server: any;
export let file_count: "single" | "multiple" = "multiple";
export let value: string[][] = [];
export let ls_fn: (path: string[]) => Promise<FileNode[]>;
let selected_folders: string[][] = [];
const dispatch = createEventDispatcher<{
change: typeof value;
}>();
const tree = make_fs_store();
const render_tree = (): void => {
server.ls().then((v: any) => {
tree.create_fs_graph(v);
});
const paths_equal = (path: string[], path_2: string[]): boolean => {
return path.join("/") === path_2.join("/");
};
$: glob, ignore_glob, root_dir, render_tree();
const path_in_set = (path: string[], set: string[][]): boolean => {
return set.some((x) => paths_equal(x, path));
};
$: value.length && $tree && set_checked_from_paths();
function set_checked_from_paths(): void {
value = file_count === "single" ? [value[0] || []] : value;
value = tree.set_checked_from_paths(value);
if (!dequal(value, old_value)) {
old_value = value;
dispatch("change", value);
}
}
let old_value: typeof value = [];
function handle_select({
node_indices,
checked
}: {
node_indices: number[];
checked: boolean;
}): void {
value = tree.set_checked(node_indices, checked, value, file_count);
if (!dequal(value, old_value)) {
old_value = value;
dispatch("change", value);
}
}
const path_inside = (path: string[], path_2: string[]): boolean => {
return path.join("/").startsWith(path_2.join("/"));
};
</script>
{#if $tree && $tree.length}
<div class="file-wrap">
<FileTree
tree={$tree}
{interactive}
on:check={({ detail }) => handle_select(detail)}
{file_count}
/>
</div>
{:else}
<Empty size="large"><File /></Empty>
{/if}
<div class="file-wrap">
<FileTree
path={[]}
selected_files={value}
{selected_folders}
{interactive}
{ls_fn}
{file_count}
valid_for_selection={false}
on:check={(e) => {
const { path, checked, type } = e.detail;
if (checked) {
if (file_count === "single") {
value = [path];
} else if (type === "folder") {
if (!path_in_set(path, selected_folders)) {
selected_folders = [...selected_folders, path];
}
} else {
if (!path_in_set(path, value)) {
value = [...value, path];
}
}
} else {
selected_folders = selected_folders.filter(
(folder) => !path_inside(path, folder)
); // deselect all parent folders
if (type === "folder") {
selected_folders = selected_folders.filter(
(folder) => !path_inside(folder, path)
); // deselect all children folders
value = value.filter((file) => !path_inside(file, path)); // deselect all children files
} else {
value = value.filter((x) => !paths_equal(x, path));
}
}
}}
/>
</div>
<style>
.file-wrap {

View File

@ -1,76 +1,129 @@
<script lang="ts">
import type { Node } from "./utils";
import { createEventDispatcher, tick } from "svelte";
import type { FileNode } from "./types";
import { createEventDispatcher } from "svelte";
import Arrow from "./ArrowIcon.svelte";
import Checkbox from "./Checkbox.svelte";
import FileIcon from "../icons/light-file.svg";
import FolderIcon from "../icons/light-folder.svg";
export let path: string[] = [];
export let selected_files: string[][] = [];
export let selected_folders: string[][] = [];
export let is_selected_entirely = false;
export let interactive: boolean;
export let tree: Node[] = [];
export let icons: any = {};
export let node_indices: number[] = [];
export let ls_fn: (path: string[]) => Promise<FileNode[]>;
export let file_count: "single" | "multiple" = "multiple";
export let valid_for_selection: boolean;
const dispatch = createEventDispatcher<{
check: { node_indices: number[]; checked: boolean };
}>();
let content: FileNode[] = [];
let opened_folders: number[] = [];
async function dispatch_change(i: number): Promise<void> {
await tick();
const toggle_open_folder = (i: number): void => {
if (opened_folders.includes(i)) {
opened_folders = opened_folders.filter((x) => x !== i);
} else {
opened_folders = [...opened_folders, i];
}
};
dispatch("check", {
node_indices: [...node_indices, i],
checked: !tree[i].checked
const open_folder = (i: number): void => {
if (!opened_folders.includes(i)) {
opened_folders = [...opened_folders, i];
}
};
(async () => {
content = await ls_fn(path);
if (valid_for_selection) {
content = [{ name: ".", type: "file" }, ...content];
}
opened_folders = content
.map((x, i) =>
x.type === "folder" &&
(is_selected_entirely || selected_files.some((y) => y[0] === x.name))
? i
: null
)
.filter((x): x is number => x !== null);
})();
$: if (is_selected_entirely) {
content.forEach((x) => {
dispatch("check", {
path: [...path, x.name],
checked: true,
type: x.type
});
});
}
const dispatch = createEventDispatcher<{
check: { path: string[]; checked: boolean; type: "file" | "folder" };
}>();
</script>
<ul>
{#each tree as { type, path, children, children_visible, checked }, i}
{#each content as { type, name, valid }, i}
<li>
<span class="wrap">
<Checkbox
disabled={!interactive ||
(type === "folder" && file_count === "single")}
bind:value={checked}
on:change={() => dispatch_change(i)}
value={(type === "file" ? selected_files : selected_folders).some(
(x) => x[0] === name && x.length === 1
)}
on:change={(e) => {
let checked = e.detail;
dispatch("check", {
path: [...path, name],
checked,
type
});
if (type === "folder" && checked) {
open_folder(i);
}
}}
/>
{#if type === "folder"}
<span
class="icon"
class:hidden={!tree[i].children_visible}
on:click|stopPropagation={() =>
(tree[i].children_visible = !tree[i].children_visible)}
class:hidden={!opened_folders.includes(i)}
on:click|stopPropagation={() => toggle_open_folder(i)}
role="button"
aria-label="expand directory"
tabindex="0"
on:keydown={({ key }) =>
(key === " " || key === "Enter") &&
(tree[i].children_visible = !tree[i].children_visible)}
><Arrow /></span
on:keydown={({ key }) => {
if (key === " " || key === "Enter") {
toggle_open_folder(i);
}
}}><Arrow /></span
>
{:else if path === ""}
<span class="file-icon">
<img src={FolderIcon} alt="folder icon" />
</span>
{:else}
<span class="file-icon">
<img src={FileIcon} alt="file icon" />
<img src={name === "." ? FolderIcon : FileIcon} alt="file icon" />
</span>
{/if}
{path ? path : "."}
{name}
</span>
{#if children && children_visible}
{#if type === "folder" && opened_folders.includes(i)}
<svelte:self
tree={children}
{icons}
on:check
node_indices={[...node_indices, i]}
path={[...path, name]}
selected_files={selected_files
.filter((x) => x[0] === name)
.map((x) => x.slice(1))}
selected_folders={selected_folders
.filter((x) => x[0] === name)
.map((x) => x.slice(1))}
is_selected_entirely={selected_folders.some(
(x) => x[0] === name && x.length === 1
)}
{interactive}
{ls_fn}
{file_count}
valid_for_selection={valid}
on:check
/>
{/if}
</li>

View File

@ -0,0 +1,5 @@
export interface FileNode {
type: "file" | "folder";
name: string;
valid?: boolean;
}

View File

@ -1,187 +0,0 @@
import { describe, test, expect, beforeAll } from "vitest";
import { make_fs_store, type SerialisedNode } from "./utils";
import { get } from "svelte/store";
describe("fs_store", () => {
let store: ReturnType<typeof make_fs_store>,
n0: SerialisedNode,
n1: SerialisedNode,
n2: SerialisedNode,
n3: SerialisedNode,
n4: SerialisedNode,
n5: SerialisedNode;
beforeAll(() => {
n4 = {
type: "file",
path: "d.txt",
parent: null,
children: []
};
n5 = {
type: "file",
path: "e.txt",
parent: null,
children: []
};
n3 = {
type: "folder",
path: "c",
parent: null,
children: [n4, n5]
};
n2 = {
type: "folder",
path: "b",
parent: null,
children: [n3]
};
n1 = {
type: "folder",
path: "a",
parent: null,
children: [n2]
};
n0 = {
type: "file",
path: "my-file.txt",
parent: null,
children: []
};
store = make_fs_store();
});
test("initialise store with correct references", () => {
store.create_fs_graph([n1, n0]);
const items = get(store);
expect(items?.[0].last).toEqual(items?.[1]);
expect(items?.[1].last).toEqual(items?.[1]);
expect(items?.[1].previous).toEqual(items?.[0]);
expect(items?.[0].previous).toEqual(null);
expect(items?.[0].parent).toEqual(null);
expect(items?.[0].children?.[0].parent).toEqual(items?.[0]);
});
test("set_checked_from_paths", () => {
const checked_paths = [
["a", "b", "c", "d.txt"],
["a", "b", "c", "e.txt"]
];
const new_checked_paths = store.set_checked_from_paths(checked_paths);
const items = get(store);
expect(new_checked_paths).toEqual(checked_paths);
expect(items?.[0].checked).toEqual(true);
});
test("set_checked_from_paths should be deterministic", () => {
const checked_paths = [
["a", "b", "c", "d.txt"],
["a", "b", "c", "e.txt"]
];
const new_checked_paths = store.set_checked_from_paths(checked_paths);
const items = get(store);
expect(new_checked_paths).toEqual(checked_paths);
expect(items?.[0].checked).toEqual(true);
});
test("set_checked should check the appropriate index", () => {
const checked_indices = [0, 0, 0, 0];
store.set_checked(checked_indices, false, [], "multiple");
const items = get(store);
expect(
items?.[0].children?.[0].children?.[0].children?.[0].checked
).toEqual(false);
});
test("if all children are set to false then all parents should also be false", () => {
const checked_indices = [0, 0, 0, 1];
store.set_checked(checked_indices, false, [], "multiple");
const items = get(store);
expect(
items?.[0].children?.[0].children?.[0].children?.[0].checked
).toEqual(false);
expect(
items?.[0].children?.[0].children?.[0].children?.[1].checked
).toEqual(false);
expect(items?.[0].children?.[0].children?.[0].checked).toEqual(false);
expect(items?.[0].children?.[0].checked).toEqual(false);
expect(items?.[0].checked).toEqual(false);
});
test("if only one child is set to true then parent should be false", () => {
const checked_indices = [0, 0, 0, 1];
store.set_checked(checked_indices, true, [], "multiple");
const items = get(store);
expect(
items?.[0].children?.[0].children?.[0].children?.[0].checked
).toEqual(false);
expect(
items?.[0].children?.[0].children?.[0].children?.[1].checked
).toEqual(true);
expect(items?.[0].children?.[0].children?.[0].checked).toEqual(false);
expect(items?.[0].children?.[0].checked).toEqual(false);
expect(items?.[0].checked).toEqual(false);
});
test("if all children are set to true then parents should be true", () => {
const checked_indices = [0, 0, 0, 0];
store.set_checked(checked_indices, true, [], "multiple");
const items = get(store);
expect(
items?.[0].children?.[0].children?.[0].children?.[0].checked
).toEqual(true);
expect(
items?.[0].children?.[0].children?.[0].children?.[1].checked
).toEqual(true);
expect(items?.[0].children?.[0].children?.[0].checked).toEqual(true);
expect(items?.[0].children?.[0].checked).toEqual(true);
expect(items?.[0].checked).toEqual(true);
});
test("calling set_checked multiple times should not impact other nodes", () => {
store.set_checked([1], true, [], "multiple");
expect(get(store)?.[1].checked).toEqual(true);
store.set_checked([0], true, [], "multiple");
expect(get(store)?.[1].checked).toEqual(true);
store.set_checked([0], false, [], "multiple");
expect(get(store)?.[1].checked).toEqual(true);
store.set_checked([0], true, [], "multiple");
expect(get(store)?.[1].checked).toEqual(true);
store.set_checked([0], false, [], "multiple");
expect(get(store)?.[1].checked).toEqual(true);
const items = get(store);
// expect(
// items?.[0].children?.[0].children?.[0].children?.[0].checked
// ).toEqual(true);
// expect(
// items?.[0].children?.[0].children?.[0].children?.[1].checked
// ).toEqual(true);
// expect(items?.[0].children?.[0].children?.[0].checked).toEqual(true);
// expect(items?.[0].children?.[0].checked).toEqual(true);
// expect(items?.[0].checked).toEqual(true);
});
});

View File

@ -1,273 +0,0 @@
import { writable, type Readable } from "svelte/store";
import { dequal } from "dequal";
export interface Node {
type: "file" | "folder";
path: string;
children?: Node[];
checked: boolean;
children_visible: boolean;
last?: Node | null;
parent: Node | null;
previous?: Node | null;
}
export type SerialisedNode = Omit<
Node,
"checked" | "children_visible" | "children"
> & { children?: SerialisedNode[] };
interface FSStore {
subscribe: Readable<Node[] | null>["subscribe"];
create_fs_graph: (serialised_node: SerialisedNode[]) => void;
set_checked: (
indices: number[],
checked: boolean,
checked_paths: string[][],
file_count: "single" | "multiple"
) => string[][];
set_checked_from_paths: (checked_paths: string[][]) => string[][];
}
export const make_fs_store = (): FSStore => {
const { subscribe, set } = writable<Node[] | null>(null);
let root: Node = {
type: "folder",
path: "",
checked: false,
children_visible: false,
parent: null
};
let tree_updated = false;
function create_fs_graph(serialised_node: SerialisedNode[]): void {
root.children = process_tree(serialised_node);
tree_updated = true;
set(root.children);
}
let old_checked_paths: string[][] = [];
function set_checked_from_paths(checked_paths: string[][]): string[][] {
if (dequal(checked_paths, old_checked_paths) && !tree_updated) {
return checked_paths;
}
old_checked_paths = checked_paths;
check_node_and_children(root.children, false, []);
const new_checked_paths: string[][] = [];
const seen_nodes = new Set();
for (let i = 0; i < checked_paths.length; i++) {
let _node = root;
let _path = [];
for (let j = 0; j < checked_paths[i].length; j++) {
if (!_node?.children) {
continue;
}
_path.push(checked_paths[i][j]);
_node = _node.children!.find((v) => v.path === checked_paths[i][j])!;
}
if (!_node) {
continue;
}
_node.checked = true;
ensure_visible(_node);
const nodes = check_node_and_children(_node.children, true, [_node]);
check_parent(_node);
nodes.forEach((node) => {
const path = get_full_path(node);
const normalized_path = path.join("/");
// let normalized_path = path.join("/");
// normalized_path = normalized_path.endsWith("/") ? normalized_path.slice(0, -1) : normalized_path;
if (node.type === "file") {
if (seen_nodes.has(normalized_path)) {
return;
}
new_checked_paths.push(path);
seen_nodes.add(normalized_path);
}
});
}
set(root.children!);
tree_updated = false;
return new_checked_paths;
}
function set_checked(
indices: number[],
checked: boolean,
checked_paths: string[][],
file_count: "single" | "multiple"
): string[][] {
let _node = root;
if (file_count === "single") {
check_node_and_children(root.children, false, []);
set(root.children!);
}
for (let i = 0; i < indices.length; i++) {
_node = _node.children![indices[i]];
}
_node.checked = checked;
const nodes = check_node_and_children(_node.children, checked, [_node]);
let new_checked_paths = new Map(checked_paths.map((v) => [v.join("/"), v]));
for (let i = 0; i < nodes.length; i++) {
const _path = get_full_path(nodes[i]);
if (!nodes[i].checked) {
new_checked_paths.delete(_path.join("/"));
} else if (nodes[i].checked) {
if (file_count === "single") {
new_checked_paths = new Map();
}
new_checked_paths.set(_path.join("/"), _path);
}
}
check_parent(_node);
set(root.children!);
old_checked_paths = Array.from(new_checked_paths).map((v) => v[1]);
return old_checked_paths;
}
return {
subscribe,
create_fs_graph,
set_checked,
set_checked_from_paths
};
};
function ensure_visible(node: Node): void {
if (node.parent) {
node.parent.children_visible = true;
ensure_visible(node.parent);
}
}
function process_tree(
node: SerialisedNode[],
depth = 0,
path_segments: string[] = [],
parent: Node | null = null
): Node[] {
const folders: Node[] = [];
const files: Node[] = [];
for (let i = 0; i < node.length; i++) {
let n: (typeof node)[number] = node[i];
if (n.type === "file") {
let index = files.findIndex(
(v) => v.path.toLocaleLowerCase() >= n.path.toLocaleLowerCase()
);
const _node: Node = {
children: undefined,
type: "file",
path: n.path,
checked: false,
children_visible: false,
parent: parent
};
files.splice(index === -1 ? files.length : index, 0, _node);
} else {
let index = folders.findIndex(
(v) => v.path.toLocaleLowerCase() >= n.path.toLocaleLowerCase()
);
const _node: Node = {
type: "folder",
path: n.path,
checked: false,
children_visible: false,
parent: parent
};
const children = process_tree(
n.children!,
depth + 1,
[...path_segments, n.path],
_node
);
_node.children = children;
folders.splice(index === -1 ? folders.length : index, 0, _node);
}
}
const last = files[files.length - 1] || folders[folders.length - 1];
for (let i = 0; i < folders.length; i++) {
folders[i].last = last;
folders[i].previous = folders[i - 1] || null;
}
for (let i = 0; i < files.length; i++) {
if (i === 0) {
files[i].previous = folders[folders.length - 1] || null;
} else {
files[i].previous = files[i - 1] || null;
}
files[i].last = last;
}
return Array().concat(folders, files);
}
function get_full_path(node: Node, path: string[] = []): string[] {
const new_path = [node.path, ...path];
if (node.parent) {
return get_full_path(node.parent, new_path);
}
return new_path;
}
function check_node_and_children(
node: Node[] | null | undefined,
checked: boolean,
checked_nodes: Node[]
): Node[] {
if (node === null || node === undefined) return checked_nodes;
for (let i = 0; i < node.length; i++) {
node[i].checked = checked;
checked_nodes.push(node[i]);
if (checked) ensure_visible(node[i]);
checked_nodes.concat(
check_node_and_children(node[i].children, checked, checked_nodes)
);
}
return checked_nodes;
}
function check_parent(node: Node | null | undefined): void {
if (node === null || node === undefined || !node.parent) return;
let _node = node.last;
let nodes_checked = [];
while (_node) {
nodes_checked.push(_node.checked);
_node = _node.previous;
}
if (nodes_checked.every((v) => v === true)) {
node.parent!.checked = true;
check_parent(node?.parent);
} else if (nodes_checked.some((v) => v === false)) {
node.parent!.checked = false;
check_parent(node?.parent);
}
}

View File

@ -23,6 +23,7 @@ def copy_all_demos(source_dir: str, dest_dir: str):
"code",
"fake_gan",
"fake_diffusion_with_gif",
"file_explorer_component_events",
"image_mod_default_image",
"image_segmentation",
"interface_random_slider",

View File

@ -2874,7 +2874,7 @@ class TestFileExplorer:
file_explorer = gr.FileExplorer(file_count="single")
config = file_explorer.get_config()
assert config["glob"] == "**/*.*"
assert config["glob"] == "**/*"
assert config["value"] is None
assert config["file_count"] == "single"
assert config["server_fns"] == ["ls"]
@ -2891,7 +2891,7 @@ class TestFileExplorer:
file_explorer = gr.FileExplorer(file_count="multiple")
config = file_explorer.get_config()
assert config["glob"] == "**/*.*"
assert config["glob"] == "**/*"
assert config["value"] is None
assert config["file_count"] == "multiple"
assert config["server_fns"] == ["ls"]
@ -2905,66 +2905,24 @@ class TestFileExplorer:
preprocessed_data = file_explorer.preprocess(input_data)
assert preprocessed_data == []
def test_file_explorer_dir_only_glob(self, tmpdir):
def test_file_explorer_txt_only_glob(self, tmpdir):
tmpdir.mkdir("foo")
tmpdir.mkdir("bar")
tmpdir.mkdir("baz")
(Path(tmpdir) / "baz" / "qux").mkdir()
(Path(tmpdir) / "foo" / "abc").mkdir()
(Path(tmpdir) / "foo" / "abc" / "def").mkdir()
(Path(tmpdir) / "foo" / "abc" / "def" / "file.txt").touch()
(Path(tmpdir) / "foo" / "bar").mkdir()
(Path(tmpdir) / "foo" / "file.txt").touch()
(Path(tmpdir) / "foo" / "file2.txt").touch()
(Path(tmpdir) / "foo" / "file3.log").touch()
(Path(tmpdir) / "foo" / "img.png").touch()
(Path(tmpdir) / "foo" / "bar" / "bar.txt").touch()
file_explorer = gr.FileExplorer(glob="**/", root=Path(tmpdir))
tree = file_explorer.ls()
def sort_answer(answer):
answer = sorted(answer, key=lambda x: x["path"])
for item in answer:
if item["children"]:
item["children"] = sort_answer(item["children"])
return answer
file_explorer = gr.FileExplorer(glob="*.txt", root=Path(tmpdir))
tree = file_explorer.ls(["foo"])
answer = [
{
"path": "bar",
"type": "folder",
"children": [{"path": "", "type": "file", "children": None}],
},
{
"path": "baz",
"type": "folder",
"children": [
{"path": "", "type": "file", "children": None},
{
"path": "qux",
"type": "folder",
"children": [{"path": "", "type": "file", "children": None}],
},
],
},
{
"path": "foo",
"type": "folder",
"children": [
{"path": "", "type": "file", "children": None},
{
"path": "abc",
"type": "folder",
"children": [
{"path": "", "type": "file", "children": None},
{
"path": "def",
"type": "folder",
"children": [
{"path": "", "type": "file", "children": None}
],
},
],
},
],
},
{"name": "bar", "type": "folder", "valid": False},
{"name": "file.txt", "type": "file", "valid": True},
{"name": "file2.txt", "type": "file", "valid": True},
]
assert sort_answer(tree) == sort_answer(answer)
assert tree == answer
def test_component_class_ids():

View File

@ -7,7 +7,6 @@ import warnings
from pathlib import Path
from unittest.mock import MagicMock, patch
import httpx
import pytest
from typing_extensions import Literal
@ -20,6 +19,7 @@ from gradio.utils import (
check_function_inputs_match,
colab_check,
delete_none,
download_if_url,
get_continuous_fn,
get_extension_from_file_path_or_url,
get_type_hints,
@ -27,7 +27,6 @@ from gradio.utils import (
is_in_or_equal,
is_special_typed_parameter,
kaggle_check,
download_if_url,
sagemaker_check,
sanitize_list_for_csv,
sanitize_value_for_csv,