diff --git a/Cargo.lock b/Cargo.lock index cafa2b5d2..9def049fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4170,13 +4170,16 @@ dependencies = [ [[package]] name = "ide-cli" -version = "0.1.36" +version = "0.1.38" dependencies = [ "dirs 6.0.0", "serde", "serde_json", "tempfile", + "tokio", "tracing", + "windows-sys 0.61.2", + "winreg 0.56.0", ] [[package]] diff --git a/README.md b/README.md index 843b88ce3..b90460487 100644 --- a/README.md +++ b/README.md @@ -79,5 +79,6 @@ the [official plugins](plugins/official). The plugin API is still a preview. Database tool window notes, including Oracle Instant Client setup, are in [`docs/databases.md`](docs/databases.md). Java language server choices and their capabilities are in [`docs/java.md`](docs/java.md). +Command-line launcher setup for macOS, Windows and Linux is in [`docs/command-line.md`](docs/command-line.md). Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o. diff --git a/crates/ide-cli/Cargo.toml b/crates/ide-cli/Cargo.toml index 829943612..193aa5d40 100644 --- a/crates/ide-cli/Cargo.toml +++ b/crates/ide-cli/Cargo.toml @@ -11,5 +11,10 @@ serde_json.workspace = true dirs.workspace = true tracing.workspace = true +[target.'cfg(windows)'.dependencies] +tokio = { workspace = true, features = ["net"] } +winreg = "0.56" +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] } + [dev-dependencies] tempfile.workspace = true diff --git a/crates/ide-cli/src/args.rs b/crates/ide-cli/src/args.rs index e43f965b6..d68211f80 100644 --- a/crates/ide-cli/src/args.rs +++ b/crates/ide-cli/src/args.rs @@ -5,6 +5,7 @@ use std::path::Path; pub const USAGE: &str = "\ Usage: openidea [options] [[--line ] [--column ] path[:line[:column]] ...] + openidea diff Opens folders as projects and files in the editor of the running OpenIDEA, starting it if needed. Without paths, brings OpenIDEA to the front. @@ -22,6 +23,7 @@ pub enum Command { Help, Version, Open(Vec), + Diff(Vec), } /// Parses `args` (without the program name); relative paths resolve against `cwd`. @@ -30,6 +32,17 @@ pub fn parse(args: &[String], cwd: &Path) -> Result { } fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result { + if args.first().is_some_and(|arg| arg == "diff") { + return match parse_paths(&args[1..], cwd, exists)? { + Command::Open(targets) if targets.len() == 2 => Ok(Command::Diff(targets)), + Command::Help => Ok(Command::Help), + _ => Err("diff needs two files".into()), + }; + } + parse_paths(args, cwd, exists) +} + +fn parse_paths(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result { let mut targets = Vec::new(); let mut options = true; let (mut line, mut column) = (None, None); @@ -51,8 +64,8 @@ fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Re a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")), _ => { let mut target = target(arg, cwd, exists)?; - if let Some(line) = line.take() { - target.line = Some(line); + if line.is_some() || column.is_some() { + target.line = line.take().or(target.line).or(Some(1)); target.column = column.take(); } targets.push(target); @@ -150,6 +163,25 @@ mod tests { assert!(split_position(r"C:\a.rs").is_empty()); } + #[cfg(windows)] + #[test] + fn windows_absolute_and_unc_targets_keep_spaces_and_unicode() { + let local = PathBuf::from(r"C:\项目\my file.rs"); + let network = PathBuf::from(r"\\server\share\folder name"); + let command = parse_with( + &args(&[r"C:\项目\my file.rs:12:5", r"\\server\share\folder name"]), + Path::new(r"D:\work"), + &|path| path == local || path == network, + ); + assert_eq!( + command, + Ok(Command::Open(vec![ + Target { path: local, line: Some(12), column: Some(5) }, + Target { path: network, line: None, column: None }, + ])) + ); + } + #[test] fn line_and_column_flags_apply_to_the_next_path() { let files = ["/work/a.rs", "/work/b.rs"]; @@ -173,4 +205,23 @@ mod tests { assert!(parse_in(&["--nope"], &[]).is_err()); assert_eq!(parse_in(&["--", "-h"], &["/work/-h"]), Ok(Command::Open(vec![at("/work/-h", None, None)]))); } + + #[test] + fn existing_diff_command_is_supported() { + let files = ["/work/a.rs", "/work/b.rs"]; + assert_eq!( + parse_in(&["diff", "a.rs", "b.rs"], &files), + Ok(Command::Diff(vec![at("/work/a.rs", None, None), at("/work/b.rs", None, None),])) + ); + assert!(parse_in(&["diff", "a.rs"], &files).is_err()); + assert_eq!( + parse_in(&["diff", "diff", "b.rs"], &["/work/diff", "/work/b.rs"]), + Ok(Command::Diff(vec![at("/work/diff", None, None), at("/work/b.rs", None, None),])) + ); + assert_eq!(parse_in(&["--", "diff"], &["/work/diff"]), Ok(Command::Open(vec![at("/work/diff", None, None)]))); + assert_eq!( + parse_in(&["--column=4", "a.rs", "b.rs"], &files), + Ok(Command::Open(vec![at("/work/a.rs", Some(1), Some(4)), at("/work/b.rs", None, None),])) + ); + } } diff --git a/crates/ide-cli/src/install.rs b/crates/ide-cli/src/install.rs index e27a53a28..4cdad7438 100644 --- a/crates/ide-cli/src/install.rs +++ b/crates/ide-cli/src/install.rs @@ -1,5 +1,5 @@ //! Installing the `openidea` command: a symlink on `PATH` to the launcher -//! script in the app bundle (`Contents/Resources/bin/openidea`). macOS only. +//! script in a macOS bundle, or a wrapper in a user's bin directory. use std::ffi::OsStr; use std::io; @@ -116,6 +116,304 @@ fn applescript_escape(s: &str) -> String { s.replace('\\', r"\\").replace('"', "\\\"") } +const WRAPPER_MARKER: &str = "OpenIDEA command launcher v1"; + +/// AppImages remount their executable on every launch. Keep the original image path. +pub fn stable_executable(exe: &Path, appimage: Option<&Path>) -> io::Result { + let path = appimage.unwrap_or(exe); + if !path.is_absolute() || !path.is_file() { + return Err(io::Error::new(io::ErrorKind::NotFound, "the OpenIDEA executable must be in a permanent location")); + } + Ok(path.to_path_buf()) +} + +pub fn wrapper_path(dir: &Path, windows: bool) -> PathBuf { + dir.join(if windows { "openidea.cmd" } else { COMMAND }) +} + +fn wrapper(exe: &Path, windows: bool) -> io::Result { + let exe = + exe.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the executable path is not UTF-8"))?; + if exe.contains(['\r', '\n']) || (windows && exe.contains('"')) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "the executable path contains unsupported characters")); + } + Ok(if windows { + format!( + "@echo off\r\nrem {WRAPPER_MARKER}\r\nsetlocal DisableDelayedExpansion\r\nfor /f \"tokens=2 delims=:\" %%c in ('chcp') do set \"_openidea_codepage=%%c\"\r\nchcp 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\nchcp %_openidea_codepage% >nul\r\nexit /b %_openidea_exit%\r\n", + exe.replace('%', "%%") + ) + } else { + format!("#!/bin/sh\n# {WRAPPER_MARKER}\nexec {} --cli \"$@\"\n", quote(Path::new(exe))) + }) +} + +pub fn is_wrapper(path: &Path, windows: bool) -> bool { + path.symlink_metadata().is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink()) + && std::fs::read_to_string(path).is_ok_and(|text| { + text.lines().nth(1) == Some(&format!("{} {WRAPPER_MARKER}", if windows { "rem" } else { "#" })) + }) +} + +/// Never follows or overwrites a foreign file, even if it has the same command name. +pub fn install_wrapper(exe: &Path, dir: &Path, windows: bool) -> io::Result { + use std::io::Write; + let text = wrapper(exe, windows)?; + let path = wrapper_path(dir, windows); + if path.symlink_metadata().is_ok() { + if !is_wrapper(&path, windows) { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("{} already exists and is not an OpenIDEA command", path.display()), + )); + } + std::fs::remove_file(&path)?; + } + std::fs::create_dir_all(dir)?; + let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o755))?; + } + file.write_all(text.as_bytes())?; + Ok(path) +} + +pub fn uninstall_wrapper(dir: &Path, windows: bool) -> io::Result> { + let path = wrapper_path(dir, windows); + if !is_wrapper(&path, windows) { + return Ok(vec![]); + } + std::fs::remove_file(&path)?; + Ok(vec![path]) +} + +/// Preserve every unrelated PATH entry, including expandable environment variables. +pub fn windows_path(current: &str, dir: &str, add: bool) -> Option { + fn equal(a: &str, b: &str) -> bool { + a.trim().trim_matches('"').trim_end_matches(['\\', '/']).eq_ignore_ascii_case(b.trim_end_matches(['\\', '/'])) + } + let found = current.split(';').any(|entry| equal(entry, dir)); + if add { + if found { + return None; + } + Some(format!("{dir}{}{current}", if current.is_empty() { "" } else { ";" })) + } else if found { + Some(current.split(';').filter(|entry| !equal(entry, dir)).collect::>().join(";")) + } else { + None + } +} + +#[cfg(windows)] +pub fn update_user_path(dir: &Path, add: bool) -> io::Result<()> { + use winreg::{ + RegKey, + enums::{HKEY_CURRENT_USER, REG_EXPAND_SZ}, + types::{FromRegValue, ToRegValue}, + }; + let (environment, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey("Environment")?; + let raw = match environment.get_raw_value("Path") { + Ok(raw) => Some(raw), + Err(e) if e.kind() == io::ErrorKind::NotFound => None, + Err(e) => return Err(e), + }; + let current = raw.as_ref().map(String::from_reg_value).transpose()?.unwrap_or_default(); + let Some(updated) = windows_path(¤t, &dir.to_string_lossy(), add) else { return Ok(()) }; + let mut value = updated.to_reg_value(); + value.vtype = raw.as_ref().map_or(REG_EXPAND_SZ, |raw| raw.vtype.clone()); + environment.set_raw_value("Path", &value)?; + let message: Vec = "Environment\0".encode_utf16().collect(); + // SAFETY: the terminated string stays alive for this bounded synchronous broadcast. + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::SendMessageTimeoutW( + windows_sys::Win32::UI::WindowsAndMessaging::HWND_BROADCAST, + windows_sys::Win32::UI::WindowsAndMessaging::WM_SETTINGCHANGE, + 0, + message.as_ptr() as isize, + windows_sys::Win32::UI::WindowsAndMessaging::SMTO_ABORTIFHUNG, + 1000, + std::ptr::null_mut(), + ); + } + Ok(()) +} + +pub struct Installation { + pub path: PathBuf, + /// Some when the user needs to add this directory to their shell's PATH. + pub path_directory: Option, +} + +fn resolves_to(path_var: Option<&OsStr>, command: &Path) -> bool { + path_var + .and_then(|path| std::env::split_paths(path).map(|dir| dir.join(COMMAND)).find(|candidate| candidate.is_file())) + .is_some_and(|candidate| candidate == command) +} + +fn user_bin() -> io::Result { + if cfg!(windows) { + dirs::data_local_dir().map(|dir| dir.join("OpenIDEA/bin")) + } else { + dirs::home_dir().map(|dir| dir.join(".local/bin")) + } + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user directory for the openidea command")) +} + +pub fn install_current() -> io::Result { + let exe = std::env::current_exe()?; + if cfg!(target_os = "macos") { + let launcher = bundled_launcher(&exe).ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "install the command from the packaged OpenIDEA app") + })?; + if translocated(&launcher) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "move OpenIDEA to the Applications folder first", + )); + } + let dir = link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref()); + return Ok(Installation { path: install(&launcher, &dir)?, path_directory: None }); + } + let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None }; + let exe = stable_executable(&exe, image.as_deref())?; + let dir = user_bin()?; + let path = install_wrapper(&exe, &dir, cfg!(windows))?; + #[cfg(windows)] + update_user_path(&dir, true)?; + let on_path = cfg!(windows) || resolves_to(std::env::var_os("PATH").as_deref(), &path); + Ok(Installation { path, path_directory: (!on_path).then_some(dir) }) +} + +pub fn uninstall_current() -> io::Result> { + if cfg!(target_os = "macos") { + let links = installed(&link_dirs(dirs::home_dir().as_deref())); + uninstall(&links)?; + return Ok(links); + } + let dir = user_bin()?; + let path = wrapper_path(&dir, cfg!(windows)); + if is_wrapper(&path, cfg!(windows)) { + #[cfg(windows)] + update_user_path(&dir, false)?; + return uninstall_wrapper(&dir, cfg!(windows)); + } + Ok(vec![]) +} + +#[cfg(test)] +mod wrapper_tests { + use super::*; + + #[test] + fn wrappers_install_update_and_remove_only_their_own_files() { + for windows in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("App folder/程序 & it's 100%.exe"); + let path = install_wrapper(&exe, dir.path(), windows).unwrap(); + assert!(is_wrapper(&path, windows)); + assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap(), path); + assert_eq!(uninstall_wrapper(dir.path(), windows).unwrap(), vec![path.clone()]); + assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty()); + std::fs::write(&path, "foreign command").unwrap(); + assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap_err().kind(), io::ErrorKind::AlreadyExists); + assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "foreign command"); + } + } + + #[test] + fn path_changes_keep_other_entries_and_do_not_duplicate_the_command_dir() { + let dir = r"C:\Users\用户\OpenIDEA\bin"; + let initial = r"%USERPROFILE%\tools;C:\other"; + let updated = windows_path(initial, dir, true).unwrap(); + assert_eq!(updated, format!("{dir};{initial}")); + assert_eq!(windows_path(&updated, dir, true), None); + assert_eq!(windows_path(&updated, dir, false).as_deref(), Some(initial)); + assert_eq!( + windows_path(&format!("\"{dir}\\\";C:\\other"), &dir.to_uppercase(), false).as_deref(), + Some(r"C:\other") + ); + assert_eq!(windows_path(initial, dir, false), None); + } + + #[test] + fn appimage_uses_the_persistent_image_not_the_mounted_binary() { + let dir = tempfile::tempdir().unwrap(); + let image = dir.path().join("OpenIDEA.AppImage"); + std::fs::write(&image, "image").unwrap(); + assert_eq!(stable_executable(Path::new("/tmp/.mount_App/usr/bin/openidea"), Some(&image)).unwrap(), image); + assert_eq!(stable_executable(&image, None).unwrap(), image); + assert!(stable_executable(Path::new("missing"), None).is_err()); + } + + #[test] + fn detects_a_system_binary_shadowing_the_linux_wrapper() { + let dir = tempfile::tempdir().unwrap(); + let system = dir.path().join("system"); + let user = dir.path().join("user"); + for bin in [&system, &user] { + std::fs::create_dir_all(bin).unwrap(); + std::fs::write(bin.join(COMMAND), "command").unwrap(); + } + let path = std::env::join_paths([&system, &user]).unwrap(); + assert!(!resolves_to(Some(&path), &user.join(COMMAND))); + let path = std::env::join_paths([&user, &system]).unwrap(); + assert!(resolves_to(Some(&path), &user.join(COMMAND))); + } + + #[cfg(unix)] + #[test] + fn posix_wrapper_preserves_arguments_and_exit_status() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("app & it's $dollar `quoted` \"double\""); + std::fs::write(&exe, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap(); + std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap(); + let path = install_wrapper(&exe, &dir.path().join("bin"), false).unwrap(); + let output = std::process::Command::new(path).args(["a b", "中文", "x'$`&"]).output().unwrap(); + assert_eq!(output.status.code(), Some(7)); + assert_eq!(String::from_utf8(output.stdout).unwrap(), "--cli\na b\n中文\nx'$`&\n"); + } + + #[cfg(windows)] + #[test] + fn cmd_wrapper_preserves_arguments_and_exit_status() { + use std::os::windows::process::CommandExt; + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("程序 & 100% !special!.exe"); + let source = dir.path().join("helper.rs"); + std::fs::write( + &source, + r#"fn main() { for arg in std::env::args().skip(1) { println!("{arg}"); } std::process::exit(7); }"#, + ) + .unwrap(); + let compiled = std::process::Command::new("rustc") + .args(["--crate-name", "launcher_test"]) + .arg(&source) + .arg("-o") + .arg(&exe) + .output() + .unwrap(); + assert!(compiled.status.success(), "{}", String::from_utf8_lossy(&compiled.stderr)); + let path = install_wrapper(&exe, &dir.path().join("bin"), true).unwrap(); + let output = std::process::Command::new("cmd.exe") + .args(["/d", "/c"]) + .arg(&path) + .args(["a b", "中文"]) + .raw_arg("\"x&y\"") + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(7)); + assert_eq!(String::from_utf8(output.stdout).unwrap().replace("\r\n", "\n"), "--cli\na b\n中文\nx&y\n"); + let check = dir.path().join("check.cmd"); + std::fs::write(&check, "@echo off\r\nfor /f \"tokens=2 delims=:\" %%c in ('chcp') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('chcp') do set \"after=%%c\"\r\nif not \"%before%\"==\"%after%\" exit /b 99\r\nexit /b %code%\r\n").unwrap(); + let output = std::process::Command::new("cmd.exe").args(["/d", "/c"]).arg(check).output().unwrap(); + assert_eq!(output.status.code(), Some(7)); + } +} + #[cfg(all(test, unix))] mod tests { use super::*; diff --git a/crates/ide-cli/src/lib.rs b/crates/ide-cli/src/lib.rs index ebd15b1d7..f23fa98ed 100644 --- a/crates/ide-cli/src/lib.rs +++ b/crates/ide-cli/src/lib.rs @@ -30,11 +30,13 @@ pub struct Target { pub struct Request { pub version: u32, pub targets: Vec, + #[serde(default)] + pub diff: bool, } impl Request { pub fn open(targets: Vec) -> Self { - Self { version: PROTOCOL_VERSION, targets } + Self { version: PROTOCOL_VERSION, targets, diff: false } } } @@ -44,3 +46,27 @@ pub enum Response { Ok, Error { message: String }, } + +/// A GUI subsystem executable inherits redirected handles, but needs its parent's +/// console for interactive help and errors. Batch launchers wait for this process. +#[cfg(windows)] +pub fn attach_console() { + use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType}; + use windows_sys::Win32::System::Console::{ + ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, + SetStdHandle, + }; + // SAFETY: attaches only this CLI process, without allocating a new console. + unsafe { + let redirected = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE].map(|kind| { + let handle = GetStdHandle(kind); + (kind, handle, matches!(GetFileType(handle), FILE_TYPE_DISK | FILE_TYPE_PIPE)) + }); + AttachConsole(ATTACH_PARENT_PROCESS); + for (kind, handle, keep) in redirected { + if keep { + SetStdHandle(kind, handle); + } + } + } +} diff --git a/crates/ide-cli/src/socket.rs b/crates/ide-cli/src/socket.rs index cefe5530c..d5fb7861d 100644 --- a/crates/ide-cli/src/socket.rs +++ b/crates/ide-cli/src/socket.rs @@ -1,13 +1,34 @@ //! The local socket the running app listens on: one JSON [`Request`] line in, -//! one [`Response`] line out. Unix domain sockets; not available on Windows yet. +//! one [`Response`] line out. Unix domain sockets or Windows named pipes. use crate::{Request, Response}; use std::io; use std::path::{Path, PathBuf}; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(10); +const MAX_MESSAGE: u64 = 1024 * 1024; + +fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response { + match serde_json::from_str::(line) { + Ok(request) if request.version == crate::PROTOCOL_VERSION && (!request.diff || request.targets.len() == 2) => { + handle(request) + } + Ok(_) => Response::Error { message: "unsupported or invalid CLI request".into() }, + Err(e) => Response::Error { message: format!("invalid request: {e}") }, + } +} /// The socket of the app with bundle identifier `identifier`. The name is a hash: /// socket paths are limited to about 100 bytes, and development identifiers are long. pub fn socket_path(identifier: &str) -> Option { + if cfg!(windows) { + let user = dirs::data_local_dir()?; + return Some(PathBuf::from(format!( + r"\\.\pipe\openidea-{:016x}", + fnv1a(&format!("{}:{identifier}", user.display())) + ))); + } let dir = if cfg!(target_os = "macos") { dirs::cache_dir() } else { dirs::runtime_dir().or_else(dirs::cache_dir) }; Some(dir?.join(format!("openidea-{:016x}.sock", fnv1a(identifier)))) } @@ -19,28 +40,17 @@ fn fnv1a(s: &str) -> u64 { #[cfg(unix)] mod imp { use super::*; - use crate::PROTOCOL_VERSION; - use std::io::{BufRead, BufReader, Write}; - use std::os::unix::fs::PermissionsExt; + use std::io::{BufRead, BufReader, Read, Write}; + use std::os::unix::fs::{FileTypeExt, PermissionsExt}; use std::os::unix::net::{UnixListener, UnixStream}; - use std::time::Duration; - - const TIMEOUT: Duration = Duration::from_secs(10); - - pub(super) fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response { - match serde_json::from_str::(line) { - Ok(request) if request.version == PROTOCOL_VERSION => handle(request), - Ok(request) => Response::Error { message: format!("unsupported request version {}", request.version) }, - Err(e) => Response::Error { message: format!("invalid request: {e}") }, - } - } pub fn send(socket: &Path, request: &Request) -> io::Result { let stream = UnixStream::connect(socket)?; stream.set_read_timeout(Some(TIMEOUT))?; + stream.set_write_timeout(Some(TIMEOUT))?; write_line(&stream, request)?; let mut reply = String::new(); - BufReader::new(&stream).read_line(&mut reply)?; + BufReader::new((&stream).take(MAX_MESSAGE)).read_line(&mut reply)?; Ok(serde_json::from_str(&reply)?) } @@ -63,7 +73,7 @@ mod imp { } let listener = match UnixListener::bind(socket) { Err(e) if e.kind() == io::ErrorKind::AddrInUse => { - if UnixStream::connect(socket).is_ok() { + if !std::fs::symlink_metadata(socket)?.file_type().is_socket() || UnixStream::connect(socket).is_ok() { return Err(e); } std::fs::remove_file(socket)?; @@ -77,8 +87,9 @@ mod imp { fn serve_one(stream: &UnixStream, handle: &dyn Fn(Request) -> Response) -> io::Result<()> { stream.set_read_timeout(Some(TIMEOUT))?; + stream.set_write_timeout(Some(TIMEOUT))?; let mut line = String::new(); - BufReader::new(stream).read_line(&mut line)?; + BufReader::new(stream.take(MAX_MESSAGE)).read_line(&mut line)?; write_line(stream, &answer(&line, handle)) } @@ -89,20 +100,93 @@ mod imp { } } -#[cfg(not(unix))] +#[cfg(windows)] mod imp { use super::*; + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions}; - fn unsupported() -> io::Error { - io::Error::new(io::ErrorKind::Unsupported, "the openidea command is not available on this platform yet") + fn runtime() -> io::Result { + tokio::runtime::Builder::new_current_thread().enable_all().build() } - pub fn send(_socket: &Path, _request: &Request) -> io::Result { - Err(unsupported()) + pub fn send(socket: &Path, request: &Request) -> io::Result { + runtime()?.block_on(async { + tokio::time::timeout(TIMEOUT, async { + let mut pipe = loop { + match ClientOptions::new().open(socket) { + Ok(pipe) => break pipe, + // All existing instances are busy; wait instead of starting another app. + Err(e) if e.raw_os_error() == Some(231) => tokio::time::sleep(Duration::from_millis(10)).await, + Err(e) => return Err(e), + } + }; + let mut line = serde_json::to_vec(request)?; + line.push(b'\n'); + pipe.write_all(&line).await?; + let mut reply = String::new(); + BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut reply).await?; + pipe.write_all(b"\n").await?; + Ok(serde_json::from_str(&reply)?) + }) + .await + .map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "CLI request timed out"))? + }) } - pub fn serve(_socket: &Path, _handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> { - Err(unsupported()) + pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> { + let socket = socket.to_owned(); + let (ready, receiver) = std::sync::mpsc::sync_channel(1); + std::thread::Builder::new().name("cli-pipe".into()).spawn(move || { + let runtime = match runtime() { + Ok(runtime) => runtime, + Err(e) => { + let _ = ready.send(Err(e)); + return; + } + }; + runtime.block_on(async move { + let mut pipe = match ServerOptions::new().first_pipe_instance(true).create(&socket) { + Ok(pipe) => pipe, + Err(e) => { + let _ = ready.send(Err(e)); + return; + } + }; + let _ = ready.send(Ok(())); + loop { + if let Err(e) = pipe.connect().await { + tracing::warn!("CLI pipe connection failed: {e}"); + return; + } + // Keep an instance bound while answering, so another process cannot take the name. + let next = match ServerOptions::new().create(&socket) { + Ok(next) => next, + Err(e) => { + tracing::warn!("cannot create CLI pipe: {e}"); + return; + } + }; + let reply = tokio::time::timeout(TIMEOUT, async { + let mut line = String::new(); + BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut line).await?; + let mut reply = serde_json::to_vec(&answer(&line, &handle))?; + reply.push(b'\n'); + pipe.write_all(&reply).await?; + // Wait until the client has drained the reply before closing its instance. + pipe.read_exact(&mut [0u8; 1]).await.map(|_| ()) + }) + .await; + if !matches!(reply, Ok(Ok(()))) { + tracing::warn!("CLI pipe request failed: {reply:?}"); + } + // Dropping after the write lets the client drain its reply before disconnecting. + drop(pipe); + pipe = next; + } + }); + })?; + receiver.recv().map_err(|_| io::Error::other("CLI pipe thread stopped"))? } } @@ -120,13 +204,11 @@ mod tests { let release = socket_path("ai.l0ng.openidea").unwrap(); let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap(); assert_ne!(release, dev); - assert_eq!(dev.file_name().unwrap().len(), "openidea-0123456789abcdef.sock".len()); + assert!(dev.as_os_str().len() < 100 || cfg!(unix)); } - #[cfg(unix)] #[test] fn answers_only_its_protocol_version() { - use imp::answer; let ok = |_: Request| Response::Ok; assert_eq!(answer(&serde_json::to_string(&Request::open(vec![])).unwrap(), &ok), Response::Ok); let old = r#"{"version":0,"targets":[]}"#; @@ -134,6 +216,24 @@ mod tests { assert!(matches!(answer("garbage", &ok), Response::Error { .. })); } + #[cfg(windows)] + #[test] + fn named_pipe_round_trip() { + let socket = socket_path(&format!("test-{}", std::process::id())).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + serve(&socket, move |request| { + tx.send(request).unwrap(); + Response::Ok + }) + .unwrap(); + for _ in 0..2 { + let target = crate::Target { path: r"C:\项目\my file.rs".into(), line: Some(3), column: Some(2) }; + assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok); + assert_eq!(rx.recv_timeout(TIMEOUT).unwrap().targets, vec![target]); + } + assert!(serve(&socket, |_| Response::Ok).is_err()); + } + #[cfg(unix)] #[test] fn request_round_trip_and_stale_socket() { @@ -153,4 +253,14 @@ mod tests { // A live server keeps its socket. assert_eq!(serve(&socket, |_| Response::Ok).unwrap_err().kind(), io::ErrorKind::AddrInUse); } + + #[cfg(unix)] + #[test] + fn refuses_to_remove_an_unrelated_file() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("cli.sock"); + std::fs::write(&socket, "keep").unwrap(); + assert!(serve(&socket, |_| Response::Ok).is_err()); + assert_eq!(std::fs::read_to_string(&socket).unwrap(), "keep"); + } } diff --git a/docs/command-line.md b/docs/command-line.md new file mode 100644 index 000000000..aa8381118 --- /dev/null +++ b/docs/command-line.md @@ -0,0 +1,41 @@ +# Command-line launcher + +Install the `openidea` command from **OpenIDEA → Install 'openidea' Command** on +macOS, or **Tools → Install 'openidea' Command** on Windows and Linux. The same +action is available in Find Action. Open a new terminal after installation. + +- **macOS:** move OpenIDEA to Applications first. The command links to the launcher + inside the app bundle. It uses `~/.local/bin` when that directory is on PATH; + otherwise it uses `/usr/local/bin` and may request an administrator password. +- **Windows:** installation creates `openidea.cmd` in + `%LOCALAPPDATA%\OpenIDEA\bin` and adds that directory to the user PATH. This + works with installed and portable copies, without administrator rights. +- **Linux:** installation creates `~/.local/bin/openidea`. If another command + takes precedence or this directory is absent from PATH, the notification asks + you to put it at the beginning of your shell's PATH. For Bash and Zsh, add + `export PATH="$HOME/.local/bin:$PATH"` to your shell profile. For Fish, run + `fish_add_path --prepend ~/.local/bin`. An AppImage launcher targets the original + AppImage; keep that file in a permanent location. + +```sh +openidea . +openidea src/main.rs README.md +openidea --line 12 --column 5 src/main.rs +openidea src/main.rs:12:5 +openidea diff before.txt after.txt +openidea --help +openidea --version +``` + +Folders open as projects. Files open in their project's window, or in LightEdit +when no project contains them. Lines and columns start at 1. With no paths, the +command focuses OpenIDEA. It starts the app if necessary and returns after handing +off the request. Unknown options and missing files exit with code 1. + +Use `--` before a filename beginning with `-`, or before a file named `diff`. +The existing GUI executable and OS file associations still work. + +**Uninstall 'openidea' Command** removes only OpenIDEA's launcher, and on Windows +its dedicated user PATH entry. A command belonging to another application is +never overwritten or removed. Reinstall the command after moving a portable +copy or AppImage to a different location. diff --git a/src-tauri/src/cli.rs b/src-tauri/src/cli.rs index bb2fcad73..61750797c 100644 --- a/src-tauri/src/cli.rs +++ b/src-tauri/src/cli.rs @@ -22,6 +22,8 @@ pub fn run(identifier: &str) -> Option { return None; } let args: Vec = args.collect(); + #[cfg(windows)] + ide_cli::attach_console(); Some(match client(identifier, &args) { Ok(()) => 0, Err(message) => { @@ -33,7 +35,9 @@ pub fn run(identifier: &str) -> Option { fn client(identifier: &str, args: &[String]) -> Result<(), String> { let cwd = std::env::current_dir().map_err(|e| format!("no working directory: {e}"))?; - let targets = match args::parse(args, &cwd)? { + let command = args::parse(args, &cwd)?; + let diff = matches!(command, Command::Diff(_)); + let targets = match command { Command::Help => { print!("{}", args::USAGE); return Ok(()); @@ -42,10 +46,10 @@ fn client(identifier: &str, args: &[String]) -> Result<(), String> { println!("OpenIDEA {}", env!("CARGO_PKG_VERSION")); return Ok(()); } - Command::Open(targets) => targets, + Command::Open(targets) | Command::Diff(targets) => targets, }; let socket = socket::socket_path(identifier).ok_or("no cache directory for the OpenIDEA socket")?; - let request = Request::open(targets); + let request = Request { diff, ..Request::open(targets) }; let response = match socket::send(&socket, &request) { Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::ConnectionRefused) => { start_app()?; @@ -63,6 +67,8 @@ fn client(identifier: &str, args: &[String]) -> Result<(), String> { /// bundle, else (a development build) this executable without [`CLI_FLAG`]. fn start_app() -> Result<(), String> { let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None }; + let exe = ide_cli::install::stable_executable(&exe, image.as_deref()).map_err(|e| e.to_string())?; let mut command = match app_bundle(&exe) { Some(bundle) => { let mut open = std::process::Command::new("/usr/bin/open"); @@ -72,8 +78,11 @@ fn start_app() -> Result<(), String> { None => std::process::Command::new(&exe), }; command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()); + ide_core::process::host_env(&mut command); #[cfg(unix)] std::os::unix::process::CommandExt::process_group(&mut command, 0); + #[cfg(windows)] + std::os::windows::process::CommandExt::creation_flags(&mut command, 0x0000_0008 | 0x0000_0200); command.spawn().map(drop).map_err(|e| format!("cannot start OpenIDEA: {e}")) } @@ -100,14 +109,23 @@ fn app_bundle(exe: &Path) -> Option { /// Answers the `openidea` command for this app: its targets open like paths from Finder. pub fn serve(app: &AppHandle, identifier: &str) { - if !cfg!(unix) { - return; - } let Some(socket) = socket::socket_path(identifier) else { return }; let app = app.clone(); let served = socket::serve(&socket, move |request| { let handle = app.clone(); - match app.run_on_main_thread(move || crate::windows::open_targets(&handle, request.targets)) { + match app.run_on_main_thread(move || { + if request.diff { + let mut targets = request.targets.into_iter(); + if let (Some(left), Some(right)) = (targets.next(), targets.next()) { + crate::windows::open_diff_files( + &handle, + crate::windows::DiffFilesLaunch { left: left.path.into(), right: right.path.into() }, + ); + } + } else { + crate::windows::open_targets(&handle, request.targets); + } + }) { Ok(()) => Response::Ok, Err(e) => Response::Error { message: format!("OpenIDEA cannot open windows: {e}") }, } @@ -117,35 +135,23 @@ pub fn serve(app: &AppHandle, identifier: &str) { } } -/// The launcher script of this app's bundle; it must be in a stable location. -fn launcher() -> IdeResult { - let exe = std::env::current_exe().map_err(|e| IdeError::internal(e.to_string()))?; - let launcher = ide_cli::install::bundled_launcher(&exe) - .ok_or_else(|| IdeError::unavailable("only the packaged macOS app can install the openidea command"))?; - if ide_cli::install::translocated(&launcher) { - return Err(IdeError::unavailable("move OpenIDEA to the Applications folder first")); - } - Ok(launcher) -} - fn install_error(e: std::io::Error) -> IdeError { if e.kind() == ErrorKind::Interrupted { IdeError::cancelled() } else { IdeError::internal(e.to_string()) } } /// Links the `openidea` command onto `PATH`; returns the link. Blocks while the /// administrator password prompt is up. -pub fn install() -> IdeResult { - let launcher = launcher()?; - let dir = ide_cli::install::link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref()); - let link = ide_cli::install::install(&launcher, &dir).map_err(install_error)?; - tracing::info!("installed the openidea command at {}", link.display()); - Ok(link.into()) +pub fn install() -> IdeResult { + let result = ide_cli::install::install_current().map_err(install_error)?; + Ok(crate::commands::window::CliInstallation { + path: result.path.into(), + path_directory: result.path_directory.map(Into::into), + }) } /// Removes every installed `openidea` command link; returns them. pub fn uninstall() -> IdeResult> { - let links = ide_cli::install::installed(&ide_cli::install::link_dirs(dirs::home_dir().as_deref())); - ide_cli::install::uninstall(&links).map_err(install_error)?; + let links = ide_cli::install::uninstall_current().map_err(install_error)?; Ok(links.into_iter().map(Into::into).collect()) } diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 17f1529e4..17f7e04fa 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -295,10 +295,17 @@ pub fn app_log_location() -> IdeResult { Ok(LogLocation { dir: dir.into(), file: crate::logging::latest_log(dir).map(Into::into) }) } -/// Links the `openidea` command onto `PATH` (macOS app bundle only); returns the link. +#[derive(Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct CliInstallation { + pub path: ide_core::IpcPath, + pub path_directory: Option, +} + +/// Installs the `openidea` command for this user. #[tauri::command] #[specta::specta] -pub async fn app_cli_install() -> IdeResult { +pub async fn app_cli_install() -> IdeResult { tauri::async_runtime::spawn_blocking(crate::cli::install).await.map_err(|e| IdeError::internal(e.to_string()))? } diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index d802e6656..492bf63ad 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -704,41 +704,45 @@ pub fn spec(os: Os) -> Vec { ], )); - menus.push(sub( - "tools", - "Tools", - vec![ - act("http.newRequest", "New HTTP Request"), - act("http.showToolWindow", "HTTP Client"), + let mut tools = vec![ + act("http.newRequest", "New HTTP Request"), + act("http.showToolWindow", "HTTP Client"), + SEP, + act("db.showToolWindow", "Database"), + act("db.newDataSource", "New Data Source…"), + act("db.openConsole", "Open Query Console"), + SEP, + act("ssh.startSession", "Start SSH Session…"), + act("ssh.configurations", "SSH Configurations…"), + sub( + "deployment", + "Deployment", + vec![ + act("deployment.uploadToDefault", "Upload to Default Server"), + act("deployment.uploadTo", "Upload to…"), + act("deployment.downloadFromDefault", "Download from Default Server"), + act("deployment.downloadFrom", "Download from…"), + act("deployment.syncWith", "Sync with Deployed…"), + act("deployment.deleteFromServer", "Delete from Server"), + SEP, + act("deployment.toggleAutoUpload", "Automatic Upload"), + act("deployment.browseRemoteHost", "Browse Remote Host"), + act("deployment.openInBrowser", "Open in Browser"), + act("deployment.configure", "Configuration…"), + ], + ), + SEP, + act("vim.toggle", "Vim"), + Entry::Dynamic(DYNAMIC_PLUGIN_TOOLS), + ]; + if !mac { + tools.extend([ SEP, - act("db.showToolWindow", "Database"), - act("db.newDataSource", "New Data Source…"), - act("db.openConsole", "Open Query Console"), - SEP, - act("ssh.startSession", "Start SSH Session…"), - act("ssh.configurations", "SSH Configurations…"), - sub( - "deployment", - "Deployment", - vec![ - act("deployment.uploadToDefault", "Upload to Default Server"), - act("deployment.uploadTo", "Upload to…"), - act("deployment.downloadFromDefault", "Download from Default Server"), - act("deployment.downloadFrom", "Download from…"), - act("deployment.syncWith", "Sync with Deployed…"), - act("deployment.deleteFromServer", "Delete from Server"), - SEP, - act("deployment.toggleAutoUpload", "Automatic Upload"), - act("deployment.browseRemoteHost", "Browse Remote Host"), - act("deployment.openInBrowser", "Open in Browser"), - act("deployment.configure", "Configuration…"), - ], - ), - SEP, - act("vim.toggle", "Vim"), - Entry::Dynamic(DYNAMIC_PLUGIN_TOOLS), - ], - )); + act("app.installCli", "Install 'openidea' Command"), + act("app.uninstallCli", "Uninstall 'openidea' Command"), + ]); + } + menus.push(sub("tools", "Tools", tools)); let mut window = Vec::new(); if mac { diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 1e0176609..ccf6b52d9 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -1163,7 +1163,7 @@ pub fn open_targets(app: &AppHandle, targets: Vec) { /// `openidea diff a.txt b.txt` while the app is running: hands the pair to the window the user is /// looking at (a project window, never an auxiliary one), else opens a new window for it. -fn open_diff_files(app: &AppHandle, diff: DiffFilesLaunch) { +pub(crate) fn open_diff_files(app: &AppHandle, diff: DiffFilesLaunch) { let launch = Launch { diff: Some(Box::new(diff)), ..Default::default() }; let target = target_window(app) .filter(|window| auxiliary_parent(window.label()).is_none()) diff --git a/src/bindings.ts b/src/bindings.ts index cb6a89071..f2e8f829d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -952,8 +952,8 @@ export const commands = { */ menuSetDisabled: (ids: string[]) => __TAURI_INVOKE("menu_set_disabled", { ids }), appLogLocation: () => typedError(__TAURI_INVOKE("app_log_location")), - /** Links the `openidea` command onto `PATH` (macOS app bundle only); returns the link. */ - appCliInstall: () => typedError(__TAURI_INVOKE("app_cli_install")), + /** Installs the `openidea` command for this user. */ + appCliInstall: () => typedError(__TAURI_INVOKE("app_cli_install")), /** Removes the installed `openidea` command; returns the links removed. */ appCliUninstall: () => typedError(__TAURI_INVOKE("app_cli_uninstall")), appBuildInfo: () => __TAURI_INVOKE("app_build_info"), @@ -2247,6 +2247,11 @@ export type ClearCachesResult = { rssAfterBytes: number | null, }; +export type CliInstallation = { + path: IpcPath, + pathDirectory: IpcPath | null, +}; + export type CloseContext = { /** Quit is in progress (exit was already confirmed). */ quitting: boolean, diff --git a/src/features/app/__tests__/cliCommand.test.ts b/src/features/app/__tests__/cliCommand.test.ts new file mode 100644 index 000000000..190798066 --- /dev/null +++ b/src/features/app/__tests__/cliCommand.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { getContext } from "@/platform/context"; +import { provideNotificationService } from "@/workbench/contributions"; + +const { install, uninstall, notify } = vi.hoisted(() => ({ install: vi.fn(), uninstall: vi.fn(), notify: vi.fn() })); +vi.mock("@/platform/ipc", () => ({ + call: (result: Promise) => result, + commands: { appCliInstall: install, appCliUninstall: uninstall }, + isCancelled: (error: unknown) => (error as { kind?: string })?.kind === "cancelled", +})); + +const { cliActions } = await import("../cliCommand"); +const run = (id: string) => + cliActions() + .find((action) => action.id === id)! + .run({ ctx: getContext() }); + +beforeEach(() => { + vi.resetAllMocks(); + provideNotificationService({ notify, expire: () => {} }); +}); + +it("explains the Linux PATH step when the user bin is missing from PATH", async () => { + install.mockResolvedValue({ path: "/home/user/.local/bin/openidea", pathDirectory: "/home/user/.local/bin" }); + await run("app.installCli"); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + level: "info", + body: expect.stringContaining("Add /home/user/.local/bin to the beginning of PATH"), + }), + ); +}); + +it("tells users to open a new terminal after installation", async () => { + install.mockResolvedValue({ path: "C:/Users/user/OpenIDEA/bin/openidea.cmd", pathDirectory: null }); + await run("app.installCli"); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.stringContaining("Open a new terminal") }), + ); +}); + +it("reports an absent command on repeated uninstall", async () => { + uninstall.mockResolvedValue([]); + await run("app.uninstallCli"); + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ title: "The openidea command is not installed" })); +}); + +it("reports an install failure and stays quiet when an administrator prompt is cancelled", async () => { + install.mockRejectedValueOnce(new Error("foreign command")); + await run("app.installCli"); + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", body: "foreign command" })); + notify.mockClear(); + install.mockRejectedValueOnce({ kind: "cancelled" }); + await run("app.installCli"); + expect(notify).not.toHaveBeenCalled(); +}); diff --git a/src/features/app/cliCommand.ts b/src/features/app/cliCommand.ts index 5e91f84eb..267e43305 100644 --- a/src/features/app/cliCommand.ts +++ b/src/features/app/cliCommand.ts @@ -1,6 +1,6 @@ -/** Install / Uninstall the `openidea` shell command (macOS app bundle only). */ +/** Install / Uninstall the `openidea` shell command. */ import type { Action } from "@/platform/actions"; -import { t } from "@/platform/i18n"; +import { tx } from "@/platform/i18n"; import { call, commands, isCancelled } from "@/platform/ipc"; import { notifications } from "@/workbench/contributions"; @@ -12,11 +12,24 @@ function errorText(e: unknown) { async function installCli(): Promise { try { - const link = await call(commands.appCliInstall()); - notifications().notify({ level: "info", title: t("app.cli.installed"), body: link, key: KEY }); + const result = await call(commands.appCliInstall()); + const hint = result.pathDirectory + ? tx`Add ${result.pathDirectory} to the beginning of PATH, then open a new terminal.` + : tx("Open a new terminal to use the openidea command."); + notifications().notify({ + level: "info", + title: tx("The openidea command is installed"), + body: `${result.path}\n${hint}`, + key: KEY, + }); } catch (e) { if (!isCancelled(e)) - notifications().notify({ level: "error", title: t("app.cli.installFailed"), body: errorText(e), key: KEY }); + notifications().notify({ + level: "error", + title: tx("Cannot install the openidea command"), + body: errorText(e), + key: KEY, + }); } } @@ -25,13 +38,18 @@ async function uninstallCli(): Promise { const links = await call(commands.appCliUninstall()); notifications().notify({ level: "info", - title: t(links.length ? "app.cli.uninstalled" : "app.cli.notInstalled"), + title: tx(links.length ? "The openidea command is uninstalled" : "The openidea command is not installed"), body: links.join("\n") || undefined, key: KEY, }); } catch (e) { if (!isCancelled(e)) - notifications().notify({ level: "error", title: t("app.cli.uninstallFailed"), body: errorText(e), key: KEY }); + notifications().notify({ + level: "error", + title: tx("Cannot uninstall the openidea command"), + body: errorText(e), + key: KEY, + }); } } @@ -39,15 +57,15 @@ export function cliActions(): Action[] { return [ { id: "app.installCli", - title: t("app.action.installCli"), - category: t("app.category.tools"), + title: tx("Install 'openidea' Command"), + category: tx("Tools"), keywords: ["cli", "shell", "terminal", "path", "command line", "launcher"], run: installCli, }, { id: "app.uninstallCli", - title: t("app.action.uninstallCli"), - category: t("app.category.tools"), + title: tx("Uninstall 'openidea' Command"), + category: tx("Tools"), keywords: ["cli", "shell", "terminal", "path", "command line", "launcher"], run: uninstallCli, }, diff --git a/src/features/app/index.ts b/src/features/app/index.ts index 80270905a..b70867fb3 100644 --- a/src/features/app/index.ts +++ b/src/features/app/index.ts @@ -10,7 +10,7 @@ import { cliActions } from "./cliCommand"; import { installCloseGuard } from "./closeGuard"; import { TerminationOverlay } from "./termination"; import { InvalidateCachesOverlay, registerInvalidateCachesActions } from "./InvalidateCaches"; -import { buildInfo, guessOs } from "./host"; +import { buildInfo } from "./host"; import { KeymapReferenceOverlay } from "./KeymapReference"; import { LightEditStatus } from "./LightEditStatus"; import { installLocaleSync, registerLocalizedActions } from "./locale"; @@ -32,7 +32,7 @@ export function activate(): () => void { const disposers = [ installLocaleSync(), registerLocalizedActions(() => [...appActions(), ...windowActions()]), - registerLocalizedActions(() => (guessOs() === "macos" ? cliActions() : [])), + registerLocalizedActions(cliActions), registerOverlay({ id: "app.about", component: AboutOverlay }), registerOverlay({ id: "app.keymapReference", component: KeymapReferenceOverlay }), registerOverlay({ id: "app.invalidateCaches", component: InvalidateCachesOverlay }), diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 37f77f045..d907828b9 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -17,7 +17,6 @@ "app.action.checkForUpdates": "Check for Updates…", "app.action.copy": "Copy", "app.action.cut": "Cut", - "app.action.installCli": "Install 'openidea' Command", "app.action.keymapReference": "Keymap Reference", "app.action.lightEditOpenProject": "Open Containing Folder as Project", "app.action.newWindow": "New Window", @@ -33,18 +32,11 @@ "app.action.showLog": "{os, select, macos {Show Log in Finder} windows {Show Log in Explorer} other {Show Log in Files}}", "app.action.toggleFullScreen": "Toggle Full Screen", "app.action.undo": "Undo", - "app.action.uninstallCli": "Uninstall 'openidea' Command", "app.category.edit": "Edit", "app.category.file": "File", "app.category.help": "Help", - "app.category.tools": "Tools", "app.category.view": "View", "app.category.window": "Window", - "app.cli.installFailed": "Cannot install the openidea command", - "app.cli.installed": "The openidea command is installed", - "app.cli.notInstalled": "The openidea command is not installed", - "app.cli.uninstallFailed": "Cannot uninstall the openidea command", - "app.cli.uninstalled": "The openidea command is uninstalled", "app.exit.body": "Are you sure you want to exit OpenIDEA?", "app.exit.confirm": "Exit", "app.exit.dontAsk": "Don't ask again", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index f4911f285..a1893b35c 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -183,5 +183,14 @@ "Per-module language levels and compiler output overrides are not supported yet. Configure them in Maven or Gradle.": "Per-module language levels and compiler output overrides are not supported yet. Configure them in Maven or Gradle.", "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.": "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.", "Remove Module Overrides": "Remove Module Overrides", - "Java compiler output must be inside its module: {output}": "Java compiler output must be inside its module: {output}" + "Java compiler output must be inside its module: {output}": "Java compiler output must be inside its module: {output}", + "Install 'openidea' Command": "Install 'openidea' Command", + "Uninstall 'openidea' Command": "Uninstall 'openidea' Command", + "The openidea command is installed": "The openidea command is installed", + "The openidea command is uninstalled": "The openidea command is uninstalled", + "The openidea command is not installed": "The openidea command is not installed", + "Cannot install the openidea command": "Cannot install the openidea command", + "Cannot uninstall the openidea command": "Cannot uninstall the openidea command", + "Open a new terminal to use the openidea command.": "Open a new terminal to use the openidea command.", + "Add {path} to the beginning of PATH, then open a new terminal.": "Add {path} to the beginning of PATH, then open a new terminal." } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 2cb6af139..018a20027 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6562,5 +6562,14 @@ "Per-module language levels and compiler output overrides are not supported yet. Configure them in Maven or Gradle.": "暂不支持模块级语言级别和编译输出覆盖设置。请在 Maven 或 Gradle 中配置。", "JDK used by Run/Debug for this module. Maven and Gradle manage their own compiler JDK.": "此模块运行和调试使用的 JDK。编译 JDK 由 Maven 和 Gradle 管理。", "Remove Module Overrides": "移除模块覆盖设置", - "Java compiler output must be inside its module: {output}": "Java 编译输出必须位于所属模块目录内:{output}" + "Java compiler output must be inside its module: {output}": "Java 编译输出必须位于所属模块目录内:{output}", + "Install 'openidea' Command": "安装 'openidea' 命令", + "Uninstall 'openidea' Command": "卸载 'openidea' 命令", + "The openidea command is installed": "openidea 命令已安装", + "The openidea command is uninstalled": "openidea 命令已卸载", + "The openidea command is not installed": "openidea 命令未安装", + "Cannot install the openidea command": "无法安装 openidea 命令", + "Cannot uninstall the openidea command": "无法卸载 openidea 命令", + "Open a new terminal to use the openidea command.": "请打开新终端以使用 openidea 命令。", + "Add {path} to the beginning of PATH, then open a new terminal.": "请将 {path} 添加到 PATH 开头,然后打开新终端。" } diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index bae5edc3a..69ea176d3 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -17,7 +17,6 @@ "app.action.checkForUpdates": "检查更新…", "app.action.copy": "复制", "app.action.cut": "剪切", - "app.action.installCli": "安装 'openidea' 命令", "app.action.keymapReference": "快捷键参考", "app.action.lightEditOpenProject": "将所在文件夹作为项目打开", "app.action.newWindow": "新建窗口", @@ -33,18 +32,11 @@ "app.action.showLog": "{os, select, macos {在访达中显示日志} windows {在资源管理器中显示日志} other {在文件管理器中显示日志}}", "app.action.toggleFullScreen": "切换全屏", "app.action.undo": "撤销", - "app.action.uninstallCli": "卸载 'openidea' 命令", "app.category.edit": "编辑", "app.category.file": "文件", "app.category.help": "帮助", - "app.category.tools": "工具", "app.category.view": "视图", "app.category.window": "窗口", - "app.cli.installFailed": "无法安装 openidea 命令", - "app.cli.installed": "openidea 命令已安装", - "app.cli.notInstalled": "openidea 命令未安装", - "app.cli.uninstallFailed": "无法卸载 openidea 命令", - "app.cli.uninstalled": "openidea 命令已卸载", "app.exit.body": "确定要退出 OpenIDEA 吗?", "app.exit.confirm": "退出", "app.exit.dontAsk": "不再询问",