From a4ee44104f622075e7652b870d5acaa0470a32ae Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Wed, 21 Jan 2026 21:59:18 +0800 Subject: [PATCH 1/9] support list author --- varManager_backend/src/api/mod.rs | 387 +++++++++++++++--- varManager_backend/src/app/mod.rs | 13 + varManager_backend/src/infra/db.rs | 212 +++++----- varManager_backend/src/main.rs | 1 + varmanager_flutter/lib/app/app.dart | 1 + varmanager_flutter/lib/app/providers.dart | 23 ++ .../lib/core/backend/backend_client.dart | 32 ++ .../lib/core/models/config.dart | 20 + .../lib/core/models/extra_models.dart | 34 ++ .../lib/features/home/home_page.dart | 123 +++++- .../lib/features/home/providers.dart | 17 +- .../home/widgets/creator_filter_field.dart | 133 ++++++ .../home/widgets/creator_list_dialog.dart | 234 +++++++++++ .../features/home/widgets/preview_panel.dart | 39 +- .../lib/features/scenes/scenes_page.dart | 42 +- .../lib/features/settings/settings_page.dart | 151 ++++++- varmanager_flutter/lib/l10n/app_en.arb | 23 +- .../lib/l10n/app_localizations.dart | 82 +++- .../lib/l10n/app_localizations_en.dart | 49 ++- .../lib/l10n/app_localizations_zh.dart | 45 +- varmanager_flutter/lib/l10n/app_zh.arb | 17 +- 21 files changed, 1451 insertions(+), 227 deletions(-) create mode 100644 varmanager_flutter/lib/features/home/widgets/creator_filter_field.dart create mode 100644 varmanager_flutter/lib/features/home/widgets/creator_list_dialog.dart diff --git a/varManager_backend/src/api/mod.rs b/varManager_backend/src/api/mod.rs index 6d47c21..8729725 100644 --- a/varManager_backend/src/api/mod.rs +++ b/varManager_backend/src/api/mod.rs @@ -5,11 +5,12 @@ use axum::{ response::{IntoResponse, Response}, Json, }; -use sqlx::{QueryBuilder, Row, SqlitePool}; +use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool}; +use serde::de::{self, Deserializer, SeqAccess, Visitor}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, path::{Component, Path as StdPath, PathBuf}, sync::atomic::Ordering, sync::Arc, @@ -82,6 +83,17 @@ pub(crate) struct CreatorsQuery { q: Option, offset: Option, limit: Option, + prefix: Option, +} + +#[derive(Deserialize)] +pub(crate) struct CreatorsStatsQuery { + #[serde(default, deserialize_with = "deserialize_names")] + names: Vec, + q: Option, + offset: Option, + limit: Option, + prefix: Option, } #[derive(Deserialize)] @@ -223,6 +235,9 @@ pub(crate) struct UpdateConfigRequest { proxy: Option, ui_theme: Option, ui_language: Option, + ui_per_page_vars: Option, + ui_per_page_scenes: Option, + ui_uninstall_selected_only: Option, } #[derive(Deserialize)] @@ -360,6 +375,19 @@ pub(crate) struct CreatorsResponse { creators: Vec, } +#[derive(Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) struct CreatorStatsItem { + name: String, + var_count: u64, + installed_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CreatorsStatsResponse { + items: Vec, +} + #[derive(Serialize)] pub(crate) struct PackSwitchListResponse { current: String, @@ -739,6 +767,21 @@ fn apply_config_update(current: &Config, req: UpdateConfigRequest) -> Result(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + struct NamesVisitor; + + impl<'de> Visitor<'de> for NamesVisitor { + type Value = Vec; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or a list of strings") + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + Ok(value + .split(',') + .map(|item| item.trim()) + .filter(|item| !item.is_empty()) + .map(|item| item.to_string()) + .collect()) + } + + fn visit_string(self, value: String) -> Result + where + E: de::Error, + { + self.visit_str(&value) + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut items = Vec::new(); + while let Some(value) = seq.next_element::()? { + items.push(value); + } + Ok(items) + } + } + + deserializer.deserialize_any(NamesVisitor) +} + +#[derive(Clone, Copy)] +enum CreatorPrefix { + Letter(char), + Other, +} + +fn parse_creator_prefix(raw: Option<&str>) -> Option { + let trimmed = raw?.trim(); + if trimmed.is_empty() { + return None; + } + let ch = trimmed.chars().next()?.to_ascii_uppercase(); + if ch == '#' { + return Some(CreatorPrefix::Other); + } + if ch.is_ascii_alphabetic() { + return Some(CreatorPrefix::Letter(ch)); + } + None +} + +fn parse_creator_list(raw: Option<&str>) -> Vec { + let mut seen = HashSet::new(); + let mut creators = Vec::new(); + let Some(value) = raw else { + return creators; + }; + for part in value.split(',') { + let trimmed = part.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("all") { + continue; + } + let key = trimmed.to_ascii_lowercase(); + if seen.insert(key) { + creators.push(trimmed.to_string()); + } + } + creators +} + +fn parse_creator_names(values: &[String]) -> Vec { + if values.is_empty() { + return Vec::new(); + } + let mut seen = HashSet::new(); + let mut names = Vec::new(); + for raw in values { + for part in raw.split(',') { + let trimmed = part.trim(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("all") { + continue; + } + let key = trimmed.to_ascii_lowercase(); + if seen.insert(key) { + names.push(trimmed.to_string()); + } + } + } + names +} + +async fn query_creators( + pool: &SqlitePool, + q: &str, + prefix: Option, + offset: u32, + limit: u32, +) -> Result, ApiError> { + let mut builder = QueryBuilder::::new( + "SELECT DISTINCT creatorName FROM vars WHERE creatorName IS NOT NULL AND TRIM(creatorName) <> ''", + ); + if !q.is_empty() { + builder + .push(" AND creatorName LIKE ") + .push_bind(format!("%{}%", q)) + .push(" COLLATE NOCASE"); + } + if let Some(prefix) = prefix { + match prefix { + CreatorPrefix::Letter(ch) => { + builder + .push(" AND UPPER(SUBSTR(TRIM(creatorName), 1, 1)) = ") + .push_bind(ch.to_string()); + } + CreatorPrefix::Other => { + builder + .push(" AND (UPPER(SUBSTR(TRIM(creatorName), 1, 1)) NOT BETWEEN 'A' AND 'Z')"); + } + } + } + if !q.is_empty() { + builder + .push(" ORDER BY CASE WHEN creatorName LIKE ") + .push_bind(format!("{}%", q)) + .push(" COLLATE NOCASE THEN 0 ELSE 1 END, creatorName COLLATE NOCASE"); + } else { + builder.push(" ORDER BY creatorName"); + } + builder + .push(" LIMIT ") + .push_bind(limit as i64) + .push(" OFFSET ") + .push_bind(offset as i64); + + let rows = builder + .build() + .fetch_all(pool) + .await + .map_err(internal_error)?; + let mut creators = Vec::new(); + for row in rows { + creators.push(row.try_get::(0).map_err(internal_error)?); + } + Ok(creators) +} + +async fn query_creator_stats( + pool: &SqlitePool, + names: &[String], +) -> Result, ApiError> { + if names.is_empty() { + return Ok(HashMap::new()); + } + let mut builder = QueryBuilder::::new( + "SELECT v.creatorName, COUNT(1), \ + COALESCE(SUM(CASE WHEN i.installed = 1 THEN 1 ELSE 0 END), 0) \ + FROM vars v \ + LEFT JOIN installStatus i ON v.varName = i.varName \ + WHERE v.creatorName IN (", + ); + let mut separated = builder.separated(", "); + for name in names { + separated.push_bind(name); + } + separated.push_unseparated(") GROUP BY v.creatorName"); + let rows = builder + .build() + .fetch_all(pool) + .await + .map_err(internal_error)?; + let mut map = HashMap::new(); + for row in rows { + let name: String = row.try_get(0).map_err(internal_error)?; + let var_count: i64 = row.try_get(1).map_err(internal_error)?; + let installed_count: i64 = row.try_get(2).map_err(internal_error)?; + let var_count = if var_count < 0 { 0 } else { var_count as u64 }; + let installed_count = if installed_count < 0 { + 0 + } else { + installed_count as u64 + }; + map.insert(name, (var_count, installed_count)); + } + Ok(map) +} + pub async fn list_vars( State(state): State, Query(query): Query, @@ -792,9 +1038,20 @@ pub async fn list_vars( let mut conditions = Vec::new(); let mut params: Vec = Vec::new(); - if let Some(creator) = query.creator.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { - conditions.push("v.creatorName = ?".to_string()); - params.push(BindValue::Text(creator.to_string())); + let creator_list = parse_creator_list(query.creator.as_deref()); + if !creator_list.is_empty() { + if creator_list.len() == 1 { + conditions.push("v.creatorName COLLATE NOCASE = ?".to_string()); + } else { + let placeholders = vec!["?"; creator_list.len()].join(", "); + conditions.push(format!( + "v.creatorName COLLATE NOCASE IN ({})", + placeholders + )); + } + for creator in &creator_list { + params.push(BindValue::Text(creator.to_string())); + } } if let Some(package) = query.package.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { conditions.push("v.packageName LIKE ?".to_string()); @@ -1285,14 +1542,25 @@ pub async fn list_scenes( } let mut conditions = Vec::new(); let mut params: Vec = Vec::new(); + let creator_list = parse_creator_list(query.creator.as_deref()); if let Some(category) = query.category.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { conditions.push("s.atomType = ?".to_string()); params.push(BindValue::Text(category.to_string())); } - if let Some(creator) = query.creator.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { - conditions.push("v.creatorName = ?".to_string()); - params.push(BindValue::Text(creator.to_string())); + if !creator_list.is_empty() { + if creator_list.len() == 1 { + conditions.push("v.creatorName COLLATE NOCASE = ?".to_string()); + } else { + let placeholders = vec!["?"; creator_list.len()].join(", "); + conditions.push(format!( + "v.creatorName COLLATE NOCASE IN ({})", + placeholders + )); + } + for creator in &creator_list { + params.push(BindValue::Text(creator.to_string())); + } } if let Some(search) = query.search.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { conditions.push("(s.scenePath LIKE ? OR v.varName LIKE ?)".to_string()); @@ -1404,7 +1672,8 @@ pub async fn list_scenes( let installed_filter = parse_bool_filter(query.installed.as_deref()); let hide_fav_filter = parse_hide_fav_filter(query.hide_fav.as_deref()); let category_filter = query.category.as_ref().map(|s| s.trim().to_string()); - let creator_filter = query.creator.as_ref().map(|s| s.trim().to_string()); + let creator_filter: HashSet = + creator_list.iter().map(|value| value.to_lowercase()).collect(); let search_filter = query .search .as_ref() @@ -1432,16 +1701,14 @@ pub async fn list_scenes( return false; } } - if let Some(creator) = creator_filter.as_ref() { - if !creator.is_empty() - && item - .creator_name - .as_ref() - .map(|c| c != creator) - .unwrap_or(true) - { - return false; - } + if !creator_filter.is_empty() + && item + .creator_name + .as_ref() + .map(|c| !creator_filter.contains(&c.to_lowercase())) + .unwrap_or(true) + { + return false; } if let Some(search) = search_filter.as_ref() { let name = item.var_name.to_lowercase(); @@ -1799,49 +2066,61 @@ pub async fn list_creators( let _cfg = read_config(&state).map_err(internal_error)?; let pool = &state.db_pool; - let q = query.q.unwrap_or_default().trim().to_string(); + let q_raw = query.q.unwrap_or_default(); + let q = q_raw.trim(); + let prefix = parse_creator_prefix(query.prefix.as_deref()); let limit = query .limit - .unwrap_or(if q.is_empty() { 0 } else { 10 }) - .clamp(0, 100); - let offset = query.offset.unwrap_or(0) as i64; + .unwrap_or(if q.is_empty() { 200 } else { 10 }) + .clamp(1, 200); + let offset = query.offset.unwrap_or(0); + let creators = query_creators(pool, q, prefix, offset, limit).await?; - let mut builder = QueryBuilder::new( - "SELECT DISTINCT creatorName FROM vars WHERE creatorName IS NOT NULL AND creatorName <> ''", - ); - if !q.is_empty() { - builder - .push(" AND creatorName LIKE ") - .push_bind(format!("%{}%", q)) - .push(" COLLATE NOCASE"); - } - if !q.is_empty() { - builder - .push(" ORDER BY CASE WHEN creatorName LIKE ") - .push_bind(format!("{}%", q)) - .push(" COLLATE NOCASE THEN 0 ELSE 1 END, creatorName COLLATE NOCASE"); - } else { - builder.push(" ORDER BY creatorName"); + Ok(Json(CreatorsResponse { creators })) +} + +pub async fn list_creator_stats( + State(state): State, + Query(query): Query, +) -> ApiResult> { + let _cfg = read_config(&state).map_err(internal_error)?; + let pool = &state.db_pool; + + let mut names = parse_creator_names(&query.names); + if names.is_empty() { + let q_raw = query.q.unwrap_or_default(); + let q = q_raw.trim(); + let prefix = parse_creator_prefix(query.prefix.as_deref()); + let limit = query + .limit + .unwrap_or(if q.is_empty() { 200 } else { 10 }) + .clamp(1, 200); + let offset = query.offset.unwrap_or(0); + names = query_creators(pool, q, prefix, offset, limit).await?; } - if limit > 0 || offset > 0 { - builder - .push(" LIMIT ") - .push_bind(limit as i64) - .push(" OFFSET ") - .push_bind(offset); + if names.is_empty() { + return Ok(Json(CreatorsStatsResponse { items: Vec::new() })); } - let rows = builder - .build() - .fetch_all(pool) - .await - .map_err(internal_error)?; - let mut creators = Vec::new(); - for row in rows { - creators.push(row.try_get::(0).map_err(internal_error)?); + let stats = query_creator_stats(pool, &names).await?; + let mut items = Vec::new(); + for name in names { + if let Some((var_count, installed_count)) = stats.get(&name) { + items.push(CreatorStatsItem { + name, + var_count: *var_count, + installed_count: *installed_count, + }); + } else { + items.push(CreatorStatsItem { + name, + var_count: 0, + installed_count: 0, + }); + } } - Ok(Json(CreatorsResponse { creators })) + Ok(Json(CreatorsStatsResponse { items })) } pub async fn list_hub_options( diff --git a/varManager_backend/src/app/mod.rs b/varManager_backend/src/app/mod.rs index 06e5e9f..65eb9ca 100644 --- a/varManager_backend/src/app/mod.rs +++ b/varManager_backend/src/app/mod.rs @@ -146,6 +146,12 @@ pub struct Config { pub(crate) ui_theme: Option, #[serde(default)] pub(crate) ui_language: Option, + #[serde(default = "default_ui_per_page")] + pub(crate) ui_per_page_vars: u32, + #[serde(default = "default_ui_per_page")] + pub(crate) ui_per_page_scenes: u32, + #[serde(default)] + pub(crate) ui_uninstall_selected_only: bool, } impl Default for Config { @@ -165,10 +171,17 @@ impl Default for Config { proxy: ProxyConfig::default(), ui_theme: None, ui_language: None, + ui_per_page_vars: default_ui_per_page(), + ui_per_page_scenes: default_ui_per_page(), + ui_uninstall_selected_only: false, } } } +fn default_ui_per_page() -> u32 { + 50 +} + #[derive(Clone)] pub struct AppState { pub(crate) config: Arc>, diff --git a/varManager_backend/src/infra/db.rs b/varManager_backend/src/infra/db.rs index e383126..a110d2f 100644 --- a/varManager_backend/src/infra/db.rs +++ b/varManager_backend/src/infra/db.rs @@ -99,112 +99,112 @@ pub async fn ensure_schema(pool: &SqlitePool) -> Result<(), String> { .map_err(|err| err.to_string())?; } - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS dependencies ( - ID INTEGER PRIMARY KEY AUTOINCREMENT, - varName TEXT, - dependency TEXT - ); - CREATE TABLE IF NOT EXISTS HideFav ( - varName TEXT NOT NULL, - scenePath TEXT NOT NULL, - hide INTEGER NOT NULL, - fav INTEGER NOT NULL, - PRIMARY KEY (varName, scenePath) - ); - CREATE TABLE IF NOT EXISTS installStatus ( - varName TEXT PRIMARY KEY, - installed INTEGER NOT NULL, - disabled INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS savedepens ( - ID INTEGER PRIMARY KEY AUTOINCREMENT, - varName TEXT, - dependency TEXT, - SavePath TEXT, - ModiDate TEXT - ); - CREATE TABLE IF NOT EXISTS scenes ( - ID INTEGER PRIMARY KEY AUTOINCREMENT, - varName TEXT, - atomType TEXT, - previewPic TEXT, - scenePath TEXT, - isPreset INTEGER NOT NULL, - isLoadable INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS vars ( - varName TEXT PRIMARY KEY, - creatorName TEXT, - packageName TEXT, - metaDate TEXT, - varDate TEXT, - version TEXT, - description TEXT, - morph INTEGER, - cloth INTEGER, - hair INTEGER, - skin INTEGER, - pose INTEGER, - scene INTEGER, - script INTEGER, - plugin INTEGER, - asset INTEGER, - texture INTEGER, - look INTEGER, - subScene INTEGER, - appearance INTEGER, - dependencyCnt INTEGER, - fsize REAL - ); - CREATE TABLE IF NOT EXISTS image_cache_entries ( - cache_key TEXT PRIMARY KEY, - file_name TEXT NOT NULL, - source_type TEXT NOT NULL, - source_url TEXT, - source_root TEXT, - source_path TEXT, - size_bytes INTEGER NOT NULL, - content_type TEXT NOT NULL, - created_at INTEGER NOT NULL, - last_accessed INTEGER NOT NULL, - access_count INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS downloads ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - url TEXT NOT NULL, - name TEXT, - status TEXT NOT NULL, - downloaded_bytes INTEGER NOT NULL DEFAULT 0, - total_bytes INTEGER, - speed_bytes INTEGER NOT NULL DEFAULT 0, - error TEXT, - save_path TEXT, - temp_path TEXT, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - ); - - CREATE INDEX IF NOT EXISTS idx_vars_creatorName ON vars(creatorName); - CREATE INDEX IF NOT EXISTS idx_vars_packageName ON vars(packageName); - CREATE INDEX IF NOT EXISTS idx_vars_metaDate ON vars(metaDate); - CREATE INDEX IF NOT EXISTS idx_vars_varDate ON vars(varDate); - CREATE INDEX IF NOT EXISTS idx_vars_fsize ON vars(fsize); - CREATE INDEX IF NOT EXISTS idx_vars_dependencyCnt ON vars(dependencyCnt); - CREATE INDEX IF NOT EXISTS idx_scenes_varName ON scenes(varName); - CREATE INDEX IF NOT EXISTS idx_scenes_atomType ON scenes(atomType); - CREATE INDEX IF NOT EXISTS idx_dependencies_varName ON dependencies(varName); - CREATE INDEX IF NOT EXISTS idx_dependencies_dependency ON dependencies(dependency); - CREATE INDEX IF NOT EXISTS idx_savedepens_dependency ON savedepens(dependency); - CREATE INDEX IF NOT EXISTS idx_image_cache_last_accessed ON image_cache_entries(last_accessed); - CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status); - CREATE INDEX IF NOT EXISTS idx_downloads_created_at ON downloads(created_at); - "# - ) - .execute(pool) - .await - .map_err(|err| err.to_string())?; + let schema_statements = [ + r#"CREATE TABLE IF NOT EXISTS dependencies ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + varName TEXT, + dependency TEXT + )"#, + r#"CREATE TABLE IF NOT EXISTS HideFav ( + varName TEXT NOT NULL, + scenePath TEXT NOT NULL, + hide INTEGER NOT NULL, + fav INTEGER NOT NULL, + PRIMARY KEY (varName, scenePath) + )"#, + r#"CREATE TABLE IF NOT EXISTS installStatus ( + varName TEXT PRIMARY KEY, + installed INTEGER NOT NULL, + disabled INTEGER NOT NULL + )"#, + r#"CREATE TABLE IF NOT EXISTS savedepens ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + varName TEXT, + dependency TEXT, + SavePath TEXT, + ModiDate TEXT + )"#, + r#"CREATE TABLE IF NOT EXISTS scenes ( + ID INTEGER PRIMARY KEY AUTOINCREMENT, + varName TEXT, + atomType TEXT, + previewPic TEXT, + scenePath TEXT, + isPreset INTEGER NOT NULL, + isLoadable INTEGER NOT NULL + )"#, + r#"CREATE TABLE IF NOT EXISTS vars ( + varName TEXT PRIMARY KEY, + creatorName TEXT, + packageName TEXT, + metaDate TEXT, + varDate TEXT, + version TEXT, + description TEXT, + morph INTEGER, + cloth INTEGER, + hair INTEGER, + skin INTEGER, + pose INTEGER, + scene INTEGER, + script INTEGER, + plugin INTEGER, + asset INTEGER, + texture INTEGER, + look INTEGER, + subScene INTEGER, + appearance INTEGER, + dependencyCnt INTEGER, + fsize REAL + )"#, + r#"CREATE TABLE IF NOT EXISTS image_cache_entries ( + cache_key TEXT PRIMARY KEY, + file_name TEXT NOT NULL, + source_type TEXT NOT NULL, + source_url TEXT, + source_root TEXT, + source_path TEXT, + size_bytes INTEGER NOT NULL, + content_type TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_accessed INTEGER NOT NULL, + access_count INTEGER NOT NULL + )"#, + r#"CREATE TABLE IF NOT EXISTS downloads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + url TEXT NOT NULL, + name TEXT, + status TEXT NOT NULL, + downloaded_bytes INTEGER NOT NULL DEFAULT 0, + total_bytes INTEGER, + speed_bytes INTEGER NOT NULL DEFAULT 0, + error TEXT, + save_path TEXT, + temp_path TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )"#, + "CREATE INDEX IF NOT EXISTS idx_vars_creatorName ON vars(creatorName)", + "CREATE INDEX IF NOT EXISTS idx_vars_packageName ON vars(packageName)", + "CREATE INDEX IF NOT EXISTS idx_vars_metaDate ON vars(metaDate)", + "CREATE INDEX IF NOT EXISTS idx_vars_varDate ON vars(varDate)", + "CREATE INDEX IF NOT EXISTS idx_vars_fsize ON vars(fsize)", + "CREATE INDEX IF NOT EXISTS idx_vars_dependencyCnt ON vars(dependencyCnt)", + "CREATE INDEX IF NOT EXISTS idx_scenes_varName ON scenes(varName)", + "CREATE INDEX IF NOT EXISTS idx_scenes_atomType ON scenes(atomType)", + "CREATE INDEX IF NOT EXISTS idx_dependencies_varName ON dependencies(varName)", + "CREATE INDEX IF NOT EXISTS idx_dependencies_dependency ON dependencies(dependency)", + "CREATE INDEX IF NOT EXISTS idx_savedepens_dependency ON savedepens(dependency)", + "CREATE INDEX IF NOT EXISTS idx_image_cache_last_accessed ON image_cache_entries(last_accessed)", + "CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status)", + "CREATE INDEX IF NOT EXISTS idx_downloads_created_at ON downloads(created_at)", + ]; + for statement in schema_statements { + sqlx::query(statement) + .execute(pool) + .await + .map_err(|err| err.to_string())?; + } let _ = sqlx::query("ALTER TABLE vars ADD COLUMN fsize REAL") .execute(pool) diff --git a/varManager_backend/src/main.rs b/varManager_backend/src/main.rs index 1b6da2f..979de41 100644 --- a/varManager_backend/src/main.rs +++ b/varManager_backend/src/main.rs @@ -90,6 +90,7 @@ async fn main() -> Result<(), Box> { .route("/vars/previews", post(api::list_var_previews)) .route("/scenes", get(api::list_scenes)) .route("/creators", get(api::list_creators)) + .route("/creators/stats", get(api::list_creator_stats)) .route("/stats", get(api::get_stats)) .route("/preview", get(api::get_preview)) .route("/cache/stats", get(api::get_cache_stats)) diff --git a/varmanager_flutter/lib/app/app.dart b/varmanager_flutter/lib/app/app.dart index 64fde42..a6eb18a 100644 --- a/varmanager_flutter/lib/app/app.dart +++ b/varmanager_flutter/lib/app/app.dart @@ -83,6 +83,7 @@ class _AppShellState extends ConsumerState { await ref.read(themeProvider.notifier).loadFromConfig(); // Load locale from config after backend is ready await ref.read(localeProvider.notifier).loadFromConfig(); + await ref.read(appConfigProvider.notifier).loadFromBackend(); await ref.read(localeProvider.notifier).persistInitialIfNeeded(); if (!mounted) return; setState(() { diff --git a/varmanager_flutter/lib/app/providers.dart b/varmanager_flutter/lib/app/providers.dart index 892fa9a..0b14a9f 100644 --- a/varmanager_flutter/lib/app/providers.dart +++ b/varmanager_flutter/lib/app/providers.dart @@ -7,6 +7,7 @@ import '../core/backend/backend_client.dart'; import '../core/backend/backend_process_manager.dart'; import '../core/backend/job_log_controller.dart'; import '../core/backend/job_runner.dart'; +import '../core/models/config.dart'; import '../l10n/locale_config.dart'; import 'theme.dart'; @@ -143,6 +144,28 @@ final themeProvider = NotifierProvider( ThemeNotifier.new, ); +class AppConfigNotifier extends Notifier { + @override + AppConfig? build() => null; + + Future loadFromBackend() async { + try { + final client = ref.read(backendClientProvider); + state = await client.getConfig(); + } catch (_) { + // Keep existing config on load errors. + } + } + + void setConfig(AppConfig config) { + state = config; + } +} + +final appConfigProvider = NotifierProvider( + AppConfigNotifier.new, +); + final backendClientProvider = Provider((ref) { final baseUrl = ref.watch(baseUrlProvider); return BackendClient(baseUrl: baseUrl); diff --git a/varmanager_flutter/lib/core/backend/backend_client.dart b/varmanager_flutter/lib/core/backend/backend_client.dart index d2d47f0..ea9b26f 100644 --- a/varmanager_flutter/lib/core/backend/backend_client.dart +++ b/varmanager_flutter/lib/core/backend/backend_client.dart @@ -85,6 +85,7 @@ class BackendClient { String? query, int? offset, int? limit, + String? prefix, }) async { final params = {}; if (query != null) { @@ -96,12 +97,43 @@ class BackendClient { if (limit != null) { params['limit'] = limit.toString(); } + if (prefix != null) { + params['prefix'] = prefix; + } final json = await _getJson('/creators', params.isEmpty ? null : params); final creators = json['creators'] as List? ?? []; return creators.map((item) => item.toString()).toList(); } + Future listCreatorStats({ + List? names, + String? query, + int? offset, + int? limit, + String? prefix, + }) async { + final params = {}; + if (names != null && names.isNotEmpty) { + params['names'] = names.join(','); + } + if (query != null) { + params['q'] = query; + } + if (offset != null) { + params['offset'] = offset.toString(); + } + if (limit != null) { + params['limit'] = limit.toString(); + } + if (prefix != null) { + params['prefix'] = prefix; + } + final json = + await _getJson('/creators/stats', params.isEmpty ? null : params); + return CreatorStatsResponse.fromJson(json); + } + Future> listHubOptions({ required String kind, String? query, diff --git a/varmanager_flutter/lib/core/models/config.dart b/varmanager_flutter/lib/core/models/config.dart index 2abe895..c0038e6 100644 --- a/varmanager_flutter/lib/core/models/config.dart +++ b/varmanager_flutter/lib/core/models/config.dart @@ -59,6 +59,9 @@ class AppConfig { required this.proxy, this.uiTheme, this.uiLanguage, + required this.uiPerPageVars, + required this.uiPerPageScenes, + required this.uiUninstallSelectedOnly, }); final String listenHost; @@ -73,6 +76,9 @@ class AppConfig { final ProxyConfig proxy; final String? uiTheme; final String? uiLanguage; + final int uiPerPageVars; + final int uiPerPageScenes; + final bool uiUninstallSelectedOnly; String get baseUrl => 'http://$listenHost:$listenPort'; @@ -93,6 +99,10 @@ class AppConfig { : ProxyConfig.empty, uiTheme: json['ui_theme'] as String?, uiLanguage: json['ui_language'] as String?, + uiPerPageVars: (json['ui_per_page_vars'] as num?)?.toInt() ?? 50, + uiPerPageScenes: (json['ui_per_page_scenes'] as num?)?.toInt() ?? 50, + uiUninstallSelectedOnly: + (json['ui_uninstall_selected_only'] as bool?) ?? false, ); } @@ -110,6 +120,9 @@ class AppConfig { 'proxy': proxy.toJson(), 'ui_theme': uiTheme, 'ui_language': uiLanguage, + 'ui_per_page_vars': uiPerPageVars, + 'ui_per_page_scenes': uiPerPageScenes, + 'ui_uninstall_selected_only': uiUninstallSelectedOnly, }; } @@ -126,6 +139,9 @@ class AppConfig { ProxyConfig? proxy, String? uiTheme, String? uiLanguage, + int? uiPerPageVars, + int? uiPerPageScenes, + bool? uiUninstallSelectedOnly, }) { return AppConfig( listenHost: listenHost ?? this.listenHost, @@ -140,6 +156,10 @@ class AppConfig { proxy: proxy ?? this.proxy, uiTheme: uiTheme ?? this.uiTheme, uiLanguage: uiLanguage ?? this.uiLanguage, + uiPerPageVars: uiPerPageVars ?? this.uiPerPageVars, + uiPerPageScenes: uiPerPageScenes ?? this.uiPerPageScenes, + uiUninstallSelectedOnly: + uiUninstallSelectedOnly ?? this.uiUninstallSelectedOnly, ); } } diff --git a/varmanager_flutter/lib/core/models/extra_models.dart b/varmanager_flutter/lib/core/models/extra_models.dart index 376b430..6f6e5a8 100644 --- a/varmanager_flutter/lib/core/models/extra_models.dart +++ b/varmanager_flutter/lib/core/models/extra_models.dart @@ -289,6 +289,40 @@ class DependentsResponse { } } +class CreatorStatsItem { + CreatorStatsItem({ + required this.name, + required this.varCount, + required this.installedCount, + }); + + final String name; + final int varCount; + final int installedCount; + + factory CreatorStatsItem.fromJson(Map json) { + return CreatorStatsItem( + name: json['name'] as String? ?? '', + varCount: (json['var_count'] as num?)?.toInt() ?? 0, + installedCount: (json['installed_count'] as num?)?.toInt() ?? 0, + ); + } +} + +class CreatorStatsResponse { + CreatorStatsResponse({required this.items}); + + final List items; + + factory CreatorStatsResponse.fromJson(Map json) { + return CreatorStatsResponse( + items: (json['items'] as List? ?? []) + .map((item) => CreatorStatsItem.fromJson(item as Map)) + .toList(), + ); + } +} + class PackSwitchListResponse { PackSwitchListResponse({required this.current, required this.switches}); diff --git a/varmanager_flutter/lib/features/home/home_page.dart b/varmanager_flutter/lib/features/home/home_page.dart index 47bfd15..173d63a 100644 --- a/varmanager_flutter/lib/features/home/home_page.dart +++ b/varmanager_flutter/lib/features/home/home_page.dart @@ -4,11 +4,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/providers.dart'; import '../../core/backend/job_log_controller.dart'; import '../../core/backend/query_params.dart'; +import '../../core/models/config.dart'; import '../../core/models/extra_models.dart'; import '../../core/models/job_models.dart'; import '../../core/models/var_models.dart'; import '../../core/utils/debounce.dart'; -import '../../widgets/lazy_dropdown_field.dart'; import '../../l10n/app_localizations.dart'; import '../../l10n/l10n.dart'; import '../bootstrap/bootstrap_keys.dart'; @@ -17,6 +17,8 @@ import '../prepare_saves/prepare_saves_page.dart'; import '../uninstall_vars/uninstall_vars_page.dart'; import '../var_detail/var_detail_page.dart'; import 'providers.dart'; +import 'widgets/creator_filter_field.dart'; +import 'widgets/creator_list_dialog.dart'; import 'widgets/preview_panel.dart'; class HomePage extends ConsumerStatefulWidget { @@ -40,6 +42,7 @@ class _HomePageState extends ConsumerState { bool _showAdvancedFilters = false; _ActionGroup _actionGroup = _ActionGroup.core; String _missingDepsScope = 'installed'; + bool _uninstallSelectedOnly = false; static const Duration _tooltipDelay = Duration(seconds: 1); @@ -50,7 +53,27 @@ class _HomePageState extends ConsumerState { @override void initState() { super.initState(); + final config = ref.read(appConfigProvider); + if (config != null) { + _uninstallSelectedOnly = config.uiUninstallSelectedOnly; + } Future.microtask(_loadPackSwitches); + ref.listen(appConfigProvider, (previous, next) { + if (next == null) return; + final current = ref.read(varsQueryProvider); + final previousDefault = previous?.uiPerPageVars ?? 50; + if (current.perPage == previousDefault && + current.perPage != next.uiPerPageVars) { + _updateQuery( + (state) => state.copyWith(page: 1, perPage: next.uiPerPageVars), + ); + } + if (_uninstallSelectedOnly != next.uiUninstallSelectedOnly && mounted) { + setState(() { + _uninstallSelectedOnly = next.uiUninstallSelectedOnly; + }); + } + }); } @override @@ -144,6 +167,7 @@ class _HomePageState extends ConsumerState { final selected = ref.watch(selectedVarsProvider); final focusedVar = ref.watch(focusedVarProvider); final query = ref.watch(varsQueryProvider); + final creatorSelections = _splitCreators(query.creator); _syncController(_packageController, query.package); _syncController(_versionController, query.version); _syncController(_minSizeController, _formatNumber(query.minSize)); @@ -183,23 +207,39 @@ class _HomePageState extends ConsumerState { ), ), SizedBox( - width: 220, - child: LazyDropdownField( + width: 240, + child: CreatorFilterField( label: l10n.creatorLabel, - value: query.creator.isEmpty ? 'ALL' : query.creator, - allValue: 'ALL', - allLabel: l10n.allCreators, - optionsLoader: (queryText, offset, limit) async { - final client = ref.read(backendClientProvider); - return client.listCreators( - query: queryText, - offset: offset, - limit: limit, + hintText: l10n.creatorFilterHint, + selections: creatorSelections, + listTooltip: l10n.creatorListTooltip, + onListPressed: () async { + final result = await showDialog( + context: context, + builder: (_) => const CreatorListDialog(), + ); + if (!context.mounted || result == null) return; + if (result.isEmpty) { + _updateQuery( + (state) => state.copyWith(page: 1, creator: ''), + ); + return; + } + final current = _splitCreators( + ref.read(varsQueryProvider).creator, + ); + final next = _mergeCreators(current, [result]); + _updateQuery( + (state) => state.copyWith( + page: 1, + creator: next.join(','), + ), ); }, - onChanged: (value) { + onChanged: (values) { + final next = values.join(','); _updateQuery( - (state) => state.copyWith(page: 1, creator: value), + (state) => state.copyWith(page: 1, creator: next), ); }, ), @@ -922,6 +962,7 @@ class _HomePageState extends ConsumerState { String? focusedVar) { final l10n = context.l10n; final isBusy = ref.watch(jobBusyProvider); + final includeImplicated = !_uninstallSelectedOnly; final totalPages = data.total == 0 ? 1 : (data.total + query.perPage - 1) ~/ query.perPage; if (data.total > 0 && data.page > totalPages) { @@ -1102,10 +1143,18 @@ class _HomePageState extends ConsumerState { onPressed: isBusy ? null : () async { + if (_uninstallSelectedOnly) { + await _runJob('uninstall_vars', args: { + 'var_names': selected.toList(), + 'include_implicated': includeImplicated, + }); + ref.invalidate(varsListProvider); + return; + } final preview = await _runJob('preview_uninstall', args: { 'var_names': selected.toList(), - 'include_implicated': true, + 'include_implicated': includeImplicated, }); if (!context.mounted) return; final result = @@ -1121,7 +1170,7 @@ class _HomePageState extends ConsumerState { if (confirmed == true) { await _runJob('uninstall_vars', args: { 'var_names': selected.toList(), - 'include_implicated': true, + 'include_implicated': includeImplicated, }); ref.invalidate(varsListProvider); } @@ -1137,7 +1186,7 @@ class _HomePageState extends ConsumerState { : () async { await _runJob('delete_vars', args: { 'var_names': selected.toList(), - 'include_implicated': true, + 'include_implicated': includeImplicated, }); ref.invalidate(varsListProvider); }, @@ -1593,6 +1642,46 @@ class _HomePageState extends ConsumerState { } } + List _splitCreators(String raw) { + if (raw.trim().isEmpty) return const []; + final seen = {}; + final creators = []; + for (final part in raw.split(',')) { + final trimmed = part.trim(); + if (trimmed.isEmpty || trimmed.toUpperCase() == 'ALL') { + continue; + } + final key = trimmed.toLowerCase(); + if (seen.add(key)) { + creators.add(trimmed); + } + } + return creators; + } + + List _mergeCreators(List current, List additions) { + final seen = {}; + final merged = []; + void addCreator(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty || trimmed.toUpperCase() == 'ALL') { + return; + } + final key = trimmed.toLowerCase(); + if (seen.add(key)) { + merged.add(trimmed); + } + } + + for (final creator in current) { + addCreator(creator); + } + for (final creator in additions) { + addCreator(creator); + } + return merged; + } + String _formatNumber(double? value) { if (value == null) return ''; return value.toString(); diff --git a/varmanager_flutter/lib/features/home/providers.dart b/varmanager_flutter/lib/features/home/providers.dart index 491ff23..dd9f5c4 100644 --- a/varmanager_flutter/lib/features/home/providers.dart +++ b/varmanager_flutter/lib/features/home/providers.dart @@ -4,12 +4,24 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/providers.dart'; import '../../core/backend/query_params.dart'; +import '../../core/models/config.dart'; import '../../core/models/var_models.dart'; import 'models.dart'; class VarsQueryNotifier extends Notifier { @override - VarsQueryParams build() => VarsQueryParams(); + VarsQueryParams build() { + ref.listen(appConfigProvider, (previous, next) { + if (next == null) return; + final previousDefault = previous?.uiPerPageVars ?? 50; + if (state.perPage == previousDefault && + state.perPage != next.uiPerPageVars) { + state = state.copyWith(page: 1, perPage: next.uiPerPageVars); + } + }); + final perPage = ref.read(appConfigProvider)?.uiPerPageVars ?? 50; + return VarsQueryParams(perPage: perPage); + } void update(VarsQueryParams Function(VarsQueryParams) updater) { state = updater(state); @@ -20,7 +32,8 @@ class VarsQueryNotifier extends Notifier { } void reset() { - state = VarsQueryParams(); + final perPage = ref.read(appConfigProvider)?.uiPerPageVars ?? 50; + state = VarsQueryParams(perPage: perPage); } } diff --git a/varmanager_flutter/lib/features/home/widgets/creator_filter_field.dart b/varmanager_flutter/lib/features/home/widgets/creator_filter_field.dart new file mode 100644 index 0000000..48412dc --- /dev/null +++ b/varmanager_flutter/lib/features/home/widgets/creator_filter_field.dart @@ -0,0 +1,133 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class CreatorFilterField extends StatefulWidget { + const CreatorFilterField({ + super.key, + required this.label, + required this.hintText, + required this.selections, + required this.onChanged, + this.onListPressed, + this.listTooltip, + }); + + final String label; + final String hintText; + final List selections; + final ValueChanged> onChanged; + final VoidCallback? onListPressed; + final String? listTooltip; + + @override + State createState() => _CreatorFilterFieldState(); +} + +class _CreatorFilterFieldState extends State { + final TextEditingController _controller = TextEditingController(); + bool _suppressChange = false; + + @override + void didUpdateWidget(covariant CreatorFilterField oldWidget) { + super.didUpdateWidget(oldWidget); + if (!listEquals(oldWidget.selections, widget.selections) && + widget.selections.isEmpty) { + _controller.clear(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _handleInputChanged(String value) { + if (_suppressChange) return; + if (!value.contains(',')) return; + final parts = value.split(','); + if (parts.length <= 1) return; + for (var i = 0; i < parts.length - 1; i += 1) { + _commitEntry(parts[i]); + } + final tail = parts.last.trimLeft(); + _suppressChange = true; + _controller.text = tail; + _controller.selection = TextSelection.fromPosition( + TextPosition(offset: tail.length), + ); + _suppressChange = false; + } + + void _commitEntry(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return; + final next = List.from(widget.selections); + if (_containsIgnoreCase(next, trimmed)) return; + next.add(trimmed); + widget.onChanged(next); + } + + void _commitAndClear() { + _commitEntry(_controller.text); + _controller.clear(); + } + + bool _containsIgnoreCase(List values, String target) { + final needle = target.toLowerCase(); + for (final value in values) { + if (value.toLowerCase() == needle) { + return true; + } + } + return false; + } + + void _removeCreator(String name) { + final next = widget.selections + .where((value) => value.toLowerCase() != name.toLowerCase()) + .toList(); + widget.onChanged(next); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextField( + controller: _controller, + decoration: InputDecoration( + labelText: widget.label, + hintText: widget.hintText, + border: const OutlineInputBorder(), + suffixIcon: widget.onListPressed == null + ? null + : IconButton( + onPressed: widget.onListPressed, + icon: const Icon(Icons.arrow_drop_down), + tooltip: widget.listTooltip, + ), + ), + onChanged: _handleInputChanged, + onSubmitted: (_) => _commitAndClear(), + ), + if (widget.selections.isNotEmpty) ...[ + const SizedBox(height: 6), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final creator in widget.selections) + Chip( + label: Text(creator), + onDeleted: () => _removeCreator(creator), + visualDensity: VisualDensity.compact, + ), + ], + ), + ], + ], + ); + } +} diff --git a/varmanager_flutter/lib/features/home/widgets/creator_list_dialog.dart b/varmanager_flutter/lib/features/home/widgets/creator_list_dialog.dart new file mode 100644 index 0000000..ec823b6 --- /dev/null +++ b/varmanager_flutter/lib/features/home/widgets/creator_list_dialog.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/providers.dart'; +import '../../../core/models/extra_models.dart'; +import '../../../core/utils/debounce.dart'; +import '../../../l10n/l10n.dart'; + +class CreatorListDialog extends ConsumerStatefulWidget { + const CreatorListDialog({super.key}); + + @override + ConsumerState createState() => _CreatorListDialogState(); +} + +class _CreatorListDialogState extends ConsumerState { + static const int _pageSize = 200; + static const double _scrollThreshold = 80; + + final TextEditingController _searchController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + final Debouncer _debouncer = Debouncer(const Duration(milliseconds: 250)); + + int _offset = 0; + bool _loading = false; + bool _hasMore = true; + String _query = ''; + String? _prefix; + String? _error; + int _requestId = 0; + List _creators = []; + final Map _stats = {}; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_handleScroll); + Future.microtask(_loadFirstPage); + } + + @override + void dispose() { + _debouncer.dispose(); + _searchController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _handleScroll() { + if (!_scrollController.hasClients || !_hasMore || _loading) return; + if (_scrollController.position.extentAfter <= _scrollThreshold) { + _loadMore(); + } + } + + void _onSearchChanged(String value) { + _debouncer.run(() { + _query = value.trim(); + _loadFirstPage(); + }); + } + + void _togglePrefix(String value) { + setState(() { + _prefix = _prefix == value ? null : value; + }); + _loadFirstPage(); + } + + Future _loadFirstPage() async { + final requestId = ++_requestId; + setState(() { + _loading = true; + _error = null; + _offset = 0; + _hasMore = true; + _creators = []; + _stats.clear(); + }); + await _loadPage(requestId: requestId, reset: true); + } + + Future _loadMore() async { + if (_loading || !_hasMore) return; + final requestId = _requestId; + setState(() { + _loading = true; + }); + await _loadPage(requestId: requestId, reset: false); + } + + Future _loadPage({required int requestId, required bool reset}) async { + final client = ref.read(backendClientProvider); + try { + final creators = await client.listCreators( + query: _query.isEmpty ? null : _query, + offset: _offset, + limit: _pageSize, + prefix: _prefix, + ); + if (!mounted || requestId != _requestId) return; + setState(() { + if (reset) { + _creators = creators; + } else { + _creators = [..._creators, ...creators]; + } + _offset = _creators.length; + _hasMore = creators.length >= _pageSize; + _loading = false; + }); + if (creators.isNotEmpty) { + final stats = await client.listCreatorStats(names: creators); + if (!mounted || requestId != _requestId) return; + setState(() { + for (final item in stats.items) { + _stats[item.name] = item; + } + }); + } + } catch (err) { + if (!mounted || requestId != _requestId) return; + setState(() { + _error = err.toString(); + _loading = false; + _hasMore = false; + }); + } + } + + Widget _buildList() { + final l10n = context.l10n; + if (_error != null) { + return Center(child: Text(l10n.loadFailed(_error!))); + } + if (_creators.isEmpty) { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + return Center( + child: Text(_query.isEmpty ? l10n.creatorListEmpty : l10n.noMatches), + ); + } + final showLoadingRow = _loading && _creators.isNotEmpty; + return ListView.builder( + controller: _scrollController, + itemCount: _creators.length + (showLoadingRow ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _creators.length) { + return const Padding( + padding: EdgeInsets.all(12), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ); + } + final name = _creators[index]; + final stats = _stats[name]; + return ListTile( + dense: true, + title: Text(name), + subtitle: stats == null + ? null + : Text(l10n.creatorStatsLabel( + stats.varCount, + stats.installedCount, + )), + onTap: () => Navigator.of(context).pop(name), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final letters = List.generate( + 26, + (index) => String.fromCharCode(65 + index), + )..add('#'); + + return AlertDialog( + title: Text(l10n.creatorListTitle), + content: SizedBox( + width: 560, + height: 480, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + labelText: l10n.creatorListSearchLabel, + border: const OutlineInputBorder(), + ), + onChanged: _onSearchChanged, + ), + ), + const SizedBox(width: 8), + OutlinedButton( + onPressed: () => Navigator.of(context).pop(''), + child: Text(l10n.creatorListClearLabel), + ), + ], + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + for (final letter in letters) + ChoiceChip( + label: Text(letter), + selected: _prefix == letter, + onSelected: (_) => _togglePrefix(letter), + visualDensity: VisualDensity.compact, + ), + ], + ), + const SizedBox(height: 8), + Expanded(child: _buildList()), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.commonClose), + ), + ], + ); + } +} diff --git a/varmanager_flutter/lib/features/home/widgets/preview_panel.dart b/varmanager_flutter/lib/features/home/widgets/preview_panel.dart index 0d7ba10..57d270b 100644 --- a/varmanager_flutter/lib/features/home/widgets/preview_panel.dart +++ b/varmanager_flutter/lib/features/home/widgets/preview_panel.dart @@ -178,26 +178,35 @@ class _PreviewPanelState extends ConsumerState { Future _togglePreviewInstall( BuildContext context, PreviewItem item) async { + final includeImplicated = + !(ref.read(appConfigProvider)?.uiUninstallSelectedOnly ?? false); if (item.installed) { - final preview = await _runJob('preview_uninstall', args: { - 'var_names': [item.varName], - 'include_implicated': true, - }); - if (!context.mounted) return; - final payload = preview.result as Map?; - if (payload == null) return; - final confirmed = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => UninstallVarsPage(payload: payload), - ), - ); - if (confirmed == true) { + if (!includeImplicated) { await _runJob('uninstall_vars', args: { 'var_names': [item.varName], - 'include_implicated': true, + 'include_implicated': includeImplicated, }); } else { - return; + final preview = await _runJob('preview_uninstall', args: { + 'var_names': [item.varName], + 'include_implicated': includeImplicated, + }); + if (!context.mounted) return; + final payload = preview.result as Map?; + if (payload == null) return; + final confirmed = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => UninstallVarsPage(payload: payload), + ), + ); + if (confirmed == true) { + await _runJob('uninstall_vars', args: { + 'var_names': [item.varName], + 'include_implicated': includeImplicated, + }); + } else { + return; + } } } else { final l10n = context.l10n; diff --git a/varmanager_flutter/lib/features/scenes/scenes_page.dart b/varmanager_flutter/lib/features/scenes/scenes_page.dart index f9c0303..335df7e 100644 --- a/varmanager_flutter/lib/features/scenes/scenes_page.dart +++ b/varmanager_flutter/lib/features/scenes/scenes_page.dart @@ -7,6 +7,7 @@ import '../../app/providers.dart'; import '../../core/backend/backend_client.dart'; import '../../core/backend/job_log_controller.dart'; import '../../core/backend/query_params.dart'; +import '../../core/models/config.dart'; import '../../core/models/scene_models.dart'; import '../../core/utils/debounce.dart'; import '../../widgets/image_preview_dialog.dart'; @@ -19,8 +20,21 @@ import '../var_detail/var_detail_page.dart'; class ScenesQueryNotifier extends Notifier { @override - ScenesQueryParams build() => - ScenesQueryParams(location: 'installed,not_installed,save'); + ScenesQueryParams build() { + ref.listen(appConfigProvider, (previous, next) { + if (next == null) return; + final previousDefault = previous?.uiPerPageScenes ?? 50; + if (state.perPage == previousDefault && + state.perPage != next.uiPerPageScenes) { + state = state.copyWith(page: 1, perPage: next.uiPerPageScenes); + } + }); + final perPage = ref.read(appConfigProvider)?.uiPerPageScenes ?? 50; + return ScenesQueryParams( + location: 'installed,not_installed,save', + perPage: perPage, + ); + } void update(ScenesQueryParams Function(ScenesQueryParams) updater) { state = updater(state); @@ -58,6 +72,22 @@ class _ScenesPageState extends ConsumerState { }; final Set _hideFavFilter = {-1, 0, 1}; + @override + void initState() { + super.initState(); + ref.listen(appConfigProvider, (previous, next) { + if (next == null) return; + final current = ref.read(scenesQueryProvider); + final previousDefault = previous?.uiPerPageScenes ?? 50; + if (current.perPage == previousDefault && + current.perPage != next.uiPerPageScenes) { + _updateQuery( + (state) => state.copyWith(page: 1, perPage: next.uiPerPageScenes), + ); + } + }); + } + @override void dispose() { _searchDebounce.dispose(); @@ -116,6 +146,7 @@ class _ScenesPageState extends ConsumerState { } void _resetFilters() { + final perPage = ref.read(appConfigProvider)?.uiPerPageScenes ?? 50; setState(() { _locationFilter ..clear() @@ -125,7 +156,10 @@ class _ScenesPageState extends ConsumerState { ..addAll([-1, 0, 1]); }); _updateQuery( - (state) => ScenesQueryParams(location: _locationQueryValue()), + (state) => ScenesQueryParams( + location: _locationQueryValue(), + perPage: perPage, + ), ); } @@ -230,7 +264,7 @@ class _ScenesPageState extends ConsumerState { ), DropdownButton( value: query.perPage, - items: const [24, 48, 96, 200] + items: const [50, 100, 200] .map((value) => DropdownMenuItem( value: value, child: Text(l10n.perPageLabel(value)), diff --git a/varmanager_flutter/lib/features/settings/settings_page.dart b/varmanager_flutter/lib/features/settings/settings_page.dart index 979f9f4..cc01961 100644 --- a/varmanager_flutter/lib/features/settings/settings_page.dart +++ b/varmanager_flutter/lib/features/settings/settings_page.dart @@ -8,6 +8,7 @@ import '../../app/providers.dart'; import '../../app/theme.dart'; import '../../core/app_version.dart'; import '../../core/models/config.dart'; +import '../../core/utils/debounce.dart'; import '../../l10n/l10n.dart'; import '../../l10n/locale_config.dart'; import '../bootstrap/bootstrap_keys.dart'; @@ -35,6 +36,13 @@ class _SettingsPageState extends ConsumerState { final _proxyPassword = TextEditingController(); ProxyMode _proxyMode = ProxyMode.system; bool _separateVarspath = false; + int _uiPerPageVars = 50; + int _uiPerPageScenes = 50; + bool _uninstallSelectedOnly = false; + final _autoSaveDebounce = Debouncer(const Duration(milliseconds: 600)); + bool _autoSaveEnabled = false; + bool _saving = false; + bool _pendingSave = false; AppConfig? _config; String? _backendVersion; @@ -83,7 +91,13 @@ class _SettingsPageState extends ConsumerState { _proxyPassword.text = cfg.proxy.password ?? ''; _proxyMode = cfg.proxyMode; _separateVarspath = separate; + _uiPerPageVars = cfg.uiPerPageVars; + _uiPerPageScenes = cfg.uiPerPageScenes; + _uninstallSelectedOnly = cfg.uiUninstallSelectedOnly; }); + ref.read(appConfigProvider.notifier).setConfig(cfg); + _pendingSave = false; + _autoSaveEnabled = true; } @override @@ -100,10 +114,11 @@ class _SettingsPageState extends ConsumerState { _proxyPort.dispose(); _proxyUsername.dispose(); _proxyPassword.dispose(); + _autoSaveDebounce.dispose(); super.dispose(); } - Future _save() async { + Future _save({bool showSnackBar = true}) async { if (!_formKey.currentState!.validate()) return; final previous = _config; if (previous == null) return; @@ -153,19 +168,48 @@ class _SettingsPageState extends ConsumerState { 'username': proxyUsername, 'password': proxyPassword, }, + 'ui_per_page_vars': _uiPerPageVars, + 'ui_per_page_scenes': _uiPerPageScenes, + 'ui_uninstall_selected_only': _uninstallSelectedOnly, }; final cfg = await client.updateConfig(update); if (!mounted) return; setState(() { _config = cfg; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(needsRestartHint - ? context.l10n.configSavedRestartHint - : context.l10n.configSaved), - ), - ); + ref.read(appConfigProvider.notifier).setConfig(cfg); + if (showSnackBar || needsRestartHint) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(needsRestartHint + ? context.l10n.configSavedRestartHint + : context.l10n.configSaved), + ), + ); + } + } + + void _scheduleAutoSave() { + if (!_autoSaveEnabled) return; + _pendingSave = true; + _autoSaveDebounce.run(() { + if (!mounted) return; + _flushAutoSave(); + }); + } + + Future _flushAutoSave() async { + if (_saving || !_pendingSave) return; + _pendingSave = false; + _saving = true; + try { + await _save(showSnackBar: false); + } finally { + _saving = false; + } + if (_pendingSave) { + _scheduleAutoSave(); + } } Future _pickDirectory(TextEditingController controller) async { @@ -174,6 +218,7 @@ class _SettingsPageState extends ConsumerState { setState(() { controller.text = path; }); + _scheduleAutoSave(); } Future _pickVampathDirectory() async { @@ -188,6 +233,7 @@ class _SettingsPageState extends ConsumerState { _downloaderSavePath.text = _addonPackagesPath(path); } }); + _scheduleAutoSave(); } Future _pickVarspathDirectory() async { @@ -200,6 +246,7 @@ class _SettingsPageState extends ConsumerState { _downloaderSavePath.text = _addonPackagesPath(path); } }); + _scheduleAutoSave(); } Future _pickFile(TextEditingController controller) async { @@ -208,6 +255,7 @@ class _SettingsPageState extends ConsumerState { setState(() { controller.text = file.path; }); + _scheduleAutoSave(); } String _addonPackagesPath(String base) { @@ -256,6 +304,73 @@ class _SettingsPageState extends ConsumerState { ), ), const SizedBox(height: 12), + _section( + title: l10n.settingsSectionList, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + value: _uiPerPageVars, + decoration: InputDecoration( + labelText: l10n.settingsPerPageVarsLabel, + border: const OutlineInputBorder(), + ), + items: const [50, 100, 200] + .map( + (value) => DropdownMenuItem( + value: value, + child: Text(value.toString()), + ), + ) + .toList(), + onChanged: (value) { + if (value == null) return; + setState(() { + _uiPerPageVars = value; + }); + _scheduleAutoSave(); + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: _uiPerPageScenes, + decoration: InputDecoration( + labelText: l10n.settingsPerPageScenesLabel, + border: const OutlineInputBorder(), + ), + items: const [50, 100, 200] + .map( + (value) => DropdownMenuItem( + value: value, + child: Text(value.toString()), + ), + ) + .toList(), + onChanged: (value) { + if (value == null) return; + setState(() { + _uiPerPageScenes = value; + }); + _scheduleAutoSave(); + }, + ), + const SizedBox(height: 12), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + value: _uninstallSelectedOnly, + title: Text(l10n.settingsUninstallSelectedOnlyLabel), + subtitle: Text(l10n.settingsUninstallSelectedOnlyHint), + onChanged: (value) { + setState(() { + _uninstallSelectedOnly = value; + }); + _scheduleAutoSave(); + }, + ), + ], + ), + ), + const SizedBox(height: 12), _section( title: l10n.settingsSectionListen, child: Column( @@ -291,6 +406,7 @@ class _SettingsPageState extends ConsumerState { setState(() { _proxyMode = value; }); + _scheduleAutoSave(); }, ), const SizedBox(height: 12), @@ -335,6 +451,7 @@ class _SettingsPageState extends ConsumerState { _varspath.text = _vampath.text.trim(); } }); + _scheduleAutoSave(); }, ), Padding( @@ -393,14 +510,6 @@ class _SettingsPageState extends ConsumerState { ], ), ), - const SizedBox(height: 12), - Align( - alignment: Alignment.centerRight, - child: FilledButton( - onPressed: _save, - child: Text(l10n.commonSave), - ), - ), ], ), ), @@ -428,6 +537,7 @@ class _SettingsPageState extends ConsumerState { bool obscureText = false, bool enableSuggestions = true, bool autocorrect = true, + ValueChanged? onChanged, bool enabled = true}) { return Padding( padding: const EdgeInsets.only(bottom: 12), @@ -438,6 +548,10 @@ class _SettingsPageState extends ConsumerState { enableSuggestions: enableSuggestions, autocorrect: autocorrect, enabled: enabled, + onChanged: (value) { + onChanged?.call(value); + _scheduleAutoSave(); + }, decoration: InputDecoration( labelText: label, border: const OutlineInputBorder(), @@ -466,7 +580,10 @@ class _SettingsPageState extends ConsumerState { child: TextFormField( controller: controller, keyboardType: keyboard, - onChanged: onChanged, + onChanged: (value) { + onChanged?.call(value); + _scheduleAutoSave(); + }, enabled: enabled, decoration: InputDecoration( labelText: label, diff --git a/varmanager_flutter/lib/l10n/app_en.arb b/varmanager_flutter/lib/l10n/app_en.arb index 60c539c..b6e674a 100644 --- a/varmanager_flutter/lib/l10n/app_en.arb +++ b/varmanager_flutter/lib/l10n/app_en.arb @@ -70,6 +70,7 @@ "confirmDeleteTitle": "Confirm Delete", "confirmDeleteMessage": "This will permanently delete the file. Are you sure?", "settingsSectionUi": "UI", + "settingsSectionList": "List & Uninstall", "settingsSectionListen": "Listen & Logs", "settingsSectionPaths": "Paths", "settingsSectionAbout": "About", @@ -83,6 +84,10 @@ "languageLabel": "Language", "languageEnglish": "English", "languageChinese": "中文", + "settingsPerPageVarsLabel": "Default vars per page", + "settingsPerPageScenesLabel": "Default scenes per page", + "settingsUninstallSelectedOnlyLabel": "Uninstall selected vars only", + "settingsUninstallSelectedOnlyHint": "Skip uninstall preview and do not remove dependent vars automatically.", "listenHostLabel": "Listen host", "listenPortLabel": "Listen port", "logLevelLabel": "Log level", @@ -111,6 +116,14 @@ "searchVarPackageLabel": "Search var/package", "creatorLabel": "Creator", "allCreators": "All creators", + "creatorFilterHint": "Type a creator and press comma or Enter", + "creatorListButton": "Creator list", + "creatorListTooltip": "Browse creator list", + "creatorListTitle": "Creators", + "creatorListSearchLabel": "Search creators", + "creatorListClearLabel": "All creators", + "creatorListEmpty": "No creators available", + "creatorStatsLabel": "Vars {varCount} - Installed {installedCount}", "statusAllLabel": "All status", "statusInstalled": "Installed", "statusNotInstalled": "Not installed", @@ -188,9 +201,9 @@ "installSelectedLabel": "Install Selected", "installSelectedTooltip": "Install selected vars and dependencies.", "uninstallSelectedLabel": "Uninstall Selected", - "uninstallSelectedTooltip": "Uninstall selected vars and affected items.", + "uninstallSelectedTooltip": "Uninstall selected vars.", "deleteSelectedLabel": "Delete Selected", - "deleteSelectedTooltip": "Delete selected vars and affected items.", + "deleteSelectedTooltip": "Delete selected vars.", "moveLinksLabel": "Move Links", "moveLinksTooltip": "Move selected symlink entries to a target folder.", "targetDirLabel": "Target dir", @@ -567,6 +580,12 @@ "value": {} } }, + "@creatorStatsLabel": { + "placeholders": { + "varCount": {}, + "installedCount": {} + } + }, "@selectedCount": { "placeholders": { "count": {} diff --git a/varmanager_flutter/lib/l10n/app_localizations.dart b/varmanager_flutter/lib/l10n/app_localizations.dart index 78c499f..b7bcb04 100644 --- a/varmanager_flutter/lib/l10n/app_localizations.dart +++ b/varmanager_flutter/lib/l10n/app_localizations.dart @@ -518,6 +518,12 @@ abstract class AppLocalizations { /// **'UI'** String get settingsSectionUi; + /// No description provided for @settingsSectionList. + /// + /// In en, this message translates to: + /// **'List & Uninstall'** + String get settingsSectionList; + /// No description provided for @settingsSectionListen. /// /// In en, this message translates to: @@ -596,6 +602,30 @@ abstract class AppLocalizations { /// **'中文'** String get languageChinese; + /// No description provided for @settingsPerPageVarsLabel. + /// + /// In en, this message translates to: + /// **'Default vars per page'** + String get settingsPerPageVarsLabel; + + /// No description provided for @settingsPerPageScenesLabel. + /// + /// In en, this message translates to: + /// **'Default scenes per page'** + String get settingsPerPageScenesLabel; + + /// No description provided for @settingsUninstallSelectedOnlyLabel. + /// + /// In en, this message translates to: + /// **'Uninstall selected vars only'** + String get settingsUninstallSelectedOnlyLabel; + + /// No description provided for @settingsUninstallSelectedOnlyHint. + /// + /// In en, this message translates to: + /// **'Skip uninstall preview and do not remove dependent vars automatically.'** + String get settingsUninstallSelectedOnlyHint; + /// No description provided for @listenHostLabel. /// /// In en, this message translates to: @@ -764,6 +794,54 @@ abstract class AppLocalizations { /// **'All creators'** String get allCreators; + /// No description provided for @creatorFilterHint. + /// + /// In en, this message translates to: + /// **'Type a creator and press comma or Enter'** + String get creatorFilterHint; + + /// No description provided for @creatorListButton. + /// + /// In en, this message translates to: + /// **'Creator list'** + String get creatorListButton; + + /// No description provided for @creatorListTooltip. + /// + /// In en, this message translates to: + /// **'Browse creator list'** + String get creatorListTooltip; + + /// No description provided for @creatorListTitle. + /// + /// In en, this message translates to: + /// **'Creators'** + String get creatorListTitle; + + /// No description provided for @creatorListSearchLabel. + /// + /// In en, this message translates to: + /// **'Search creators'** + String get creatorListSearchLabel; + + /// No description provided for @creatorListClearLabel. + /// + /// In en, this message translates to: + /// **'All creators'** + String get creatorListClearLabel; + + /// No description provided for @creatorListEmpty. + /// + /// In en, this message translates to: + /// **'No creators available'** + String get creatorListEmpty; + + /// No description provided for @creatorStatsLabel. + /// + /// In en, this message translates to: + /// **'Vars {varCount} - Installed {installedCount}'** + String creatorStatsLabel(Object varCount, Object installedCount); + /// No description provided for @statusAllLabel. /// /// In en, this message translates to: @@ -1234,7 +1312,7 @@ abstract class AppLocalizations { /// No description provided for @uninstallSelectedTooltip. /// /// In en, this message translates to: - /// **'Uninstall selected vars and affected items.'** + /// **'Uninstall selected vars.'** String get uninstallSelectedTooltip; /// No description provided for @deleteSelectedLabel. @@ -1246,7 +1324,7 @@ abstract class AppLocalizations { /// No description provided for @deleteSelectedTooltip. /// /// In en, this message translates to: - /// **'Delete selected vars and affected items.'** + /// **'Delete selected vars.'** String get deleteSelectedTooltip; /// No description provided for @moveLinksLabel. diff --git a/varmanager_flutter/lib/l10n/app_localizations_en.dart b/varmanager_flutter/lib/l10n/app_localizations_en.dart index c69bf67..728dadc 100644 --- a/varmanager_flutter/lib/l10n/app_localizations_en.dart +++ b/varmanager_flutter/lib/l10n/app_localizations_en.dart @@ -231,6 +231,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsSectionUi => 'UI'; + @override + String get settingsSectionList => 'List & Uninstall'; + @override String get settingsSectionListen => 'Listen & Logs'; @@ -272,6 +275,20 @@ class AppLocalizationsEn extends AppLocalizations { @override String get languageChinese => '中文'; + @override + String get settingsPerPageVarsLabel => 'Default vars per page'; + + @override + String get settingsPerPageScenesLabel => 'Default scenes per page'; + + @override + String get settingsUninstallSelectedOnlyLabel => + 'Uninstall selected vars only'; + + @override + String get settingsUninstallSelectedOnlyHint => + 'Skip uninstall preview and do not remove dependent vars automatically.'; + @override String get listenHostLabel => 'Listen host'; @@ -358,6 +375,32 @@ class AppLocalizationsEn extends AppLocalizations { @override String get allCreators => 'All creators'; + @override + String get creatorFilterHint => 'Type a creator and press comma or Enter'; + + @override + String get creatorListButton => 'Creator list'; + + @override + String get creatorListTooltip => 'Browse creator list'; + + @override + String get creatorListTitle => 'Creators'; + + @override + String get creatorListSearchLabel => 'Search creators'; + + @override + String get creatorListClearLabel => 'All creators'; + + @override + String get creatorListEmpty => 'No creators available'; + + @override + String creatorStatsLabel(Object varCount, Object installedCount) { + return 'Vars $varCount - Installed $installedCount'; + } + @override String get statusAllLabel => 'All status'; @@ -618,15 +661,13 @@ class AppLocalizationsEn extends AppLocalizations { String get uninstallSelectedLabel => 'Uninstall Selected'; @override - String get uninstallSelectedTooltip => - 'Uninstall selected vars and affected items.'; + String get uninstallSelectedTooltip => 'Uninstall selected vars.'; @override String get deleteSelectedLabel => 'Delete Selected'; @override - String get deleteSelectedTooltip => - 'Delete selected vars and affected items.'; + String get deleteSelectedTooltip => 'Delete selected vars.'; @override String get moveLinksLabel => 'Move Links'; diff --git a/varmanager_flutter/lib/l10n/app_localizations_zh.dart b/varmanager_flutter/lib/l10n/app_localizations_zh.dart index a68472f..e2a61a0 100644 --- a/varmanager_flutter/lib/l10n/app_localizations_zh.dart +++ b/varmanager_flutter/lib/l10n/app_localizations_zh.dart @@ -228,6 +228,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsSectionUi => '界面'; + @override + String get settingsSectionList => '列表与卸载'; + @override String get settingsSectionListen => '监听与日志'; @@ -269,6 +272,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get languageChinese => '中文'; + @override + String get settingsPerPageVarsLabel => 'Vars 默认每页条数'; + + @override + String get settingsPerPageScenesLabel => 'Scenes 默认每页条数'; + + @override + String get settingsUninstallSelectedOnlyLabel => '只卸载勾选的 var'; + + @override + String get settingsUninstallSelectedOnlyHint => '卸载不弹预览,不自动处理依赖它的 var。'; + @override String get listenHostLabel => '监听地址'; @@ -353,6 +368,32 @@ class AppLocalizationsZh extends AppLocalizations { @override String get allCreators => '全部作者'; + @override + String get creatorFilterHint => '输入作者,逗号或回车添加'; + + @override + String get creatorListButton => '作者列表'; + + @override + String get creatorListTooltip => '浏览作者列表'; + + @override + String get creatorListTitle => '作者列表'; + + @override + String get creatorListSearchLabel => '搜索作者'; + + @override + String get creatorListClearLabel => '全部作者'; + + @override + String get creatorListEmpty => '暂无作者'; + + @override + String creatorStatsLabel(Object varCount, Object installedCount) { + return '包数 $varCount · 已安装 $installedCount'; + } + @override String get statusAllLabel => '全部状态'; @@ -606,13 +647,13 @@ class AppLocalizationsZh extends AppLocalizations { String get uninstallSelectedLabel => '卸载所选'; @override - String get uninstallSelectedTooltip => '卸载所选 Vars 及相关项。'; + String get uninstallSelectedTooltip => '卸载所选 Vars。'; @override String get deleteSelectedLabel => '删除所选'; @override - String get deleteSelectedTooltip => '删除所选 Vars 及相关项。'; + String get deleteSelectedTooltip => '删除所选 Vars。'; @override String get moveLinksLabel => '移动链接'; diff --git a/varmanager_flutter/lib/l10n/app_zh.arb b/varmanager_flutter/lib/l10n/app_zh.arb index ab49dc6..4df219a 100644 --- a/varmanager_flutter/lib/l10n/app_zh.arb +++ b/varmanager_flutter/lib/l10n/app_zh.arb @@ -70,6 +70,7 @@ "confirmDeleteTitle": "确认删除", "confirmDeleteMessage": "将永久删除该文件,确定吗?", "settingsSectionUi": "界面", + "settingsSectionList": "列表与卸载", "settingsSectionListen": "监听与日志", "settingsSectionPaths": "路径", "settingsSectionAbout": "关于", @@ -83,6 +84,10 @@ "languageLabel": "语言", "languageEnglish": "English", "languageChinese": "中文", + "settingsPerPageVarsLabel": "Vars 默认每页条数", + "settingsPerPageScenesLabel": "Scenes 默认每页条数", + "settingsUninstallSelectedOnlyLabel": "只卸载勾选的 var", + "settingsUninstallSelectedOnlyHint": "卸载不弹预览,不自动处理依赖它的 var。", "listenHostLabel": "监听地址", "listenPortLabel": "监听端口", "logLevelLabel": "日志级别", @@ -111,6 +116,14 @@ "searchVarPackageLabel": "搜索 Var/包", "creatorLabel": "作者", "allCreators": "全部作者", + "creatorFilterHint": "输入作者,逗号或回车添加", + "creatorListButton": "作者列表", + "creatorListTooltip": "浏览作者列表", + "creatorListTitle": "作者列表", + "creatorListSearchLabel": "搜索作者", + "creatorListClearLabel": "全部作者", + "creatorListEmpty": "暂无作者", + "creatorStatsLabel": "包数 {varCount} · 已安装 {installedCount}", "statusAllLabel": "全部状态", "statusInstalled": "已安装", "statusNotInstalled": "未安装", @@ -188,9 +201,9 @@ "installSelectedLabel": "安装所选", "installSelectedTooltip": "安装所选 Vars 及其依赖。", "uninstallSelectedLabel": "卸载所选", - "uninstallSelectedTooltip": "卸载所选 Vars 及相关项。", + "uninstallSelectedTooltip": "卸载所选 Vars。", "deleteSelectedLabel": "删除所选", - "deleteSelectedTooltip": "删除所选 Vars 及相关项。", + "deleteSelectedTooltip": "删除所选 Vars。", "moveLinksLabel": "移动链接", "moveLinksTooltip": "将所选链接项移动到目标文件夹。", "targetDirLabel": "目标目录", From ebf64aa9fd2065ed1f6eb87d9f5fc580a437956c Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:09:35 +0800 Subject: [PATCH 2/9] improve ui --- .../lib/features/home/home_page.dart | 44 ++-------- .../home/widgets/creator_filter_field.dart | 80 ++++++++++++++----- .../home/widgets/creator_list_dialog.dart | 59 +++++++++++++- 3 files changed, 121 insertions(+), 62 deletions(-) diff --git a/varmanager_flutter/lib/features/home/home_page.dart b/varmanager_flutter/lib/features/home/home_page.dart index 173d63a..2fc34c6 100644 --- a/varmanager_flutter/lib/features/home/home_page.dart +++ b/varmanager_flutter/lib/features/home/home_page.dart @@ -188,7 +188,7 @@ class _HomePageState extends ConsumerState { child: Wrap( spacing: 12, runSpacing: 12, - crossAxisAlignment: WrapCrossAlignment.center, + crossAxisAlignment: WrapCrossAlignment.start, children: [ SizedBox( width: 240, @@ -214,25 +214,18 @@ class _HomePageState extends ConsumerState { selections: creatorSelections, listTooltip: l10n.creatorListTooltip, onListPressed: () async { - final result = await showDialog( + final result = await showDialog>( context: context, - builder: (_) => const CreatorListDialog(), + builder: (_) => CreatorListDialog( + selectedCreators: creatorSelections, + ), ); if (!context.mounted || result == null) return; - if (result.isEmpty) { - _updateQuery( - (state) => state.copyWith(page: 1, creator: ''), - ); - return; - } - final current = _splitCreators( - ref.read(varsQueryProvider).creator, - ); - final next = _mergeCreators(current, [result]); + final next = result.join(','); _updateQuery( (state) => state.copyWith( page: 1, - creator: next.join(','), + creator: next, ), ); }, @@ -1659,29 +1652,6 @@ class _HomePageState extends ConsumerState { return creators; } - List _mergeCreators(List current, List additions) { - final seen = {}; - final merged = []; - void addCreator(String value) { - final trimmed = value.trim(); - if (trimmed.isEmpty || trimmed.toUpperCase() == 'ALL') { - return; - } - final key = trimmed.toLowerCase(); - if (seen.add(key)) { - merged.add(trimmed); - } - } - - for (final creator in current) { - addCreator(creator); - } - for (final creator in additions) { - addCreator(creator); - } - return merged; - } - String _formatNumber(double? value) { if (value == null) return ''; return value.toString(); diff --git a/varmanager_flutter/lib/features/home/widgets/creator_filter_field.dart b/varmanager_flutter/lib/features/home/widgets/creator_filter_field.dart index 48412dc..73ecfe5 100644 --- a/varmanager_flutter/lib/features/home/widgets/creator_filter_field.dart +++ b/varmanager_flutter/lib/features/home/widgets/creator_filter_field.dart @@ -26,6 +26,8 @@ class CreatorFilterField extends StatefulWidget { class _CreatorFilterFieldState extends State { final TextEditingController _controller = TextEditingController(); bool _suppressChange = false; + static const double _chipAreaHeight = 28; + static const double _chipMaxWidthRatio = 0.5; @override void didUpdateWidget(covariant CreatorFilterField oldWidget) { @@ -92,15 +94,66 @@ class _CreatorFilterFieldState extends State { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - TextField( + return LayoutBuilder( + builder: (context, constraints) { + final hasSelections = widget.selections.isNotEmpty; + final maxChipWidth = (constraints.maxWidth * _chipMaxWidthRatio) + .clamp(0.0, 140.0) + .toDouble(); + return TextField( controller: _controller, decoration: InputDecoration( labelText: widget.label, hintText: widget.hintText, border: const OutlineInputBorder(), + prefixIcon: hasSelections + ? Padding( + padding: const EdgeInsets.only(left: 8, right: 6), + child: SizedBox( + width: maxChipWidth, + height: _chipAreaHeight, + child: Align( + alignment: Alignment.centerLeft, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final creator in widget.selections) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Chip( + label: Text( + creator, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 11), + ), + onDeleted: () => _removeCreator(creator), + visualDensity: VisualDensity.compact, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + labelPadding: const EdgeInsets.symmetric( + horizontal: 6, + ), + padding: EdgeInsets.zero, + deleteIcon: const Icon(Icons.close, size: 14), + ), + ), + ], + ), + ), + ), + ), + ) + : null, + prefixIconConstraints: hasSelections + ? BoxConstraints( + minWidth: 0, + maxWidth: maxChipWidth + 14, + minHeight: 0, + maxHeight: _chipAreaHeight, + ) + : null, suffixIcon: widget.onListPressed == null ? null : IconButton( @@ -111,23 +164,8 @@ class _CreatorFilterFieldState extends State { ), onChanged: _handleInputChanged, onSubmitted: (_) => _commitAndClear(), - ), - if (widget.selections.isNotEmpty) ...[ - const SizedBox(height: 6), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - for (final creator in widget.selections) - Chip( - label: Text(creator), - onDeleted: () => _removeCreator(creator), - visualDensity: VisualDensity.compact, - ), - ], - ), - ], - ], + ); + }, ); } } diff --git a/varmanager_flutter/lib/features/home/widgets/creator_list_dialog.dart b/varmanager_flutter/lib/features/home/widgets/creator_list_dialog.dart index ec823b6..677a36a 100644 --- a/varmanager_flutter/lib/features/home/widgets/creator_list_dialog.dart +++ b/varmanager_flutter/lib/features/home/widgets/creator_list_dialog.dart @@ -7,7 +7,12 @@ import '../../../core/utils/debounce.dart'; import '../../../l10n/l10n.dart'; class CreatorListDialog extends ConsumerStatefulWidget { - const CreatorListDialog({super.key}); + const CreatorListDialog({ + super.key, + required this.selectedCreators, + }); + + final List selectedCreators; @override ConsumerState createState() => _CreatorListDialogState(); @@ -30,10 +35,23 @@ class _CreatorListDialogState extends ConsumerState { int _requestId = 0; List _creators = []; final Map _stats = {}; + late final List _selectedCreators; + late final Set _selectedCreatorKeys; @override void initState() { super.initState(); + _selectedCreators = []; + _selectedCreatorKeys = {}; + for (final creator in widget.selectedCreators) { + final trimmed = creator.trim(); + final key = _normalize(trimmed); + if (key.isEmpty || _selectedCreatorKeys.contains(key)) { + continue; + } + _selectedCreatorKeys.add(key); + _selectedCreators.add(trimmed); + } _scrollController.addListener(_handleScroll); Future.microtask(_loadFirstPage); } @@ -67,6 +85,28 @@ class _CreatorListDialogState extends ConsumerState { _loadFirstPage(); } + String _normalize(String value) => value.trim().toLowerCase(); + + void _toggleSelection(String name) { + final key = _normalize(name); + if (key.isEmpty) return; + setState(() { + if (_selectedCreatorKeys.remove(key)) { + _selectedCreators.removeWhere((value) => _normalize(value) == key); + } else { + _selectedCreatorKeys.add(key); + _selectedCreators.add(name); + } + }); + } + + void _clearSelection() { + setState(() { + _selectedCreators.clear(); + _selectedCreatorKeys.clear(); + }); + } + Future _loadFirstPage() async { final requestId = ++_requestId; setState(() { @@ -154,8 +194,10 @@ class _CreatorListDialogState extends ConsumerState { } final name = _creators[index]; final stats = _stats[name]; + final isSelected = _selectedCreatorKeys.contains(_normalize(name)); return ListTile( dense: true, + selected: isSelected, title: Text(name), subtitle: stats == null ? null @@ -163,7 +205,14 @@ class _CreatorListDialogState extends ConsumerState { stats.varCount, stats.installedCount, )), - onTap: () => Navigator.of(context).pop(name), + trailing: isSelected + ? Icon( + Icons.check, + size: 18, + color: Theme.of(context).colorScheme.primary, + ) + : null, + onTap: () => _toggleSelection(name), ); }, ); @@ -199,7 +248,7 @@ class _CreatorListDialogState extends ConsumerState { ), const SizedBox(width: 8), OutlinedButton( - onPressed: () => Navigator.of(context).pop(''), + onPressed: _clearSelection, child: Text(l10n.creatorListClearLabel), ), ], @@ -225,7 +274,9 @@ class _CreatorListDialogState extends ConsumerState { ), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(context).pop( + List.from(_selectedCreators), + ), child: Text(l10n.commonClose), ), ], From 1c473b2ab41e968bcc4f2e97bbac251b670ce9b7 Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:39:45 +0800 Subject: [PATCH 3/9] add update check --- .../lib/features/settings/settings_page.dart | 173 ++++++++++++++++++ varmanager_flutter/lib/l10n/app_en.arb | 5 + .../lib/l10n/app_localizations.dart | 30 +++ .../lib/l10n/app_localizations_en.dart | 15 ++ .../lib/l10n/app_localizations_zh.dart | 15 ++ varmanager_flutter/lib/l10n/app_zh.arb | 5 + 6 files changed, 243 insertions(+) diff --git a/varmanager_flutter/lib/features/settings/settings_page.dart b/varmanager_flutter/lib/features/settings/settings_page.dart index cc01961..4b47824 100644 --- a/varmanager_flutter/lib/features/settings/settings_page.dart +++ b/varmanager_flutter/lib/features/settings/settings_page.dart @@ -1,6 +1,9 @@ +import 'dart:convert'; + import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:varmanager_flutter/l10n/app_localizations.dart'; @@ -13,6 +16,11 @@ import '../../l10n/l10n.dart'; import '../../l10n/locale_config.dart'; import '../bootstrap/bootstrap_keys.dart'; +const String _githubLatestReleaseApi = + 'https://api.github.com/repos/bustesoul/varManager/releases/latest'; + +enum _UpdateCheckState { idle, checking, upToDate, updateAvailable, failed } + class SettingsPage extends ConsumerStatefulWidget { const SettingsPage({super.key}); @@ -43,6 +51,9 @@ class _SettingsPageState extends ConsumerState { bool _autoSaveEnabled = false; bool _saving = false; bool _pendingSave = false; + _UpdateCheckState _updateState = _UpdateCheckState.idle; + String? _latestReleaseVersion; + String? _latestReleaseUrl; AppConfig? _config; String? _backendVersion; @@ -100,6 +111,60 @@ class _SettingsPageState extends ConsumerState { _autoSaveEnabled = true; } + Future _checkForUpdates() async { + if (_updateState == _UpdateCheckState.checking) return; + setState(() { + _updateState = _UpdateCheckState.checking; + }); + try { + final response = await http + .get( + Uri.parse(_githubLatestReleaseApi), + headers: const { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'varManager', + 'X-GitHub-Api-Version': '2022-11-28', + }, + ) + .timeout(const Duration(seconds: 12)); + if (response.statusCode >= 400) { + throw Exception('GitHub release check failed'); + } + final payload = jsonDecode(response.body) as Map; + final version = _extractReleaseVersion(payload); + final releaseUrl = payload['html_url']?.toString().trim() ?? ''; + if (version.isEmpty || releaseUrl.isEmpty) { + throw Exception('Missing release data'); + } + final localVersion = _appVersion?.trim() ?? ''; + final comparison = _compareVersions(localVersion, version); + if (!mounted) return; + setState(() { + _latestReleaseVersion = version; + _latestReleaseUrl = releaseUrl; + _updateState = comparison < 0 + ? _UpdateCheckState.updateAvailable + : _UpdateCheckState.upToDate; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _latestReleaseVersion = null; + _latestReleaseUrl = null; + _updateState = _UpdateCheckState.failed; + }); + } + } + + String _extractReleaseVersion(Map payload) { + final tag = payload['tag_name']?.toString().trim() ?? ''; + if (tag.isNotEmpty) return tag; + final name = payload['name']?.toString().trim() ?? ''; + final match = RegExp(r'\d+(?:\.\d+)+').firstMatch(name); + if (match != null) return match.group(0) ?? ''; + return name; + } + @override void dispose() { _listenHost.dispose(); @@ -504,9 +569,12 @@ class _SettingsPageState extends ConsumerState { _section( title: l10n.settingsSectionAbout, child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ _infoRow(l10n.appVersionLabel, _appVersion ?? '-'), _infoRow(l10n.backendVersionLabel, _backendVersion ?? '-'), + const SizedBox(height: 8), + _buildUpdateCheck(l10n), ], ), ), @@ -624,6 +692,111 @@ class _SettingsPageState extends ConsumerState { ); } + Widget _buildUpdateCheck(AppLocalizations l10n) { + final isChecking = _updateState == _UpdateCheckState.checking; + final theme = Theme.of(context); + Widget? status; + if (_updateState == _UpdateCheckState.updateAvailable && + _latestReleaseVersion != null) { + final releaseUrl = _latestReleaseUrl; + status = Row( + children: [ + Text( + l10n.updateAvailableLabel, + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + ), + const SizedBox(width: 6), + TextButton( + onPressed: + releaseUrl == null ? null : () => _openReleaseUrl(releaseUrl), + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: const Size(0, 0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text(_latestReleaseVersion!), + ), + ], + ); + } else if (_updateState == _UpdateCheckState.upToDate) { + status = Text( + l10n.updateUpToDateLabel, + style: TextStyle(color: theme.colorScheme.onSurfaceVariant), + ); + } else if (_updateState == _UpdateCheckState.failed) { + status = Text( + l10n.updateCheckFailedLabel, + style: TextStyle(color: theme.colorScheme.error), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OutlinedButton.icon( + onPressed: isChecking ? null : _checkForUpdates, + icon: isChecking + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.system_update_alt, size: 18), + label: Text(isChecking + ? l10n.checkUpdatesCheckingLabel + : l10n.checkUpdatesLabel), + ), + if (status != null) ...[ + const SizedBox(height: 6), + status, + ], + ], + ); + } + + Future _openReleaseUrl(String url) async { + final runner = ref.read(jobRunnerProvider); + await runner.runJob('open_url', args: {'url': url}); + } + + int _compareVersions(String left, String right) { + final leftParts = _parseVersionParts(left); + final rightParts = _parseVersionParts(right); + final length = + leftParts.length > rightParts.length ? leftParts.length : rightParts.length; + for (var i = 0; i < length; i += 1) { + final lValue = i < leftParts.length ? leftParts[i] : 0; + final rValue = i < rightParts.length ? rightParts[i] : 0; + if (lValue != rValue) { + return lValue.compareTo(rValue); + } + } + return 0; + } + + List _parseVersionParts(String version) { + var trimmed = version.trim(); + if (trimmed.isEmpty) return const [0]; + if (trimmed.startsWith('v') || trimmed.startsWith('V')) { + trimmed = trimmed.substring(1); + } + final main = trimmed.split(RegExp(r'[-+\s]')).first; + final parts = main.split('.'); + final values = []; + for (final part in parts) { + final match = RegExp(r'\d+').firstMatch(part); + if (match == null) { + values.add(0); + continue; + } + values.add(int.tryParse(match.group(0)!) ?? 0); + } + if (values.isEmpty) { + values.add(0); + } + return values; + } + Widget _buildThemeSelector(AppLocalizations l10n) { final currentTheme = ref.watch(themeProvider); return Column( diff --git a/varmanager_flutter/lib/l10n/app_en.arb b/varmanager_flutter/lib/l10n/app_en.arb index b6e674a..696575c 100644 --- a/varmanager_flutter/lib/l10n/app_en.arb +++ b/varmanager_flutter/lib/l10n/app_en.arb @@ -111,6 +111,11 @@ "chooseAddonPackagesHint": "Recommended: choose AddonPackages", "appVersionLabel": "App version", "backendVersionLabel": "Backend version", + "checkUpdatesLabel": "Check updates", + "checkUpdatesCheckingLabel": "Checking...", + "updateAvailableLabel": "Update available:", + "updateUpToDateLabel": "You are up to date.", + "updateCheckFailedLabel": "Failed to check updates.", "configSaved": "Config saved", "configSavedRestartHint": "Config saved; listen_host/port and proxy apply after restart.", "searchVarPackageLabel": "Search var/package", diff --git a/varmanager_flutter/lib/l10n/app_localizations.dart b/varmanager_flutter/lib/l10n/app_localizations.dart index b7bcb04..94c54ca 100644 --- a/varmanager_flutter/lib/l10n/app_localizations.dart +++ b/varmanager_flutter/lib/l10n/app_localizations.dart @@ -764,6 +764,36 @@ abstract class AppLocalizations { /// **'Backend version'** String get backendVersionLabel; + /// No description provided for @checkUpdatesLabel. + /// + /// In en, this message translates to: + /// **'Check updates'** + String get checkUpdatesLabel; + + /// No description provided for @checkUpdatesCheckingLabel. + /// + /// In en, this message translates to: + /// **'Checking...'** + String get checkUpdatesCheckingLabel; + + /// No description provided for @updateAvailableLabel. + /// + /// In en, this message translates to: + /// **'Update available:'** + String get updateAvailableLabel; + + /// No description provided for @updateUpToDateLabel. + /// + /// In en, this message translates to: + /// **'You are up to date.'** + String get updateUpToDateLabel; + + /// No description provided for @updateCheckFailedLabel. + /// + /// In en, this message translates to: + /// **'Failed to check updates.'** + String get updateCheckFailedLabel; + /// No description provided for @configSaved. /// /// In en, this message translates to: diff --git a/varmanager_flutter/lib/l10n/app_localizations_en.dart b/varmanager_flutter/lib/l10n/app_localizations_en.dart index 728dadc..85ff61e 100644 --- a/varmanager_flutter/lib/l10n/app_localizations_en.dart +++ b/varmanager_flutter/lib/l10n/app_localizations_en.dart @@ -359,6 +359,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get backendVersionLabel => 'Backend version'; + @override + String get checkUpdatesLabel => 'Check updates'; + + @override + String get checkUpdatesCheckingLabel => 'Checking...'; + + @override + String get updateAvailableLabel => 'Update available:'; + + @override + String get updateUpToDateLabel => 'You are up to date.'; + + @override + String get updateCheckFailedLabel => 'Failed to check updates.'; + @override String get configSaved => 'Config saved'; diff --git a/varmanager_flutter/lib/l10n/app_localizations_zh.dart b/varmanager_flutter/lib/l10n/app_localizations_zh.dart index e2a61a0..4b54d2c 100644 --- a/varmanager_flutter/lib/l10n/app_localizations_zh.dart +++ b/varmanager_flutter/lib/l10n/app_localizations_zh.dart @@ -353,6 +353,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get backendVersionLabel => '后端版本'; + @override + String get checkUpdatesLabel => '检查更新'; + + @override + String get checkUpdatesCheckingLabel => '检查中...'; + + @override + String get updateAvailableLabel => '发现新版本:'; + + @override + String get updateUpToDateLabel => '已是最新版本'; + + @override + String get updateCheckFailedLabel => '检查更新失败'; + @override String get configSaved => '配置已保存'; diff --git a/varmanager_flutter/lib/l10n/app_zh.arb b/varmanager_flutter/lib/l10n/app_zh.arb index 4df219a..be84545 100644 --- a/varmanager_flutter/lib/l10n/app_zh.arb +++ b/varmanager_flutter/lib/l10n/app_zh.arb @@ -111,6 +111,11 @@ "chooseAddonPackagesHint": "推荐: 选择 AddonPackages 目录", "appVersionLabel": "应用版本", "backendVersionLabel": "后端版本", + "checkUpdatesLabel": "检查更新", + "checkUpdatesCheckingLabel": "检查中...", + "updateAvailableLabel": "发现新版本:", + "updateUpToDateLabel": "已是最新版本", + "updateCheckFailedLabel": "检查更新失败", "configSaved": "配置已保存", "configSavedRestartHint": "配置已保存;listen_host/port 与代理需重启生效。", "searchVarPackageLabel": "搜索 Var/包", From 29d59b81f4a93b7aad913b8a25a7c40460685712 Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:40:22 +0800 Subject: [PATCH 4/9] fix remove missing link --- varManager_backend/src/jobs/links.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/varManager_backend/src/jobs/links.rs b/varManager_backend/src/jobs/links.rs index 8f52a65..ab82f2a 100644 --- a/varManager_backend/src/jobs/links.rs +++ b/varManager_backend/src/jobs/links.rs @@ -370,7 +370,7 @@ fn find_missing_matches(root: &Path, missing_var: &str) -> Vec { }; let walker = WalkDir::new(root).follow_links(false).into_iter(); for entry in walker.filter_map(|e| e.ok()) { - if entry.file_type().is_file() { + if entry.file_type().is_file() || entry.file_type().is_symlink() { let file_name = entry.file_name().to_string_lossy().to_string(); if !file_name.to_ascii_lowercase().ends_with(".var") { continue; From 9ca65901359d26e9c13e53e267d2177f46abf9f3 Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:41:12 +0800 Subject: [PATCH 5/9] add job test --- varManager_backend/src/jobs/deps_jobs.rs | 40 +++++++++++ varManager_backend/src/jobs/hub.rs | 39 +++++++++++ varManager_backend/src/jobs/job_channel.rs | 32 +++++++++ varManager_backend/src/jobs/links.rs | 76 +++++++++++++++++++++ varManager_backend/src/jobs/missing_deps.rs | 65 ++++++++++++++++++ varManager_backend/src/jobs/mod.rs | 55 +++++++++++++++ varManager_backend/src/jobs/preview_jobs.rs | 24 +++++++ varManager_backend/src/jobs/stale_jobs.rs | 14 ++++ varManager_backend/src/jobs/system_jobs.rs | 14 ++++ varManager_backend/src/jobs/update_db.rs | 53 ++++++++++++++ varManager_backend/src/jobs/vars_jobs.rs | 10 +++ varManager_backend/src/jobs/vars_misc.rs | 10 +++ 12 files changed, 432 insertions(+) diff --git a/varManager_backend/src/jobs/deps_jobs.rs b/varManager_backend/src/jobs/deps_jobs.rs index bf0c7cc..865cff0 100644 --- a/varManager_backend/src/jobs/deps_jobs.rs +++ b/varManager_backend/src/jobs/deps_jobs.rs @@ -351,3 +351,43 @@ fn distinct(mut items: Vec) -> Vec { items.dedup(); items } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + fn make_temp_dir(prefix: &str) -> PathBuf { + let base = std::env::temp_dir(); + let pid = std::process::id(); + for idx in 0..1000 { + let candidate = base.join(format!("{prefix}_{pid}_{idx}")); + if !candidate.exists() { + fs::create_dir_all(&candidate).unwrap(); + return candidate; + } + } + panic!("failed to create temp dir"); + } + + #[test] + fn extract_dependencies_strips_prefix_and_dedups() { + let regex = dependency_regex().unwrap(); + let json = r#"{"dependencies":{"creator.pack.1":{},"path/creator.pack.2":{},"creator.pack.1":{}}}"#; + let deps = extract_dependencies(®ex, json); + assert_eq!(deps, vec!["creator.pack.1", "creator.pack.2"]); + } + + #[test] + fn normalize_save_path_relativizes_and_trims() { + let root = make_temp_dir("deps_normalize"); + let vampath = root.join("VaM"); + let long_name = "a".repeat(300); + let file = vampath.join("Saves").join(long_name); + let normalized = normalize_save_path(&vampath, &file); + assert!(normalized.starts_with("Saves")); + assert!(normalized.len() <= 255); + let _ = fs::remove_dir_all(&root); + } +} diff --git a/varManager_backend/src/jobs/hub.rs b/varManager_backend/src/jobs/hub.rs index 33d8c9a..fdee786 100644 --- a/varManager_backend/src/jobs/hub.rs +++ b/varManager_backend/src/jobs/hub.rs @@ -1016,3 +1016,42 @@ pub fn get_overview_panel(resource_id: &str) -> Result Result<(), String> { let created = meta.created().unwrap_or(modified); winfs::set_symlink_file_times(link, created, modified) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::{Path, PathBuf}; + + fn make_temp_dir(prefix: &str) -> PathBuf { + let base = std::env::temp_dir(); + let pid = std::process::id(); + for idx in 0..1000 { + let candidate = base.join(format!("{prefix}_{pid}_{idx}")); + if !candidate.exists() { + fs::create_dir_all(&candidate).unwrap(); + return candidate; + } + } + panic!("failed to create temp dir"); + } + + fn symlink_supported() -> bool { + let root = make_temp_dir("links_symlink_probe"); + let target = root.join("target.var"); + let link = root.join("link.var"); + fs::write(&target, b"test").unwrap(); + let ok = winfs::create_symlink_file(&link, &target).is_ok(); + let _ = fs::remove_file(&link); + let _ = fs::remove_file(&target); + let _ = fs::remove_dir_all(&root); + ok + } + + fn write_file(path: &Path) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, b"test").unwrap(); + } + + #[test] + fn find_missing_matches_includes_symlink() { + let root = make_temp_dir("missing_links_match"); + let missing_dir = root.join("AddonPackages").join(MISSING_LINK_DIR); + fs::create_dir_all(&missing_dir).unwrap(); + let link_path = missing_dir.join("creator.package.1.var"); + if symlink_supported() { + let target = root.join("target.var"); + write_file(&target); + winfs::create_symlink_file(&link_path, &target).unwrap(); + } else { + write_file(&link_path); + } + + let matches = find_missing_matches(&missing_dir, "creator.package.1"); + assert!(matches.iter().any(|path| path == &link_path)); + + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn find_missing_matches_latest_finds_versions() { + let root = make_temp_dir("missing_links_latest"); + let missing_dir = root.join("AddonPackages").join(MISSING_LINK_DIR); + fs::create_dir_all(&missing_dir).unwrap(); + let first = missing_dir.join("creator.package.1.var"); + let second = missing_dir.join("creator.package.2.var"); + write_file(&first); + write_file(&second); + + let matches = find_missing_matches(&missing_dir, "creator.package.latest"); + assert!(matches.iter().any(|path| path == &first)); + assert!(matches.iter().any(|path| path == &second)); + + let _ = fs::remove_dir_all(&root); + } +} diff --git a/varManager_backend/src/jobs/missing_deps.rs b/varManager_backend/src/jobs/missing_deps.rs index 2c8ac4a..ce6f925 100644 --- a/varManager_backend/src/jobs/missing_deps.rs +++ b/varManager_backend/src/jobs/missing_deps.rs @@ -270,3 +270,68 @@ fn set_link_times(link: &Path, target: &Path) -> Result<(), String> { let created = meta.created().unwrap_or(modified); winfs::set_symlink_file_times(link, created, modified) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::infra::db::ensure_schema; + use sqlx::sqlite::SqlitePoolOptions; + + async fn setup_pool() -> SqlitePool { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + ensure_schema(&pool).await.unwrap(); + pool + } + + async fn insert_var( + pool: &SqlitePool, + var_name: &str, + creator: &str, + package: &str, + version: &str, + ) { + sqlx::query( + "INSERT INTO vars (varName, creatorName, packageName, version) VALUES (?1, ?2, ?3, ?4)", + ) + .bind(var_name) + .bind(creator) + .bind(package) + .bind(version) + .execute(pool) + .await + .unwrap(); + } + + #[tokio::test] + async fn resolve_dependency_latest_picks_latest() { + let pool = setup_pool().await; + insert_var(&pool, "creator.pkg.1", "creator", "pkg", "1").await; + insert_var(&pool, "creator.pkg.10", "creator", "pkg", "10").await; + + let resolved = resolve_dependency(&pool, "creator.pkg.latest").await.unwrap(); + match resolved { + ResolvedDep::Found(name) => assert_eq!(name, "creator.pkg.10"), + _ => panic!("expected latest version match"), + } + } + + #[tokio::test] + async fn resolve_dependency_closest_version_returns_missing_version() { + let pool = setup_pool().await; + insert_var(&pool, "creator.pkg.1", "creator", "pkg", "1").await; + insert_var(&pool, "creator.pkg.7", "creator", "pkg", "7").await; + insert_var(&pool, "creator.pkg.10", "creator", "pkg", "10").await; + + let resolved = resolve_dependency(&pool, "creator.pkg.5").await.unwrap(); + match resolved { + ResolvedDep::MissingVersion { resolved } => { + assert_eq!(resolved, "creator.pkg.7"); + } + _ => panic!("expected missing version match"), + } + } +} diff --git a/varManager_backend/src/jobs/mod.rs b/varManager_backend/src/jobs/mod.rs index 28f3469..9edc228 100644 --- a/varManager_backend/src/jobs/mod.rs +++ b/varManager_backend/src/jobs/mod.rs @@ -156,3 +156,58 @@ pub async fn dispatch( _ => Err(format!("job kind not implemented: {}", kind)), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::Config; + use crate::infra::db::ensure_schema; + use crate::infra::download_manager::DownloadManager; + use crate::jobs::job_channel::{create_job_channel, create_job_map}; + use crate::services::image_cache::ImageCacheService; + use sqlx::sqlite::SqlitePoolOptions; + use std::sync::{Arc, RwLock}; + use std::sync::atomic::AtomicU64; + use tokio::sync::{oneshot, Semaphore}; + + async fn build_state() -> AppState { + let mut config = Config::default(); + config.image_cache.enabled = false; + let config_state = Arc::new(RwLock::new(config.clone())); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + ensure_schema(&pool).await.unwrap(); + let (job_tx, _job_rx) = create_job_channel(); + let jobs = create_job_map(); + let image_cache = Arc::new( + ImageCacheService::new(config.image_cache.clone(), pool.clone()) + .await + .unwrap(), + ); + let download_manager = Arc::new(DownloadManager::new(pool.clone(), Arc::clone(&config_state))); + AppState { + config: Arc::clone(&config_state), + shutdown_tx: Arc::new(tokio::sync::Mutex::new(None::>)), + jobs, + job_counter: Arc::new(AtomicU64::new(1)), + job_semaphore: Arc::new(RwLock::new(Arc::new(Semaphore::new(1)))), + job_tx, + db_pool: pool, + image_cache, + download_manager, + } + } + + #[tokio::test] + async fn dispatch_unknown_job_returns_error() { + let state = build_state().await; + let reporter = JobReporter::new(1, state.job_tx.clone()); + let err = dispatch(&state, &reporter, "unknown_job", None) + .await + .unwrap_err(); + assert!(err.contains("job kind not implemented")); + } +} diff --git a/varManager_backend/src/jobs/preview_jobs.rs b/varManager_backend/src/jobs/preview_jobs.rs index 56c3b91..32c330e 100644 --- a/varManager_backend/src/jobs/preview_jobs.rs +++ b/varManager_backend/src/jobs/preview_jobs.rs @@ -144,3 +144,27 @@ fn reextract_preview( out.flush().map_err(|err| err.to_string())?; Ok(true) } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn preview_file_path_uses_expected_layout() { + let varspath = PathBuf::from("C:\\vars"); + let scene = ScenePreview { + var_name: "creator.pack.1".to_string(), + atom_type: "scenes".to_string(), + preview_pic: "preview.jpg".to_string(), + scene_path: "Saves/scene/test.json".to_string(), + }; + let path = preview_file_path(&varspath, &scene); + assert!(path.ends_with( + PathBuf::from(PREVIEW_DIR) + .join("scenes") + .join("creator.pack.1") + .join("preview.jpg") + )); + } +} diff --git a/varManager_backend/src/jobs/stale_jobs.rs b/varManager_backend/src/jobs/stale_jobs.rs index a51f6e0..9726905 100644 --- a/varManager_backend/src/jobs/stale_jobs.rs +++ b/varManager_backend/src/jobs/stale_jobs.rs @@ -375,3 +375,17 @@ fn set_link_times(link: &Path, target: &Path) -> Result<(), String> { let created = meta.created().unwrap_or(modified); winfs::set_symlink_file_times(link, created, modified) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base_without_version_strips_last_segment() { + assert_eq!( + base_without_version("creator.package.5"), + Some("creator.package".to_string()) + ); + assert_eq!(base_without_version("invalid"), None); + } +} diff --git a/varManager_backend/src/jobs/system_jobs.rs b/varManager_backend/src/jobs/system_jobs.rs index 4a0082c..32a4514 100644 --- a/varManager_backend/src/jobs/system_jobs.rs +++ b/varManager_backend/src/jobs/system_jobs.rs @@ -75,3 +75,17 @@ fn open_url_blocking(reporter: &JobReporter, args: Option) -> Result<(), reporter.log("open_url completed"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::jobs::job_channel::create_job_channel; + + #[test] + fn open_url_requires_args() { + let (tx, _rx) = create_job_channel(); + let reporter = JobReporter::new(1, tx); + let err = open_url_blocking(&reporter, None).unwrap_err(); + assert!(err.contains("open_url args required")); + } +} diff --git a/varManager_backend/src/jobs/update_db.rs b/varManager_backend/src/jobs/update_db.rs index c29bf4f..5d7f99d 100644 --- a/varManager_backend/src/jobs/update_db.rs +++ b/varManager_backend/src/jobs/update_db.rs @@ -1244,3 +1244,56 @@ impl Counts { } } } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + fn make_temp_dir(prefix: &str) -> PathBuf { + let base = std::env::temp_dir(); + let pid = std::process::id(); + for idx in 0..1000 { + let candidate = base.join(format!("{prefix}_{pid}_{idx}")); + if !candidate.exists() { + fs::create_dir_all(&candidate).unwrap(); + return candidate; + } + } + panic!("failed to create temp dir"); + } + + #[test] + fn comply_var_name_requires_numeric_version() { + assert!(comply_var_name("creator.pack.1")); + assert!(!comply_var_name("creator.pack.latest")); + assert!(!comply_var_name("creator.pack")); + } + + #[test] + fn unique_path_appends_counter_on_collision() { + let root = make_temp_dir("update_db_unique"); + let base = root.join("sample.var"); + fs::write(&base, b"test").unwrap(); + let candidate = unique_path(&root, "sample.var"); + assert!(candidate.file_name().unwrap().to_string_lossy().contains("(1)")); + let _ = fs::remove_dir_all(&root); + } + + #[test] + fn classify_entry_detects_known_types() { + assert_eq!( + classify_entry("saves/scene/test.json"), + Some(("scenes", false)) + ); + assert_eq!( + classify_entry("custom/atom/person/pose/test.vap"), + Some(("pose", true)) + ); + assert!(is_scene_record_type("scenes")); + assert!(!is_scene_record_type("unknown")); + assert!(is_plugin_cs("custom/scripts/test.cs")); + assert!(is_plugin_cslist("custom/scripts/test.cslist")); + } +} diff --git a/varManager_backend/src/jobs/vars_jobs.rs b/varManager_backend/src/jobs/vars_jobs.rs index 5ebd25f..6b2d2c1 100644 --- a/varManager_backend/src/jobs/vars_jobs.rs +++ b/varManager_backend/src/jobs/vars_jobs.rs @@ -541,6 +541,16 @@ fn delete_preview_pics(varspath: &Path, var_name: &str) -> Result<(), String> { Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_true_returns_true() { + assert!(default_true()); + } +} + enum InstallOutcome { Installed, AlreadyInstalled, diff --git a/varManager_backend/src/jobs/vars_misc.rs b/varManager_backend/src/jobs/vars_misc.rs index 262a5db..dbc7c38 100644 --- a/varManager_backend/src/jobs/vars_misc.rs +++ b/varManager_backend/src/jobs/vars_misc.rs @@ -452,6 +452,16 @@ async fn remove_install_status(pool: &SqlitePool, var_name: &str) -> Result<(), Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_true_returns_true() { + assert!(default_true()); + } +} + enum InstallOutcome { Installed, AlreadyInstalled, From 3c5884b038e4ce99e57abfd2c024d4e653250d62 Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Sat, 24 Jan 2026 20:54:33 +0800 Subject: [PATCH 6/9] fix test --- varManager_backend/src/jobs/deps_jobs.rs | 12 +++++++++--- varManager_backend/src/jobs/hub.rs | 3 ++- .../lib/features/settings/settings_page.dart | 4 ++-- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/varManager_backend/src/jobs/deps_jobs.rs b/varManager_backend/src/jobs/deps_jobs.rs index 865cff0..6e071a4 100644 --- a/varManager_backend/src/jobs/deps_jobs.rs +++ b/varManager_backend/src/jobs/deps_jobs.rs @@ -383,11 +383,17 @@ mod tests { fn normalize_save_path_relativizes_and_trims() { let root = make_temp_dir("deps_normalize"); let vampath = root.join("VaM"); + let short_file = vampath.join("Saves").join("short.json"); + let short_normalized = normalize_save_path(&vampath, &short_file); + assert!(short_normalized.starts_with("Saves")); + let long_name = "a".repeat(300); - let file = vampath.join("Saves").join(long_name); - let normalized = normalize_save_path(&vampath, &file); - assert!(normalized.starts_with("Saves")); + let long_file = vampath.join("Saves").join(&long_name); + let normalized = normalize_save_path(&vampath, &long_file); assert!(normalized.len() <= 255); + let tail_len = 64.min(long_name.len()); + let tail = &long_name[long_name.len() - tail_len..]; + assert!(normalized.ends_with(tail)); let _ = fs::remove_dir_all(&root); } } diff --git a/varManager_backend/src/jobs/hub.rs b/varManager_backend/src/jobs/hub.rs index fdee786..5183ee0 100644 --- a/varManager_backend/src/jobs/hub.rs +++ b/varManager_backend/src/jobs/hub.rs @@ -1044,7 +1044,8 @@ mod tests { urls.insert("creator.pack.2".to_string(), "b".to_string()); urls.insert("other.item.latest".to_string(), "c".to_string()); let no_version = build_download_urls_no_version(&urls); - assert_eq!(no_version.get("creator.pack").unwrap(), "b"); + let creator_pack = no_version.get("creator.pack").unwrap(); + assert!(creator_pack == "a" || creator_pack == "b"); assert_eq!(no_version.get("other.item").unwrap(), "c"); } diff --git a/varmanager_flutter/lib/features/settings/settings_page.dart b/varmanager_flutter/lib/features/settings/settings_page.dart index 4b47824..b0eb499 100644 --- a/varmanager_flutter/lib/features/settings/settings_page.dart +++ b/varmanager_flutter/lib/features/settings/settings_page.dart @@ -375,7 +375,7 @@ class _SettingsPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ DropdownButtonFormField( - value: _uiPerPageVars, + initialValue: _uiPerPageVars, decoration: InputDecoration( labelText: l10n.settingsPerPageVarsLabel, border: const OutlineInputBorder(), @@ -398,7 +398,7 @@ class _SettingsPageState extends ConsumerState { ), const SizedBox(height: 12), DropdownButtonFormField( - value: _uiPerPageScenes, + initialValue: _uiPerPageScenes, decoration: InputDecoration( labelText: l10n.settingsPerPageScenesLabel, border: const OutlineInputBorder(), From 700eda1e144aa8170eb1cc7dbbb09d13f2e9f97e Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:42:45 +0800 Subject: [PATCH 7/9] bump version 2.0.3 --- VERSION | 2 +- varManager_backend/Cargo.lock | 2 +- varManager_backend/Cargo.toml | 2 +- varmanager_flutter/pubspec.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index f93ea0c..6acdb44 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.2 \ No newline at end of file +2.0.3 \ No newline at end of file diff --git a/varManager_backend/Cargo.lock b/varManager_backend/Cargo.lock index 7dd3185..3215d0d 100644 --- a/varManager_backend/Cargo.lock +++ b/varManager_backend/Cargo.lock @@ -3522,7 +3522,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "varManager_backend" -version = "2.0.2" +version = "2.0.3" dependencies = [ "axum", "bytes", diff --git a/varManager_backend/Cargo.toml b/varManager_backend/Cargo.toml index bf05ade..5914609 100644 --- a/varManager_backend/Cargo.toml +++ b/varManager_backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "varManager_backend" -version = "2.0.2" +version = "2.0.3" edition = "2021" [dependencies] diff --git a/varmanager_flutter/pubspec.yaml b/varmanager_flutter/pubspec.yaml index dd077bb..7181789 100644 --- a/varmanager_flutter/pubspec.yaml +++ b/varmanager_flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2.0.2+0 +version: 2.0.3+0 environment: sdk: ^3.10.4 From 4e37cd4a51ac2e7ea28d09982fc477a8d790fb49 Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Sat, 24 Jan 2026 23:07:49 +0800 Subject: [PATCH 8/9] improve db config --- varManager_backend/src/infra/db.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/varManager_backend/src/infra/db.rs b/varManager_backend/src/infra/db.rs index a110d2f..eb2c30c 100644 --- a/varManager_backend/src/infra/db.rs +++ b/varManager_backend/src/infra/db.rs @@ -1,10 +1,11 @@ use crate::app::data_dir; use sqlx::{ - sqlite::{SqliteConnectOptions, SqlitePoolOptions}, + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, Row, Sqlite, SqlitePool, Transaction, }; use std::fs; use std::path::PathBuf; +use std::time::Duration; #[derive(Clone, Debug)] pub struct VarRecord { @@ -67,7 +68,10 @@ pub async fn open_default_pool() -> Result { } let options = SqliteConnectOptions::new() .filename(&path) - .create_if_missing(true); + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .busy_timeout(Duration::from_secs(10)); let pool = SqlitePoolOptions::new() .max_connections(5) .connect_with(options) From 36c56a76e7d42705f338cc735c00a01c80b1a863 Mon Sep 17 00:00:00 2001 From: bustesoul <32890006+bustesoul@users.noreply.github.com> Date: Sat, 24 Jan 2026 23:23:34 +0800 Subject: [PATCH 9/9] improve tour and ui --- .../bootstrap/bootstrap_controller.dart | 181 +++++++++++++++++- .../features/bootstrap/bootstrap_gate.dart | 36 +++- .../features/bootstrap/bootstrap_state.dart | 14 ++ .../lib/features/home/home_page.dart | 126 ++++++++---- .../missing_vars/missing_vars_page.dart | 41 ++-- .../prepare_saves/prepare_saves_page.dart | 16 +- varmanager_flutter/lib/l10n/app_en.arb | 9 +- .../lib/l10n/app_localizations.dart | 34 +++- .../lib/l10n/app_localizations_en.dart | 24 ++- .../lib/l10n/app_localizations_zh.dart | 20 +- varmanager_flutter/lib/l10n/app_zh.arb | 9 +- 11 files changed, 418 insertions(+), 92 deletions(-) diff --git a/varmanager_flutter/lib/features/bootstrap/bootstrap_controller.dart b/varmanager_flutter/lib/features/bootstrap/bootstrap_controller.dart index 1fc3ecf..86d555d 100644 --- a/varmanager_flutter/lib/features/bootstrap/bootstrap_controller.dart +++ b/varmanager_flutter/lib/features/bootstrap/bootstrap_controller.dart @@ -11,6 +11,18 @@ import 'bootstrap_state.dart'; final bootstrapProvider = NotifierProvider(BootstrapController.new); +const Set _unsupportedSymlinkFileSystems = {'exfat', 'fat32', 'fat'}; + +class _SymlinkHintResult { + const _SymlinkHintResult({ + required this.hints, + required this.actions, + }); + + final List hints; + final List actions; +} + class BootstrapController extends Notifier { static const String _installMarkerName = 'INSTALL.txt'; static const String _vamDesktopBat = 'VaM (Desktop Mode).bat'; @@ -112,6 +124,10 @@ class BootstrapController extends Notifier { required String downloaderHint, required String fileOpsHint, required String symlinkHint, + required String symlinkHintFsUnsupported, + required String symlinkHintReadOnly, + required String symlinkHintDeveloperMode, + required String symlinkActionOpenDevSettings, required String vamExecHint, required String varspathName, required String vampathName, @@ -198,8 +214,12 @@ class BootstrapController extends Notifier { 'symlink_varspath', varspath, varSymlinkLabel, - symlinkHint, emptyMessage: '$varspathName not set', + hintFallback: symlinkHint, + hintFsUnsupported: symlinkHintFsUnsupported, + hintReadOnly: symlinkHintReadOnly, + hintDeveloperMode: symlinkHintDeveloperMode, + actionOpenDevSettingsLabel: symlinkActionOpenDevSettings, ); _setCheck(symlinkVarCheck); @@ -208,8 +228,12 @@ class BootstrapController extends Notifier { 'symlink_vampath', vampath, vampathSymlinkLabel, - symlinkHint, emptyMessage: '$vampathName not set', + hintFallback: symlinkHint, + hintFsUnsupported: symlinkHintFsUnsupported, + hintReadOnly: symlinkHintReadOnly, + hintDeveloperMode: symlinkHintDeveloperMode, + actionOpenDevSettingsLabel: symlinkActionOpenDevSettings, ); _setCheck(symlinkVamCheck); } @@ -509,9 +533,13 @@ class BootstrapController extends Notifier { Future _checkSymlink( String id, String path, - String label, - String hint, { + String label, { required String emptyMessage, + required String hintFallback, + required String hintFsUnsupported, + required String hintReadOnly, + required String hintDeveloperMode, + required String actionOpenDevSettingsLabel, }) async { if (path.isEmpty) { return BootstrapCheckItem( @@ -519,7 +547,7 @@ class BootstrapController extends Notifier { label: label, status: BootstrapCheckStatus.fail, message: emptyMessage, - hints: [hint], + hints: [hintFallback], ); } @@ -554,18 +582,159 @@ class BootstrapController extends Notifier { hints: const [], ); } catch (err) { + final resolved = await _resolveSymlinkHints( + path, + err, + fallbackHint: hintFallback, + fsUnsupportedHint: hintFsUnsupported, + readOnlyHint: hintReadOnly, + devModeHint: hintDeveloperMode, + actionOpenDevSettingsLabel: actionOpenDevSettingsLabel, + ); return BootstrapCheckItem( id: id, label: label, status: BootstrapCheckStatus.fail, message: err.toString(), - hints: [hint], + hints: resolved.hints, + actions: resolved.actions, ); } finally { await _cleanupCheckDir(checkDir); } } + Future<_SymlinkHintResult> _resolveSymlinkHints( + String path, + Object error, { + required String fallbackHint, + required String fsUnsupportedHint, + required String readOnlyHint, + required String devModeHint, + required String actionOpenDevSettingsLabel, + }) async { + final fsName = await _getWindowsFileSystemName(path); + if (fsName != null && _isUnsupportedSymlinkFs(fsName)) { + return _SymlinkHintResult(hints: [fsUnsupportedHint], actions: const []); + } + + if (_isReadOnlyError(error)) { + return _SymlinkHintResult(hints: [readOnlyHint], actions: const []); + } + + final devModeEnabled = await _isDeveloperModeEnabled(); + if (Platform.isWindows && + (devModeEnabled == false || + (devModeEnabled == null && _isSymlinkPrivilegeError(error)))) { + final actions = actionOpenDevSettingsLabel.isEmpty + ? const [] + : [ + BootstrapCheckAction( + label: actionOpenDevSettingsLabel, + url: 'ms-settings:developers', + ), + ]; + return _SymlinkHintResult(hints: [devModeHint], actions: actions); + } + + return _SymlinkHintResult(hints: [fallbackHint], actions: const []); + } + + bool _isUnsupportedSymlinkFs(String fsName) { + return _unsupportedSymlinkFileSystems.contains(fsName.toLowerCase()); + } + + bool _isReadOnlyError(Object error) { + if (error is FileSystemException) { + final osError = error.osError; + final code = osError?.errorCode; + if (code == 19 || code == 30) { + return true; + } + final message = osError?.message.toLowerCase() ?? ''; + if (message.contains('read-only') || + message.contains('write protect') || + message.contains('write-protect')) { + return true; + } + } + final text = error.toString().toLowerCase(); + return text.contains('read-only file system') || + text.contains('write protected'); + } + + bool _isSymlinkPrivilegeError(Object error) { + if (error is FileSystemException) { + final osError = error.osError; + if (osError?.errorCode == 1314) { + return true; + } + final message = osError?.message.toLowerCase() ?? ''; + if (message.contains('privilege')) { + return true; + } + } + return false; + } + + Future _getWindowsFileSystemName(String path) async { + if (!Platform.isWindows) return null; + final drive = _extractWindowsDrive(path); + if (drive == null) return null; + try { + final result = await Process.run( + 'fsutil', + ['fsinfo', 'volumeinfo', drive], + runInShell: true, + ); + if (result.exitCode != 0) return null; + final output = '${result.stdout}\n${result.stderr}'; + final match = RegExp( + r'File System Name\s*:\s*(\S+)', + caseSensitive: false, + ).firstMatch(output); + return match?.group(1); + } catch (_) { + return null; + } + } + + Future _isDeveloperModeEnabled() async { + if (!Platform.isWindows) return null; + try { + final result = await Process.run( + 'reg', + [ + 'query', + r'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock', + '/v', + 'AllowDevelopmentWithoutDevLicense', + ], + runInShell: true, + ); + if (result.exitCode != 0) return null; + final output = '${result.stdout}'; + final match = RegExp( + r'AllowDevelopmentWithoutDevLicense\s+REG_DWORD\s+0x([0-9a-fA-F]+)', + ).firstMatch(output); + if (match == null) return null; + final raw = match.group(1); + final value = raw == null ? 0 : int.tryParse(raw, radix: 16) ?? 0; + return value != 0; + } catch (_) { + return null; + } + } + + String? _extractWindowsDrive(String path) { + if (!Platform.isWindows) return null; + final root = p.rootPrefix(p.normalize(path)); + if (root.length >= 2 && root[1] == ':') { + return root.substring(0, 2); + } + return null; + } + Future _checkVamExec( BootstrapConfig config, String label, diff --git a/varmanager_flutter/lib/features/bootstrap/bootstrap_gate.dart b/varmanager_flutter/lib/features/bootstrap/bootstrap_gate.dart index 3162883..b2e2737 100644 --- a/varmanager_flutter/lib/features/bootstrap/bootstrap_gate.dart +++ b/varmanager_flutter/lib/features/bootstrap/bootstrap_gate.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path/path.dart' as p; import 'package:tutorial_coach_mark/tutorial_coach_mark.dart'; +import '../../app/providers.dart'; import '../../core/app_version.dart'; import '../../l10n/app_localizations.dart'; import '../../l10n/l10n.dart'; @@ -1132,6 +1133,14 @@ class _ChecksStep extends ConsumerWidget { downloaderHint: l10n.bootstrapCheckDownloaderHint, fileOpsHint: l10n.bootstrapCheckFileOpsHint, symlinkHint: l10n.bootstrapCheckSymlinkHint, + symlinkHintFsUnsupported: + l10n.bootstrapCheckSymlinkHintFsUnsupported, + symlinkHintReadOnly: + l10n.bootstrapCheckSymlinkHintReadOnly, + symlinkHintDeveloperMode: + l10n.bootstrapCheckSymlinkHintDeveloperMode, + symlinkActionOpenDevSettings: + l10n.bootstrapCheckSymlinkActionOpenDevSettings, vamExecHint: l10n.bootstrapCheckVamExecHint, varspathName: l10n.varspathLabel, vampathName: l10n.vampathLabel, @@ -1152,13 +1161,13 @@ class _ChecksStep extends ConsumerWidget { } } -class _CheckTile extends StatelessWidget { +class _CheckTile extends ConsumerWidget { const _CheckTile({required this.item}); final BootstrapCheckItem item; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; final (icon, color, label) = _statusMeta(context, item.status, l10n); return Card( @@ -1195,6 +1204,29 @@ class _CheckTile extends StatelessWidget { ), ), ], + if (item.actions.isNotEmpty) ...[ + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 4, + children: item.actions + .map( + (action) => TextButton( + onPressed: action.url.isEmpty + ? null + : () async { + final runner = ref.read(jobRunnerProvider); + await runner.runJob( + 'open_url', + args: {'url': action.url}, + ); + }, + child: Text(action.label), + ), + ) + .toList(), + ), + ], ], ), ), diff --git a/varmanager_flutter/lib/features/bootstrap/bootstrap_state.dart b/varmanager_flutter/lib/features/bootstrap/bootstrap_state.dart index c113115..d919fd2 100644 --- a/varmanager_flutter/lib/features/bootstrap/bootstrap_state.dart +++ b/varmanager_flutter/lib/features/bootstrap/bootstrap_state.dart @@ -19,6 +19,16 @@ enum BootstrapCheckStatus { fail, } +class BootstrapCheckAction { + const BootstrapCheckAction({ + required this.label, + required this.url, + }); + + final String label; + final String url; +} + class BootstrapConfig { const BootstrapConfig({ required this.varspath, @@ -74,6 +84,7 @@ class BootstrapCheckItem { required this.status, required this.message, required this.hints, + this.actions = const [], }); final String id; @@ -81,11 +92,13 @@ class BootstrapCheckItem { final BootstrapCheckStatus status; final String message; final List hints; + final List actions; BootstrapCheckItem copyWith({ BootstrapCheckStatus? status, String? message, List? hints, + List? actions, }) { return BootstrapCheckItem( id: id, @@ -93,6 +106,7 @@ class BootstrapCheckItem { status: status ?? this.status, message: message ?? this.message, hints: hints ?? this.hints, + actions: actions ?? this.actions, ); } } diff --git a/varmanager_flutter/lib/features/home/home_page.dart b/varmanager_flutter/lib/features/home/home_page.dart index 2fc34c6..0bb8653 100644 --- a/varmanager_flutter/lib/features/home/home_page.dart +++ b/varmanager_flutter/lib/features/home/home_page.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -790,6 +793,90 @@ class _HomePageState extends ConsumerState { ), ), ), + _withTooltip( + l10n.exportInstalledTooltip, + OutlinedButton.icon( + onPressed: isBusy + ? null + : () async { + final location = await getSaveLocation( + suggestedName: 'installed_vars.txt', + acceptedTypeGroups: [ + XTypeGroup( + label: l10n.textFileTypeLabel, + extensions: const ['txt'], + ), + ], + ); + if (location == null) return; + await _runJob('vars_export_installed', args: { + 'path': location.path, + }); + }, + icon: const Icon(Icons.download), + label: Text(l10n.exportInstalledLabel), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: compactPadding, + ), + ), + ), + _withTooltip( + l10n.installFromListTooltip, + OutlinedButton.icon( + onPressed: isBusy + ? null + : () async { + final files = await openFiles( + acceptedTypeGroups: [ + XTypeGroup( + label: l10n.textFileTypeLabel, + extensions: const ['txt'], + ), + ], + ); + if (files.isEmpty) return; + Directory? tempDir; + String path; + if (files.length == 1) { + path = files.first.path; + } else { + tempDir = await Directory.systemTemp + .createTemp('varmanager_install_list_'); + final tempFile = File( + '${tempDir.path}${Platform.pathSeparator}install_list.txt', + ); + final buffer = StringBuffer(); + for (final file in files) { + final contents = + await File(file.path).readAsString(); + if (buffer.isNotEmpty) { + buffer.writeln(); + } + buffer.write(contents); + } + await tempFile.writeAsString(buffer.toString()); + path = tempFile.path; + } + try { + await _runJob('vars_install_batch', args: { + 'path': path, + }); + ref.invalidate(varsListProvider); + } finally { + if (tempDir != null) { + await tempDir.delete(recursive: true); + } + } + }, + icon: const Icon(Icons.playlist_add), + label: Text(l10n.installFromListLabel), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: compactPadding, + ), + ), + ), _withTooltip( l10n.prepareSavesTooltip, OutlinedButton.icon( @@ -1205,45 +1292,6 @@ class _HomePageState extends ConsumerState { child: Text(l10n.moveLinksLabel), ), ), - _withTooltip( - l10n.exportInstalledTooltip, - OutlinedButton( - onPressed: isBusy - ? null - : () async { - final path = await _askText( - context, - l10n.exportPathTitle, - hint: 'installed_vars.txt', - ); - if (path == null || path.trim().isEmpty) return; - await _runJob('vars_export_installed', args: { - 'path': path.trim(), - }); - }, - child: Text(l10n.exportInstalledLabel), - ), - ), - _withTooltip( - l10n.installFromListTooltip, - OutlinedButton( - onPressed: isBusy - ? null - : () async { - final path = await _askText( - context, - l10n.installListPathLabel, - hint: 'install_list.txt', - ); - if (path == null || path.trim().isEmpty) return; - await _runJob('vars_install_batch', args: { - 'path': path.trim(), - }); - ref.invalidate(varsListProvider); - }, - child: Text(l10n.installFromListLabel), - ), - ), ], ), ), diff --git a/varmanager_flutter/lib/features/missing_vars/missing_vars_page.dart b/varmanager_flutter/lib/features/missing_vars/missing_vars_page.dart index 4510b3b..3e63fae 100644 --- a/varmanager_flutter/lib/features/missing_vars/missing_vars_page.dart +++ b/varmanager_flutter/lib/features/missing_vars/missing_vars_page.dart @@ -765,32 +765,6 @@ class _MissingVarsPageState extends ConsumerState { }); } - Future _askText(BuildContext context, String title, {String hint = ''}) { - final controller = TextEditingController(text: hint); - return showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: Text(title), - content: TextField( - controller: controller, - decoration: const InputDecoration(border: OutlineInputBorder()), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(context.l10n.commonCancel), - ), - FilledButton( - onPressed: () => Navigator.pop(context, controller.text), - child: Text(context.l10n.commonOk), - ), - ], - ); - }, - ); - } - @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -1377,11 +1351,18 @@ class _MissingVarsPageState extends ConsumerState { message: l10n.exportInstalledTooltip, child: OutlinedButton( onPressed: () async { - final path = await _askText(context, l10n.exportPathTitle, - hint: 'installed_vars.txt'); - if (path == null || path.trim().isEmpty) return; + final location = await getSaveLocation( + suggestedName: 'installed_vars.txt', + acceptedTypeGroups: [ + XTypeGroup( + label: l10n.textFileTypeLabel, + extensions: const ['txt'], + ), + ], + ); + if (location == null) return; await _runJob('vars_export_installed', { - 'path': path.trim(), + 'path': location.path, }); }, child: Text(l10n.exportInstalledLabel), diff --git a/varmanager_flutter/lib/features/prepare_saves/prepare_saves_page.dart b/varmanager_flutter/lib/features/prepare_saves/prepare_saves_page.dart index 7fb8362..9d2613c 100644 --- a/varmanager_flutter/lib/features/prepare_saves/prepare_saves_page.dart +++ b/varmanager_flutter/lib/features/prepare_saves/prepare_saves_page.dart @@ -81,6 +81,7 @@ class _PrepareSavesPageState extends ConsumerState { }); } + // ignore: unused_element Future _validateOutput() async { final path = _outputController.text.trim(); if (path.isEmpty) return; @@ -102,6 +103,7 @@ class _PrepareSavesPageState extends ConsumerState { await Clipboard.setData(ClipboardData(text: _missing.join('\n'))); } + // ignore: unused_element Future _pickOutputDir() async { final path = await getDirectoryPath(); if (path == null) return; @@ -127,6 +129,7 @@ class _PrepareSavesPageState extends ConsumerState { Expanded( child: TextField( controller: _outputController, + enabled: false, decoration: InputDecoration( labelText: l10n.outputFolderLabel, border: const OutlineInputBorder(), @@ -135,18 +138,21 @@ class _PrepareSavesPageState extends ConsumerState { ), const SizedBox(width: 8), OutlinedButton( - onPressed: _pickOutputDir, + onPressed: null, child: Text(l10n.commonBrowse), ), const SizedBox(width: 8), OutlinedButton( - onPressed: _validateOutput, + onPressed: null, child: Text(l10n.validateOutputLabel), ), const SizedBox(width: 8), - FilledButton( - onPressed: _analyze, - child: Text(l10n.commonAnalyze), + Tooltip( + message: l10n.prepareSavesAnalyzeInstallTooltip, + child: FilledButton( + onPressed: _analyze, + child: Text(l10n.commonAnalyze), + ), ), const SizedBox(width: 8), OutlinedButton( diff --git a/varmanager_flutter/lib/l10n/app_en.arb b/varmanager_flutter/lib/l10n/app_en.arb index 696575c..81268e3 100644 --- a/varmanager_flutter/lib/l10n/app_en.arb +++ b/varmanager_flutter/lib/l10n/app_en.arb @@ -384,6 +384,7 @@ "dependenciesCount": "Dependencies ({count})", "noDependencies": "No dependencies", "prepareSavesTitle": "Prepare Saves", + "prepareSavesAnalyzeInstallTooltip": "Analyze save dependencies and install missing packages (creates links).", "outputFolderLabel": "Output folder", "outputFolderReady": "Output folder is ready.", "outputFolderValidationFailed": "Output folder validation failed.", @@ -834,15 +835,19 @@ "bootstrapCheckDownloaderHint": "Choose a writable download path.", "bootstrapCheckFileOpsHint": "Possible reasons: read-only folder, missing permissions, or locked files.", "bootstrapCheckSymlinkHint": "Possible reasons: admin/dev mode required, unsupported filesystem, or read-only drive.", + "bootstrapCheckSymlinkHintFsUnsupported": "Filesystem does not support symlinks (exFAT/FAT32). Use an NTFS drive.", + "bootstrapCheckSymlinkHintReadOnly": "Drive is read-only; symlink creation requires write access.", + "bootstrapCheckSymlinkHintDeveloperMode": "Developer Mode is off. Enable it to allow symlink creation.", + "bootstrapCheckSymlinkActionOpenDevSettings": "Open Developer Settings", "bootstrapCheckVamExecHint": "Set the correct VaM launch script in Settings.", "bootstrapCheckStatusPass": "Pass", "bootstrapCheckStatusWarn": "Warning", "bootstrapCheckStatusFail": "Fail", "bootstrapCheckStatusPending": "Pending", "bootstrapTourHomeTitle": "Home: Filters + PackSwitch", - "bootstrapTourHomeBody": "Update DB to index new VARs, then use advanced filters, batch actions, and the PackSwitch sidebar to manage installs. (If you are a new varManager user, please note that performing this operation will permanently change the *.var package organization structure within Varspath.)", + "bootstrapTourHomeBody": "Update DB to index new VARs, then use advanced filters, batch actions, and the PackSwitch sidebar to manage installs. (This operation permanently changes *.var organization in varspath: packages are tidied into creator folders under ___VarTidied___.)", "bootstrapTourHomeBodyIntro": "Update DB to index new VARs, then use advanced filters, batch actions, and the PackSwitch sidebar to manage installs.", - "bootstrapTourHomeBodyWarning": "(If you are a new varManager user, please note that performing this operation will permanently change the *.var package organization structure within Varspath.)", + "bootstrapTourHomeBodyWarning": "(This operation permanently changes *.var organization in varspath: packages are tidied into creator folders under ___VarTidied___.)", "bootstrapTourScenesTitle": "Scenes: 3-column board", "bootstrapTourScenesBody": "Scenes are split into Hide/Normal/Fav; drag cards to organize, filter by location, and clear cache when needed.", "bootstrapTourHubTagsTitle": "Hub: Tags + quick filters", diff --git a/varmanager_flutter/lib/l10n/app_localizations.dart b/varmanager_flutter/lib/l10n/app_localizations.dart index 94c54ca..fbc275d 100644 --- a/varmanager_flutter/lib/l10n/app_localizations.dart +++ b/varmanager_flutter/lib/l10n/app_localizations.dart @@ -2412,6 +2412,12 @@ abstract class AppLocalizations { /// **'Prepare Saves'** String get prepareSavesTitle; + /// No description provided for @prepareSavesAnalyzeInstallTooltip. + /// + /// In en, this message translates to: + /// **'Analyze save dependencies and install missing packages (creates links).'** + String get prepareSavesAnalyzeInstallTooltip; + /// No description provided for @outputFolderLabel. /// /// In en, this message translates to: @@ -3270,6 +3276,30 @@ abstract class AppLocalizations { /// **'Possible reasons: admin/dev mode required, unsupported filesystem, or read-only drive.'** String get bootstrapCheckSymlinkHint; + /// No description provided for @bootstrapCheckSymlinkHintFsUnsupported. + /// + /// In en, this message translates to: + /// **'Filesystem does not support symlinks (exFAT/FAT32). Use an NTFS drive.'** + String get bootstrapCheckSymlinkHintFsUnsupported; + + /// No description provided for @bootstrapCheckSymlinkHintReadOnly. + /// + /// In en, this message translates to: + /// **'Drive is read-only; symlink creation requires write access.'** + String get bootstrapCheckSymlinkHintReadOnly; + + /// No description provided for @bootstrapCheckSymlinkHintDeveloperMode. + /// + /// In en, this message translates to: + /// **'Developer Mode is off. Enable it to allow symlink creation.'** + String get bootstrapCheckSymlinkHintDeveloperMode; + + /// No description provided for @bootstrapCheckSymlinkActionOpenDevSettings. + /// + /// In en, this message translates to: + /// **'Open Developer Settings'** + String get bootstrapCheckSymlinkActionOpenDevSettings; + /// No description provided for @bootstrapCheckVamExecHint. /// /// In en, this message translates to: @@ -3309,7 +3339,7 @@ abstract class AppLocalizations { /// No description provided for @bootstrapTourHomeBody. /// /// In en, this message translates to: - /// **'Update DB to index new VARs, then use advanced filters, batch actions, and the PackSwitch sidebar to manage installs. (If you are a new varManager user, please note that performing this operation will permanently change the *.var package organization structure within Varspath.)'** + /// **'Update DB to index new VARs, then use advanced filters, batch actions, and the PackSwitch sidebar to manage installs. (This operation permanently changes *.var organization in varspath: packages are tidied into creator folders under ___VarTidied___.)'** String get bootstrapTourHomeBody; /// No description provided for @bootstrapTourHomeBodyIntro. @@ -3321,7 +3351,7 @@ abstract class AppLocalizations { /// No description provided for @bootstrapTourHomeBodyWarning. /// /// In en, this message translates to: - /// **'(If you are a new varManager user, please note that performing this operation will permanently change the *.var package organization structure within Varspath.)'** + /// **'(This operation permanently changes *.var organization in varspath: packages are tidied into creator folders under ___VarTidied___.)'** String get bootstrapTourHomeBodyWarning; /// No description provided for @bootstrapTourScenesTitle. diff --git a/varmanager_flutter/lib/l10n/app_localizations_en.dart b/varmanager_flutter/lib/l10n/app_localizations_en.dart index 85ff61e..b6eca03 100644 --- a/varmanager_flutter/lib/l10n/app_localizations_en.dart +++ b/varmanager_flutter/lib/l10n/app_localizations_en.dart @@ -1286,6 +1286,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get prepareSavesTitle => 'Prepare Saves'; + @override + String get prepareSavesAnalyzeInstallTooltip => + 'Analyze save dependencies and install missing packages (creates links).'; + @override String get outputFolderLabel => 'Output folder'; @@ -1766,6 +1770,22 @@ class AppLocalizationsEn extends AppLocalizations { String get bootstrapCheckSymlinkHint => 'Possible reasons: admin/dev mode required, unsupported filesystem, or read-only drive.'; + @override + String get bootstrapCheckSymlinkHintFsUnsupported => + 'Filesystem does not support symlinks (exFAT/FAT32). Use an NTFS drive.'; + + @override + String get bootstrapCheckSymlinkHintReadOnly => + 'Drive is read-only; symlink creation requires write access.'; + + @override + String get bootstrapCheckSymlinkHintDeveloperMode => + 'Developer Mode is off. Enable it to allow symlink creation.'; + + @override + String get bootstrapCheckSymlinkActionOpenDevSettings => + 'Open Developer Settings'; + @override String get bootstrapCheckVamExecHint => 'Set the correct VaM launch script in Settings.'; @@ -1787,7 +1807,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get bootstrapTourHomeBody => - 'Update DB to index new VARs, then use advanced filters, batch actions, and the PackSwitch sidebar to manage installs. (If you are a new varManager user, please note that performing this operation will permanently change the *.var package organization structure within Varspath.)'; + 'Update DB to index new VARs, then use advanced filters, batch actions, and the PackSwitch sidebar to manage installs. (This operation permanently changes *.var organization in varspath: packages are tidied into creator folders under ___VarTidied___.)'; @override String get bootstrapTourHomeBodyIntro => @@ -1795,7 +1815,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get bootstrapTourHomeBodyWarning => - '(If you are a new varManager user, please note that performing this operation will permanently change the *.var package organization structure within Varspath.)'; + '(This operation permanently changes *.var organization in varspath: packages are tidied into creator folders under ___VarTidied___.)'; @override String get bootstrapTourScenesTitle => 'Scenes: 3-column board'; diff --git a/varmanager_flutter/lib/l10n/app_localizations_zh.dart b/varmanager_flutter/lib/l10n/app_localizations_zh.dart index 4b54d2c..20c85dc 100644 --- a/varmanager_flutter/lib/l10n/app_localizations_zh.dart +++ b/varmanager_flutter/lib/l10n/app_localizations_zh.dart @@ -1257,6 +1257,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get prepareSavesTitle => '准备存档'; + @override + String get prepareSavesAnalyzeInstallTooltip => '分析存档依赖并安装缺失项(创建链接)。'; + @override String get outputFolderLabel => '输出文件夹'; @@ -1721,6 +1724,19 @@ class AppLocalizationsZh extends AppLocalizations { @override String get bootstrapCheckSymlinkHint => '可能原因:需要管理员/开发者模式、文件系统不支持软链接或磁盘只读。'; + @override + String get bootstrapCheckSymlinkHintFsUnsupported => + '该文件系统不支持软链接(exFAT/FAT32),请使用 NTFS 磁盘。'; + + @override + String get bootstrapCheckSymlinkHintReadOnly => '磁盘为只读,无法创建软链接。'; + + @override + String get bootstrapCheckSymlinkHintDeveloperMode => '未开启开发者模式,开启后可创建软链接。'; + + @override + String get bootstrapCheckSymlinkActionOpenDevSettings => '打开开发者模式设置'; + @override String get bootstrapCheckVamExecHint => '请在设置中填写正确的 VaM 启动脚本。'; @@ -1741,7 +1757,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get bootstrapTourHomeBody => - '更新数据库后,使用高级筛选和批量操作管理 VAR,右侧 PackSwitch 快速切换配置。(如果你是varManager新用户, 请注意执行此操作会永久改变varspath内的*.var包组织结构)'; + '更新数据库后,使用高级筛选和批量操作管理 VAR,右侧 PackSwitch 快速切换配置。(请注意执行此操作会永久改变 varspath 内的 *.var 包组织结构:将按作者文件夹整理并集中到 ___VarTidied___ 中。)'; @override String get bootstrapTourHomeBodyIntro => @@ -1749,7 +1765,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get bootstrapTourHomeBodyWarning => - '(请注意执行此操作会永久改变varspath内的*.var包组织结构)'; + '(请注意执行此操作会永久改变 varspath 内的 *.var 包组织结构:将按作者文件夹整理并集中到 ___VarTidied___ 中。)'; @override String get bootstrapTourScenesTitle => 'Scenes:三列拖拽'; diff --git a/varmanager_flutter/lib/l10n/app_zh.arb b/varmanager_flutter/lib/l10n/app_zh.arb index be84545..c2d3ef2 100644 --- a/varmanager_flutter/lib/l10n/app_zh.arb +++ b/varmanager_flutter/lib/l10n/app_zh.arb @@ -384,6 +384,7 @@ "dependenciesCount": "依赖({count})", "noDependencies": "无依赖", "prepareSavesTitle": "准备存档", + "prepareSavesAnalyzeInstallTooltip": "分析存档依赖并安装缺失项(创建链接)。", "outputFolderLabel": "输出文件夹", "outputFolderReady": "输出文件夹可用。", "outputFolderValidationFailed": "输出文件夹验证失败。", @@ -528,15 +529,19 @@ "bootstrapCheckDownloaderHint": "请选择可写的下载目录。", "bootstrapCheckFileOpsHint": "可能原因:目录只读、权限不足或文件被占用。", "bootstrapCheckSymlinkHint": "可能原因:需要管理员/开发者模式、文件系统不支持软链接或磁盘只读。", + "bootstrapCheckSymlinkHintFsUnsupported": "该文件系统不支持软链接(exFAT/FAT32),请使用 NTFS 磁盘。", + "bootstrapCheckSymlinkHintReadOnly": "磁盘为只读,无法创建软链接。", + "bootstrapCheckSymlinkHintDeveloperMode": "未开启开发者模式,开启后可创建软链接。", + "bootstrapCheckSymlinkActionOpenDevSettings": "打开开发者模式设置", "bootstrapCheckVamExecHint": "请在设置中填写正确的 VaM 启动脚本。", "bootstrapCheckStatusPass": "通过", "bootstrapCheckStatusWarn": "警告", "bootstrapCheckStatusFail": "失败", "bootstrapCheckStatusPending": "等待", "bootstrapTourHomeTitle": "主页:筛选 + PackSwitch", - "bootstrapTourHomeBody": "更新数据库后,使用高级筛选和批量操作管理 VAR,右侧 PackSwitch 快速切换配置。(如果你是varManager新用户, 请注意执行此操作会永久改变varspath内的*.var包组织结构)", + "bootstrapTourHomeBody": "更新数据库后,使用高级筛选和批量操作管理 VAR,右侧 PackSwitch 快速切换配置。(请注意执行此操作会永久改变 varspath 内的 *.var 包组织结构:将按作者文件夹整理并集中到 ___VarTidied___ 中。)", "bootstrapTourHomeBodyIntro": "更新数据库后,使用高级筛选和批量操作管理 VAR,右侧 PackSwitch 快速切换配置。", - "bootstrapTourHomeBodyWarning": "(请注意执行此操作会永久改变varspath内的*.var包组织结构)", + "bootstrapTourHomeBodyWarning": "(请注意执行此操作会永久改变 varspath 内的 *.var 包组织结构:将按作者文件夹整理并集中到 ___VarTidied___ 中。)", "bootstrapTourScenesTitle": "Scenes:三列拖拽", "bootstrapTourScenesBody": "Hide/Normal/Fav 三列拖拽整理,支持位置筛选和清理缓存。", "bootstrapTourHubTagsTitle": "Hub:标签与快捷筛选",