From 32a4d85af937e4fd882fa671aee8b72878cc564f Mon Sep 17 00:00:00 2001 From: 苏向夜 Date: Sun, 29 Mar 2026 00:58:15 +0800 Subject: feat(ui): remove all stores --- packages/ui/src/stores/assistant-store.ts | 201 ----------- packages/ui/src/stores/auth-store.ts | 296 ---------------- packages/ui/src/stores/logs-store.ts | 200 ----------- packages/ui/src/stores/releases-store.ts | 63 ---- packages/ui/src/stores/settings-store.ts | 568 ------------------------------ packages/ui/src/stores/ui-store.ts | 42 --- 6 files changed, 1370 deletions(-) delete mode 100644 packages/ui/src/stores/assistant-store.ts delete mode 100644 packages/ui/src/stores/auth-store.ts delete mode 100644 packages/ui/src/stores/logs-store.ts delete mode 100644 packages/ui/src/stores/releases-store.ts delete mode 100644 packages/ui/src/stores/settings-store.ts delete mode 100644 packages/ui/src/stores/ui-store.ts (limited to 'packages/ui/src/stores') diff --git a/packages/ui/src/stores/assistant-store.ts b/packages/ui/src/stores/assistant-store.ts deleted file mode 100644 index 180031b..0000000 --- a/packages/ui/src/stores/assistant-store.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import { create } from "zustand"; -import type { GenerationStats, StreamChunk } from "@/types/bindings/assistant"; - -export interface Message { - role: "user" | "assistant" | "system"; - content: string; - stats?: GenerationStats; -} - -interface AssistantState { - // State - messages: Message[]; - isProcessing: boolean; - isProviderHealthy: boolean | undefined; - streamingContent: string; - initialized: boolean; - streamUnlisten: UnlistenFn | null; - - // Actions - init: () => Promise; - checkHealth: () => Promise; - sendMessage: ( - content: string, - isEnabled: boolean, - provider: string, - endpoint: string, - ) => Promise; - finishStreaming: () => void; - clearHistory: () => void; - setMessages: (messages: Message[]) => void; - setIsProcessing: (isProcessing: boolean) => void; - setIsProviderHealthy: (isProviderHealthy: boolean | undefined) => void; - setStreamingContent: (streamingContent: string) => void; -} - -export const useAssistantStore = create((set, get) => ({ - // Initial state - messages: [], - isProcessing: false, - isProviderHealthy: false, - streamingContent: "", - initialized: false, - streamUnlisten: null, - - // Actions - init: async () => { - const { initialized } = get(); - if (initialized) return; - set({ initialized: true }); - await get().checkHealth(); - }, - - checkHealth: async () => { - try { - const isHealthy = await invoke("assistant_check_health"); - set({ isProviderHealthy: isHealthy }); - } catch (e) { - console.error("Failed to check provider health:", e); - set({ isProviderHealthy: false }); - } - }, - - finishStreaming: () => { - const { streamUnlisten } = get(); - set({ isProcessing: false, streamingContent: "" }); - - if (streamUnlisten) { - streamUnlisten(); - set({ streamUnlisten: null }); - } - }, - - sendMessage: async (content, isEnabled, provider, endpoint) => { - if (!content.trim()) return; - - const { messages } = get(); - - if (!isEnabled) { - const newMessage: Message = { - role: "assistant", - content: "Assistant is disabled. Enable it in Settings > AI Assistant.", - }; - set({ messages: [...messages, { role: "user", content }, newMessage] }); - return; - } - - // Add user message - const userMessage: Message = { role: "user", content }; - const updatedMessages = [...messages, userMessage]; - set({ - messages: updatedMessages, - isProcessing: true, - streamingContent: "", - }); - - // Add empty assistant message for streaming - const assistantMessage: Message = { role: "assistant", content: "" }; - const withAssistantMessage = [...updatedMessages, assistantMessage]; - set({ messages: withAssistantMessage }); - - try { - // Set up stream listener - const unlisten = await listen( - "assistant-stream", - (event) => { - const chunk = event.payload; - const currentState = get(); - - if (chunk.content) { - const newStreamingContent = - currentState.streamingContent + chunk.content; - const currentMessages = [...currentState.messages]; - const lastIdx = currentMessages.length - 1; - - if (lastIdx >= 0 && currentMessages[lastIdx].role === "assistant") { - currentMessages[lastIdx] = { - ...currentMessages[lastIdx], - content: newStreamingContent, - }; - set({ - streamingContent: newStreamingContent, - messages: currentMessages, - }); - } - } - - if (chunk.done) { - const finalMessages = [...currentState.messages]; - const lastIdx = finalMessages.length - 1; - - if ( - chunk.stats && - lastIdx >= 0 && - finalMessages[lastIdx].role === "assistant" - ) { - finalMessages[lastIdx] = { - ...finalMessages[lastIdx], - stats: chunk.stats, - }; - set({ messages: finalMessages }); - } - - get().finishStreaming(); - } - }, - ); - - set({ streamUnlisten: unlisten }); - - // Start streaming chat - await invoke("assistant_chat_stream", { - messages: withAssistantMessage.slice(0, -1), // Exclude the empty assistant message - }); - } catch (e) { - console.error("Failed to send message:", e); - const errorMessage = e instanceof Error ? e.message : String(e); - - let helpText = ""; - if (provider === "ollama") { - helpText = `\n\nPlease ensure Ollama is running at ${endpoint}.`; - } else if (provider === "openai") { - helpText = "\n\nPlease check your OpenAI API key in Settings."; - } - - // Update the last message with error - const currentMessages = [...get().messages]; - const lastIdx = currentMessages.length - 1; - if (lastIdx >= 0 && currentMessages[lastIdx].role === "assistant") { - currentMessages[lastIdx] = { - role: "assistant", - content: `Error: ${errorMessage}${helpText}`, - }; - set({ messages: currentMessages }); - } - - get().finishStreaming(); - } - }, - - clearHistory: () => { - set({ messages: [], streamingContent: "" }); - }, - - setMessages: (messages) => { - set({ messages }); - }, - - setIsProcessing: (isProcessing) => { - set({ isProcessing }); - }, - - setIsProviderHealthy: (isProviderHealthy) => { - set({ isProviderHealthy }); - }, - - setStreamingContent: (streamingContent) => { - set({ streamingContent }); - }, -})); diff --git a/packages/ui/src/stores/auth-store.ts b/packages/ui/src/stores/auth-store.ts deleted file mode 100644 index bf7e3c5..0000000 --- a/packages/ui/src/stores/auth-store.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import { open } from "@tauri-apps/plugin-shell"; -import { toast } from "sonner"; -import { create } from "zustand"; -import type { Account, DeviceCodeResponse } from "../types/bindings/auth"; - -interface AuthState { - // State - currentAccount: Account | null; - isLoginModalOpen: boolean; - isLogoutConfirmOpen: boolean; - loginMode: "select" | "offline" | "microsoft"; - offlineUsername: string; - deviceCodeData: DeviceCodeResponse | null; - msLoginLoading: boolean; - msLoginStatus: string; - - // Private state - pollInterval: ReturnType | null; - isPollingRequestActive: boolean; - authProgressUnlisten: UnlistenFn | null; - - // Actions - checkAccount: () => Promise; - openLoginModal: () => void; - openLogoutConfirm: () => void; - cancelLogout: () => void; - confirmLogout: () => Promise; - closeLoginModal: () => void; - resetLoginState: () => void; - performOfflineLogin: () => Promise; - startMicrosoftLogin: () => Promise; - checkLoginStatus: (deviceCode: string) => Promise; - stopPolling: () => void; - cancelMicrosoftLogin: () => void; - setLoginMode: (mode: "select" | "offline" | "microsoft") => void; - setOfflineUsername: (username: string) => void; -} - -export const useAuthStore = create((set, get) => ({ - // Initial state - currentAccount: null, - isLoginModalOpen: false, - isLogoutConfirmOpen: false, - loginMode: "select", - offlineUsername: "", - deviceCodeData: null, - msLoginLoading: false, - msLoginStatus: "Waiting for authorization...", - - // Private state - pollInterval: null, - isPollingRequestActive: false, - authProgressUnlisten: null, - - // Actions - checkAccount: async () => { - try { - const acc = await invoke("get_active_account"); - set({ currentAccount: acc }); - } catch (error) { - console.error("Failed to check account:", error); - } - }, - - openLoginModal: () => { - const { currentAccount } = get(); - if (currentAccount) { - // Show custom logout confirmation dialog - set({ isLogoutConfirmOpen: true }); - return; - } - get().resetLoginState(); - set({ isLoginModalOpen: true }); - }, - - openLogoutConfirm: () => { - set({ isLogoutConfirmOpen: true }); - }, - - cancelLogout: () => { - set({ isLogoutConfirmOpen: false }); - }, - - confirmLogout: async () => { - set({ isLogoutConfirmOpen: false }); - try { - await invoke("logout"); - set({ currentAccount: null }); - } catch (error) { - console.error("Logout failed:", error); - } - }, - - closeLoginModal: () => { - get().stopPolling(); - set({ isLoginModalOpen: false }); - }, - - resetLoginState: () => { - set({ - loginMode: "select", - offlineUsername: "", - deviceCodeData: null, - msLoginLoading: false, - msLoginStatus: "Waiting for authorization...", - }); - }, - - performOfflineLogin: async () => { - const { offlineUsername } = get(); - if (!offlineUsername.trim()) return; - - try { - const account = await invoke("login_offline", { - username: offlineUsername, - }); - set({ - currentAccount: account, - isLoginModalOpen: false, - offlineUsername: "", - }); - } catch (error) { - // Keep UI-friendly behavior consistent with prior code - alert("Login failed: " + String(error)); - } - }, - - startMicrosoftLogin: async () => { - // Prepare UI state - set({ - msLoginLoading: true, - msLoginStatus: "Waiting for authorization...", - loginMode: "microsoft", - deviceCodeData: null, - }); - - // Listen to general launcher logs so we can display progress to the user. - // The backend emits logs via "launcher-log"; using that keeps this store decoupled - // from a dedicated auth event channel (backend may reuse launcher-log). - try { - const unlisten = await listen("launcher-log", (event) => { - const payload = event.payload; - // Normalize payload to string if possible - const message = - typeof payload === "string" - ? payload - : (payload?.toString?.() ?? JSON.stringify(payload)); - set({ msLoginStatus: message }); - }); - set({ authProgressUnlisten: unlisten }); - } catch (err) { - console.warn("Failed to attach launcher-log listener:", err); - } - - try { - const deviceCodeData = await invoke( - "start_microsoft_login", - ); - set({ deviceCodeData }); - - if (deviceCodeData) { - // Try to copy user code to clipboard for convenience (best-effort) - try { - await navigator.clipboard?.writeText(deviceCodeData.userCode ?? ""); - } catch (err) { - // ignore clipboard errors - console.debug("Clipboard copy failed:", err); - } - - // Open verification URI in default browser - try { - if (deviceCodeData.verificationUri) { - await open(deviceCodeData.verificationUri); - } - } catch (err) { - console.debug("Failed to open verification URI:", err); - } - - // Start polling for completion - // `interval` from the bindings is a bigint (seconds). Convert safely to number. - const intervalSeconds = - deviceCodeData.interval !== undefined && - deviceCodeData.interval !== null - ? Number(deviceCodeData.interval) - : 5; - const intervalMs = intervalSeconds * 1000; - const pollInterval = setInterval( - () => get().checkLoginStatus(deviceCodeData.deviceCode), - intervalMs, - ); - set({ pollInterval }); - } - } catch (error) { - toast.error(`Failed to start Microsoft login: ${error}`); - set({ loginMode: "select" }); - // cleanup listener if present - const { authProgressUnlisten } = get(); - if (authProgressUnlisten) { - authProgressUnlisten(); - set({ authProgressUnlisten: null }); - } - } finally { - set({ msLoginLoading: false }); - } - }, - - checkLoginStatus: async (deviceCode: string) => { - const { isPollingRequestActive } = get(); - if (isPollingRequestActive) return; - - set({ isPollingRequestActive: true }); - - try { - const account = await invoke("complete_microsoft_login", { - deviceCode, - }); - - // On success, stop polling and cleanup listener - get().stopPolling(); - const { authProgressUnlisten } = get(); - if (authProgressUnlisten) { - authProgressUnlisten(); - set({ authProgressUnlisten: null }); - } - - set({ - currentAccount: account, - isLoginModalOpen: false, - }); - } catch (error: unknown) { - const errStr = String(error); - if (errStr.includes("authorization_pending")) { - // Still waiting — keep polling - } else { - set({ msLoginStatus: "Error: " + errStr }); - - if ( - errStr.includes("expired_token") || - errStr.includes("access_denied") - ) { - // Terminal errors — stop polling and reset state - get().stopPolling(); - const { authProgressUnlisten } = get(); - if (authProgressUnlisten) { - authProgressUnlisten(); - set({ authProgressUnlisten: null }); - } - alert("Login failed: " + errStr); - set({ loginMode: "select" }); - } - } - } finally { - set({ isPollingRequestActive: false }); - } - }, - - stopPolling: () => { - const { pollInterval, authProgressUnlisten } = get(); - if (pollInterval) { - try { - clearInterval(pollInterval); - } catch (err) { - console.debug("Failed to clear poll interval:", err); - } - set({ pollInterval: null }); - } - if (authProgressUnlisten) { - try { - authProgressUnlisten(); - } catch (err) { - console.debug("Failed to unlisten auth progress:", err); - } - set({ authProgressUnlisten: null }); - } - }, - - cancelMicrosoftLogin: () => { - get().stopPolling(); - set({ - deviceCodeData: null, - msLoginLoading: false, - msLoginStatus: "", - loginMode: "select", - }); - }, - - setLoginMode: (mode: "select" | "offline" | "microsoft") => { - set({ loginMode: mode }); - }, - - setOfflineUsername: (username: string) => { - set({ offlineUsername: username }); - }, -})); diff --git a/packages/ui/src/stores/logs-store.ts b/packages/ui/src/stores/logs-store.ts deleted file mode 100644 index b19f206..0000000 --- a/packages/ui/src/stores/logs-store.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { listen } from "@tauri-apps/api/event"; -import { create } from "zustand"; - -export interface LogEntry { - id: number; - timestamp: string; - level: "info" | "warn" | "error" | "debug" | "fatal"; - source: string; - message: string; -} - -// Parse Minecraft/Java log format: [HH:MM:SS] [Thread/LEVEL]: message -// or: [HH:MM:SS] [Thread/LEVEL] [Source]: message -const GAME_LOG_REGEX = - /^\[[\d:]+\]\s*\[([^\]]+)\/(\w+)\](?:\s*\[([^\]]+)\])?:\s*(.*)$/; - -function parseGameLogLevel(levelStr: string): LogEntry["level"] { - const upper = levelStr.toUpperCase(); - if (upper === "INFO") return "info"; - if (upper === "WARN" || upper === "WARNING") return "warn"; - if (upper === "ERROR" || upper === "SEVERE") return "error"; - if ( - upper === "DEBUG" || - upper === "TRACE" || - upper === "FINE" || - upper === "FINER" || - upper === "FINEST" - ) - return "debug"; - if (upper === "FATAL") return "fatal"; - return "info"; -} - -interface LogsState { - // State - logs: LogEntry[]; - sources: Set; - nextId: number; - maxLogs: number; - initialized: boolean; - - // Actions - addLog: (level: LogEntry["level"], source: string, message: string) => void; - addGameLog: (rawLine: string, isStderr: boolean) => void; - clear: () => void; - exportLogs: (filteredLogs: LogEntry[]) => string; - init: () => Promise; - setLogs: (logs: LogEntry[]) => void; - setSources: (sources: Set) => void; -} - -export const useLogsStore = create((set, get) => ({ - // Initial state - logs: [], - sources: new Set(["Launcher"]), - nextId: 0, - maxLogs: 5000, - initialized: false, - - // Actions - addLog: (level, source, message) => { - const { nextId, logs, maxLogs, sources } = get(); - const now = new Date(); - const timestamp = - now.toLocaleTimeString() + - "." + - now.getMilliseconds().toString().padStart(3, "0"); - - const newLog: LogEntry = { - id: nextId, - timestamp, - level, - source, - message, - }; - - const newLogs = [...logs, newLog]; - const newSources = new Set(sources); - - // Track source - if (!newSources.has(source)) { - newSources.add(source); - } - - // Trim logs if exceeding max - const trimmedLogs = - newLogs.length > maxLogs ? newLogs.slice(-maxLogs) : newLogs; - - set({ - logs: trimmedLogs, - sources: newSources, - nextId: nextId + 1, - }); - }, - - addGameLog: (rawLine, isStderr) => { - const match = rawLine.match(GAME_LOG_REGEX); - - if (match) { - const [, thread, levelStr, extraSource, message] = match; - const level = parseGameLogLevel(levelStr); - // Use extraSource if available, otherwise use thread name as source hint - const source = extraSource || `Game/${thread.split("-")[0]}`; - get().addLog(level, source, message); - } else { - // Fallback: couldn't parse, use stderr as error indicator - const level = isStderr ? "error" : "info"; - get().addLog(level, "Game", rawLine); - } - }, - - clear: () => { - set({ - logs: [], - sources: new Set(["Launcher"]), - }); - get().addLog("info", "Launcher", "Logs cleared"); - }, - - exportLogs: (filteredLogs) => { - return filteredLogs - .map( - (l) => - `[${l.timestamp}] [${l.source}/${l.level.toUpperCase()}] ${l.message}`, - ) - .join("\n"); - }, - - init: async () => { - const { initialized } = get(); - if (initialized) return; - - set({ initialized: true }); - - // Initial log - get().addLog("info", "Launcher", "Logs initialized"); - - // General Launcher Logs - await listen("launcher-log", (e) => { - get().addLog("info", "Launcher", e.payload); - }); - - // Game Stdout - parse log level - await listen("game-stdout", (e) => { - get().addGameLog(e.payload, false); - }); - - // Game Stderr - parse log level, default to error - await listen("game-stderr", (e) => { - get().addGameLog(e.payload, true); - }); - - // Download Events (Summarized) - await listen("download-start", (e: any) => { - get().addLog( - "info", - "Downloader", - `Starting batch download of ${e.payload} files...`, - ); - }); - - await listen("download-complete", () => { - get().addLog("info", "Downloader", "All downloads completed."); - }); - - // Listen to file download progress to log finished files - await listen("download-progress", (e) => { - const p = e.payload; - if (p.status === "Finished") { - if (p.file.endsWith(".jar")) { - get().addLog("info", "Downloader", `Downloaded ${p.file}`); - } - } - }); - - // Java Download - await listen("java-download-progress", (e) => { - const p = e.payload; - if (p.status === "Downloading" && p.percentage === 0) { - get().addLog( - "info", - "JavaInstaller", - `Downloading Java: ${p.file_name}`, - ); - } else if (p.status === "Completed") { - get().addLog("info", "JavaInstaller", `Java installed: ${p.file_name}`); - } else if (p.status === "Error") { - get().addLog("error", "JavaInstaller", `Java download error`); - } - }); - }, - - setLogs: (logs) => { - set({ logs }); - }, - - setSources: (sources) => { - set({ sources }); - }, -})); diff --git a/packages/ui/src/stores/releases-store.ts b/packages/ui/src/stores/releases-store.ts deleted file mode 100644 index 56afa08..0000000 --- a/packages/ui/src/stores/releases-store.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { invoke } from "@tauri-apps/api/core"; -import { create } from "zustand"; -import type { GithubRelease } from "@/types/bindings/core"; - -interface ReleasesState { - // State - releases: GithubRelease[]; - isLoading: boolean; - isLoaded: boolean; - error: string | null; - - // Actions - loadReleases: () => Promise; - setReleases: (releases: GithubRelease[]) => void; - setIsLoading: (isLoading: boolean) => void; - setIsLoaded: (isLoaded: boolean) => void; - setError: (error: string | null) => void; -} - -export const useReleasesStore = create((set, get) => ({ - // Initial state - releases: [], - isLoading: false, - isLoaded: false, - error: null, - - // Actions - loadReleases: async () => { - const { isLoaded, isLoading } = get(); - - // If already loaded or currently loading, skip to prevent duplicate requests - if (isLoaded || isLoading) return; - - set({ isLoading: true, error: null }); - - try { - const releases = await invoke("get_github_releases"); - set({ releases, isLoaded: true }); - } catch (e) { - const error = e instanceof Error ? e.message : String(e); - console.error("Failed to load releases:", e); - set({ error }); - } finally { - set({ isLoading: false }); - } - }, - - setReleases: (releases) => { - set({ releases }); - }, - - setIsLoading: (isLoading) => { - set({ isLoading }); - }, - - setIsLoaded: (isLoaded) => { - set({ isLoaded }); - }, - - setError: (error) => { - set({ error }); - }, -})); diff --git a/packages/ui/src/stores/settings-store.ts b/packages/ui/src/stores/settings-store.ts deleted file mode 100644 index 0bfc1e1..0000000 --- a/packages/ui/src/stores/settings-store.ts +++ /dev/null @@ -1,568 +0,0 @@ -import { convertFileSrc, invoke } from "@tauri-apps/api/core"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import { toast } from "sonner"; -import { create } from "zustand"; -import { downloadAdoptiumJava } from "@/client"; -import type { ModelInfo } from "../types/bindings/assistant"; -import type { LauncherConfig } from "../types/bindings/config"; -import type { - JavaDownloadProgress, - PendingJavaDownload, -} from "../types/bindings/downloader"; -import type { - JavaCatalog, - JavaInstallation, - JavaReleaseInfo, -} from "../types/bindings/java"; - -type JavaDownloadSource = "adoptium" | "mojang" | "azul"; - -/** - * State shape for settings store. - * - * Note: Uses camelCase naming to match ts-rs generated bindings (which now use - * `serde(rename_all = "camelCase")`). When reading raw binding objects from - * invoke, convert/mapping should be applied where necessary. - */ -interface SettingsState { - // State - settings: LauncherConfig; - javaInstallations: JavaInstallation[]; - isDetectingJava: boolean; - showJavaDownloadModal: boolean; - selectedDownloadSource: JavaDownloadSource; - javaCatalog: JavaCatalog | null; - isLoadingCatalog: boolean; - catalogError: string; - selectedMajorVersion: number | null; - selectedImageType: "jre" | "jdk"; - showOnlyRecommended: boolean; - searchQuery: string; - isDownloadingJava: boolean; - downloadProgress: JavaDownloadProgress | null; - javaDownloadStatus: string; - pendingDownloads: PendingJavaDownload[]; - ollamaModels: ModelInfo[]; - openaiModels: ModelInfo[]; - isLoadingOllamaModels: boolean; - isLoadingOpenaiModels: boolean; - ollamaModelsError: string; - openaiModelsError: string; - showConfigEditor: boolean; - rawConfigContent: string; - configFilePath: string; - configEditorError: string; - - // Computed / derived - backgroundUrl: string | undefined; - filteredReleases: JavaReleaseInfo[]; - availableMajorVersions: number[]; - installStatus: ( - version: number, - imageType: string, - ) => "installed" | "downloading" | "available"; - selectedRelease: JavaReleaseInfo | null; - currentModelOptions: Array<{ - value: string; - label: string; - details?: string; - }>; - - // Actions - loadSettings: () => Promise; - saveSettings: () => Promise; - // compatibility helper to mirror the older set({ key: value }) usage - set: (patch: Partial>) => void; - - detectJava: () => Promise; - selectJava: (path: string) => void; - - openJavaDownloadModal: () => Promise; - closeJavaDownloadModal: () => void; - loadJavaCatalog: (forceRefresh: boolean) => Promise; - refreshCatalog: () => Promise; - loadPendingDownloads: () => Promise; - selectMajorVersion: (version: number) => void; - downloadJava: () => Promise; - cancelDownload: () => Promise; - resumeDownloads: () => Promise; - - openConfigEditor: () => Promise; - closeConfigEditor: () => void; - saveRawConfig: () => Promise; - - loadOllamaModels: () => Promise; - loadOpenaiModels: () => Promise; - - setSetting: ( - key: K, - value: LauncherConfig[K], - ) => void; - setAssistantSetting: ( - key: K, - value: LauncherConfig["assistant"][K], - ) => void; - setFeatureFlag: ( - key: K, - value: LauncherConfig["featureFlags"][K], - ) => void; - - // Private - progressUnlisten: UnlistenFn | null; -} - -/** - * Default settings (camelCase) — lightweight defaults used until `get_settings` - * returns real values. - */ -const defaultSettings: LauncherConfig = { - minMemory: 1024, - maxMemory: 2048, - javaPath: "java", - width: 854, - height: 480, - downloadThreads: 32, - enableGpuAcceleration: false, - enableVisualEffects: true, - activeEffect: "constellation", - theme: "dark", - customBackgroundPath: null, - logUploadService: "paste.rs", - pastebinApiKey: null, - assistant: { - enabled: true, - llmProvider: "ollama", - ollamaEndpoint: "http://localhost:11434", - ollamaModel: "llama3", - openaiApiKey: null, - openaiEndpoint: "https://api.openai.com/v1", - openaiModel: "gpt-3.5-turbo", - systemPrompt: - "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.", - responseLanguage: "auto", - ttsEnabled: false, - ttsProvider: "disabled", - }, - useSharedCaches: false, - keepLegacyPerInstanceStorage: true, - featureFlags: { - demoUser: false, - quickPlayEnabled: false, - quickPlayPath: null, - quickPlaySingleplayer: true, - quickPlayMultiplayerServer: null, - }, -}; - -export const useSettingsStore = create((set, get) => ({ - // initial state - settings: defaultSettings, - javaInstallations: [], - isDetectingJava: false, - showJavaDownloadModal: false, - selectedDownloadSource: "adoptium", - javaCatalog: null, - isLoadingCatalog: false, - catalogError: "", - selectedMajorVersion: null, - selectedImageType: "jre", - showOnlyRecommended: true, - searchQuery: "", - isDownloadingJava: false, - downloadProgress: null, - javaDownloadStatus: "", - pendingDownloads: [], - ollamaModels: [], - openaiModels: [], - isLoadingOllamaModels: false, - isLoadingOpenaiModels: false, - ollamaModelsError: "", - openaiModelsError: "", - showConfigEditor: false, - rawConfigContent: "", - configFilePath: "", - configEditorError: "", - progressUnlisten: null, - - // derived getters - get backgroundUrl() { - const { settings } = get(); - if (settings.customBackgroundPath) { - return convertFileSrc(settings.customBackgroundPath); - } - return undefined; - }, - - get filteredReleases() { - const { - javaCatalog, - selectedMajorVersion, - selectedImageType, - showOnlyRecommended, - searchQuery, - } = get(); - - if (!javaCatalog) return []; - - let releases = javaCatalog.releases; - - if (selectedMajorVersion !== null) { - releases = releases.filter( - (r) => r.majorVersion === selectedMajorVersion, - ); - } - - releases = releases.filter((r) => r.imageType === selectedImageType); - - if (showOnlyRecommended) { - releases = releases.filter((r) => r.isLts); - } - - if (searchQuery.trim() !== "") { - const q = searchQuery.toLowerCase(); - releases = releases.filter( - (r) => - r.version.toLowerCase().includes(q) || - (r.releaseName ?? "").toLowerCase().includes(q), - ); - } - - // sort newest-first by parsed version number - return releases.sort((a, b) => { - const aVer = parseFloat(a.version.split("-")[0]); - const bVer = parseFloat(b.version.split("-")[0]); - return bVer - aVer; - }); - }, - - get availableMajorVersions() { - return get().javaCatalog?.availableMajorVersions || []; - }, - - installStatus: (version: number, imageType: string) => { - const { - javaInstallations, - pendingDownloads, - isDownloadingJava, - downloadProgress, - } = get(); - - const installed = javaInstallations.some( - (inst) => parseInt(inst.version.split(".")[0], 10) === version, - ); - if (installed) return "installed"; - - if ( - isDownloadingJava && - downloadProgress?.fileName?.includes(`${version}`) - ) { - return "downloading"; - } - - const pending = pendingDownloads.some( - (d) => d.majorVersion === version && d.imageType === imageType, - ); - if (pending) return "downloading"; - - return "available"; - }, - - get selectedRelease() { - const { javaCatalog, selectedMajorVersion, selectedImageType } = get(); - if (!javaCatalog || selectedMajorVersion === null) return null; - return ( - javaCatalog.releases.find( - (r) => - r.majorVersion === selectedMajorVersion && - r.imageType === selectedImageType, - ) || null - ); - }, - - get currentModelOptions() { - const { settings, ollamaModels, openaiModels } = get(); - const provider = settings.assistant.llmProvider; - if (provider === "ollama") { - return ollamaModels.map((m) => ({ - value: m.id, - label: m.name, - details: m.details || m.size || "", - })); - } else { - return openaiModels.map((m) => ({ - value: m.id, - label: m.name, - details: m.details || "", - })); - } - }, - - // actions - loadSettings: async () => { - try { - const result = await invoke("get_settings"); - // result already uses camelCase fields from bindings - set({ settings: result }); - - // enforce dark theme at app-level if necessary - if (result.theme !== "dark") { - const updated = { ...result, theme: "dark" } as LauncherConfig; - set({ settings: updated }); - await invoke("save_settings", { config: updated }); - } - - // ensure customBackgroundPath is undefined rather than null for reactiveness - if (!result.customBackgroundPath) { - set((s) => ({ - settings: { ...s.settings, customBackgroundPath: null }, - })); - } - } catch (e) { - console.error("Failed to load settings:", e); - } - }, - - saveSettings: async () => { - try { - const { settings } = get(); - - // Clean up empty strings to null where appropriate - if ((settings.customBackgroundPath ?? "") === "") { - set((state) => ({ - settings: { ...state.settings, customBackgroundPath: null }, - })); - } - - await invoke("save_settings", { config: settings }); - toast.success("Settings saved!"); - } catch (e) { - console.error("Failed to save settings:", e); - toast.error(`Error saving settings: ${String(e)}`); - } - }, - - set: (patch: Partial>) => { - set(patch); - }, - - detectJava: async () => { - set({ isDetectingJava: true }); - try { - const installs = await invoke("detect_java"); - set({ javaInstallations: installs }); - if (installs.length === 0) toast.info("No Java installations found"); - else toast.success(`Found ${installs.length} Java installation(s)`); - } catch (e) { - console.error("Failed to detect Java:", e); - toast.error(`Error detecting Java: ${String(e)}`); - } finally { - set({ isDetectingJava: false }); - } - }, - - selectJava: (path: string) => { - set((s) => ({ settings: { ...s.settings, javaPath: path } })); - }, - - openJavaDownloadModal: async () => { - set({ - showJavaDownloadModal: true, - javaDownloadStatus: "", - catalogError: "", - downloadProgress: null, - }); - - // attach event listener for download progress - const state = get(); - if (state.progressUnlisten) { - state.progressUnlisten(); - } - - const unlisten = await listen( - "java-download-progress", - (event) => { - set({ downloadProgress: event.payload }); - }, - ); - - set({ progressUnlisten: unlisten }); - - // load catalog and pending downloads - await get().loadJavaCatalog(false); - await get().loadPendingDownloads(); - }, - - closeJavaDownloadModal: () => { - const { isDownloadingJava, progressUnlisten } = get(); - - if (!isDownloadingJava) { - set({ showJavaDownloadModal: false }); - if (progressUnlisten) { - try { - progressUnlisten(); - } catch { - // ignore - } - set({ progressUnlisten: null }); - } - } - }, - - loadJavaCatalog: async (forceRefresh: boolean) => { - set({ isLoadingCatalog: true, catalogError: "" }); - try { - const cmd = forceRefresh ? "refresh_java_catalog" : "get_java_catalog"; - const result = await invoke(cmd); - set({ javaCatalog: result, isLoadingCatalog: false }); - } catch (e) { - console.error("Failed to load Java catalog:", e); - set({ catalogError: String(e), isLoadingCatalog: false }); - } - }, - - refreshCatalog: async () => { - await get().loadJavaCatalog(true); - }, - - loadPendingDownloads: async () => { - try { - const pending = await invoke( - "get_pending_java_downloads", - ); - set({ pendingDownloads: pending }); - } catch (e) { - console.error("Failed to load pending downloads:", e); - } - }, - - selectMajorVersion: (version: number) => { - set({ selectedMajorVersion: version }); - }, - - downloadJava: async () => { - const { selectedMajorVersion, selectedImageType, selectedDownloadSource } = - get(); - if (!selectedMajorVersion) return; - set({ isDownloadingJava: true, javaDownloadStatus: "Starting..." }); - try { - const result = await downloadAdoptiumJava( - selectedMajorVersion, - selectedImageType, - selectedDownloadSource, - ); - set({ - javaDownloadStatus: `Java ${selectedMajorVersion} download started: ${result.path}`, - }); - toast.success("Download started"); - } catch (e) { - console.error("Failed to download Java:", e); - toast.error(`Failed to start Java download: ${String(e)}`); - } finally { - set({ isDownloadingJava: false }); - } - }, - - cancelDownload: async () => { - try { - await invoke("cancel_java_download"); - toast.success("Cancelled Java download"); - set({ isDownloadingJava: false, javaDownloadStatus: "" }); - } catch (e) { - console.error("Failed to cancel download:", e); - toast.error(`Failed to cancel download: ${String(e)}`); - } - }, - - resumeDownloads: async () => { - try { - const installed = await invoke("resume_java_downloads"); - if (installed) toast.success("Resumed Java downloads"); - else toast.info("No downloads to resume"); - } catch (e) { - console.error("Failed to resume downloads:", e); - toast.error(`Failed to resume downloads: ${String(e)}`); - } - }, - - openConfigEditor: async () => { - try { - const path = await invoke("get_config_path"); - const content = await invoke("read_config_raw"); - set({ - configFilePath: path, - rawConfigContent: content, - showConfigEditor: true, - }); - } catch (e) { - console.error("Failed to open config editor:", e); - set({ configEditorError: String(e) }); - } - }, - - closeConfigEditor: () => { - set({ - showConfigEditor: false, - rawConfigContent: "", - configFilePath: "", - configEditorError: "", - }); - }, - - saveRawConfig: async () => { - try { - await invoke("write_config_raw", { content: get().rawConfigContent }); - toast.success("Config saved"); - set({ showConfigEditor: false }); - } catch (e) { - console.error("Failed to save config:", e); - set({ configEditorError: String(e) }); - toast.error(`Failed to save config: ${String(e)}`); - } - }, - - loadOllamaModels: async () => { - set({ isLoadingOllamaModels: true, ollamaModelsError: "" }); - try { - const models = await invoke("get_ollama_models"); - set({ ollamaModels: models, isLoadingOllamaModels: false }); - } catch (e) { - console.error("Failed to load Ollama models:", e); - set({ isLoadingOllamaModels: false, ollamaModelsError: String(e) }); - } - }, - - loadOpenaiModels: async () => { - set({ isLoadingOpenaiModels: true, openaiModelsError: "" }); - try { - const models = await invoke("get_openai_models"); - set({ openaiModels: models, isLoadingOpenaiModels: false }); - } catch (e) { - console.error("Failed to load OpenAI models:", e); - set({ isLoadingOpenaiModels: false, openaiModelsError: String(e) }); - } - }, - - setSetting: (key, value) => { - set((s) => ({ - settings: { ...s.settings, [key]: value } as unknown as LauncherConfig, - })); - }, - - setAssistantSetting: (key, value) => { - set((s) => ({ - settings: { - ...s.settings, - assistant: { ...s.settings.assistant, [key]: value }, - } as LauncherConfig, - })); - }, - - setFeatureFlag: (key, value) => { - set((s) => ({ - settings: { - ...s.settings, - featureFlags: { ...s.settings.featureFlags, [key]: value }, - } as LauncherConfig, - })); - }, -})); diff --git a/packages/ui/src/stores/ui-store.ts b/packages/ui/src/stores/ui-store.ts deleted file mode 100644 index 89b9191..0000000 --- a/packages/ui/src/stores/ui-store.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { create } from "zustand"; - -export type ViewType = "home" | "versions" | "settings" | "guide" | "instances"; - -interface UIState { - // State - currentView: ViewType; - showConsole: boolean; - appVersion: string; - - // Actions - toggleConsole: () => void; - setView: (view: ViewType) => void; - setAppVersion: (version: string) => void; -} - -export const useUIStore = create((set) => ({ - // Initial state - currentView: "home", - showConsole: false, - appVersion: "...", - - // Actions - toggleConsole: () => { - set((state) => ({ showConsole: !state.showConsole })); - }, - - setView: (view: ViewType) => { - set({ currentView: view }); - }, - - setAppVersion: (version: string) => { - set({ appVersion: version }); - }, -})); - -// Provide lowercase alias for compatibility with existing imports. -// Use a function wrapper to ensure the named export exists as a callable value -// at runtime (some bundlers/tree-shakers may remove simple aliases). -export function useUiStore() { - return useUIStore(); -} -- cgit v1.2.3-70-g09d2 From 397cbb34b327a0addfdf8e36f859b456956b66fe Mon Sep 17 00:00:00 2001 From: 苏向夜 Date: Sun, 29 Mar 2026 01:19:25 +0800 Subject: fix(lint): apply ui code lint --- packages/ui/src/components/bottom-bar.tsx | 14 ++++++++++---- packages/ui/src/components/instance-editor-modal.tsx | 15 +++++++++------ packages/ui/src/models/auth.ts | 5 ++++- packages/ui/src/models/instance.ts | 5 ++++- packages/ui/src/pages/home-view.tsx | 10 +++++----- packages/ui/src/pages/index.tsx | 7 ++++++- packages/ui/src/pages/instances-view.tsx | 16 ++++++++++++---- packages/ui/src/stores/game-store.ts | 4 +++- 8 files changed, 53 insertions(+), 23 deletions(-) (limited to 'packages/ui/src/stores') diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx index 8f70985..2746e00 100644 --- a/packages/ui/src/components/bottom-bar.tsx +++ b/packages/ui/src/components/bottom-bar.tsx @@ -21,13 +21,17 @@ export function BottomBar() { const account = useAuthStore((state) => state.account); const instances = useInstanceStore((state) => state.instances); const activeInstance = useInstanceStore((state) => state.activeInstance); - const setActiveInstance = useInstanceStore((state) => state.setActiveInstance); + const setActiveInstance = useInstanceStore( + (state) => state.setActiveInstance, + ); const selectedVersion = useGameStore((state) => state.selectedVersion); const setSelectedVersion = useGameStore((state) => state.setSelectedVersion); const startGame = useGameStore((state) => state.startGame); const stopGame = useGameStore((state) => state.stopGame); const runningInstanceId = useGameStore((state) => state.runningInstanceId); - const launchingInstanceId = useGameStore((state) => state.launchingInstanceId); + const launchingInstanceId = useGameStore( + (state) => state.launchingInstanceId, + ); const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId); const [showLoginModal, setShowLoginModal] = useState(false); @@ -39,7 +43,7 @@ export function BottomBar() { } setSelectedVersion(nextVersion); - }, [activeInstance?.id, activeInstance?.versionId, selectedVersion, setSelectedVersion]); + }, [activeInstance?.versionId, selectedVersion, setSelectedVersion]); const handleInstanceChange = useCallback( async (instanceId: string) => { @@ -47,7 +51,9 @@ export function BottomBar() { return; } - const nextInstance = instances.find((instance) => instance.id === instanceId); + const nextInstance = instances.find( + (instance) => instance.id === instanceId, + ); if (!nextInstance) { return; } diff --git a/packages/ui/src/components/instance-editor-modal.tsx b/packages/ui/src/components/instance-editor-modal.tsx index 2a2bd7d..105d7e9 100644 --- a/packages/ui/src/components/instance-editor-modal.tsx +++ b/packages/ui/src/components/instance-editor-modal.tsx @@ -1,8 +1,12 @@ - import { toNumber } from "es-toolkit/compat"; import { Folder, Loader2, Save, Trash2, X } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; +import { + deleteInstanceFile, + listInstanceDirectory, + openFileExplorer, +} from "@/client"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -18,7 +22,6 @@ import { useInstanceStore } from "@/models/instance"; import { useSettingsStore } from "@/models/settings"; import type { FileInfo } from "../types/bindings/core"; import type { Instance } from "../types/bindings/instance"; -import { deleteInstanceFile, listInstanceDirectory, openFileExplorer } from "@/client"; type Props = { open: boolean; @@ -97,7 +100,7 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) { setFileList(files); } catch (err) { console.error("Failed to load files:", err); - toast.error("Failed to load files: " + String(err)); + toast.error(`Failed to load files: ${String(err)}`); setFileList([]); } finally { setLoadingFiles(false); @@ -137,7 +140,7 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) { toast.success("Deleted"); } catch (err) { console.error("Failed to delete file:", err); - toast.error("Failed to delete file: " + String(err)); + toast.error(`Failed to delete file: ${String(err)}`); } finally { setDeletingPath(null); } @@ -148,7 +151,7 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) { await openFileExplorer(filePath); } catch (err) { console.error("Failed to open in explorer:", err); - toast.error("Failed to open file explorer: " + String(err)); + toast.error(`Failed to open file explorer: ${String(err)}`); } } @@ -180,7 +183,7 @@ export function InstanceEditorModal({ open, instance, onOpenChange }: Props) { } catch (err) { console.error("Failed to save instance:", err); setErrorMessage(String(err)); - toast.error("Failed to save instance: " + String(err)); + toast.error(`Failed to save instance: ${String(err)}`); } finally { setSaving(false); } diff --git a/packages/ui/src/models/auth.ts b/packages/ui/src/models/auth.ts index 9c814d2..d64b67a 100644 --- a/packages/ui/src/models/auth.ts +++ b/packages/ui/src/models/auth.ts @@ -95,7 +95,10 @@ export const useAuthStore = create((set, get) => ({ } catch (error) { const message = getAuthErrorMessage(error); console.error("Failed to start Microsoft login:", error); - set({ loginMode: null, statusMessage: `Failed to start login: ${message}` }); + set({ + loginMode: null, + statusMessage: `Failed to start login: ${message}`, + }); toast.error(`Failed to start Microsoft login: ${message}`); } }, diff --git a/packages/ui/src/models/instance.ts b/packages/ui/src/models/instance.ts index e1eb7c1..2f338b5 100644 --- a/packages/ui/src/models/instance.ts +++ b/packages/ui/src/models/instance.ts @@ -26,7 +26,10 @@ interface InstanceState { setActiveInstance: (instance: Instance) => Promise; duplicate: (id: string, newName: string) => Promise; exportArchive: (id: string, archivePath: string) => Promise; - importArchive: (archivePath: string, newName?: string) => Promise; + importArchive: ( + archivePath: string, + newName?: string, + ) => Promise; repair: () => Promise; get: (id: string) => Promise; } diff --git a/packages/ui/src/pages/home-view.tsx b/packages/ui/src/pages/home-view.tsx index 6060370..da7238f 100644 --- a/packages/ui/src/pages/home-view.tsx +++ b/packages/ui/src/pages/home-view.tsx @@ -32,20 +32,20 @@ export function HomeView() { const handleSaturnTouchStart = (e: React.TouchEvent) => { if (e.touches && e.touches.length === 1) { - const clientX = e.touches[0].clientX; - saturn?.handleTouchStart(clientX); + const clientX = e.touches[0].clientX; + saturn?.handleTouchStart(clientX); } }; const handleSaturnTouchMove = (e: React.TouchEvent) => { if (e.touches && e.touches.length === 1) { - const clientX = e.touches[0].clientX; - saturn?.handleTouchMove(clientX); + const clientX = e.touches[0].clientX; + saturn?.handleTouchMove(clientX); } }; const handleSaturnTouchEnd = () => { - saturn?.handleTouchEnd(); + saturn?.handleTouchEnd(); }; return ( diff --git a/packages/ui/src/pages/index.tsx b/packages/ui/src/pages/index.tsx index bccca22..2acd377 100644 --- a/packages/ui/src/pages/index.tsx +++ b/packages/ui/src/pages/index.tsx @@ -22,7 +22,12 @@ export function IndexPage() { void initGameLifecycle().catch((error) => { console.error("Failed to initialize game lifecycle:", error); }); - }, [authStore.init, settingsStore.refresh, instanceStore.refresh, initGameLifecycle]); + }, [ + authStore.init, + settingsStore.refresh, + instanceStore.refresh, + initGameLifecycle, + ]); return (
diff --git a/packages/ui/src/pages/instances-view.tsx b/packages/ui/src/pages/instances-view.tsx index 07a2135..7bb3302 100644 --- a/packages/ui/src/pages/instances-view.tsx +++ b/packages/ui/src/pages/instances-view.tsx @@ -35,7 +35,9 @@ export function InstancesView() { const startGame = useGameStore((state) => state.startGame); const stopGame = useGameStore((state) => state.stopGame); const runningInstanceId = useGameStore((state) => state.runningInstanceId); - const launchingInstanceId = useGameStore((state) => state.launchingInstanceId); + const launchingInstanceId = useGameStore( + (state) => state.launchingInstanceId, + ); const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId); const [isImporting, setIsImporting] = useState(false); const [repairing, setRepairing] = useState(false); @@ -191,7 +193,8 @@ export function InstancesView() { const isRunning = runningInstanceId === instance.id; const isLaunching = launchingInstanceId === instance.id; const isStopping = stoppingInstanceId === instance.id; - const otherInstanceRunning = runningInstanceId !== null && !isRunning; + const otherInstanceRunning = + runningInstanceId !== null && !isRunning; return (
  • undefined, ); }} - disabled={otherInstanceRunning || isLaunching || isStopping} + disabled={ + otherInstanceRunning || isLaunching || isStopping + } > {isLaunching || isStopping ? ( ... diff --git a/packages/ui/src/stores/game-store.ts b/packages/ui/src/stores/game-store.ts index 1eaf7e7..7b6e746 100644 --- a/packages/ui/src/stores/game-store.ts +++ b/packages/ui/src/stores/game-store.ts @@ -70,7 +70,9 @@ export const useGameStore = create((set, get) => ({ }); if (wasStopped) { - toast.success(`Stopped Minecraft ${versionId} for instance ${instanceId}`); + toast.success( + `Stopped Minecraft ${versionId} for instance ${instanceId}`, + ); } else { toast.info(`Minecraft ${versionId} exited for instance ${instanceId}`); } -- cgit v1.2.3-70-g09d2 From ffbfce895c37e8e8306d426a2e59e73647ed6a86 Mon Sep 17 00:00:00 2001 From: 苏向夜 Date: Sun, 29 Mar 2026 02:46:51 +0800 Subject: refactor(ui): rewrite game store --- .changes/game-store.md | 5 + packages/ui/src/components/bottom-bar.tsx | 31 +- packages/ui/src/models/game.ts | 113 ++++++++ packages/ui/src/pages/home-view.tsx | 102 ------- packages/ui/src/pages/home.tsx | 102 +++++++ packages/ui/src/pages/index.tsx | 12 +- packages/ui/src/pages/instances-view.tsx | 457 ------------------------------ packages/ui/src/pages/routes.ts | 6 +- packages/ui/src/stores/game-store.ts | 184 ------------ 9 files changed, 236 insertions(+), 776 deletions(-) create mode 100644 .changes/game-store.md create mode 100644 packages/ui/src/models/game.ts delete mode 100644 packages/ui/src/pages/home-view.tsx create mode 100644 packages/ui/src/pages/home.tsx delete mode 100644 packages/ui/src/pages/instances-view.tsx delete mode 100644 packages/ui/src/stores/game-store.ts (limited to 'packages/ui/src/stores') diff --git a/.changes/game-store.md b/.changes/game-store.md new file mode 100644 index 0000000..0b2f7f6 --- /dev/null +++ b/.changes/game-store.md @@ -0,0 +1,5 @@ +--- +"@dropout/ui": "patch:refactor" +--- + +Refactor game store and rename `HomePage` component. diff --git a/packages/ui/src/components/bottom-bar.tsx b/packages/ui/src/components/bottom-bar.tsx index 2746e00..fd4a681 100644 --- a/packages/ui/src/components/bottom-bar.tsx +++ b/packages/ui/src/components/bottom-bar.tsx @@ -3,8 +3,8 @@ import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/models/auth"; +import { useGameStore } from "@/models/game"; import { useInstanceStore } from "@/models/instance"; -import { useGameStore } from "@/stores/game-store"; import { LoginModal } from "./login-modal"; import { Button } from "./ui/button"; import { @@ -19,21 +19,17 @@ import { Spinner } from "./ui/spinner"; export function BottomBar() { const account = useAuthStore((state) => state.account); - const instances = useInstanceStore((state) => state.instances); - const activeInstance = useInstanceStore((state) => state.activeInstance); - const setActiveInstance = useInstanceStore( - (state) => state.setActiveInstance, - ); - const selectedVersion = useGameStore((state) => state.selectedVersion); - const setSelectedVersion = useGameStore((state) => state.setSelectedVersion); - const startGame = useGameStore((state) => state.startGame); - const stopGame = useGameStore((state) => state.stopGame); - const runningInstanceId = useGameStore((state) => state.runningInstanceId); - const launchingInstanceId = useGameStore( - (state) => state.launchingInstanceId, - ); - const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId); + const { instances, activeInstance, setActiveInstance } = useInstanceStore(); + const { + runningInstanceId, + launchingInstanceId, + stoppingInstanceId, + startGame, + stopGame, + } = useGameStore(); + + const [selectedVersion, setSelectedVersion] = useState(null); const [showLoginModal, setShowLoginModal] = useState(false); useEffect(() => { @@ -43,7 +39,7 @@ export function BottomBar() { } setSelectedVersion(nextVersion); - }, [activeInstance?.versionId, selectedVersion, setSelectedVersion]); + }, [activeInstance?.versionId, selectedVersion]); const handleInstanceChange = useCallback( async (instanceId: string) => { @@ -75,11 +71,8 @@ export function BottomBar() { } await startGame( - account, - () => setShowLoginModal(true), activeInstance.id, selectedVersion || activeInstance.versionId, - () => undefined, ); }; diff --git a/packages/ui/src/models/game.ts b/packages/ui/src/models/game.ts new file mode 100644 index 0000000..5342078 --- /dev/null +++ b/packages/ui/src/models/game.ts @@ -0,0 +1,113 @@ +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { toast } from "sonner"; +import { create } from "zustand"; +import { + startGame as startGameCommand, + stopGame as stopGameCommand, +} from "@/client"; +import type { GameExitedEvent } from "@/types/bindings/core"; + +interface GameState { + runningInstanceId: string | null; + runningVersionId: string | null; + launchingInstanceId: string | null; + stoppingInstanceId: string | null; + lifecycleUnlisten: UnlistenFn | null; + + isGameRunning: boolean; + startGame: (instanceId: string, versionId: string) => Promise; + stopGame: (instanceId?: string | null) => Promise; +} + +export const useGameStore = create((set, get) => ({ + runningInstanceId: null, + runningVersionId: null, + launchingInstanceId: null, + stoppingInstanceId: null, + lifecycleUnlisten: null, + + get isGameRunning() { + return get().runningInstanceId !== null; + }, + + startGame: async (instanceId, versionId) => { + const { isGameRunning, lifecycleUnlisten } = get(); + + if (isGameRunning) { + toast.info("A game is already running"); + return null; + } else { + lifecycleUnlisten?.(); + } + + set({ + launchingInstanceId: instanceId, + }); + toast.info(`Preparing to launch ${versionId}...`); + + const unlisten = await listen("game-exited", (event) => { + const { instanceId, versionId, wasStopped, exitCode } = event.payload; + + set({ + runningInstanceId: null, + runningVersionId: null, + launchingInstanceId: null, + stoppingInstanceId: null, + }); + + if (wasStopped) { + toast.success( + `Stopped Minecraft ${versionId} for instance ${instanceId}`, + ); + } else { + toast.info( + `Minecraft ${versionId} exited with code ${exitCode} for instance ${instanceId}`, + ); + } + }); + + set({ lifecycleUnlisten: unlisten }); + + try { + const message = await startGameCommand(instanceId, versionId); + set({ + launchingInstanceId: null, + runningInstanceId: instanceId, + runningVersionId: versionId, + }); + toast.success(message); + return message; + } catch (e) { + console.error(e); + set({ launchingInstanceId: null }); + toast.error(`Error: ${e}`); + return null; + } + }, + + stopGame: async (instanceId) => { + const { runningInstanceId } = get(); + + if (!runningInstanceId) { + toast.info("No running game found"); + return null; + } + + if (instanceId !== runningInstanceId) { + toast.info("That instance is not the one currently running"); + return null; + } + + set({ stoppingInstanceId: runningInstanceId }); + + try { + return await stopGameCommand(); + } catch (e) { + console.error("Failed to stop game:", e); + toast.error(`Failed to stop game: ${e}`); + return null; + } finally { + set({ stoppingInstanceId: null }); + } + }, +})); diff --git a/packages/ui/src/pages/home-view.tsx b/packages/ui/src/pages/home-view.tsx deleted file mode 100644 index da7238f..0000000 --- a/packages/ui/src/pages/home-view.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useState } from "react"; -import { BottomBar } from "@/components/bottom-bar"; -import { useSaturnEffect } from "@/components/particle-background"; - -export function HomeView() { - const [mouseX, setMouseX] = useState(0); - const [mouseY, setMouseY] = useState(0); - const saturn = useSaturnEffect(); - - const handleMouseMove = (e: React.MouseEvent) => { - const x = (e.clientX / window.innerWidth) * 2 - 1; - const y = (e.clientY / window.innerHeight) * 2 - 1; - setMouseX(x); - setMouseY(y); - - // Forward mouse move to SaturnEffect (if available) for parallax/rotation interactions - saturn?.handleMouseMove(e.clientX); - }; - - const handleSaturnMouseDown = (e: React.MouseEvent) => { - saturn?.handleMouseDown(e.clientX); - }; - - const handleSaturnMouseUp = () => { - saturn?.handleMouseUp(); - }; - - const handleSaturnMouseLeave = () => { - // Treat leaving the area as mouse-up for the effect - saturn?.handleMouseUp(); - }; - - const handleSaturnTouchStart = (e: React.TouchEvent) => { - if (e.touches && e.touches.length === 1) { - const clientX = e.touches[0].clientX; - saturn?.handleTouchStart(clientX); - } - }; - - const handleSaturnTouchMove = (e: React.TouchEvent) => { - if (e.touches && e.touches.length === 1) { - const clientX = e.touches[0].clientX; - saturn?.handleTouchMove(clientX); - } - }; - - const handleSaturnTouchEnd = () => { - saturn?.handleTouchEnd(); - }; - - return ( -
    - {/* Hero Section (Full Height) - Interactive area */} -
    - {/* 3D Floating Hero Text */} -
    -
    -
    - - Launcher Active - -
    - -

    - MINECRAFT -

    - -
    -
    - Java Edition -
    -
    -
    - - {/* Action Area */} -
    -
    - > Ready to launch session. -
    -
    - - -
    -
    - ); -} diff --git a/packages/ui/src/pages/home.tsx b/packages/ui/src/pages/home.tsx new file mode 100644 index 0000000..dc1413d --- /dev/null +++ b/packages/ui/src/pages/home.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { BottomBar } from "@/components/bottom-bar"; +import { useSaturnEffect } from "@/components/particle-background"; + +export function HomePage() { + const [mouseX, setMouseX] = useState(0); + const [mouseY, setMouseY] = useState(0); + const saturn = useSaturnEffect(); + + const handleMouseMove = (e: React.MouseEvent) => { + const x = (e.clientX / window.innerWidth) * 2 - 1; + const y = (e.clientY / window.innerHeight) * 2 - 1; + setMouseX(x); + setMouseY(y); + + // Forward mouse move to SaturnEffect (if available) for parallax/rotation interactions + saturn?.handleMouseMove(e.clientX); + }; + + const handleSaturnMouseDown = (e: React.MouseEvent) => { + saturn?.handleMouseDown(e.clientX); + }; + + const handleSaturnMouseUp = () => { + saturn?.handleMouseUp(); + }; + + const handleSaturnMouseLeave = () => { + // Treat leaving the area as mouse-up for the effect + saturn?.handleMouseUp(); + }; + + const handleSaturnTouchStart = (e: React.TouchEvent) => { + if (e.touches && e.touches.length === 1) { + const clientX = e.touches[0].clientX; + saturn?.handleTouchStart(clientX); + } + }; + + const handleSaturnTouchMove = (e: React.TouchEvent) => { + if (e.touches && e.touches.length === 1) { + const clientX = e.touches[0].clientX; + saturn?.handleTouchMove(clientX); + } + }; + + const handleSaturnTouchEnd = () => { + saturn?.handleTouchEnd(); + }; + + return ( +
    + {/* Hero Section (Full Height) - Interactive area */} +
    + {/* 3D Floating Hero Text */} +
    +
    +
    + + Launcher Active + +
    + +

    + MINECRAFT +

    + +
    +
    + Java Edition +
    +
    +
    + + {/* Action Area */} +
    +
    + > Ready to launch session. +
    +
    + + +
    +
    + ); +} diff --git a/packages/ui/src/pages/index.tsx b/packages/ui/src/pages/index.tsx index 2acd377..d12646b 100644 --- a/packages/ui/src/pages/index.tsx +++ b/packages/ui/src/pages/index.tsx @@ -5,13 +5,11 @@ import { Sidebar } from "@/components/sidebar"; import { useAuthStore } from "@/models/auth"; import { useInstanceStore } from "@/models/instance"; import { useSettingsStore } from "@/models/settings"; -import { useGameStore } from "@/stores/game-store"; export function IndexPage() { const authStore = useAuthStore(); const settingsStore = useSettingsStore(); const instanceStore = useInstanceStore(); - const initGameLifecycle = useGameStore((state) => state.initLifecycle); const location = useLocation(); @@ -19,15 +17,7 @@ export function IndexPage() { authStore.init(); settingsStore.refresh(); instanceStore.refresh(); - void initGameLifecycle().catch((error) => { - console.error("Failed to initialize game lifecycle:", error); - }); - }, [ - authStore.init, - settingsStore.refresh, - instanceStore.refresh, - initGameLifecycle, - ]); + }, [authStore.init, settingsStore.refresh, instanceStore.refresh]); return (
    diff --git a/packages/ui/src/pages/instances-view.tsx b/packages/ui/src/pages/instances-view.tsx deleted file mode 100644 index 7bb3302..0000000 --- a/packages/ui/src/pages/instances-view.tsx +++ /dev/null @@ -1,457 +0,0 @@ -import { open, save } from "@tauri-apps/plugin-dialog"; -import { - CopyIcon, - EditIcon, - FolderOpenIcon, - Plus, - RocketIcon, - Trash2Icon, - XIcon, -} from "lucide-react"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { openFileExplorer } from "@/client"; -import InstanceCreationModal from "@/components/instance-creation-modal"; -import InstanceEditorModal from "@/components/instance-editor-modal"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { cn } from "@/lib/utils"; -import { useAuthStore } from "@/models/auth"; -import { useInstanceStore } from "@/models/instance"; -import { useGameStore } from "@/stores/game-store"; -import type { Instance } from "@/types"; - -export function InstancesView() { - const account = useAuthStore((state) => state.account); - const instancesStore = useInstanceStore(); - const startGame = useGameStore((state) => state.startGame); - const stopGame = useGameStore((state) => state.stopGame); - const runningInstanceId = useGameStore((state) => state.runningInstanceId); - const launchingInstanceId = useGameStore( - (state) => state.launchingInstanceId, - ); - const stoppingInstanceId = useGameStore((state) => state.stoppingInstanceId); - const [isImporting, setIsImporting] = useState(false); - const [repairing, setRepairing] = useState(false); - const [exportingId, setExportingId] = useState(null); - - // Modal / UI state - const [showCreateModal, setShowCreateModal] = useState(false); - const [showEditModal, setShowEditModal] = useState(false); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [showDuplicateModal, setShowDuplicateModal] = useState(false); - - // Selected / editing instance state - const [selectedInstance, setSelectedInstance] = useState( - null, - ); - const [editingInstance, setEditingInstance] = useState(null); - - // Form fields - const [duplicateName, setDuplicateName] = useState(""); - - useEffect(() => { - instancesStore.refresh(); - }, [instancesStore.refresh]); - - // Handlers to open modals - const openCreate = () => { - setShowCreateModal(true); - }; - - const openEdit = (instance: Instance) => { - setEditingInstance({ ...instance }); - setShowEditModal(true); - }; - - const openDelete = (instance: Instance) => { - setSelectedInstance(instance); - setShowDeleteConfirm(true); - }; - - const openDuplicate = (instance: Instance) => { - setSelectedInstance(instance); - setDuplicateName(`${instance.name} (Copy)`); - setShowDuplicateModal(true); - }; - - const confirmDelete = async () => { - if (!selectedInstance) return; - await instancesStore.delete(selectedInstance.id); - setSelectedInstance(null); - setShowDeleteConfirm(false); - }; - - const confirmDuplicate = async () => { - if (!selectedInstance) return; - const name = duplicateName.trim(); - if (!name) return; - await instancesStore.duplicate(selectedInstance.id, name); - setSelectedInstance(null); - setDuplicateName(""); - setShowDuplicateModal(false); - }; - - const handleImport = async () => { - setIsImporting(true); - try { - const selected = await open({ - multiple: false, - filters: [{ name: "Zip Archive", extensions: ["zip"] }], - }); - - if (typeof selected !== "string") { - return; - } - - await instancesStore.importArchive(selected); - } finally { - setIsImporting(false); - } - }; - - const handleRepair = async () => { - setRepairing(true); - try { - await instancesStore.repair(); - } finally { - setRepairing(false); - } - }; - - const handleExport = async (instance: Instance) => { - setExportingId(instance.id); - try { - const filePath = await save({ - defaultPath: `${instance.name.replace(/[\\/:*?"<>|]/g, "_")}.zip`, - filters: [{ name: "Zip Archive", extensions: ["zip"] }], - }); - - if (!filePath) { - return; - } - - await instancesStore.exportArchive(instance.id, filePath); - } finally { - setExportingId(null); - } - }; - - return ( -
    -
    -

    - Instances -

    -
    - - - -
    -
    - - {instancesStore.instances.length === 0 ? ( -
    -
    -

    No instances yet

    -

    Create your first instance to get started

    -
    -
    - ) : ( -
      - {instancesStore.instances.map((instance) => { - const isActive = instancesStore.activeInstance?.id === instance.id; - const isRunning = runningInstanceId === instance.id; - const isLaunching = launchingInstanceId === instance.id; - const isStopping = stoppingInstanceId === instance.id; - const otherInstanceRunning = - runningInstanceId !== null && !isRunning; - - return ( -
    • instancesStore.setActiveInstance(instance)} - onKeyDown={async (e) => { - if (e.key === "Enter") { - try { - await instancesStore.setActiveInstance(instance); - } catch (e) { - console.error("Failed to set active instance:", e); - toast.error("Error setting active instance"); - } - } - }} - className="cursor-pointer" - > -
      -
      - {instance.iconPath ? ( -
      - {instance.name} -
      - ) : ( -
      - - {instance.name.charAt(0).toUpperCase()} - -
      - )} - -
      -

      {instance.name}

      - {instance.versionId ? ( -

      - {instance.versionId} -

      - ) : ( -

      - No version selected -

      - )} -
      -
      - -
      -
      - - - - - - -
      -
      -
      -
    • - ); - })} -
    - )} - - - - { - setShowEditModal(open); - if (!open) setEditingInstance(null); - }} - /> - - {/* Delete Confirmation */} - - - - Delete Instance - - Are you sure you want to delete "{selectedInstance?.name}"? This - action cannot be undone. - - - - - - - - - - - {/* Duplicate Modal */} - - - - Duplicate Instance - - Provide a name for the duplicated instance. - - - -
    - setDuplicateName(e.target.value)} - placeholder="New instance name" - onKeyDown={(e) => e.key === "Enter" && confirmDuplicate()} - /> -
    - - - - - -
    -
    -
    - ); -} diff --git a/packages/ui/src/pages/routes.ts b/packages/ui/src/pages/routes.ts index 8d105d4..55eb8fd 100644 --- a/packages/ui/src/pages/routes.ts +++ b/packages/ui/src/pages/routes.ts @@ -1,6 +1,6 @@ import { createHashRouter } from "react-router"; -import { IndexPage } from "."; -import { HomeView } from "./home-view"; +import { HomePage } from "./home"; +import { IndexPage } from "./index"; import instanceRoute from "./instances/routes"; import { SettingsPage } from "./settings"; @@ -11,7 +11,7 @@ const router = createHashRouter([ children: [ { index: true, - Component: HomeView, + Component: HomePage, }, { path: "settings", diff --git a/packages/ui/src/stores/game-store.ts b/packages/ui/src/stores/game-store.ts deleted file mode 100644 index 7b6e746..0000000 --- a/packages/ui/src/stores/game-store.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import { toast } from "sonner"; -import { create } from "zustand"; -import { - getVersions, - getVersionsOfInstance, - startGame as startGameCommand, - stopGame as stopGameCommand, -} from "@/client"; -import type { Account } from "@/types/bindings/auth"; -import type { GameExitedEvent } from "@/types/bindings/core"; -import type { Version } from "@/types/bindings/manifest"; - -interface GameState { - versions: Version[]; - selectedVersion: string; - runningInstanceId: string | null; - runningVersionId: string | null; - launchingInstanceId: string | null; - stoppingInstanceId: string | null; - lifecycleUnlisten: UnlistenFn | null; - - latestRelease: Version | undefined; - isGameRunning: boolean; - - initLifecycle: () => Promise; - loadVersions: (instanceId?: string) => Promise; - startGame: ( - currentAccount: Account | null, - openLoginModal: () => void, - activeInstanceId: string | null, - versionId: string | null, - setView: (view: string) => void, - ) => Promise; - stopGame: (instanceId?: string | null) => Promise; - setSelectedVersion: (version: string) => void; - setVersions: (versions: Version[]) => void; -} - -export const useGameStore = create((set, get) => ({ - versions: [], - selectedVersion: "", - runningInstanceId: null, - runningVersionId: null, - launchingInstanceId: null, - stoppingInstanceId: null, - lifecycleUnlisten: null, - - get latestRelease() { - return get().versions.find((v) => v.type === "release"); - }, - - get isGameRunning() { - return get().runningInstanceId !== null; - }, - - initLifecycle: async () => { - if (get().lifecycleUnlisten) { - return; - } - - const unlisten = await listen("game-exited", (event) => { - const { instanceId, versionId, wasStopped } = event.payload; - - set({ - runningInstanceId: null, - runningVersionId: null, - launchingInstanceId: null, - stoppingInstanceId: null, - }); - - if (wasStopped) { - toast.success( - `Stopped Minecraft ${versionId} for instance ${instanceId}`, - ); - } else { - toast.info(`Minecraft ${versionId} exited for instance ${instanceId}`); - } - }); - - set({ lifecycleUnlisten: unlisten }); - }, - - loadVersions: async (instanceId?: string) => { - try { - const versions = instanceId - ? await getVersionsOfInstance(instanceId) - : await getVersions(); - set({ versions: versions ?? [] }); - } catch (e) { - console.error("Failed to load versions:", e); - set({ versions: [] }); - } - }, - - startGame: async ( - currentAccount, - openLoginModal, - activeInstanceId, - versionId, - setView, - ) => { - const { isGameRunning } = get(); - const targetVersion = versionId ?? get().selectedVersion; - - if (!currentAccount) { - toast.info("Please login first"); - openLoginModal(); - return null; - } - - if (!targetVersion) { - toast.info("Please select a version first"); - return null; - } - - if (!activeInstanceId) { - toast.info("Please select an instance first"); - setView("instances"); - return null; - } - - if (isGameRunning) { - toast.info("A game is already running"); - return null; - } - - set({ - launchingInstanceId: activeInstanceId, - selectedVersion: targetVersion, - }); - toast.info(`Preparing to launch ${targetVersion}...`); - - try { - const message = await startGameCommand(activeInstanceId, targetVersion); - set({ - launchingInstanceId: null, - runningInstanceId: activeInstanceId, - runningVersionId: targetVersion, - }); - toast.success(message); - return message; - } catch (e) { - console.error(e); - set({ launchingInstanceId: null }); - toast.error(`Error: ${e}`); - return null; - } - }, - - stopGame: async (instanceId) => { - const { runningInstanceId } = get(); - - if (!runningInstanceId) { - toast.info("No running game found"); - return null; - } - - if (instanceId && instanceId !== runningInstanceId) { - toast.info("That instance is not the one currently running"); - return null; - } - - set({ stoppingInstanceId: runningInstanceId }); - - try { - return await stopGameCommand(); - } catch (e) { - console.error("Failed to stop game:", e); - toast.error(`Failed to stop game: ${e}`); - return null; - } finally { - set({ stoppingInstanceId: null }); - } - }, - - setSelectedVersion: (version: string) => { - set({ selectedVersion: version }); - }, - - setVersions: (versions: Version[]) => { - set({ versions }); - }, -})); -- cgit v1.2.3-70-g09d2