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": "不再询问",