from __future__ import annotations
import os
import itertools
import json
from pathlib import Path
import gradio as gr
import gradio.component_meta as gradio_component_meta
from gradio.components.base import server
from shared.utils import files_locator as fl
VIDEO_FILE_EXTENSIONS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".mpeg", ".mpg", ".ogv"}
CHECKPOINT_FILE_EXTENSIONS = {".safetensors", ".safetensor", ".sft", ".ckpt", ".bin", ".binn", ".pt", ".pth", ".gguf", ".onnx"}
LAST_DIRECTORY_CONFIG_KEY = "local_file_picker_last_dir"
_server_config: dict | None = None
_tooltip_counter = itertools.count(1)
def configure_last_directory_store(server_config: dict | None) -> None:
global _server_config
_server_config = server_config
def _normalize_extensions(file_extensions: set[str] | list[str] | tuple[str, ...] | None) -> set[str]:
return {f".{str(ext).strip().lower().lstrip('.')}" for ext in (file_extensions or []) if str(ext).strip()}
def _available_roots() -> list[tuple[str, str]]:
roots: list[tuple[str, str]] = []
if os.name == "nt":
try:
import ctypes
drive_mask = ctypes.windll.kernel32.GetLogicalDrives()
roots = [(f"{chr(ord('A') + index)}:\\", f"{chr(ord('A') + index)}:\\") for index in range(26) if drive_mask & (1 << index)]
except Exception:
roots = []
else:
roots = [("/", "/")]
current_root = str(Path.cwd().anchor or "").strip()
if current_root and current_root.casefold() not in {value.casefold() for _label, value in roots}:
roots.insert(0, (current_root, current_root))
return roots or [(".", ".")]
def _is_under_root(path: str, root: str) -> bool:
path = os.path.abspath(os.path.normpath(path))
root = os.path.abspath(os.path.normpath(root))
try:
return os.path.commonpath([path, root]).casefold() == root.casefold()
except ValueError:
return False
def _is_probable_url(value: str) -> bool:
return "://" in str(value) or str(value).startswith(("mailto:", "urn:"))
def _is_non_local_entry(value: str) -> bool:
return _is_probable_url(value) or str(value).startswith("=")
_create_or_modify_pyi = gradio_component_meta.create_or_modify_pyi
gradio_component_meta.create_or_modify_pyi = lambda *args, **kwargs: None
class _FilteredFileExplorer(gr.FileExplorer):
def __init__(
self,
glob: str = "**/*",
*,
value=None,
file_count: str = "multiple",
root_dir: str | Path = ".",
ignore_glob: str | None = None,
label: str | None = None,
every=None,
inputs=None,
show_label: bool | None = None,
container: bool = True,
scale: int | None = None,
min_width: int = 160,
height: int | str | None = None,
max_height: int | str | None = 500,
min_height: int | str | None = None,
interactive: bool | None = None,
visible: bool = True,
elem_id: str | None = None,
elem_classes: list[str] | str | None = None,
render: bool = True,
key: int | str | None = None,
file_extensions: set[str] | list[str] | tuple[str, ...] | None = None,
) -> None:
self.file_extensions = _normalize_extensions(file_extensions)
super().__init__(glob=glob, value=value, file_count=file_count, root_dir=root_dir, ignore_glob=ignore_glob, label=label, every=every, inputs=inputs, show_label=show_label, container=container, scale=scale, min_width=min_width, height=height, max_height=max_height, min_height=min_height, interactive=interactive, visible=visible, elem_id=elem_id, elem_classes=elem_classes, render=render, key=key)
@classmethod
def get_component_class_id(cls) -> str:
return gr.FileExplorer.get_component_class_id()
def get_block_name(self) -> str:
return "fileexplorer"
def get_block_class(self) -> str:
return "fileexplorer"
def get_config(self):
config = super().get_config()
config.pop("file_extensions", None)
return config
def _strip_root(self, path: str) -> str:
root_dir = os.path.abspath(os.path.normpath(self.root_dir))
normalized_path = os.path.abspath(os.path.normpath(path))
try:
relative_path = os.path.relpath(normalized_path, root_dir)
except ValueError:
return normalized_path
if relative_path == "." or relative_path.startswith(f"..{os.path.sep}") or relative_path == "..":
return normalized_path
return relative_path
@server
def ls(self, subdirectory=None):
if subdirectory is None:
subdirectory = []
try:
full_subdir_path = self._safe_join(subdirectory)
subdir_items = sorted(os.listdir(full_subdir_path), key=str.casefold)
except OSError:
return []
files, folders = [], []
for item in subdir_items:
full_path = os.path.join(full_subdir_path, item)
if os.path.isfile(full_path):
if not self.file_extensions or Path(full_path).suffix.lower() in self.file_extensions:
files.append({"name": item, "type": "file", "valid": True})
else:
folders.append({"name": item, "type": "folder", "valid": False})
return folders + files
gradio_component_meta.create_or_modify_pyi = _create_or_modify_pyi
class LocalFilePickerTextbox:
def __init__(
self,
*,
label: str,
value: str = "",
file_extensions: set[str] | list[str] | tuple[str, ...] | None = None,
multiselect: bool = False,
popup_title: str = "Browse Local Files",
browse_label: str = "\U0001F4C2",
browse_tooltip: str | None = None,
lines: int | None = None,
textbox_scale: int = 8,
button_min_width: int = 34,
browser_height: int = 320,
) -> None:
self.label = label
self.value = value
self.file_extensions = _normalize_extensions(file_extensions)
self.multiselect = bool(multiselect)
self.popup_title = popup_title
self.browse_label = browse_label
self.browse_tooltip = browse_tooltip or ("Browse local files" if self.multiselect else "Browse local file")
self.tooltip_class = f"wangp-local-file-picker-tooltip-{next(_tooltip_counter)}"
self.lines = lines if lines is not None else (4 if self.multiselect else 1)
self.textbox_scale = textbox_scale
self.button_min_width = button_min_width
self.browser_height = browser_height
self.roots = _available_roots()
paths = self._parse_text(value)
self.default_dir = self._start_dir_for_paths(paths) or self._remembered_dir() or self.roots[0][1]
self.default_root = self._root_for_paths(paths) or self._root_for_directory(self.default_dir) or self.roots[0][1]
self.textbox: gr.Textbox | None = None
self.browse_button: gr.Button | None = None
self.popup: gr.Column | None = None
self.drive: gr.Dropdown | None = None
self.current_dir: gr.Textbox | None = None
self.up_button: gr.Button | None = None
self.close_button: gr.Button | None = None
self.exit_button: gr.Button | None = None
self.browser: gr.FileExplorer | None = None
def mount(self) -> gr.Textbox:
gr.HTML(f"", elem_classes=["wangp-local-file-picker-style"])
with gr.Column(elem_classes=["wangp-local-file-picker-field", self.tooltip_class]):
self.textbox = gr.Textbox(label=self.label, value=self.value, lines=self.lines, scale=self.textbox_scale)
self.browse_button = gr.Button(self.browse_label, min_width=1, elem_classes=["wangp-local-file-picker-browse-btn"])
self._mount_popup()
self._wire_events()
return self.textbox
def _tooltip_css(self) -> str:
return f"""
.{self.tooltip_class} .wangp-local-file-picker-browse-btn::after {{
content: {json.dumps(self.browse_tooltip)};
}}
"""
def _mount_popup(self) -> None:
with gr.Column(visible=False, elem_classes=["wangp-local-file-picker-popup"]) as self.popup:
with gr.Column(elem_classes=["wangp-model-info-card", "wangp-local-file-picker-card"]):
with gr.Row(elem_classes=["wangp-model-info-titlebar", "wangp-local-file-picker-titlebar"]):
gr.HTML(f"
{self.popup_title}
", elem_classes=["wangp-local-file-picker-heading"])
self.close_button = gr.Button("x", elem_classes=["wangp-model-info-close", "wangp-local-file-picker-close"], min_width=26, scale=0)
with gr.Column(elem_classes=["wangp-model-info-content", "wangp-local-file-picker-content"]):
with gr.Row(elem_classes=["wangp-local-file-picker-location-row"]):
self.drive = gr.Dropdown(self.roots, value=self.default_root, show_label=False, container=False, scale=1, min_width=90, elem_classes=["wangp-local-file-picker-drive"])
self.current_dir = gr.Text(show_label=False, value=self._display_dir(self.default_root, self.default_dir), placeholder="Folder", interactive=True, container=False, scale=8, min_width=240, elem_classes=["wangp-local-file-picker-folder-input"])
self.up_button = gr.Button("Up", min_width=58, elem_classes=["wangp-local-file-picker-up-btn"])
self.browser = _FilteredFileExplorer(value=self._browser_value(self.default_dir, self._parse_text(self.value)), file_extensions=self.file_extensions, glob="**/*.*", show_label=False, root_dir=self.default_dir, file_count="multiple" if self.multiselect else "single", height=self.browser_height, interactive=True)
self.exit_button = gr.Button("Exit", visible=self.multiselect, elem_classes=["wangp-local-file-picker-exit-btn"], min_width=72)
def _wire_events(self) -> None:
assert self.textbox is not None
assert self.browse_button is not None
assert self.popup is not None
assert self.drive is not None
assert self.current_dir is not None
assert self.up_button is not None
assert self.close_button is not None
assert self.exit_button is not None
assert self.browser is not None
self.browse_button.click(fn=self._open_popup, inputs=[self.textbox], outputs=[self.popup, self.drive, self.current_dir, self.browser], queue=False, show_progress="hidden")
self.drive.change(fn=self._change_drive, inputs=[self.drive, self.textbox], outputs=[self.current_dir, self.browser], queue=False, show_progress="hidden")
self.current_dir.blur(fn=self._change_folder, inputs=[self.drive, self.current_dir, self.textbox], outputs=[self.drive, self.current_dir, self.browser], queue=False, show_progress="hidden")
self.current_dir.submit(fn=self._change_folder, inputs=[self.drive, self.current_dir, self.textbox], outputs=[self.drive, self.current_dir, self.browser], queue=False, show_progress="hidden")
self.up_button.click(fn=self._go_up, inputs=[self.drive, self.current_dir, self.textbox], outputs=[self.drive, self.current_dir, self.browser], queue=False, show_progress="hidden")
self.close_button.click(fn=lambda: gr.update(visible=False), outputs=[self.popup], queue=False, show_progress="hidden")
self.exit_button.click(fn=lambda: gr.update(visible=False), outputs=[self.popup], queue=False, show_progress="hidden")
self.browser.change(fn=self._select_files, inputs=[self.browser, self.textbox, self.drive, self.current_dir], outputs=[self.textbox, self.popup], queue=False, show_progress="hidden")
def _open_popup(self, current_text: str):
paths = self._parse_text(current_text)
current_dir = self._start_dir_for_paths(paths) or self._remembered_dir() or self.default_dir
selected_root = self._root_for_directory(current_dir) or self.default_root
self._remember_dir(current_dir)
return gr.update(visible=True), gr.update(value=selected_root), gr.update(value=self._display_dir(selected_root, current_dir)), self._browser_update(current_dir, paths)
def _change_drive(self, selected_root: str, current_text: str):
current_dir = str(selected_root or self.default_root)
self._remember_dir(current_dir)
return gr.update(value=""), self._browser_update(current_dir, self._parse_text(current_text))
def _change_folder(self, selected_root: str, displayed_dir: str, current_text: str):
current_dir = self._resolve_dir_for_root(selected_root, displayed_dir)
if not os.path.isdir(current_dir):
return gr.skip(), gr.skip(), gr.skip()
selected_root = self._root_for_directory(current_dir) or selected_root or self.default_root
self._remember_dir(current_dir)
return gr.update(value=selected_root), gr.update(value=self._display_dir(selected_root, current_dir)), self._browser_update(current_dir, self._parse_text(current_text))
def _go_up(self, selected_root: str, displayed_dir: str, current_text: str):
current_dir = self._resolve_dir_for_root(selected_root, displayed_dir)
current_root = self._root_for_directory(current_dir)
if current_root and os.path.abspath(os.path.normpath(current_root)).casefold() == current_dir.casefold():
parent_dir = current_root
else:
parent_dir = os.path.dirname(current_dir) or current_root or current_dir
selected_root = self._root_for_directory(parent_dir) or self.default_root
self._remember_dir(parent_dir)
return gr.update(value=selected_root), gr.update(value=self._display_dir(selected_root, parent_dir)), self._browser_update(parent_dir, self._parse_text(current_text))
def _select_files(self, selected_paths, current_text: str, selected_root: str, displayed_dir: str):
current_dir = self._resolve_dir_for_root(selected_root, displayed_dir)
selected = self._coerce_selected_paths(selected_paths)
if self.multiselect:
current_paths = self._current_entries_outside_dir(current_text, current_dir)
current_paths.extend(selected)
return self._format_paths(current_paths), gr.update()
selected_text = self._format_paths(selected)
if not selected_text:
return gr.skip(), gr.update(visible=True)
current_text = self._format_paths(self._parse_text(current_text))
return selected_text, gr.update(visible=selected_text == current_text and bool(selected_text))
def _browser_update(self, current_dir: str, paths: list[str]):
self._remember_dir(current_dir)
return gr.update(root_dir=current_dir, value=self._browser_value(current_dir, paths))
def _browser_value(self, root: str, paths: list[str]):
root_paths = [path for path in paths if _is_under_root(path, root)]
if self.multiselect:
return root_paths
return root_paths[0] if root_paths else []
def _root_for_paths(self, paths: list[str]) -> str | None:
for path in paths:
if not self._path_is_allowed(path):
continue
for _label, root in self.roots:
if _is_under_root(path, root):
return root
return None
def _root_for_directory(self, directory: str) -> str | None:
for _label, root in self.roots:
if _is_under_root(directory, root):
return root
return None
def _resolve_dir_for_root(self, root: str | None, displayed_dir: str | None) -> str:
root = str(root or self.default_root)
displayed_dir = str(displayed_dir or "").strip().strip('"')
if os.path.isabs(displayed_dir):
return os.path.abspath(os.path.normpath(displayed_dir))
return os.path.abspath(os.path.normpath(os.path.join(root, displayed_dir))) if displayed_dir else os.path.abspath(os.path.normpath(root))
def _display_dir(self, root: str, directory: str) -> str:
root = os.path.abspath(os.path.normpath(root or self.default_root))
directory = os.path.abspath(os.path.normpath(directory or root))
try:
relative = os.path.relpath(directory, root)
except ValueError:
return directory
return "" if relative == "." else relative
def _start_dir_for_paths(self, paths: list[str]) -> str | None:
for path in paths:
if self._path_is_allowed(path):
return os.path.dirname(path)
return None
def _remembered_dir(self) -> str | None:
value = str((_server_config or {}).get(LAST_DIRECTORY_CONFIG_KEY, "") or "").strip()
return os.path.abspath(os.path.normpath(value)) if value and os.path.isdir(value) else None
def _remember_dir(self, directory: str) -> None:
if _server_config is not None and directory and os.path.isdir(directory):
_server_config[LAST_DIRECTORY_CONFIG_KEY] = os.path.abspath(os.path.normpath(directory))
def _parse_text(self, value: str | None) -> list[str]:
chunks = str(value or "").splitlines() if self.multiselect else [str(value or "")]
paths = []
seen = set()
for chunk in chunks:
path = chunk.strip().strip('"')
if not path:
continue
if _is_non_local_entry(path):
continue
path = os.path.abspath(os.path.normpath(fl.uncompress_path(path)))
key = path.casefold()
if key not in seen:
paths.append(path)
seen.add(key)
return paths
def _coerce_selected_paths(self, selected_paths) -> list[str]:
if selected_paths is None:
return []
paths = selected_paths if isinstance(selected_paths, list) else [selected_paths]
return [path for path in self._parse_text("\n".join(str(path) for path in paths)) if self._path_is_allowed(path)]
def _format_paths(self, paths: list[str]) -> str:
if self.multiselect:
return "\n".join(fl.compress_path(path) for path in paths)
return fl.compress_path(paths[0]) if paths else ""
def _current_entries_outside_dir(self, current_text: str, current_dir: str) -> list[str]:
entries = []
seen = set()
for chunk in str(current_text or "").splitlines():
entry = chunk.strip().strip('"')
if not entry:
continue
if _is_non_local_entry(entry):
normalized = entry
else:
path = os.path.abspath(os.path.normpath(fl.uncompress_path(entry)))
if _is_under_root(path, current_dir):
continue
normalized = path
key = normalized.casefold()
if key not in seen:
seen.add(key)
entries.append(normalized)
return entries
def _path_is_allowed(self, path: str) -> bool:
if not os.path.isfile(path):
return False
return not self.file_extensions or Path(path).suffix.lower() in self.file_extensions
@staticmethod
def get_css() -> str:
return """
.wangp-local-file-picker-style {
display: none !important;
}
.wangp-local-file-picker-field {
position: relative !important;
}
.wangp-local-file-picker-field > .form {
border: 0 !important;
padding: 0 !important;
background: transparent !important;
box-shadow: none !important;
overflow: visible !important;
}
.wangp-local-file-picker-browse-btn {
position: absolute !important;
top: 0 !important;
right: 4px !important;
z-index: 3 !important;
width: 24px !important;
min-width: 24px !important;
max-width: 24px !important;
height: 24px !important;
min-height: 24px !important;
max-height: 24px !important;
padding: 0 !important;
flex: 0 0 24px !important;
font-size: 14px !important;
line-height: 1 !important;
border: 0 !important;
outline: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.wangp-local-file-picker-browse-btn > .form {
border: 0 !important;
outline: 0 !important;
padding: 0 !important;
background: transparent !important;
box-shadow: none !important;
overflow: visible !important;
}
.wangp-local-file-picker-browse-btn button,
.wangp-local-file-picker-browse-btn > button,
.wangp-local-file-picker-browse-btn button:hover,
.wangp-local-file-picker-browse-btn > button:hover,
.wangp-local-file-picker-browse-btn button:focus,
.wangp-local-file-picker-browse-btn > button:focus {
width: 24px !important;
min-width: 24px !important;
max-width: 24px !important;
height: 24px !important;
min-height: 24px !important;
max-height: 24px !important;
padding: 0 !important;
border: 0 !important;
outline: 0 !important;
border-radius: 0 !important;
background: transparent !important;
box-shadow: none !important;
color: var(--body-text-color, #172b3a) !important;
font-size: 14px !important;
line-height: 1 !important;
}
.wangp-local-file-picker-browse-btn *,
.wangp-local-file-picker-browse-btn *::before,
.wangp-local-file-picker-browse-btn *::after {
border-color: transparent !important;
outline: 0 !important;
box-shadow: none !important;
}
.wangp-local-file-picker-browse-btn::after {
position: absolute;
top: 28px;
right: 0;
width: max-content;
max-width: 260px;
padding: 5px 7px;
border: 1px solid var(--border-color-primary, rgba(17, 84, 118, 0.18));
border-radius: 5px;
background: var(--background-fill-primary, #ffffff);
color: var(--body-text-color, #172b3a);
box-shadow: var(--shadow-drop, 0 4px 12px rgba(0, 0, 0, 0.14));
font-size: 12px;
line-height: 1.25;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, visibility 0s linear 0.12s;
}
.wangp-local-file-picker-browse-btn:hover::after {
opacity: 1;
visibility: visible;
transition-delay: 0.5s;
}
.wangp-local-file-picker-popup {
position: fixed !important;
top: 96px !important;
right: 32px !important;
width: min(780px, calc(100vw - 34px)) !important;
max-height: min(80vh, 760px) !important;
z-index: 1210 !important;
pointer-events: none !important;
}
.wangp-local-file-picker-popup > .form {
border: 0 !important;
padding: 0 !important;
background: transparent !important;
box-shadow: none !important;
}
.wangp-local-file-picker-card {
pointer-events: auto !important;
position: relative !important;
overflow: hidden !important;
border: 1px solid var(--border-color-primary, rgba(17, 84, 118, 0.16)) !important;
border-radius: 18px !important;
background: var(--background-fill-primary, rgba(255, 255, 255, 0.99)) !important;
box-shadow: 0 28px 62px rgba(7, 31, 48, 0.24) !important;
}
.wangp-local-file-picker-titlebar {
margin: 0 !important;
}
.wangp-local-file-picker-titlebar > .form {
border: 0 !important;
padding: 0 !important;
background: transparent !important;
}
.wangp-local-file-picker-heading,
.wangp-local-file-picker-heading > .html-container {
min-height: 0 !important;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
}
.wangp-local-file-picker-titlebar .wangp-local-file-picker-close {
flex: 0 0 26px !important;
}
.wangp-local-file-picker-titlebar .wangp-local-file-picker-close button,
.wangp-local-file-picker-close {
width: 26px !important;
height: 26px !important;
min-width: 26px !important;
min-height: 26px !important;
padding: 0 !important;
}
.wangp-local-file-picker-content {
max-height: calc(min(80vh, 760px) - 46px) !important;
overflow: hidden !important;
padding-bottom: 14px !important;
}
.wangp-local-file-picker-content .file-wrap {
max-height: none !important;
}
.wangp-local-file-picker-location-row {
align-items: flex-end !important;
gap: 8px !important;
}
.wangp-local-file-picker-location-row .form {
border: 0 !important;
background: transparent !important;
}
.wangp-local-file-picker-drive {
min-width: 90px !important;
}
.wangp-local-file-picker-folder-input {
border: 1px solid var(--border-color-primary) !important;
border-radius: 6px !important;
background: var(--input-background-fill, var(--body-background-fill)) !important;
display: block !important;
min-width: 240px !important;
}
.wangp-local-file-picker-folder-input textarea,
.wangp-local-file-picker-folder-input input {
border: 1px solid var(--border-color-primary) !important;
border-radius: 6px !important;
background: var(--input-background-fill, var(--body-background-fill)) !important;
display: block !important;
width: 100% !important;
min-height: 32px !important;
height: 32px !important;
padding: 4px 8px !important;
}
.wangp-local-file-picker-content .file-wrap,
.wangp-local-file-picker-content .file-explorer,
.wangp-local-file-picker-content [data-testid="file-explorer"] {
overflow-y: auto !important;
}
.wangp-local-file-picker-exit-btn {
align-self: flex-end !important;
margin-top: 8px !important;
width: 72px !important;
min-width: 72px !important;
}
"""