From 02520ca62ac5e508e8748b2445171be64f459b6c Mon Sep 17 00:00:00 2001
From: HsiangNianian <i@jyunko.cn>
Date: Sun, 18 Jan 2026 13:34:52 +0800
Subject: fix(ci): improve pre-commit fmt hook configuration

- Add pass_filenames: false to fmt hook
- Add -- separator for cargo fmt args
- Manually format code with cargo fmt
---
 ui/src/components/SettingsView.svelte | 121 ++++++++++++++++++++++++++++++++++
 1 file changed, 121 insertions(+)

(limited to 'ui/src/components/SettingsView.svelte')

diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte
index 4de18b3..0e89e25 100644
--- a/ui/src/components/SettingsView.svelte
+++ b/ui/src/components/SettingsView.svelte
@@ -123,6 +123,15 @@
     settingsState.settings.custom_background_path = undefined;
     settingsState.saveSettings();
   }
+
+  async function runMigrationToSharedCaches() {
+    try {
+      await (await import("@tauri-apps/api/core")).invoke("migrate_shared_caches");
+      settingsState.loadSettings();
+    } catch (e) {
+      console.error("Migration failed:", e);
+    }
+  }
 </script>
 
 <div class="h-full flex flex-col p-6 overflow-hidden">
@@ -398,6 +407,118 @@
         </div>
     </div>
 
+    <!-- Storage & Caches -->
+    <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+      <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">Storage & Version Caches</h3>
+      <div class="space-y-4">
+        <div class="flex items-center justify-between">
+          <div>
+            <h4 class="text-sm font-medium text-white/90" id="shared-caches-label">Use Shared Caches</h4>
+            <p class="text-xs text-white/40 mt-1">Store versions/libraries/assets in a global cache shared by all instances.</p>
+          </div>
+          <button
+            aria-labelledby="shared-caches-label"
+            onclick={() => { settingsState.settings.use_shared_caches = !settingsState.settings.use_shared_caches; settingsState.saveSettings(); }}
+            class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.use_shared_caches ? 'bg-indigo-500' : 'bg-white/10'}"
+          >
+            <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.use_shared_caches ? 'translate-x-5' : 'translate-x-0'}"></div>
+          </button>
+        </div>
+
+        <div class="flex items-center justify-between">
+          <div>
+            <h4 class="text-sm font-medium text-white/90" id="legacy-storage-label">Keep Legacy Per-Instance Storage</h4>
+            <p class="text-xs text-white/40 mt-1">Do not migrate existing instance caches; keep current layout.</p>
+          </div>
+          <button
+            aria-labelledby="legacy-storage-label"
+            onclick={() => { settingsState.settings.keep_legacy_per_instance_storage = !settingsState.settings.keep_legacy_per_instance_storage; settingsState.saveSettings(); }}
+            class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.keep_legacy_per_instance_storage ? 'bg-indigo-500' : 'bg-white/10'}"
+          >
+            <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.keep_legacy_per_instance_storage ? 'translate-x-5' : 'translate-x-0'}"></div>
+          </button>
+        </div>
+
+        <div class="flex items-center justify-between pt-2 border-t border-white/10">
+          <div>
+            <h4 class="text-sm font-medium text-white/90">Run Migration</h4>
+            <p class="text-xs text-white/40 mt-1">Hard-link or copy existing per-instance caches into the shared cache.</p>
+          </div>
+          <button onclick={runMigrationToSharedCaches} class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm">Migrate Now</button>
+        </div>
+      </div>
+    </div>
+
+    <!-- Feature Flags -->
+    <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+      <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">Feature Flags (Launcher Arguments)</h3>
+      <div class="space-y-4">
+        <div class="flex items-center justify-between">
+          <div>
+            <h4 class="text-sm font-medium text-white/90" id="demo-user-label">Demo User</h4>
+            <p class="text-xs text-white/40 mt-1">Enable demo-related arguments when rules require them.</p>
+          </div>
+          <button
+            aria-labelledby="demo-user-label"
+            onclick={() => { settingsState.settings.feature_flags.demo_user = !settingsState.settings.feature_flags.demo_user; settingsState.saveSettings(); }}
+            class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.demo_user ? 'bg-indigo-500' : 'bg-white/10'}"
+          >
+            <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.feature_flags.demo_user ? 'translate-x-5' : 'translate-x-0'}"></div>
+          </button>
+        </div>
+
+        <div class="flex items-center justify-between">
+          <div>
+            <h4 class="text-sm font-medium text-white/90" id="quick-play-label">Quick Play</h4>
+            <p class="text-xs text-white/40 mt-1">Enable quick play singleplayer/multiplayer arguments.</p>
+          </div>
+          <button
+            aria-labelledby="quick-play-label"
+            onclick={() => { settingsState.settings.feature_flags.quick_play_enabled = !settingsState.settings.feature_flags.quick_play_enabled; settingsState.saveSettings(); }}
+            class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.quick_play_enabled ? 'bg-indigo-500' : 'bg-white/10'}"
+          >
+            <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.feature_flags.quick_play_enabled ? 'translate-x-5' : 'translate-x-0'}"></div>
+          </button>
+        </div>
+
+        {#if settingsState.settings.feature_flags.quick_play_enabled}
+          <div class="grid grid-cols-1 md:grid-cols-2 gap-4 pl-2 border-l-2 border-white/10">
+            <div>
+              <label class="block text-sm font-medium text-white/70 mb-2">Singleplayer World Path</label>
+              <input
+                type="text"
+                bind:value={settingsState.settings.feature_flags.quick_play_path}
+                placeholder="/path/to/saves/MyWorld"
+                class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
+              />
+            </div>
+            <div class="flex items-center justify-between">
+              <div>
+                <h4 class="text-sm font-medium text-white/90" id="qp-singleplayer-label">Prefer Singleplayer</h4>
+                <p class="text-xs text-white/40 mt-1">If enabled, use singleplayer quick play path.</p>
+              </div>
+              <button
+                aria-labelledby="qp-singleplayer-label"
+                onclick={() => { settingsState.settings.feature_flags.quick_play_singleplayer = !settingsState.settings.feature_flags.quick_play_singleplayer; settingsState.saveSettings(); }}
+                class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.feature_flags.quick_play_singleplayer ? 'bg-indigo-500' : 'bg-white/10'}"
+              >
+                <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.feature_flags.quick_play_singleplayer ? 'translate-x-5' : 'translate-x-0'}"></div>
+              </button>
+            </div>
+            <div>
+              <label class="block text-sm font-medium text-white/70 mb-2">Multiplayer Server Address</label>
+              <input
+                type="text"
+                bind:value={settingsState.settings.feature_flags.quick_play_multiplayer_server}
+                placeholder="example.org:25565"
+                class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
+              />
+            </div>
+          </div>
+        {/if}
+      </div>
+    </div>
+
     <!-- Debug / Logs -->
     <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
         <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
-- 
cgit v1.2.3-70-g09d2


From 17e8dd78ca5b7aae9baa4f86d38fa755c8af21c5 Mon Sep 17 00:00:00 2001
From: HsiangNianian <i@jyunko.cn>
Date: Sun, 18 Jan 2026 13:43:12 +0800
Subject: feat(migration): implement shared cache migration with SHA1 dedup

- Add migrate_to_shared_caches() with hard link preference
- SHA1-based deduplication across all instances
- Copy fallback for cross-filesystem scenarios
- Auto-enable use_shared_caches after successful migration
- UI shows statistics: moved files, hardlinks/copies, MB saved
---
 src-tauri/src/core/instance.rs        | 224 ++++++++++++++++++++++++++++++++++
 src-tauri/src/main.rs                 |  52 +++++++-
 ui/src/components/SettingsView.svelte |  31 ++++-
 3 files changed, 303 insertions(+), 4 deletions(-)

(limited to 'ui/src/components/SettingsView.svelte')

diff --git a/src-tauri/src/core/instance.rs b/src-tauri/src/core/instance.rs
index 738dbd8..183e1cc 100644
--- a/src-tauri/src/core/instance.rs
+++ b/src-tauri/src/core/instance.rs
@@ -6,6 +6,7 @@
 //! - Support for instance switching and isolation
 
 use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
 use std::fs;
 use std::path::{Path, PathBuf};
 use std::sync::Mutex;
@@ -344,3 +345,226 @@ pub fn migrate_legacy_data(
 
     Ok(())
 }
+
+/// Migrate instance caches to shared global caches
+///
+/// This function deduplicates versions, libraries, and assets from all instances
+/// into a global shared cache. It prefers hard links (instant, zero-copy) and
+/// falls back to copying if hard links are not supported.
+///
+/// # Arguments
+/// * `app_handle` - Tauri app handle
+/// * `instance_state` - Instance state management
+///
+/// # Returns
+/// * `Ok((moved_count, hardlink_count, copy_count, saved_bytes))` on success
+/// * `Err(String)` on failure
+pub fn migrate_to_shared_caches(
+    app_handle: &AppHandle,
+    instance_state: &InstanceState,
+) -> Result<(usize, usize, usize, u64), String> {
+    let app_dir = app_handle.path().app_data_dir().unwrap();
+
+    // Global shared cache directories
+    let global_versions = app_dir.join("versions");
+    let global_libraries = app_dir.join("libraries");
+    let global_assets = app_dir.join("assets");
+
+    // Create global cache directories
+    std::fs::create_dir_all(&global_versions).map_err(|e| e.to_string())?;
+    std::fs::create_dir_all(&global_libraries).map_err(|e| e.to_string())?;
+    std::fs::create_dir_all(&global_assets).map_err(|e| e.to_string())?;
+
+    let mut total_moved = 0;
+    let mut hardlink_count = 0;
+    let mut copy_count = 0;
+    let mut saved_bytes = 0u64;
+
+    // Get all instances
+    let instances = instance_state.list_instances();
+
+    for instance in instances {
+        let instance_versions = instance.game_dir.join("versions");
+        let instance_libraries = instance.game_dir.join("libraries");
+        let instance_assets = instance.game_dir.join("assets");
+
+        // Migrate versions
+        if instance_versions.exists() {
+            let (moved, hardlinks, copies, bytes) =
+                deduplicate_directory(&instance_versions, &global_versions)?;
+            total_moved += moved;
+            hardlink_count += hardlinks;
+            copy_count += copies;
+            saved_bytes += bytes;
+        }
+
+        // Migrate libraries
+        if instance_libraries.exists() {
+            let (moved, hardlinks, copies, bytes) =
+                deduplicate_directory(&instance_libraries, &global_libraries)?;
+            total_moved += moved;
+            hardlink_count += hardlinks;
+            copy_count += copies;
+            saved_bytes += bytes;
+        }
+
+        // Migrate assets
+        if instance_assets.exists() {
+            let (moved, hardlinks, copies, bytes) =
+                deduplicate_directory(&instance_assets, &global_assets)?;
+            total_moved += moved;
+            hardlink_count += hardlinks;
+            copy_count += copies;
+            saved_bytes += bytes;
+        }
+    }
+
+    Ok((total_moved, hardlink_count, copy_count, saved_bytes))
+}
+
+/// Deduplicate a directory tree into a global cache
+///
+/// Recursively processes all files, checking SHA1 hashes for deduplication.
+/// Returns (total_moved, hardlink_count, copy_count, saved_bytes)
+fn deduplicate_directory(
+    source_dir: &Path,
+    dest_dir: &Path,
+) -> Result<(usize, usize, usize, u64), String> {
+    let mut moved = 0;
+    let mut hardlinks = 0;
+    let mut copies = 0;
+    let mut saved_bytes = 0u64;
+
+    // Build a hash map of existing files in dest (hash -> path)
+    let mut dest_hashes: HashMap<String, PathBuf> = HashMap::new();
+    if dest_dir.exists() {
+        index_directory_hashes(dest_dir, dest_dir, &mut dest_hashes)?;
+    }
+
+    // Process source directory
+    process_directory_for_migration(
+        source_dir,
+        source_dir,
+        dest_dir,
+        &dest_hashes,
+        &mut moved,
+        &mut hardlinks,
+        &mut copies,
+        &mut saved_bytes,
+    )?;
+
+    Ok((moved, hardlinks, copies, saved_bytes))
+}
+
+/// Index all files in a directory by their SHA1 hash
+fn index_directory_hashes(
+    dir: &Path,
+    base: &Path,
+    hashes: &mut HashMap<String, PathBuf>,
+) -> Result<(), String> {
+    if !dir.is_dir() {
+        return Ok(());
+    }
+
+    for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
+        let entry = entry.map_err(|e| e.to_string())?;
+        let path = entry.path();
+
+        if path.is_dir() {
+            index_directory_hashes(&path, base, hashes)?;
+        } else if path.is_file() {
+            let hash = compute_file_sha1(&path)?;
+            hashes.insert(hash, path);
+        }
+    }
+
+    Ok(())
+}
+
+/// Process directory for migration (recursive)
+fn process_directory_for_migration(
+    current: &Path,
+    source_base: &Path,
+    dest_base: &Path,
+    dest_hashes: &HashMap<String, PathBuf>,
+    moved: &mut usize,
+    hardlinks: &mut usize,
+    copies: &mut usize,
+    saved_bytes: &mut u64,
+) -> Result<(), String> {
+    if !current.is_dir() {
+        return Ok(());
+    }
+
+    for entry in std::fs::read_dir(current).map_err(|e| e.to_string())? {
+        let entry = entry.map_err(|e| e.to_string())?;
+        let source_path = entry.path();
+
+        // Compute relative path
+        let rel_path = source_path
+            .strip_prefix(source_base)
+            .map_err(|e| e.to_string())?;
+        let dest_path = dest_base.join(rel_path);
+
+        if source_path.is_dir() {
+            // Recurse into subdirectory
+            process_directory_for_migration(
+                &source_path,
+                source_base,
+                dest_base,
+                dest_hashes,
+                moved,
+                hardlinks,
+                copies,
+                saved_bytes,
+            )?;
+        } else if source_path.is_file() {
+            let file_size = std::fs::metadata(&source_path)
+                .map(|m| m.len())
+                .unwrap_or(0);
+
+            // Compute file hash
+            let source_hash = compute_file_sha1(&source_path)?;
+
+            // Check if file already exists in dest with same hash
+            if let Some(_existing) = dest_hashes.get(&source_hash) {
+                // File exists, delete source (already deduplicated)
+                std::fs::remove_file(&source_path).map_err(|e| e.to_string())?;
+                *saved_bytes += file_size;
+                *moved += 1;
+            } else {
+                // File doesn't exist, move it
+                // Create parent directory in dest
+                if let Some(parent) = dest_path.parent() {
+                    std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
+                }
+
+                // Try hard link first
+                if std::fs::hard_link(&source_path, &dest_path).is_ok() {
+                    // Hard link succeeded, remove source
+                    std::fs::remove_file(&source_path).map_err(|e| e.to_string())?;
+                    *hardlinks += 1;
+                    *moved += 1;
+                } else {
+                    // Hard link failed (different filesystem?), copy instead
+                    std::fs::copy(&source_path, &dest_path).map_err(|e| e.to_string())?;
+                    std::fs::remove_file(&source_path).map_err(|e| e.to_string())?;
+                    *copies += 1;
+                    *moved += 1;
+                }
+            }
+        }
+    }
+
+    Ok(())
+}
+
+/// Compute SHA1 hash of a file
+fn compute_file_sha1(path: &Path) -> Result<String, String> {
+    use sha1::{Digest, Sha1};
+
+    let data = std::fs::read(path).map_err(|e| e.to_string())?;
+    let mut hasher = Sha1::new();
+    hasher.update(&data);
+    Ok(hex::encode(hasher.finalize()))
+}
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index 6a230c9..a506713 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -2373,6 +2373,55 @@ async fn assistant_chat_stream(
         .await
 }
 
+/// Migrate instance caches to shared global caches
+#[derive(Serialize)]
+struct MigrationResult {
+    moved_files: usize,
+    hardlinks: usize,
+    copies: usize,
+    saved_bytes: u64,
+    saved_mb: f64,
+}
+
+#[tauri::command]
+async fn migrate_shared_caches(
+    window: Window,
+    instance_state: State<'_, core::instance::InstanceState>,
+    config_state: State<'_, core::config::ConfigState>,
+) -> Result<MigrationResult, String> {
+    emit_log!(window, "Starting migration to shared caches...".to_string());
+
+    let app_handle = window.app_handle();
+    let (moved, hardlinks, copies, saved_bytes) =
+        core::instance::migrate_to_shared_caches(app_handle, &instance_state)?;
+
+    let saved_mb = saved_bytes as f64 / (1024.0 * 1024.0);
+
+    emit_log!(
+        window,
+        format!(
+            "Migration complete: {} files moved ({} hardlinks, {} copies), {:.2} MB saved",
+            moved, hardlinks, copies, saved_mb
+        )
+    );
+
+    // Automatically enable shared caches config
+    let mut config = config_state.config.lock().unwrap().clone();
+    config.use_shared_caches = true;
+    drop(config);
+    *config_state.config.lock().unwrap() = config_state.config.lock().unwrap().clone();
+    config_state.config.lock().unwrap().use_shared_caches = true;
+    config_state.save()?;
+
+    Ok(MigrationResult {
+        moved_files: moved,
+        hardlinks,
+        copies,
+        saved_bytes,
+        saved_mb,
+    })
+}
+
 fn main() {
     tauri::Builder::default()
         .plugin(tauri_plugin_fs::init())
@@ -2479,7 +2528,8 @@ fn main() {
             get_instance,
             set_active_instance,
             get_active_instance,
-            duplicate_instance
+            duplicate_instance,
+            migrate_shared_caches
         ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");
diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte
index 0e89e25..0020506 100644
--- a/ui/src/components/SettingsView.svelte
+++ b/ui/src/components/SettingsView.svelte
@@ -124,12 +124,31 @@
     settingsState.saveSettings();
   }
 
+  let migrating = $state(false);
   async function runMigrationToSharedCaches() {
+    if (migrating) return;
+    migrating = true;
     try {
-      await (await import("@tauri-apps/api/core")).invoke("migrate_shared_caches");
-      settingsState.loadSettings();
+      const { invoke } = await import("@tauri-apps/api/core");
+      const result = await invoke<{
+        moved_files: number;
+        hardlinks: number;
+        copies: number;
+        saved_mb: number;
+      }>("migrate_shared_caches");
+      
+      // Reload settings to reflect changes
+      await settingsState.loadSettings();
+      
+      // Show success message
+      const msg = `Migration complete! ${result.moved_files} files (${result.hardlinks} hardlinks, ${result.copies} copies), ${result.saved_mb.toFixed(2)} MB saved.`;
+      console.log(msg);
+      alert(msg);
     } catch (e) {
       console.error("Migration failed:", e);
+      alert(`Migration failed: ${e}`);
+    } finally {
+      migrating = false;
     }
   }
 </script>
@@ -444,7 +463,13 @@
             <h4 class="text-sm font-medium text-white/90">Run Migration</h4>
             <p class="text-xs text-white/40 mt-1">Hard-link or copy existing per-instance caches into the shared cache.</p>
           </div>
-          <button onclick={runMigrationToSharedCaches} class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm">Migrate Now</button>
+          <button 
+            onclick={runMigrationToSharedCaches} 
+            disabled={migrating}
+            class="px-4 py-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            {migrating ? "Migrating..." : "Migrate Now"}
+          </button>
         </div>
       </div>
     </div>
-- 
cgit v1.2.3-70-g09d2

