feat(ui): complete Plan 06-01 - fix model scan + move model selector to input
- Fix Rust model scan to only list top-level models (not subdirectories) - Add scan_model_dir_top_level() function in model_files.rs - Create ModelSelectorPill component for chat input area - Remove model-section from Sidebar, move to ChatView - Update ChatView props to receive model-related props - Update tests for new ChatView props
This commit is contained in:
1 parent
3364dba5aa
commit
cb722e79a6
11 files changed
+1695
-254
No files matched your search
@@ -3,6 +3,8 @@ use std::path::Path;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
use crate::model_library::paths::resolve_models_dir;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ModelFile {
|
||||
pub name: String,
|
||||
@@ -34,23 +36,64 @@ fn scan_model_dir(path: &Path) -> Result<Vec<ModelFile>, String> {
|
||||
return Err(format!("Not a directory: {}", path.display()));
|
||||
}
|
||||
|
||||
// Default scan is top-level only (no recursion into subdirectories)
|
||||
scan_model_dir_top_level(path)
|
||||
}
|
||||
|
||||
/// Scans only the immediate children of `path` (depth=1, no recursion).
|
||||
/// Returns only files with a `.gguf` extension (case-insensitive), sorted by name.
|
||||
fn scan_model_dir_top_level(path: &Path) -> Result<Vec<ModelFile>, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("Directory not found: {}", path.display()));
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return Err(format!("Not a directory: {}", path.display()));
|
||||
}
|
||||
|
||||
let entries = std::fs::read_dir(path).map_err(|e| e.to_string())?;
|
||||
let mut models = Vec::new();
|
||||
let mut models: Vec<ModelFile> = entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.is_file()
|
||||
&& p.extension()
|
||||
.map(|ext| ext.eq_ignore_ascii_case("gguf"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.map(|p| model_file_from_path(&p))
|
||||
.collect();
|
||||
|
||||
models.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
/// Recursively scans all subdirectories for .gguf files.
|
||||
/// Kept for future use when deep scanning may be needed.
|
||||
#[allow(dead_code)]
|
||||
fn scan_model_dir_recursive(dir: &Path, models: &mut Vec<ModelFile>) -> Result<(), String> {
|
||||
let entries = std::fs::read_dir(dir).map_err(|e| e.to_string())?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file()
|
||||
if path.is_dir() {
|
||||
// Skip hidden directories (e.g. .git, .cache)
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
scan_model_dir_recursive(&path, models)?;
|
||||
} else if path.is_file()
|
||||
&& path
|
||||
.extension()
|
||||
.map(|ext| ext.eq_ignore_ascii_case("gguf"))
|
||||
.unwrap_or(false)
|
||||
.extension()
|
||||
.map(|ext| ext.eq_ignore_ascii_case("gguf"))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
models.push(model_file_from_path(&path));
|
||||
}
|
||||
}
|
||||
|
||||
models.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(models)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn import_model_file(src: &Path, dest_dir: &Path) -> Result<ModelFile, String> {
|
||||
@@ -86,7 +129,12 @@ fn import_model_file(src: &Path, dest_dir: &Path) -> Result<ModelFile, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub fn scan_models(dir: String) -> Result<Vec<ModelFile>, String> {
|
||||
scan_model_dir(Path::new(&dir))
|
||||
let path = if dir.trim().is_empty() {
|
||||
resolve_models_dir(None)
|
||||
} else {
|
||||
resolve_models_dir(Some(&dir))
|
||||
};
|
||||
scan_model_dir_top_level(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -171,6 +219,63 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_model_dir_returns_only_top_level_gguf() {
|
||||
// Tests that scan_model_dir (now using top-level scan) does NOT recurse into subdirectories
|
||||
let dir = temp_path("scan-models-top-level");
|
||||
let sub = dir.join("mradermacher").join("Suri-Qwen-3.5-4B-Uncensored-i1-GGUF");
|
||||
std::fs::create_dir_all(&sub).expect("create nested dirs");
|
||||
std::fs::write(dir.join("top-level.gguf"), b"demo").expect("write top-level model");
|
||||
std::fs::write(sub.join("nested-model.gguf"), b"demo").expect("write nested model");
|
||||
std::fs::write(sub.join("notes.txt"), b"ignore").expect("write non-model file");
|
||||
|
||||
let models = scan_model_dir(&dir).expect("scan model dir");
|
||||
let names: Vec<_> = models.iter().map(|m| m.name.as_str()).collect();
|
||||
|
||||
// Only top-level model should be returned, NOT nested ones
|
||||
assert_eq!(names, vec!["top-level.gguf"]);
|
||||
assert!(models.iter().all(|m| m.path.ends_with(".gguf")));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_model_dir_recursive_finds_nested_gguf() {
|
||||
// Tests that the recursive scan function still works for future use
|
||||
use super::scan_model_dir_recursive;
|
||||
|
||||
let dir = temp_path("scan-models-recursive-fn");
|
||||
let sub = dir.join("mradermacher").join("Suri-Qwen-3.5-4B-Uncensored-i1-GGUF");
|
||||
std::fs::create_dir_all(&sub).expect("create nested dirs");
|
||||
std::fs::write(dir.join("top-level.gguf"), b"demo").expect("write top-level model");
|
||||
std::fs::write(sub.join("nested-model.gguf"), b"demo").expect("write nested model");
|
||||
|
||||
let mut models = Vec::new();
|
||||
scan_model_dir_recursive(&dir, &mut models).expect("recursive scan");
|
||||
models.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
let names: Vec<_> = models.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["nested-model.gguf", "top-level.gguf"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_model_dir_skips_hidden_directories() {
|
||||
let dir = temp_path("scan-models-hidden");
|
||||
let hidden = dir.join(".git");
|
||||
std::fs::create_dir_all(&hidden).expect("create hidden dir");
|
||||
std::fs::write(dir.join("visible.gguf"), b"demo").expect("write visible model");
|
||||
std::fs::write(hidden.join("secret.gguf"), b"demo").expect("write hidden model");
|
||||
|
||||
let models = scan_model_dir(&dir).expect("scan model dir");
|
||||
let names: Vec<_> = models.into_iter().map(|m| m.name).collect();
|
||||
|
||||
assert_eq!(names, vec!["visible.gguf"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_model_file_returns_existing_model_when_source_matches_destination() {
|
||||
let dir = temp_path("import-model");
|
||||
|
||||
Reference in new issue
Block a user