From 6c48be023c6fc8ce24f3766d7f3e67eb1bcf56b5 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 7 Mar 2026 12:17:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AC=A2=E8=BF=8E=E9=A1=B5=E9=9D=A2=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/app_error.rs | 83 ++++++++++++ src-tauri/src/commands/folders.rs | 104 ++++++++++++--- src-tauri/src/commands/windows.rs | 35 ++++-- src-tauri/src/lib.rs | 1 + src/components/welcome/clone-dialog.tsx | 48 +++++-- src/components/welcome/error-utils.ts | 146 ++++++++++++++++++++++ src/components/welcome/folder-actions.tsx | 20 ++- src/components/welcome/folder-list.tsx | 52 +++++--- src/components/welcome/software-info.tsx | 7 +- src/components/welcome/welcome-screen.tsx | 36 ++++-- src/i18n/messages/en.json | 45 +++++++ src/i18n/messages/zh-CN.json | 45 +++++++ src/i18n/messages/zh-TW.json | 45 +++++++ src/lib/app-error.ts | 69 ++++++++++ src/lib/types.ts | 21 ++++ 15 files changed, 685 insertions(+), 72 deletions(-) create mode 100644 src-tauri/src/app_error.rs create mode 100644 src/components/welcome/error-utils.ts create mode 100644 src/lib/app-error.ts diff --git a/src-tauri/src/app_error.rs b/src-tauri/src/app_error.rs new file mode 100644 index 0000000..4e73a2f --- /dev/null +++ b/src-tauri/src/app_error.rs @@ -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, +} + +impl AppCommandError { + pub fn new(code: AppErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + detail: None, + } + } + + pub fn with_detail(mut self, detail: impl Into) -> 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, detail: impl Into) -> Self { + Self::new(AppErrorCode::WindowOperationFailed, message).with_detail(detail) + } + + pub fn external_command(message: impl Into, detail: impl Into) -> Self { + Self::new(AppErrorCode::ExternalCommandFailed, message).with_detail(detail) + } +} + +impl From for AppCommandError { + fn from(value: DbError) -> Self { + Self::db(value) + } +} diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 9c40581..4fea278 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -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, DbError> { - folder_service::list_folders(&db.conn).await +) -> Result, 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, 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, diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index ed41843..66c55cc 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -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, agent_type: Option, 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(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1d6c12b..d14b9d1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod acp; +mod app_error; mod commands; mod db; mod models; diff --git a/src/components/welcome/clone-dialog.tsx b/src/components/welcome/clone-dialog.tsx index 0da02a4..83be353 100644 --- a/src/components/welcome/clone-dialog.tsx +++ b/src/components/welcome/clone-dialog.tsx @@ -2,6 +2,8 @@ import { useState } from "react" import { open } from "@tauri-apps/plugin-dialog" +import { useTranslations } from "next-intl" +import { toast } from "sonner" import { cloneRepository, openFolderWindow } from "@/lib/tauri" import { Dialog, @@ -14,6 +16,7 @@ import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { Label } from "@/components/ui/label" import { FolderOpen, Loader2 } from "lucide-react" +import { resolveCloneError } from "@/components/welcome/error-utils" interface CloneDialogProps { open: boolean @@ -21,10 +24,14 @@ interface CloneDialogProps { } export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) { + const t = useTranslations("WelcomePage") const [url, setUrl] = useState("") const [targetDir, setTargetDir] = useState("") const [cloning, setCloning] = useState(false) - const [error, setError] = useState(null) + const [error, setError] = useState<{ + message: string + detail: string | null + } | null>(null) const handleBrowse = async () => { const selected = await open({ directory: true, multiple: false }) @@ -52,8 +59,15 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) { await openFolderWindow(fullPath) onOpenChange(false) resetForm() - } catch (e) { - setError(e instanceof Error ? e.message : String(e)) + } catch (err) { + const resolvedError = resolveCloneError(err) + setError({ + message: t(resolvedError.key), + detail: resolvedError.detail ?? null, + }) + toast.error(t("toasts.cloneFailed"), { + description: resolvedError.detail ?? t(resolvedError.key), + }) } finally { setCloning(false) } @@ -75,15 +89,15 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) { > - Clone Repository + {t("cloneDialog.title")}
- + setUrl(e.target.value)} disabled={cloning} @@ -91,11 +105,11 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
- +
setTargetDir(e.target.value)} disabled={cloning} @@ -106,13 +120,23 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) { size="icon" onClick={handleBrowse} disabled={cloning} + title={t("cloneDialog.browseDirectory")} + aria-label={t("cloneDialog.browseDirectory")} + type="button" >
- {error &&

{error}

} + {error && ( +
+

{error.message}

+ {error.detail && ( +

{error.detail}

+ )} +
+ )}
@@ -120,15 +144,17 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) { variant="outline" onClick={() => onOpenChange(false)} disabled={cloning} + type="button" > - Cancel + {t("cloneDialog.cancel")}
diff --git a/src/components/welcome/error-utils.ts b/src/components/welcome/error-utils.ts new file mode 100644 index 0000000..1a4f9a5 --- /dev/null +++ b/src/components/welcome/error-utils.ts @@ -0,0 +1,146 @@ +import { extractAppCommandError, toErrorMessage } from "@/lib/app-error" + +export type WelcomeErrorKey = + | "errors.unknown" + | "errors.invalidInput" + | "errors.notFound" + | "errors.alreadyExists" + | "errors.dependencyMissing" + | "errors.databaseError" + | "errors.ioError" + | "errors.externalCommandFailed" + | "errors.windowOperationFailed" + | "errors.gitNotInstalled" + | "errors.targetDirectoryNotEmpty" + | "errors.repositoryNotFound" + | "errors.networkUnavailable" + | "errors.authenticationFailed" + | "errors.permissionDenied" + +export interface WelcomeErrorResult { + key: WelcomeErrorKey + detail?: string +} + +export function normalizeErrorMessage(error: unknown): string { + return toErrorMessage(error) +} + +function stripClonePrefix(message: string): string { + return message.replace(/^clone failed:\s*/i, "").trim() +} + +function mapCommonCodeToKey(code: string): WelcomeErrorKey { + switch (code) { + case "invalid_input": + return "errors.invalidInput" + case "not_found": + return "errors.notFound" + case "already_exists": + return "errors.alreadyExists" + case "permission_denied": + return "errors.permissionDenied" + case "dependency_missing": + return "errors.dependencyMissing" + case "network_error": + return "errors.networkUnavailable" + case "authentication_failed": + return "errors.authenticationFailed" + case "database_error": + return "errors.databaseError" + case "io_error": + return "errors.ioError" + case "external_command_failed": + return "errors.externalCommandFailed" + case "window_operation_failed": + return "errors.windowOperationFailed" + default: + return "errors.unknown" + } +} + +export function resolveWelcomeError(error: unknown): WelcomeErrorResult { + const appError = extractAppCommandError(error) + if (appError) { + const key = mapCommonCodeToKey(appError.code) + const detail = + key === "errors.unknown" ? appError.detail || appError.message : undefined + + return detail ? { key, detail } : { key } + } + + return { + key: "errors.unknown", + detail: normalizeErrorMessage(error), + } +} + +export function resolveCloneError(error: unknown): WelcomeErrorResult { + const appError = extractAppCommandError(error) + if (appError) { + switch (appError.code) { + case "dependency_missing": + return { key: "errors.gitNotInstalled" } + case "already_exists": + return { key: "errors.targetDirectoryNotEmpty" } + case "not_found": + return { key: "errors.repositoryNotFound" } + case "network_error": + return { key: "errors.networkUnavailable" } + case "authentication_failed": + return { key: "errors.authenticationFailed" } + case "permission_denied": + return { key: "errors.permissionDenied" } + default: { + const key = mapCommonCodeToKey(appError.code) + const detail = + key === "errors.unknown" + ? appError.detail || appError.message + : undefined + return detail ? { key, detail } : { key } + } + } + } + + const rawMessage = normalizeErrorMessage(error) + const message = stripClonePrefix(rawMessage) + const normalized = message.toLowerCase() + + if (normalized.includes("git is not installed")) { + return { key: "errors.gitNotInstalled" } + } + + if (normalized.includes("already exists and is not an empty directory")) { + return { key: "errors.targetDirectoryNotEmpty" } + } + + if (normalized.includes("repository not found")) { + return { key: "errors.repositoryNotFound" } + } + + if ( + normalized.includes("could not resolve host") || + normalized.includes("network is unreachable") || + normalized.includes("connection timed out") || + normalized.includes("failed to connect") + ) { + return { key: "errors.networkUnavailable" } + } + + if ( + normalized.includes("authentication failed") || + normalized.includes("could not read username") || + normalized.includes("permission denied (publickey)") + ) { + return { key: "errors.authenticationFailed" } + } + + if (normalized.includes("permission denied")) { + return { key: "errors.permissionDenied" } + } + + return { + key: "errors.unknown", + detail: message || rawMessage || undefined, + } +} diff --git a/src/components/welcome/folder-actions.tsx b/src/components/welcome/folder-actions.tsx index 94a3f83..17efb84 100644 --- a/src/components/welcome/folder-actions.tsx +++ b/src/components/welcome/folder-actions.tsx @@ -2,18 +2,30 @@ import { useState } from "react" import { FolderOpen, GitBranch } from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" import { open } from "@tauri-apps/plugin-dialog" import { openFolderWindow } from "@/lib/tauri" import { Button } from "@/components/ui/button" import { CloneDialog } from "./clone-dialog" +import { resolveWelcomeError } from "@/components/welcome/error-utils" export function FolderActions() { + const t = useTranslations("WelcomePage") const [cloneOpen, setCloneOpen] = useState(false) const handleOpen = async () => { const selected = await open({ directory: true, multiple: false }) - if (selected) { + if (!selected) return + + try { await openFolderWindow(selected) + } catch (err) { + console.error("[FolderActions] failed to open folder:", err) + const resolvedError = resolveWelcomeError(err) + toast.error(t("toasts.openFolderFailed"), { + description: resolvedError.detail ?? t(resolvedError.key), + }) } } @@ -23,17 +35,19 @@ export function FolderActions() { variant="ghost" className="justify-start gap-2 h-9" onClick={handleOpen} + type="button" > - Open Folder + {t("openFolder")} diff --git a/src/components/welcome/folder-list.tsx b/src/components/welcome/folder-list.tsx index 0a56c52..b78337a 100644 --- a/src/components/welcome/folder-list.tsx +++ b/src/components/welcome/folder-list.tsx @@ -1,12 +1,15 @@ "use client" -import { useState } from "react" +import { useMemo, useState } from "react" import { Search, X, FolderOpen } from "lucide-react" import { formatDistanceToNow } from "date-fns" -import { zhCN } from "date-fns/locale" +import { enUS, zhCN, zhTW } from "date-fns/locale" +import { useLocale, useTranslations } from "next-intl" +import { toast } from "sonner" import { openFolderWindow, removeFolderFromHistory } from "@/lib/tauri" import type { FolderHistoryEntry } from "@/lib/types" import { Input } from "@/components/ui/input" +import { resolveWelcomeError } from "@/components/welcome/error-utils" interface FolderListProps { history: FolderHistoryEntry[] @@ -15,21 +18,32 @@ interface FolderListProps { } export function FolderList({ history, loading, onRefresh }: FolderListProps) { + const t = useTranslations("WelcomePage") + const locale = useLocale() const [search, setSearch] = useState("") + const dateFnsLocale = + locale === "zh-CN" ? zhCN : locale === "zh-TW" ? zhTW : enUS - const filtered = search - ? history.filter( - (h) => - h.name.toLowerCase().includes(search.toLowerCase()) || - h.path.toLowerCase().includes(search.toLowerCase()) - ) - : history + const filtered = useMemo(() => { + if (!search) return history + const lowerCaseSearch = search.toLowerCase() + + return history.filter( + (h) => + h.name.toLowerCase().includes(lowerCaseSearch) || + h.path.toLowerCase().includes(lowerCaseSearch) + ) + }, [history, search]) const handleOpen = async (path: string) => { try { await openFolderWindow(path) - } catch (e) { - console.error("Failed to open folder:", e) + } catch (err) { + console.error("Failed to open folder:", err) + const resolvedError = resolveWelcomeError(err) + toast.error(t("toasts.openFolderFailed"), { + description: resolvedError.detail ?? t(resolvedError.key), + }) } } @@ -40,6 +54,10 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) { onRefresh() } catch (err) { console.error("Failed to remove folder:", err) + const resolvedError = resolveWelcomeError(err) + toast.error(t("toasts.removeFromHistoryFailed"), { + description: resolvedError.detail ?? t(resolvedError.key), + }) } } @@ -48,7 +66,7 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) {
setSearch(e.target.value)} className="pl-9 h-9" @@ -58,12 +76,12 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) {
{loading ? (
- Loading... + {t("loading")}
) : filtered.length === 0 ? (
- 暂无文件夹 + {t("emptyFolders")}
) : ( filtered.map((entry) => ( @@ -93,7 +111,7 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) { {formatDistanceToNow(new Date(entry.last_opened_at), { addSuffix: true, - locale: zhCN, + locale: dateFnsLocale, })}
@@ -101,7 +119,9 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) { diff --git a/src/components/welcome/software-info.tsx b/src/components/welcome/software-info.tsx index 55811d0..1ff1bbc 100644 --- a/src/components/welcome/software-info.tsx +++ b/src/components/welcome/software-info.tsx @@ -1,14 +1,19 @@ "use client" import { MessageCircleCode } from "lucide-react" +import { useTranslations } from "next-intl" export function SoftwareInfo() { + const t = useTranslations("WelcomePage") + return (
Codeg - version 0.0.1 + + {t("softwareVersion", { version: "0.0.1" })} +
) diff --git a/src/components/welcome/welcome-screen.tsx b/src/components/welcome/welcome-screen.tsx index aeb6b19..4abb5c6 100644 --- a/src/components/welcome/welcome-screen.tsx +++ b/src/components/welcome/welcome-screen.tsx @@ -1,39 +1,48 @@ "use client" -import { useState, useEffect } from "react" +import { useState, useEffect, useCallback } from "react" import { Settings } from "lucide-react" -import { loadFolderHistory } from "@/lib/tauri" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { loadFolderHistory, openSettingsWindow } from "@/lib/tauri" import type { FolderHistoryEntry } from "@/lib/types" import { FolderList } from "@/components/welcome/folder-list" import { FolderActions } from "@/components/welcome/folder-actions" import { SoftwareInfo } from "@/components/welcome/software-info" import { Button } from "@/components/ui/button" -import { openSettingsWindow } from "@/lib/tauri" +import { AppToaster } from "@/components/ui/app-toaster" +import { resolveWelcomeError } from "@/components/welcome/error-utils" import { AppTitleBar } from "@/components/layout/app-title-bar" export function WelcomeScreen() { + const t = useTranslations("WelcomePage") const [history, setHistory] = useState([]) const [loading, setLoading] = useState(true) - const refreshHistory = async () => { + const refreshHistory = useCallback(async () => { try { setHistory(await loadFolderHistory()) + } catch (err) { + console.error("[WelcomeScreen] failed to load folder history:", err) + const resolvedError = resolveWelcomeError(err) + toast.error(t("toasts.loadFolderHistoryFailed"), { + description: resolvedError.detail ?? t(resolvedError.key), + }) + setHistory([]) } finally { setLoading(false) } - } + }, [t]) useEffect(() => { refreshHistory() - }, []) + }, [refreshHistory]) return (
- 欢迎使用Codeg - + {t("title")} } right={ @@ -63,6 +78,7 @@ export function WelcomeScreen() { onRefresh={refreshHistory} />
+
) } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 348547e..59961ed 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5,6 +5,51 @@ "simplifiedChinese": "Simplified Chinese", "traditionalChinese": "Traditional Chinese" }, + "WelcomePage": { + "title": "Welcome to Codeg", + "openSettings": "Open Settings", + "searchPlaceholder": "Search folders...", + "loading": "Loading...", + "emptyFolders": "No folders yet", + "removeFromHistory": "Remove from history", + "openFolder": "Open Folder", + "cloneRepository": "Clone Repository", + "softwareVersion": "version {version}", + "toasts": { + "loadFolderHistoryFailed": "Failed to load folder history", + "openFolderFailed": "Failed to open folder", + "removeFromHistoryFailed": "Failed to remove folder", + "openSettingsFailed": "Failed to open settings", + "cloneFailed": "Failed to clone repository" + }, + "errors": { + "unknown": "Unexpected error", + "invalidInput": "Invalid input.", + "notFound": "Resource not found.", + "alreadyExists": "Resource already exists.", + "dependencyMissing": "Required dependency is missing.", + "databaseError": "Database operation failed.", + "ioError": "File operation failed.", + "externalCommandFailed": "External command failed.", + "windowOperationFailed": "Window operation failed.", + "gitNotInstalled": "Git is not installed. Please install Git first.", + "targetDirectoryNotEmpty": "Target directory already exists and is not empty.", + "repositoryNotFound": "Repository not found. Check URL and access permissions.", + "networkUnavailable": "Network is unavailable. Check your connection and try again.", + "authenticationFailed": "Authentication failed. Check credentials or SSH key.", + "permissionDenied": "Permission denied. Check directory permissions." + }, + "cloneDialog": { + "title": "Clone Repository", + "repositoryUrl": "Repository URL", + "repositoryUrlPlaceholder": "https://github.com/user/repo.git", + "directory": "Directory", + "directoryPlaceholder": "Select target directory...", + "browseDirectory": "Browse directory", + "cancel": "Cancel", + "clone": "Clone" + } + }, "SettingsShell": { "title": "Settings", "preferences": "Preferences", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index cc3a701..54ac913 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -5,6 +5,51 @@ "simplifiedChinese": "简体中文", "traditionalChinese": "繁體中文" }, + "WelcomePage": { + "title": "欢迎使用Codeg", + "openSettings": "打开设置", + "searchPlaceholder": "搜索文件夹...", + "loading": "加载中...", + "emptyFolders": "暂无文件夹", + "removeFromHistory": "从历史中移除", + "openFolder": "打开文件夹", + "cloneRepository": "克隆仓库", + "softwareVersion": "版本 {version}", + "toasts": { + "loadFolderHistoryFailed": "加载文件夹历史失败", + "openFolderFailed": "打开文件夹失败", + "removeFromHistoryFailed": "移除历史记录失败", + "openSettingsFailed": "打开设置失败", + "cloneFailed": "克隆仓库失败" + }, + "errors": { + "unknown": "发生未知错误", + "invalidInput": "输入无效。", + "notFound": "资源不存在。", + "alreadyExists": "资源已存在。", + "dependencyMissing": "缺少必要依赖。", + "databaseError": "数据库操作失败。", + "ioError": "文件操作失败。", + "externalCommandFailed": "外部命令执行失败。", + "windowOperationFailed": "窗口操作失败。", + "gitNotInstalled": "未检测到 Git,请先安装 Git。", + "targetDirectoryNotEmpty": "目标目录已存在且不为空。", + "repositoryNotFound": "仓库不存在,请检查地址和访问权限。", + "networkUnavailable": "网络不可用,请检查网络连接后重试。", + "authenticationFailed": "认证失败,请检查凭据或 SSH Key。", + "permissionDenied": "权限不足,请检查目录权限。" + }, + "cloneDialog": { + "title": "克隆仓库", + "repositoryUrl": "仓库地址", + "repositoryUrlPlaceholder": "https://github.com/user/repo.git", + "directory": "目录", + "directoryPlaceholder": "选择目标目录...", + "browseDirectory": "浏览目录", + "cancel": "取消", + "clone": "克隆" + } + }, "SettingsShell": { "title": "设置", "preferences": "偏好设置", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 60d639d..17dc2c5 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -5,6 +5,51 @@ "simplifiedChinese": "简体中文", "traditionalChinese": "繁體中文" }, + "WelcomePage": { + "title": "歡迎使用Codeg", + "openSettings": "打開設定", + "searchPlaceholder": "搜尋資料夾...", + "loading": "載入中...", + "emptyFolders": "暫無資料夾", + "removeFromHistory": "從歷史中移除", + "openFolder": "打開資料夾", + "cloneRepository": "複製倉庫", + "softwareVersion": "版本 {version}", + "toasts": { + "loadFolderHistoryFailed": "載入資料夾歷史失敗", + "openFolderFailed": "打開資料夾失敗", + "removeFromHistoryFailed": "移除歷史記錄失敗", + "openSettingsFailed": "打開設定失敗", + "cloneFailed": "複製倉庫失敗" + }, + "errors": { + "unknown": "發生未知錯誤", + "invalidInput": "輸入無效。", + "notFound": "資源不存在。", + "alreadyExists": "資源已存在。", + "dependencyMissing": "缺少必要依賴。", + "databaseError": "資料庫操作失敗。", + "ioError": "檔案操作失敗。", + "externalCommandFailed": "外部指令執行失敗。", + "windowOperationFailed": "視窗操作失敗。", + "gitNotInstalled": "未檢測到 Git,請先安裝 Git。", + "targetDirectoryNotEmpty": "目標目錄已存在且不為空。", + "repositoryNotFound": "倉庫不存在,請檢查地址與存取權限。", + "networkUnavailable": "網路不可用,請檢查網路連線後重試。", + "authenticationFailed": "驗證失敗,請檢查憑證或 SSH Key。", + "permissionDenied": "權限不足,請檢查目錄權限。" + }, + "cloneDialog": { + "title": "複製倉庫", + "repositoryUrl": "倉庫地址", + "repositoryUrlPlaceholder": "https://github.com/user/repo.git", + "directory": "目錄", + "directoryPlaceholder": "選擇目標目錄...", + "browseDirectory": "瀏覽目錄", + "cancel": "取消", + "clone": "複製" + } + }, "SettingsShell": { "title": "設定", "preferences": "偏好設定", diff --git a/src/lib/app-error.ts b/src/lib/app-error.ts new file mode 100644 index 0000000..6b85e95 --- /dev/null +++ b/src/lib/app-error.ts @@ -0,0 +1,69 @@ +import type { AppCommandError } from "@/lib/types" + +type ObjectLike = Record + +function asObject(value: unknown): ObjectLike | null { + return value !== null && typeof value === "object" + ? (value as ObjectLike) + : null +} + +function normalizeString(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null +} + +function parseErrorObject(value: unknown): AppCommandError | null { + const obj = asObject(value) + if (!obj) return null + + const code = normalizeString(obj.code) + const message = normalizeString(obj.message) + const detailRaw = normalizeString(obj.detail) + const detail = detailRaw ?? null + + if (!code || !message) return null + + return { + code, + message, + detail, + } +} + +export function extractAppCommandError(error: unknown): AppCommandError | null { + const direct = parseErrorObject(error) + if (direct) return direct + + const errorObject = asObject(error) + const message = normalizeString(errorObject?.message) + if (!message) return null + + try { + const parsed = JSON.parse(message) + return parseErrorObject(parsed) + } catch { + return null + } +} + +export function toErrorMessage(error: unknown): string { + const appError = extractAppCommandError(error) + if (appError) { + return appError.detail?.trim() || appError.message + } + + if (error instanceof Error) { + return error.message.trim() + } + + if (typeof error === "string") { + return error.trim() + } + + try { + const serialized = JSON.stringify(error) + return serialized ? serialized.trim() : String(error) + } catch { + return String(error) + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 4d46711..d06617d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -20,6 +20,27 @@ export type AgentType = | "open_claw" | "stakpak" +export type AppErrorCode = + | "unknown" + | "invalid_input" + | "not_found" + | "already_exists" + | "permission_denied" + | "dependency_missing" + | "network_error" + | "authentication_failed" + | "database_error" + | "io_error" + | "external_command_failed" + | "window_operation_failed" + | (string & {}) + +export interface AppCommandError { + code: AppErrorCode + message: string + detail?: string | null +} + export interface ConversationSummary { id: string agent_type: AgentType