From dda45b7df6b0ab2891d00b4cce9844ebc3c66d84 Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Thu, 19 Mar 2026 04:23:10 +0000 Subject: [PATCH 1/2] feat: add git-based workspace backup Adds workspace-aware backup/restore and git-based workspace backup: - Copy-based backup/restore now includes workspace directory - Workspace path resolved from OpenClaw config (custom or default) - New git backup commands: workspace_git_status, workspace_git_backup, workspace_git_init - Remote variants for all git backup commands via SSH - WorkspaceGitBackup UI component with status, backup, and init actions - clawpal-core: git status probe, backup, and init shell command builders Rebased on origin/develop (timed_async!/timed_sync! macro wrapping, read_openclaw_config(&OpenClawPaths) signature). --- .gitignore | 1 + clawpal-core/src/backup.rs | 249 ++++++++++++++++++++++ src-tauri/src/commands/backup.rs | 294 +++++++++++++++++++++++++- src-tauri/src/lib.rs | 14 +- src/components/BackupsPanel.tsx | 5 +- src/components/WorkspaceGitBackup.tsx | 214 +++++++++++++++++++ src/lib/api.ts | 14 +- src/lib/types.ts | 87 ++++++++ src/lib/use-api.ts | 16 ++ src/locales/en.json | 55 +++-- src/locales/zh.json | 55 +++-- 11 files changed, 937 insertions(+), 67 deletions(-) create mode 100644 src/components/WorkspaceGitBackup.tsx diff --git a/.gitignore b/.gitignore index a324c7d1..da7bcc03 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ tmp/ *.sqlite3 *.log src-tauri/gen/ +screenshots/ diff --git a/clawpal-core/src/backup.rs b/clawpal-core/src/backup.rs index 05808b20..eafeb2ab 100644 --- a/clawpal-core/src/backup.rs +++ b/clawpal-core/src/backup.rs @@ -1,5 +1,167 @@ use regex::Regex; +// ---- Workspace Git Backup ---- + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceGitStatus { + /// Whether the workspace directory is a git repository + pub is_git_repo: bool, + /// Whether a remote named "origin" is configured + pub has_remote: bool, + /// The remote URL (if any) + pub remote_url: Option, + /// Current branch name + pub branch: Option, + /// Number of uncommitted changes (staged + unstaged + untracked) + pub uncommitted_count: u32, + /// Number of commits ahead of the remote tracking branch + pub ahead: u32, + /// Number of commits behind the remote tracking branch + pub behind: u32, + /// Last commit timestamp (ISO 8601) if any + pub last_commit_time: Option, + /// Last commit message (first line) + pub last_commit_message: Option, +} + +/// The default `.gitignore` content recommended for OpenClaw workspaces. +pub const WORKSPACE_GITIGNORE: &str = "\ +.DS_Store +.env +**/*.key +**/*.pem +**/secrets* +"; + +/// Parse the output of the combined git-status probe command. +/// +/// Expected input format (one command producing multiple tagged lines): +/// ```text +/// GIT_REPO:true +/// BRANCH:main +/// REMOTE_URL:https://github.com/user/openclaw-workspace.git +/// UNCOMMITTED:3 +/// AHEAD:1 +/// BEHIND:0 +/// LAST_COMMIT_TIME:2026-03-15T10:30:00+00:00 +/// LAST_COMMIT_MSG:Update memory +/// ``` +pub fn parse_workspace_git_status(output: &str) -> WorkspaceGitStatus { + let mut status = WorkspaceGitStatus { + is_git_repo: false, + has_remote: false, + remote_url: None, + branch: None, + uncommitted_count: 0, + ahead: 0, + behind: 0, + last_commit_time: None, + last_commit_message: None, + }; + + for line in output.lines() { + let line = line.trim(); + if let Some(val) = line.strip_prefix("GIT_REPO:") { + status.is_git_repo = val.trim() == "true"; + } else if let Some(val) = line.strip_prefix("BRANCH:") { + let val = val.trim(); + if !val.is_empty() { + status.branch = Some(val.to_string()); + } + } else if let Some(val) = line.strip_prefix("REMOTE_URL:") { + let val = val.trim(); + if !val.is_empty() { + status.has_remote = true; + status.remote_url = Some(val.to_string()); + } + } else if let Some(val) = line.strip_prefix("UNCOMMITTED:") { + status.uncommitted_count = val.trim().parse().unwrap_or(0); + } else if let Some(val) = line.strip_prefix("AHEAD:") { + status.ahead = val.trim().parse().unwrap_or(0); + } else if let Some(val) = line.strip_prefix("BEHIND:") { + status.behind = val.trim().parse().unwrap_or(0); + } else if let Some(val) = line.strip_prefix("LAST_COMMIT_TIME:") { + let val = val.trim(); + if !val.is_empty() { + status.last_commit_time = Some(val.to_string()); + } + } else if let Some(val) = line.strip_prefix("LAST_COMMIT_MSG:") { + let val = val.trim(); + if !val.is_empty() { + status.last_commit_message = Some(val.to_string()); + } + } + } + + status +} + +/// Build the shell command that probes workspace git status. +/// `workspace_path` should be a shell-safe absolute or `$HOME`-relative path. +pub fn build_git_status_probe_cmd(workspace_path: &str) -> String { + format!( + concat!( + "cd {ws} 2>/dev/null || {{ echo 'GIT_REPO:false'; exit 0; }}; ", + "if [ ! -d .git ] && ! git rev-parse --git-dir >/dev/null 2>&1; then ", + "echo 'GIT_REPO:false'; exit 0; fi; ", + "echo 'GIT_REPO:true'; ", + "echo \"BRANCH:$(git rev-parse --abbrev-ref HEAD 2>/dev/null)\"; ", + "echo \"REMOTE_URL:$(git remote get-url origin 2>/dev/null)\"; ", + "echo \"UNCOMMITTED:$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')\"; ", + "TRACKING=$(git rev-parse --abbrev-ref '@{{u}}' 2>/dev/null); ", + "if [ -n \"$TRACKING\" ]; then ", + "echo \"AHEAD:$(git rev-list --count '@{{u}}..HEAD' 2>/dev/null || echo 0)\"; ", + "echo \"BEHIND:$(git rev-list --count 'HEAD..@{{u}}' 2>/dev/null || echo 0)\"; ", + "else echo 'AHEAD:0'; echo 'BEHIND:0'; fi; ", + "echo \"LAST_COMMIT_TIME:$(git log -1 --format='%aI' 2>/dev/null)\"; ", + "echo \"LAST_COMMIT_MSG:$(git log -1 --format='%s' 2>/dev/null)\"" + ), + ws = workspace_path + ) +} + +/// Build the shell command that runs git add + commit + push. +pub fn build_git_backup_cmd(workspace_path: &str, message: &str) -> String { + let safe_msg = message.replace('\'', "'\\''"); + format!( + concat!( + "cd {ws} || exit 1; ", + "git add -A; ", + "if git diff --cached --quiet 2>/dev/null; then ", + "echo 'NOTHING_TO_COMMIT'; exit 0; fi; ", + "git commit -m '{msg}'; ", + "if git remote get-url origin >/dev/null 2>&1; then ", + "git push 2>&1; echo 'PUSHED'; ", + "else echo 'COMMITTED_NO_REMOTE'; fi" + ), + ws = workspace_path, + msg = safe_msg + ) +} + +/// Build the shell command that initializes a git repo in the workspace +/// and writes a `.gitignore` if one doesn't exist. +pub fn build_git_init_cmd(workspace_path: &str) -> String { + let gitignore_content = WORKSPACE_GITIGNORE.replace('\n', "\\n"); + format!( + concat!( + "cd {ws} || exit 1; ", + "if [ -d .git ] || git rev-parse --git-dir >/dev/null 2>&1; then ", + "echo 'ALREADY_INITIALIZED'; exit 0; fi; ", + "git init; ", + "if [ ! -f .gitignore ]; then printf '{gitignore}' > .gitignore; fi; ", + "git add -A; ", + "git commit -m 'Initial workspace backup'; ", + "echo 'INITIALIZED'" + ), + ws = workspace_path, + gitignore = gitignore_content + ) +} + +// ---- Copy-based Backup ---- + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BackupEntry { @@ -67,6 +229,93 @@ pub fn parse_upgrade_result(output: &str) -> UpgradeResult { mod tests { use super::*; + // ---- Workspace git status tests ---- + + #[test] + fn parse_git_status_full() { + let output = "\ +GIT_REPO:true +BRANCH:main +REMOTE_URL:https://github.com/user/workspace.git +UNCOMMITTED:5 +AHEAD:2 +BEHIND:1 +LAST_COMMIT_TIME:2026-03-15T10:30:00+00:00 +LAST_COMMIT_MSG:Update memory +"; + let s = parse_workspace_git_status(output); + assert!(s.is_git_repo); + assert!(s.has_remote); + assert_eq!( + s.remote_url.as_deref(), + Some("https://github.com/user/workspace.git") + ); + assert_eq!(s.branch.as_deref(), Some("main")); + assert_eq!(s.uncommitted_count, 5); + assert_eq!(s.ahead, 2); + assert_eq!(s.behind, 1); + assert_eq!( + s.last_commit_time.as_deref(), + Some("2026-03-15T10:30:00+00:00") + ); + assert_eq!(s.last_commit_message.as_deref(), Some("Update memory")); + } + + #[test] + fn parse_git_status_not_a_repo() { + let output = "GIT_REPO:false\n"; + let s = parse_workspace_git_status(output); + assert!(!s.is_git_repo); + assert!(!s.has_remote); + assert_eq!(s.uncommitted_count, 0); + } + + #[test] + fn parse_git_status_no_remote() { + let output = "\ +GIT_REPO:true +BRANCH:main +REMOTE_URL: +UNCOMMITTED:0 +AHEAD:0 +BEHIND:0 +LAST_COMMIT_TIME:2026-03-10T08:00:00+00:00 +LAST_COMMIT_MSG:init +"; + let s = parse_workspace_git_status(output); + assert!(s.is_git_repo); + assert!(!s.has_remote); + assert_eq!(s.remote_url, None); + } + + #[test] + fn parse_git_status_empty_input() { + let s = parse_workspace_git_status(""); + assert!(!s.is_git_repo); + } + + #[test] + fn build_git_status_probe_cmd_uses_workspace_path() { + let cmd = build_git_status_probe_cmd("$HOME/.openclaw/workspace"); + assert!(cmd.contains("cd $HOME/.openclaw/workspace")); + assert!(cmd.contains("GIT_REPO:")); + } + + #[test] + fn build_git_backup_cmd_escapes_quotes_in_message() { + let cmd = build_git_backup_cmd("/ws", "it's a test"); + assert!(cmd.contains("it'\\''s a test")); + } + + #[test] + fn build_git_init_cmd_contains_gitignore() { + let cmd = build_git_init_cmd("/ws"); + assert!(cmd.contains(".gitignore")); + assert!(cmd.contains("git init")); + } + + // ---- Copy-based backup tests ---- + #[test] fn parse_backup_list_reads_du_lines() { let out = parse_backup_list("10\t/home/a\n0\t/home/b\n"); diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs index d5d07acc..3aa9c62f 100644 --- a/src-tauri/src/commands/backup.rs +++ b/src-tauri/src/commands/backup.rs @@ -20,7 +20,13 @@ pub async fn remote_backup_before_upgrade( "mkdir -p \"$BDIR\"; ", "cp \"$HOME/.openclaw/openclaw.json\" \"$BDIR/\" 2>/dev/null || true; ", "cp -r \"$HOME/.openclaw/agents\" \"$BDIR/\" 2>/dev/null || true; ", - "cp -r \"$HOME/.openclaw/memory\" \"$BDIR/\" 2>/dev/null || true; ", + // Resolve workspace from config (default: ~/.openclaw/workspace) + "WS=$(cat \"$HOME/.openclaw/openclaw.json\" 2>/dev/null ", + "| python3 -c \"import sys,json; c=json.load(sys.stdin); print(c.get('agents',{{}}).get('defaults',{{}}).get('workspace',c.get('agent',{{}}).get('workspace','')))\" 2>/dev/null ", + "| head -1); ", + "WS=${{WS:-$HOME/.openclaw/workspace}}; ", + "WS=$(echo \"$WS\" | sed \"s|^~/|$HOME/|\"); ", + "[ -d \"$WS\" ] && cp -r \"$WS\" \"$BDIR/workspace\" 2>/dev/null || true; ", "du -sk \"$BDIR\" 2>/dev/null | awk '{{print $1 * 1024}}' || echo 0" ), name = escaped_name @@ -132,7 +138,16 @@ pub async fn remote_restore_from_backup( "[ -d \"$BDIR\" ] || {{ echo 'Backup not found'; exit 1; }}; ", "cp \"$BDIR/openclaw.json\" \"$HOME/.openclaw/openclaw.json\" 2>/dev/null || true; ", "[ -d \"$BDIR/agents\" ] && cp -r \"$BDIR/agents\" \"$HOME/.openclaw/\" 2>/dev/null || true; ", - "[ -d \"$BDIR/memory\" ] && cp -r \"$BDIR/memory\" \"$HOME/.openclaw/\" 2>/dev/null || true; ", + // Restore workspace if it was backed up + "if [ -d \"$BDIR/workspace\" ]; then ", + "WS=$(cat \"$HOME/.openclaw/openclaw.json\" 2>/dev/null ", + "| python3 -c \"import sys,json; c=json.load(sys.stdin); print(c.get('agents',{{}}).get('defaults',{{}}).get('workspace',c.get('agent',{{}}).get('workspace','')))\" 2>/dev/null ", + "| head -1); ", + "WS=${{WS:-$HOME/.openclaw/workspace}}; ", + "WS=$(echo \"$WS\" | sed \"s|^~/|$HOME/|\"); ", + "mkdir -p \"$WS\"; ", + "cp -r \"$BDIR/workspace/.\" \"$WS/\" 2>/dev/null || true; ", + "fi; ", "echo 'Restored from backup '{name}" ), name = escaped_name @@ -261,6 +276,31 @@ pub fn backup_before_upgrade() -> Result { .collect(); copy_dir_recursive(&paths.base_dir, &backup_dir, &skip_dirs, &mut total_bytes)?; + // Also copy workspace if it lives outside ~/.openclaw/ (custom workspace path) + let cfg = read_openclaw_config(&paths).unwrap_or_default(); + let ws_raw = cfg + .pointer("/agents/defaults/workspace") + .or_else(|| cfg.pointer("/agents/default/workspace")) + .or_else(|| cfg.pointer("/agent/workspace")) + .and_then(Value::as_str); + if let Some(ws_str) = ws_raw { + let ws_path = if let Some(rest) = ws_str.strip_prefix("~/") { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(rest) + } else { + PathBuf::from(ws_str) + }; + // Only copy if workspace is outside base_dir (otherwise already copied above) + if ws_path.exists() && !ws_path.starts_with(&paths.base_dir) { + let ws_dest = backup_dir.join("workspace"); + fs::create_dir_all(&ws_dest) + .map_err(|e| format!("Failed to create workspace backup dir: {e}"))?; + let ws_skip: HashSet<&str> = [".git"].iter().copied().collect(); + copy_dir_recursive(&ws_path, &ws_dest, &ws_skip, &mut total_bytes)?; + } + } + Ok(BackupInfo { name: name.clone(), path: backup_dir.to_string_lossy().to_string(), @@ -324,12 +364,37 @@ pub fn restore_from_backup(backup_name: String) -> Result { } // Restore other directories (agents except sessions/archive, memory, etc.) - let skip_dirs: HashSet<&str> = ["sessions", "archive", ".clawpal"] + let skip_dirs: HashSet<&str> = ["sessions", "archive", ".clawpal", "workspace"] .iter() .copied() .collect(); restore_dir_recursive(&backup_dir, &paths.base_dir, &skip_dirs)?; + // Restore workspace if it was backed up separately (custom workspace path) + let ws_backup = backup_dir.join("workspace"); + if ws_backup.exists() { + let cfg = read_openclaw_config(&paths).unwrap_or_default(); + let ws_raw = cfg + .pointer("/agents/defaults/workspace") + .or_else(|| cfg.pointer("/agents/default/workspace")) + .or_else(|| cfg.pointer("/agent/workspace")) + .and_then(Value::as_str); + if let Some(ws_str) = ws_raw { + let ws_path = if let Some(rest) = ws_str.strip_prefix("~/") { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(rest) + } else { + PathBuf::from(ws_str) + }; + if !ws_path.starts_with(&paths.base_dir) { + fs::create_dir_all(&ws_path).map_err(|e| e.to_string())?; + let ws_skip: HashSet<&str> = HashSet::new(); + restore_dir_recursive(&ws_backup, &ws_path, &ws_skip)?; + } + } + } + Ok(format!("Restored from backup '{}'", backup_name)) }) } @@ -457,3 +522,226 @@ pub(crate) fn restore_dir_recursive( } Ok(()) } + +// ---- Workspace Git Backup Commands ---- + +/// Resolve the workspace path from the OpenClaw config. +/// Returns the tilde-based path (e.g. "~/.openclaw/workspace") suitable for shell commands, +/// and the expanded absolute path for local file operations. +fn resolve_workspace_paths() -> Result<(String, PathBuf), String> { + let paths = resolve_paths(); + let cfg = read_openclaw_config(&paths).unwrap_or_default(); + let ws_raw = cfg + .pointer("/agents/defaults/workspace") + .or_else(|| cfg.pointer("/agents/default/workspace")) + .or_else(|| cfg.pointer("/agent/workspace")) + .and_then(Value::as_str) + .unwrap_or("~/.openclaw/workspace"); + let expanded = if let Some(rest) = ws_raw.strip_prefix("~/") { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(rest) + } else { + PathBuf::from(ws_raw) + }; + Ok((ws_raw.to_string(), expanded)) +} + +/// Resolve the workspace path from a remote OpenClaw config. +/// Returns the shell-safe path string (tilde form or absolute). +async fn resolve_remote_workspace_path( + pool: &SshConnectionPool, + host_id: &str, +) -> Result { + let result = pool + .exec_login( + host_id, + concat!("cat \"$HOME/.openclaw/openclaw.json\" 2>/dev/null || echo '{}'"), + ) + .await?; + let cfg: Value = serde_json::from_str(result.stdout.trim()).unwrap_or_default(); + let ws = cfg + .pointer("/agents/defaults/workspace") + .or_else(|| cfg.pointer("/agents/default/workspace")) + .or_else(|| cfg.pointer("/agent/workspace")) + .and_then(Value::as_str) + .unwrap_or("~/.openclaw/workspace"); + let shell_path = if let Some(rest) = ws.strip_prefix("~/") { + format!("\"$HOME/{}\"", rest) + } else { + format!("\"{}\"", ws) + }; + Ok(shell_path) +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GitBackupResult { + pub committed: bool, + pub pushed: bool, + pub message: String, +} + +#[tauri::command] +pub fn workspace_git_status() -> Result { + let (_ws_raw, ws_path) = resolve_workspace_paths()?; + let ws_str = ws_path.to_string_lossy().to_string(); + let cmd = clawpal_core::backup::build_git_status_probe_cmd(&ws_str); + let output = std::process::Command::new("sh") + .arg("-c") + .arg(&cmd) + .output() + .map_err(|e| format!("Failed to run git status: {e}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(clawpal_core::backup::parse_workspace_git_status(&stdout)) +} + +#[tauri::command] +pub fn workspace_git_backup(message: Option) -> Result { + let (_ws_raw, ws_path) = resolve_workspace_paths()?; + let ws_str = ws_path.to_string_lossy().to_string(); + let msg = message.unwrap_or_else(|| { + let now = chrono::Utc::now(); + format!("Workspace backup {}", now.format("%Y-%m-%d %H:%M")) + }); + let cmd = clawpal_core::backup::build_git_backup_cmd(&ws_str, &msg); + let output = std::process::Command::new("sh") + .arg("-c") + .arg(&cmd) + .output() + .map_err(|e| format!("Failed to run git backup: {e}"))?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if output.status.code().unwrap_or(1) != 0 { + return Err(format!("{stdout}\n{stderr}").trim().to_string()); + } + + if stdout.contains("NOTHING_TO_COMMIT") { + return Ok(GitBackupResult { + committed: false, + pushed: false, + message: "Nothing to commit — workspace is clean".to_string(), + }); + } + + let pushed = stdout.contains("PUSHED"); + Ok(GitBackupResult { + committed: true, + pushed, + message: if pushed { + "Committed and pushed to remote".to_string() + } else if stdout.contains("COMMITTED_NO_REMOTE") { + "Committed locally (no remote configured)".to_string() + } else { + "Committed".to_string() + }, + }) +} + +#[tauri::command] +pub fn workspace_git_init() -> Result { + let (_ws_raw, ws_path) = resolve_workspace_paths()?; + if !ws_path.exists() { + return Err(format!( + "Workspace directory does not exist: {}", + ws_path.display() + )); + } + let ws_str = ws_path.to_string_lossy().to_string(); + let cmd = clawpal_core::backup::build_git_init_cmd(&ws_str); + let output = std::process::Command::new("sh") + .arg("-c") + .arg(&cmd) + .output() + .map_err(|e| format!("Failed to init git repo: {e}"))?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if output.status.code().unwrap_or(1) != 0 { + return Err(format!("{stdout}\n{stderr}").trim().to_string()); + } + + if stdout.contains("ALREADY_INITIALIZED") { + Ok("already_initialized".to_string()) + } else { + Ok("initialized".to_string()) + } +} + +#[tauri::command] +pub async fn remote_workspace_git_status( + pool: State<'_, SshConnectionPool>, + host_id: String, +) -> Result { + let ws = resolve_remote_workspace_path(&pool, &host_id).await?; + let cmd = clawpal_core::backup::build_git_status_probe_cmd(&ws); + let result = pool.exec_login(&host_id, &cmd).await?; + Ok(clawpal_core::backup::parse_workspace_git_status( + &result.stdout, + )) +} + +#[tauri::command] +pub async fn remote_workspace_git_backup( + pool: State<'_, SshConnectionPool>, + host_id: String, + message: Option, +) -> Result { + let ws = resolve_remote_workspace_path(&pool, &host_id).await?; + let msg = message.unwrap_or_else(|| { + let now = chrono::Utc::now(); + format!("Workspace backup {}", now.format("%Y-%m-%d %H:%M")) + }); + let cmd = clawpal_core::backup::build_git_backup_cmd(&ws, &msg); + let result = pool.exec_login(&host_id, &cmd).await?; + + if result.exit_code != 0 { + return Err(format!("{}\n{}", result.stdout, result.stderr) + .trim() + .to_string()); + } + + if result.stdout.contains("NOTHING_TO_COMMIT") { + return Ok(GitBackupResult { + committed: false, + pushed: false, + message: "Nothing to commit — workspace is clean".to_string(), + }); + } + + let pushed = result.stdout.contains("PUSHED"); + Ok(GitBackupResult { + committed: true, + pushed, + message: if pushed { + "Committed and pushed to remote".to_string() + } else if result.stdout.contains("COMMITTED_NO_REMOTE") { + "Committed locally (no remote configured)".to_string() + } else { + "Committed".to_string() + }, + }) +} + +#[tauri::command] +pub async fn remote_workspace_git_init( + pool: State<'_, SshConnectionPool>, + host_id: String, +) -> Result { + let ws = resolve_remote_workspace_path(&pool, &host_id).await?; + let cmd = clawpal_core::backup::build_git_init_cmd(&ws); + let result = pool.exec_login(&host_id, &cmd).await?; + + if result.exit_code != 0 { + return Err(format!("{}\n{}", result.stdout, result.stderr) + .trim() + .to_string()); + } + + if result.stdout.contains("ALREADY_INITIALIZED") { + Ok("already_initialized".to_string()) + } else { + Ok("initialized".to_string()) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index adedb810..09b27bf8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -48,15 +48,19 @@ use crate::commands::{ remote_read_gateway_error_log, remote_read_gateway_log, remote_read_helper_log, remote_read_raw_config, remote_refresh_model_catalog, remote_repair_doctor_assistant, remote_repair_primary_via_rescue, remote_resolve_api_keys, remote_restart_gateway, - remote_restore_from_backup, remote_rollback, remote_run_doctor, remote_run_openclaw_upgrade, + remote_restore_from_backup, remote_rollback, + remote_run_doctor, remote_run_openclaw_upgrade, remote_setup_agent_identity, remote_start_watchdog, remote_stop_watchdog, remote_sync_profiles_to_local_auth, remote_test_model_profile, remote_trigger_cron_job, remote_uninstall_watchdog, remote_upsert_model_profile, remote_write_raw_config, repair_doctor_assistant, repair_primary_via_rescue, resolve_api_keys, resolve_provider_auth, - restart_gateway, restore_from_backup, rollback, run_doctor_command, run_openclaw_upgrade, + restart_gateway, restore_from_backup, rollback, + run_doctor_command, run_openclaw_upgrade, set_active_clawpal_data_dir, set_active_openclaw_home, set_agent_model, set_bug_report_settings, set_global_model, set_session_model_override, set_ssh_transfer_speed_ui_preference, setup_agent_identity, sftp_list_dir, sftp_read_file, + remote_workspace_git_backup, remote_workspace_git_init, remote_workspace_git_status, + workspace_git_backup, workspace_git_init, workspace_git_status, sftp_remove_file, sftp_write_file, ssh_connect, ssh_connect_with_passphrase, ssh_disconnect, ssh_exec, ssh_status, start_watchdog, stop_watchdog, test_model_profile, trigger_cron_job, uninstall_watchdog, upsert_model_profile, upsert_ssh_host, @@ -170,6 +174,9 @@ pub fn run() { list_backups, restore_from_backup, delete_backup, + workspace_git_status, + workspace_git_backup, + workspace_git_init, list_channels_minimal, get_channels_config_snapshot, get_channels_runtime_snapshot, @@ -253,6 +260,9 @@ pub fn run() { remote_list_backups, remote_restore_from_backup, remote_delete_backup, + remote_workspace_git_status, + remote_workspace_git_backup, + remote_workspace_git_init, list_cron_jobs, get_cron_config_snapshot, get_cron_runs, diff --git a/src/components/BackupsPanel.tsx b/src/components/BackupsPanel.tsx index 3e7dbaa4..29bb6d6e 100644 --- a/src/components/BackupsPanel.tsx +++ b/src/components/BackupsPanel.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { hasGuidanceEmitted, useApi } from "@/lib/use-api"; +import { WorkspaceGitBackup } from "@/components/WorkspaceGitBackup"; import { formatBytes, formatTime } from "@/lib/utils"; import type { BackupInfo } from "@/lib/types"; import { Card, CardContent } from "@/components/ui/card"; @@ -44,7 +45,9 @@ export function BackupsPanel() { return ( <> -
+ + +
(null); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(""); + const [messageType, setMessageType] = useState<"success" | "error" | "info">("info"); + + const refresh = useCallback(() => { + setLoading(true); + ua.workspaceGitStatus() + .then((s) => { + setStatus(s); + setLoading(false); + }) + .catch((e) => { + console.error("Failed to load git status:", e); + setStatus(null); + setLoading(false); + }); + }, [ua]); + + useEffect(() => { + setStatus(null); + setMessage(""); + refresh(); + }, [refresh, ua.instanceId, ua.instanceToken, ua.isRemote, ua.isConnected]); + + const showMessage = (text: string, type: "success" | "error" | "info") => { + setMessage(text); + setMessageType(type); + }; + + if (loading) { + return ( + + + + + {t("home.gitBackup")} + + + + + + + ); + } + + // Workspace not a git repo — show init button + if (!status?.isGitRepo) { + return ( + + + + + {t("home.gitBackup")} + + + +

+ {t("home.gitNotInitialized")} +

+ {message && ( +

+ {message} +

+ )} + { + setMessage(""); + try { + const result = await ua.workspaceGitInit(); + if (result === "already_initialized") { + showMessage(t("home.gitAlreadyInitialized"), "info"); + } else { + showMessage(t("home.gitInitialized"), "success"); + } + refresh(); + } catch (e) { + if (!hasGuidanceEmitted(e)) { + showMessage(t("home.gitInitFailed", { error: String(e) }), "error"); + } + } + }} + > + {t("home.gitInitRepo")} + +
+
+ ); + } + + // Git repo exists — show status + sync button + return ( + + + + + {t("home.gitBackup")} + + + + {/* Status details */} +
+ {status.branch && ( +
+ + {t("home.gitBranch", { branch: status.branch })} +
+ )} + {status.hasRemote && status.remoteUrl ? ( +
+ + + {t("home.gitRemote", { url: status.remoteUrl })} + +
+ ) : ( +
+ + {t("home.gitLocalOnly")} +
+ )} + {status.uncommittedCount > 0 ? ( +
+ + {t("home.gitUncommitted", { count: status.uncommittedCount })} +
+ ) : ( +
+ + {t("home.gitClean")} +
+ )} + {status.ahead > 0 && ( +
+ {t("home.gitAhead", { count: status.ahead })} +
+ )} + {status.behind > 0 && ( +
+ {t("home.gitBehind", { count: status.behind })} +
+ )} + {status.lastCommitMessage && ( +
+ {t("home.gitLastCommit", { message: status.lastCommitMessage })} +
+ )} +
+ + {/* Message */} + {message && ( +

+ {message} +

+ )} + + {/* Sync button */} +
+ 0 ? "default" : "outline"} + loadingText={t("home.gitSyncing")} + onClick={async () => { + setMessage(""); + try { + const result = await ua.workspaceGitBackup(); + if (!result.committed) { + showMessage(t("home.gitClean"), "info"); + } else { + showMessage(t("home.gitSynced"), "success"); + } + refresh(); + } catch (e) { + if (!hasGuidanceEmitted(e)) { + showMessage(t("home.gitSyncFailed", { error: String(e) }), "error"); + } + } + }} + > + {t("home.gitSync")} + + {!status.hasRemote && ( +

+ {t("home.gitNoRemote")} +

+ )} +
+
+
+ ); +} diff --git a/src/lib/api.ts b/src/lib/api.ts index e596b015..24cff4c1 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { AgentOverview, AgentSessionAnalysis, AppPreferences, ApplyQueueResult, ApplyResult, BackupInfo, Binding, BugReportSettings, BugReportStats, ChannelNode, ChannelsConfigSnapshot, ChannelsRuntimeSnapshot, CronConfigSnapshot, CronJob, CronRun, CronRuntimeSnapshot, DiscordGuildChannel, DiscoveredInstance, DockerInstance, EnsureAccessResult, GuidanceAction, HistoryItem, InstallMethodCapability, InstallOrchestratorDecision, InstallSession, InstallStepResult, InstallTargetDecision, InstanceConfigSnapshot, InstanceRuntimeSnapshot, InstanceStatus, StatusExtra, ModelCatalogProvider, ModelProfile, PendingCommand, PrecheckIssue, PreviewQueueResult, PreviewResult, ProfilePushResult, ProviderAuthSuggestion, Recipe, RecordInstallExperienceResult, RegisteredInstance, RelatedSecretPushResult, RemoteAuthSyncResult, RescueBotAction, RescueBotManageResult, RescuePrimaryDiagnosisResult, RescuePrimaryRepairResult, ResolvedApiKey, SshConfigHostSuggestion, SshConnectionProfile, SshDiagnosticReport, SshHost, SshIntent, SshTransferStats, SystemStatus, DoctorReport, SessionFile, WatchdogStatus } from "./types"; +import type { AgentOverview, AgentSessionAnalysis, AppPreferences, ApplyQueueResult, ApplyResult, BackupInfo, Binding, WorkspaceGitStatus, GitBackupResult, BugReportSettings, BugReportStats, ChannelNode, ChannelsConfigSnapshot, ChannelsRuntimeSnapshot, CronConfigSnapshot, CronJob, CronRun, CronRuntimeSnapshot, DiscordGuildChannel, DiscoveredInstance, DockerInstance, EnsureAccessResult, GuidanceAction, HistoryItem, InstallMethodCapability, InstallOrchestratorDecision, InstallSession, InstallStepResult, InstallTargetDecision, InstanceConfigSnapshot, InstanceRuntimeSnapshot, InstanceStatus, StatusExtra, ModelCatalogProvider, ModelProfile, PendingCommand, PrecheckIssue, PreviewQueueResult, PreviewResult, ProfilePushResult, ProviderAuthSuggestion, Recipe, RecordInstallExperienceResult, RegisteredInstance, RelatedSecretPushResult, RemoteAuthSyncResult, RescueBotAction, RescueBotManageResult, RescuePrimaryDiagnosisResult, RescuePrimaryRepairResult, ResolvedApiKey, SshConfigHostSuggestion, SshConnectionProfile, SshDiagnosticReport, SshHost, SshIntent, SshTransferStats, SystemStatus, DoctorReport, SessionFile, WatchdogStatus } from "./types"; export const api = { setActiveOpenclawHome: (path: string | null): Promise => @@ -166,6 +166,12 @@ export const api = { invoke("restore_from_backup", { backupName }), deleteBackup: (backupName: string): Promise => invoke("delete_backup", { backupName }), + workspaceGitStatus: (): Promise => + invoke("workspace_git_status", {}), + workspaceGitBackup: (message?: string): Promise => + invoke("workspace_git_backup", { message }), + workspaceGitInit: (): Promise => + invoke("workspace_git_init", {}), listChannelsMinimal: (): Promise => invoke("list_channels_minimal", {}), getChannelsConfigSnapshot: (): Promise => @@ -336,6 +342,12 @@ export const api = { invoke("remote_restore_from_backup", { hostId, backupName }), remoteDeleteBackup: (hostId: string, backupName: string): Promise => invoke("remote_delete_backup", { hostId, backupName }), + remoteWorkspaceGitStatus: (hostId: string): Promise => + invoke("remote_workspace_git_status", { hostId }), + remoteWorkspaceGitBackup: (hostId: string, message?: string): Promise => + invoke("remote_workspace_git_backup", { hostId, message }), + remoteWorkspaceGitInit: (hostId: string): Promise => + invoke("remote_workspace_git_init", { hostId }), // Upgrade checkOpenclawUpdate: (): Promise<{ upgradeAvailable: boolean; latestVersion: string | null; installedVersion: string }> => diff --git a/src/lib/types.ts b/src/lib/types.ts index df7bd36e..1a535202 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -359,6 +359,93 @@ export interface BackupInfo { sizeBytes: number; } +export interface WorkspaceGitStatus { + isGitRepo: boolean; + hasRemote: boolean; + remoteUrl?: string; + branch?: string; + uncommittedCount: number; + ahead: number; + behind: number; + lastCommitTime?: string; + lastCommitMessage?: string; +} + +export interface GitBackupResult { + committed: boolean; + pushed: boolean; + message: string; +} + +export interface SshHost { + id: string; + label: string; + host: string; + port: number; + username: string; + authMethod: "key" | "ssh_config" | "password"; + keyPath?: string; + password?: string; + passphrase?: string; +} + +export interface SshConfigHostSuggestion { + hostAlias: string; + hostName?: string; + user?: string; + port?: number; + identityFile?: string; +} + +export type SshStage = + | "resolveHostConfig" + | "tcpReachability" + | "hostKeyVerification" + | "authNegotiation" + | "sessionOpen" + | "remoteExec" + | "sftpRead" + | "sftpWrite" + | "sftpRemove"; + +export type SshIntent = + | "connect" + | "exec" + | "sftp_read" + | "sftp_write" + | "sftp_remove" + | "install_step" + | "doctor_remote" + | "health_check"; + +export type SshDiagnosticStatus = "ok" | "degraded" | "failed"; + +export type SshErrorCode = + | "SSH_HOST_UNREACHABLE" + | "SSH_CONNECTION_REFUSED" + | "SSH_TIMEOUT" + | "SSH_HOST_KEY_FAILED" + | "SSH_KEYFILE_MISSING" + | "SSH_PASSPHRASE_REQUIRED" + | "SSH_AUTH_FAILED" + | "SSH_REMOTE_COMMAND_FAILED" + | "SSH_SFTP_PERMISSION_DENIED" + | "SSH_SESSION_STALE" + | "SSH_UNKNOWN"; + +export type SshRepairAction = + | "promptPassphrase" + | "retryWithBackoff" + | "switchAuthMethodToSshConfig" + | "suggestKnownHostsBootstrap" + | "suggestAuthorizedKeysCheck" + | "suggestPortHostValidation" + | "reconnectSession"; + +export interface SshEvidence { + kind: string; + value: string; +} diff --git a/src/lib/use-api.ts b/src/lib/use-api.ts index 75efb60d..7cb1d16f 100644 --- a/src/lib/use-api.ts +++ b/src/lib/use-api.ts @@ -481,6 +481,22 @@ export function useApi() { dispatch(api.deleteBackup, api.remoteDeleteBackup), ["listBackups"], ), + + // Workspace Git Backup + workspaceGitStatus: dispatchCached( + "workspaceGitStatus", + isRemote ? 20_000 : 12_000, + api.workspaceGitStatus, + api.remoteWorkspaceGitStatus, + ), + workspaceGitBackup: withInvalidation( + dispatch(api.workspaceGitBackup, api.remoteWorkspaceGitBackup), + ["workspaceGitStatus"], + ), + workspaceGitInit: withInvalidation( + dispatch(api.workspaceGitInit, api.remoteWorkspaceGitInit), + ["workspaceGitStatus"], + ), runOpenclawUpgrade: withInvalidation( dispatch( api.runOpenclawUpgrade, diff --git a/src/locales/en.json b/src/locales/en.json index 1592d994..04e6b30c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3,7 +3,7 @@ "nav.recipes": "Recipes", "nav.channels": "Channels", "nav.history": "History", - "nav.doctor": "\uD83E\uDD9E help \uD83E\uDD9E", + "nav.doctor": "🦞 help 🦞", "nav.sessions": "Sessions", "nav.orchestrator": "Orchestrator", "nav.settings": "Settings", @@ -11,7 +11,6 @@ "nav.context": "Context", "nav.website": "Website", "nav.chat": "Chat", - "config.pendingChanges": "Pending changes", "config.applyChanges": "Apply Changes", "config.discard": "Discard", @@ -27,7 +26,6 @@ "config.discardFailed": "Discard failed: {{error}}", "config.sshFailed": "SSH connection failed: {{error}}", "config.noRecipeSelected": "No recipe selected.", - "home.title": "Home", "home.status": "Status", "home.health": "Health", @@ -129,7 +127,6 @@ "installChat.gatewayLogsApp": "Gateway Logs (app)", "installChat.gatewayLogsError": "Gateway Logs (error)", "installChat.diagnosticSourceRemote": "remote ({{hostId}})", - "install.docker.setupFailed": "Docker setup failed", "install.docker.precheck.summary": "Docker precheck completed", "install.docker.precheck.details": "Docker, Docker Compose, and git are available", @@ -139,7 +136,6 @@ "install.docker.init.details": "Built official OpenClaw Docker image (openclaw:local)", "install.docker.verify.summary": "Docker verify completed", "install.docker.verify.details": "Official Docker compose configuration and local image verified", - "install.local.precheck.summary": "Local precheck completed", "install.local.precheck.failed": "Local precheck failed", "install.local.precheck.detailsFound": "OpenClaw detected on local machine", @@ -151,7 +147,6 @@ "install.local.init.failed": "Local init failed", "install.local.init.details": "Initialized OpenClaw directory", "install.local.verify.summary": "Local verify completed", - "install.ssh.precheck.summary": "Remote SSH precheck completed", "install.ssh.precheck.failed": "Remote SSH precheck failed", "install.ssh.precheck.detailsFound": "OpenClaw detected on remote host", @@ -165,7 +160,6 @@ "install.ssh.init.details": "Initialized ~/.openclaw on remote host", "install.ssh.verify.summary": "Remote SSH verify completed", "install.ssh.verify.failed": "Remote SSH verify failed", - "install.wsl2.precheck.summary": "WSL2 precheck completed", "install.wsl2.install.summary": "WSL2 install completed", "install.wsl2.init.summary": "WSL2 init completed", @@ -181,7 +175,6 @@ "home.deleteBackupFailed": "Delete failed: {{error}}", "home.remoteReadFailed": "Failed to read remote OpenClaw instance: {{error}}", "home.remoteAgentsFailed": "Failed to load remote agents: {{error}}", - "settings.title": "Settings", "settings.oauthHint": "For OAuth providers, you can authorize directly in Add Profile (Start OAuth -> paste callback URL/code). CLI login is still supported.", "settings.oauthHintSuffix": "Profiles created via CLI will appear in the list on the right.", @@ -360,8 +353,7 @@ "settings.bugReportStatsLastHour": "Sent in last hour: {{count}}", "settings.bugReportStatsDropped": "Rate-limited drops: {{count}}", "settings.bugReportStatsLastSent": "Last sent: {{value}}", - - "doctor.title": "\uD83E\uDD9E help \uD83E\uDD9E", + "doctor.title": "🦞 help 🦞", "doctor.healthScore": "Health score: {{score}}", "doctor.healthSummary": "Health: {{score}}", "doctor.fix": "fix", @@ -389,8 +381,8 @@ "doctor.failedLoadRemoteSessions": "Failed to load remote session files", "doctor.filesCount": "{{count}} files ({{size}})", "doctor.noSessionFiles": "No session files found.", - "doctor.collapse": "\u25B2 Collapse", - "doctor.details": "\u25BC Details", + "doctor.collapse": "▲ Collapse", + "doctor.details": "▼ Details", "doctor.empty": "{{count}} empty", "doctor.lowValue": "{{count}} low value", "doctor.valuable": "{{count}} valuable", @@ -605,14 +597,12 @@ "doctor.fullAutoWarning": "In full-auto mode, all commands from the agent will be executed immediately without your approval. This includes write operations and arbitrary shell commands. Only enable this if you trust the agent source.", "doctor.fullAutoConfirm": "Enable", "doctor.cancel": "Cancel", - "orchestrator.title": "Orchestrator", "orchestrator.description": "Live decision and execution log from install orchestration.", "orchestrator.scope.current": "Current Instance", "orchestrator.scope.all": "All Instances", "orchestrator.clear": "Clear Log", "orchestrator.empty": "No orchestration events yet.", - "history.title": "History", "history.rollback": "rollback", "history.reverted": "Reverted {{details}}", @@ -629,7 +619,6 @@ "history.unknown": "unknown", "history.rollbackConfirmTitle": "Rollback to this snapshot?", "history.rollbackConfirmDescription": "This will revert your configuration to the state captured in this snapshot. The current config will be replaced.", - "channels.title": "Channels", "channels.noChannels": "No channels configured. Add channel plugins in your OpenClaw config, then refresh to see them here.", "channels.refresh": "Refresh", @@ -642,14 +631,12 @@ "channels.mainDefault": "main (default)", "channels.defaultModel": "default model", "channels.newAgent": "+ New agent...", - "recipes.title": "Recipes", "recipes.sourceLabel": "Recipe source (file path or URL)", "recipes.loading": "Loading...", "recipes.load": "Load", "recipes.loadedFrom": "Loaded from: {{source}}", "recipes.builtinSource": "builtin / clawpal recipes", - "cook.recipeNotFound": "Recipe not found", "cook.next": "Next", "cook.skippedEmpty": "(skipped — empty params)", @@ -661,14 +648,12 @@ "cook.stepsSkipped": ", {{skipped}} skipped", "cook.applyHint": "Use \"Apply Changes\" in the sidebar to restart the gateway and activate config changes.", "cook.done": "Done", - "chat.new": "New", "chat.thinking": "Thinking...", "chat.placeholder": "Ask your OpenClaw agent...", "chat.send": "Send", "chat.noResponse": "No response", "chat.noAgents": "No agents available. Create an agent first.", - "createAgent.title": "New Agent", "createAgent.agentId": "Agent ID", "createAgent.agentIdPlaceholder": "e.g. my-agent", @@ -685,7 +670,6 @@ "createAgent.creating": "Creating...", "createAgent.create": "Create", "createAgent.agentIdRequired": "Agent ID is required", - "upgrade.title": "Upgrade OpenClaw", "upgrade.backupTitle": "Creating Backup...", "upgrade.upgradingTitle": "Upgrading...", @@ -706,14 +690,12 @@ "upgrade.startUpgrade": "Start Upgrade", "upgrade.retryBackup": "Retry Backup", "upgrade.close": "Close", - "recipeCard.steps_one": "{{count}} step", "recipeCard.steps_other": "{{count}} steps", "recipeCard.easy": "Easy", "recipeCard.normal": "Normal", "recipeCard.advanced": "Advanced", "recipeCard.cook": "Cook", - "paramForm.isRequired": "{{label}} is required", "paramForm.tooShort": "{{label}} is too short", "paramForm.tooLong": "{{label}} is too long", @@ -725,7 +707,6 @@ "paramForm.selectAgent": "Select an agent", "paramForm.selectModel": "Select a model", "paramForm.useGlobalDefault": "Use global default", - "instance.local": "Local", "instance.start": "Start", "instance.typeLocal": "Local", @@ -792,7 +773,6 @@ "instance.keyGuideClose": "Got it", "instance.keyGuideCopy": "Copy", "instance.keyGuideCopied": "Copied!", - "ssh.errorConnectionRefused": "Connection refused — please check Host and Port", "ssh.errorNoSuchFile": "Key file not found — please check Key Path", "ssh.errorHostUnreachable": "Host unreachable or hostname cannot be resolved — please check Host (for example, use reachable IP/FQDN instead of an unknown alias)", @@ -821,7 +801,6 @@ "ssh.passphraseLabel": "Passphrase", "ssh.passphrasePlaceholder": "Leave empty if key has no passphrase", "ssh.passphraseConfirm": "Connect", - "nav.cron": "Cron", "cron.title": "Cron Jobs", "cron.noJobs": "No cron jobs configured", @@ -893,7 +872,6 @@ "watchdog.stopSuccess": "Watchdog stopped", "watchdog.actionFailed": "Watchdog action failed: {{error}}", "watchdog.startingHint": "Waiting for first health check...", - "queue.pendingCount_one": "{{count}} pending change", "queue.pendingCount_other": "{{count}} pending changes", "queue.remove": "Remove", @@ -911,7 +889,6 @@ "queue.discardFailed": "Discard failed: {{error}}", "queue.discardTitle": "Discard all queued changes?", "queue.discardDescription": "This will remove {{count}} queued command(s). No changes will be applied.", - "start.welcome": "Welcome to ClawPal", "start.welcomeHint": "Select an instance to manage, or connect a new one.", "start.addInstance": "Connect Instance", @@ -963,9 +940,7 @@ "quickDiagnose.placeholder": "Diagnose this issue and propose concrete next steps.", "quickDiagnose.buttonLabel": "Quick Diagnose", "quickDiagnose.diagnoseWithZeroclaw": "Diagnose with Zeroclaw", - "instance.close": "Close tab", - "onboarding.summary": "This instance needs some setup before it's ready to use", "onboarding.noProfilesSummary": "No model profiles detected yet. Doctor Claw needs at least one available model profile.", "onboarding.actionCheckDoctor": "Check the Doctor page — the instance is not healthy", @@ -973,5 +948,25 @@ "onboarding.actionSetDefault": "Select a default model on the Home page", "onboarding.actionSyncProfiles": "Open Settings and sync/extract profiles from your existing OpenClaw config", "onboarding.actionConnectInstanceFirst": "Connect an instance first from Start, then configure model profiles", - "onboarding.actionOpenConnectEntry": "Open Start → Connect Instance" + "onboarding.actionOpenConnectEntry": "Open Start → Connect Instance", + "home.gitBackup": "Git Backup", + "home.gitBackupDescription": "Back up your workspace via git. Recommended for private repos.", + "home.gitNotInitialized": "Workspace is not a git repository.", + "home.gitInitRepo": "Initialize Git", + "home.gitInitialized": "Git repository initialized with .gitignore.", + "home.gitAlreadyInitialized": "Already a git repository.", + "home.gitNoRemote": "No remote configured. Add one with: git remote add origin ", + "home.gitSync": "Sync Now", + "home.gitSyncing": "Syncing...", + "home.gitSynced": "Workspace synced successfully.", + "home.gitSyncFailed": "Git sync failed: {{error}}", + "home.gitBranch": "Branch: {{branch}}", + "home.gitRemote": "Remote: {{url}}", + "home.gitUncommitted": "{{count}} uncommitted change(s)", + "home.gitAhead": "{{count}} commit(s) ahead", + "home.gitBehind": "{{count}} commit(s) behind", + "home.gitLastCommit": "Last: {{message}}", + "home.gitClean": "Workspace is clean — nothing to commit.", + "home.gitInitFailed": "Failed to initialize: {{error}}", + "home.gitLocalOnly": "Local only (no remote)" } diff --git a/src/locales/zh.json b/src/locales/zh.json index c5c9b248..264c535a 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3,7 +3,7 @@ "nav.recipes": "菜谱", "nav.channels": "频道", "nav.history": "历史", - "nav.doctor": "\uD83E\uDD9E help \uD83E\uDD9E", + "nav.doctor": "🦞 help 🦞", "nav.sessions": "会话管理", "nav.orchestrator": "编排器", "nav.settings": "设置", @@ -11,7 +11,6 @@ "nav.context": "上下文", "nav.website": "官网", "nav.chat": "聊天", - "config.pendingChanges": "待应用变更", "config.applyChanges": "应用变更", "config.discard": "放弃", @@ -27,7 +26,6 @@ "config.discardFailed": "放弃失败:{{error}}", "config.sshFailed": "SSH 连接失败:{{error}}", "config.noRecipeSelected": "未选择菜谱。", - "home.title": "首页", "home.status": "状态", "home.health": "健康状态", @@ -128,7 +126,6 @@ "installChat.gatewayLogsApp": "Gateway 日志(应用)", "installChat.gatewayLogsError": "Gateway 日志(错误)", "installChat.diagnosticSourceRemote": "远程({{hostId}})", - "install.docker.setupFailed": "Docker 安装失败", "install.docker.precheck.summary": "Docker 预检完成", "install.docker.precheck.details": "Docker、Docker Compose 和 git 均可用", @@ -138,7 +135,6 @@ "install.docker.init.details": "已构建 OpenClaw Docker 镜像 (openclaw:local)", "install.docker.verify.summary": "Docker 验证完成", "install.docker.verify.details": "Docker Compose 配置和本地镜像验证通过", - "install.local.precheck.summary": "本地预检完成", "install.local.precheck.failed": "本地预检失败", "install.local.precheck.detailsFound": "检测到本地已安装 OpenClaw", @@ -150,7 +146,6 @@ "install.local.init.failed": "本地初始化失败", "install.local.init.details": "已初始化 OpenClaw 目录", "install.local.verify.summary": "本地验证完成", - "install.ssh.precheck.summary": "远程 SSH 预检完成", "install.ssh.precheck.failed": "远程 SSH 预检失败", "install.ssh.precheck.detailsFound": "检测到远程主机已安装 OpenClaw", @@ -164,7 +159,6 @@ "install.ssh.init.details": "已初始化远程主机 ~/.openclaw 目录", "install.ssh.verify.summary": "远程 SSH 验证完成", "install.ssh.verify.failed": "远程 SSH 验证失败", - "install.wsl2.precheck.summary": "WSL2 预检完成", "install.wsl2.install.summary": "WSL2 安装完成", "install.wsl2.init.summary": "WSL2 初始化完成", @@ -180,7 +174,6 @@ "home.deleteBackupFailed": "删除失败:{{error}}", "home.remoteReadFailed": "无法读取远程 OpenClaw 实例:{{error}}", "home.remoteAgentsFailed": "无法加载远程 Agent:{{error}}", - "settings.title": "设置", "settings.oauthHint": "对于 OAuth 提供商,你可以在“添加配置”里直接完成授权(开始 OAuth -> 粘贴回调 URL/授权码)。仍然支持通过 CLI 登录。", "settings.oauthHintSuffix": "通过 CLI 创建的配置将显示在右侧列表中。", @@ -359,8 +352,7 @@ "settings.bugReportStatsLastHour": "最近一小时发送:{{count}}", "settings.bugReportStatsDropped": "限流丢弃:{{count}}", "settings.bugReportStatsLastSent": "最近发送:{{value}}", - - "doctor.title": "\uD83E\uDD9E help \uD83E\uDD9E", + "doctor.title": "🦞 help 🦞", "doctor.healthScore": "健康评分:{{score}}", "doctor.healthSummary": "健康度: {{score}}", "doctor.fix": "修复", @@ -388,8 +380,8 @@ "doctor.failedLoadRemoteSessions": "加载远程会话文件失败", "doctor.filesCount": "{{count}} 个文件({{size}})", "doctor.noSessionFiles": "未找到会话文件。", - "doctor.collapse": "\u25B2 折叠", - "doctor.details": "\u25BC 详情", + "doctor.collapse": "▲ 折叠", + "doctor.details": "▼ 详情", "doctor.empty": "{{count}} 个空会话", "doctor.lowValue": "{{count}} 个低价值", "doctor.valuable": "{{count}} 个有价值", @@ -602,14 +594,12 @@ "doctor.fullAutoWarning": "全自动模式下,Agent 发出的所有命令将立即执行,无需你的审批。这包括写入操作和任意 Shell 命令。请仅在信任 Agent 来源时启用。", "doctor.fullAutoConfirm": "启用", "doctor.cancel": "取消", - "orchestrator.title": "编排器", "orchestrator.description": "安装编排的实时决策与执行日志。", "orchestrator.scope.current": "当前实例", "orchestrator.scope.all": "全部实例", "orchestrator.clear": "清空日志", "orchestrator.empty": "暂无编排事件。", - "history.title": "历史", "history.rollback": "回滚", "history.reverted": "已恢复 {{details}}", @@ -626,7 +616,6 @@ "history.unknown": "未知", "history.rollbackConfirmTitle": "回滚到此快照?", "history.rollbackConfirmDescription": "这将把配置恢复到此快照的状态,当前配置将被替换。", - "channels.title": "频道", "channels.noChannels": "未配置频道。请在 OpenClaw 配置中添加频道插件,然后刷新查看。", "channels.refresh": "刷新", @@ -639,14 +628,12 @@ "channels.mainDefault": "main(默认)", "channels.defaultModel": "默认模型", "channels.newAgent": "+ 新建 Agent...", - "recipes.title": "菜谱", "recipes.sourceLabel": "菜谱来源(文件路径或 URL)", "recipes.loading": "加载中...", "recipes.load": "加载", "recipes.loadedFrom": "加载自:{{source}}", "recipes.builtinSource": "内置 / clawpal 菜谱", - "cook.recipeNotFound": "未找到菜谱", "cook.next": "下一步", "cook.skippedEmpty": "(已跳过 — 参数为空)", @@ -658,14 +645,12 @@ "cook.stepsSkipped": ",{{skipped}} 个已跳过", "cook.applyHint": "使用侧栏中的「应用变更」重启网关并激活配置更改。", "cook.done": "完成", - "chat.new": "新对话", "chat.thinking": "思考中...", "chat.placeholder": "向你的 OpenClaw Agent 提问...", "chat.send": "发送", "chat.noResponse": "无响应", "chat.noAgents": "暂无可用 Agent,请先创建一个。", - "createAgent.title": "新建 Agent", "createAgent.agentId": "Agent ID", "createAgent.agentIdPlaceholder": "例如 my-agent", @@ -682,7 +667,6 @@ "createAgent.creating": "创建中...", "createAgent.create": "创建", "createAgent.agentIdRequired": "Agent ID 为必填项", - "upgrade.title": "升级 OpenClaw", "upgrade.backupTitle": "正在创建备份...", "upgrade.upgradingTitle": "升级中...", @@ -703,14 +687,12 @@ "upgrade.startUpgrade": "开始升级", "upgrade.retryBackup": "重试备份", "upgrade.close": "关闭", - "recipeCard.steps_one": "{{count}} 个步骤", "recipeCard.steps_other": "{{count}} 个步骤", "recipeCard.easy": "简单", "recipeCard.normal": "普通", "recipeCard.advanced": "高级", "recipeCard.cook": "开始烹饪", - "paramForm.isRequired": "{{label}} 为必填项", "paramForm.tooShort": "{{label}} 太短", "paramForm.tooLong": "{{label}} 太长", @@ -722,7 +704,6 @@ "paramForm.selectAgent": "选择 Agent", "paramForm.selectModel": "选择模型", "paramForm.useGlobalDefault": "使用全局默认", - "instance.local": "本地", "instance.start": "Start", "instance.typeLocal": "本地", @@ -789,7 +770,6 @@ "instance.keyGuideClose": "知道了", "instance.keyGuideCopy": "复制", "instance.keyGuideCopied": "已复制!", - "ssh.errorConnectionRefused": "连接被拒绝,请检查 Host 和 Port 是否正确", "ssh.errorNoSuchFile": "密钥文件不存在,请检查 Key Path", "ssh.errorHostUnreachable": "主机不可达或主机名无法解析,请检查 Host(例如未知别名可改用可达 IP/FQDN)", @@ -818,7 +798,6 @@ "ssh.passphraseLabel": "口令", "ssh.passphrasePlaceholder": "若密钥未加密可留空", "ssh.passphraseConfirm": "连接", - "nav.cron": "定时任务", "cron.title": "定时任务", "cron.noJobs": "未配置定时任务", @@ -890,7 +869,6 @@ "watchdog.stopSuccess": "看门狗已停止", "watchdog.actionFailed": "看门狗操作失败:{{error}}", "watchdog.startingHint": "等待首次健康检查...", - "queue.pendingCount_one": "{{count}} 项待执行变更", "queue.pendingCount_other": "{{count}} 项待执行变更", "queue.remove": "移除", @@ -908,7 +886,6 @@ "queue.discardFailed": "放弃失败:{{error}}", "queue.discardTitle": "放弃所有排队变更?", "queue.discardDescription": "这将移除 {{count}} 条排队命令,不会应用任何变更。", - "start.welcome": "欢迎使用 ClawPal", "start.welcomeHint": "选择一个实例进行管理,或连接新实例。", "start.addInstance": "连接实例", @@ -960,9 +937,7 @@ "quickDiagnose.placeholder": "请诊断当前问题并给出可执行的下一步。", "quickDiagnose.buttonLabel": "快速诊断", "quickDiagnose.diagnoseWithZeroclaw": "使用 Zeroclaw 诊断", - "instance.close": "关闭标签页", - "onboarding.summary": "该实例还需要一些配置才能正常使用", "onboarding.noProfilesSummary": "暂未检测到可用模型配置。小龙虾至少需要一个可用的模型配置才能工作。", "onboarding.actionCheckDoctor": "前往 Doctor 页面检查 — 实例健康状态异常", @@ -970,5 +945,25 @@ "onboarding.actionSetDefault": "在首页选择默认模型", "onboarding.actionSyncProfiles": "前往设置页,从已有 OpenClaw 配置同步/提取模型配置", "onboarding.actionConnectInstanceFirst": "请先在 Start 连接实例,再配置模型", - "onboarding.actionOpenConnectEntry": "打开 Start → 连接实例" + "onboarding.actionOpenConnectEntry": "打开 Start → 连接实例", + "home.gitBackup": "Git 备份", + "home.gitBackupDescription": "通过 git 备份工作区。建议使用私有仓库。", + "home.gitNotInitialized": "工作区尚未初始化为 git 仓库。", + "home.gitInitRepo": "初始化 Git", + "home.gitInitialized": "Git 仓库已初始化,已创建 .gitignore。", + "home.gitAlreadyInitialized": "已经是 git 仓库。", + "home.gitNoRemote": "未配置远程仓库。请运行:git remote add origin ", + "home.gitSync": "立即同步", + "home.gitSyncing": "同步中...", + "home.gitSynced": "工作区同步成功。", + "home.gitSyncFailed": "Git 同步失败:{{error}}", + "home.gitBranch": "分支:{{branch}}", + "home.gitRemote": "远程:{{url}}", + "home.gitUncommitted": "{{count}} 个未提交的更改", + "home.gitAhead": "领先 {{count}} 个提交", + "home.gitBehind": "落后 {{count}} 个提交", + "home.gitLastCommit": "最近:{{message}}", + "home.gitClean": "工作区已干净 — 无需提交。", + "home.gitInitFailed": "初始化失败:{{error}}", + "home.gitLocalOnly": "仅本地(无远程仓库)" } From 5243008d74a88c08fa954987c46db324f2a467ff Mon Sep 17 00:00:00 2001 From: dev01lay2 Date: Thu, 19 Mar 2026 04:54:25 +0000 Subject: [PATCH 2/2] fix: resolve cargo fmt and duplicate SSH type exports in CI --- src-tauri/src/lib.rs | 14 ++++----- src/lib/types.ts | 75 -------------------------------------------- 2 files changed, 6 insertions(+), 83 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 09b27bf8..19198983 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -48,22 +48,20 @@ use crate::commands::{ remote_read_gateway_error_log, remote_read_gateway_log, remote_read_helper_log, remote_read_raw_config, remote_refresh_model_catalog, remote_repair_doctor_assistant, remote_repair_primary_via_rescue, remote_resolve_api_keys, remote_restart_gateway, - remote_restore_from_backup, remote_rollback, - remote_run_doctor, remote_run_openclaw_upgrade, + remote_restore_from_backup, remote_rollback, remote_run_doctor, remote_run_openclaw_upgrade, remote_setup_agent_identity, remote_start_watchdog, remote_stop_watchdog, remote_sync_profiles_to_local_auth, remote_test_model_profile, remote_trigger_cron_job, - remote_uninstall_watchdog, remote_upsert_model_profile, remote_write_raw_config, + remote_uninstall_watchdog, remote_upsert_model_profile, remote_workspace_git_backup, + remote_workspace_git_init, remote_workspace_git_status, remote_write_raw_config, repair_doctor_assistant, repair_primary_via_rescue, resolve_api_keys, resolve_provider_auth, - restart_gateway, restore_from_backup, rollback, - run_doctor_command, run_openclaw_upgrade, + restart_gateway, restore_from_backup, rollback, run_doctor_command, run_openclaw_upgrade, set_active_clawpal_data_dir, set_active_openclaw_home, set_agent_model, set_bug_report_settings, set_global_model, set_session_model_override, set_ssh_transfer_speed_ui_preference, setup_agent_identity, sftp_list_dir, sftp_read_file, - remote_workspace_git_backup, remote_workspace_git_init, remote_workspace_git_status, - workspace_git_backup, workspace_git_init, workspace_git_status, sftp_remove_file, sftp_write_file, ssh_connect, ssh_connect_with_passphrase, ssh_disconnect, ssh_exec, ssh_status, start_watchdog, stop_watchdog, test_model_profile, trigger_cron_job, - uninstall_watchdog, upsert_model_profile, upsert_ssh_host, + uninstall_watchdog, upsert_model_profile, upsert_ssh_host, workspace_git_backup, + workspace_git_init, workspace_git_status, }; use crate::install::commands::{ install_create_session, install_decide_target, install_get_session, install_list_methods, diff --git a/src/lib/types.ts b/src/lib/types.ts index 1a535202..d03d6a22 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -377,81 +377,6 @@ export interface GitBackupResult { message: string; } -export interface SshHost { - id: string; - label: string; - host: string; - port: number; - username: string; - authMethod: "key" | "ssh_config" | "password"; - keyPath?: string; - password?: string; - passphrase?: string; -} - -export interface SshConfigHostSuggestion { - hostAlias: string; - hostName?: string; - user?: string; - port?: number; - identityFile?: string; -} - -export type SshStage = - | "resolveHostConfig" - | "tcpReachability" - | "hostKeyVerification" - | "authNegotiation" - | "sessionOpen" - | "remoteExec" - | "sftpRead" - | "sftpWrite" - | "sftpRemove"; - -export type SshIntent = - | "connect" - | "exec" - | "sftp_read" - | "sftp_write" - | "sftp_remove" - | "install_step" - | "doctor_remote" - | "health_check"; - -export type SshDiagnosticStatus = "ok" | "degraded" | "failed"; - -export type SshErrorCode = - | "SSH_HOST_UNREACHABLE" - | "SSH_CONNECTION_REFUSED" - | "SSH_TIMEOUT" - | "SSH_HOST_KEY_FAILED" - | "SSH_KEYFILE_MISSING" - | "SSH_PASSPHRASE_REQUIRED" - | "SSH_AUTH_FAILED" - | "SSH_REMOTE_COMMAND_FAILED" - | "SSH_SFTP_PERMISSION_DENIED" - | "SSH_SESSION_STALE" - | "SSH_UNKNOWN"; - -export type SshRepairAction = - | "promptPassphrase" - | "retryWithBackoff" - | "switchAuthMethodToSshConfig" - | "suggestKnownHostsBootstrap" - | "suggestAuthorizedKeysCheck" - | "suggestPortHostValidation" - | "reconnectSession"; - -export interface SshEvidence { - kind: string; - value: string; -} - - - - - -