From 08d3bd288ca753fc977be18dbbaca5479866ba4c Mon Sep 17 00:00:00 2001 From: Florian Wilhelm Date: Fri, 21 Aug 2026 12:50:47 +0200 Subject: [PATCH] feat: add About dialog with goreleaser-style version info Capture build metadata (git commit + tree state, build timestamp, rustc version, target triple) at compile time via a build script in code_assistant_core and expose it through a new version module. - CLI: -V prints a one-line summary, --version prints the full block - GPUI: new About dialog opened from an info button in the titlebar, with a Copy button for the version block --- crates/code_assistant/src/cli.rs | 17 +- crates/code_assistant/src/main.rs | 2 +- crates/code_assistant_core/Cargo.toml | 4 + crates/code_assistant_core/build.rs | 66 ++++++ crates/code_assistant_core/src/lib.rs | 1 + crates/code_assistant_core/src/version.rs | 134 +++++++++++ crates/ui_gpui/assets/icons/info.svg | 5 + .../ui_gpui/src/main_screen/about_dialog.rs | 211 ++++++++++++++++++ crates/ui_gpui/src/main_screen/mod.rs | 91 ++++++-- 9 files changed, 511 insertions(+), 20 deletions(-) create mode 100644 crates/code_assistant_core/build.rs create mode 100644 crates/code_assistant_core/src/version.rs create mode 100644 crates/ui_gpui/assets/icons/info.svg create mode 100644 crates/ui_gpui/src/main_screen/about_dialog.rs diff --git a/crates/code_assistant/src/cli.rs b/crates/code_assistant/src/cli.rs index 60b1518a..fc3ae74f 100644 --- a/crates/code_assistant/src/cli.rs +++ b/crates/code_assistant/src/cli.rs @@ -1,5 +1,6 @@ use crate::types::ToolSyntax; -use clap::{Parser, Subcommand, ValueEnum}; +use crate::version; +use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use llm::provider_config::ConfigurationSystem; use sandbox::SandboxPolicy; use std::path::PathBuf; @@ -66,7 +67,7 @@ pub enum Mode { /// Define the application arguments #[derive(Parser, Debug)] -#[command(version, about, long_about = None)] +#[command(about, long_about = None)] pub struct Args { #[command(subcommand)] pub mode: Option, @@ -138,7 +139,17 @@ pub struct Args { impl Args { pub fn parse() -> Self { - ::parse() + // Build the command with goreleaser-style version strings captured at + // compile time: `-V` prints the one-line summary, `--version` prints + // the full commit/build/toolchain block. Leaked to `&'static str` + // because clap wants an owned static; `parse` runs once at startup. + let short: &'static str = Box::leak(version::short().into_boxed_str()); + let long: &'static str = Box::leak(version::long().into_boxed_str()); + let command = ::command() + .version(short) + .long_version(long); + let matches = command.get_matches(); + ::from_arg_matches(&matches).unwrap_or_else(|err| err.exit()) } /// Resolve a model name, ensuring it exists in the configuration and providing a fallback. diff --git a/crates/code_assistant/src/main.rs b/crates/code_assistant/src/main.rs index 85e81f03..327f39ff 100644 --- a/crates/code_assistant/src/main.rs +++ b/crates/code_assistant/src/main.rs @@ -8,7 +8,7 @@ mod logging; #[allow(unused_imports)] pub(crate) use code_assistant_core::{ agent, config, config_dir, persistence, plugins, session, skills, tool_dialects, tools, types, - ui, utils, + ui, utils, version, }; use crate::cli::{Args, Mode}; diff --git a/crates/code_assistant_core/Cargo.toml b/crates/code_assistant_core/Cargo.toml index 821f2402..d888a9bb 100644 --- a/crates/code_assistant_core/Cargo.toml +++ b/crates/code_assistant_core/Cargo.toml @@ -76,6 +76,10 @@ transmutation = { version = "0.3", features = ["office"], optional = true } # Prevent system sleep while agents are running keepawake = "0.6" +[build-dependencies] +# Format the build timestamp as RFC3339 UTC in build.rs. +chrono = "0.4" + [dev-dependencies] axum = "0.7" # test-util enables the paused clock (`start_paused`) for timer tests diff --git a/crates/code_assistant_core/build.rs b/crates/code_assistant_core/build.rs new file mode 100644 index 00000000..07694136 --- /dev/null +++ b/crates/code_assistant_core/build.rs @@ -0,0 +1,66 @@ +//! Build script that captures version/build information at compile time and +//! exposes it to the crate via `cargo:rustc-env` variables. The values mirror +//! the kind of metadata goreleaser embeds into Go binaries (commit, dirty +//! tree, build timestamp, toolchain, target triple). +//! +//! The corresponding runtime accessors live in `src/version.rs`. + +use std::process::Command; + +fn main() { + // Re-run the build script when the git state that feeds the commit/dirty + // fields changes. Working-tree edits that don't touch these files won't + // force a rerun, so the dirty flag / build timestamp reflect the last + // build that observed a relevant change — accurate for clean CI/release + // builds (which always start from a fresh checkout). + for path in [ + "../../.git/HEAD", + "../../.git/index", + "../../.git/refs", + "build.rs", + ] { + println!("cargo:rerun-if-changed={path}"); + } + + // Short git commit hash (full SHA). "unknown" when git is unavailable + // (e.g. building from a source tarball without a .git directory). + let commit = run_git(&["rev-parse", "HEAD"]).unwrap_or_else(|| "unknown".to_string()); + println!("cargo:rustc-env=BUILD_GIT_COMMIT={commit}"); + + // Whether the working tree had uncommitted changes at build time. + let dirty = run_git(&["status", "--porcelain"]) + .map(|out| !out.trim().is_empty()) + .unwrap_or(false); + println!( + "cargo:rustc-env=BUILD_GIT_DIRTY={}", + if dirty { "dirty" } else { "clean" } + ); + + // Build timestamp in RFC3339 UTC (e.g. 2026-08-20T17:01:00Z). + let built = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); + println!("cargo:rustc-env=BUILD_TIMESTAMP={built}"); + + // Rust compiler version, e.g. "rustc 1.83.0 (90b35a623 2024-11-26)". + let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string()); + let rustc_version = Command::new(&rustc) + .arg("--version") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()); + println!("cargo:rustc-env=BUILD_RUSTC_VERSION={rustc_version}"); + + // Target triple the binary is being built for, e.g. "aarch64-apple-darwin". + let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown".to_string()); + println!("cargo:rustc-env=BUILD_TARGET={target}"); +} + +/// Run a git subcommand and return its trimmed stdout on success. +fn run_git(args: &[&str]) -> Option { + let output = Command::new("git").args(args).output().ok()?; + if !output.status.success() { + return None; + } + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} diff --git a/crates/code_assistant_core/src/lib.rs b/crates/code_assistant_core/src/lib.rs index 4dbd2064..49001558 100644 --- a/crates/code_assistant_core/src/lib.rs +++ b/crates/code_assistant_core/src/lib.rs @@ -26,6 +26,7 @@ pub mod tools; pub mod types; pub mod ui; pub mod utils; +pub mod version; // Mock building blocks (LLM provider, UI, project manager, tool fixtures). // Compiled for our own tests and, behind the `test-utils` feature, for diff --git a/crates/code_assistant_core/src/version.rs b/crates/code_assistant_core/src/version.rs new file mode 100644 index 00000000..ab9574c2 --- /dev/null +++ b/crates/code_assistant_core/src/version.rs @@ -0,0 +1,134 @@ +//! Build/version information captured at compile time by `build.rs`. +//! +//! Mirrors the metadata a Go binary built with goreleaser exposes: crate +//! version, git commit + clean/dirty tree state, build timestamp, and the +//! Rust toolchain / target triple. Consumed by the CLI `--version` output and +//! the GPUI "About" dialog. + +/// Crate (semantic) version, e.g. `0.2.16`. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Full git commit SHA the binary was built from, or `unknown`. +pub const GIT_COMMIT: &str = env!("BUILD_GIT_COMMIT"); + +/// Working-tree state at build time: `clean` or `dirty`. +pub const GIT_DIRTY: &str = env!("BUILD_GIT_DIRTY"); + +/// Build timestamp in RFC3339 UTC, e.g. `2026-08-20T17:01:00Z`. +pub const BUILD_TIMESTAMP: &str = env!("BUILD_TIMESTAMP"); + +/// Rust compiler version string, e.g. `rustc 1.83.0 (90b35a623 2024-11-26)`. +pub const RUSTC_VERSION: &str = env!("BUILD_RUSTC_VERSION"); + +/// Target triple, e.g. `aarch64-apple-darwin`. +pub const TARGET: &str = env!("BUILD_TARGET"); + +/// `true` for optimized (non-debug) builds. +pub fn is_release_build() -> bool { + !cfg!(debug_assertions) +} + +/// `true` when the git working tree was clean at build time. +pub fn git_tree_clean() -> bool { + GIT_DIRTY == "clean" +} + +/// Human label for the build profile, e.g. `release build` / `debug build`. +pub fn build_profile() -> &'static str { + if is_release_build() { + "release build" + } else { + "debug build" + } +} + +/// Human label for the git tree state, e.g. `clean git tree` / `dirty git tree`. +pub fn git_tree_label() -> &'static str { + if git_tree_clean() { + "clean git tree" + } else { + "dirty git tree" + } +} + +/// Single-line version summary as consumed by clap's `-V` output. clap +/// prepends the binary name, so this is just `v0.2.16 (release build)` and the +/// printed line becomes `code-assistant v0.2.16 (release build)`. +pub fn short() -> String { + format!("v{VERSION} ({})", build_profile()) +} + +/// Multi-line version block for clap's `--version` output (clap prepends the +/// binary name to the first line), mirroring goreleaser-style metadata: +/// +/// ```text +/// code-assistant v0.2.16 (release build) +/// commit: 6e24def… (clean git tree) +/// built: 2026-08-20T17:01:00Z +/// rust: rustc 1.83.0 (90b35a623 2024-11-26) aarch64-apple-darwin +/// ``` +pub fn long() -> String { + format!( + "{header}\n\ + commit: {commit} ({tree})\n\ + built: {built}\n\ + rust: {rustc} {target}", + header = short(), + commit = GIT_COMMIT, + tree = git_tree_label(), + built = BUILD_TIMESTAMP, + rustc = RUSTC_VERSION, + target = TARGET, + ) +} + +/// Self-contained multi-line version block including the application name on +/// the first line (e.g. for clipboard copy from the About dialog). +pub fn long_with_name() -> String { + format!("code-assistant {}", long()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn short_starts_with_version() { + let s = short(); + assert!(s.starts_with(&format!("v{VERSION}")), "unexpected: {s}"); + assert!(s.ends_with("build)"), "unexpected: {s}"); + } + + #[test] + fn long_has_all_fields() { + let l = long(); + assert!( + l.lines() + .next() + .unwrap() + .starts_with(&format!("v{VERSION}")) + ); + assert!(l.contains("commit: ")); + assert!(l.contains("built: ")); + assert!(l.contains("rust: ")); + assert!(l.contains(TARGET)); + } + + #[test] + fn long_with_name_prefixes_app_name() { + assert!(long_with_name().starts_with("code-assistant v")); + } + + #[test] + fn labels_match_flags() { + assert_eq!( + build_profile(), + if cfg!(debug_assertions) { + "debug build" + } else { + "release build" + } + ); + assert!(matches!(GIT_DIRTY, "clean" | "dirty")); + } +} diff --git a/crates/ui_gpui/assets/icons/info.svg b/crates/ui_gpui/assets/icons/info.svg new file mode 100644 index 00000000..91338f78 --- /dev/null +++ b/crates/ui_gpui/assets/icons/info.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/ui_gpui/src/main_screen/about_dialog.rs b/crates/ui_gpui/src/main_screen/about_dialog.rs new file mode 100644 index 00000000..f2b07602 --- /dev/null +++ b/crates/ui_gpui/src/main_screen/about_dialog.rs @@ -0,0 +1,211 @@ +//! A modal "About" dialog showing goreleaser-style build/version information +//! (version, git commit + tree state, build timestamp, Rust toolchain and +//! target triple). Modeled on [`super::project_dialog::NewProjectDialog`]. + +use code_assistant_core::version; +use gpui::{ + ClipboardItem, Context, EventEmitter, FocusHandle, Focusable, SharedString, Window, div, + prelude::*, px, +}; +use gpui_component::{ActiveTheme, Icon, Sizable, Size, StyledExt}; + +/// Events emitted by the [`AboutDialog`]. +#[derive(Clone, Debug)] +pub enum AboutDialogEvent { + /// User closed the dialog. + Closed, +} + +pub struct AboutDialog { + focus_handle: FocusHandle, +} + +impl AboutDialog { + pub fn new(_window: &mut Window, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + } + } + + fn close(&mut self, cx: &mut Context) { + cx.emit(AboutDialogEvent::Closed); + } + + fn copy(&mut self, cx: &mut Context) { + cx.write_to_clipboard(ClipboardItem::new_string(version::long_with_name())); + } + + /// A single label/value info row. + fn info_row(label: &str, value: String, cx: &Context) -> gpui::AnyElement { + div() + .flex() + .flex_row() + .gap_2() + .items_baseline() + .child( + div() + .flex_none() + .w(px(64.)) + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(label.to_string())), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_xs() + .font_family("monospace") + .text_color(cx.theme().foreground) + .child(SharedString::from(value)), + ) + .into_any_element() + } +} + +impl EventEmitter for AboutDialog {} + +impl Focusable for AboutDialog { + fn focus_handle(&self, _: &gpui::App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for AboutDialog { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Full-screen overlay with a backdrop + div() + .id("about-dialog-backdrop") + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .bg(cx.theme().background.opacity(0.6)) + .on_mouse_down( + gpui::MouseButton::Left, + cx.listener(|this, _, _, cx| this.close(cx)), + ) + .child( + // Dialog card + div() + .id("about-dialog") + .w(px(460.)) + .bg(cx.theme().popover) + .border_1() + .border_color(cx.theme().border) + .rounded_lg() + .shadow_lg() + .p_4() + .flex() + .flex_col() + .gap_3() + // Prevent backdrop click from closing when clicking inside dialog + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + // Header: icon + app name + version summary + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .child( + Icon::default() + .path(SharedString::from("icons/info.svg")) + .with_size(Size::Medium) + .text_color(cx.theme().primary), + ) + .child( + div() + .flex() + .flex_col() + .child( + div() + .text_base() + .font_medium() + .text_color(cx.theme().foreground) + .child("Code Assistant"), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!( + "v{} ({})", + version::VERSION, + version::build_profile() + ))), + ), + ), + ) + // Info rows + .child( + div() + .flex() + .flex_col() + .gap_1() + .child(Self::info_row( + "commit", + format!("{} ({})", version::GIT_COMMIT, version::git_tree_label()), + cx, + )) + .child(Self::info_row( + "built", + version::BUILD_TIMESTAMP.to_string(), + cx, + )) + .child(Self::info_row( + "rust", + format!("{} {}", version::RUSTC_VERSION, version::TARGET), + cx, + )), + ) + // Buttons row + .child( + div() + .flex() + .justify_end() + .gap_2() + // Copy button + .child( + div() + .id("about-copy-btn") + .px_3() + .py_1() + .rounded_md() + .cursor_pointer() + .border_1() + .border_color(cx.theme().border) + .hover(|s| s.bg(cx.theme().muted.opacity(0.5))) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Copy"), + ) + .on_click(cx.listener(|this, _, _, cx| this.copy(cx))), + ) + // Close button + .child( + div() + .id("about-close-btn") + .px_3() + .py_1() + .rounded_md() + .cursor_pointer() + .bg(cx.theme().primary) + .hover(|s| s.bg(cx.theme().primary.opacity(0.8))) + .child( + div() + .text_sm() + .text_color(cx.theme().primary_foreground) + .child("Close"), + ) + .on_click(cx.listener(|this, _, _, cx| this.close(cx))), + ), + ), + ) + } +} diff --git a/crates/ui_gpui/src/main_screen/mod.rs b/crates/ui_gpui/src/main_screen/mod.rs index ba428853..9dacdfaf 100644 --- a/crates/ui_gpui/src/main_screen/mod.rs +++ b/crates/ui_gpui/src/main_screen/mod.rs @@ -1,3 +1,4 @@ +mod about_dialog; pub mod project_dialog; mod status_popover; @@ -14,6 +15,7 @@ use crate::{CloseWindow, Gpui, UiEventSender, UiSettingsGlobal, WorktreeData}; use code_assistant_core::ui::ui_events::UiEvent; +use about_dialog::{AboutDialog, AboutDialogEvent}; use project_dialog::{NewProjectDialog, NewProjectDialogEvent}; use gpui::{ @@ -87,6 +89,8 @@ pub struct MainScreen { last_worktree_data: Option, /// Modal dialog for creating a new project (shown as overlay when Some) new_project_dialog: Option>, + /// Modal "About" dialog (shown as overlay when Some) + about_dialog: Option>, /// Pending folder path from the file picker, waiting to create the dialog in render pending_project_path: Option, @@ -105,6 +109,7 @@ pub struct MainScreen { _plan_banner_subscription: Subscription, _project_sidebar_subscription: Subscription, _new_project_dialog_subscription: Option, + _about_dialog_subscription: Option, _window_bounds_subscription: Subscription, } @@ -158,6 +163,7 @@ impl MainScreen { plan_collapsed: false, last_worktree_data: None, new_project_dialog: None, + about_dialog: None, pending_project_path: None, sidebar_animation_state: SidebarAnimationState::Idle, @@ -170,6 +176,7 @@ impl MainScreen { _plan_banner_subscription: plan_banner_subscription, _project_sidebar_subscription: project_sidebar_subscription, _new_project_dialog_subscription: None, + _about_dialog_subscription: None, _window_bounds_subscription: window_bounds_subscription, }; @@ -200,6 +207,30 @@ impl MainScreen { cx.emit(MainScreenEvent::OpenSettings); } + fn on_open_about(&mut self, _: &ClickEvent, window: &mut gpui::Window, cx: &mut Context) { + let dialog = cx.new(|cx| AboutDialog::new(window, cx)); + let subscription = cx.subscribe_in(&dialog, window, Self::on_about_dialog_event); + self.about_dialog = Some(dialog); + self._about_dialog_subscription = Some(subscription); + cx.notify(); + } + + fn on_about_dialog_event( + &mut self, + _dialog: &Entity, + event: &AboutDialogEvent, + _window: &mut gpui::Window, + cx: &mut Context, + ) { + match event { + AboutDialogEvent::Closed => { + self.about_dialog = None; + self._about_dialog_subscription = None; + cx.notify(); + } + } + } + // ── Sidebar animation ───────────────────────────────────────────────── fn start_sidebar_animation(&mut self, should_expand: bool, cx: &mut Context) { @@ -1349,6 +1380,7 @@ impl Render for MainScreen { } let new_project_dialog = self.new_project_dialog.clone(); + let about_dialog = self.about_dialog.clone(); let sidebar_scale = self.sidebar_animation_scale(); let permission_prompts = self.render_permission_prompts(cx); @@ -1485,31 +1517,56 @@ impl Render for MainScreen { .on_click(cx.listener(Self::on_zoom_in)), ), ) - // Right side - settings button + // Right side - about + settings buttons .child( div() - .id("settings-btn") .flex() .items_center() .gap_1() - .px_2() - .py_1() - .rounded_sm() - .cursor_pointer() - .hover(|s| s.bg(cx.theme().muted)) .child( - Icon::default() - .path(SharedString::from("icons/settings.svg")) - .with_size(Size::Small) - .text_color(cx.theme().muted_foreground), + div() + .id("about-btn") + .size(px(28.)) + .rounded_sm() + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().muted)) + .child( + Icon::default() + .path(SharedString::from("icons/info.svg")) + .with_size(Size::Small) + .text_color(cx.theme().muted_foreground), + ) + .on_click(cx.listener(Self::on_open_about)), ) + // Settings button .child( div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("Settings"), - ) - .on_click(cx.listener(Self::on_open_settings)), + .id("settings-btn") + .flex() + .items_center() + .gap_1() + .px_2() + .py_1() + .rounded_sm() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().muted)) + .child( + Icon::default() + .path(SharedString::from("icons/settings.svg")) + .with_size(Size::Small) + .text_color(cx.theme().muted_foreground), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Settings"), + ) + .on_click(cx.listener(Self::on_open_settings)), + ), ), ) // Main content area with project sidebar and messages+input (2-column layout) @@ -1594,6 +1651,8 @@ impl Render for MainScreen { ) // Modal dialog overlay for new project creation .when_some(new_project_dialog, |el, dialog| el.child(dialog)) + // Modal "About" dialog overlay + .when_some(about_dialog, |el, dialog| el.child(dialog)) } }