Show editor availability (vibe-kanban) (#1325)
* 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<String>` 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<ResponseJson<ApiResponse<OpenEditorResponse>>, StatusCode>` - Changed to: `Result<ResponseJson<ApiResponse<OpenEditorResponse>>, 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<Option<String>, io::Error>` → `Result<Option<String>, 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
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4381,6 +4381,7 @@ dependencies = [
|
|||||||
"shlex",
|
"shlex",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"strip-ansi-escapes",
|
"strip-ansi-escapes",
|
||||||
|
"strum",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ axum-extra = { version = "0.9", features = ["typed-header"] }
|
|||||||
url = "2.5"
|
url = "2.5"
|
||||||
rand = { version = "0.8", features = ["std"] }
|
rand = { version = "0.8", features = ["std"] }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
|
strum = "0.27.2"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ fn generate_types_content() -> String {
|
|||||||
server::routes::config::McpServerQuery::decl(),
|
server::routes::config::McpServerQuery::decl(),
|
||||||
server::routes::config::UpdateMcpServersBody::decl(),
|
server::routes::config::UpdateMcpServersBody::decl(),
|
||||||
server::routes::config::GetMcpServerResponse::decl(),
|
server::routes::config::GetMcpServerResponse::decl(),
|
||||||
|
server::routes::config::CheckEditorAvailabilityQuery::decl(),
|
||||||
|
server::routes::config::CheckEditorAvailabilityResponse::decl(),
|
||||||
server::routes::task_attempts::CreateFollowUpAttempt::decl(),
|
server::routes::task_attempts::CreateFollowUpAttempt::decl(),
|
||||||
services::services::drafts::DraftResponse::decl(),
|
services::services::drafts::DraftResponse::decl(),
|
||||||
services::services::drafts::UpdateFollowUpDraftRequest::decl(),
|
services::services::drafts::UpdateFollowUpDraftRequest::decl(),
|
||||||
@@ -97,6 +99,7 @@ fn generate_types_content() -> String {
|
|||||||
services::services::config::ThemeMode::decl(),
|
services::services::config::ThemeMode::decl(),
|
||||||
services::services::config::EditorConfig::decl(),
|
services::services::config::EditorConfig::decl(),
|
||||||
services::services::config::EditorType::decl(),
|
services::services::config::EditorType::decl(),
|
||||||
|
services::services::config::EditorOpenError::decl(),
|
||||||
services::services::config::GitHubConfig::decl(),
|
services::services::config::GitHubConfig::decl(),
|
||||||
services::services::config::SoundFile::decl(),
|
services::services::config::SoundFile::decl(),
|
||||||
services::services::config::UiLanguage::decl(),
|
services::services::config::UiLanguage::decl(),
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ use deployment::{DeploymentError, RemoteClientNotConfigured};
|
|||||||
use executors::executors::ExecutorError;
|
use executors::executors::ExecutorError;
|
||||||
use git2::Error as Git2Error;
|
use git2::Error as Git2Error;
|
||||||
use services::services::{
|
use services::services::{
|
||||||
config::ConfigError, container::ContainerError, drafts::DraftsServiceError,
|
config::{ConfigError, EditorOpenError},
|
||||||
git::GitServiceError, github::GitHubServiceError, image::ImageError,
|
container::ContainerError,
|
||||||
remote_client::RemoteClientError, share::ShareError, worktree_manager::WorktreeError,
|
drafts::DraftsServiceError,
|
||||||
|
git::GitServiceError,
|
||||||
|
github::GitHubServiceError,
|
||||||
|
image::ImageError,
|
||||||
|
remote_client::RemoteClientError,
|
||||||
|
share::ShareError,
|
||||||
|
worktree_manager::WorktreeError,
|
||||||
};
|
};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use utils::response::ApiResponse;
|
use utils::response::ApiResponse;
|
||||||
@@ -52,6 +58,8 @@ pub enum ApiError {
|
|||||||
#[error("IO error: {0}")]
|
#[error("IO error: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
|
EditorOpen(#[from] EditorOpenError),
|
||||||
|
#[error(transparent)]
|
||||||
RemoteClient(#[from] RemoteClientError),
|
RemoteClient(#[from] RemoteClientError),
|
||||||
#[error("Unauthorized")]
|
#[error("Unauthorized")]
|
||||||
Unauthorized,
|
Unauthorized,
|
||||||
@@ -129,6 +137,12 @@ impl IntoResponse for ApiError {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
ApiError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IoError"),
|
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::Multipart(_) => (StatusCode::BAD_REQUEST, "MultipartError"),
|
||||||
ApiError::RemoteClient(err) => match err {
|
ApiError::RemoteClient(err) => match err {
|
||||||
RemoteClientError::Auth => (StatusCode::UNAUTHORIZED, "RemoteClientError"),
|
RemoteClientError::Auth => (StatusCode::UNAUTHORIZED, "RemoteClientError"),
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ use executors::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
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 tokio::fs;
|
||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use utils::{api::oauth::LoginStatus, assets::config_path, response::ApiResponse};
|
use utils::{api::oauth::LoginStatus, assets::config_path, response::ApiResponse};
|
||||||
@@ -30,6 +34,10 @@ pub fn router() -> Router<DeploymentImpl> {
|
|||||||
.route("/sounds/{sound}", get(get_sound))
|
.route("/sounds/{sound}", get(get_sound))
|
||||||
.route("/mcp-config", get(get_mcp_servers).post(update_mcp_servers))
|
.route("/mcp-config", get(get_mcp_servers).post(update_mcp_servers))
|
||||||
.route("/profiles", get(get_profiles).put(update_profiles))
|
.route("/profiles", get(get_profiles).put(update_profiles))
|
||||||
|
.route(
|
||||||
|
"/editors/check-availability",
|
||||||
|
get(check_editor_availability),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, TS)]
|
#[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<DeploymentImpl>,
|
||||||
|
Query(query): Query<CheckEditorAvailabilityQuery>,
|
||||||
|
) -> ResponseJson<ApiResponse<CheckEditorAvailabilityResponse>> {
|
||||||
|
// 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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ pub async fn open_project_in_editor(
|
|||||||
Extension(project): Extension<Project>,
|
Extension(project): Extension<Project>,
|
||||||
State(deployment): State<DeploymentImpl>,
|
State(deployment): State<DeploymentImpl>,
|
||||||
Json(payload): Json<Option<OpenEditorRequest>>,
|
Json(payload): Json<Option<OpenEditorRequest>>,
|
||||||
) -> Result<ResponseJson<ApiResponse<OpenEditorResponse>>, StatusCode> {
|
) -> Result<ResponseJson<ApiResponse<OpenEditorResponse>>, ApiError> {
|
||||||
let path = project.git_repo_path;
|
let path = project.git_repo_path;
|
||||||
|
|
||||||
let editor_config = {
|
let editor_config = {
|
||||||
@@ -448,8 +448,8 @@ pub async fn open_project_in_editor(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Failed to open editor for project {}: {}", project.id, e);
|
tracing::error!("Failed to open editor for project {}: {:?}", project.id, e);
|
||||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
Err(ApiError::EditorOpen(e))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -793,13 +793,11 @@ pub async fn open_task_attempt_in_editor(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
"Failed to open editor for attempt {}: {}",
|
"Failed to open editor for attempt {}: {:?}",
|
||||||
task_attempt.id,
|
task_attempt.id,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
Err(ApiError::TaskAttempt(TaskAttemptError::ValidationError(
|
Err(ApiError::EditorOpen(e))
|
||||||
format!("Failed to open editor: {}", e),
|
|
||||||
)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
187
crates/services/src/services/config/editor/mod.rs
Normal file
187
crates/services/src/services/config/editor/mod.rs
Normal file
@@ -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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
remote_ssh_host: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
remote_ssh_user: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
remote_ssh_host: Option<String>,
|
||||||
|
remote_ssh_user: Option<String>,
|
||||||
|
) -> 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<String>), 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<Option<String>, 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<String> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub mod editor;
|
||||||
mod versions;
|
mod versions;
|
||||||
|
|
||||||
|
pub use editor::EditorOpenError;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ConfigError {
|
pub enum ConfigError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
use std::{
|
use std::path::PathBuf;
|
||||||
io,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
str::FromStr,
|
|
||||||
};
|
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -10,8 +6,35 @@ use strum_macros::EnumString;
|
|||||||
use ts_rs::TS;
|
use ts_rs::TS;
|
||||||
use utils::{assets::SoundAssets, cache_dir};
|
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;
|
use crate::services::config::versions::v1;
|
||||||
|
|
||||||
|
// Keep the From conversions here since v1 types are only accessible within versions module
|
||||||
|
impl From<v1::EditorConfig> for EditorConfig {
|
||||||
|
fn from(old: v1::EditorConfig) -> Self {
|
||||||
|
EditorConfig::new(
|
||||||
|
EditorType::from(old.editor_type),
|
||||||
|
old.custom_command,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<v1::EditorType> 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)]
|
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub config_version: String,
|
pub config_version: String,
|
||||||
@@ -269,162 +292,6 @@ impl From<v1::SoundFile> for SoundFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
|
||||||
pub struct EditorConfig {
|
|
||||||
editor_type: EditorType,
|
|
||||||
custom_command: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
remote_ssh_host: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
remote_ssh_user: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<v1::EditorConfig> 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<v1::EditorType> 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<String> {
|
|
||||||
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<Option<String>, 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<String> {
|
|
||||||
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize, TS, EnumString)]
|
||||||
#[ts(use_ts_enum)]
|
#[ts(use_ts_enum)]
|
||||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||||
|
|||||||
48
frontend/src/components/EditorAvailabilityIndicator.tsx
Normal file
48
frontend/src/components/EditorAvailabilityIndicator.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{availability === 'checking' && (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t('settings.general.editor.availability.checking')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{availability === 'available' && (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4 text-green-500" />
|
||||||
|
<span className="text-green-600">
|
||||||
|
{t('settings.general.editor.availability.available')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{availability === 'unavailable' && (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="h-4 w-4 text-orange-500" />
|
||||||
|
<span className="text-orange-600">
|
||||||
|
{t('settings.general.editor.availability.notFound')}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ import { useUserSystem } from '@/components/ConfigProvider';
|
|||||||
import { toPrettyCase } from '@/utils/string';
|
import { toPrettyCase } from '@/utils/string';
|
||||||
import NiceModal, { useModal } from '@ebay/nice-modal-react';
|
import NiceModal, { useModal } from '@ebay/nice-modal-react';
|
||||||
import { defineModal, type NoProps } from '@/lib/modals';
|
import { defineModal, type NoProps } from '@/lib/modals';
|
||||||
|
import { useEditorAvailability } from '@/hooks/useEditorAvailability';
|
||||||
|
import { EditorAvailabilityIndicator } from '@/components/EditorAvailabilityIndicator';
|
||||||
|
|
||||||
export type OnboardingResult = {
|
export type OnboardingResult = {
|
||||||
profile: ExecutorProfileId;
|
profile: ExecutorProfileId;
|
||||||
@@ -50,6 +52,9 @@ const OnboardingDialogImpl = NiceModal.create<NoProps>(() => {
|
|||||||
const [editorType, setEditorType] = useState<EditorType>(EditorType.VS_CODE);
|
const [editorType, setEditorType] = useState<EditorType>(EditorType.VS_CODE);
|
||||||
const [customCommand, setCustomCommand] = useState<string>('');
|
const [customCommand, setCustomCommand] = useState<string>('');
|
||||||
|
|
||||||
|
// Check editor availability when selection changes
|
||||||
|
const editorAvailability = useEditorAvailability(editorType);
|
||||||
|
|
||||||
const handleComplete = () => {
|
const handleComplete = () => {
|
||||||
modal.resolve({
|
modal.resolve({
|
||||||
profile,
|
profile,
|
||||||
@@ -192,6 +197,12 @@ const OnboardingDialogImpl = NiceModal.create<NoProps>(() => {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{/* Editor availability status indicator */}
|
||||||
|
{editorType !== EditorType.CUSTOM && (
|
||||||
|
<EditorAvailabilityIndicator availability={editorAvailability} />
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
This editor will be used to open task attempts and project files.
|
This editor will be used to open task attempts and project files.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
44
frontend/src/hooks/useEditorAvailability.ts
Normal file
44
frontend/src/hooks/useEditorAvailability.ts
Normal file
@@ -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<EditorAvailabilityState>(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;
|
||||||
|
}
|
||||||
@@ -74,6 +74,11 @@
|
|||||||
"placeholder": "e.g., username",
|
"placeholder": "e.g., username",
|
||||||
"helper": "SSH username for the remote connection. If not set, VS Code will use your SSH config or prompt you."
|
"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": {
|
"github": {
|
||||||
|
|||||||
@@ -74,6 +74,11 @@
|
|||||||
"placeholder": "ej., nombre de usuario",
|
"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á."
|
"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": {
|
"github": {
|
||||||
|
|||||||
@@ -74,6 +74,11 @@
|
|||||||
"placeholder": "例: ユーザー名",
|
"placeholder": "例: ユーザー名",
|
||||||
"helper": "リモート接続のSSHユーザー名。設定されていない場合、VS CodeはSSH設定を使用するか、入力を求めます。"
|
"helper": "リモート接続のSSHユーザー名。設定されていない場合、VS CodeはSSH設定を使用するか、入力を求めます。"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"availability": {
|
||||||
|
"checking": "利用可能性を確認中...",
|
||||||
|
"available": "利用可能",
|
||||||
|
"notFound": "PATHに見つかりません"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"github": {
|
"github": {
|
||||||
|
|||||||
@@ -74,6 +74,11 @@
|
|||||||
"placeholder": "예: 사용자 이름",
|
"placeholder": "예: 사용자 이름",
|
||||||
"helper": "원격 연결을 위한 SSH 사용자 이름입니다. 설정하지 않으면 VS Code가 SSH 설정을 사용하거나 입력을 요청합니다."
|
"helper": "원격 연결을 위한 SSH 사용자 이름입니다. 설정하지 않으면 VS Code가 SSH 설정을 사용하거나 입력을 요청합니다."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"availability": {
|
||||||
|
"checking": "사용 가능 여부 확인 중...",
|
||||||
|
"available": "사용 가능",
|
||||||
|
"notFound": "PATH에서 찾을 수 없음"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"github": {
|
"github": {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
ChangeTargetBranchResponse,
|
ChangeTargetBranchResponse,
|
||||||
RenameBranchRequest,
|
RenameBranchRequest,
|
||||||
RenameBranchResponse,
|
RenameBranchResponse,
|
||||||
|
CheckEditorAvailabilityResponse,
|
||||||
RunAgentSetupRequest,
|
RunAgentSetupRequest,
|
||||||
RunAgentSetupResponse,
|
RunAgentSetupResponse,
|
||||||
GhCliSetupError,
|
GhCliSetupError,
|
||||||
@@ -715,6 +716,14 @@ export const configApi = {
|
|||||||
});
|
});
|
||||||
return handleApiResponse<Config>(response);
|
return handleApiResponse<Config>(response);
|
||||||
},
|
},
|
||||||
|
checkEditorAvailability: async (
|
||||||
|
editorType: EditorType
|
||||||
|
): Promise<CheckEditorAvailabilityResponse> => {
|
||||||
|
const response = await makeRequest(
|
||||||
|
`/api/editors/check-availability?editor_type=${encodeURIComponent(editorType)}`
|
||||||
|
);
|
||||||
|
return handleApiResponse<CheckEditorAvailabilityResponse>(response);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Task Tags APIs (all tags are global)
|
// Task Tags APIs (all tags are global)
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import {
|
|||||||
import { getLanguageOptions } from '@/i18n/languages';
|
import { getLanguageOptions } from '@/i18n/languages';
|
||||||
|
|
||||||
import { toPrettyCase } from '@/utils/string';
|
import { toPrettyCase } from '@/utils/string';
|
||||||
|
import { useEditorAvailability } from '@/hooks/useEditorAvailability';
|
||||||
|
import { EditorAvailabilityIndicator } from '@/components/EditorAvailabilityIndicator';
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
import { useUserSystem } from '@/components/ConfigProvider';
|
import { useUserSystem } from '@/components/ConfigProvider';
|
||||||
import { TagManager } from '@/components/TagManager';
|
import { TagManager } from '@/components/TagManager';
|
||||||
@@ -70,6 +72,9 @@ export function GeneralSettings() {
|
|||||||
);
|
);
|
||||||
const { setTheme } = useTheme();
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
|
// Check editor availability when draft editor changes
|
||||||
|
const editorAvailability = useEditorAvailability(draft?.editor.editor_type);
|
||||||
|
|
||||||
const validateBranchPrefix = useCallback(
|
const validateBranchPrefix = useCallback(
|
||||||
(prefix: string): string | null => {
|
(prefix: string): string | null => {
|
||||||
if (!prefix) return null; // empty allowed
|
if (!prefix) return null; // empty allowed
|
||||||
@@ -447,6 +452,12 @@ export function GeneralSettings() {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{/* Editor availability status indicator */}
|
||||||
|
{draft?.editor.editor_type !== EditorType.CUSTOM && (
|
||||||
|
<EditorAvailabilityIndicator availability={editorAvailability} />
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t('settings.general.editor.type.helper')}
|
{t('settings.general.editor.type.helper')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -136,6 +136,10 @@ export type UpdateMcpServersBody = { servers: { [key in string]?: JsonValue }, }
|
|||||||
|
|
||||||
export type GetMcpServerResponse = { mcp_config: McpConfig, config_path: string, };
|
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<string> | null, retry_process_id: string | null, force_when_dirty: boolean | null, perform_git_reset: boolean | null, };
|
export type CreateFollowUpAttempt = { prompt: string, variant: string | null, image_ids: Array<string> | 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<string> | null, version: bigint, };
|
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<string> | 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 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 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" }
|
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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user