Studio: add an Open button to reveal the models folder in the file manager (#6452)

* Studio: add an Open button to reveal the models folder in the file manager

* Studio: report models folder creation failures

---------

Co-authored-by: Wasim Yousef Said <wasimysdev@gmail.com>
This commit is contained in:
oobabooga 2026-06-19 09:14:58 -03:00 committed by GitHub
commit 420799b61e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 272 additions and 8 deletions

View file

@ -29,6 +29,7 @@ from hub.schemas.inventory import (
DeleteCachedModelResponse,
GgufVariantsResponse,
LocalModelListResponse,
ModelsFolderResponse,
RecommendedFoldersResponse,
RemoveScanFolderResponse,
ScanFolderInfo,
@ -91,6 +92,11 @@ def browse_folders(
return folder_browser.browse_folders_response(path, show_hidden)
@router.get("/models-folder", response_model = ModelsFolderResponse)
def get_models_folder(current_subject: str = Depends(get_current_subject)):
return local_inventory.get_models_folder_response()
@router.get("/gguf-variants", response_model = GgufVariantsResponse)
async def get_gguf_variants(
repo_id: str = Query(

View file

@ -284,3 +284,13 @@ class BrowseFoldersResponse(BaseModel):
"they contain only files, no subdirectories)."
),
)
class ModelsFolderResponse(BaseModel):
"""The directory where downloaded models are stored (the active HF hub
cache, honoring ``HF_HOME`` / ``HF_HUB_CACHE``)."""
path: str = Field(
...,
description = "Path to the model download directory.",
)

View file

@ -670,6 +670,31 @@ async def list_local_models_response(models_dir: str = "./models") -> LocalModel
)
def get_models_folder_response() -> dict:
"""Return the directory where downloaded models are stored.
This is the active HF hub cache (honors ``HF_HOME`` / ``HF_HUB_CACHE``);
the desktop app reveals it in the OS file manager.
"""
path = _resolve_hf_cache_dir()
# Create it if missing so "Open folder" works before the first download:
# HF builds the cache lazily, and studio only pre-creates the *default*
# dir, not a user's explicit HF_HOME / HF_HUB_CACHE.
try:
path.mkdir(parents = True, exist_ok = True)
except OSError as e:
raise HTTPException(
status_code = 500,
detail = f"Failed to create models folder: {path}: {e}",
) from e
if not path.is_dir():
raise HTTPException(
status_code = 500,
detail = f"Models folder path is not a directory: {path}",
)
return {"path": str(path)}
def get_scan_folders_response() -> dict:
return {"folders": list_scan_folders()}

View file

@ -181,6 +181,55 @@ def test_browse_folders_hides_sensitive_dirs(monkeypatch, tmp_path):
assert ".ssh" not in names
def test_get_models_folder_response_creates_and_returns_dir(monkeypatch, tmp_path):
# The endpoint creates the cache dir on demand so the desktop "Open folder"
# action works even before the first download.
target = tmp_path / "hub"
monkeypatch.setattr(local_inventory, "_resolve_hf_cache_dir", lambda: target)
response = local_inventory.get_models_folder_response()
assert response == {"path": str(target)}
assert target.is_dir()
def test_get_models_folder_response_reports_create_failure(monkeypatch, tmp_path):
target = tmp_path / "hub"
target.write_text("not a directory")
monkeypatch.setattr(local_inventory, "_resolve_hf_cache_dir", lambda: target)
with pytest.raises(HTTPException) as exc_info:
local_inventory.get_models_folder_response()
assert exc_info.value.status_code == 500
assert "Failed to create models folder" in exc_info.value.detail
def test_get_models_folder_response_requires_directory(monkeypatch, tmp_path):
class MissingPath:
def __init__(self, value: Path):
self.value = value
def mkdir(self, *, parents: bool, exist_ok: bool):
assert parents is True
assert exist_ok is True
def is_dir(self):
return False
def __str__(self):
return str(self.value)
target = MissingPath(tmp_path / "hub")
monkeypatch.setattr(local_inventory, "_resolve_hf_cache_dir", lambda: target)
with pytest.raises(HTTPException) as exc_info:
local_inventory.get_models_folder_response()
assert exc_info.value.status_code == 500
assert "not a directory" in exc_info.value.detail
def test_contained_link_path_confines_to_link_dir(tmp_path):
link_dir = tmp_path / "ollama" / ".studio_links" / "abc123"

View file

@ -23,11 +23,14 @@ import {
listScanFolders,
removeScanFolder,
} from "@/features/hub/inventory";
import { openModelsDir } from "@/features/native-intents/api";
import { isTauri } from "@/lib/api-base";
import { cn } from "@/lib/utils";
import {
Delete02Icon,
FileSearchIcon,
FolderAddIcon,
FolderExportIcon,
FolderOpenIcon,
FolderSearchIcon,
PlusSignIcon,
@ -138,6 +141,16 @@ export function OnDeviceFoldersDialog({
[handleInventoryChanged, pending],
);
// Scan folders are arbitrary paths that may be moved or deleted after they
// were registered, so surface the command's failure as a toast.
const handleOpen = useCallback(async (folder: ScanFolderInfo) => {
try {
await openModelsDir(folder.path);
} catch (err) {
toast.error("Couldn't open location", { description: formatError(err) });
}
}, []);
const handleRemove = useCallback(
async (folder: ScanFolderInfo) => {
const key = `remove:${folder.id}` as const;
@ -333,6 +346,27 @@ export function OnDeviceFoldersDialog({
</TooltipContent>
</Tooltip>
</div>
{isTauri ? (
<Tooltip>
<TooltipTrigger asChild={true}>
<button
type="button"
aria-label={`Open ${folder.path}`}
onClick={() => void handleOpen(folder)}
className="inline-flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<HugeiconsIcon
icon={FolderExportIcon}
strokeWidth={1.75}
className="size-4"
/>
</button>
</TooltipTrigger>
<TooltipContent side="left" className="tooltip-compact">
Open in file manager
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild={true}>
<button

View file

@ -44,3 +44,9 @@ export async function revealPathToken(token: string): Promise<void> {
export async function openPathToken(token: string): Promise<void> {
return invokeNative<void>("open_path_token", { token });
}
// Open a backend-resolved directory (e.g. the models/HF cache folder) in the
// OS file manager. The Tauri command validates the path is a real directory.
export async function openModelsDir(path: string): Promise<void> {
return invokeNative<void>("open_models_dir", { path });
}

View file

@ -0,0 +1,40 @@
// 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 { authFetch } from "@/features/auth";
import { readFastApiError } from "@/lib/format-fastapi-error";
export type ModelsFolder = {
path: string;
};
// The path is resolved once at backend startup and never changes, so cache it
// and dedupe concurrent loads (same shape as the sibling settings loaders).
let cachedModelsFolder: ModelsFolder | null = null;
let inFlightModelsFolder: Promise<ModelsFolder> | null = null;
async function fetchModelsFolder(): Promise<ModelsFolder> {
const res = await authFetch("/api/hub/models-folder");
if (!res.ok) {
throw new Error(
await readFastApiError(res, "Failed to load models folder"),
);
}
const data = (await res.json()) as { path: string };
return { path: data.path };
}
export async function loadModelsFolder(): Promise<ModelsFolder> {
if (cachedModelsFolder) {
return cachedModelsFolder;
}
inFlightModelsFolder ??= fetchModelsFolder()
.then((folder) => {
cachedModelsFolder = folder;
return folder;
})
.finally(() => {
inFlightModelsFolder = null;
});
return inFlightModelsFolder;
}

View file

@ -13,6 +13,11 @@ import {
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { usePlatformStore } from "@/config/env";
import { isTauri } from "@/lib/api-base";
import { openModelsDir } from "@/features/native-intents/api";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { toast } from "@/lib/toast";
import { loadModelsFolder, type ModelsFolder } from "../api/models-folder";
import { resetOnboardingDone } from "@/features/auth";
import { useChatRuntimeStore } from "@/features/chat";
import {
@ -134,6 +139,7 @@ export function GeneralTab() {
null,
);
const [isSavingHelperPrecache, setIsSavingHelperPrecache] = useState(false);
const [modelsFolder, setModelsFolder] = useState<ModelsFolder | null>(null);
const draftRef = useRef(draftToken);
useEffect(() => {
@ -199,6 +205,43 @@ export function GeneralTab() {
};
}, [t]);
useEffect(() => {
let cancelled = false;
void loadModelsFolder()
.then((folder) => {
if (cancelled) return;
setModelsFolder(folder);
})
.catch(() => {
// Non-critical: leave the row hidden if the path can't be resolved.
});
return () => {
cancelled = true;
};
}, []);
// Desktop opens the folder in the OS file manager; the browser can't, so it
// falls back to copying the path (which is the info users actually want).
const handleModelsFolder = async () => {
const folder = modelsFolder;
if (!folder) return;
if (isTauri) {
try {
await openModelsDir(folder.path);
} catch (error) {
toast.error(t("settings.general.storage.openError"), {
description: error instanceof Error ? error.message : undefined,
});
}
return;
}
if (await copyToClipboard(folder.path)) {
toast.success(t("settings.general.storage.copied"));
} else {
toast.error(t("settings.general.storage.copyError"));
}
};
const saveHelperPrecache = async (enabled: boolean) => {
setIsSavingHelperPrecache(true);
setHelperPrecacheError(null);
@ -287,6 +330,33 @@ export function GeneralTab() {
</SettingsRow>
</SettingsSection>
{modelsFolder ? (
<SettingsSection title={t("settings.general.storage.sectionTitle")}>
<SettingsRow
label={t("settings.general.storage.modelsFolder")}
description={t("settings.general.storage.modelsFolderDescription")}
>
<div className="flex items-center gap-2">
<span
title={modelsFolder.path}
className="max-w-[280px] truncate font-mono text-xs text-muted-foreground"
>
{modelsFolder.path}
</span>
<Button
variant="outline"
size="sm"
onClick={() => void handleModelsFolder()}
>
{isTauri
? t("settings.general.storage.openAction")
: t("settings.general.storage.copyAction")}
</Button>
</div>
</SettingsRow>
</SettingsSection>
) : null}
<SettingsSection title={t("settings.general.chatDefaults")}>
<SettingsRow
label={t("settings.general.autoTitleNewChats")}

View file

@ -136,6 +136,17 @@ export const en = {
maxUploadSizeDescription:
"Default is {defaultSize} MB.",
},
storage: {
sectionTitle: "Storage",
modelsFolder: "Models folder",
modelsFolderDescription:
"Where downloaded models are stored.",
openAction: "Open",
copyAction: "Copy path",
copied: "Path copied",
openError: "Couldn't open the folder",
copyError: "Couldn't copy the path",
},
resetPreferences: {
sectionTitle: "Danger zone",
label: "Reset all local preferences",

View file

@ -321,17 +321,29 @@ pub fn get_server_logs(state: tauri::State<'_, BackendState>) -> Vec<String> {
}
}
/// Open the Unsloth Studio directory in the system file manager.
#[tauri::command]
pub fn open_logs_dir() -> Result<(), String> {
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
let dir = home.join(".unsloth").join("studio");
if !dir.exists() {
/// Open an existing directory in the system file manager. Validates the path
/// up front so callers get a clean error instead of a raw OS failure.
fn open_existing_dir(dir: &std::path::Path) -> Result<(), String> {
if !dir.is_dir() {
return Err(format!("Directory does not exist: {}", dir.display()));
}
open::that(dir).map_err(|e| format!("Failed to open directory: {}", e))
}
open::that(&dir).map_err(|e| format!("Failed to open directory: {}", e))
/// Open the Unsloth Studio directory in the system file manager.
#[tauri::command]
pub fn open_logs_dir(window: tauri::WebviewWindow) -> Result<(), String> {
crate::native_intents::ensure_main_window(&window)?;
let home = dirs::home_dir().ok_or("Could not determine home directory")?;
open_existing_dir(&home.join(".unsloth").join("studio"))
}
/// Open a models directory (resolved by the backend, e.g. the HF cache) in the
/// system file manager.
#[tauri::command]
pub fn open_models_dir(window: tauri::WebviewWindow, path: String) -> Result<(), String> {
crate::native_intents::ensure_main_window(&window)?;
open_existing_dir(std::path::Path::new(&path))
}
/// Start the first-launch installation process.

View file

@ -204,6 +204,7 @@ fn main() {
commands::check_health,
commands::get_server_logs,
commands::open_logs_dir,
commands::open_models_dir,
commands::start_backend_update,
commands::start_managed_repair,
commands::cancel_pending_elevation,

View file

@ -258,7 +258,7 @@ fn prune_expired(inner: &mut NativeIntakeInner) {
.retain(|intent| intent.path.expires_at_ms > now);
}
fn ensure_main_window(window: &WebviewWindow) -> Result<(), String> {
pub(crate) fn ensure_main_window(window: &WebviewWindow) -> Result<(), String> {
if window.label() == "main" {
Ok(())
} else {