From 9d8b012c4f90d944a857b937dcccbbdaaa0b7768 Mon Sep 17 00:00:00 2001 From: gandarfh Date: Tue, 16 Jun 2026 09:33:06 -0300 Subject: [PATCH] feat(desktop): crash viewer panel with global panic capture --- Cargo.lock | 6 +- httui-desktop/src-tauri/Cargo.toml | 2 +- .../src-tauri/src/commands/settings.rs | 22 +++ httui-desktop/src-tauri/src/main.rs | 28 ++++ .../layout/settings/CrashesSection.tsx | 142 ++++++++++++++++++ .../layout/settings/SettingsDrawer.tsx | 10 ++ .../__tests__/CrashesSection.render.test.tsx | 70 +++++++++ .../__tests__/SettingsDrawer.test.tsx | 10 ++ .../src/lib/tauri/__tests__/crashes.test.ts | 46 ++++++ httui-desktop/src/lib/tauri/crashes.ts | 29 ++++ 10 files changed, 361 insertions(+), 4 deletions(-) create mode 100644 httui-desktop/src/components/layout/settings/CrashesSection.tsx create mode 100644 httui-desktop/src/components/layout/settings/__tests__/CrashesSection.render.test.tsx create mode 100644 httui-desktop/src/lib/tauri/__tests__/crashes.test.ts create mode 100644 httui-desktop/src/lib/tauri/crashes.ts diff --git a/Cargo.lock b/Cargo.lock index c6cdd177..5306cfa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2267,7 +2267,7 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httui-core" version = "0.1.0" -source = "git+https://github.com/httuicom/httui-core?rev=6e2f97db9f3201d931c59fe1c4da93904355f73f#6e2f97db9f3201d931c59fe1c4da93904355f73f" +source = "git+https://github.com/httuicom/httui-core?rev=3a36cc75e9bc1f5e15e0220332272225d6f7690e#3a36cc75e9bc1f5e15e0220332272225d6f7690e" dependencies = [ "async-trait", "base64 0.22.1", @@ -2294,7 +2294,7 @@ dependencies = [ [[package]] name = "httui-core" version = "0.1.0" -source = "git+https://github.com/httuicom/httui-core?rev=c331047ae6812125653633f651a7cdb05ad79d41#c331047ae6812125653633f651a7cdb05ad79d41" +source = "git+https://github.com/httuicom/httui-core?rev=6e2f97db9f3201d931c59fe1c4da93904355f73f#6e2f97db9f3201d931c59fe1c4da93904355f73f" dependencies = [ "async-trait", "base64 0.22.1", @@ -2346,7 +2346,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "hmac 0.13.0", - "httui-core 0.1.0 (git+https://github.com/httuicom/httui-core?rev=c331047ae6812125653633f651a7cdb05ad79d41)", + "httui-core 0.1.0 (git+https://github.com/httuicom/httui-core?rev=3a36cc75e9bc1f5e15e0220332272225d6f7690e)", "image", "notify", "regex", diff --git a/httui-desktop/src-tauri/Cargo.toml b/httui-desktop/src-tauri/Cargo.toml index 4cca4a8a..36530ac8 100644 --- a/httui-desktop/src-tauri/Cargo.toml +++ b/httui-desktop/src-tauri/Cargo.toml @@ -21,7 +21,7 @@ lang-version = "0.2.7" tauri-build = { version = "2", features = [] } [dependencies] -httui-core = { git = "https://github.com/httuicom/httui-core", rev = "c331047ae6812125653633f651a7cdb05ad79d41" } +httui-core = { git = "https://github.com/httuicom/httui-core", rev = "3a36cc75e9bc1f5e15e0220332272225d6f7690e" } tauri = { version = "2", features = ["protocol-asset"] } tauri-plugin-fs = "2" tauri-plugin-shell = "2" diff --git a/httui-desktop/src-tauri/src/commands/settings.rs b/httui-desktop/src-tauri/src/commands/settings.rs index c9b33fc0..de71910b 100644 --- a/httui-desktop/src-tauri/src/commands/settings.rs +++ b/httui-desktop/src-tauri/src/commands/settings.rs @@ -6,6 +6,7 @@ use sqlx::sqlite::SqlitePool; use tauri::State; use httui_core::config; +use httui_core::crash_log::{self, CrashLog}; use httui_core::db::feature_usage::{self, FeatureUsage}; /// Read a single key from the `app_config` table. @@ -65,3 +66,24 @@ pub async fn get_feature_usage( pub async fn clear_feature_usage(pool: State<'_, SqlitePool>) -> Result<(), String> { feature_usage::clear_feature_usage(&pool).await } + +/// List local crash logs newest-first. Powers the Crashes settings panel. +#[tauri::command] +pub fn list_crash_logs() -> Result, String> { + let dir = crash_log::crashes_dir().map_err(|e| e.to_string())?; + Ok(crash_log::list_crashes(&dir)) +} + +/// Read one crash log's full body by file name. +#[tauri::command] +pub fn read_crash_log(name: String) -> Result { + let dir = crash_log::crashes_dir().map_err(|e| e.to_string())?; + crash_log::read_crash(&dir, &name).map_err(|e| e.to_string()) +} + +/// Delete every local crash log. Backs the panel's clear control. +#[tauri::command] +pub fn clear_crash_logs() -> Result<(), String> { + let dir = crash_log::crashes_dir().map_err(|e| e.to_string())?; + crash_log::clear_crashes(&dir).map_err(|e| e.to_string()) +} diff --git a/httui-desktop/src-tauri/src/main.rs b/httui-desktop/src-tauri/src/main.rs index 08575a9c..8da1c33f 100644 --- a/httui-desktop/src-tauri/src/main.rs +++ b/httui-desktop/src-tauri/src/main.rs @@ -308,6 +308,31 @@ fn main() { let app_data_dir = httui_core::paths::default_data_dir().expect("failed to resolve data dir"); + // Persist a crash log before the default hook prints to + // stderr, so the in-app Crashes panel can surface the panic + // after a restart. Best-effort — write_crash never panics. + { + let crashes = httui_core::crash_log::crashes_dir() + .unwrap_or_else(|_| app_data_dir.join("crashes")); + let default_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let payload = info + .payload() + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| info.payload().downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic".to_string()); + let location = info + .location() + .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column())) + .unwrap_or_default(); + let backtrace = std::backtrace::Backtrace::force_capture(); + let body = format!("{location}\n{payload}\n\n{backtrace}"); + httui_core::crash_log::write_crash(&crashes, "desktop", &body); + default_hook(info); + })); + } + match httui_core::paths::migrate_legacy_data(&app_data_dir) { Ok(httui_core::paths::MigrationOutcome::Migrated { from }) => { eprintln!( @@ -513,6 +538,9 @@ fn main() { httui_notes::commands::settings::record_feature_usage, httui_notes::commands::settings::get_feature_usage, httui_notes::commands::settings::clear_feature_usage, + httui_notes::commands::settings::list_crash_logs, + httui_notes::commands::settings::read_crash_log, + httui_notes::commands::settings::clear_crash_logs, force_reload_file, query_internal_db, httui_notes::lsp_sidecar::lsp_start, diff --git a/httui-desktop/src/components/layout/settings/CrashesSection.tsx b/httui-desktop/src/components/layout/settings/CrashesSection.tsx new file mode 100644 index 00000000..dc9bc089 --- /dev/null +++ b/httui-desktop/src/components/layout/settings/CrashesSection.tsx @@ -0,0 +1,142 @@ +import { useState, useEffect, useCallback } from "react"; +import { + Box, + Flex, + HStack, + VStack, + Text, + Button, + Badge, +} from "@chakra-ui/react"; +import { + listCrashLogs, + readCrashLog, + clearCrashLogs, + type CrashLog, +} from "@/lib/tauri/crashes"; + +function formatTimestamp(epochMs: number): string { + return new Date(epochMs).toLocaleString(); +} + +export function CrashesSection() { + const [logs, setLogs] = useState([]); + const [selected, setSelected] = useState(null); + const [body, setBody] = useState(""); + + const refresh = useCallback(async () => { + try { + setLogs(await listCrashLogs()); + } catch (e) { + console.error("Failed to list crash logs:", e); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const handleSelect = useCallback(async (name: string) => { + setSelected(name); + try { + setBody(await readCrashLog(name)); + } catch (e) { + setBody(`Failed to read crash log: ${String(e)}`); + } + }, []); + + const handleClear = useCallback(async () => { + try { + await clearCrashLogs(); + setSelected(null); + setBody(""); + await refresh(); + } catch (e) { + console.error("Failed to clear crash logs:", e); + } + }, [refresh]); + + return ( + + + + + Crash logs + + + + + Panics captured locally from the app and the language server. Stored + on this machine only — nothing is uploaded. + + + + {logs.length === 0 ? ( + + No crashes recorded + + ) : ( + + {logs.map((log) => ( + handleSelect(log.name)} + > + + + {log.source} + + + {formatTimestamp(log.epoch_ms)} + + + + {log.summary || "(empty)"} + + + ))} + + )} + + {selected && ( + + + {selected} + + + {body} + + + )} + + ); +} diff --git a/httui-desktop/src/components/layout/settings/SettingsDrawer.tsx b/httui-desktop/src/components/layout/settings/SettingsDrawer.tsx index 15bd4c22..94096442 100644 --- a/httui-desktop/src/components/layout/settings/SettingsDrawer.tsx +++ b/httui-desktop/src/components/layout/settings/SettingsDrawer.tsx @@ -17,6 +17,7 @@ import { LuInfo, LuPalette, LuChartBar, + LuTriangleAlert, } from "react-icons/lu"; import { useSettingsStore } from "@/stores/settings"; import { AuditSection } from "./AuditSection"; @@ -26,6 +27,7 @@ import { ShortcutsSection } from "./ShortcutsSection"; import { ThemeSection } from "./ThemeSection"; import { AboutSection } from "./AboutSection"; import { UsageSection } from "./UsageSection"; +import { CrashesSection } from "./CrashesSection"; type SettingsTab = | "general" @@ -33,6 +35,7 @@ type SettingsTab = | "editor" | "shortcuts" | "usage" + | "crashes" | "audit" | "about"; @@ -74,6 +77,12 @@ const TABS: TabDef[] = [ icon: , group: "advanced", }, + { + id: "crashes", + label: "Crashes", + icon: , + group: "advanced", + }, { id: "audit", label: "Audit", @@ -191,6 +200,7 @@ export function SettingsDrawer() { {activeTab === "editor" && } {activeTab === "shortcuts" && } {activeTab === "usage" && } + {activeTab === "crashes" && } {activeTab === "audit" && } {activeTab === "about" && } diff --git a/httui-desktop/src/components/layout/settings/__tests__/CrashesSection.render.test.tsx b/httui-desktop/src/components/layout/settings/__tests__/CrashesSection.render.test.tsx new file mode 100644 index 00000000..86a1e8f9 --- /dev/null +++ b/httui-desktop/src/components/layout/settings/__tests__/CrashesSection.render.test.tsx @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "@/test/render"; + +const listCrashLogs = vi.fn(); +const readCrashLog = vi.fn(); +const clearCrashLogs = vi.fn(); +vi.mock("@/lib/tauri/crashes", () => ({ + listCrashLogs: () => listCrashLogs(), + readCrashLog: (name: string) => readCrashLog(name), + clearCrashLogs: () => clearCrashLogs(), +})); + +import { CrashesSection } from "../CrashesSection"; + +const ROW = { + name: "200-desktop.log", + source: "desktop", + epoch_ms: 200, + summary: "thread panicked at boom", +}; + +beforeEach(() => { + listCrashLogs.mockResolvedValue([]); + readCrashLog.mockResolvedValue("thread panicked at boom\nbacktrace..."); + clearCrashLogs.mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("CrashesSection", () => { + it("shows the empty state when there are no crashes", async () => { + renderWithProviders(); + await waitFor(() => expect(listCrashLogs).toHaveBeenCalled()); + expect(screen.getByText("No crashes recorded")).toBeTruthy(); + }); + + it("lists crash rows with their source and summary", async () => { + listCrashLogs.mockResolvedValue([ROW]); + renderWithProviders(); + await waitFor(() => + expect(screen.getByText("thread panicked at boom")).toBeTruthy(), + ); + expect(screen.getByText("desktop")).toBeTruthy(); + }); + + it("reads and shows the body when a row is selected", async () => { + const user = userEvent.setup(); + listCrashLogs.mockResolvedValue([ROW]); + renderWithProviders(); + await waitFor(() => + expect(screen.getByText("thread panicked at boom")).toBeTruthy(), + ); + await user.click(screen.getByText("thread panicked at boom")); + await waitFor(() => expect(readCrashLog).toHaveBeenCalledWith(ROW.name)); + expect(screen.getByText(/backtrace\.\.\./)).toBeTruthy(); + }); + + it("clears all crash logs via the button", async () => { + const user = userEvent.setup(); + listCrashLogs.mockResolvedValue([ROW]); + renderWithProviders(); + await waitFor(() => expect(listCrashLogs).toHaveBeenCalled()); + await user.click(screen.getByRole("button", { name: /clear all/i })); + expect(clearCrashLogs).toHaveBeenCalledTimes(1); + }); +}); diff --git a/httui-desktop/src/components/layout/settings/__tests__/SettingsDrawer.test.tsx b/httui-desktop/src/components/layout/settings/__tests__/SettingsDrawer.test.tsx index 13d04871..5d6b5bec 100644 --- a/httui-desktop/src/components/layout/settings/__tests__/SettingsDrawer.test.tsx +++ b/httui-desktop/src/components/layout/settings/__tests__/SettingsDrawer.test.tsx @@ -20,6 +20,9 @@ vi.mock("../ShortcutsSection", () => ({ vi.mock("../UsageSection", () => ({ UsageSection: () =>
, })); +vi.mock("../CrashesSection", () => ({ + CrashesSection: () =>
, +})); vi.mock("../AuditSection", () => ({ AuditSection: () =>
, })); @@ -59,6 +62,13 @@ describe("SettingsDrawer", () => { expect(screen.queryByTestId("section-general")).toBeNull(); }); + it("switches to the Crashes tab on click", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByText("Crashes")); + expect(screen.getByTestId("section-crashes")).toBeTruthy(); + }); + it("switches to the Audit tab on click", async () => { const user = userEvent.setup(); renderWithProviders(); diff --git a/httui-desktop/src/lib/tauri/__tests__/crashes.test.ts b/httui-desktop/src/lib/tauri/__tests__/crashes.test.ts new file mode 100644 index 00000000..d22b5897 --- /dev/null +++ b/httui-desktop/src/lib/tauri/__tests__/crashes.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + listCrashLogs, + readCrashLog, + clearCrashLogs, +} from "@/lib/tauri/crashes"; +import { clearTauriMocks, mockTauriCommand } from "@/test/mocks/tauri"; + +afterEach(() => { + clearTauriMocks(); +}); + +describe("crashes Tauri wrappers", () => { + it("listCrashLogs invokes 'list_crash_logs' and returns the rows", async () => { + mockTauriCommand("list_crash_logs", () => [ + { + name: "200-desktop.log", + source: "desktop", + epoch_ms: 200, + summary: "boom", + }, + ]); + const rows = await listCrashLogs(); + expect(rows).toHaveLength(1); + expect(rows[0].source).toBe("desktop"); + }); + + it("readCrashLog forwards the name and returns the body", async () => { + mockTauriCommand("read_crash_log", (args) => { + expect(args).toEqual({ name: "200-desktop.log" }); + return "boom\nbacktrace"; + }); + expect(await readCrashLog("200-desktop.log")).toBe("boom\nbacktrace"); + }); + + it("clearCrashLogs invokes 'clear_crash_logs'", async () => { + let called = false; + mockTauriCommand("clear_crash_logs", () => { + called = true; + return null; + }); + await clearCrashLogs(); + expect(called).toBe(true); + }); +}); diff --git a/httui-desktop/src/lib/tauri/crashes.ts b/httui-desktop/src/lib/tauri/crashes.ts new file mode 100644 index 00000000..38241e2a --- /dev/null +++ b/httui-desktop/src/lib/tauri/crashes.ts @@ -0,0 +1,29 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** One crash log's metadata. Mirrors the Rust `CrashLog` struct + * (`httui_core::crash_log`). The body is fetched separately. */ +export interface CrashLog { + /** File name — the id passed to `readCrashLog`. */ + name: string; + /** Origin tag (e.g. `desktop`, `lsp`). */ + source: string; + /** Capture time, Unix epoch milliseconds. */ + epoch_ms: number; + /** First non-empty line of the body, for the list preview. */ + summary: string; +} + +/** List local crash logs, newest-first. */ +export function listCrashLogs(): Promise { + return invoke("list_crash_logs"); +} + +/** Read one crash log's full body by file name. */ +export function readCrashLog(name: string): Promise { + return invoke("read_crash_log", { name }); +} + +/** Delete every local crash log. */ +export function clearCrashLogs(): Promise { + return invoke("clear_crash_logs"); +}