Basic crash reporting integrated to Github

This commit is contained in:
Sampo Kivistö 2026-03-07 11:09:31 +02:00
parent b5449c6fe3
commit 97b6fd8a53
No known key found for this signature in database
GPG key ID: 3B426F446F481CFF
7 changed files with 660 additions and 10 deletions

25
.github/ISSUE_TEMPLATE/crash_report.md vendored Normal file
View file

@ -0,0 +1,25 @@
---
name: Crash report
about: Report a GitComet crash
title: "Crash: "
labels: bug
---
## Crash Summary
Describe what you were doing right before GitComet crashed.
## Reproduction Steps
1.
2.
3.
## Expected Behavior
What did you expect to happen instead of the crash?
## Additional Context
Add any extra context that may help investigate the crash.

View file

@ -163,6 +163,10 @@ If the app crashes due to a Rust panic, GitComet writes a crash log to:
- macOS: `~/Library/Logs/gitcomet/crashes/`
- Windows: `%LOCALAPPDATA%\gitcomet\crashes\` (fallback: `%APPDATA%\gitcomet\crashes\`)
On next startup, GitComet can prompt you to report the crash as a prefilled
GitHub issue in `Auto-Explore/GitComet`, including app version, platform,
panic details, and a trimmed backtrace.
### Roadmap (high level)
- Open repositories; show status + commit history timeline.

View file

@ -1,10 +1,23 @@
use std::backtrace::Backtrace;
use std::fmt::Write as _;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
static WRITING_CRASH_LOG: AtomicBool = AtomicBool::new(false);
const CRASH_ISSUE_URL: &str = "https://github.com/Auto-Explore/GitComet/issues/new";
const CRASH_ISSUE_TEMPLATE: &str = "crash_report.md";
const PENDING_REPORT_FILE: &str = "pending-report-path.txt";
const MAX_TITLE_CHARS: usize = 96;
const MAX_BACKTRACE_CHARS: usize = 2_400;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StartupCrashReport {
pub issue_url: String,
pub summary: String,
pub crash_log_path: PathBuf,
}
pub fn install() {
let previous = std::panic::take_hook();
@ -14,6 +27,23 @@ pub fn install() {
}));
}
pub fn take_startup_report() -> Option<StartupCrashReport> {
let dir = crash_dir()?;
take_startup_report_from_dir(&dir)
}
fn take_startup_report_from_dir(dir: &Path) -> Option<StartupCrashReport> {
let pending_path = pending_report_path(dir);
let crash_log_path = std::fs::read_to_string(&pending_path)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(PathBuf::from)?;
let _ = std::fs::remove_file(&pending_path);
let crash_log = std::fs::read_to_string(&crash_log_path).ok()?;
Some(build_startup_report(crash_log_path, &crash_log))
}
fn write_panic_log(info: &std::panic::PanicHookInfo<'_>) {
if WRITING_CRASH_LOG
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
@ -69,6 +99,7 @@ fn write_panic_log(info: &std::panic::PanicHookInfo<'_>) {
let _ = writeln!(file, "backtrace:\n{bt}");
let _ = writeln!(file);
let _ = file.flush();
let _ = std::fs::write(pending_report_path(&dir), path.to_string_lossy().as_ref());
}
fn crash_dir() -> Option<PathBuf> {
@ -126,6 +157,10 @@ fn open_append(path: &Path) -> std::io::Result<File> {
OpenOptions::new().create(true).append(true).open(path)
}
fn pending_report_path(dir: &Path) -> PathBuf {
dir.join(PENDING_REPORT_FILE)
}
fn unix_time_ms() -> u128 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
@ -134,6 +169,257 @@ fn unix_time_ms() -> u128 {
.unwrap_or(0)
}
fn build_startup_report(crash_log_path: PathBuf, crash_log: &str) -> StartupCrashReport {
let parsed = parse_crash_log(crash_log);
let issue_title = build_issue_title(&parsed);
let issue_body = build_issue_body(&parsed, &crash_log_path);
let summary_message = parsed
.message
.as_deref()
.map(single_line_text)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown panic".to_string());
let summary_location = parsed
.location
.as_deref()
.map(single_line_text)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown location".to_string());
StartupCrashReport {
issue_url: build_issue_url(&issue_title, &issue_body),
summary: format!(
"{} at {}",
truncate_chars(&summary_message, 160),
truncate_chars(&summary_location, 160)
),
crash_log_path,
}
}
#[derive(Default)]
struct ParsedCrashLog {
timestamp_unix_ms: Option<String>,
crate_name: Option<String>,
crate_version: Option<String>,
thread: Option<String>,
location: Option<String>,
message: Option<String>,
info: Option<String>,
backtrace: String,
}
fn parse_crash_log(crash_log: &str) -> ParsedCrashLog {
let mut parsed = ParsedCrashLog::default();
let mut in_backtrace = false;
for raw_line in crash_log.lines() {
let line = raw_line.trim_end_matches('\r');
if in_backtrace {
parsed.backtrace.push_str(line);
parsed.backtrace.push('\n');
continue;
}
if line == "backtrace:" {
in_backtrace = true;
continue;
}
if let Some(rest) = line.strip_prefix("backtrace:") {
in_backtrace = true;
let rest = rest.trim_start();
if !rest.is_empty() {
parsed.backtrace.push_str(rest);
parsed.backtrace.push('\n');
}
continue;
}
if let Some(rest) = line.strip_prefix("timestamp_unix_ms=") {
parsed.timestamp_unix_ms = Some(rest.trim().to_string());
continue;
}
if let Some(rest) = line.strip_prefix("crate=") {
if let Some((name, version)) = rest.split_once(" version=") {
parsed.crate_name = Some(name.trim().to_string());
parsed.crate_version = Some(version.trim().to_string());
} else {
parsed.crate_name = Some(rest.trim().to_string());
}
continue;
}
if let Some(rest) = line.strip_prefix("thread=") {
parsed.thread = Some(rest.trim().to_string());
continue;
}
if let Some(rest) = line.strip_prefix("location=") {
parsed.location = Some(rest.trim().to_string());
continue;
}
if let Some(rest) = line.strip_prefix("message=") {
parsed.message = Some(rest.trim().to_string());
continue;
}
if let Some(rest) = line.strip_prefix("info=") {
parsed.info = Some(rest.trim().to_string());
}
}
parsed
}
fn build_issue_url(title: &str, body: &str) -> String {
format!(
"{CRASH_ISSUE_URL}?template={}&title={}&body={}",
percent_encode(CRASH_ISSUE_TEMPLATE),
percent_encode(title),
percent_encode(body)
)
}
fn build_issue_title(parsed: &ParsedCrashLog) -> String {
let panic_message = parsed
.message
.as_deref()
.map(single_line_text)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown panic".to_string());
format!("Crash: {}", truncate_chars(&panic_message, MAX_TITLE_CHARS))
}
fn build_issue_body(parsed: &ParsedCrashLog, crash_log_path: &Path) -> String {
let crate_name = parsed
.crate_name
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(env!("CARGO_PKG_NAME"));
let crate_version = parsed
.crate_version
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(env!("CARGO_PKG_VERSION"));
let timestamp = parsed
.timestamp_unix_ms
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("<unknown>");
let thread = parsed
.thread
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("<unknown>");
let location = parsed
.location
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("<unknown>");
let message = parsed
.message
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("<unknown panic message>");
let info = parsed
.info
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("<unknown panic info>");
let backtrace = {
let trimmed = parsed.backtrace.trim();
if trimmed.is_empty() {
"<no backtrace captured>".to_string()
} else {
truncate_chars(trimmed, MAX_BACKTRACE_CHARS)
}
};
let mut body = String::new();
let _ = writeln!(body, "## Crash Summary");
let _ = writeln!(body);
let _ = writeln!(
body,
"<!-- Please describe what you were doing right before the crash. -->"
);
let _ = writeln!(body, "GitComet crashed with a panic.");
let _ = writeln!(body);
let _ = writeln!(body, "## Environment");
let _ = writeln!(body);
let _ = writeln!(body, "- GitComet crate: `{crate_name}`");
let _ = writeln!(body, "- GitComet version: `{crate_version}`");
let _ = writeln!(body, "- OS: `{}`", std::env::consts::OS);
let _ = writeln!(body, "- Arch: `{}`", std::env::consts::ARCH);
let _ = writeln!(body, "- Crash timestamp (unix ms): `{timestamp}`");
let _ = writeln!(body, "- Thread: `{thread}`");
let _ = writeln!(body, "- Panic location: `{location}`");
let _ = writeln!(body, "- Crash log path: `{}`", crash_log_path.display());
let _ = writeln!(body);
let _ = writeln!(body, "## Panic Message");
let _ = writeln!(body);
let _ = writeln!(body, "```text");
let _ = writeln!(body, "{message}");
let _ = writeln!(body, "```");
let _ = writeln!(body);
let _ = writeln!(body, "## Panic Info");
let _ = writeln!(body);
let _ = writeln!(body, "```text");
let _ = writeln!(body, "{info}");
let _ = writeln!(body, "```");
let _ = writeln!(body);
let _ = writeln!(body, "## Backtrace (trimmed)");
let _ = writeln!(body);
let _ = writeln!(body, "```text");
let _ = writeln!(body, "{backtrace}");
let _ = writeln!(body, "```");
body
}
fn percent_encode(input: &str) -> String {
let mut encoded = String::with_capacity(input.len());
for byte in input.bytes() {
let is_unreserved =
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~');
if is_unreserved {
encoded.push(char::from(byte));
} else {
let _ = write!(encoded, "%{byte:02X}");
}
}
encoded
}
fn single_line_text(input: &str) -> String {
input.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn truncate_chars(input: &str, max_chars: usize) -> String {
if input.chars().count() <= max_chars {
return input.to_string();
}
if max_chars <= 3 {
return ".".repeat(max_chars);
}
let mut out = String::with_capacity(max_chars);
for (idx, ch) in input.chars().enumerate() {
if idx + 3 >= max_chars {
break;
}
out.push(ch);
}
out.push_str("...");
out
}
struct ResetFlagOnDrop;
impl Drop for ResetFlagOnDrop {
@ -141,3 +427,147 @@ impl Drop for ResetFlagOnDrop {
WRITING_CRASH_LOG.store(false, Ordering::SeqCst);
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn percent_encode_encodes_reserved_characters() {
assert_eq!(percent_encode("a b&c/d"), "a%20b%26c%2Fd");
}
#[test]
fn parse_crash_log_extracts_fields() {
let log = r#"=== GitComet crash (panic) ===
timestamp_unix_ms=123
crate=gitcomet-app version=0.1.0
thread=main
location=src/main.rs#L42
message=boom happened
info=panic info
backtrace:
frame 1
frame 2
"#;
let parsed = parse_crash_log(log);
assert_eq!(parsed.timestamp_unix_ms.as_deref(), Some("123"));
assert_eq!(parsed.crate_name.as_deref(), Some("gitcomet-app"));
assert_eq!(parsed.crate_version.as_deref(), Some("0.1.0"));
assert_eq!(parsed.thread.as_deref(), Some("main"));
assert_eq!(parsed.location.as_deref(), Some("src/main.rs#L42"));
assert_eq!(parsed.message.as_deref(), Some("boom happened"));
assert_eq!(parsed.info.as_deref(), Some("panic info"));
assert!(parsed.backtrace.contains("frame 1"));
assert!(parsed.backtrace.contains("frame 2"));
}
#[test]
fn parse_crash_log_supports_inline_backtrace_header() {
let log = "message=boom\nbacktrace:frame 1\nframe 2\n";
let parsed = parse_crash_log(log);
assert_eq!(parsed.message.as_deref(), Some("boom"));
assert!(parsed.backtrace.contains("frame 1"));
assert!(parsed.backtrace.contains("frame 2"));
}
#[test]
fn build_startup_report_populates_issue_url_and_summary() {
let log = r#"timestamp_unix_ms=123
crate=gitcomet-app version=0.1.0
thread=main
location=src/main.rs#L42
message=boom happened
info=panic info
backtrace:
frame 1
frame 2
"#;
let report = build_startup_report(PathBuf::from("/tmp/panic.log"), log);
assert!(report.issue_url.contains("template=crash_report.md"));
assert!(
report
.issue_url
.contains("title=Crash%3A%20boom%20happened")
);
assert!(report.summary.contains("boom happened"));
assert!(report.summary.contains("src/main.rs#L42"));
}
#[test]
fn take_startup_report_from_dir_returns_none_without_pending_marker() {
let dir = tempdir().expect("temp dir");
assert!(take_startup_report_from_dir(dir.path()).is_none());
}
#[test]
fn take_startup_report_from_dir_consumes_pending_marker_and_returns_report() {
let dir = tempdir().expect("temp dir");
let crash_log_path = dir.path().join("panic.log");
let crash_log = r#"timestamp_unix_ms=123
crate=gitcomet-app version=0.1.0
thread=main
location=src/main.rs#L42
message=boom happened
info=panic info
backtrace:
frame 1
frame 2
"#;
std::fs::write(&crash_log_path, crash_log).expect("write crash log");
std::fs::write(
pending_report_path(dir.path()),
crash_log_path.to_string_lossy().as_ref(),
)
.expect("write pending marker");
let report =
take_startup_report_from_dir(dir.path()).expect("startup report should be available");
assert_eq!(report.crash_log_path, crash_log_path);
assert!(report.issue_url.contains("template=crash_report.md"));
assert!(report.summary.contains("boom happened"));
assert!(
!pending_report_path(dir.path()).exists(),
"pending marker should be removed after consumption"
);
}
#[test]
fn take_startup_report_from_dir_missing_log_clears_pending_marker() {
let dir = tempdir().expect("temp dir");
let missing_log_path = dir.path().join("missing.log");
std::fs::write(
pending_report_path(dir.path()),
missing_log_path.to_string_lossy().as_ref(),
)
.expect("write pending marker");
assert!(take_startup_report_from_dir(dir.path()).is_none());
assert!(
!pending_report_path(dir.path()).exists(),
"pending marker should be removed even when crash log is missing"
);
}
#[test]
fn build_issue_body_trims_very_long_backtrace() {
let parsed = ParsedCrashLog {
backtrace: "x".repeat(MAX_BACKTRACE_CHARS + 128),
..Default::default()
};
let body = build_issue_body(&parsed, Path::new("/tmp/panic.log"));
let marker = "## Backtrace (trimmed)\n\n```text\n";
let start = body.find(marker).expect("backtrace section should exist") + marker.len();
let end = start
+ body[start..]
.find("\n```")
.expect("backtrace code block should close");
let backtrace_text = &body[start..end];
assert_eq!(backtrace_text.chars().count(), MAX_BACKTRACE_CHARS);
assert!(backtrace_text.ends_with("..."));
}
}

View file

@ -95,6 +95,7 @@ fn main() {
AppMode::Browser { path } => {
#[cfg(feature = "ui")]
{
let startup_crash_report = crashlog::take_startup_report();
let backend = build_backend();
// Pass path to the UI layer. The existing run() reads
@ -105,14 +106,27 @@ fn main() {
if cfg!(feature = "ui-gpui") {
#[cfg(feature = "ui-gpui")]
{
gitcomet_ui_gpui::run(backend);
let startup_report = startup_crash_report.clone().map(|report| {
gitcomet_ui_gpui::StartupCrashReport {
issue_url: report.issue_url,
summary: report.summary,
crash_log_path: report.crash_log_path,
}
});
gitcomet_ui_gpui::run_with_startup_crash_report(backend, startup_report);
}
#[cfg(not(feature = "ui-gpui"))]
{
if let Some(report) = startup_crash_report.as_ref() {
print_startup_crash_report_hint(report);
}
gitcomet_ui::run(backend);
}
} else {
if let Some(report) = startup_crash_report.as_ref() {
print_startup_crash_report_hint(report);
}
gitcomet_ui::run(backend);
}
}
@ -228,6 +242,16 @@ fn main() {
}
}
#[cfg(feature = "ui")]
fn print_startup_crash_report_hint(report: &crashlog::StartupCrashReport) {
eprintln!("GitComet detected a crash from a previous run.");
eprintln!(
"Open this URL to file a prefilled crash report:\n{}",
report.issue_url
);
eprintln!("Crash log: {}", report.crash_log_path.display());
}
#[cfg(feature = "ui")]
fn build_backend() -> std::sync::Arc<dyn gitcomet_core::services::GitBackend> {
if cfg!(feature = "gix") {

View file

@ -1,7 +1,7 @@
use crate::assets::GitCometAssets;
use crate::view::{
FocusedMergetoolLabels, FocusedMergetoolViewConfig, GitCometView, GitCometViewConfig,
GitCometViewMode,
GitCometViewMode, StartupCrashReport,
};
use gitcomet_core::services::GitBackend;
use gitcomet_state::session;
@ -42,8 +42,18 @@ struct WindowLaunchConfig {
}
pub fn run(backend: Arc<dyn GitBackend>) {
run_with_startup_crash_report(backend, None);
}
pub fn run_with_startup_crash_report(
backend: Arc<dyn GitBackend>,
startup_crash_report: Option<StartupCrashReport>,
) {
let initial_path = std::env::args_os().nth(1).map(std::path::PathBuf::from);
run_windowed_app(backend, normal_launch_config(initial_path));
run_windowed_app(
backend,
normal_launch_config(initial_path, startup_crash_report),
);
}
/// Launch the unified focused mergetool window using the shared `GitCometView`.
@ -56,12 +66,15 @@ pub fn run_focused_mergetool(backend: Arc<dyn GitBackend>, config: FocusedMerget
exit_code.load(Ordering::SeqCst)
}
fn normal_launch_config(initial_path: Option<PathBuf>) -> WindowLaunchConfig {
fn normal_launch_config(
initial_path: Option<PathBuf>,
startup_crash_report: Option<StartupCrashReport>,
) -> WindowLaunchConfig {
WindowLaunchConfig {
title: "GitComet".to_string(),
app_id: "gitcomet".to_string(),
view_config: GitCometViewConfig::normal(initial_path),
use_legacy_constructor: true,
view_config: GitCometViewConfig::normal(initial_path, startup_crash_report),
use_legacy_constructor: false,
}
}
@ -85,6 +98,7 @@ fn focused_mergetool_launch_config(
},
}),
focused_mergetool_exit_code: exit_code,
startup_crash_report: None,
},
use_legacy_constructor: false,
}

View file

@ -5,8 +5,9 @@ mod kit;
mod theme;
mod view;
pub use app::{FocusedMergetoolConfig, run, run_focused_mergetool};
pub use app::{FocusedMergetoolConfig, run, run_focused_mergetool, run_with_startup_crash_report};
pub use focused_diff::{FocusedDiffConfig, run_focused_diff};
pub use view::StartupCrashReport;
#[doc(hidden)]
pub mod benchmarks {

View file

@ -850,19 +850,31 @@ pub struct GitCometViewConfig {
pub view_mode: GitCometViewMode,
pub focused_mergetool: Option<FocusedMergetoolViewConfig>,
pub focused_mergetool_exit_code: Option<Arc<AtomicI32>>,
pub startup_crash_report: Option<StartupCrashReport>,
}
impl GitCometViewConfig {
pub fn normal(initial_path: Option<std::path::PathBuf>) -> Self {
pub fn normal(
initial_path: Option<std::path::PathBuf>,
startup_crash_report: Option<StartupCrashReport>,
) -> Self {
Self {
initial_path,
view_mode: GitCometViewMode::Normal,
focused_mergetool: None,
focused_mergetool_exit_code: None,
startup_crash_report,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StartupCrashReport {
pub issue_url: String,
pub summary: String,
pub crash_log_path: std::path::PathBuf,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FocusedMergetoolLabels {
pub local: String,
@ -1029,6 +1041,7 @@ pub struct GitCometView {
last_mouse_pos: Point<Pixels>,
pending_pull_reconcile_prompt: Option<RepoId>,
pending_force_delete_branch_prompt: Option<(RepoId, String)>,
startup_crash_report: Option<StartupCrashReport>,
error_banner_input: Entity<components::TextInput>,
active_context_menu_invoker: Option<SharedString>,
@ -1105,7 +1118,7 @@ impl GitCometView {
Self::new_with_config(
store,
events,
GitCometViewConfig::normal(initial_path),
GitCometViewConfig::normal(initial_path, None),
window,
cx,
)
@ -1123,6 +1136,7 @@ impl GitCometView {
view_mode,
focused_mergetool,
focused_mergetool_exit_code,
startup_crash_report,
} = config;
if initial_path.is_none() {
initial_path = focused_mergetool.as_ref().map(|cfg| cfg.repo_path.clone());
@ -1403,6 +1417,7 @@ impl GitCometView {
last_mouse_pos: point(px(0.0), px(0.0)),
pending_pull_reconcile_prompt: None,
pending_force_delete_branch_prompt: None,
startup_crash_report,
error_banner_input,
active_context_menu_invoker: None,
};
@ -1613,6 +1628,59 @@ impl GitCometView {
.update(cx, |host, cx| host.push_toast(kind, message, cx));
}
fn open_external_url(&mut self, url: &str) -> Result<(), std::io::Error> {
if url.trim().is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"URL is empty",
));
}
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open").arg(url).spawn()?;
return Ok(());
}
#[cfg(target_os = "windows")]
{
let _ = std::process::Command::new("cmd")
.args(["/C", "start", ""])
.arg(url)
.spawn()?;
return Ok(());
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
{
match std::process::Command::new("xdg-open").arg(url).spawn() {
Ok(_) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
let _ = std::process::Command::new("gio")
.args(["open"])
.arg(url)
.spawn()?;
Ok(())
}
Err(err) => Err(err),
}
}
#[cfg(not(any(
target_os = "macos",
target_os = "windows",
target_os = "linux",
target_os = "freebsd"
)))]
{
let _ = url;
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"Opening URLs is not supported on this platform",
))
}
}
#[cfg(test)]
pub(crate) fn is_popover_open(&self, app: &App) -> bool {
self.popover_host.read(app).is_open()
@ -1749,6 +1817,90 @@ impl Render for GitCometView {
.child(self.title_bar.clone())
.child(center_content);
if let Some(report) = self.startup_crash_report.clone()
&& self.view_mode == GitCometViewMode::Normal
{
let issue_url = report.issue_url.clone();
let summary = report.summary.clone();
let report_button =
components::Button::new("startup_crash_report_open", "Report Issue")
.style(components::ButtonStyle::Filled)
.on_click(theme, cx, move |this, _e, _w, cx| {
match this.open_external_url(&issue_url) {
Ok(()) => {
this.push_toast(
components::ToastKind::Success,
"Opened crash report page in your browser.".to_string(),
cx,
);
this.startup_crash_report = None;
}
Err(err) => {
this.push_toast(
components::ToastKind::Error,
format!("Failed to open browser: {err}"),
cx,
);
}
}
cx.notify();
});
let dismiss_button = components::Button::new("startup_crash_report_dismiss", "Dismiss")
.style(components::ButtonStyle::Outlined)
.on_click(theme, cx, |this, _e, _w, cx| {
this.startup_crash_report = None;
cx.notify();
});
body = body.child(
div()
.relative()
.px_2()
.py_1()
.bg(with_alpha(theme.colors.warning, 0.13))
.border_1()
.border_color(with_alpha(theme.colors.warning, 0.30))
.rounded(px(theme.radii.panel))
.child(
div()
.flex()
.flex_col()
.gap_1()
.child(
div()
.text_sm()
.font_weight(FontWeight::BOLD)
.child("GitComet recovered from program crash"),
)
.child(
div()
.text_sm()
.text_color(theme.colors.text_muted)
.child(
"Would you like to contribute by reporting issue to GitComet GitHub repository?",
),
)
.child(
div()
.text_xs()
.text_color(theme.colors.text_muted)
.child(format!("Summary: {summary}")),
)
.child(
div()
.pt_1()
.flex()
.items_center()
.gap_1()
.child(report_button)
.child(dismiss_button),
),
),
);
}
if let Some(repo_id) = self.active_repo_id()
&& let Some(repo) = self.active_repo()
&& let Some(err) = repo.last_error.as_ref()