From 4b1af8cdc5b3da03cfd23a82c9a73f6be3234900 Mon Sep 17 00:00:00 2001 From: Alex Netsch Date: Wed, 19 Nov 2025 12:57:15 +0000 Subject: [PATCH] Show editor availability (vibe-kanban) (#1325) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Perfect! I've successfully refactored the editor opening code to use the `CommandBuilder` pattern. Here's a summary of the changes: - Added `use executors::command::CommandBuilder;` - Changed return type from `Vec` to `CommandBuilder` - Now returns a proper `CommandBuilder` that handles shell word splitting correctly - Supports quoted strings in custom commands (via `shlex` on Unix, `winsplit` on Windows) - Uses `CommandBuilder` pattern: `build_initial()` → `into_resolved()` - Resolves executables properly on **both Unix and Windows** (previously only Windows had resolution) - Better error messages that show which executable couldn't be found - Maps `ExecutorError` to `io::Error` for consistency with the function signature ✅ **Consistent cross-platform behavior**: Unix now also resolves executables properly ✅ **Sophisticated PATH resolution**: Uses login shell refresh logic for finding executables ✅ **Proper shell word splitting**: Handles quoted strings correctly in custom commands ✅ **Better error messages**: Shows exactly which executable couldn't be found ✅ **Follows established patterns**: Uses the same proven pattern as other executors in the codebase All checks pass: compilation, tests, and Clippy linting. The code is ready! Perfect! All changes have been successfully implemented and tested. Here's what was done: - Changed from: `Result>, StatusCode>` - Changed to: `Result>, ApiError>` - Changed from: `Err(StatusCode::INTERNAL_SERVER_ERROR)` (generic 500 error) - Changed to: `Err(ApiError::Io(e))` (passes actual error message) Now when the editor command fails (e.g., executable not found), the API will return a proper error response with the actual error message like: - `"IoError: Editor command not found: Executable 'cursor' not found in PATH"` This matches the pattern already used in `task_attempts.rs` and provides meaningful feedback to the frontend, which can display the error to users or fall back to the editor selection dialog. All checks pass: compilation, TypeScript type checking, and Clippy linting! Perfect! All changes have been successfully implemented and tested. Here's a summary: - New `EditorOpenError` enum with `ExecutableNotFound` variant - Contains structured fields: `executable` (the command that wasn't found) and `editor_type` (the configured editor) - Exports to TypeScript with discriminated union type - Changed `open_file()` return type: `Result, io::Error>` → `Result, EditorOpenError>` - Changed `spawn_local()` return type: `io::Result<()>` → `Result<(), EditorOpenError>` - Maps `ExecutorError::ExecutableNotFound` to structured `EditorOpenError::ExecutableNotFound` - Removed unused `io` import - Added `EditorOpen(#[from] EditorOpenError)` variant - Maps to `StatusCode::BAD_REQUEST` (400) instead of 500 - Returns proper error type: `"EditorOpenError"` - **`crates/server/src/routes/projects.rs:452`**: Changed from `ApiError::Io(e)` to `ApiError::EditorOpen(e)` - **`crates/server/src/routes/task_attempts.rs:965`**: Changed from generic `TaskAttemptError::ValidationError` to `ApiError::EditorOpen(e)` ```typescript export type EditorOpenError = { "type": "executable_not_found", executable: string, editor_type: string, }; ``` Now when an editor executable isn't found, the frontend receives: ```json { "type": "executable_not_found", "executable": "cursor", "editor_type": "CURSOR" } ``` Instead of a generic 500 error. This allows the frontend to: - Show specific error messages ("Cursor not found in PATH") - Suggest alternative editors - Provide installation links or PATH setup instructions - Offer to open the editor settings dialog All checks pass: compilation, TypeScript type checking, Clippy linting, and tests! Cleanup script changes for task attempt f9923e07-9b3b-4d18-b4d1-d4235cf0e12d * Refactor editor config (vibe-kanban a60c079b) Currently edit config is defined in crates/services/src/services/config/versions/v2.rs, we should consider moving it into a module instead. Weigh pros and cons, best practices and the current architecture * Review editor refactor (vibe-kanban 829ba3e5) Review the last 5 commits There's a rebase conflict in `GeneralSettings.tsx` that needs resolution. The conflict is in the imports: **Conflict (lines 41-49):** - **Your branch (HEAD)**: Imports from `ThemeProvider` and `ConfigProvider` (PascalCase) - **Main branch**: Imports from `theme-provider` and `config-provider` (kebab-case) + adds new editor availability features **Resolution needed:** Keep the main branch version because it: 1. Uses the newer kebab-case file naming convention 2. Adds `useEditorAvailability` and `EditorAvailabilityIndicator` features that complement your error handling work The resolved imports should be: ```typescript import { useEditorAvailability } from '@/hooks/useEditorAvailability'; import { EditorAvailabilityIndicator } from '@/components/EditorAvailabilityIndicator'; import { useTheme } from '@/components/theme-provider'; import { useUserSystem } from '@/components/config-provider'; ``` Would you like me to resolve this conflict and continue the rebase? Show available editors at startup (vibe-kanban 9f1ea85f) When onboarding, show a green checkmark next to installed editors. Use the editor struct refactored in the last 6 commits to make it work. Check eveyer editor in the enum at vk startup. Mkae sure editors being unailable does NOT break anything Fix imports after rebase - use PascalCase for ThemeProvider and ConfigProvider Perfect! All changes complete. Let me create a summary: ✅ **Changes made:** - Added `CheckEditorAvailabilityQuery::decl()` - Added `CheckEditorAvailabilityResponse::decl()` New types exported: ```typescript export type CheckEditorAvailabilityQuery = { editor_type: EditorType }; export type CheckEditorAvailabilityResponse = { available: boolean }; ``` - Added import for `CheckEditorAvailabilityResponse` - Updated `checkEditorAvailability` method: - Parameter type: `string` → `EditorType` (type-safe enum) - Return type: `{ available: boolean }` → `CheckEditorAvailabilityResponse` - ✅ Full type safety for editor availability checking - ✅ Frontend uses typed `EditorType` enum instead of strings - ✅ Consistent with other query/response type patterns - ✅ All checks pass (TypeScript + Rust compilation) The editor availability feature now has complete type safety from backend to frontend! * Fix i18n (vibe-kanban 57188642) For new editory avilability added in the last 3 commits. Run @scripts/check-i18n.sh until it passes. Make sure to check the script and set GITHUB_BASE_REF to main * Fix import: use github not github_service after rebase --- Cargo.lock | 1 + crates/server/Cargo.toml | 1 + crates/server/src/bin/generate_types.rs | 3 + crates/server/src/error.rs | 20 +- crates/server/src/routes/config.rs | 38 +++- crates/server/src/routes/projects.rs | 6 +- crates/server/src/routes/task_attempts.rs | 6 +- .../src/services/config/editor/mod.rs | 187 +++++++++++++++++ crates/services/src/services/config/mod.rs | 3 + .../src/services/config/versions/v2.rs | 189 +++--------------- .../EditorAvailabilityIndicator.tsx | 48 +++++ .../dialogs/global/OnboardingDialog.tsx | 11 + frontend/src/hooks/useEditorAvailability.ts | 44 ++++ frontend/src/i18n/locales/en/settings.json | 5 + frontend/src/i18n/locales/es/settings.json | 5 + frontend/src/i18n/locales/ja/settings.json | 5 + frontend/src/i18n/locales/ko/settings.json | 5 + frontend/src/lib/api.ts | 9 + .../src/pages/settings/GeneralSettings.tsx | 11 + shared/types.ts | 6 + 20 files changed, 431 insertions(+), 172 deletions(-) create mode 100644 crates/services/src/services/config/editor/mod.rs create mode 100644 frontend/src/components/EditorAvailabilityIndicator.tsx create mode 100644 frontend/src/hooks/useEditorAvailability.ts diff --git a/Cargo.lock b/Cargo.lock index 2f7a01a5..eceb5306 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4381,6 +4381,7 @@ dependencies = [ "shlex", "sqlx", "strip-ansi-escapes", + "strum", "thiserror 2.0.17", "tokio", "tokio-util", diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 28cf9a34..fae16bde 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -46,6 +46,7 @@ axum-extra = { version = "0.9", features = ["typed-header"] } url = "2.5" rand = { version = "0.8", features = ["std"] } sha2 = "0.10" +strum = "0.27.2" [build-dependencies] dotenv = "0.15" diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 82b51656..b7931ec5 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -74,6 +74,8 @@ fn generate_types_content() -> String { server::routes::config::McpServerQuery::decl(), server::routes::config::UpdateMcpServersBody::decl(), server::routes::config::GetMcpServerResponse::decl(), + server::routes::config::CheckEditorAvailabilityQuery::decl(), + server::routes::config::CheckEditorAvailabilityResponse::decl(), server::routes::task_attempts::CreateFollowUpAttempt::decl(), services::services::drafts::DraftResponse::decl(), services::services::drafts::UpdateFollowUpDraftRequest::decl(), @@ -97,6 +99,7 @@ fn generate_types_content() -> String { services::services::config::ThemeMode::decl(), services::services::config::EditorConfig::decl(), services::services::config::EditorType::decl(), + services::services::config::EditorOpenError::decl(), services::services::config::GitHubConfig::decl(), services::services::config::SoundFile::decl(), services::services::config::UiLanguage::decl(), diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index 5447b8fb..c6525cff 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -11,9 +11,15 @@ use deployment::{DeploymentError, RemoteClientNotConfigured}; use executors::executors::ExecutorError; use git2::Error as Git2Error; use services::services::{ - config::ConfigError, container::ContainerError, drafts::DraftsServiceError, - git::GitServiceError, github::GitHubServiceError, image::ImageError, - remote_client::RemoteClientError, share::ShareError, worktree_manager::WorktreeError, + config::{ConfigError, EditorOpenError}, + container::ContainerError, + drafts::DraftsServiceError, + git::GitServiceError, + github::GitHubServiceError, + image::ImageError, + remote_client::RemoteClientError, + share::ShareError, + worktree_manager::WorktreeError, }; use thiserror::Error; use utils::response::ApiResponse; @@ -52,6 +58,8 @@ pub enum ApiError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error(transparent)] + EditorOpen(#[from] EditorOpenError), + #[error(transparent)] RemoteClient(#[from] RemoteClientError), #[error("Unauthorized")] Unauthorized, @@ -129,6 +137,12 @@ impl IntoResponse for ApiError { } }, ApiError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IoError"), + ApiError::EditorOpen(err) => match err { + EditorOpenError::LaunchFailed { .. } => { + (StatusCode::INTERNAL_SERVER_ERROR, "EditorLaunchError") + } + _ => (StatusCode::BAD_REQUEST, "EditorOpenError"), + }, ApiError::Multipart(_) => (StatusCode::BAD_REQUEST, "MultipartError"), ApiError::RemoteClient(err) => match err { RemoteClientError::Auth => (StatusCode::UNAUTHORIZED, "RemoteClientError"), diff --git a/crates/server/src/routes/config.rs b/crates/server/src/routes/config.rs index c2c0ec28..f5548b08 100644 --- a/crates/server/src/routes/config.rs +++ b/crates/server/src/routes/config.rs @@ -16,7 +16,11 @@ use executors::{ }; use serde::{Deserialize, Serialize}; use serde_json::Value; -use services::services::config::{Config, ConfigError, SoundFile, save_config_to_file}; +use services::services::config::{ + Config, ConfigError, SoundFile, + editor::{EditorConfig, EditorType}, + save_config_to_file, +}; use tokio::fs; use ts_rs::TS; use utils::{api::oauth::LoginStatus, assets::config_path, response::ApiResponse}; @@ -30,6 +34,10 @@ pub fn router() -> Router { .route("/sounds/{sound}", get(get_sound)) .route("/mcp-config", get(get_mcp_servers).post(update_mcp_servers)) .route("/profiles", get(get_profiles).put(update_profiles)) + .route( + "/editors/check-availability", + get(check_editor_availability), + ) } #[derive(Debug, Serialize, Deserialize, TS)] @@ -426,3 +434,31 @@ async fn update_profiles( ))), } } + +#[derive(Debug, Serialize, Deserialize, TS)] +pub struct CheckEditorAvailabilityQuery { + editor_type: EditorType, +} + +#[derive(Debug, Serialize, Deserialize, TS)] +pub struct CheckEditorAvailabilityResponse { + available: bool, +} + +async fn check_editor_availability( + State(_deployment): State, + Query(query): Query, +) -> ResponseJson> { + // Construct a minimal EditorConfig for checking + let editor_config = EditorConfig::new( + query.editor_type, + None, // custom_command + None, // remote_ssh_host + None, // remote_ssh_user + ); + + let available = editor_config.check_availability().await; + ResponseJson(ApiResponse::success(CheckEditorAvailabilityResponse { + available, + })) +} diff --git a/crates/server/src/routes/projects.rs b/crates/server/src/routes/projects.rs index bbbaa30b..14bcc92e 100644 --- a/crates/server/src/routes/projects.rs +++ b/crates/server/src/routes/projects.rs @@ -414,7 +414,7 @@ pub async fn open_project_in_editor( Extension(project): Extension, State(deployment): State, Json(payload): Json>, -) -> Result>, StatusCode> { +) -> Result>, ApiError> { let path = project.git_repo_path; let editor_config = { @@ -448,8 +448,8 @@ pub async fn open_project_in_editor( }))) } Err(e) => { - tracing::error!("Failed to open editor for project {}: {}", project.id, e); - Err(StatusCode::INTERNAL_SERVER_ERROR) + tracing::error!("Failed to open editor for project {}: {:?}", project.id, e); + Err(ApiError::EditorOpen(e)) } } } diff --git a/crates/server/src/routes/task_attempts.rs b/crates/server/src/routes/task_attempts.rs index 37729f35..0d817a27 100644 --- a/crates/server/src/routes/task_attempts.rs +++ b/crates/server/src/routes/task_attempts.rs @@ -793,13 +793,11 @@ pub async fn open_task_attempt_in_editor( } Err(e) => { tracing::error!( - "Failed to open editor for attempt {}: {}", + "Failed to open editor for attempt {}: {:?}", task_attempt.id, e ); - Err(ApiError::TaskAttempt(TaskAttemptError::ValidationError( - format!("Failed to open editor: {}", e), - ))) + Err(ApiError::EditorOpen(e)) } } } diff --git a/crates/services/src/services/config/editor/mod.rs b/crates/services/src/services/config/editor/mod.rs new file mode 100644 index 00000000..afa27ba4 --- /dev/null +++ b/crates/services/src/services/config/editor/mod.rs @@ -0,0 +1,187 @@ +use std::{path::Path, str::FromStr}; + +use executors::{command::CommandBuilder, executors::ExecutorError}; +use serde::{Deserialize, Serialize}; +use strum_macros::{EnumIter, EnumString}; +use thiserror::Error; +use ts_rs::TS; + +#[derive(Debug, Clone, Serialize, Deserialize, TS, Error)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type", rename_all = "snake_case")] +#[ts(export)] +pub enum EditorOpenError { + #[error("Editor executable '{executable}' not found in PATH")] + ExecutableNotFound { + executable: String, + editor_type: EditorType, + }, + #[error("Editor command for {editor_type:?} is invalid: {details}")] + InvalidCommand { + details: String, + editor_type: EditorType, + }, + #[error("Failed to launch '{executable}' for {editor_type:?}: {details}")] + LaunchFailed { + executable: String, + details: String, + editor_type: EditorType, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +pub struct EditorConfig { + editor_type: EditorType, + custom_command: Option, + #[serde(default)] + remote_ssh_host: Option, + #[serde(default)] + remote_ssh_user: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString, EnumIter)] +#[ts(use_ts_enum)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +#[strum(serialize_all = "SCREAMING_SNAKE_CASE")] +pub enum EditorType { + VsCode, + Cursor, + Windsurf, + IntelliJ, + Zed, + Xcode, + Custom, +} + +impl Default for EditorConfig { + fn default() -> Self { + Self { + editor_type: EditorType::VsCode, + custom_command: None, + remote_ssh_host: None, + remote_ssh_user: None, + } + } +} + +impl EditorConfig { + /// Create a new EditorConfig. This is primarily used by version migrations. + pub fn new( + editor_type: EditorType, + custom_command: Option, + remote_ssh_host: Option, + remote_ssh_user: Option, + ) -> Self { + Self { + editor_type, + custom_command, + remote_ssh_host, + remote_ssh_user, + } + } + + pub fn get_command(&self) -> CommandBuilder { + let base_command = match &self.editor_type { + EditorType::VsCode => "code", + EditorType::Cursor => "cursor", + EditorType::Windsurf => "windsurf", + EditorType::IntelliJ => "idea", + EditorType::Zed => "zed", + EditorType::Xcode => "xed", + EditorType::Custom => { + // Custom editor - use user-provided command or fallback to VSCode + self.custom_command.as_deref().unwrap_or("code") + } + }; + CommandBuilder::new(base_command) + } + + /// Resolve the editor command to an executable path and args. + /// This is shared logic used by both check_availability() and spawn_local(). + async fn resolve_command(&self) -> Result<(std::path::PathBuf, Vec), EditorOpenError> { + let command_builder = self.get_command(); + let command_parts = + command_builder + .build_initial() + .map_err(|e| EditorOpenError::InvalidCommand { + details: e.to_string(), + editor_type: self.editor_type.clone(), + })?; + + let (executable, args) = command_parts.into_resolved().await.map_err(|e| match e { + ExecutorError::ExecutableNotFound { program } => EditorOpenError::ExecutableNotFound { + executable: program, + editor_type: self.editor_type.clone(), + }, + _ => EditorOpenError::InvalidCommand { + details: e.to_string(), + editor_type: self.editor_type.clone(), + }, + })?; + + Ok((executable, args)) + } + + /// Check if the editor is available on the system. + /// Uses the same command resolution logic as spawn_local(). + pub async fn check_availability(&self) -> bool { + self.resolve_command().await.is_ok() + } + + pub async fn open_file(&self, path: &Path) -> Result, EditorOpenError> { + if let Some(url) = self.remote_url(path) { + return Ok(Some(url)); + } + self.spawn_local(path).await?; + Ok(None) + } + + fn remote_url(&self, path: &Path) -> Option { + let remote_host = self.remote_ssh_host.as_ref()?; + let scheme = match self.editor_type { + EditorType::VsCode => "vscode", + EditorType::Cursor => "cursor", + EditorType::Windsurf => "windsurf", + _ => return None, + }; + let user_part = self + .remote_ssh_user + .as_ref() + .map(|u| format!("{u}@")) + .unwrap_or_default(); + // files must contain a line and column number + let line_col = if path.is_file() { ":1:1" } else { "" }; + let path = path.to_string_lossy(); + Some(format!( + "{scheme}://vscode-remote/ssh-remote+{user_part}{remote_host}{path}{line_col}" + )) + } + + pub async fn spawn_local(&self, path: &Path) -> Result<(), EditorOpenError> { + let (executable, args) = self.resolve_command().await?; + + let mut cmd = std::process::Command::new(&executable); + cmd.args(&args).arg(path); + cmd.spawn().map_err(|e| EditorOpenError::LaunchFailed { + executable: executable.to_string_lossy().into_owned(), + details: e.to_string(), + editor_type: self.editor_type.clone(), + })?; + Ok(()) + } + + pub fn with_override(&self, editor_type_str: Option<&str>) -> Self { + if let Some(editor_type_str) = editor_type_str { + let editor_type = + EditorType::from_str(editor_type_str).unwrap_or(self.editor_type.clone()); + EditorConfig { + editor_type, + custom_command: self.custom_command.clone(), + remote_ssh_host: self.remote_ssh_host.clone(), + remote_ssh_user: self.remote_ssh_user.clone(), + } + } else { + self.clone() + } + } +} diff --git a/crates/services/src/services/config/mod.rs b/crates/services/src/services/config/mod.rs index 9b1018ce..baa5ef5e 100644 --- a/crates/services/src/services/config/mod.rs +++ b/crates/services/src/services/config/mod.rs @@ -2,8 +2,11 @@ use std::path::PathBuf; use thiserror::Error; +pub mod editor; mod versions; +pub use editor::EditorOpenError; + #[derive(Debug, Error)] pub enum ConfigError { #[error(transparent)] diff --git a/crates/services/src/services/config/versions/v2.rs b/crates/services/src/services/config/versions/v2.rs index 924b48c7..9dcc2557 100644 --- a/crates/services/src/services/config/versions/v2.rs +++ b/crates/services/src/services/config/versions/v2.rs @@ -1,8 +1,4 @@ -use std::{ - io, - path::{Path, PathBuf}, - str::FromStr, -}; +use std::path::PathBuf; use anyhow::Error; use serde::{Deserialize, Serialize}; @@ -10,8 +6,35 @@ use strum_macros::EnumString; use ts_rs::TS; use utils::{assets::SoundAssets, cache_dir}; +// Re-export editor config from the dedicated editor module +pub use crate::services::config::editor::{EditorConfig, EditorType}; use crate::services::config::versions::v1; +// Keep the From conversions here since v1 types are only accessible within versions module +impl From for EditorConfig { + fn from(old: v1::EditorConfig) -> Self { + EditorConfig::new( + EditorType::from(old.editor_type), + old.custom_command, + None, + None, + ) + } +} + +impl From for EditorType { + fn from(old: v1::EditorType) -> Self { + match old { + v1::EditorType::VsCode => EditorType::VsCode, + v1::EditorType::Cursor => EditorType::Cursor, + v1::EditorType::Windsurf => EditorType::Windsurf, + v1::EditorType::IntelliJ => EditorType::IntelliJ, + v1::EditorType::Zed => EditorType::Zed, + v1::EditorType::Custom => EditorType::Custom, + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, TS)] pub struct Config { pub config_version: String, @@ -269,162 +292,6 @@ impl From for SoundFile { } } -#[derive(Debug, Clone, Serialize, Deserialize, TS)] -pub struct EditorConfig { - editor_type: EditorType, - custom_command: Option, - #[serde(default)] - remote_ssh_host: Option, - #[serde(default)] - remote_ssh_user: Option, -} - -impl From for EditorConfig { - fn from(old: v1::EditorConfig) -> Self { - Self { - editor_type: EditorType::from(old.editor_type), // Now SCREAMING_SNAKE_CASE - custom_command: old.custom_command, - remote_ssh_host: None, - remote_ssh_user: None, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString)] -#[ts(use_ts_enum)] -#[serde(rename_all = "SCREAMING_SNAKE_CASE")] -#[strum(serialize_all = "SCREAMING_SNAKE_CASE")] -pub enum EditorType { - VsCode, - Cursor, - Windsurf, - IntelliJ, - Zed, - Xcode, - Custom, -} - -impl From for EditorType { - fn from(old: v1::EditorType) -> Self { - match old { - v1::EditorType::VsCode => EditorType::VsCode, - v1::EditorType::Cursor => EditorType::Cursor, - v1::EditorType::Windsurf => EditorType::Windsurf, - v1::EditorType::IntelliJ => EditorType::IntelliJ, - v1::EditorType::Zed => EditorType::Zed, - v1::EditorType::Custom => EditorType::Custom, - } - } -} - -impl Default for EditorConfig { - fn default() -> Self { - Self { - editor_type: EditorType::VsCode, - custom_command: None, - remote_ssh_host: None, - remote_ssh_user: None, - } - } -} - -impl EditorConfig { - pub fn get_command(&self) -> Vec { - match &self.editor_type { - EditorType::VsCode => vec!["code".to_string()], - EditorType::Cursor => vec!["cursor".to_string()], - EditorType::Windsurf => vec!["windsurf".to_string()], - EditorType::IntelliJ => vec!["idea".to_string()], - EditorType::Zed => vec!["zed".to_string()], - EditorType::Xcode => vec!["xed".to_string()], - EditorType::Custom => { - if let Some(custom) = &self.custom_command { - custom.split_whitespace().map(|s| s.to_string()).collect() - } else { - vec!["code".to_string()] // fallback to VSCode - } - } - } - } - - pub async fn open_file(&self, path: &Path) -> Result, io::Error> { - if let Some(url) = self.remote_url(path) { - return Ok(Some(url)); - } - self.spawn_local(path).await?; - Ok(None) - } - - fn remote_url(&self, path: &Path) -> Option { - let remote_host = self.remote_ssh_host.as_ref()?; - let scheme = match self.editor_type { - EditorType::VsCode => "vscode", - EditorType::Cursor => "cursor", - EditorType::Windsurf => "windsurf", - _ => return None, - }; - let user_part = self - .remote_ssh_user - .as_ref() - .map(|u| format!("{u}@")) - .unwrap_or_default(); - // files must contain a line and column number - let line_col = if path.is_file() { ":1:1" } else { "" }; - let path = path.to_string_lossy(); - Some(format!( - "{scheme}://vscode-remote/ssh-remote+{user_part}{remote_host}{path}{line_col}" - )) - } - - pub async fn spawn_local(&self, path: &Path) -> io::Result<()> { - let command = self.get_command(); - if command.is_empty() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "No editor command configured", - )); - } - - let executable = { - #[cfg(windows)] - { - utils::shell::resolve_executable_path(&command[0]) - .await - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - format!("Editor command '{}' not found", command[0]), - ) - })? - } - #[cfg(not(windows))] - { - command[0].clone() - } - }; - - let mut cmd = std::process::Command::new(executable); - cmd.args(&command[1..]).arg(path); - cmd.spawn()?; - Ok(()) - } - - pub fn with_override(&self, editor_type_str: Option<&str>) -> Self { - if let Some(editor_type_str) = editor_type_str { - let editor_type = - EditorType::from_str(editor_type_str).unwrap_or(self.editor_type.clone()); - EditorConfig { - editor_type, - custom_command: self.custom_command.clone(), - remote_ssh_host: self.remote_ssh_host.clone(), - remote_ssh_user: self.remote_ssh_user.clone(), - } - } else { - self.clone() - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString)] #[ts(use_ts_enum)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] diff --git a/frontend/src/components/EditorAvailabilityIndicator.tsx b/frontend/src/components/EditorAvailabilityIndicator.tsx new file mode 100644 index 00000000..2b3f387b --- /dev/null +++ b/frontend/src/components/EditorAvailabilityIndicator.tsx @@ -0,0 +1,48 @@ +import { Check, AlertCircle, Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { EditorAvailabilityState } from '@/hooks/useEditorAvailability'; + +interface EditorAvailabilityIndicatorProps { + availability: EditorAvailabilityState; +} + +/** + * Visual indicator showing whether an editor is available on the system. + * Shows loading spinner, green checkmark, or orange warning. + */ +export function EditorAvailabilityIndicator({ + availability, +}: EditorAvailabilityIndicatorProps) { + const { t } = useTranslation('settings'); + + if (!availability) return null; + + return ( +
+ {availability === 'checking' && ( + <> + + + {t('settings.general.editor.availability.checking')} + + + )} + {availability === 'available' && ( + <> + + + {t('settings.general.editor.availability.available')} + + + )} + {availability === 'unavailable' && ( + <> + + + {t('settings.general.editor.availability.notFound')} + + + )} +
+ ); +} diff --git a/frontend/src/components/dialogs/global/OnboardingDialog.tsx b/frontend/src/components/dialogs/global/OnboardingDialog.tsx index 276191e9..eac72aee 100644 --- a/frontend/src/components/dialogs/global/OnboardingDialog.tsx +++ b/frontend/src/components/dialogs/global/OnboardingDialog.tsx @@ -31,6 +31,8 @@ import { useUserSystem } from '@/components/ConfigProvider'; import { toPrettyCase } from '@/utils/string'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; import { defineModal, type NoProps } from '@/lib/modals'; +import { useEditorAvailability } from '@/hooks/useEditorAvailability'; +import { EditorAvailabilityIndicator } from '@/components/EditorAvailabilityIndicator'; export type OnboardingResult = { profile: ExecutorProfileId; @@ -50,6 +52,9 @@ const OnboardingDialogImpl = NiceModal.create(() => { const [editorType, setEditorType] = useState(EditorType.VS_CODE); const [customCommand, setCustomCommand] = useState(''); + // Check editor availability when selection changes + const editorAvailability = useEditorAvailability(editorType); + const handleComplete = () => { modal.resolve({ profile, @@ -192,6 +197,12 @@ const OnboardingDialogImpl = NiceModal.create(() => { ))} + + {/* Editor availability status indicator */} + {editorType !== EditorType.CUSTOM && ( + + )} +

This editor will be used to open task attempts and project files.

diff --git a/frontend/src/hooks/useEditorAvailability.ts b/frontend/src/hooks/useEditorAvailability.ts new file mode 100644 index 00000000..8ce6d829 --- /dev/null +++ b/frontend/src/hooks/useEditorAvailability.ts @@ -0,0 +1,44 @@ +import { useState, useEffect } from 'react'; +import { EditorType } from 'shared/types'; +import { configApi } from '@/lib/api'; + +export type EditorAvailabilityState = + | 'checking' + | 'available' + | 'unavailable' + | null; + +/** + * Hook to check if an editor is available on the system. + * Automatically checks when the editor type changes. + * Returns null for Custom editors (can't pre-validate). + */ +export function useEditorAvailability( + editorType: EditorType | null | undefined +): EditorAvailabilityState { + const [availability, setAvailability] = + useState(null); + + useEffect(() => { + // Don't check for Custom editor or if no editor type + if (!editorType || editorType === EditorType.CUSTOM) { + setAvailability(null); + return; + } + + const checkAvailability = async () => { + setAvailability('checking'); + try { + const result = await configApi.checkEditorAvailability(editorType); + setAvailability(result.available ? 'available' : 'unavailable'); + } catch (error) { + console.error('Failed to check editor availability:', error); + setAvailability(null); + } + }; + + checkAvailability(); + }, [editorType]); + + return availability; +} diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index ee4eadce..12b9f31f 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -74,6 +74,11 @@ "placeholder": "e.g., username", "helper": "SSH username for the remote connection. If not set, VS Code will use your SSH config or prompt you." } + }, + "availability": { + "checking": "Checking availability...", + "available": "Available", + "notFound": "Not found in PATH" } }, "github": { diff --git a/frontend/src/i18n/locales/es/settings.json b/frontend/src/i18n/locales/es/settings.json index fa9bfda7..e5e63059 100644 --- a/frontend/src/i18n/locales/es/settings.json +++ b/frontend/src/i18n/locales/es/settings.json @@ -74,6 +74,11 @@ "placeholder": "ej., nombre de usuario", "helper": "Nombre de usuario SSH para la conexión remota. Si no se establece, VS Code usará tu configuración SSH o te lo pedirá." } + }, + "availability": { + "checking": "Verificando disponibilidad...", + "available": "Disponible", + "notFound": "No encontrado en PATH" } }, "github": { diff --git a/frontend/src/i18n/locales/ja/settings.json b/frontend/src/i18n/locales/ja/settings.json index 5d1016e4..dd63be32 100644 --- a/frontend/src/i18n/locales/ja/settings.json +++ b/frontend/src/i18n/locales/ja/settings.json @@ -74,6 +74,11 @@ "placeholder": "例: ユーザー名", "helper": "リモート接続のSSHユーザー名。設定されていない場合、VS CodeはSSH設定を使用するか、入力を求めます。" } + }, + "availability": { + "checking": "利用可能性を確認中...", + "available": "利用可能", + "notFound": "PATHに見つかりません" } }, "github": { diff --git a/frontend/src/i18n/locales/ko/settings.json b/frontend/src/i18n/locales/ko/settings.json index 4ef67ca3..9e4b2a8a 100644 --- a/frontend/src/i18n/locales/ko/settings.json +++ b/frontend/src/i18n/locales/ko/settings.json @@ -74,6 +74,11 @@ "placeholder": "예: 사용자 이름", "helper": "원격 연결을 위한 SSH 사용자 이름입니다. 설정하지 않으면 VS Code가 SSH 설정을 사용하거나 입력을 요청합니다." } + }, + "availability": { + "checking": "사용 가능 여부 확인 중...", + "available": "사용 가능", + "notFound": "PATH에서 찾을 수 없음" } }, "github": { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fa16fc2a..3b0d4ed1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -46,6 +46,7 @@ import { ChangeTargetBranchResponse, RenameBranchRequest, RenameBranchResponse, + CheckEditorAvailabilityResponse, RunAgentSetupRequest, RunAgentSetupResponse, GhCliSetupError, @@ -715,6 +716,14 @@ export const configApi = { }); return handleApiResponse(response); }, + checkEditorAvailability: async ( + editorType: EditorType + ): Promise => { + const response = await makeRequest( + `/api/editors/check-availability?editor_type=${encodeURIComponent(editorType)}` + ); + return handleApiResponse(response); + }, }; // Task Tags APIs (all tags are global) diff --git a/frontend/src/pages/settings/GeneralSettings.tsx b/frontend/src/pages/settings/GeneralSettings.tsx index 6a870bc3..e3ea1177 100644 --- a/frontend/src/pages/settings/GeneralSettings.tsx +++ b/frontend/src/pages/settings/GeneralSettings.tsx @@ -38,6 +38,8 @@ import { import { getLanguageOptions } from '@/i18n/languages'; import { toPrettyCase } from '@/utils/string'; +import { useEditorAvailability } from '@/hooks/useEditorAvailability'; +import { EditorAvailabilityIndicator } from '@/components/EditorAvailabilityIndicator'; import { useTheme } from '@/components/ThemeProvider'; import { useUserSystem } from '@/components/ConfigProvider'; import { TagManager } from '@/components/TagManager'; @@ -70,6 +72,9 @@ export function GeneralSettings() { ); const { setTheme } = useTheme(); + // Check editor availability when draft editor changes + const editorAvailability = useEditorAvailability(draft?.editor.editor_type); + const validateBranchPrefix = useCallback( (prefix: string): string | null => { if (!prefix) return null; // empty allowed @@ -447,6 +452,12 @@ export function GeneralSettings() { ))} + + {/* Editor availability status indicator */} + {draft?.editor.editor_type !== EditorType.CUSTOM && ( + + )} +

{t('settings.general.editor.type.helper')}

diff --git a/shared/types.ts b/shared/types.ts index f84c4abd..8084a160 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -136,6 +136,10 @@ export type UpdateMcpServersBody = { servers: { [key in string]?: JsonValue }, } export type GetMcpServerResponse = { mcp_config: McpConfig, config_path: string, }; +export type CheckEditorAvailabilityQuery = { editor_type: EditorType, }; + +export type CheckEditorAvailabilityResponse = { available: boolean, }; + export type CreateFollowUpAttempt = { prompt: string, variant: string | null, image_ids: Array | null, retry_process_id: string | null, force_when_dirty: boolean | null, perform_git_reset: boolean | null, }; export type DraftResponse = { task_attempt_id: string, draft_type: DraftType, retry_process_id: string | null, prompt: string, queued: boolean, variant: string | null, image_ids: Array | null, version: bigint, }; @@ -182,6 +186,8 @@ export type EditorConfig = { editor_type: EditorType, custom_command: string | n export enum EditorType { VS_CODE = "VS_CODE", CURSOR = "CURSOR", WINDSURF = "WINDSURF", INTELLI_J = "INTELLI_J", ZED = "ZED", XCODE = "XCODE", CUSTOM = "CUSTOM" } +export type EditorOpenError = { "type": "executable_not_found", executable: string, editor_type: EditorType, } | { "type": "invalid_command", details: string, editor_type: EditorType, } | { "type": "launch_failed", executable: string, details: string, editor_type: EditorType, }; + export type GitHubConfig = { pat: string | null, oauth_token: string | null, username: string | null, primary_email: string | null, default_pr_base: string | null, }; export enum SoundFile { ABSTRACT_SOUND1 = "ABSTRACT_SOUND1", ABSTRACT_SOUND2 = "ABSTRACT_SOUND2", ABSTRACT_SOUND3 = "ABSTRACT_SOUND3", ABSTRACT_SOUND4 = "ABSTRACT_SOUND4", COW_MOOING = "COW_MOOING", PHONE_VIBRATION = "PHONE_VIBRATION", ROOSTER = "ROOSTER" }