设置界面支持版本控制和github账号管理

This commit is contained in:
itpkcn@gmail.com
2026-03-21 11:33:48 +08:00
parent e801f041a0
commit 62fab2c3f2
22 changed files with 1595 additions and 4 deletions

View File

@@ -32,7 +32,7 @@ sacp-tokio = "11.0.0-alpha.1"
tokio = { version = "1", features = ["process", "io-util", "sync", "macros", "rt"] }
uuid = { version = "1", features = ["v4"] }
futures = "0.3"
reqwest = { version = "0.12", features = ["stream"] }
reqwest = { version = "0.12", features = ["stream", "json"] }
flate2 = "1"
bzip2 = "0.5"
tar = "0.4"

View File

@@ -5,4 +5,5 @@ pub mod folders;
pub mod mcp;
pub mod system_settings;
pub mod terminal;
pub mod version_control;
pub mod windows;

View File

@@ -0,0 +1,288 @@
use serde::Deserialize;
use tauri::State;
use crate::app_error::AppCommandError;
use crate::db::service::app_metadata_service;
use crate::db::AppDatabase;
use crate::models::{
GitDetectResult, GitHubAccountsSettings, GitHubTokenValidation, GitSettings,
};
const GIT_SETTINGS_KEY: &str = "git_settings";
const GITHUB_ACCOUNTS_KEY: &str = "github_accounts";
// ---------------------------------------------------------------------------
// Git detection
// ---------------------------------------------------------------------------
async fn run_git_version(git_path: &str) -> Result<GitDetectResult, AppCommandError> {
let output = crate::process::tokio_command(git_path)
.arg("--version")
.output()
.await
.map_err(|_| {
AppCommandError::not_found(format!("Cannot execute git at: {git_path}"))
})?;
if !output.status.success() {
return Ok(GitDetectResult {
installed: false,
version: None,
path: Some(git_path.to_string()),
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let version = stdout
.trim()
.strip_prefix("git version ")
.unwrap_or(stdout.trim())
.to_string();
Ok(GitDetectResult {
installed: true,
version: Some(version),
path: Some(git_path.to_string()),
})
}
async fn detect_git_path() -> Option<String> {
let which_cmd = if cfg!(target_os = "windows") {
"where"
} else {
"which"
};
let output = crate::process::tokio_command(which_cmd)
.arg("git")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()?
.trim()
.to_string();
if path.is_empty() {
None
} else {
Some(path)
}
}
#[tauri::command]
pub async fn detect_git(
db: State<'_, AppDatabase>,
) -> Result<GitDetectResult, AppCommandError> {
// Check if there's a custom path configured
let settings = load_git_settings(&db.conn).await?;
if let Some(custom) = &settings.custom_path {
let trimmed = custom.trim();
if !trimmed.is_empty() {
return run_git_version(trimmed).await;
}
}
// Auto-detect
if let Some(path) = detect_git_path().await {
return run_git_version(&path).await;
}
// Fallback: try "git" directly (might be in PATH but `which` failed)
match run_git_version("git").await {
Ok(result) if result.installed => Ok(result),
_ => Ok(GitDetectResult {
installed: false,
version: None,
path: None,
}),
}
}
#[tauri::command]
pub async fn test_git_path(path: String) -> Result<GitDetectResult, AppCommandError> {
let trimmed = path.trim();
if trimmed.is_empty() {
return Err(AppCommandError::invalid_input("Git path cannot be empty"));
}
run_git_version(trimmed).await
}
// ---------------------------------------------------------------------------
// Git settings
// ---------------------------------------------------------------------------
async fn load_git_settings(
conn: &sea_orm::DatabaseConnection,
) -> Result<GitSettings, AppCommandError> {
let raw = app_metadata_service::get_value(conn, GIT_SETTINGS_KEY)
.await
.map_err(AppCommandError::from)?;
match raw {
Some(raw) => serde_json::from_str::<GitSettings>(&raw).map_err(|e| {
AppCommandError::configuration_invalid("Failed to parse stored git settings")
.with_detail(e.to_string())
}),
None => Ok(GitSettings::default()),
}
}
#[tauri::command]
pub async fn get_git_settings(
db: State<'_, AppDatabase>,
) -> Result<GitSettings, AppCommandError> {
load_git_settings(&db.conn).await
}
#[tauri::command]
pub async fn update_git_settings(
settings: GitSettings,
db: State<'_, AppDatabase>,
) -> Result<GitSettings, AppCommandError> {
let serialized = serde_json::to_string(&settings).map_err(|e| {
AppCommandError::invalid_input("Failed to serialize git settings")
.with_detail(e.to_string())
})?;
app_metadata_service::upsert_value(&db.conn, GIT_SETTINGS_KEY, &serialized)
.await
.map_err(AppCommandError::from)?;
Ok(settings)
}
// ---------------------------------------------------------------------------
// GitHub accounts
// ---------------------------------------------------------------------------
async fn load_github_accounts(
conn: &sea_orm::DatabaseConnection,
) -> Result<GitHubAccountsSettings, AppCommandError> {
let raw = app_metadata_service::get_value(conn, GITHUB_ACCOUNTS_KEY)
.await
.map_err(AppCommandError::from)?;
match raw {
Some(raw) => serde_json::from_str::<GitHubAccountsSettings>(&raw).map_err(|e| {
AppCommandError::configuration_invalid("Failed to parse stored GitHub accounts")
.with_detail(e.to_string())
}),
None => Ok(GitHubAccountsSettings::default()),
}
}
#[tauri::command]
pub async fn get_github_accounts(
db: State<'_, AppDatabase>,
) -> Result<GitHubAccountsSettings, AppCommandError> {
load_github_accounts(&db.conn).await
}
#[tauri::command]
pub async fn update_github_accounts(
settings: GitHubAccountsSettings,
db: State<'_, AppDatabase>,
) -> Result<GitHubAccountsSettings, AppCommandError> {
let serialized = serde_json::to_string(&settings).map_err(|e| {
AppCommandError::invalid_input("Failed to serialize GitHub accounts")
.with_detail(e.to_string())
})?;
app_metadata_service::upsert_value(&db.conn, GITHUB_ACCOUNTS_KEY, &serialized)
.await
.map_err(AppCommandError::from)?;
Ok(settings)
}
#[derive(Debug, Deserialize)]
struct GitHubUserResponse {
login: String,
avatar_url: Option<String>,
}
#[tauri::command]
pub async fn validate_github_token(
server_url: String,
token: String,
) -> Result<GitHubTokenValidation, AppCommandError> {
let trimmed_url = server_url.trim().trim_end_matches('/');
let trimmed_token = token.trim();
if trimmed_token.is_empty() {
return Err(AppCommandError::invalid_input("Token cannot be empty"));
}
// Build API URL: github.com uses api.github.com, enterprise uses {url}/api/v3
let api_url = if trimmed_url.is_empty()
|| trimmed_url == "https://github.com"
|| trimmed_url == "http://github.com"
{
"https://api.github.com/user".to_string()
} else {
format!("{trimmed_url}/api/v3/user")
};
let response = reqwest::Client::new()
.get(&api_url)
.header("Authorization", format!("Bearer {trimmed_token}"))
.header("User-Agent", "codeg")
.header("Accept", "application/vnd.github+json")
.send()
.await
.map_err(|e| AppCommandError::network("Failed to connect to GitHub API").with_detail(e.to_string()))?;
if !response.status().is_success() {
let status = response.status().as_u16();
let body = response.text().await.unwrap_or_default();
let message = if status == 401 {
"Invalid or expired token".to_string()
} else {
format!("GitHub API returned status {status}: {body}")
};
return Ok(GitHubTokenValidation {
success: false,
username: None,
scopes: vec![],
avatar_url: None,
message: Some(message),
});
}
// Parse scopes from x-oauth-scopes header
let scopes: Vec<String> = response
.headers()
.get("x-oauth-scopes")
.and_then(|v| v.to_str().ok())
.map(|s| {
s.split(',')
.map(|scope| scope.trim().to_string())
.filter(|scope| !scope.is_empty())
.collect()
})
.unwrap_or_default();
let user = response
.json::<GitHubUserResponse>()
.await
.map_err(|e| {
AppCommandError::network("Failed to parse GitHub API response")
.with_detail(e.to_string())
})?;
Ok(GitHubTokenValidation {
success: true,
username: Some(user.login),
scopes,
avatar_url: user.avatar_url,
message: None,
})
}

View File

@@ -13,7 +13,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use acp::manager::ConnectionManager;
use commands::{
acp as acp_commands, conversations, folder_commands, folders, mcp as mcp_commands,
system_settings, terminal as terminal_commands, windows,
system_settings, terminal as terminal_commands, version_control, windows,
};
use tauri::Manager;
use terminal::manager::TerminalManager;
@@ -264,6 +264,13 @@ pub fn run() {
system_settings::update_system_proxy_settings,
system_settings::get_system_language_settings,
system_settings::update_system_language_settings,
version_control::detect_git,
version_control::test_git_path,
version_control::get_git_settings,
version_control::update_git_settings,
version_control::get_github_accounts,
version_control::validate_github_token,
version_control::update_github_accounts,
acp_commands::acp_preflight,
acp_commands::acp_connect,
acp_commands::acp_prompt,

View File

@@ -12,4 +12,7 @@ pub use conversation::{
};
pub use folder::{FolderCommandInfo, FolderDetail, FolderHistoryEntry, OpenedConversation};
pub use message::{ContentBlock, MessageRole, MessageTurn, TurnRole, TurnUsage, UnifiedMessage};
pub use system::{SystemLanguageSettings, SystemProxySettings};
pub use system::{
GitDetectResult, GitHubAccountsSettings, GitHubTokenValidation, GitSettings,
SystemLanguageSettings, SystemProxySettings,
};

View File

@@ -36,3 +36,45 @@ pub struct SystemLanguageSettings {
pub mode: LanguageMode,
pub language: AppLocale,
}
// --- Version Control ---
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitDetectResult {
pub installed: bool,
pub version: Option<String>,
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct GitSettings {
pub custom_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubAccount {
pub id: String,
pub server_url: String,
pub username: String,
pub token: String,
pub scopes: Vec<String>,
pub avatar_url: Option<String>,
pub is_default: bool,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct GitHubAccountsSettings {
pub accounts: Vec<GitHubAccount>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubTokenValidation {
pub success: bool,
pub username: Option<String>,
pub scopes: Vec<String>,
pub avatar_url: Option<String>,
pub message: Option<String>,
}