diff --git a/studio/backend/hub/routes/inventory.py b/studio/backend/hub/routes/inventory.py index fcfdd0ad14..4b6c179a2b 100644 --- a/studio/backend/hub/routes/inventory.py +++ b/studio/backend/hub/routes/inventory.py @@ -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( diff --git a/studio/backend/hub/schemas/inventory.py b/studio/backend/hub/schemas/inventory.py index c333c7ca89..44ff545e76 100644 --- a/studio/backend/hub/schemas/inventory.py +++ b/studio/backend/hub/schemas/inventory.py @@ -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.", + ) diff --git a/studio/backend/hub/services/models/local_inventory.py b/studio/backend/hub/services/models/local_inventory.py index 94e0913ac9..a3782efead 100644 --- a/studio/backend/hub/services/models/local_inventory.py +++ b/studio/backend/hub/services/models/local_inventory.py @@ -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()} diff --git a/studio/backend/hub/tests/test_model_services.py b/studio/backend/hub/tests/test_model_services.py index df97b9cf97..1eb7042e4e 100644 --- a/studio/backend/hub/tests/test_model_services.py +++ b/studio/backend/hub/tests/test_model_services.py @@ -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" diff --git a/studio/frontend/src/features/hub/catalog/on-device-folders-dialog.tsx b/studio/frontend/src/features/hub/catalog/on-device-folders-dialog.tsx index 1769f8861a..20a4eca4dd 100644 --- a/studio/frontend/src/features/hub/catalog/on-device-folders-dialog.tsx +++ b/studio/frontend/src/features/hub/catalog/on-device-folders-dialog.tsx @@ -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({ + {isTauri ? ( + + + + + + Open in file manager + + + ) : null} + + + + ) : null} + ) -> Vec { } } -/// 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. diff --git a/studio/src-tauri/src/main.rs b/studio/src-tauri/src/main.rs index 498cd81579..4ed12051ed 100644 --- a/studio/src-tauri/src/main.rs +++ b/studio/src-tauri/src/main.rs @@ -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, diff --git a/studio/src-tauri/src/native_intents.rs b/studio/src-tauri/src/native_intents.rs index 30e11aa310..dccbba7083 100644 --- a/studio/src-tauri/src/native_intents.rs +++ b/studio/src-tauri/src/native_intents.rs @@ -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 {