diff --git a/.changeset/itchy-radios-pay.md b/.changeset/itchy-radios-pay.md new file mode 100644 index 000000000000..38ac2db67bb3 --- /dev/null +++ b/.changeset/itchy-radios-pay.md @@ -0,0 +1,24 @@ +--- +"@gradio/app": minor +"@gradio/client": minor +"@gradio/file": minor +"@gradio/fileexplorer": minor +"@gradio/theme": minor +"gradio": minor +"gradio_client": minor +--- + +highlight: + +#### new `FileExplorer` component + +Thanks to a new capability that allows components to communicate directly with the server _without_ passing data via the value, we have created a new `FileExplorer` component. + +This component allows you to populate the explorer by passing a glob, but only provides the selected file(s) in your prediction function. + +Users can then navigate the virtual filesystem and select files which will be accessible in your predict function. This component will allow developers to build more complex spaces, with more flexible input options. + +![output](https://github.com/pngwn/MDsveX/assets/12937446/ef108f0b-0e84-4292-9984-9dc66b3e144d) + +For more information check the [`FileExplorer` documentation](https://gradio.app/docs/fileexplorer). + diff --git a/client/js/src/client.ts b/client/js/src/client.ts index 3880c246de1b..d32be1ab2325 100644 --- a/client/js/src/client.ts +++ b/client/js/src/client.ts @@ -45,6 +45,11 @@ type client_return = { data?: unknown[], event_data?: unknown ) => SubmitReturn; + component_server: ( + component_id: number, + fn_name: string, + data: unknown[] + ) => any; view_api: (c?: Config) => Promise>; }; @@ -243,7 +248,8 @@ export function api_factory( const return_obj = { predict, submit, - view_api + view_api, + component_server // duplicate }; @@ -710,6 +716,51 @@ export function api_factory( }; } + async function component_server( + component_id: number, + fn_name: string, + data: unknown[] + ): Promise { + const headers: { + Authorization?: string; + "Content-Type": "application/json"; + } = { "Content-Type": "application/json" }; + if (hf_token) { + headers.Authorization = `Bearer ${hf_token}`; + } + let root_url: string; + let component = config.components.find( + (comp) => comp.id === component_id + ); + if (component?.props?.root_url) { + root_url = component.props.root_url; + } else { + root_url = `${http_protocol}//${host + config.path}/`; + } + const response = await fetch_implementation( + `${root_url}component_server/`, + { + method: "POST", + body: JSON.stringify({ + data: data, + component_id: component_id, + fn_name: fn_name, + session_hash: session_hash + }), + headers + } + ); + + if (!response.ok) { + throw new Error( + "Could not connect to component server: " + response.statusText + ); + } + + const output = await response.json(); + return output; + } + async function view_api(config?: Config): Promise> { if (api) return api; diff --git a/client/python/gradio_client/serializing.py b/client/python/gradio_client/serializing.py index 0919b58e2b7d..b6548f8fc365 100644 --- a/client/python/gradio_client/serializing.py +++ b/client/python/gradio_client/serializing.py @@ -573,6 +573,7 @@ def deserialize( "file": FileSerializable, "dataframe": JSONSerializable, "timeseries": JSONSerializable, + "fileexplorer": JSONSerializable, "state": SimpleSerializable, "button": StringSerializable, "uploadbutton": FileSerializable, diff --git a/demo/file_explorer/run.ipynb b/demo/file_explorer/run.ipynb new file mode 100644 index 000000000000..de782294fca5 --- /dev/null +++ b/demo/file_explorer/run.ipynb @@ -0,0 +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} \ No newline at end of file diff --git a/demo/file_explorer/run.py b/demo/file_explorer/run.py new file mode 100644 index 000000000000..6db60c188890 --- /dev/null +++ b/demo/file_explorer/run.py @@ -0,0 +1,51 @@ +import gradio as gr +from pathlib import Path + +current_file_path = Path(__file__).resolve() +relative_path = "path/to/file" +absolute_path = (current_file_path.parent / ".." / ".." / "gradio").resolve() + + +def get_file_content(file): + return (file,) + + +with gr.Blocks() as demo: + gr.Markdown('### `FileExplorer` to `FileExplorer` -- `file_count="multiple"`') + submit_btn = gr.Button("Select") + with gr.Row(): + file = gr.FileExplorer( + glob="**/{components,themes}/*.py", + # value=["themes/utils"], + root=absolute_path, + ignore_glob="**/__init__.py", + ) + + file2 = gr.FileExplorer( + glob="**/{components,themes}/**/*.py", + root=absolute_path, + ignore_glob="**/__init__.py", + ) + submit_btn.click(lambda x: x, file, file2) + + gr.Markdown("---") + gr.Markdown('### `FileExplorer` to `Code` -- `file_count="single"`') + with gr.Group(): + with gr.Row(): + file_3 = gr.FileExplorer( + scale=1, + glob="**/{components,themes}/**/*.py", + value=["themes/utils"], + file_count="single", + root=absolute_path, + ignore_glob="**/__init__.py", + elem_id="file", + ) + + code = gr.Code(lines=30, scale=2, language="python") + + file_3.change(get_file_content, file_3, code) + + +if __name__ == "__main__": + demo.launch() diff --git a/gradio/__init__.py b/gradio/__init__.py index 2cefd271dadb..e615c9578207 100644 --- a/gradio/__init__.py +++ b/gradio/__init__.py @@ -30,6 +30,7 @@ Dropdown, DuplicateButton, File, + FileExplorer, Gallery, Highlight, HighlightedText, diff --git a/gradio/blocks.py b/gradio/blocks.py index a6ee4fdf7745..074d965c4a44 100644 --- a/gradio/blocks.py +++ b/gradio/blocks.py @@ -731,6 +731,7 @@ def get_block_instance(id: int) -> Block: block_config["props"].pop("type", None) block_config["props"].pop("name", None) block_config["props"].pop("selectable", None) + block_config["props"].pop("server_fns", None) # If a Gradio app B is loaded into a Gradio app A, and B itself loads a # Gradio app C, then the root_urls of the components in A need to be the diff --git a/gradio/components/__init__.py b/gradio/components/__init__.py index b39464312237..e98213f21e81 100644 --- a/gradio/components/__init__.py +++ b/gradio/components/__init__.py @@ -25,6 +25,7 @@ from gradio.components.dropdown import Dropdown from gradio.components.duplicate_button import DuplicateButton from gradio.components.file import File +from gradio.components.file_explorer import FileExplorer from gradio.components.gallery import Gallery from gradio.components.highlighted_text import HighlightedText from gradio.components.html import HTML @@ -82,6 +83,7 @@ "FormComponent", "Gallery", "HTML", + "FileExplorer", "Image", "IOComponent", "Interpretation", diff --git a/gradio/components/base.py b/gradio/components/base.py index 8bf266ca96cd..431a49c787c7 100644 --- a/gradio/components/base.py +++ b/gradio/components/base.py @@ -58,6 +58,11 @@ class Component(Updateable, Block, Serializable): def __init__(self, *args, **kwargs): Block.__init__(self, *args, **kwargs) EventListener.__init__(self) + self.server_fns = [ + value + for value in self.__class__.__dict__.values() + if callable(value) and getattr(value, "_is_server_fn", False) + ] def __str__(self): return self.__repr__() @@ -112,6 +117,17 @@ def style(self, *args, **kwargs): self.parent.variant = "compact" return self + def get_config(self): + config = super().get_config() + if len(self.server_fns): + config["server_fns"] = [fn.__name__ for fn in self.server_fns] + return config + + +def server(fn): + fn._is_server_fn = True + return fn + class IOComponent(Component): """ diff --git a/gradio/components/file_explorer.py b/gradio/components/file_explorer.py new file mode 100644 index 000000000000..d1a4e83444c4 --- /dev/null +++ b/gradio/components/file_explorer.py @@ -0,0 +1,219 @@ +"""gr.FileExplorer() component""" + +from __future__ import annotations + +import itertools +import os +import re +from glob import glob as glob_func +from pathlib import Path +from typing import Callable, Literal + +from gradio_client.documentation import document, set_documentation_group +from gradio_client.serializing import JSONSerializable + +from gradio.components.base import IOComponent, server +from gradio.events import ( + Changeable, + EventListenerMethod, +) + +set_documentation_group("component") + + +@document() +class FileExplorer(Changeable, IOComponent, JSONSerializable): + """ + Creates a file component that allows uploading generic file (when used as an input) and or displaying generic files (output). + Preprocessing: passes the selected file or directory as a {str} path (relative to root) or {list[str}} depending on `file_count` + Postprocessing: expects function to return a {str} path to a file, or {List[str]} consisting of paths to files. + Examples-format: a {str} path to a local file that populates the component. + Demos: zip_to_json, zip_files + """ + + def __init__( + self, + glob: str = "**/*.*", + *, + value: str | list[str] | Callable | None = None, + file_count: Literal["single", "multiple"] = "multiple", + root: str | Path = ".", + ignore_glob: str | None = None, + label: str | None = None, + every: float | None = None, + show_label: bool | None = None, + container: bool = True, + scale: int | None = None, + min_width: int = 160, + height: int | float | None = None, + interactive: bool | None = None, + visible: bool = True, + elem_id: str | None = None, + elem_classes: list[str] | str | None = None, + **kwargs, + ): + """ + Parameters: + glob: The glob-style pattern used to select which files to display, e.g. "*" to match all files, "*.png" to match all .png files, "**/*.txt" to match any .txt file in any subdirectory, etc. The default value matches all files and folders recursively. See the Python glob documentation at https://docs.python.org/3/library/glob.html for more information. + 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: 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. + label: Component name in interface. + every: If `value` is a callable, run the function 'every' number of seconds while the client connection is open. Has no effect otherwise. Queue must be enabled. The event can be accessed (e.g. to cancel it) via this component's .load_event attribute. + show_label: if True, will display label. + container: If True, will place the component in a container - providing some extra padding around the border. + scale: relative width compared to adjacent Components in a Row. For example, if Component A has scale=2, and Component B has scale=1, A will be twice as wide as B. Should be an integer. + min_width: minimum pixel width, will wrap if not sufficient screen space to satisfy this value. If a certain scale value results in this Component being narrower than min_width, the min_width parameter will be respected first. + height: The maximum height of the file component, in pixels. If more files are uploaded than can fit in the height, a scrollbar will appear. + interactive: if True, will allow users to upload a file; if False, can only be used to display files. If not provided, this is inferred based on whether the component is used as an input or output. + visible: If False, component will be hidden. + elem_id: An optional string that is assigned as the id of this component in the HTML DOM. Can be used for targeting CSS styles. + elem_classes: An optional list of strings that are assigned as the classes of this component in the HTML DOM. Can be used for targeting CSS styles. + """ + self.root = os.path.abspath(root) + self.glob = glob + self.ignore_glob = ignore_glob + valid_file_count = ["single", "multiple", "directory"] + 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}" + ) + self.file_count = file_count + self.height = height + self.select: EventListenerMethod + """ + Event listener for when the user selects file from list. + Uses event data gradio.SelectData to carry `value` referring to name of selected file, and `index` to refer to index. + See EventData documentation on how to use this event data. + """ + IOComponent.__init__( + self, + label=label, + every=every, + show_label=show_label, + container=container, + scale=scale, + min_width=min_width, + interactive=interactive, + visible=visible, + elem_id=elem_id, + elem_classes=elem_classes, + value=value, + **kwargs, + ) + + def preprocess(self, x: list[list[str]] | None) -> list[str] | str | None: + """ + Parameters: + x: File path segments as a list of list of strings for each file relative to the root. + Returns: + File path selected, as an absolute path. + """ + if x is None: + return None + + if self.file_count == "single": + if len(x) > 1: + raise ValueError(f"Expected only one file, but {len(x)} were selected.") + return self._safe_join(x[0]) + + return [self._safe_join(file) for file in (x)] + + def _strip_root(self, path): + if path.startswith(self.root): + return path[len(self.root) + 1 :] + return path + + def postprocess(self, y: str | list[str] | None) -> list[list[str]] | None: + """ + Parameters: + y: file path + Returns: + list representing filepath, where each string is a directory level relative to the root. + """ + if y is None: + return None + + files = [y] if isinstance(y, str) else y + + return [self._strip_root(file).split(os.path.sep) for file in (files)] + + @server + def ls(self, y=None) -> list[dict[str, str]] | None: + """ + Parameters: + y: file path as a list of strings for each directory level relative to the root. + Returns: + tuple of list of files in directory, then list of folders in directory + """ + + def expand_braces(text, seen=None): + if seen is None: + seen = set() + + spans = [m.span() for m in re.finditer("{[^{}]*}", text)][::-1] + alts = [text[start + 1 : stop - 1].split(",") for start, stop in spans] + + if len(spans) == 0: + if text not in seen: + yield text + seen.add(text) + + 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("/") + 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 = [] + for result in expand_braces(self.glob): + files += glob_func(result, recursive=True, root_dir=self.root) # type: ignore + + ignore_files = [] + if self.ignore_glob: + for result in expand_braces(self.ignore_glob): + ignore_files += glob_func(result, recursive=True, root_dir=self.root) # type: ignore + files = list(set(files) - set(ignore_files)) + + tree = make_tree(files) + + return tree + + def _safe_join(self, folders): + combined_path = os.path.join(self.root, *folders) + absolute_path = os.path.abspath(combined_path) + if os.path.commonprefix([self.root, absolute_path]) != os.path.abspath( + self.root + ): + raise ValueError("Attempted to navigate outside of root directory") + return absolute_path diff --git a/gradio/data_classes.py b/gradio/data_classes.py index 5ebb0c2bded2..514bed2accca 100644 --- a/gradio/data_classes.py +++ b/gradio/data_classes.py @@ -26,6 +26,13 @@ class ResetBody(BaseModel): fn_index: int +class ComponentServerBody(BaseModel): + session_hash: str + component_id: int + fn_name: str + data: Any + + class InterfaceTypes(Enum): STANDARD = auto() INPUT_ONLY = auto() diff --git a/gradio/routes.py b/gradio/routes.py index 39274548c417..71418dd25554 100644 --- a/gradio/routes.py +++ b/gradio/routes.py @@ -22,7 +22,7 @@ from asyncio import TimeoutError as AsyncTimeOutError from collections import defaultdict from pathlib import Path -from typing import Any, Dict, List, Optional, Type +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type import fastapi import httpx @@ -48,7 +48,7 @@ import gradio.ranged_response as ranged_response from gradio import route_utils, utils, wasm_utils from gradio.context import Context -from gradio.data_classes import PredictBody, ResetBody +from gradio.data_classes import ComponentServerBody, PredictBody, ResetBody from gradio.deprecation import warn_deprecation from gradio.exceptions import Error from gradio.oauth import attach_oauth @@ -62,6 +62,10 @@ set_task_name, ) +if TYPE_CHECKING: + from gradio.blocks import Block + + mimetypes.init() STATIC_TEMPLATE_LIB = files("gradio").joinpath("templates").as_posix() # type: ignore @@ -616,6 +620,19 @@ async def join_queue( if websocket.application_state == WebSocketState.DISCONNECTED: return + @app.post("/component_server", dependencies=[Depends(login_check)]) + @app.post("/component_server/", dependencies=[Depends(login_check)]) + def component_server(body: ComponentServerBody): + state = app.state_holder[body.session_hash] + component_id = body.component_id + block: Block + if component_id in state: + block = state[component_id] + else: + block = app.get_blocks().blocks[component_id] + fn = getattr(block, body.fn_name) + return fn(body.data) + @app.get( "/queue/status", dependencies=[Depends(login_check)], diff --git a/gradio/state_holder.py b/gradio/state_holder.py index 012457f75c29..a0c4a95dfce2 100644 --- a/gradio/state_holder.py +++ b/gradio/state_holder.py @@ -19,16 +19,16 @@ def set_blocks(self, blocks: Blocks): self.blocks = blocks self.capacity = blocks.state_session_capacity - def __getitem__(self, session_id: int) -> SessionState: + def __getitem__(self, session_id: str) -> SessionState: if session_id not in self.session_data: self.session_data[session_id] = SessionState(self.blocks) self.update(session_id) return self.session_data[session_id] - def __contains__(self, session_id: int): + def __contains__(self, session_id: str): return session_id in self.session_data - def update(self, session_id: int): + def update(self, session_id: str): with self.lock: if session_id in self.session_data: self.session_data.move_to_end(session_id) diff --git a/js/app/package.json b/js/app/package.json index 4e54c30471e0..5db47287017e 100644 --- a/js/app/package.json +++ b/js/app/package.json @@ -39,6 +39,7 @@ "@gradio/dataframe": "workspace:^", "@gradio/dropdown": "workspace:^", "@gradio/file": "workspace:^", + "@gradio/fileexplorer": "workspace:^", "@gradio/form": "workspace:^", "@gradio/gallery": "workspace:^", "@gradio/group": "workspace:^", diff --git a/js/app/src/Blocks.svelte b/js/app/src/Blocks.svelte index fa37cb3682c3..470075268a25 100644 --- a/js/app/src/Blocks.svelte +++ b/js/app/src/Blocks.svelte @@ -29,7 +29,6 @@ export let components: ComponentMeta[]; export let layout: LayoutNode; export let dependencies: Dependency[]; - export let title = "Gradio"; export let analytics_enabled = false; export let target: HTMLElement; @@ -250,6 +249,20 @@ } else { (c.props as any).mode = "static"; } + + if ((c.props as any).server_fns) { + let server: Record Promise> = {}; + (c.props as any).server_fns.forEach((fn: string) => { + server[fn] = async (...args: any[]) => { + if (args.length === 1) { + args = args[0]; + } + const result = await app.component_server(c.id, fn, args); + return result; + }; + }); + (c.props as any).server = server; + } __type_for_id.set(c.id, c.props.mode); const _c = load_component(c.type, c.props.mode); diff --git a/js/app/src/components/Dataset/Dataset.svelte b/js/app/src/components/Dataset/Dataset.svelte index 4ce338c53de3..6e93a106bc89 100644 --- a/js/app/src/components/Dataset/Dataset.svelte +++ b/js/app/src/components/Dataset/Dataset.svelte @@ -69,7 +69,7 @@ $: component_meta = selected_samples.map((sample_row) => sample_row.map((sample_cell, j) => ({ value: sample_cell, - component: component_map[components[j]] as ComponentType, + component: component_map[components[j]] as ComponentType })) ); diff --git a/js/app/src/components/Dataset/directory.ts b/js/app/src/components/Dataset/directory.ts index bd99676357e8..f9684f089b41 100644 --- a/js/app/src/components/Dataset/directory.ts +++ b/js/app/src/components/Dataset/directory.ts @@ -16,6 +16,7 @@ import ExampleTimeSeries from "@gradio/timeseries/example"; import ExampleMarkdown from "@gradio/markdown/example"; import ExampleHTML from "@gradio/html/example"; import ExampleCode from "@gradio/code/example"; +import ExampleFileExplorer from "@gradio/fileexplorer/example"; export const component_map = { dropdown: ExampleDropdown, @@ -35,5 +36,6 @@ export const component_map = { timeseries: ExampleTimeSeries, markdown: ExampleMarkdown, html: ExampleHTML, - code: ExampleCode + code: ExampleCode, + fileexplorer: ExampleFileExplorer }; diff --git a/js/app/src/components/directory.ts b/js/app/src/components/directory.ts index 7edb4c66823c..8c115d473e1a 100644 --- a/js/app/src/components/directory.ts +++ b/js/app/src/components/directory.ts @@ -65,6 +65,10 @@ export const component_map = { static: () => import("@gradio/highlightedtext/static"), interactive: () => import("@gradio/highlightedtext/interactive") }, + fileexplorer: { + static: () => import("@gradio/fileexplorer/static"), + interactive: () => import("@gradio/fileexplorer/interactive") + }, html: { static: () => import("@gradio/html/static") }, diff --git a/js/code/static/StaticCode.svelte b/js/code/static/StaticCode.svelte index 13b092b04c42..f4ca0095046b 100644 --- a/js/code/static/StaticCode.svelte +++ b/js/code/static/StaticCode.svelte @@ -21,6 +21,7 @@ export let label = $_("code.code"); export let show_label = true; export let loading_status: LoadingStatus; + export let scale: number | null = null; export let gradio: Gradio<{ change: typeof value; input: never; @@ -40,7 +41,14 @@ $: value, handle_change(); - + diff --git a/js/file/static/index.ts b/js/file/static/index.ts index b1de0d178d6e..586ede80caee 100644 --- a/js/file/static/index.ts +++ b/js/file/static/index.ts @@ -1 +1,2 @@ export { default } from "./StaticFile.svelte"; +export { FilePreview } from "../shared"; diff --git a/js/fileexplorer/example/File.svelte b/js/fileexplorer/example/File.svelte new file mode 100644 index 000000000000..e9aa1ea35f9b --- /dev/null +++ b/js/fileexplorer/example/File.svelte @@ -0,0 +1,41 @@ + + +
    + {#each Array.isArray(value) ? value.slice(0, 3) : [value] as path} +
  • ./{path}
  • + {/each} + {#if Array.isArray(value) && value.length > 3} +
  • ...
  • + {/if} +
+ + diff --git a/js/fileexplorer/example/index.ts b/js/fileexplorer/example/index.ts new file mode 100644 index 000000000000..446a5d6d3fe9 --- /dev/null +++ b/js/fileexplorer/example/index.ts @@ -0,0 +1 @@ +export { default } from "./File.svelte"; diff --git a/js/fileexplorer/icons/light-file.svg b/js/fileexplorer/icons/light-file.svg new file mode 100644 index 000000000000..b3d6de26f1cb --- /dev/null +++ b/js/fileexplorer/icons/light-file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/fileexplorer/interactive/InteractiveFileExplorer.svelte b/js/fileexplorer/interactive/InteractiveFileExplorer.svelte new file mode 100644 index 000000000000..0355550b8208 --- /dev/null +++ b/js/fileexplorer/interactive/InteractiveFileExplorer.svelte @@ -0,0 +1,63 @@ + + + + + + + + + gradio.dispatch("change")} + /> + diff --git a/js/fileexplorer/interactive/index.ts b/js/fileexplorer/interactive/index.ts new file mode 100644 index 000000000000..b3b01e8e3f96 --- /dev/null +++ b/js/fileexplorer/interactive/index.ts @@ -0,0 +1 @@ +export { default } from "./InteractiveFileExplorer.svelte"; diff --git a/js/fileexplorer/package.json b/js/fileexplorer/package.json new file mode 100644 index 000000000000..a4bceba2e96a --- /dev/null +++ b/js/fileexplorer/package.json @@ -0,0 +1,28 @@ +{ + "name": "@gradio/fileexplorer", + "version": "0.1.2", + "description": "Gradio UI packages", + "type": "module", + "main": "./index.svelte", + "author": "", + "license": "ISC", + "private": true, + "dependencies": { + "@gradio/atoms": "workspace:^", + "@gradio/checkbox": "workspace:^", + "@gradio/client": "workspace:^", + "@gradio/file": "workspace:^", + "@gradio/icons": "workspace:^", + "@gradio/statustracker": "workspace:^", + "@gradio/upload": "workspace:^", + "@gradio/utils": "workspace:^", + "dequal": "^2.0.2" + }, + "main_changeset": true, + "exports": { + "./package.json": "./package.json", + "./interactive": "./interactive/index.ts", + "./static": "./static/index.ts", + "./example": "./example/index.ts" + } +} diff --git a/js/fileexplorer/shared/ArrowIcon.svelte b/js/fileexplorer/shared/ArrowIcon.svelte new file mode 100644 index 000000000000..1ab21c539b98 --- /dev/null +++ b/js/fileexplorer/shared/ArrowIcon.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/js/fileexplorer/shared/Checkbox.svelte b/js/fileexplorer/shared/Checkbox.svelte new file mode 100644 index 000000000000..ee12c4b3a64c --- /dev/null +++ b/js/fileexplorer/shared/Checkbox.svelte @@ -0,0 +1,60 @@ + + + dispatch("change", value)} + {disabled} + class:disabled={disabled && !value} +/> + + diff --git a/js/fileexplorer/shared/DirectoryExplorer.svelte b/js/fileexplorer/shared/DirectoryExplorer.svelte new file mode 100644 index 000000000000..63cbf1f4143a --- /dev/null +++ b/js/fileexplorer/shared/DirectoryExplorer.svelte @@ -0,0 +1,69 @@ + + +{#if $tree && $tree.length} +
+ handle_select(detail)} + {file_count} + /> +
+{:else} + +{/if} + + diff --git a/js/fileexplorer/shared/FileTree.svelte b/js/fileexplorer/shared/FileTree.svelte new file mode 100644 index 000000000000..8ec08b58eb2b --- /dev/null +++ b/js/fileexplorer/shared/FileTree.svelte @@ -0,0 +1,151 @@ + + +
    + {#each tree as { type, path, children, children_visible, checked }, i} +
  • + + dispatch_change(i)} + /> + + {#if type === "folder"} + + (tree[i].children_visible = !tree[i].children_visible)} + role="button" + tabindex="0" + on:keydown={({ key }) => + key === " " && + (tree[i].children_visible = !tree[i].children_visible)} + > + {:else} + + file icon + + {/if} + {path} + + {#if children && children_visible} + + {/if} +
  • + {/each} +
+ + diff --git a/js/fileexplorer/shared/index.ts b/js/fileexplorer/shared/index.ts new file mode 100644 index 000000000000..864561ae86b5 --- /dev/null +++ b/js/fileexplorer/shared/index.ts @@ -0,0 +1 @@ +export { default as DirectoryExplorer } from "./DirectoryExplorer.svelte"; diff --git a/js/fileexplorer/shared/utils.test.ts b/js/fileexplorer/shared/utils.test.ts new file mode 100644 index 000000000000..2c7c4247bfea --- /dev/null +++ b/js/fileexplorer/shared/utils.test.ts @@ -0,0 +1,189 @@ +import { describe, test, expect, beforeAll } from "vitest"; +import { make_fs_store, type SerialisedNode } from "./utils"; +import { get } from "svelte/store"; +import exp from "constants"; +import { e } from "vitest/dist/types-3c7dbfa5"; + +describe("fs_store", () => { + let store: ReturnType, + 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); + }); +}); diff --git a/js/fileexplorer/shared/utils.ts b/js/fileexplorer/shared/utils.ts new file mode 100644 index 000000000000..e894c05af1b6 --- /dev/null +++ b/js/fileexplorer/shared/utils.ts @@ -0,0 +1,269 @@ +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["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, update } = writable(null); + let root: Node = { + type: "folder", + path: "", + checked: false, + children_visible: false, + parent: null + }; + + function create_fs_graph(serialised_node: SerialisedNode[]): void { + root.children = process_tree(serialised_node); + set(root.children); + } + + let old_checked_paths: string[][] = []; + + function set_checked_from_paths(checked_paths: string[][]): string[][] { + if (dequal(checked_paths, old_checked_paths)) { + 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); + if (seen_nodes.has(path.join("/"))) { + return; + } + if (node.type === "file") { + new_checked_paths.push(path); + } + seen_nodes.add(path.join("/")); + }); + } + + set(root.children!); + + 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 (!checked) { + new_checked_paths.delete(_path.join("/")); + } else if (checked) { + if (file_count === "single") { + new_checked_paths = new Map(); + } + + if (nodes[i].type === "file") { + 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[] { + // console.log(node, checked); + 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); + } +} diff --git a/js/fileexplorer/static/StaticFileExplorer.svelte b/js/fileexplorer/static/StaticFileExplorer.svelte new file mode 100644 index 000000000000..4a25f4c3cb7e --- /dev/null +++ b/js/fileexplorer/static/StaticFileExplorer.svelte @@ -0,0 +1,63 @@ + + + + + + + + gradio.dispatch("change")} + /> + diff --git a/js/fileexplorer/static/index.ts b/js/fileexplorer/static/index.ts new file mode 100644 index 000000000000..3ae1c4d789bb --- /dev/null +++ b/js/fileexplorer/static/index.ts @@ -0,0 +1 @@ +export { default } from "./StaticFileExplorer.svelte"; diff --git a/js/fileexplorer/static/utils.ts b/js/fileexplorer/static/utils.ts new file mode 100644 index 000000000000..4b0ce6057f91 --- /dev/null +++ b/js/fileexplorer/static/utils.ts @@ -0,0 +1,33 @@ +import type { FileData } from "@gradio/upload"; + +export const prettyBytes = (bytes: number): string => { + let units = ["B", "KB", "MB", "GB", "PB"]; + let i = 0; + while (bytes > 1024) { + bytes /= 1024; + i++; + } + let unit = units[i]; + return bytes.toFixed(1) + " " + unit; +}; + +export const display_file_name = (value: FileData): string => { + var str: string; + str = value.orig_name || value.name; + if (str.length > 30) { + return `${str.substr(0, 30)}...`; + } + return str; +}; + +export const display_file_size = (value: FileData | FileData[]): string => { + var total_size = 0; + if (Array.isArray(value)) { + for (var file of value) { + if (file.size !== undefined) total_size += file.size; + } + } else { + total_size = value.size || 0; + } + return prettyBytes(total_size); +}; diff --git a/js/slider/Slider.stories.svelte b/js/slider/Slider.stories.svelte index a9317d7901c0..48d0c5755cbb 100644 --- a/js/slider/Slider.stories.svelte +++ b/js/slider/Slider.stories.svelte @@ -38,4 +38,4 @@ minimum: 0, maximum: 100 }} -/> \ No newline at end of file +/> diff --git a/js/theme/src/reset.css b/js/theme/src/reset.css index 051c248ae786..229fd05c0b48 100644 --- a/js/theme/src/reset.css +++ b/js/theme/src/reset.css @@ -257,9 +257,8 @@ select { vertical-align: middle; appearance: none; border-width: 1px; - border-color: #6b7280; background-origin: border-box; - background-color: #fff; + padding: 0; width: 1rem; height: 1rem; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d49441e23b4f..394e54338309 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -415,6 +415,9 @@ importers: '@gradio/file': specifier: workspace:^ version: link:../file + '@gradio/fileexplorer': + specifier: workspace:^ + version: link:../fileexplorer '@gradio/form': specifier: workspace:^ version: link:../form @@ -796,6 +799,36 @@ importers: specifier: workspace:^ version: link:../utils + js/fileexplorer: + dependencies: + '@gradio/atoms': + specifier: workspace:^ + version: link:../atoms + '@gradio/checkbox': + specifier: workspace:^ + version: link:../checkbox + '@gradio/client': + specifier: workspace:^ + version: link:../../client/js + '@gradio/file': + specifier: workspace:^ + version: link:../file + '@gradio/icons': + specifier: workspace:^ + version: link:../icons + '@gradio/statustracker': + specifier: workspace:^ + version: link:../statustracker + '@gradio/upload': + specifier: workspace:^ + version: link:../upload + '@gradio/utils': + specifier: workspace:^ + version: link:../utils + dequal: + specifier: ^2.0.2 + version: 2.0.3 + js/form: dependencies: '@gradio/atoms': @@ -6404,7 +6437,7 @@ packages: peerDependencies: '@sveltejs/kit': ^1.0.0 dependencies: - '@sveltejs/kit': 1.16.3(svelte@3.57.0)(vite@4.3.5) + '@sveltejs/kit': 1.16.3(svelte@3.59.2)(vite@4.3.9) import-meta-resolve: 3.0.0 dev: true diff --git a/test/test_blocks.py b/test/test_blocks.py index 37f7e732d802..8dd8deaa23fb 100644 --- a/test/test_blocks.py +++ b/test/test_blocks.py @@ -612,7 +612,15 @@ def test_blocks_do_not_filter_none_values_from_updates(self, io_components): io_components = [ c() for c in io_components - if c not in [gr.State, gr.Button, gr.ScatterPlot, gr.LinePlot, gr.BarPlot] + if c + not in [ + gr.State, + gr.Button, + gr.ScatterPlot, + gr.LinePlot, + gr.BarPlot, + gr.FileExplorer, + ] ] with gr.Blocks() as demo: for component in io_components: