дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
feat(app): install the openidea command on Windows and Linux
Цей коміт міститься в:
1 джерело
356c62f930
коміт
388ea0a198
20 files changed
+762
-129
No files matched your search
Створено
+4
-1
@@ -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]]
|
||||
|
||||
@@ -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).
|
||||
|
||||
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
|
||||
@@ -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
|
||||
@@ -5,6 +5,7 @@ use std::path::Path;
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
Usage: openidea [options] [[--line <n>] [--column <n>] path[:line[:column]] ...]
|
||||
openidea diff <left> <right>
|
||||
|
||||
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<Target>),
|
||||
Diff(Vec<Target>),
|
||||
}
|
||||
|
||||
/// Parses `args` (without the program name); relative paths resolve against `cwd`.
|
||||
@@ -30,6 +32,17 @@ pub fn parse(args: &[String], cwd: &Path) -> Result<Command, String> {
|
||||
}
|
||||
|
||||
fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
|
||||
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<Command, String> {
|
||||
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),]))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
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<String> {
|
||||
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<PathBuf> {
|
||||
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<Vec<PathBuf>> {
|
||||
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<String> {
|
||||
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::<Vec<_>>().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<u16> = "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<PathBuf>,
|
||||
}
|
||||
|
||||
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<PathBuf> {
|
||||
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<Installation> {
|
||||
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<Vec<PathBuf>> {
|
||||
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::*;
|
||||
|
||||
@@ -30,11 +30,13 @@ pub struct Target {
|
||||
pub struct Request {
|
||||
pub version: u32,
|
||||
pub targets: Vec<Target>,
|
||||
#[serde(default)]
|
||||
pub diff: bool,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn open(targets: Vec<Target>) -> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::<Request>(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<PathBuf> {
|
||||
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::<Request>(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<Response> {
|
||||
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::Runtime> {
|
||||
tokio::runtime::Builder::new_current_thread().enable_all().build()
|
||||
}
|
||||
|
||||
pub fn send(_socket: &Path, _request: &Request) -> io::Result<Response> {
|
||||
Err(unsupported())
|
||||
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
+32
-26
@@ -22,6 +22,8 @@ pub fn run(identifier: &str) -> Option<i32> {
|
||||
return None;
|
||||
}
|
||||
let args: Vec<String> = 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<i32> {
|
||||
|
||||
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<PathBuf> {
|
||||
|
||||
/// 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<PathBuf> {
|
||||
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<IpcPath> {
|
||||
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<crate::commands::window::CliInstallation> {
|
||||
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<Vec<IpcPath>> {
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -295,10 +295,17 @@ pub fn app_log_location() -> IdeResult<LogLocation> {
|
||||
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<ide_core::IpcPath>,
|
||||
}
|
||||
|
||||
/// Installs the `openidea` command for this user.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn app_cli_install() -> IdeResult<ide_core::IpcPath> {
|
||||
pub async fn app_cli_install() -> IdeResult<CliInstallation> {
|
||||
tauri::async_runtime::spawn_blocking(crate::cli::install).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
|
||||
@@ -704,41 +704,45 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
],
|
||||
));
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1163,7 +1163,7 @@ pub fn open_targets(app: &AppHandle, targets: Vec<ide_cli::Target>) {
|
||||
|
||||
/// `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())
|
||||
|
||||
@@ -952,8 +952,8 @@ export const commands = {
|
||||
*/
|
||||
menuSetDisabled: (ids: string[]) => __TAURI_INVOKE<void>("menu_set_disabled", { ids }),
|
||||
appLogLocation: () => typedError<LogLocation, IdeError>(__TAURI_INVOKE("app_log_location")),
|
||||
/** Links the `openidea` command onto `PATH` (macOS app bundle only); returns the link. */
|
||||
appCliInstall: () => typedError<IpcPath, IdeError>(__TAURI_INVOKE("app_cli_install")),
|
||||
/** Installs the `openidea` command for this user. */
|
||||
appCliInstall: () => typedError<CliInstallation, IdeError>(__TAURI_INVOKE("app_cli_install")),
|
||||
/** Removes the installed `openidea` command; returns the links removed. */
|
||||
appCliUninstall: () => typedError<IpcPath[], IdeError>(__TAURI_INVOKE("app_cli_uninstall")),
|
||||
appBuildInfo: () => __TAURI_INVOKE<BuildInfo>("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,
|
||||
|
||||
@@ -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<unknown>) => 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();
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
},
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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 开头,然后打开新终端。"
|
||||
}
|
||||
@@ -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": "不再询问",
|
||||
|
||||
Reference in new issue
Заблокувати користувача