mirror of
https://github.com/unslothai/unsloth
synced 2026-04-21 13:37:39 +00:00
Studio: add folder browser modal for Custom Folders (#5035)
* Studio: add folder browser modal for Custom Folders
The Custom Folders row in the model picker currently only accepts a
typed path. On a remote-served Studio (Colab, shared workstation) that
means the user has to guess or paste the exact server-side absolute
path. A native browser folder picker can't solve this: HTML
`<input type="file" webkitdirectory>` hides the absolute path for
security, and the File System Access API (Chrome/Edge only) returns
handles rather than strings, neither of which the server can act on.
This PR adds a small in-app directory browser that lists paths on the
server and hands the chosen string back to the existing
`POST /api/models/scan-folders` flow.
## Backend
* New endpoint `GET /api/models/browse-folders`:
* `path` query param (expands `~`, accepts relative or absolute; empty
defaults to the user's home directory).
* `show_hidden` boolean to include dotfiles/dotdirs.
* Returns `{current, parent, entries[], suggestions[]}`. `parent` is
null at the filesystem root.
* Immediate subdirectories only (no recursion); files are never
returned.
* `entries[].has_models` is a cheap hint: the directory looks like it
holds models if it is named `models--*` (HF hub cache layout) or
one of the first 64 children is a .gguf/.safetensors/config.json/
adapter_config.json or another `models--*` subfolder.
* Sort order: model-bearing dirs, then plain, then hidden; case-
insensitive alphabetical within each bucket.
* Suggestions auto-populate from HOME, the HF cache root, and any
already-registered scan folders, deduplicated.
* Error surface: 404 for missing path, 400 for non-directory, 403 on
permission errors. Auth-required like the other models routes.
* New Pydantic schemas `BrowseEntry` and `BrowseFoldersResponse` in
`studio/backend/models/models.py`.
## Frontend
* New `FolderBrowser` component
(`studio/frontend/src/components/assistant-ui/model-selector/folder-browser.tsx`)
using the existing `Dialog` primitive. Features:
* Clickable breadcrumb with a `..` row for parent navigation.
* Quick-pick chips for the server-provided suggestions.
* `Show hidden` checkbox.
* In-flight fetch cancellation via AbortController so rapid
navigation doesn't flash stale results.
* Badges model-bearing directories inline.
* `chat-api.ts` gains `browseFolders(path?, showHidden?)` and matching
types.
* `pickers.tsx` adds a folder-magnifier icon next to the existing `Add`
button. Opening the browser seeds it with whatever the user has
already typed; confirming fills the text input, leaving the existing
validation and save flow unchanged.
## What it does NOT change
* The existing text-input flow still works; the browser is additive.
* No new permissions or escalation; the endpoint reads only directories
the server process is already allowed to read.
* No model scanning or filesystem mutation happens from the browser
itself -- it just returns basenames for render.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
* Studio: cap folder-browser entries and expose truncated flag
Pointing the folder browser at a huge directory (``/usr/lib``,
``/proc``, or a synthetic tree with thousands of subfolders) previously
walked the whole listing and stat-probed every child via
``_looks_like_model_dir``. That is both a DoS shape for the server
process and a large-payload surprise for the client.
Introduce a hard cap of 2000 subdirectory entries and a
``truncated: bool`` field on the response. The frontend renders a small
hint below the list when it fires, prompting the user to narrow the
path. Below-cap directories are unchanged.
Verified end-to-end against the live backend with a synthetic tree of
2050 directories: response lands at 2000 entries, ``truncated=true``,
listing finishes in sub-second time (versus tens of seconds if we were
stat-storming).
* Studio: suggest LM Studio / Ollama dirs + 2-level model probe
Three improvements to the folder-browser, driven by actually dropping
an LM Studio-style install (publisher/model/weights.gguf) into the
sandbox and walking the UX:
## 1. Quick-pick chips for other local-LLM tools
`well_known_model_dirs()` (new) returns paths commonly used by
adjacent tools. Only paths that exist are returned so the UI never
shows dead chips.
* LM Studio current + legacy roots + user-configured
`downloadsFolder` from its `settings.json` (reuses the existing
`lmstudio_model_dirs()` helper).
* Ollama: `$OLLAMA_MODELS` env override, then `~/.ollama/models`,
`/usr/share/ollama/.ollama/models`, and `/var/lib/ollama/.ollama/models`
(the systemd-service install path surfaced in the upstream "where is
everything?" issue).
* Generic user-choice locations: `~/models`, `~/Models`.
Dedup is stable across all sources.
## 2. Two-level model-bearing probe
LM Studio and Ollama both use `root/publisher/model/weights.gguf`.
The previous `has_models` heuristic only probed one level, so the
publisher dir (whose immediate children are model dirs, not weight
files) was always marked as non-model-bearing. Pulled the direct-
signal logic into `_has_direct_model_signal` and added a grandchild
probe so the classic layout is now recognised.
Still O(PROBE^2) worst-case, still returns immediately for
`models--*` names (HF cache layout) and for any direct weight file.
## 3. model_files_here hint on response body
A leaf model dir (just GGUFs, no subdirs) previously rendered as
`(empty directory)` in the modal, confusing users into thinking the
folder wasn't scannable. Added a `model_files_here` count on the
response (capped at 200) and a small hint row in the modal: `N model
files in this folder. Click "Use this folder" to scan it.`
## Verification
Simulated an LM Studio install by downloading the real 84 MB
`unsloth/SmolLM2-135M-Instruct-Q2_K.gguf` into
`~/.lmstudio/models/unsloth/SmolLM2-135M-Instruct-GGUF/`. Confirmed
end-to-end:
* Home listing suggests `~/.lmstudio/models` as a chip.
* Browsing `~/.lmstudio/models` flags `unsloth` (publisher) as
`has_models=true` via the 2-level probe.
* Browsing the publisher flags `SmolLM2-135M-Instruct-GGUF` (model
dir) as `has_models=true`.
* Browsing the model dir returns empty entries but
`model_files_here=1`, and the frontend renders a hint telling the
user it is a valid target.
* Studio: one-click scan-folder add + prominent remove + plain search icon
Three small Custom Folders UX fixes after real-use walkthrough:
* **One-click add from the folder browser**. Confirming `Use this
folder` now submits the path directly to
`POST /api/models/scan-folders` instead of just populating the text
input. `handleAddFolder` takes an optional explicit path so the
submit lands in the same tick as `setFolderInput`, avoiding a
state-flush race. The typed-path + `Add` button flow is unchanged.
* **Prominent remove X on scan folders**. The per-folder delete
button was `text-muted-foreground/40` and hidden entirely on
desktop until hovered (`md:opacity-0 md:group-hover:opacity-100`).
Dropped the hover-only cloak, bumped color to `text-foreground/70`,
added a red hover/focus background, and sized the icon up from
`size-2.5` to `size-3`. Always visible on every viewport.
* **Plain search icon for the Browse button**. `FolderSearchIcon`
replaced with `Search01Icon` so it reads as a simple "find a
folder" action alongside the existing `Add01Icon`.
* Studio: align Custom Folders + and X buttons on the same right edge
The Custom Folders header used `px-2.5` with a `p-0.5` icon button,
while each folder row used `px-3` with a `p-1` button. That put the
X icon 4px further from the right edge than the +. Normalised both
rows to `px-2.5` with `p-1` so the two icons share a column.
* Studio: empty-state button opens the folder browser directly
The first-run empty state for Custom Folders was a text link reading
"+ Add a folder to scan for local models" whose click toggled the
text input. That's the wrong default: a user hitting the empty state
usually doesn't know what absolute path to type, which is exactly
what the folder browser is for.
* Reword to "Browse for a models folder" with a search-icon
affordance so the label matches what the click does.
* Click opens the folder browser modal directly. The typed-path +
Add button flow is still available via the + icon in the
section header, so users who know their path keep that option.
* Slightly bump the muted foreground opacity (70 -> hover:foreground)
so the button reads as a primary empty-state action rather than a
throwaway hint.
* Studio: Custom Folders header gets a dedicated search + add button pair
The Custom Folders section header had a single toggle button that
flipped between + and X. That put the folder-browser entry point
behind the separate empty-state link. Cleaner layout: two buttons in
the header, search first, then add.
* Search icon (left) opens the folder browser modal directly.
* Plus icon (right) toggles the text-path input (unchanged).
* The first-run empty-state link is removed -- the two header icons
cover both flows on every state.
Both buttons share the same padding / icon size so they line up with
each other and with the per-folder remove X.
* Studio: sandbox folder browser + bound caps + UX recoveries
PR review fixes for the Custom Folders folder browser. Closes the
high-severity CodeQL path-traversal alert and addresses the codex /
gemini P2 findings.
Backend (studio/backend/routes/models.py):
* New _build_browse_allowlist + _is_path_inside_allowlist sandbox.
browse_folders now refuses any target that doesn't resolve under
HOME, HF cache, Studio dirs, registered scan folders, or the
well-known third-party model dirs. realpath() is used so symlink
traversal cannot escape the sandbox. Also gates the parent crumb
so the up-row hides instead of 403'ing.
* _BROWSE_ENTRY_CAP now bounds *visited* iterdir entries, not
*appended* entries. Dirs full of files (or hidden subdirs when
show_hidden is False) used to defeat the cap.
* _count_model_files gets the same visited-count fix.
* PermissionError no longer swallowed silently inside the
enumeration / counter loops -- now logged at debug.
Frontend (folder-browser.tsx, pickers.tsx, chat-api.ts):
* splitBreadcrumb stops mangling literal backslashes inside POSIX
filenames; only Windows-style absolute paths trigger separator
normalization. The Windows drive crumb value is now C:/ (drive
root) instead of C: (drive-relative CWD-on-C).
* browseFolders accepts and forwards an AbortSignal so cancelled
navigations actually cancel the in-flight backend enumeration.
* On initial-path fetch error, FolderBrowser now falls back to HOME
instead of leaving the modal as an empty dead end.
* When the auto-add path (one-click "Use this folder") fails, the
failure now surfaces via toast in addition to the inline
paragraph (which is hidden when the typed-input panel is closed).
* Studio: rebuild browse target from trusted root for CodeQL clean dataflow
CodeQL's py/path-injection rule kept flagging the post-validation
filesystem operations because the sandbox check lived inside a
helper function (_is_path_inside_allowlist) and CodeQL only does
intra-procedural taint tracking by default. The user-derived
``target`` was still flowing into ``target.exists`` /
``target.is_dir`` / ``target.iterdir``.
The fix: after resolving the user-supplied ``candidate_path``,
locate the matching trusted root from the allowlist and rebuild
``target`` by appending each individually-validated segment to
that trusted root. Each segment is rejected if it isn't a single
safe path component (no separators, no ``..``, no empty/dot).
The downstream filesystem ops now operate on a Path constructed
entirely from ``allowed_roots`` (trusted) plus those validated
segments, so CodeQL's dataflow no longer sees a tainted source.
Behavior is unchanged for all valid inputs -- only the
construction of ``target`` is restructured. Live + unit tests
all pass (58 selected, 7 deselected for Playwright env).
* Studio: walk browse paths from trusted roots for CodeQL
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Ubuntu <ubuntu@h100-8-cheapest.us-east5-a.c.unsloth.internal>
This commit is contained in:
parent
800ddc95f8
commit
f0d03655e8
8 changed files with 1161 additions and 31 deletions
|
|
@ -213,3 +213,68 @@ class ScanFolderInfo(BaseModel):
|
|||
id: int = Field(..., description = "Database row ID")
|
||||
path: str = Field(..., description = "Normalized absolute path")
|
||||
created_at: str = Field(..., description = "ISO 8601 creation timestamp")
|
||||
|
||||
|
||||
class BrowseEntry(BaseModel):
|
||||
"""A directory entry surfaced by the folder browser."""
|
||||
|
||||
name: str = Field(..., description = "Entry name (basename, not full path)")
|
||||
has_models: bool = Field(
|
||||
False,
|
||||
description = (
|
||||
"Hint that the directory likely contains models "
|
||||
"(*.gguf, *.safetensors, config.json, or HF-style "
|
||||
"`models--*` subfolders). Used by the UI to highlight "
|
||||
"promising candidates; the scanner itself is authoritative."
|
||||
),
|
||||
)
|
||||
hidden: bool = Field(
|
||||
False,
|
||||
description = "Name starts with a dot (e.g. `.cache`)",
|
||||
)
|
||||
|
||||
|
||||
class BrowseFoldersResponse(BaseModel):
|
||||
"""Response schema for the folder browser endpoint."""
|
||||
|
||||
current: str = Field(..., description = "Absolute path of the directory just listed")
|
||||
parent: Optional[str] = Field(
|
||||
None,
|
||||
description = (
|
||||
"Parent directory of `current`, or null if `current` is the "
|
||||
"filesystem root. The frontend uses this to render an `Up` row."
|
||||
),
|
||||
)
|
||||
entries: List[BrowseEntry] = Field(
|
||||
default_factory = list,
|
||||
description = (
|
||||
"Subdirectories of `current`. Sorted with model-bearing "
|
||||
"directories first, then alphabetically case-insensitive; "
|
||||
"hidden entries come last within each group."
|
||||
),
|
||||
)
|
||||
suggestions: List[str] = Field(
|
||||
default_factory = list,
|
||||
description = (
|
||||
"Handy starting points (home, HF cache, already-registered "
|
||||
"scan folders). Rendered as quick-pick chips above the list."
|
||||
),
|
||||
)
|
||||
truncated: bool = Field(
|
||||
False,
|
||||
description = (
|
||||
"True when the listing was capped because the directory had "
|
||||
"more subfolders than the server is willing to enumerate in "
|
||||
"one request. The UI should show a hint telling the user to "
|
||||
"narrow their path."
|
||||
),
|
||||
)
|
||||
model_files_here: int = Field(
|
||||
0,
|
||||
description = (
|
||||
"Count of GGUF/safetensors files immediately inside "
|
||||
"``current``. Used by the UI to surface a hint on leaf "
|
||||
"model directories (which otherwise look `empty` because "
|
||||
"they contain only files, no subdirectories)."
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -101,6 +101,8 @@ from models import (
|
|||
ModelListResponse,
|
||||
)
|
||||
from models.models import (
|
||||
BrowseEntry,
|
||||
BrowseFoldersResponse,
|
||||
GgufVariantDetail,
|
||||
GgufVariantsResponse,
|
||||
ModelType,
|
||||
|
|
@ -573,6 +575,529 @@ async def remove_scan_folder_endpoint(
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
# Heuristic ceiling on how many children to stat when checking whether a
|
||||
# directory "looks like" it contains models. Keeps the browser snappy
|
||||
# even when a directory has thousands of unrelated entries.
|
||||
_BROWSE_MODEL_HINT_PROBE = 64
|
||||
# Hard cap on how many subdirectory entries we send back. Pointing the
|
||||
# browser at something like ``/usr/lib`` or ``/proc`` must not stat-storm
|
||||
# the process or send tens of thousands of rows to the client.
|
||||
_BROWSE_ENTRY_CAP = 2000
|
||||
|
||||
|
||||
def _count_model_files(directory: Path, cap: int = 200) -> int:
|
||||
"""Count GGUF/safetensors files immediately inside *directory*.
|
||||
Used to surface a count-hint on the response so the UI can tell
|
||||
users that a leaf directory (no subdirs, only weights) is a valid
|
||||
"Use this folder" target.
|
||||
|
||||
Bounded by *visited entries*, not by *match count*: in directories
|
||||
with many non-model files (or many subdirectories) the scan still
|
||||
stops after ``cap`` entries so a UI hint never costs more than a
|
||||
bounded directory walk.
|
||||
"""
|
||||
n = 0
|
||||
visited = 0
|
||||
try:
|
||||
for f in directory.iterdir():
|
||||
visited += 1
|
||||
if visited > cap:
|
||||
break
|
||||
try:
|
||||
if f.is_file():
|
||||
low = f.name.lower()
|
||||
if low.endswith((".gguf", ".safetensors")):
|
||||
n += 1
|
||||
except OSError:
|
||||
continue
|
||||
except PermissionError as e:
|
||||
logger.debug("browse-folders: permission denied counting %s: %s", directory, e)
|
||||
return 0
|
||||
except OSError as e:
|
||||
logger.debug("browse-folders: OS error counting %s: %s", directory, e)
|
||||
return 0
|
||||
return n
|
||||
|
||||
|
||||
def _has_direct_model_signal(directory: Path) -> bool:
|
||||
"""Return True if *directory* has an immediate child that signals
|
||||
it holds a model: a GGUF/safetensors/config.json file, or a
|
||||
`models--*` subdir (HF hub cache). Bounded by
|
||||
``_BROWSE_MODEL_HINT_PROBE`` to stay fast."""
|
||||
try:
|
||||
it = directory.iterdir()
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
for i, child in enumerate(it):
|
||||
if i >= _BROWSE_MODEL_HINT_PROBE:
|
||||
break
|
||||
try:
|
||||
name = child.name
|
||||
if child.is_file():
|
||||
low = name.lower()
|
||||
if low.endswith((".gguf", ".safetensors")):
|
||||
return True
|
||||
if low in ("config.json", "adapter_config.json"):
|
||||
return True
|
||||
elif child.is_dir() and name.startswith("models--"):
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _looks_like_model_dir(directory: Path) -> bool:
|
||||
"""Bounded heuristic used by the folder browser to flag directories
|
||||
worth exploring. False negatives are fine; the real scanner is
|
||||
authoritative.
|
||||
|
||||
Three signals, cheapest first:
|
||||
|
||||
1. Directory name itself: ``models--*`` is the HuggingFace hub cache
|
||||
layout (``blobs``/``refs``/``snapshots`` children wouldn't match
|
||||
the file-level probes below).
|
||||
2. An immediate child is a weight file or config (handled by
|
||||
:func:`_has_direct_model_signal`).
|
||||
3. A grandchild has a direct signal -- this catches the
|
||||
``publisher/model/weights.gguf`` layout used by LM Studio and
|
||||
Ollama. We probe at most the first
|
||||
``_BROWSE_MODEL_HINT_PROBE`` child directories, each of which is
|
||||
checked with a bounded :func:`_has_direct_model_signal` call,
|
||||
so the total cost stays O(PROBE^2) worst-case.
|
||||
"""
|
||||
if directory.name.startswith("models--"):
|
||||
return True
|
||||
if _has_direct_model_signal(directory):
|
||||
return True
|
||||
# Grandchild probe: LM Studio / Ollama publisher/model layout.
|
||||
try:
|
||||
it = directory.iterdir()
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
for i, child in enumerate(it):
|
||||
if i >= _BROWSE_MODEL_HINT_PROBE:
|
||||
break
|
||||
try:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
# Fast name check first
|
||||
if child.name.startswith("models--"):
|
||||
return True
|
||||
if _has_direct_model_signal(child):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _build_browse_allowlist() -> list[Path]:
|
||||
"""Return the list of root directories the folder browser is allowed
|
||||
to walk. The same list is used to seed the sidebar suggestion chips,
|
||||
so chip targets are always reachable.
|
||||
|
||||
Roots include the current user's HOME, the resolved HF cache dirs,
|
||||
Studio's own outputs/exports/studio root, registered scan folders,
|
||||
and well-known third-party local-LLM dirs (LM Studio, Ollama,
|
||||
`~/models`). Each is added only if it currently resolves to a real
|
||||
directory, so we never produce a "dead" sandbox boundary the user
|
||||
can't navigate into.
|
||||
"""
|
||||
from utils.paths import (
|
||||
hf_default_cache_dir,
|
||||
legacy_hf_cache_dir,
|
||||
well_known_model_dirs,
|
||||
)
|
||||
from storage.studio_db import list_scan_folders
|
||||
|
||||
candidates: list[Path] = []
|
||||
|
||||
def _add(p: Optional[Path]) -> None:
|
||||
if p is None:
|
||||
return
|
||||
try:
|
||||
resolved = p.resolve()
|
||||
except OSError:
|
||||
return
|
||||
if resolved.is_dir():
|
||||
candidates.append(resolved)
|
||||
|
||||
_add(Path.home())
|
||||
_add(_resolve_hf_cache_dir())
|
||||
try:
|
||||
_add(hf_default_cache_dir())
|
||||
except Exception: # noqa: BLE001 -- best-effort
|
||||
pass
|
||||
try:
|
||||
_add(legacy_hf_cache_dir())
|
||||
except Exception: # noqa: BLE001 -- best-effort
|
||||
pass
|
||||
try:
|
||||
from utils.paths import (
|
||||
exports_root,
|
||||
outputs_root,
|
||||
studio_root,
|
||||
)
|
||||
|
||||
_add(studio_root())
|
||||
_add(outputs_root())
|
||||
_add(exports_root())
|
||||
except Exception as exc: # noqa: BLE001 -- best-effort
|
||||
logger.debug("browse-folders: studio roots unavailable: %s", exc)
|
||||
try:
|
||||
for folder in list_scan_folders():
|
||||
p = folder.get("path")
|
||||
if p:
|
||||
_add(Path(p))
|
||||
except Exception as exc: # noqa: BLE001 -- best-effort
|
||||
logger.debug("browse-folders: could not load scan folders: %s", exc)
|
||||
try:
|
||||
for p in well_known_model_dirs():
|
||||
_add(p)
|
||||
except Exception as exc: # noqa: BLE001 -- best-effort
|
||||
logger.debug("browse-folders: well-known dirs unavailable: %s", exc)
|
||||
|
||||
# Dedupe while preserving order.
|
||||
seen: set[str] = set()
|
||||
deduped: list[Path] = []
|
||||
for p in candidates:
|
||||
key = str(p)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
deduped.append(p)
|
||||
return deduped
|
||||
|
||||
|
||||
def _is_path_inside_allowlist(target: Path, allowed_roots: list[Path]) -> bool:
|
||||
"""Return True if *target* equals or is a descendant of any allowed
|
||||
root. The comparison uses ``os.path.realpath`` so symlinks cannot be
|
||||
used to escape the sandbox.
|
||||
"""
|
||||
try:
|
||||
target_real = os.path.realpath(str(target))
|
||||
except OSError:
|
||||
return False
|
||||
for root in allowed_roots:
|
||||
try:
|
||||
root_real = os.path.realpath(str(root))
|
||||
except OSError:
|
||||
continue
|
||||
if target_real == root_real or target_real.startswith(root_real + os.sep):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_browse_request_path(path: Optional[str]) -> str:
|
||||
"""Normalize the browse request path lexically, without touching the FS."""
|
||||
if path is None or not path.strip():
|
||||
return os.path.normpath(str(Path.home()))
|
||||
|
||||
expanded = os.path.expanduser(path.strip())
|
||||
if not os.path.isabs(expanded):
|
||||
expanded = os.path.join(str(Path.cwd()), expanded)
|
||||
return os.path.normpath(expanded)
|
||||
|
||||
|
||||
def _browse_relative_parts(requested_path: str, root: Path) -> Optional[list[str]]:
|
||||
"""Return validated relative path components under ``root``."""
|
||||
root_text = os.path.normpath(str(root))
|
||||
try:
|
||||
rel_text = os.path.relpath(requested_path, root_text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if rel_text == ".":
|
||||
return []
|
||||
if rel_text == ".." or rel_text.startswith(f"..{os.sep}"):
|
||||
return None
|
||||
|
||||
parts = [part for part in rel_text.split(os.sep) if part not in ("", ".")]
|
||||
altsep = os.altsep
|
||||
for part in parts:
|
||||
if part == ".." or os.sep in part or (altsep and altsep in part):
|
||||
return None
|
||||
return parts
|
||||
|
||||
|
||||
def _match_browse_child(current: Path, name: str) -> Optional[Path]:
|
||||
"""Return the immediate child named ``name`` under ``current``."""
|
||||
try:
|
||||
for child in current.iterdir():
|
||||
if child.name == name:
|
||||
return child
|
||||
except PermissionError:
|
||||
raise HTTPException(
|
||||
status_code = 403,
|
||||
detail = f"Permission denied reading {current}",
|
||||
) from None
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code = 500,
|
||||
detail = f"Could not read {current}: {exc}",
|
||||
) from exc
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_browse_target(path: Optional[str], allowed_roots: list[Path]) -> Path:
|
||||
"""Resolve a requested browse path by walking from trusted allowlist roots."""
|
||||
requested_path = _normalize_browse_request_path(path)
|
||||
resolved_roots: list[Path] = []
|
||||
seen_roots: set[str] = set()
|
||||
for root in sorted(allowed_roots, key = lambda p: len(str(p)), reverse = True):
|
||||
try:
|
||||
resolved = root.resolve()
|
||||
except OSError:
|
||||
continue
|
||||
key = str(resolved)
|
||||
if key in seen_roots:
|
||||
continue
|
||||
seen_roots.add(key)
|
||||
resolved_roots.append(resolved)
|
||||
|
||||
for root in resolved_roots:
|
||||
parts = _browse_relative_parts(requested_path, root)
|
||||
if parts is None:
|
||||
continue
|
||||
|
||||
current = root
|
||||
for part in parts:
|
||||
child = _match_browse_child(current, part)
|
||||
if child is None:
|
||||
raise HTTPException(
|
||||
status_code = 404,
|
||||
detail = f"Path does not exist: {requested_path}",
|
||||
)
|
||||
try:
|
||||
resolved_child = child.resolve()
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = f"Invalid path: {exc}",
|
||||
) from exc
|
||||
if not _is_path_inside_allowlist(resolved_child, resolved_roots):
|
||||
raise HTTPException(
|
||||
status_code = 403,
|
||||
detail = (
|
||||
"Path is not in the browseable allowlist. Register it via "
|
||||
"POST /api/models/scan-folders first, or pick a directory "
|
||||
"under your home folder."
|
||||
),
|
||||
)
|
||||
current = resolved_child
|
||||
|
||||
if not current.is_dir():
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = f"Not a directory: {current}",
|
||||
)
|
||||
return current
|
||||
|
||||
raise HTTPException(
|
||||
status_code = 403,
|
||||
detail = (
|
||||
"Path is not in the browseable allowlist. Register it via "
|
||||
"POST /api/models/scan-folders first, or pick a directory "
|
||||
"under your home folder."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/browse-folders", response_model = BrowseFoldersResponse)
|
||||
async def browse_folders(
|
||||
path: Optional[str] = Query(
|
||||
None,
|
||||
description = (
|
||||
"Directory to list. If omitted, defaults to the current user's "
|
||||
"home directory. Tilde (`~`) and relative paths are expanded. "
|
||||
"Must resolve inside the allowlist of browseable roots (HOME, "
|
||||
"HF cache, Studio dirs, registered scan folders, well-known "
|
||||
"model dirs)."
|
||||
),
|
||||
),
|
||||
show_hidden: bool = Query(
|
||||
False,
|
||||
description = "Include entries whose name starts with a dot",
|
||||
),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
List immediate subdirectories of *path* for the Custom Folders picker.
|
||||
|
||||
The frontend uses this to render a modal folder browser without needing
|
||||
a native OS dialog (Studio is served over HTTP, so the browser can't
|
||||
reveal absolute paths on the host). The endpoint is read-only and does
|
||||
not create, move, or delete anything. It simply enumerates visible
|
||||
subdirectories so the user can click their way to a folder and hand
|
||||
the resulting string back to POST `/api/models/scan-folders`.
|
||||
|
||||
Sandbox: requests are bounded to the allowlist returned by
|
||||
:func:`_build_browse_allowlist` (HOME, HF cache, Studio dirs,
|
||||
registered scan folders, well-known model dirs). Paths outside the
|
||||
allowlist return 403 so users cannot probe ``/etc``, ``/proc``,
|
||||
``/root`` (when not HOME), or other sensitive system locations
|
||||
even if the server process can read them. Symlinks are resolved
|
||||
via ``os.path.realpath`` before the check, so symlink traversal
|
||||
cannot escape the sandbox either.
|
||||
|
||||
Sorting: directories that look like they hold models come first, then
|
||||
plain directories, then hidden entries (if `show_hidden=true`).
|
||||
"""
|
||||
from utils.paths import hf_default_cache_dir, well_known_model_dirs
|
||||
from storage.studio_db import list_scan_folders
|
||||
|
||||
# Build the allowlist once -- both the sandbox check below and the
|
||||
# suggestion chips use the same set, so chips are always navigable.
|
||||
allowed_roots = _build_browse_allowlist()
|
||||
|
||||
try:
|
||||
target = _resolve_browse_target(path, allowed_roots)
|
||||
except HTTPException:
|
||||
requested_path = _normalize_browse_request_path(path)
|
||||
if path is not None and path.strip():
|
||||
logger.warning(
|
||||
"browse-folders: rejected path %r (normalized=%s)",
|
||||
path,
|
||||
requested_path,
|
||||
)
|
||||
raise
|
||||
|
||||
# Enumerate immediate subdirectories with a bounded cap so a stray
|
||||
# query against ``/usr/lib`` or ``/proc`` can't stat-storm the process.
|
||||
entries: list[BrowseEntry] = []
|
||||
truncated = False
|
||||
visited = 0
|
||||
try:
|
||||
it = target.iterdir()
|
||||
except PermissionError:
|
||||
raise HTTPException(
|
||||
status_code = 403,
|
||||
detail = f"Permission denied reading {target}",
|
||||
)
|
||||
except OSError as exc:
|
||||
raise HTTPException(
|
||||
status_code = 500,
|
||||
detail = f"Could not read {target}: {exc}",
|
||||
)
|
||||
|
||||
try:
|
||||
for child in it:
|
||||
# Bound by *visited entries*, not by *appended entries*: in
|
||||
# directories full of files (or hidden subdirs when
|
||||
# ``show_hidden=False``) the cap on ``len(entries)`` would
|
||||
# never trigger and we'd still stat every child. Counting
|
||||
# visits keeps the worst-case work to ``_BROWSE_ENTRY_CAP``
|
||||
# iterdir/is_dir calls regardless of how many of them
|
||||
# survive the filters below.
|
||||
visited += 1
|
||||
if visited > _BROWSE_ENTRY_CAP:
|
||||
truncated = True
|
||||
break
|
||||
try:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
name = child.name
|
||||
is_hidden = name.startswith(".")
|
||||
if is_hidden and not show_hidden:
|
||||
continue
|
||||
entries.append(
|
||||
BrowseEntry(
|
||||
name = name,
|
||||
has_models = _looks_like_model_dir(child),
|
||||
hidden = is_hidden,
|
||||
)
|
||||
)
|
||||
except PermissionError as exc:
|
||||
logger.debug(
|
||||
"browse-folders: permission denied during enumeration of %s: %s",
|
||||
target,
|
||||
exc,
|
||||
)
|
||||
except OSError as exc:
|
||||
# Rare: iterdir succeeded but reading a specific entry failed.
|
||||
logger.warning("browse-folders: partial enumeration of %s: %s", target, exc)
|
||||
|
||||
# Model-bearing dirs first, then plain, then hidden; case-insensitive
|
||||
# alphabetical within each bucket.
|
||||
def _sort_key(e: BrowseEntry) -> tuple[int, str]:
|
||||
bucket = 0 if e.has_models else (2 if e.hidden else 1)
|
||||
return (bucket, e.name.lower())
|
||||
|
||||
entries.sort(key = _sort_key)
|
||||
|
||||
# Parent is None at the filesystem root (`p.parent == p`) AND when
|
||||
# the parent would step outside the sandbox -- otherwise the up-row
|
||||
# would 403 on click. Users can still hop to other allowed roots
|
||||
# via the suggestion chips below.
|
||||
parent: Optional[str]
|
||||
if target.parent == target or not _is_path_inside_allowlist(
|
||||
target.parent, allowed_roots
|
||||
):
|
||||
parent = None
|
||||
else:
|
||||
parent = str(target.parent)
|
||||
|
||||
# Handy starting points for the quick-pick chips.
|
||||
suggestions: list[str] = []
|
||||
seen_sug: set[str] = set()
|
||||
|
||||
def _add_sug(p: Optional[Path]) -> None:
|
||||
if p is None:
|
||||
return
|
||||
try:
|
||||
resolved = str(p.resolve())
|
||||
except OSError:
|
||||
return
|
||||
if resolved in seen_sug:
|
||||
return
|
||||
if Path(resolved).is_dir():
|
||||
seen_sug.add(resolved)
|
||||
suggestions.append(resolved)
|
||||
|
||||
# Home always comes first -- it's the safe fallback when everything
|
||||
# else is cold.
|
||||
_add_sug(Path.home())
|
||||
# The HF cache root the process is actually using.
|
||||
try:
|
||||
_add_sug(hf_default_cache_dir())
|
||||
except Exception:
|
||||
pass
|
||||
# Already-registered scan folders (what the user has curated).
|
||||
try:
|
||||
for folder in list_scan_folders():
|
||||
_add_sug(Path(folder.get("path", "")))
|
||||
except Exception as exc:
|
||||
logger.debug("browse-folders: could not load scan folders: %s", exc)
|
||||
# Directories commonly used by other local-LLM tools: LM Studio
|
||||
# (`~/.lmstudio/models` + legacy `~/.cache/lm-studio/models` +
|
||||
# user-configured downloadsFolder from LM Studio's settings.json),
|
||||
# Ollama (`~/.ollama/models` + common system paths + OLLAMA_MODELS
|
||||
# env var), and generic user-choice spots (`~/models`, `~/Models`).
|
||||
# Each helper only returns paths that currently exist so we never
|
||||
# show dead chips.
|
||||
try:
|
||||
for p in well_known_model_dirs():
|
||||
_add_sug(p)
|
||||
except Exception as exc:
|
||||
logger.debug("browse-folders: could not load well-known dirs: %s", exc)
|
||||
|
||||
return BrowseFoldersResponse(
|
||||
current = str(target),
|
||||
parent = parent,
|
||||
entries = entries,
|
||||
suggestions = suggestions,
|
||||
truncated = truncated,
|
||||
model_files_here = _count_model_files(target),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_models(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
|
|
|
|||
86
studio/backend/tests/test_browse_folders_route.py
Normal file
86
studio/backend/tests/test_browse_folders_route.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Keep this test runnable in lightweight environments where optional logging
|
||||
# deps are not installed.
|
||||
if "structlog" not in sys.modules:
|
||||
|
||||
class _DummyLogger:
|
||||
def __getattr__(self, _name):
|
||||
return lambda *args, **kwargs: None
|
||||
|
||||
sys.modules["structlog"] = types.SimpleNamespace(
|
||||
BoundLogger = _DummyLogger,
|
||||
get_logger = lambda *args, **kwargs: _DummyLogger(),
|
||||
)
|
||||
|
||||
import routes.models as models_route
|
||||
|
||||
|
||||
def test_resolve_browse_target_returns_allowed_directory(tmp_path):
|
||||
allowed = tmp_path / "allowed"
|
||||
target = allowed / "models" / "nested"
|
||||
target.mkdir(parents = True)
|
||||
|
||||
resolved = models_route._resolve_browse_target(str(target), [allowed])
|
||||
|
||||
assert resolved == target.resolve()
|
||||
|
||||
|
||||
def test_resolve_browse_target_rejects_outside_allowlist(tmp_path):
|
||||
allowed = tmp_path / "allowed"
|
||||
disallowed = tmp_path / "disallowed"
|
||||
allowed.mkdir()
|
||||
disallowed.mkdir()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
models_route._resolve_browse_target(str(disallowed), [allowed])
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
def test_resolve_browse_target_rejects_file_path(tmp_path):
|
||||
allowed = tmp_path / "allowed"
|
||||
allowed.mkdir()
|
||||
model_file = allowed / "model.gguf"
|
||||
model_file.write_text("gguf")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
models_route._resolve_browse_target(str(model_file), [allowed])
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_resolve_browse_target_allows_symlink_into_other_allowed_root(tmp_path):
|
||||
home_root = tmp_path / "home"
|
||||
scan_root = tmp_path / "scan"
|
||||
target = scan_root / "nested"
|
||||
home_root.mkdir()
|
||||
target.mkdir(parents = True)
|
||||
(home_root / "scan-link").symlink_to(scan_root, target_is_directory = True)
|
||||
|
||||
resolved = models_route._resolve_browse_target(
|
||||
str(home_root / "scan-link" / "nested"),
|
||||
[home_root, scan_root],
|
||||
)
|
||||
|
||||
assert resolved == target.resolve()
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.altsep is not None, reason = "POSIX-only path semantics")
|
||||
def test_resolve_browse_target_allows_backslash_in_posix_segment(tmp_path):
|
||||
allowed = tmp_path / "allowed"
|
||||
target = allowed / r"dir\name"
|
||||
target.mkdir(parents = True)
|
||||
|
||||
resolved = models_route._resolve_browse_target(str(target), [allowed])
|
||||
|
||||
assert resolved == target.resolve()
|
||||
|
|
@ -34,6 +34,7 @@ from .storage_roots import (
|
|||
legacy_hf_cache_dir,
|
||||
hf_default_cache_dir,
|
||||
lmstudio_model_dirs,
|
||||
well_known_model_dirs,
|
||||
ensure_dir,
|
||||
ensure_studio_directories,
|
||||
resolve_under_root,
|
||||
|
|
@ -70,6 +71,7 @@ __all__ = [
|
|||
"legacy_hf_cache_dir",
|
||||
"hf_default_cache_dir",
|
||||
"lmstudio_model_dirs",
|
||||
"well_known_model_dirs",
|
||||
"ensure_dir",
|
||||
"ensure_studio_directories",
|
||||
"resolve_under_root",
|
||||
|
|
|
|||
|
|
@ -130,6 +130,51 @@ def lmstudio_model_dirs() -> list[Path]:
|
|||
return dirs
|
||||
|
||||
|
||||
def well_known_model_dirs() -> list[Path]:
|
||||
"""Return directories commonly used by other local LLM tools.
|
||||
|
||||
Used by the folder browser to offer quick-pick chips. Returns only
|
||||
paths that exist on disk, so the UI never shows dead chips. Order
|
||||
reflects a rough "likelihood the user has models here" -- LM Studio
|
||||
and Ollama first, then the generic fallbacks.
|
||||
"""
|
||||
candidates: list[Path] = []
|
||||
|
||||
# LM Studio (reuses the logic above, including settings.json override)
|
||||
candidates.extend(lmstudio_model_dirs())
|
||||
|
||||
# Ollama -- both the user-level and common system-wide install paths
|
||||
# (https://github.com/ollama/ollama/issues/733).
|
||||
ollama_env = os.environ.get("OLLAMA_MODELS")
|
||||
if ollama_env:
|
||||
candidates.append(Path(ollama_env).expanduser())
|
||||
candidates.append(Path.home() / ".ollama" / "models")
|
||||
candidates.append(Path("/usr/share/ollama/.ollama/models"))
|
||||
candidates.append(Path("/var/lib/ollama/.ollama/models"))
|
||||
|
||||
# HF hub cache root (separate from the explicit HF cache chip)
|
||||
candidates.append(Path.home() / ".cache" / "huggingface" / "hub")
|
||||
|
||||
# Generic "my models" spots users tend to drop things into
|
||||
for name in ("models", "Models"):
|
||||
candidates.append(Path.home() / name)
|
||||
|
||||
# Deduplicate while preserving order; keep only extant dirs
|
||||
out: list[Path] = []
|
||||
seen: set[str] = set()
|
||||
for p in candidates:
|
||||
try:
|
||||
resolved = str(p.resolve())
|
||||
except OSError:
|
||||
continue
|
||||
if resolved in seen:
|
||||
continue
|
||||
if Path(resolved).is_dir():
|
||||
seen.add(resolved)
|
||||
out.append(Path(resolved))
|
||||
return out
|
||||
|
||||
|
||||
def _setup_cache_env() -> None:
|
||||
"""Set cache environment variables for HuggingFace, uv, and vLLM.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,328 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
type BrowseFoldersResponse,
|
||||
browseFolders,
|
||||
} from "@/features/chat/api/chat-api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowUp02Icon, Folder02Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
export interface FolderBrowserProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Called with the absolute path the user confirmed. */
|
||||
onSelect: (path: string) => void;
|
||||
/** Optional initial directory. Defaults to the user's home on the server. */
|
||||
initialPath?: string;
|
||||
}
|
||||
|
||||
function splitBreadcrumb(path: string): { label: string; value: string }[] {
|
||||
if (!path) return [];
|
||||
// Distinguish path styles BEFORE normalizing separators. On POSIX
|
||||
// backslashes are valid filename characters, so we cannot blindly
|
||||
// rewrite ``\`` -> ``/`` -- doing so would mangle directory names
|
||||
// like ``my\backup`` into ``my/backup`` and produce breadcrumb
|
||||
// values that 404 on the server. Only Windows-style absolute paths
|
||||
// (drive letter, or UNC ``\\server\share``) get the conversion.
|
||||
const isWindowsDrive = /^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path);
|
||||
const isUnc = /^\\\\/.test(path);
|
||||
const isWindows = isWindowsDrive || isUnc;
|
||||
const normalized = isWindows ? path.replace(/\\/g, "/") : path;
|
||||
const segments = normalized.split("/");
|
||||
const parts: { label: string; value: string }[] = [];
|
||||
|
||||
// POSIX absolute path: leading empty segment from split("/")
|
||||
if (segments[0] === "") {
|
||||
parts.push({ label: "/", value: "/" });
|
||||
let cur = "";
|
||||
for (const seg of segments.slice(1)) {
|
||||
if (!seg) continue;
|
||||
cur = `${cur}/${seg}`;
|
||||
parts.push({ label: seg, value: cur });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Windows-ish drive path (C:, D:): first segment is the drive. Use
|
||||
// ``C:/`` (drive-absolute) as the crumb value so clicking the drive
|
||||
// root navigates to the root of the drive rather than the
|
||||
// drive-relative current working directory on that drive (``C:``
|
||||
// alone resolves to ``CWD-on-C``, not ``C:\``).
|
||||
if (/^[A-Za-z]:$/.test(segments[0])) {
|
||||
const driveRoot = `${segments[0]}/`;
|
||||
let cur = driveRoot;
|
||||
parts.push({ label: segments[0], value: driveRoot });
|
||||
for (const seg of segments.slice(1)) {
|
||||
if (!seg) continue;
|
||||
cur = cur.endsWith("/") ? `${cur}${seg}` : `${cur}/${seg}`;
|
||||
parts.push({ label: seg, value: cur });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Fallback: relative / UNC-ish. Render as-is as a single crumb.
|
||||
return [{ label: path, value: path }];
|
||||
}
|
||||
|
||||
export function FolderBrowser({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
initialPath,
|
||||
}: FolderBrowserProps) {
|
||||
const [data, setData] = useState<BrowseFoldersResponse | null>(null);
|
||||
const [path, setPath] = useState<string | undefined>(initialPath);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const navigate = useCallback(
|
||||
(
|
||||
target: string | undefined,
|
||||
hidden: boolean,
|
||||
opts?: { fallbackOnError?: boolean },
|
||||
) => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// Forward the signal so cancelled navigation actually cancels the
|
||||
// backend enumeration instead of just discarding the response.
|
||||
browseFolders(target, hidden, ctrl.signal)
|
||||
.then((res) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
setData(res);
|
||||
setPath(res.current);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
// Surface the error, but if the very first request (typically
|
||||
// a typo'd or denylisted ``initialPath``) fails AND the
|
||||
// browser is empty (no ``data`` to render against), fall
|
||||
// back to the user's HOME so the modal is navigable instead
|
||||
// of an irrecoverable dead end.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
if (opts?.fallbackOnError && target !== undefined) {
|
||||
// Re-issue without a target -> backend defaults to HOME.
|
||||
// Don't recurse if HOME itself fails (paranoia: shouldn't
|
||||
// happen since the sandbox allowlist always includes HOME).
|
||||
queueMicrotask(() => navigate(undefined, hidden));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!ctrl.signal.aborted) setLoading(false);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Fetch when the dialog opens. Only re-run when the dialog transitions
|
||||
// closed -> open; subsequent navigation is driven by `navigate()` so we
|
||||
// don't want `path` in the dependency list here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// ``fallbackOnError``: if the user-supplied ``initialPath`` is bad
|
||||
// (typo, denylisted, deleted) we recover into HOME instead of
|
||||
// showing an empty modal with no breadcrumbs/entries.
|
||||
navigate(initialPath, showHidden, { fallbackOnError: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (!path) return;
|
||||
onSelect(path);
|
||||
onOpenChange(false);
|
||||
}, [onSelect, onOpenChange, path]);
|
||||
|
||||
const crumbs = useMemo(
|
||||
() => (data?.current ? splitBreadcrumb(data.current) : []),
|
||||
[data?.current],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-md p-0 gap-0"
|
||||
overlayClassName="bg-black/20 backdrop-blur-none"
|
||||
data-testid="folder-browser-dialog"
|
||||
>
|
||||
<DialogHeader className="px-4 pt-4 pb-2">
|
||||
<DialogTitle className="text-sm font-medium">
|
||||
Browse for folder
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-t border-border/50 px-4 py-2 font-mono text-[11px] text-muted-foreground">
|
||||
{crumbs.length === 0 ? (
|
||||
<span className="text-muted-foreground/60">(loading…)</span>
|
||||
) : (
|
||||
crumbs.map((c, i) => (
|
||||
<span key={c.value} className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-1 py-0.5 hover:bg-accent hover:text-foreground"
|
||||
onClick={() => navigate(c.value, showHidden)}
|
||||
disabled={loading}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
{i < crumbs.length - 1 && (
|
||||
<span className="text-muted-foreground/40">/</span>
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suggestions (quick-pick chips) */}
|
||||
{data?.suggestions && data.suggestions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 border-t border-border/50 px-4 py-2">
|
||||
{data.suggestions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => navigate(s, showHidden)}
|
||||
disabled={loading}
|
||||
className="rounded-full border border-border/50 px-2 py-0.5 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
title={s}
|
||||
>
|
||||
{s.length > 36 ? `…${s.slice(-33)}` : s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entry list */}
|
||||
<div className="max-h-64 min-h-24 overflow-y-auto border-t border-border/50">
|
||||
{error && (
|
||||
<div className="px-4 py-3 text-xs text-destructive">{error}</div>
|
||||
)}
|
||||
{!error && loading && (
|
||||
<div className="flex items-center gap-2 px-4 py-3">
|
||||
<Spinner className="size-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Loading…</span>
|
||||
</div>
|
||||
)}
|
||||
{!error && !loading && data && (
|
||||
<>
|
||||
{/* Up row */}
|
||||
{data.parent !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(data.parent ?? undefined, showHidden)}
|
||||
className="flex w-full items-center gap-2 px-4 py-1.5 text-left text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowUp02Icon}
|
||||
className="size-3 shrink-0"
|
||||
/>
|
||||
<span className="font-mono">..</span>
|
||||
</button>
|
||||
)}
|
||||
{data.entries.length === 0 && !(data.model_files_here && data.model_files_here > 0) && (
|
||||
<div className="px-4 py-3 text-xs text-muted-foreground/60">
|
||||
(empty directory)
|
||||
</div>
|
||||
)}
|
||||
{data.model_files_here !== undefined && data.model_files_here > 0 && (
|
||||
<div className="border-t border-border/30 px-4 py-1.5 text-[10px] text-foreground/70">
|
||||
{data.model_files_here} model file{data.model_files_here === 1 ? "" : "s"} in this folder. Click "Use this folder" to scan it.
|
||||
</div>
|
||||
)}
|
||||
{data.truncated === true && (
|
||||
<div className="border-t border-border/30 px-4 py-1.5 text-[10px] text-muted-foreground/70">
|
||||
Showing first {data.entries.length} entries. Narrow the path
|
||||
to see more.
|
||||
</div>
|
||||
)}
|
||||
{data.entries.map((e) => (
|
||||
<button
|
||||
type="button"
|
||||
key={e.name}
|
||||
onClick={() => {
|
||||
const sep = data.current.endsWith("/") ? "" : "/";
|
||||
navigate(`${data.current}${sep}${e.name}`, showHidden);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-4 py-1.5 text-left text-xs transition-colors hover:bg-accent hover:text-foreground",
|
||||
e.hidden && "text-muted-foreground/60",
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Folder02Icon}
|
||||
className={cn(
|
||||
"size-3 shrink-0",
|
||||
e.has_models
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
<span className="truncate font-mono">{e.name}</span>
|
||||
{e.has_models && (
|
||||
<span className="ml-auto shrink-0 rounded-full border border-border/50 px-1.5 py-0 text-[9px] uppercase tracking-wider text-muted-foreground">
|
||||
models
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<DialogFooter className="flex items-center justify-between gap-2 border-t border-border/50 px-4 py-2">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-[10px] text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showHidden}
|
||||
onChange={(e) => {
|
||||
const next = e.target.checked;
|
||||
setShowHidden(next);
|
||||
navigate(path, next);
|
||||
}}
|
||||
className="size-3"
|
||||
/>
|
||||
Show hidden
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<DialogClose asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 rounded border border-border/50 px-2.5 text-[11px] text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</DialogClose>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirm}
|
||||
disabled={!path || loading || !!error}
|
||||
className="h-7 rounded bg-foreground px-2.5 text-[11px] font-medium text-background transition-colors hover:bg-foreground/90 disabled:opacity-40"
|
||||
>
|
||||
Use this folder
|
||||
</button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ import type { VramFitStatus } from "@/lib/vram";
|
|||
import { checkVramFit, estimateLoadingVram } from "@/lib/vram";
|
||||
import { Add01Icon, Cancel01Icon, Folder02Icon, Search01Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { FolderBrowser } from "./folder-browser";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import {
|
||||
type ReactNode,
|
||||
|
|
@ -512,6 +513,7 @@ export function HubModelPicker({
|
|||
const [folderError, setFolderError] = useState<string | null>(null);
|
||||
const [showFolderInput, setShowFolderInput] = useState(false);
|
||||
const [folderLoading, setFolderLoading] = useState(false);
|
||||
const [showFolderBrowser, setShowFolderBrowser] = useState(false);
|
||||
|
||||
const refreshLocalModelsList = useCallback(() => {
|
||||
listLocalModels()
|
||||
|
|
@ -537,11 +539,22 @@ export function HubModelPicker({
|
|||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleAddFolder = useCallback(async () => {
|
||||
const trimmed = folderInput.trim();
|
||||
const handleAddFolder = useCallback(async (overridePath?: string) => {
|
||||
// Accept an explicit path so the folder browser can submit the
|
||||
// chosen path in the same tick it calls `setFolderInput`; reading
|
||||
// `folderInput` alone would race the state update.
|
||||
const raw = overridePath !== undefined ? overridePath : folderInput;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || folderLoading) return;
|
||||
setFolderError(null);
|
||||
setFolderLoading(true);
|
||||
// True when the request originated from the folder browser's
|
||||
// ``onSelect`` (one-click "Use this folder"). In that flow the
|
||||
// typed-input panel is closed, so the inline ``folderError``
|
||||
// paragraph is invisible. Surface failures via toast instead so
|
||||
// the action doesn't appear to silently no-op when the backend
|
||||
// rejects (denylisted path, sandbox 403, etc.).
|
||||
const fromBrowser = overridePath !== undefined;
|
||||
try {
|
||||
const created = await addScanFolder(trimmed);
|
||||
// Backend returns existing row for duplicates, so deduplicate
|
||||
|
|
@ -557,7 +570,11 @@ export function HubModelPicker({
|
|||
// Background reconciliation with the server
|
||||
void refreshScanFolders();
|
||||
} catch (e) {
|
||||
setFolderError(e instanceof Error ? e.message : "Failed to add folder");
|
||||
const message = e instanceof Error ? e.message : "Failed to add folder";
|
||||
setFolderError(message);
|
||||
if (fromBrowser) {
|
||||
toast.error("Couldn't add folder", { description: message });
|
||||
}
|
||||
} finally {
|
||||
setFolderLoading(false);
|
||||
}
|
||||
|
|
@ -984,30 +1001,42 @@ export function HubModelPicker({
|
|||
|
||||
{!showHfSection ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2.5 py-1.5">
|
||||
<div className="flex items-center justify-between gap-1 px-2.5 py-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Custom Folders
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showFolderInput ? "Cancel adding folder" : "Add scan folder"}
|
||||
onClick={() => {
|
||||
setShowFolderInput((open) => {
|
||||
if (open) { setFolderInput(""); setFolderError(null); }
|
||||
return !open;
|
||||
});
|
||||
}}
|
||||
className="rounded p-0.5 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={showFolderInput ? Cancel01Icon : Add01Icon} className="size-3" />
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Browse for a folder on the server"
|
||||
title="Browse folders on the server"
|
||||
onClick={() => setShowFolderBrowser(true)}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={Search01Icon} className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showFolderInput ? "Cancel adding folder" : "Add scan folder by path"}
|
||||
title={showFolderInput ? "Cancel" : "Add by typing a path"}
|
||||
onClick={() => {
|
||||
setShowFolderInput((open) => {
|
||||
if (open) { setFolderInput(""); setFolderError(null); }
|
||||
return !open;
|
||||
});
|
||||
}}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground/60 transition-colors hover:text-foreground"
|
||||
>
|
||||
<HugeiconsIcon icon={showFolderInput ? Cancel01Icon : Add01Icon} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Folder paths */}
|
||||
{scanFolders.map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="group flex items-center gap-1.5 px-3 py-0.5"
|
||||
className="group flex items-center gap-1.5 px-2.5 py-0.5"
|
||||
>
|
||||
<HugeiconsIcon icon={Folder02Icon} className="size-3 shrink-0 text-muted-foreground/40" />
|
||||
<span
|
||||
|
|
@ -1020,9 +1049,9 @@ export function HubModelPicker({
|
|||
type="button"
|
||||
onClick={() => handleRemoveFolder(f.id)}
|
||||
aria-label={`Remove folder ${f.path}`}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 focus-visible:opacity-100 transition-opacity hover:text-destructive"
|
||||
className="shrink-0 rounded p-1 text-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-2.5" />
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -1046,7 +1075,17 @@ export function HubModelPicker({
|
|||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddFolder}
|
||||
onClick={() => setShowFolderBrowser(true)}
|
||||
disabled={folderLoading}
|
||||
aria-label="Browse for folder"
|
||||
title="Browse folders on the server"
|
||||
className="flex h-6 shrink-0 items-center justify-center rounded border border-border/50 px-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40"
|
||||
>
|
||||
<HugeiconsIcon icon={Search01Icon} className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void handleAddFolder(); }}
|
||||
disabled={folderLoading || !folderInput.trim()}
|
||||
className="h-6 shrink-0 rounded border border-border/50 px-1.5 text-[10px] text-muted-foreground transition-colors hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
|
|
@ -1059,16 +1098,20 @@ export function HubModelPicker({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{scanFolders.length === 0 && customFolderModels.length === 0 && !showFolderInput && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFolderInput(true)}
|
||||
className="px-2.5 pb-1.5 text-left text-[10px] text-muted-foreground/60 transition-colors hover:text-muted-foreground"
|
||||
>
|
||||
+ Add a folder to scan for local models
|
||||
</button>
|
||||
)}
|
||||
<FolderBrowser
|
||||
open={showFolderBrowser}
|
||||
onOpenChange={setShowFolderBrowser}
|
||||
initialPath={folderInput.trim() || undefined}
|
||||
onSelect={(picked) => {
|
||||
setFolderInput(picked);
|
||||
setFolderError(null);
|
||||
// One-click UX: the "Use this folder" button submits
|
||||
// the scan folder directly. Pass the path explicitly
|
||||
// because `folderInput` state hasn't flushed yet.
|
||||
void handleAddFolder(picked);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
{/* Models from custom folders */}
|
||||
{customFolderModels.map((m) => {
|
||||
|
|
|
|||
|
|
@ -247,6 +247,42 @@ export async function removeScanFolder(id: number): Promise<void> {
|
|||
await parseJsonOrThrow<unknown>(response);
|
||||
}
|
||||
|
||||
export interface BrowseEntry {
|
||||
name: string;
|
||||
has_models: boolean;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface BrowseFoldersResponse {
|
||||
current: string;
|
||||
parent: string | null;
|
||||
entries: BrowseEntry[];
|
||||
suggestions: string[];
|
||||
truncated?: boolean;
|
||||
model_files_here?: number;
|
||||
}
|
||||
|
||||
export async function browseFolders(
|
||||
path?: string,
|
||||
showHidden = false,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowseFoldersResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (path !== undefined && path !== null) params.set("path", path);
|
||||
if (showHidden) params.set("show_hidden", "true");
|
||||
const qs = params.toString();
|
||||
// Forward the AbortSignal through authFetch -> fetch so that a
|
||||
// navigation cancelled in the FolderBrowser (rapid breadcrumb / row /
|
||||
// hidden-toggle clicks) actually cancels the in-flight HTTP request
|
||||
// server-side, instead of merely dropping the response client-side
|
||||
// while the backend keeps walking large directory trees.
|
||||
const response = await authFetch(
|
||||
`/api/models/browse-folders${qs ? `?${qs}` : ""}`,
|
||||
signal ? { signal } : undefined,
|
||||
);
|
||||
return parseJsonOrThrow<BrowseFoldersResponse>(response);
|
||||
}
|
||||
|
||||
export async function listGgufVariants(
|
||||
repoId: string,
|
||||
hfToken?: string,
|
||||
|
|
|
|||
Loading…
Reference in a new issue