Basic crash reporting integrated to Github
This commit is contained in:
parent
b5449c6fe3
commit
97b6fd8a53
7 changed files with 660 additions and 10 deletions
25
.github/ISSUE_TEMPLATE/crash_report.md
vendored
Normal file
25
.github/ISSUE_TEMPLATE/crash_report.md
vendored
Normal 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.
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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("..."));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue