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:
parent
1eb15162d9
commit
420799b61e
12 changed files with 272 additions and 8 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
40
studio/frontend/src/features/settings/api/models-folder.ts
Normal file
40
studio/frontend/src/features/settings/api/models-folder.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue