diff --git a/src-tauri/src/shared/workspaces_core/io.rs b/src-tauri/src/shared/workspaces_core/io.rs index 68873e1fa..dd3d03263 100644 --- a/src-tauri/src/shared/workspaces_core/io.rs +++ b/src-tauri/src/shared/workspaces_core/io.rs @@ -15,6 +15,7 @@ use super::helpers::resolve_workspace_root; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum LineAwareLaunchStrategy { GotoFlag, + JetBrainsLineColumnFlags, PathWithLineColumn, } @@ -53,6 +54,9 @@ fn command_launch_strategy(command: &str) -> Option { { return Some(LineAwareLaunchStrategy::GotoFlag); } + if identifier == "phpstorm" || identifier == "phpstorm64" { + return Some(LineAwareLaunchStrategy::JetBrainsLineColumnFlags); + } if identifier == "zed" || identifier == "zed-preview" { return Some(LineAwareLaunchStrategy::PathWithLineColumn); } @@ -64,6 +68,9 @@ fn app_launch_strategy(app: &str) -> Option { if normalized.contains("visual studio code") || normalized.starts_with("cursor") { return Some(LineAwareLaunchStrategy::GotoFlag); } + if is_phpstorm_app_identifier(&normalized) { + return Some(LineAwareLaunchStrategy::JetBrainsLineColumnFlags); + } if normalized == "zed" || normalized.starts_with("zed ") { return Some(LineAwareLaunchStrategy::PathWithLineColumn); } @@ -81,26 +88,19 @@ fn app_cli_command(app: &str) -> Option<&'static str> { if normalized.starts_with("cursor") { return Some("cursor"); } + if is_phpstorm_app_identifier(&normalized) { + return Some("phpstorm"); + } if normalized == "zed" || normalized.starts_with("zed ") { return Some("zed"); } None } -fn normalize_app_identifier(app: &str) -> String { - app.trim() - .chars() - .map(|value| { - if value.is_ascii_alphanumeric() { - value.to_ascii_lowercase() - } else { - ' ' - } - }) - .collect::() - .split_whitespace() - .collect::>() - .join(" ") +fn is_phpstorm_app_identifier(normalized: &str) -> bool { + normalized == "phpstorm" + || normalized == "phpstorm app" + || normalized.ends_with(" phpstorm app") } fn find_executable_in_path(program: &str) -> Option { @@ -125,6 +125,22 @@ fn find_executable_in_path(program: &str) -> Option { None } +fn normalize_app_identifier(app: &str) -> String { + app.trim() + .chars() + .map(|value| { + if value.is_ascii_alphanumeric() { + value.to_ascii_lowercase() + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + fn build_launch_args( path: &str, args: &[String], @@ -141,6 +157,16 @@ fn build_launch_args( launch_args.push("--goto".to_string()); launch_args.push(located_path); } + Some(LineAwareLaunchStrategy::JetBrainsLineColumnFlags) => { + let sanitized_path = normalize_windows_namespace_path(path); + launch_args.push("--line".to_string()); + launch_args.push(line.to_string()); + if let Some(column) = column { + launch_args.push("--column".to_string()); + launch_args.push(column.to_string()); + } + launch_args.push(sanitized_path); + } Some(LineAwareLaunchStrategy::PathWithLineColumn) => { let sanitized_path = normalize_windows_namespace_path(path); let located_path = format_path_with_location(&sanitized_path, line, column); @@ -249,10 +275,25 @@ pub(crate) async fn open_workspace_in_core( .await .map_err(|error| format!("Failed to open app ({target_label}): {error}"))? } else { + let fallback_app_args = if matches!( + app_strategy, + Some(LineAwareLaunchStrategy::JetBrainsLineColumnFlags) + ) && normalize_open_location(line, column).is_some() + { + build_launch_args(&path, &args, line, column, app_strategy) + } else { + Vec::new() + }; let mut cmd = tokio_command("open"); - cmd.arg("-a").arg(trimmed).arg(&path); - if !args.is_empty() { - cmd.arg("--args").args(&args); + cmd.arg("-a").arg(trimmed); + if fallback_app_args.is_empty() { + cmd.arg(&path); + if !args.is_empty() { + cmd.arg("--args").args(&args); + } + } else { + cmd.arg("-n"); + cmd.arg("--args").args(&fallback_app_args); } cmd.output() .await @@ -323,6 +364,10 @@ mod tests { command_launch_strategy("zed"), Some(LineAwareLaunchStrategy::PathWithLineColumn) ); + assert_eq!( + command_launch_strategy("phpstorm64.exe"), + Some(LineAwareLaunchStrategy::JetBrainsLineColumnFlags) + ); assert_eq!(command_launch_strategy("vim"), None); } @@ -340,6 +385,14 @@ mod tests { app_launch_strategy("Zed Preview"), Some(LineAwareLaunchStrategy::PathWithLineColumn) ); + assert_eq!( + app_launch_strategy("PhpStorm.app"), + Some(LineAwareLaunchStrategy::JetBrainsLineColumnFlags) + ); + assert_eq!( + app_launch_strategy("/Applications/PhpStorm.app"), + Some(LineAwareLaunchStrategy::JetBrainsLineColumnFlags) + ); assert_eq!(app_launch_strategy("Ghostty"), None); } @@ -355,6 +408,11 @@ mod tests { Some("code-insiders") ); assert_eq!(app_cli_command("Cursor"), Some("cursor")); + assert_eq!(app_cli_command("PhpStorm.app"), Some("phpstorm")); + assert_eq!( + app_cli_command("/Applications/PhpStorm.app"), + Some("phpstorm") + ); assert_eq!(app_cli_command("Zed Preview"), Some("zed")); assert_eq!(app_cli_command("Ghostty"), None); } @@ -490,6 +548,28 @@ mod tests { assert_eq!(args, vec!["/tmp/project/src/App.tsx:33".to_string()]); } + #[test] + fn builds_line_and_column_flags_for_phpstorm_targets() { + let args = build_launch_args( + "/tmp/project/src/App.tsx", + &[], + Some(33), + Some(7), + Some(LineAwareLaunchStrategy::JetBrainsLineColumnFlags), + ); + + assert_eq!( + args, + vec![ + "--line".to_string(), + "33".to_string(), + "--column".to_string(), + "7".to_string(), + "/tmp/project/src/App.tsx".to_string(), + ] + ); + } + #[test] fn falls_back_to_plain_path_for_unknown_targets() { let args = build_launch_args( diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index c29b9058c..9fc22085a 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -31,11 +31,13 @@ pub(crate) fn read_settings(path: &PathBuf) -> Result { let data = std::fs::read_to_string(path).map_err(|e| e.to_string())?; let mut value: Value = serde_json::from_str(&data).map_err(|e| e.to_string())?; migrate_follow_up_message_behavior(&mut value); + migrate_open_app_targets(&mut value); match serde_json::from_value(value.clone()) { Ok(settings) => Ok(settings), Err(_) => { sanitize_remote_settings_for_tcp_only(&mut value); migrate_follow_up_message_behavior(&mut value); + migrate_open_app_targets(&mut value); serde_json::from_value(value).map_err(|e| e.to_string()) } } @@ -92,6 +94,39 @@ fn migrate_follow_up_message_behavior(value: &mut Value) { ); } +fn migrate_open_app_targets(value: &mut Value) { + let Value::Object(root) = value else { + return; + }; + let Some(Value::Array(existing_targets)) = root.get_mut("openAppTargets") else { + return; + }; + + let has_phpstorm = existing_targets + .iter() + .any(|target| target.get("id").and_then(Value::as_str) == Some("phpstorm")); + if has_phpstorm { + return; + } + + let phpstorm_target = match serde_json::to_value(AppSettings::default().open_app_targets) { + Ok(Value::Array(targets)) => targets + .into_iter() + .find(|target| target.get("id").and_then(Value::as_str) == Some("phpstorm")), + _ => None, + }; + let Some(phpstorm_target) = phpstorm_target else { + return; + }; + + let insert_at = existing_targets + .iter() + .position(|target| target.get("id").and_then(Value::as_str) == Some("finder")) + .unwrap_or(existing_targets.len()); + + existing_targets.insert(insert_at, phpstorm_target); +} + #[cfg(test)] mod tests { use super::{read_settings, read_workspaces, write_workspaces}; @@ -251,4 +286,48 @@ mod tests { let settings = read_settings(&path).expect("read settings"); assert_eq!(settings.follow_up_message_behavior, "queue"); } + + #[test] + fn read_settings_migrates_missing_open_app_targets() { + let temp_dir = std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("settings.json"); + + std::fs::write( + &path, + r#"{ + "theme": "dark", + "selectedOpenAppId": "vscode", + "openAppTargets": [ + { "id": "vscode", "label": "VS Code", "kind": "command", "appName": null, "command": "code", "args": [] }, + { "id": "cursor", "label": "Cursor", "kind": "command", "appName": null, "command": "cursor", "args": [] }, + { "id": "zed", "label": "Zed", "kind": "command", "appName": null, "command": "zed", "args": [] }, + { "id": "ghostty", "label": "Ghostty", "kind": "command", "appName": null, "command": "ghostty", "args": [] }, + { "id": "antigravity", "label": "Antigravity", "kind": "command", "appName": null, "command": "antigravity", "args": [] }, + { "id": "finder", "label": "File Manager", "kind": "finder", "appName": null, "command": null, "args": [] } + ] +}"#, + ) + .expect("write settings"); + + let settings = read_settings(&path).expect("read settings"); + let ids: Vec<&str> = settings + .open_app_targets + .iter() + .map(|target| target.id.as_str()) + .collect(); + + assert_eq!( + ids, + vec![ + "vscode", + "cursor", + "zed", + "ghostty", + "antigravity", + "phpstorm", + "finder" + ] + ); + } } diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 6b595106b..1a89bcae0 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -1001,6 +1001,14 @@ fn default_workspace_groups() -> Vec { } fn default_open_app_targets() -> Vec { + let phpstorm_command = if cfg!(target_os = "windows") { + "phpstorm64.exe" + } else if cfg!(target_os = "linux") { + "phpstorm.sh" + } else { + "phpstorm" + }; + if cfg!(target_os = "macos") { return vec![ OpenAppTarget { @@ -1043,6 +1051,14 @@ fn default_open_app_targets() -> Vec { command: None, args: Vec::new(), }, + OpenAppTarget { + id: "phpstorm".to_string(), + label: "PHPStorm".to_string(), + kind: "app".to_string(), + app_name: Some("PhpStorm".to_string()), + command: None, + args: Vec::new(), + }, OpenAppTarget { id: "finder".to_string(), label: "Finder".to_string(), @@ -1101,6 +1117,14 @@ fn default_open_app_targets() -> Vec { command: Some("antigravity".to_string()), args: Vec::new(), }, + OpenAppTarget { + id: "phpstorm".to_string(), + label: "PHPStorm".to_string(), + kind: "command".to_string(), + app_name: None, + command: Some(phpstorm_command.to_string()), + args: Vec::new(), + }, OpenAppTarget { id: "finder".to_string(), label: file_manager_label.to_string(), @@ -1364,7 +1388,7 @@ mod tests { "vscode" }; assert_eq!(settings.selected_open_app_id, expected_open_id); - assert_eq!(settings.open_app_targets.len(), 6); + assert_eq!(settings.open_app_targets.len(), 7); assert_eq!(settings.open_app_targets[0].id, "vscode"); } diff --git a/src/assets/app-icons/phpstorm.png b/src/assets/app-icons/phpstorm.png new file mode 100644 index 000000000..909d26f9a Binary files /dev/null and b/src/assets/app-icons/phpstorm.png differ diff --git a/src/features/app/constants.ts b/src/features/app/constants.ts index dacc6d2a2..ef52ae3f9 100644 --- a/src/features/app/constants.ts +++ b/src/features/app/constants.ts @@ -7,6 +7,11 @@ import { export const OPEN_APP_STORAGE_KEY = "open-workspace-app"; export const DEFAULT_OPEN_APP_ID = isWindowsPlatform() ? "finder" : "vscode"; +const PHPSTORM_COMMAND = isWindowsPlatform() + ? "phpstorm64.exe" + : isMacPlatform() + ? "phpstorm" + : "phpstorm.sh"; export type OpenAppId = string; @@ -47,6 +52,13 @@ export const DEFAULT_OPEN_APP_TARGETS: OpenAppTarget[] = isMacPlatform() appName: "Antigravity", args: [], }, + { + id: "phpstorm", + label: "PHPStorm", + kind: "app", + appName: "PhpStorm", + args: [], + }, { id: "finder", label: fileManagerName(), @@ -90,6 +102,13 @@ export const DEFAULT_OPEN_APP_TARGETS: OpenAppTarget[] = isMacPlatform() command: "antigravity", args: [], }, + { + id: "phpstorm", + label: "PHPStorm", + kind: "command", + command: PHPSTORM_COMMAND, + args: [], + }, { id: "finder", label: fileManagerName(), diff --git a/src/features/app/utils/openAppIcons.ts b/src/features/app/utils/openAppIcons.ts index 836520b90..a0207b7d9 100644 --- a/src/features/app/utils/openAppIcons.ts +++ b/src/features/app/utils/openAppIcons.ts @@ -2,6 +2,7 @@ import cursorIcon from "../../../assets/app-icons/cursor.png"; import finderIcon from "../../../assets/app-icons/finder.png"; import antigravityIcon from "../../../assets/app-icons/antigravity.png"; import ghosttyIcon from "../../../assets/app-icons/ghostty.png"; +import phpstormIcon from "../../../assets/app-icons/phpstorm.png"; import vscodeIcon from "../../../assets/app-icons/vscode.png"; import zedIcon from "../../../assets/app-icons/zed.png"; import { isMacPlatform } from "../../../utils/platformPaths"; @@ -30,6 +31,8 @@ export function getKnownOpenAppIcon(id: string): string | null { return zedIcon; case "ghostty": return ghosttyIcon; + case "phpstorm": + return phpstormIcon; case "antigravity": return antigravityIcon; case "finder": diff --git a/src/styles/messages.css b/src/styles/messages.css index 2149f5581..69db3ae3e 100644 --- a/src/styles/messages.css +++ b/src/styles/messages.css @@ -475,6 +475,8 @@ display: flex; flex-direction: column; gap: 6px; + width: 100%; + flex: 1 1 auto; min-width: 0; } @@ -485,6 +487,7 @@ gap: 8px; font-size: 12px; color: var(--text-stronger); + width: 100%; min-width: 0; } @@ -592,15 +595,17 @@ border-radius: 6px; border: 1px solid var(--border-subtle); display: inline-flex; + flex: 1 1 auto; min-width: 0; - max-width: 420px; + max-width: 100%; } .tool-inline-command-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - display: inline-block; + display: block; + flex: 1 1 auto; min-width: 0; } @@ -1096,9 +1101,11 @@ .message .markdown-codeblock pre { margin: 0; - padding: 10px 12px 12px; + padding: 10px 12px 18px; white-space: pre; overflow-x: auto; + overflow-y: hidden; + scrollbar-gutter: stable both-edges; width: 100%; max-width: none; box-sizing: border-box; @@ -1110,12 +1117,14 @@ .message .markdown-codeblock-single { margin: 8px 0; - padding: 10px 12px 12px; + padding: 10px 12px 18px; border: 1px solid var(--border-stronger); border-radius: 10px; background: var(--surface-command); white-space: pre; overflow-x: auto; + overflow-y: hidden; + scrollbar-gutter: stable both-edges; width: 100%; max-width: none; box-sizing: border-box; @@ -1182,7 +1191,7 @@ } .message.assistant .markdown :where(p, ul, ol, blockquote) { - max-width: 94ch; + max-width: none; } .markdown :where(strong) {