欢迎页面支持多语言

This commit is contained in:
xintaofei
2026-03-07 12:17:57 +08:00
parent aeecc4769c
commit 6c48be023c
15 changed files with 685 additions and 72 deletions

View File

@@ -0,0 +1,83 @@
use serde::Serialize;
use crate::db::error::DbError;
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AppErrorCode {
Unknown,
InvalidInput,
NotFound,
AlreadyExists,
PermissionDenied,
DependencyMissing,
NetworkError,
AuthenticationFailed,
DatabaseError,
IoError,
ExternalCommandFailed,
WindowOperationFailed,
}
#[derive(Debug, Clone, Serialize, thiserror::Error)]
#[error("{message}")]
pub struct AppCommandError {
pub code: AppErrorCode,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
impl AppCommandError {
pub fn new(code: AppErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
detail: None,
}
}
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
pub fn db(err: DbError) -> Self {
Self::new(AppErrorCode::DatabaseError, "Database operation failed")
.with_detail(err.to_string())
}
#[allow(dead_code)]
pub fn io(err: std::io::Error) -> Self {
let code = match err.kind() {
std::io::ErrorKind::NotFound => AppErrorCode::NotFound,
std::io::ErrorKind::PermissionDenied => AppErrorCode::PermissionDenied,
std::io::ErrorKind::AlreadyExists => AppErrorCode::AlreadyExists,
_ => AppErrorCode::IoError,
};
let message = match code {
AppErrorCode::NotFound => "Resource not found",
AppErrorCode::PermissionDenied => "Permission denied",
AppErrorCode::AlreadyExists => "Resource already exists",
_ => "I/O operation failed",
};
Self::new(code, message).with_detail(err.to_string())
}
pub fn window(message: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(AppErrorCode::WindowOperationFailed, message).with_detail(detail)
}
pub fn external_command(message: impl Into<String>, detail: impl Into<String>) -> Self {
Self::new(AppErrorCode::ExternalCommandFailed, message).with_detail(detail)
}
}
impl From<DbError> for AppCommandError {
fn from(value: DbError) -> Self {
Self::db(value)
}
}

View File

@@ -13,6 +13,7 @@ use tauri::Emitter;
use tokio::sync::Semaphore;
use walkdir::WalkDir;
use crate::app_error::{AppCommandError, AppErrorCode};
use crate::db::error::DbError;
use crate::db::service::folder_service;
use crate::db::AppDatabase;
@@ -278,8 +279,10 @@ pub async fn get_folder(
#[tauri::command]
pub async fn load_folder_history(
db: tauri::State<'_, AppDatabase>,
) -> Result<Vec<FolderHistoryEntry>, DbError> {
folder_service::list_folders(&db.conn).await
) -> Result<Vec<FolderHistoryEntry>, AppCommandError> {
folder_service::list_folders(&db.conn)
.await
.map_err(AppCommandError::from)
}
#[tauri::command]
@@ -318,8 +321,10 @@ pub async fn set_folder_parent_branch(
pub async fn remove_folder_from_history(
db: tauri::State<'_, AppDatabase>,
path: String,
) -> Result<(), DbError> {
folder_service::remove_folder(&db.conn, &path).await
) -> Result<(), AppCommandError> {
folder_service::remove_folder(&db.conn, &path)
.await
.map_err(AppCommandError::from)
}
#[tauri::command]
@@ -337,26 +342,90 @@ pub async fn create_folder_directory(path: String) -> Result<(), String> {
}
#[tauri::command]
pub async fn clone_repository(url: String, target_dir: String) -> Result<(), String> {
pub async fn clone_repository(url: String, target_dir: String) -> Result<(), AppCommandError> {
if url.trim().is_empty() || target_dir.trim().is_empty() {
return Err(AppCommandError::new(
AppErrorCode::InvalidInput,
"Repository URL and target directory are required",
));
}
let output = crate::process::tokio_command("git")
.args(["clone", &url, &target_dir])
.output()
.await
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"Git is not installed. Please install Git first: https://git-scm.com".to_string()
AppCommandError::new(
AppErrorCode::DependencyMissing,
"Git is not installed. Please install Git first.",
)
.with_detail("https://git-scm.com")
} else {
format!("Failed to run git: {}", e)
AppCommandError::external_command("Failed to run git clone", e.to_string())
}
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Clone failed: {}", stderr.trim()));
return Err(classify_git_clone_error(stderr.trim()));
}
Ok(())
}
fn classify_git_clone_error(stderr: &str) -> AppCommandError {
let normalized = stderr.to_lowercase();
if normalized.contains("already exists and is not an empty directory") {
return AppCommandError::new(
AppErrorCode::AlreadyExists,
"Target directory already exists and is not empty",
)
.with_detail(stderr.to_string());
}
if normalized.contains("repository not found") {
return AppCommandError::new(
AppErrorCode::NotFound,
"Repository not found. Check URL and access permissions.",
)
.with_detail(stderr.to_string());
}
if normalized.contains("could not resolve host")
|| normalized.contains("network is unreachable")
|| normalized.contains("connection timed out")
|| normalized.contains("failed to connect")
{
return AppCommandError::new(
AppErrorCode::NetworkError,
"Network is unavailable while cloning repository",
)
.with_detail(stderr.to_string());
}
if normalized.contains("authentication failed")
|| normalized.contains("could not read username")
|| normalized.contains("permission denied (publickey)")
{
return AppCommandError::new(
AppErrorCode::AuthenticationFailed,
"Authentication failed while cloning repository",
)
.with_detail(stderr.to_string());
}
if normalized.contains("permission denied") {
return AppCommandError::new(
AppErrorCode::PermissionDenied,
"Permission denied while cloning repository",
)
.with_detail(stderr.to_string());
}
AppCommandError::external_command("Git clone failed", stderr.to_string())
}
#[tauri::command]
pub async fn get_git_branch(path: String) -> Result<Option<String>, String> {
let output = crate::process::tokio_command("git")
@@ -1094,9 +1163,7 @@ pub async fn git_delete_branch(
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
const WATCH_IGNORED_DIRS: &[&str] = &[
"__pycache__",
];
const WATCH_IGNORED_DIRS: &[&str] = &["__pycache__"];
const FILE_TREE_IGNORED_DIRS: &[&str] = &[".git", "__pycache__"];
const FILE_PREVIEW_DEFAULT_MAX_BYTES: usize = 200_000;
@@ -1239,19 +1306,16 @@ fn is_allowed_git_watch_path(relative: &Path) -> bool {
let second_name = second.to_string_lossy();
match second_name.as_ref() {
"HEAD"
| "index"
| "packed-refs"
| "FETCH_HEAD"
| "ORIG_HEAD"
| "MERGE_HEAD"
| "CHERRY_PICK_HEAD"
| "REVERT_HEAD" => true,
"HEAD" | "index" | "packed-refs" | "FETCH_HEAD" | "ORIG_HEAD" | "MERGE_HEAD"
| "CHERRY_PICK_HEAD" | "REVERT_HEAD" => true,
"refs" => {
let Some(Component::Normal(scope)) = components.next() else {
return true;
};
matches!(scope.to_string_lossy().as_ref(), "heads" | "remotes" | "stash")
matches!(
scope.to_string_lossy().as_ref(),
"heads" | "remotes" | "stash"
)
}
"rebase-merge" | "rebase-apply" => true,
_ => false,

View File

@@ -3,6 +3,7 @@ use std::sync::Mutex;
use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder};
use crate::app_error::AppCommandError;
use crate::db::AppDatabase;
use crate::models::FolderHistoryEntry;
@@ -196,11 +197,11 @@ pub async fn open_folder_window(
app: AppHandle,
db: tauri::State<'_, AppDatabase>,
path: String,
) -> Result<(), String> {
) -> Result<(), AppCommandError> {
// Add to history via DB
let entry = crate::db::service::folder_service::add_folder(&db.conn, &path)
.await
.map_err(|e| e.to_string())?;
.map_err(AppCommandError::from)?;
// Create folder window with unique label
let label = format!("folder-{}", uuid::Uuid::new_v4());
@@ -211,12 +212,14 @@ pub async fn open_folder_window(
.min_inner_size(900.0, 600.0);
let folder_window = apply_platform_window_style(builder)
.build()
.map_err(|e| e.to_string())?;
.map_err(|e| AppCommandError::window("Failed to open folder window", e.to_string()))?;
ensure_windows_undecorated(&folder_window);
// Close welcome window
if let Some(w) = app.get_webview_window("welcome") {
w.close().map_err(|e| e.to_string())?;
w.close().map_err(|e| {
AppCommandError::window("Failed to close welcome window", e.to_string())
})?;
}
Ok(())
}
@@ -277,18 +280,24 @@ pub async fn open_settings_window(
section: Option<String>,
agent_type: Option<String>,
state: tauri::State<'_, SettingsWindowState>,
) -> Result<(), String> {
) -> Result<(), AppCommandError> {
let target_route = resolve_settings_target(section.as_deref(), agent_type.as_deref());
if let Some(existing) = app.get_webview_window("settings") {
ensure_windows_undecorated(&existing);
if section.is_some() || agent_type.is_some() {
let target_path = format!("/{target_route}");
let target_json = serde_json::to_string(&target_path).map_err(|e| e.to_string())?;
let target_json = serde_json::to_string(&target_path).map_err(|e| {
AppCommandError::window("Failed to build settings navigation target", e.to_string())
})?;
let nav_script = format!("window.location.replace({target_json});");
existing.eval(&nav_script).map_err(|e| e.to_string())?;
existing.eval(&nav_script).map_err(|e| {
AppCommandError::window("Failed to navigate settings window", e.to_string())
})?;
}
let _ = existing.unminimize();
existing.set_focus().map_err(|e| e.to_string())?;
existing.set_focus().map_err(|e| {
AppCommandError::window("Failed to focus settings window", e.to_string())
})?;
return Ok(());
}
@@ -302,20 +311,24 @@ pub async fn open_settings_window(
.center();
let settings_window = apply_platform_window_style(builder)
.build()
.map_err(|e| e.to_string())?;
.map_err(|e| AppCommandError::window("Failed to open settings window", e.to_string()))?;
ensure_windows_undecorated(&settings_window);
let mut disabled = HashSet::new();
for (label, webview) in app.webview_windows() {
if label != "settings" {
webview.set_enabled(false).map_err(|e| e.to_string())?;
webview.set_enabled(false).map_err(|e| {
AppCommandError::window("Failed to update window enabled state", e.to_string())
})?;
disabled.insert(label);
}
}
state.set_owner(owner_label);
state.set_disabled_windows(disabled);
settings_window.set_focus().map_err(|e| e.to_string())?;
settings_window
.set_focus()
.map_err(|e| AppCommandError::window("Failed to focus settings window", e.to_string()))?;
Ok(())
}

View File

@@ -1,4 +1,5 @@
mod acp;
mod app_error;
mod commands;
mod db;
mod models;