mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
90f262e9a4
Introduce a full codex-tui source snapshot under the new codex-tui2 crate so viewport work can be replayed in isolation. This change copies the entire codex-rs/tui/src tree into codex-rs/tui2/src in one atomic step, rather than piecemeal, to keep future diffs vs the original viewport bookmark easy to reason about. The goal is for codex-tui2 to render identically to the existing TUI behind the `features.tui2` flag while we gradually port the viewport/history commits from the joshka/viewport bookmark onto this forked tree. While on this baseline change, we also ran the codex-tui2 snapshot test suite and accepted all insta snapshots for the new crate, so the snapshot files now use the codex-tui2 naming scheme and encode the unmodified legacy TUI behavior. This keeps later viewport commits focused on intentional behavior changes (and their snapshots) rather than on mechanical snapshot renames.
238 lines
7.5 KiB
Rust
238 lines
7.5 KiB
Rust
#![cfg(not(debug_assertions))]
|
||
|
||
use crate::update_action;
|
||
use crate::update_action::UpdateAction;
|
||
use chrono::DateTime;
|
||
use chrono::Duration;
|
||
use chrono::Utc;
|
||
use codex_core::config::Config;
|
||
use codex_core::default_client::create_client;
|
||
use serde::Deserialize;
|
||
use serde::Serialize;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
|
||
use crate::version::CODEX_CLI_VERSION;
|
||
|
||
pub fn get_upgrade_version(config: &Config) -> Option<String> {
|
||
if !config.check_for_update_on_startup {
|
||
return None;
|
||
}
|
||
|
||
let version_file = version_filepath(config);
|
||
let info = read_version_info(&version_file).ok();
|
||
|
||
if match &info {
|
||
None => true,
|
||
Some(info) => info.last_checked_at < Utc::now() - Duration::hours(20),
|
||
} {
|
||
// Refresh the cached latest version in the background so TUI startup
|
||
// isn’t blocked by a network call. The UI reads the previously cached
|
||
// value (if any) for this run; the next run shows the banner if needed.
|
||
tokio::spawn(async move {
|
||
check_for_update(&version_file)
|
||
.await
|
||
.inspect_err(|e| tracing::error!("Failed to update version: {e}"))
|
||
});
|
||
}
|
||
|
||
info.and_then(|info| {
|
||
if is_newer(&info.latest_version, CODEX_CLI_VERSION).unwrap_or(false) {
|
||
Some(info.latest_version)
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
|
||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||
struct VersionInfo {
|
||
latest_version: String,
|
||
// ISO-8601 timestamp (RFC3339)
|
||
last_checked_at: DateTime<Utc>,
|
||
#[serde(default)]
|
||
dismissed_version: Option<String>,
|
||
}
|
||
|
||
const VERSION_FILENAME: &str = "version.json";
|
||
// We use the latest version from the cask if installation is via homebrew - homebrew does not immediately pick up the latest release and can lag behind.
|
||
const HOMEBREW_CASK_URL: &str =
|
||
"https://raw.githubusercontent.com/Homebrew/homebrew-cask/HEAD/Casks/c/codex.rb";
|
||
const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest";
|
||
|
||
#[derive(Deserialize, Debug, Clone)]
|
||
struct ReleaseInfo {
|
||
tag_name: String,
|
||
}
|
||
|
||
fn version_filepath(config: &Config) -> PathBuf {
|
||
config.codex_home.join(VERSION_FILENAME)
|
||
}
|
||
|
||
fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo> {
|
||
let contents = std::fs::read_to_string(version_file)?;
|
||
Ok(serde_json::from_str(&contents)?)
|
||
}
|
||
|
||
async fn check_for_update(version_file: &Path) -> anyhow::Result<()> {
|
||
let latest_version = match update_action::get_update_action() {
|
||
Some(UpdateAction::BrewUpgrade) => {
|
||
let cask_contents = create_client()
|
||
.get(HOMEBREW_CASK_URL)
|
||
.send()
|
||
.await?
|
||
.error_for_status()?
|
||
.text()
|
||
.await?;
|
||
extract_version_from_cask(&cask_contents)?
|
||
}
|
||
_ => {
|
||
let ReleaseInfo {
|
||
tag_name: latest_tag_name,
|
||
} = create_client()
|
||
.get(LATEST_RELEASE_URL)
|
||
.send()
|
||
.await?
|
||
.error_for_status()?
|
||
.json::<ReleaseInfo>()
|
||
.await?;
|
||
extract_version_from_latest_tag(&latest_tag_name)?
|
||
}
|
||
};
|
||
|
||
// Preserve any previously dismissed version if present.
|
||
let prev_info = read_version_info(version_file).ok();
|
||
let info = VersionInfo {
|
||
latest_version,
|
||
last_checked_at: Utc::now(),
|
||
dismissed_version: prev_info.and_then(|p| p.dismissed_version),
|
||
};
|
||
|
||
let json_line = format!("{}\n", serde_json::to_string(&info)?);
|
||
if let Some(parent) = version_file.parent() {
|
||
tokio::fs::create_dir_all(parent).await?;
|
||
}
|
||
tokio::fs::write(version_file, json_line).await?;
|
||
Ok(())
|
||
}
|
||
|
||
fn is_newer(latest: &str, current: &str) -> Option<bool> {
|
||
match (parse_version(latest), parse_version(current)) {
|
||
(Some(l), Some(c)) => Some(l > c),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn extract_version_from_cask(cask_contents: &str) -> anyhow::Result<String> {
|
||
cask_contents
|
||
.lines()
|
||
.find_map(|line| {
|
||
let line = line.trim();
|
||
line.strip_prefix("version \"")
|
||
.and_then(|rest| rest.strip_suffix('"'))
|
||
.map(ToString::to_string)
|
||
})
|
||
.ok_or_else(|| anyhow::anyhow!("Failed to find version in Homebrew cask file"))
|
||
}
|
||
|
||
fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result<String> {
|
||
latest_tag_name
|
||
.strip_prefix("rust-v")
|
||
.map(str::to_owned)
|
||
.ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'"))
|
||
}
|
||
|
||
/// Returns the latest version to show in a popup, if it should be shown.
|
||
/// This respects the user's dismissal choice for the current latest version.
|
||
pub fn get_upgrade_version_for_popup(config: &Config) -> Option<String> {
|
||
if !config.check_for_update_on_startup {
|
||
return None;
|
||
}
|
||
|
||
let version_file = version_filepath(config);
|
||
let latest = get_upgrade_version(config)?;
|
||
// If the user dismissed this exact version previously, do not show the popup.
|
||
if let Ok(info) = read_version_info(&version_file)
|
||
&& info.dismissed_version.as_deref() == Some(latest.as_str())
|
||
{
|
||
return None;
|
||
}
|
||
Some(latest)
|
||
}
|
||
|
||
/// Persist a dismissal for the current latest version so we don't show
|
||
/// the update popup again for this version.
|
||
pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<()> {
|
||
let version_file = version_filepath(config);
|
||
let mut info = match read_version_info(&version_file) {
|
||
Ok(info) => info,
|
||
Err(_) => return Ok(()),
|
||
};
|
||
info.dismissed_version = Some(version.to_string());
|
||
let json_line = format!("{}\n", serde_json::to_string(&info)?);
|
||
if let Some(parent) = version_file.parent() {
|
||
tokio::fs::create_dir_all(parent).await?;
|
||
}
|
||
tokio::fs::write(version_file, json_line).await?;
|
||
Ok(())
|
||
}
|
||
|
||
fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
|
||
let mut iter = v.trim().split('.');
|
||
let maj = iter.next()?.parse::<u64>().ok()?;
|
||
let min = iter.next()?.parse::<u64>().ok()?;
|
||
let pat = iter.next()?.parse::<u64>().ok()?;
|
||
Some((maj, min, pat))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn parses_version_from_cask_contents() {
|
||
let cask = r#"
|
||
cask "codex" do
|
||
version "0.55.0"
|
||
end
|
||
"#;
|
||
assert_eq!(
|
||
extract_version_from_cask(cask).expect("failed to parse version"),
|
||
"0.55.0"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn extracts_version_from_latest_tag() {
|
||
assert_eq!(
|
||
extract_version_from_latest_tag("rust-v1.5.0").expect("failed to parse version"),
|
||
"1.5.0"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn latest_tag_without_prefix_is_invalid() {
|
||
assert!(extract_version_from_latest_tag("v1.5.0").is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn prerelease_version_is_not_considered_newer() {
|
||
assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None);
|
||
assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn plain_semver_comparisons_work() {
|
||
assert_eq!(is_newer("0.11.1", "0.11.0"), Some(true));
|
||
assert_eq!(is_newer("0.11.0", "0.11.1"), Some(false));
|
||
assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true));
|
||
assert_eq!(is_newer("0.9.9", "1.0.0"), Some(false));
|
||
}
|
||
|
||
#[test]
|
||
fn whitespace_is_ignored() {
|
||
assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3)));
|
||
assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true));
|
||
}
|
||
}
|