summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorHsiangNianian <i@jyunko.cn>2026-01-15 16:57:20 +0800
committerHsiangNianian <i@jyunko.cn>2026-01-15 17:36:40 +0800
commit485e8290e1ceaaeb82c7decd1330c8ca1b1f28bd (patch)
tree7399da56d09d9a1571a84a6c54dc094af187a36a
parenta8738f27bbc8284249932d9589bfe927d1bfacc5 (diff)
downloadDropOut-485e8290e1ceaaeb82c7decd1330c8ca1b1f28bd.tar.gz
DropOut-485e8290e1ceaaeb82c7decd1330c8ca1b1f28bd.zip
feat: Enhance GameConsole with advanced log filtering, searching, and export/upload functionalities for improved user experience
-rw-r--r--ui/src/lib/GameConsole.svelte366
1 files changed, 281 insertions, 85 deletions
diff --git a/ui/src/lib/GameConsole.svelte b/ui/src/lib/GameConsole.svelte
index 8d5e0ce..1b1ab53 100644
--- a/ui/src/lib/GameConsole.svelte
+++ b/ui/src/lib/GameConsole.svelte
@@ -1,107 +1,303 @@
<script lang="ts">
- import { listen } from "@tauri-apps/api/event";
- import { onMount, onDestroy } from "svelte";
+ import { logsState, type LogEntry } from "../stores/logs.svelte";
+ import { uiState } from "../stores/ui.svelte";
+ import { save } from "@tauri-apps/plugin-dialog";
+ import { writeTextFile } from "@tauri-apps/plugin-fs";
+ import { invoke } from "@tauri-apps/api/core";
+ import { open } from "@tauri-apps/plugin-shell";
+ import { onMount, tick } from "svelte";
- export let visible = false;
-
- let logs: { type: 'stdout' | 'stderr' | 'launcher', line: string, timestamp: string }[] = [];
let consoleElement: HTMLDivElement;
- let unlistenStdout: () => void;
- let unlistenStderr: () => void;
- let unlistenLauncher: () => void;
- let unlistenGameExited: () => void;
-
- function getTimestamp(): string {
- const now = new Date();
- return now.toTimeString().split(' ')[0]; // HH:MM:SS
- }
+ let autoScroll = $state(true);
+
+ // Search & Filter
+ let searchQuery = $state("");
+ let showInfo = $state(true);
+ let showWarn = $state(true);
+ let showError = $state(true);
+ let showDebug = $state(false);
+
+ // Source filter: "all" or specific source name
+ let selectedSource = $state("all");
- onMount(async () => {
- // Listen for launcher logs (preparation, downloads, launch status)
- unlistenLauncher = await listen<string>("launcher-log", (event) => {
- addLog('launcher', event.payload);
- });
-
- // Listen for game stdout
- unlistenStdout = await listen<string>("game-stdout", (event) => {
- addLog('stdout', event.payload);
- });
-
- // Listen for game stderr
- unlistenStderr = await listen<string>("game-stderr", (event) => {
- addLog('stderr', event.payload);
- });
-
- // Listen for game exit event
- unlistenGameExited = await listen<number>("game-exited", (event) => {
- addLog('launcher', `Game process exited with code: ${event.payload}`);
- });
- });
+ // Get sorted sources for dropdown
+ let sortedSources = $derived([...logsState.sources].sort());
- onDestroy(() => {
- if (unlistenLauncher) unlistenLauncher();
- if (unlistenStdout) unlistenStdout();
- if (unlistenStderr) unlistenStderr();
- if (unlistenGameExited) unlistenGameExited();
- });
+ // Derived filtered logs
+ let filteredLogs = $derived(logsState.logs.filter((log) => {
+ // Source Filter
+ if (selectedSource !== "all" && log.source !== selectedSource) return false;
- function addLog(type: 'stdout' | 'stderr' | 'launcher', line: string) {
- logs = [...logs, { type, line, timestamp: getTimestamp() }];
- if (logs.length > 2000) {
- logs = logs.slice(logs.length - 2000);
+ // Level Filter
+ if (!showInfo && log.level === "info") return false;
+ if (!showWarn && log.level === "warn") return false;
+ if (!showError && (log.level === "error" || log.level === "fatal")) return false;
+ if (!showDebug && log.level === "debug") return false;
+
+ // Search Filter
+ if (searchQuery) {
+ const q = searchQuery.toLowerCase();
+ return (
+ log.message.toLowerCase().includes(q) ||
+ log.source.toLowerCase().includes(q)
+ );
}
- // Auto-scroll
- setTimeout(() => {
- if (consoleElement) {
+ return true;
+ }));
+
+ // Auto-scroll logic
+ $effect(() => {
+ // Depend on filteredLogs length to trigger scroll
+ if (filteredLogs.length && autoScroll && consoleElement) {
+ // Use tick to wait for DOM update
+ tick().then(() => {
consoleElement.scrollTop = consoleElement.scrollHeight;
- }
- }, 0);
+ });
+ }
+ });
+
+ function handleScroll() {
+ if (!consoleElement) return;
+ const { scrollTop, scrollHeight, clientHeight } = consoleElement;
+ // If user scrolls up (more than 50px from bottom), disable auto-scroll
+ if (scrollHeight - scrollTop - clientHeight > 50) {
+ autoScroll = false;
+ } else {
+ autoScroll = true;
+ }
+ }
+
+ // Export only currently filtered logs
+ async function exportLogs() {
+ try {
+ const content = logsState.exportLogs(filteredLogs);
+ const path = await save({
+ filters: [{ name: "Log File", extensions: ["txt", "log"] }],
+ defaultPath: `dropout-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.log`,
+ });
+
+ if (path) {
+ await writeTextFile(path, content);
+ logsState.addLog("info", "Console", `Exported ${filteredLogs.length} logs to ${path}`);
+ }
+ } catch (e) {
+ console.error("Export failed", e);
+ logsState.addLog("error", "Console", `Export failed: ${e}`);
+ }
}
- function clearLogs() {
- logs = [];
+ // Upload only currently filtered logs
+ async function uploadLogs() {
+ try {
+ const content = logsState.exportLogs(filteredLogs);
+ logsState.addLog("info", "Console", `Uploading ${filteredLogs.length} logs...`);
+
+ const response = await invoke<{ url: string }>("upload_to_pastebin", { content });
+
+ logsState.addLog("info", "Console", `Logs uploaded successfully: ${response.url}`);
+ await open(response.url);
+ } catch (e) {
+ console.error("Upload failed", e);
+ logsState.addLog("error", "Console", `Upload failed: ${e}`);
+ }
}
- function exportLogs() {
- const logText = logs.map(l => `[${l.timestamp}] [${l.type.toUpperCase()}] ${l.line}`).join('\n');
- const blob = new Blob([logText], { type: 'text/plain' });
- const url = URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = url;
- a.download = `dropout-logs-${new Date().toISOString().split('T')[0]}.txt`;
- a.click();
- URL.revokeObjectURL(url);
+ function highlightText(text: string, query: string) {
+ if (!query) return text;
+ // Escape regex special chars in query
+ const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const parts = text.split(new RegExp(`(${escaped})`, "gi"));
+ return parts.map(part =>
+ part.toLowerCase() === query.toLowerCase()
+ ? `<span class="bg-yellow-500/30 text-yellow-200 font-bold">${part}</span>`
+ : part
+ ).join("");
+ }
+
+ function getLevelColor(level: LogEntry["level"]) {
+ switch (level) {
+ case "info": return "text-blue-400";
+ case "warn": return "text-yellow-400";
+ case "error":
+ case "fatal": return "text-red-400";
+ case "debug": return "text-purple-400";
+ default: return "text-zinc-400";
+ }
+ }
+
+ function getLevelLabel(level: LogEntry["level"]) {
+ switch (level) {
+ case "info": return "INFO";
+ case "warn": return "WARN";
+ case "error": return "ERR";
+ case "fatal": return "FATAL";
+ case "debug": return "DEBUG";
+ }
+ }
+
+ function getMessageColor(log: LogEntry) {
+ if (log.level === "error" || log.level === "fatal") return "text-red-300";
+ if (log.level === "warn") return "text-yellow-200";
+ if (log.level === "debug") return "text-purple-200/70";
+ if (log.source.startsWith("Game")) return "text-emerald-100/80";
+ return "";
}
</script>
-{#if visible}
-<div class="fixed bottom-0 left-0 right-0 h-64 bg-zinc-950/95 border-t border-zinc-700 flex flex-col z-50 transition-transform duration-300 transform translate-y-0">
- <div class="flex items-center justify-between px-4 py-2 border-b border-zinc-800 bg-zinc-900/50">
- <div class="flex items-center gap-4">
- <span class="text-xs font-bold text-zinc-400 uppercase tracking-widest">Logs</span>
- <div class="flex gap-1 text-[10px]">
- <span class="px-1.5 py-0.5 rounded bg-indigo-900/50 text-indigo-300">LAUNCHER</span>
- <span class="px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-300">GAME</span>
- <span class="px-1.5 py-0.5 rounded bg-red-900/50 text-red-300">ERROR</span>
- </div>
+<div class="absolute inset-0 flex flex-col bg-[#1e1e1e] text-zinc-300 font-mono text-xs overflow-hidden">
+ <!-- Toolbar -->
+ <div class="flex flex-wrap items-center justify-between p-2 bg-[#252526] border-b border-[#3e3e42] gap-2">
+ <div class="flex items-center gap-3">
+ <h3 class="font-bold text-zinc-100 uppercase tracking-wider px-2">Console</h3>
+
+ <!-- Source Dropdown -->
+ <select
+ bind:value={selectedSource}
+ class="bg-zinc-900 border border-zinc-700 rounded-md px-3 py-1.5 pr-8 text-zinc-300 text-xs focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500/30 cursor-pointer hover:border-zinc-600 transition-colors"
+ >
+ <option value="all">All Sources</option>
+ {#each sortedSources as source}
+ <option value={source}>{source}</option>
+ {/each}
+ </select>
+
+ <!-- Level Filters -->
+ <div class="flex items-center bg-[#1e1e1e] rounded border border-[#3e3e42] overflow-hidden">
+ <button
+ class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showInfo ? 'text-blue-400' : 'text-zinc-600'}"
+ onclick={() => showInfo = !showInfo}
+ title="Toggle Info"
+ >Info</button>
+ <div class="w-px h-3 bg-[#3e3e42]"></div>
+ <button
+ class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showWarn ? 'text-yellow-400' : 'text-zinc-600'}"
+ onclick={() => showWarn = !showWarn}
+ title="Toggle Warnings"
+ >Warn</button>
+ <div class="w-px h-3 bg-[#3e3e42]"></div>
+ <button
+ class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showError ? 'text-red-400' : 'text-zinc-600'}"
+ onclick={() => showError = !showError}
+ title="Toggle Errors"
+ >Error</button>
+ <div class="w-px h-3 bg-[#3e3e42]"></div>
+ <button
+ class="px-2 py-1 hover:bg-[#3e3e42] transition-colors {showDebug ? 'text-purple-400' : 'text-zinc-600'}"
+ onclick={() => showDebug = !showDebug}
+ title="Toggle Debug"
+ >Debug</button>
+ </div>
+
+ <!-- Search -->
+ <div class="relative group">
+ <input
+ type="text"
+ bind:value={searchQuery}
+ placeholder="Find..."
+ class="bg-[#1e1e1e] border border-[#3e3e42] rounded pl-8 pr-2 py-1 focus:border-indigo-500 focus:outline-none w-40 text-zinc-300 placeholder:text-zinc-600 transition-all focus:w-64"
+ />
+ <svg class="w-3.5 h-3.5 text-zinc-500 absolute left-2.5 top-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
+ {#if searchQuery}
+ <button class="absolute right-2 top-1.5 text-zinc-500 hover:text-white" onclick={() => searchQuery = ""}>✕</button>
+ {/if}
+ </div>
</div>
- <div class="flex gap-2">
- <button on:click={exportLogs} class="text-xs text-zinc-500 hover:text-white px-2 py-1 rounded transition">Export</button>
- <button on:click={clearLogs} class="text-xs text-zinc-500 hover:text-white px-2 py-1 rounded transition">Clear</button>
- <button on:click={() => visible = false} class="text-xs text-zinc-500 hover:text-white px-2 py-1 rounded transition">Close</button>
+
+ <!-- Actions -->
+ <div class="flex items-center gap-2">
+ <!-- Log count indicator -->
+ <span class="text-zinc-500 text-[10px] px-2">{filteredLogs.length} / {logsState.logs.length}</span>
+
+ <button
+ onclick={() => logsState.clear()}
+ class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
+ title="Clear Logs"
+ >
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
+ </button>
+ <button
+ onclick={exportLogs}
+ class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
+ title="Export Filtered Logs"
+ >
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
+ </button>
+ <button
+ onclick={uploadLogs}
+ class="p-1.5 hover:bg-[#3e3e42] rounded text-zinc-400 hover:text-white transition-colors"
+ title="Upload Filtered Logs"
+ >
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
+ </button>
+ <div class="w-px h-4 bg-[#3e3e42] mx-1"></div>
+ <button
+ onclick={() => uiState.toggleConsole()}
+ class="p-1.5 hover:bg-red-500/20 hover:text-red-400 rounded text-zinc-400 transition-colors"
+ title="Close"
+ >
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
+ </button>
</div>
</div>
- <div bind:this={consoleElement} class="flex-1 overflow-y-auto p-4 font-mono text-xs space-y-0.5">
- {#each logs as log}
- <div class="flex whitespace-pre-wrap break-all {log.type === 'stderr' ? 'text-red-400' : log.type === 'launcher' ? 'text-indigo-300' : 'text-zinc-300'}">
- <span class="text-zinc-600 mr-2 shrink-0">{log.timestamp}</span>
- <span class="shrink-0 mr-2 {log.type === 'stderr' ? 'text-red-500' : log.type === 'launcher' ? 'text-indigo-500' : 'text-zinc-500'}">[{log.type === 'launcher' ? 'LAUNCHER' : log.type === 'stderr' ? 'ERROR' : 'GAME'}]</span>
- <span class="break-all">{log.line}</span>
- </div>
+
+ <!-- Log Area -->
+ <div
+ bind:this={consoleElement}
+ onscroll={handleScroll}
+ class="flex-1 overflow-y-auto overflow-x-hidden p-2 select-text custom-scrollbar"
+ >
+ {#each filteredLogs as log (log.id)}
+ <div class="flex gap-2 leading-relaxed hover:bg-[#2a2d2e] px-1 rounded-sm group">
+ <!-- Timestamp -->
+ <span class="text-zinc-500 shrink-0 select-none w-20 text-right opacity-50 group-hover:opacity-100">{log.timestamp.split('.')[0]}</span>
+
+ <!-- Source & Level -->
+ <div class="flex shrink-0 min-w-[140px] gap-1 justify-end font-bold select-none truncate">
+ <span class="text-zinc-500 truncate max-w-[90px]" title={log.source}>{log.source}</span>
+ <span class={getLevelColor(log.level)}>{getLevelLabel(log.level)}</span>
+ </div>
+
+ <!-- Message -->
+ <div class="flex-1 break-all whitespace-pre-wrap text-zinc-300 min-w-0 {getMessageColor(log)}">
+ {@html highlightText(log.message, searchQuery)}
+ </div>
+ </div>
{/each}
- {#if logs.length === 0}
- <div class="text-zinc-600 italic">Waiting for output... Click "Show Logs" and start a game to see logs here.</div>
+
+ {#if filteredLogs.length === 0}
+ <div class="text-center text-zinc-600 mt-10 italic select-none">
+ {#if logsState.logs.length === 0}
+ Waiting for logs...
+ {:else}
+ No logs match current filters.
+ {/if}
+ </div>
{/if}
</div>
+
+ <!-- Auto-scroll status -->
+ {#if !autoScroll}
+ <button
+ onclick={() => { autoScroll = true; consoleElement.scrollTop = consoleElement.scrollHeight; }}
+ class="absolute bottom-4 right-6 bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded shadow-lg text-xs font-bold transition-all animate-bounce"
+ >
+ Resume Auto-scroll ⬇
+ </button>
+ {/if}
</div>
-{/if}
+
+<style>
+ /* Custom Scrollbar for the log area */
+ .custom-scrollbar::-webkit-scrollbar {
+ width: 10px;
+ background-color: #1e1e1e;
+ }
+ .custom-scrollbar::-webkit-scrollbar-thumb {
+ background-color: #424242;
+ border: 2px solid #1e1e1e; /* padding around thumb */
+ border-radius: 0;
+ }
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
+ background-color: #4f4f4f;
+ }
+</style>