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:
Daniel Han 2026-04-15 08:04:33 -07:00 committed by GitHub
parent 800ddc95f8
commit f0d03655e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1161 additions and 31 deletions

View file

@ -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)."
),
)

View file

@ -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),

View 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()

View file

@ -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",

View file

@ -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.

View file

@ -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>
);
}

View file

@ -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) => {

View file

@ -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,