欢迎页面支持多语言
This commit is contained in:
83
src-tauri/src/app_error.rs
Normal file
83
src-tauri/src/app_error.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod acp;
|
||||
mod app_error;
|
||||
mod commands;
|
||||
mod db;
|
||||
mod models;
|
||||
|
||||
@@ -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<string | null>(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) {
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Clone Repository</DialogTitle>
|
||||
<DialogTitle>{t("cloneDialog.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="repo-url">Repository URL</Label>
|
||||
<Label htmlFor="repo-url">{t("cloneDialog.repositoryUrl")}</Label>
|
||||
<Input
|
||||
id="repo-url"
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
placeholder={t("cloneDialog.repositoryUrlPlaceholder")}
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
disabled={cloning}
|
||||
@@ -91,11 +105,11 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="target-dir">Directory</Label>
|
||||
<Label htmlFor="target-dir">{t("cloneDialog.directory")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="target-dir"
|
||||
placeholder="Select target directory..."
|
||||
placeholder={t("cloneDialog.directoryPlaceholder")}
|
||||
value={targetDir}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
{error && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-destructive">{error.message}</p>
|
||||
{error.detail && (
|
||||
<p className="text-xs text-muted-foreground">{error.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -120,15 +144,17 @@ export function CloneDialog({ open: isOpen, onOpenChange }: CloneDialogProps) {
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={cloning}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
{t("cloneDialog.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClone}
|
||||
disabled={!url || !targetDir || cloning}
|
||||
type="button"
|
||||
>
|
||||
{cloning && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Clone
|
||||
{t("cloneDialog.clone")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
146
src/components/welcome/error-utils.ts
Normal file
146
src/components/welcome/error-utils.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Open Folder
|
||||
{t("openFolder")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start gap-2 h-9"
|
||||
onClick={() => setCloneOpen(true)}
|
||||
type="button"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
Clone Repository
|
||||
{t("cloneRepository")}
|
||||
</Button>
|
||||
|
||||
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
|
||||
|
||||
@@ -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) {
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索文件夹..."
|
||||
placeholder={t("searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
@@ -58,12 +76,12 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) {
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32 text-sm text-muted-foreground">
|
||||
Loading...
|
||||
{t("loading")}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-32 text-sm text-muted-foreground gap-2">
|
||||
<FolderOpen className="h-8 w-8" />
|
||||
<span>暂无文件夹</span>
|
||||
<span>{t("emptyFolders")}</span>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((entry) => (
|
||||
@@ -93,7 +111,7 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) {
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(entry.last_opened_at), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
locale: dateFnsLocale,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
@@ -101,7 +119,9 @@ export function FolderList({ history, loading, onRefresh }: FolderListProps) {
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-destructive/10 rounded shrink-0 mt-0.5"
|
||||
onClick={(e) => handleRemove(e, entry.path)}
|
||||
title="从历史中移除"
|
||||
title={t("removeFromHistory")}
|
||||
aria-label={t("removeFromHistory")}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</button>
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { MessageCircleCode } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
export function SoftwareInfo() {
|
||||
const t = useTranslations("WelcomePage")
|
||||
|
||||
return (
|
||||
<div className="w-full flex gap-4 px-6 py-8">
|
||||
<MessageCircleCode className="size-12" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-base">Codeg</span>
|
||||
<span className="text-sm text-muted-foreground">version 0.0.1</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("softwareVersion", { version: "0.0.1" })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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<FolderHistoryEntry[]>([])
|
||||
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 (
|
||||
<div className="h-screen flex flex-col overflow-hidden bg-background text-foreground">
|
||||
<AppTitleBar
|
||||
center={
|
||||
<span className="text-sm font-bold tracking-tight">
|
||||
欢迎使用Codeg
|
||||
</span>
|
||||
<span className="text-sm font-bold tracking-tight">{t("title")}</span>
|
||||
}
|
||||
right={
|
||||
<Button
|
||||
@@ -43,9 +52,15 @@ export function WelcomeScreen() {
|
||||
onClick={() => {
|
||||
openSettingsWindow().catch((err) => {
|
||||
console.error("[WelcomeScreen] failed to open settings:", err)
|
||||
const resolvedError = resolveWelcomeError(err)
|
||||
toast.error(t("toasts.openSettingsFailed"), {
|
||||
description: resolvedError.detail ?? t(resolvedError.key),
|
||||
})
|
||||
})
|
||||
}}
|
||||
title="Open Settings"
|
||||
title={t("openSettings")}
|
||||
aria-label={t("openSettings")}
|
||||
type="button"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
@@ -63,6 +78,7 @@ export function WelcomeScreen() {
|
||||
onRefresh={refreshHistory}
|
||||
/>
|
||||
</div>
|
||||
<AppToaster position="bottom-right" closeButton duration={4000} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "偏好设置",
|
||||
|
||||
@@ -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": "偏好設定",
|
||||
|
||||
69
src/lib/app-error.ts
Normal file
69
src/lib/app-error.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { AppCommandError } from "@/lib/types"
|
||||
|
||||
type ObjectLike = Record<string, unknown>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user