Studio: make the model download folder reachable from the Hub, and findable in search (#7466)

* Studio: make the model download folder reachable from the Hub, and findable in search

The only control for where models download lived in Settings > System >
Storage, labelled "Model downloads". Settings search matched a row's visible
label only, so "models folder", "directory", "path" and "drive" all returned
nothing, and users concluded the location could not be changed at all.

Hub > On-device locations now leads with a Download location row: current
path, Change (folder browser on web, native picker on desktop), Use default,
free space, and a note when HF_HOME pins it. That dialog is where people
already look for where models live, but it only managed read-only scan
folders. Changing the location refreshes the inventory.

Settings search now also matches per-row keyword aliases, so "folder",
"directory", "path", "location", "drive", "disk" and "cache" find the row.
Relabels it "Models folder" and says it can be moved off the system drive.
Adds the German strings for the block, which fell back to English.

* Re-read the download location on every open, and drop it when the read fails

The dialog stays mounted between opens, so a reopen that hit a failing or slow
GET /api/settings/hugging-face-cache kept showing the previous path with Change
and Use default still enabled, as though it had just been confirmed.

The loaded flag is re-armed on each open and a failed read now clears the
settings, so the field falls back to Unknown and both buttons disable until a
read succeeds.

* Let the inventory version bump be the only refresh after a cache move

updateHuggingFaceCacheSettings already bumps the inventory version, which
re-fetches every source. Calling onInventoryChange as well started a second
round under the previous version, and the differing keys meant the two could not
be deduplicated, so moving the folder scanned everything twice.

The settings Resources tab already relies on the bump alone for the same call.

---------

Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local>
This commit is contained in:
Michael Han 2026-07-28 22:00:50 -07:00 committed by GitHub
commit 5b73c9c5b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 227 additions and 10 deletions

View file

@ -23,12 +23,21 @@ import {
removeScanFolder,
} from "@/features/hub";
import { FolderBrowser } from "@/features/model-picker";
import { openModelsDir } from "@/features/native-intents";
import {
openModelsDir,
pickHuggingFaceCacheDir,
} from "@/features/native-intents";
import {
type HuggingFaceCacheSettings,
loadHuggingFaceCacheSettings,
updateHuggingFaceCacheSettings,
} from "@/features/settings";
import { isTauri } from "@/lib/api-base";
import { toast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import {
Delete02Icon,
DownloadCircle01Icon,
FileSearchIcon,
FolderAddIcon,
FolderExportIcon,
@ -49,6 +58,12 @@ function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function formatFreeSpace(bytes: number | null): string | null {
if (bytes === null || !Number.isFinite(bytes)) return null;
const gb = bytes / 1024 ** 3;
return gb >= 10 ? `${Math.round(gb)} GB free` : `${gb.toFixed(1)} GB free`;
}
export function OnDeviceFoldersDialog({
open,
onOpenChange,
@ -68,6 +83,11 @@ export function OnDeviceFoldersDialog({
);
const refreshIdRef = useRef(0);
const mutationVersionRef = useRef(0);
const [downloadCache, setDownloadCache] =
useState<HuggingFaceCacheSettings | null>(null);
const [downloadCacheLoaded, setDownloadCacheLoaded] = useState(false);
const [downloadBrowserOpen, setDownloadBrowserOpen] = useState(false);
const [downloadSaving, setDownloadSaving] = useState(false);
const sortedFolders = useMemo(
() => [...folders].sort((a, b) => a.path.localeCompare(b.path)),
@ -108,10 +128,66 @@ export function OnDeviceFoldersDialog({
return () => window.clearTimeout(timer);
}, [open, refreshFolders]);
useEffect(() => {
if (!open) return;
let cancelled = false;
// The dialog stays mounted between opens, so re-arm the flag or a reopen
// shows the previous answer as if it were fresh.
setDownloadCacheLoaded(false);
loadHuggingFaceCacheSettings()
// Indexed locations do not depend on this. Null drops the stale path
// rather than offer Change against a location we could not confirm.
.catch(() => null)
.then((settings) => {
if (cancelled) return;
setDownloadCache(settings);
setDownloadCacheLoaded(true);
});
return () => {
cancelled = true;
};
}, [open]);
const handleInventoryChanged = useCallback(() => {
onInventoryChange?.();
}, [onInventoryChange]);
// Relocating the cache changes which repos are on disk, but
// updateHuggingFaceCacheSettings already bumps the inventory version, which
// re-fetches every source. Refreshing here too would scan twice, since the
// two rounds carry different version keys and cannot be deduplicated.
const saveDownloadLocation = useCallback(async (nextPath: string | null) => {
setDownloadSaving(true);
try {
const settings = await updateHuggingFaceCacheSettings(nextPath);
setDownloadCache(settings);
toast.success("Download location updated", {
description: settings.cacheHome,
});
} catch (err) {
toast.error("Couldn't update the download location", {
description: formatError(err),
});
} finally {
setDownloadSaving(false);
}
}, []);
const changeDownloadLocation = useCallback(async () => {
if (!isTauri) {
setDownloadBrowserOpen(true);
return;
}
try {
const picked = await pickHuggingFaceCacheDir();
if (picked) await saveDownloadLocation(picked);
} catch (err) {
toast.error("Couldn't open the folder picker", {
description: formatError(err),
});
}
}, [saveDownloadLocation]);
const handleAdd = useCallback(
async (rawPath: string) => {
const nextPath = rawPath.trim();
@ -182,10 +258,10 @@ export function OnDeviceFoldersDialog({
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="gap-0 overflow-hidden p-0 sm:max-w-[620px] lg:max-w-[660px] xl:max-w-[680px] [&_[data-slot=dialog-close]]:right-3 [&_[data-slot=dialog-close]]:top-3"
className="flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[620px] lg:max-w-[660px] xl:max-w-[680px] [&_[data-slot=dialog-close]]:right-3 [&_[data-slot=dialog-close]]:top-3"
overlayClassName="bg-black/20 backdrop-blur-none"
>
<DialogHeader className="border-b border-border/60 px-5 py-4">
<DialogHeader className="shrink-0 border-b border-border/60 px-5 py-4">
<DialogTitle className="text-ui-15">
On-device locations
</DialogTitle>
@ -195,7 +271,78 @@ export function OnDeviceFoldersDialog({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 px-5 py-4">
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
<div className="rounded-[14px] border border-border/70 bg-muted/20 p-3">
<div className="mb-2 flex items-center gap-2 text-ui-12 font-medium text-foreground">
<HugeiconsIcon
icon={DownloadCircle01Icon}
strokeWidth={1.75}
className="size-3.5 text-muted-foreground"
/>
Download location
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
readOnly={true}
aria-label="Model download location"
value={
downloadCache?.cacheHome ??
(downloadCacheLoaded ? "Unknown" : "Loading...")
}
title={downloadCache?.cacheHome}
className="field-soft h-9 min-w-0 flex-1 rounded-full px-3 font-mono text-ui-12"
/>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => void changeDownloadLocation()}
disabled={!downloadCache?.editable || downloadSaving}
className="h-9 rounded-full px-3 text-ui-12p5"
>
{downloadSaving ? (
<Spinner className="size-3.5" />
) : (
<HugeiconsIcon
icon={FolderSearchIcon}
strokeWidth={1.75}
data-icon="inline-start"
className="size-3.5"
/>
)}
Change
</Button>
{downloadCache?.isCustom ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => void saveDownloadLocation(null)}
disabled={downloadSaving}
className="h-9 rounded-full px-3 text-ui-12p5 text-muted-foreground"
>
Use default
</Button>
) : null}
</div>
</div>
<p className="mt-2 text-ui-10p5 text-muted-foreground">
{downloadCache?.source === "environment"
? `Managed by the ${
downloadCache.environmentVariable ?? "HF_HOME"
} environment variable.`
: [
"New downloads only. Models already on disk stay where they are.",
formatFreeSpace(downloadCache?.freeBytes ?? null),
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<div className="rounded-[14px] border border-border/70 bg-muted/20 p-3">
<div className="mb-2 flex items-center gap-2 text-ui-12 font-medium text-foreground">
<HugeiconsIcon
@ -425,6 +572,16 @@ export function OnDeviceFoldersDialog({
onOpenChange={setBrowserOpen}
onSelect={(selectedPath) => void handleAdd(selectedPath)}
/>
<FolderBrowser
open={!isTauri && downloadBrowserOpen}
onOpenChange={setDownloadBrowserOpen}
onSelect={(selectedPath) => void saveDownloadLocation(selectedPath)}
initialPath={downloadCache?.cacheHome}
title="Choose model download location"
confirmLabel="Use for future downloads"
showModelHints={false}
/>
</>
);
}

View file

@ -3,6 +3,11 @@
export { SettingsDialog } from "./settings-dialog";
export { loadEmbeddingModelSettings } from "./api/embedding-model";
export {
loadHuggingFaceCacheSettings,
updateHuggingFaceCacheSettings,
} from "./api/hugging-face-cache";
export type { HuggingFaceCacheSettings } from "./api/hugging-face-cache";
export {
loadPersonalization,
savePersonalization,

View file

@ -35,7 +35,10 @@ import {
useRef,
useState,
} from "react";
import { SETTINGS_SEARCH_INDEX } from "./settings-search";
import {
SETTINGS_SEARCH_INDEX,
SETTINGS_SEARCH_KEYWORDS,
} from "./settings-search";
import {
type SettingsTab,
useSettingsDialogStore,
@ -157,8 +160,12 @@ export function SettingsDialog() {
return TABS.map((tab) => {
const tabLabel = t(tab.labelKey);
const entries = SETTINGS_SEARCH_INDEX[tab.id]
.map((key) => t(key))
.filter((label) => label.toLowerCase().includes(q));
.filter((key) => {
if (t(key).toLowerCase().includes(q)) return true;
const keywordsKey = SETTINGS_SEARCH_KEYWORDS[key];
return keywordsKey ? t(keywordsKey).toLowerCase().includes(q) : false;
})
.map((key) => t(key));
const deduped = [...new Set(entries)];
return {
tab,

View file

@ -146,3 +146,15 @@ export const SETTINGS_SEARCH_INDEX: Record<SettingsTab, TranslationKey[]> = {
"settings.about.shutDownStudio",
],
};
/**
* Extra terms a row matches on, beyond its own label. The value is a
* translation key holding space-separated synonyms; it is never rendered.
* Search matched labels only, so "models folder" or "directory" found nothing.
*/
export const SETTINGS_SEARCH_KEYWORDS: Partial<
Record<TranslationKey, TranslationKey>
> = {
"settings.resources.storage.modelsFolder":
"settings.resources.storage.modelsFolderKeywords",
};

View file

@ -317,6 +317,8 @@ export const ar = {
diskUsage: "{used} مستخدم / {total}",
diskFree: "{free} متاح",
modelsFolder: "مجلد النماذج",
modelsFolderKeywords:
"النماذج مجلد دليل مسار موقع تنزيلات التنزيل ذاكرة التخزين المؤقت تخزين قرص محرك نقل تغيير models folder path hugging face",
modelsFolderDescription: "المكان الذي تُخزَّن فيه النماذج المُنزَّلة.",
openAction: "فتح",
copyAction: "نسخ المسار",

View file

@ -330,9 +330,23 @@ export const de = {
diskFree: "{free} frei",
modelsFolder: "Modell-Ordner",
modelsFolderDescription:
"Wo heruntergeladene Modelle gespeichert werden.",
"Wo heruntergeladene Modelle gespeichert werden. Ändern Sie ihn, um Modelle nicht auf dem Systemlaufwerk abzulegen.",
modelsFolderKeywords:
"Modelle Ordner Verzeichnis Pfad Speicherort Download Downloads Cache Speicher Festplatte Laufwerk verschieben ändern hugging face",
futureDownloads: "Nur neue Downloads",
environmentManaged:
"Wird über die Umgebungsvariable {variable} verwaltet.",
locationFree: "{free} frei",
openAction: "Öffnen",
copyAction: "Pfad kopieren",
changeAction: "Ändern",
resetAction: "Standard verwenden",
chooseTitle: "Speicherort für Modell-Downloads wählen",
chooseAction: "Für künftige Downloads verwenden",
cacheSaved: "Speicherort für Modell-Downloads aktualisiert",
cacheSaveError:
"Der Speicherort für Modell-Downloads konnte nicht geändert werden",
cachePickerError: "Die Ordnerauswahl konnte nicht geöffnet werden",
copied: "Pfad kopiert",
openError: "Der Ordner konnte nicht geöffnet werden",
copyError: "Der Pfad konnte nicht kopiert werden",

View file

@ -556,8 +556,12 @@ export const en = {
systemDisk: "System disk",
diskUsage: "{used} used / {total}",
diskFree: "{free} free",
modelsFolder: "Model downloads",
modelsFolderDescription: "Hugging Face cache used for model downloads.",
modelsFolder: "Models folder",
modelsFolderDescription:
"Where downloaded models are stored. Change it to keep models off your system drive.",
// Not rendered: extra terms the settings search matches this row on.
modelsFolderKeywords:
"models folder directory path location download downloads cache storage disk drive move relocate hugging face",
futureDownloads: "New downloads only",
environmentManaged: "Managed by the {variable} environment variable.",
locationFree: "{free} free",

View file

@ -328,6 +328,8 @@ export const es = {
diskUsage: "{used} en uso / {total}",
diskFree: "{free} libre",
modelsFolder: "Carpeta de modelos",
modelsFolderKeywords:
"modelos carpeta directorio ruta ubicacion ubicación descargas descarga cache caché almacenamiento disco unidad mover cambiar models folder path hugging face",
modelsFolderDescription:
"Dónde se almacenan los modelos descargados.",
openAction: "Abrir",

View file

@ -325,6 +325,8 @@ export const fr = {
diskUsage: "{used} utilisé / {total}",
diskFree: "{free} libre",
modelsFolder: "Dossier des modèles",
modelsFolderKeywords:
"modeles modèles dossier repertoire répertoire chemin emplacement telechargements téléchargements cache stockage disque lecteur deplacer déplacer changer models folder path hugging face",
modelsFolderDescription: "Emplacement de stockage des modèles téléchargés.",
openAction: "Ouvrir",
copyAction: "Copier le chemin",

View file

@ -316,6 +316,8 @@ export const hi = {
diskUsage: "{used} उपयोग में / {total}",
diskFree: "{free} खाली",
modelsFolder: "मॉडल फ़ोल्डर",
modelsFolderKeywords:
"मॉडल फ़ोल्डर फोल्डर निर्देशिका पथ स्थान डाउनलोड कैश संग्रहण डिस्क ड्राइव स्थानांतरित बदलें models folder path hugging face",
modelsFolderDescription: "जहां डाउनलोड किए गए मॉडल संग्रहीत होते हैं।",
openAction: "खोलें",
copyAction: "पथ कॉपी करें",

View file

@ -393,6 +393,8 @@ export const ja = {
diskUsage: "{used} 使用中 / {total}",
diskFree: "{free} 空き",
modelsFolder: "モデルフォルダ",
modelsFolderKeywords:
"モデル フォルダ ディレクトリ パス 保存先 場所 ダウンロード キャッシュ ストレージ ディスク ドライブ 移動 変更 models folder path hugging face",
modelsFolderDescription: "ダウンロードしたモデルの保存先。",
openAction: "開く",
copyAction: "パスをコピー",

View file

@ -315,6 +315,8 @@ export const ko = {
diskUsage: "{used} 사용 중 / {total}",
diskFree: "{free} 여유",
modelsFolder: "모델 폴더",
modelsFolderKeywords:
"모델 폴더 디렉터리 디렉토리 경로 위치 저장 다운로드 캐시 저장소 디스크 드라이브 이동 변경 models folder path hugging face",
modelsFolderDescription: "다운로드한 모델이 저장되는 위치입니다.",
openAction: "열기",
copyAction: "경로 복사",

View file

@ -417,6 +417,8 @@ export const ptBR = {
diskUsage: "{used} usados / {total}",
diskFree: "{free} livres",
modelsFolder: "Pasta de modelos",
modelsFolderKeywords:
"modelos pasta diretorio diretório caminho local localizacao localização downloads baixar cache armazenamento disco unidade mover alterar models folder path hugging face",
modelsFolderDescription: "Onde os modelos baixados são armazenados.",
openAction: "Abrir",
copyAction: "Copiar caminho",

View file

@ -316,6 +316,8 @@ export const ru = {
diskUsage: "{used} использовано / {total}",
diskFree: "{free} свободно",
modelsFolder: "Папка моделей",
modelsFolderKeywords:
"модели папка каталог путь расположение загрузки кэш хранилище диск перенести изменить models folder path hugging face",
modelsFolderDescription: "Где хранятся загруженные модели.",
openAction: "Открыть",
copyAction: "Копировать путь",

View file

@ -408,6 +408,8 @@ export const zhCN = {
diskUsage: "已用 {used} / {total}",
diskFree: "{free} 可用",
modelsFolder: "模型文件夹",
modelsFolderKeywords:
"模型 文件夹 目录 路径 位置 下载 缓存 存储 磁盘 驱动器 移动 更改 models folder path hugging face",
modelsFolderDescription: "已下载模型的存储位置。",
openAction: "打开",
copyAction: "复制路径",