Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion httui-desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 22 additions & 0 deletions httui-desktop/src-tauri/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Vec<CrashLog>, 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<String, String> {
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())
}
28 changes: 28 additions & 0 deletions httui-desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String>().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!(
Expand Down Expand Up @@ -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,
Expand Down
142 changes: 142 additions & 0 deletions httui-desktop/src/components/layout/settings/CrashesSection.tsx
Original file line number Diff line number Diff line change
@@ -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<CrashLog[]>([]);
const [selected, setSelected] = useState<string | null>(null);
const [body, setBody] = useState<string>("");

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 (
<Flex direction="column" gap={4}>
<Box>
<Flex align="center" justify="space-between" mb={1}>
<Text fontWeight="semibold" fontSize="sm">
Crash logs
</Text>
<Button
size="xs"
variant="outline"
onClick={handleClear}
disabled={logs.length === 0}
>
Clear all
</Button>
</Flex>
<Text fontSize="xs" color="fg.muted">
Panics captured locally from the app and the language server. Stored
on this machine only — nothing is uploaded.
</Text>
</Box>

{logs.length === 0 ? (
<Text fontSize="sm" color="fg.muted" textAlign="center" py={4}>
No crashes recorded
</Text>
) : (
<VStack gap={1} align="stretch">
{logs.map((log) => (
<Box
key={log.name}
as="button"
textAlign="left"
px={2.5}
py={1.5}
rounded="md"
borderWidth="1px"
borderColor={selected === log.name ? "blue.400" : "border"}
bg={selected === log.name ? "bg.subtle" : "transparent"}
_hover={{ bg: "bg.subtle" }}
onClick={() => handleSelect(log.name)}
>
<HStack gap={2} mb={0.5}>
<Badge size="xs" colorPalette="red" variant="subtle">
{log.source}
</Badge>
<Text fontSize="2xs" color="fg.muted">
{formatTimestamp(log.epoch_ms)}
</Text>
</HStack>
<Text fontSize="xs" truncate fontFamily="mono">
{log.summary || "(empty)"}
</Text>
</Box>
))}
</VStack>
)}

{selected && (
<Box>
<Text fontSize="xs" fontWeight="semibold" color="fg.muted" mb={1}>
{selected}
</Text>
<Box
as="pre"
fontSize="2xs"
fontFamily="mono"
whiteSpace="pre-wrap"
wordBreak="break-word"
bg="bg.subtle"
borderWidth="1px"
borderColor="border"
rounded="md"
p={2}
maxH="320px"
overflow="auto"
>
{body}
</Box>
</Box>
)}
</Flex>
);
}
10 changes: 10 additions & 0 deletions httui-desktop/src/components/layout/settings/SettingsDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
LuInfo,
LuPalette,
LuChartBar,
LuTriangleAlert,
} from "react-icons/lu";
import { useSettingsStore } from "@/stores/settings";
import { AuditSection } from "./AuditSection";
Expand All @@ -26,13 +27,15 @@ import { ShortcutsSection } from "./ShortcutsSection";
import { ThemeSection } from "./ThemeSection";
import { AboutSection } from "./AboutSection";
import { UsageSection } from "./UsageSection";
import { CrashesSection } from "./CrashesSection";

type SettingsTab =
| "general"
| "theme"
| "editor"
| "shortcuts"
| "usage"
| "crashes"
| "audit"
| "about";

Expand Down Expand Up @@ -74,6 +77,12 @@ const TABS: TabDef[] = [
icon: <LuChartBar size={14} />,
group: "advanced",
},
{
id: "crashes",
label: "Crashes",
icon: <LuTriangleAlert size={14} />,
group: "advanced",
},
{
id: "audit",
label: "Audit",
Expand Down Expand Up @@ -191,6 +200,7 @@ export function SettingsDrawer() {
{activeTab === "editor" && <EditorSection />}
{activeTab === "shortcuts" && <ShortcutsSection />}
{activeTab === "usage" && <UsageSection />}
{activeTab === "crashes" && <CrashesSection />}
{activeTab === "audit" && <AuditSection />}
{activeTab === "about" && <AboutSection />}
</Box>
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<CrashesSection />);
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(<CrashesSection />);
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(<CrashesSection />);
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(<CrashesSection />);
await waitFor(() => expect(listCrashLogs).toHaveBeenCalled());
await user.click(screen.getByRole("button", { name: /clear all/i }));
expect(clearCrashLogs).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ vi.mock("../ShortcutsSection", () => ({
vi.mock("../UsageSection", () => ({
UsageSection: () => <div data-testid="section-usage" />,
}));
vi.mock("../CrashesSection", () => ({
CrashesSection: () => <div data-testid="section-crashes" />,
}));
vi.mock("../AuditSection", () => ({
AuditSection: () => <div data-testid="section-audit" />,
}));
Expand Down Expand Up @@ -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(<SettingsDrawer />);
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(<SettingsDrawer />);
Expand Down
Loading