feat(app): install the openidea command on macOS, Windows and Linux (#2009)

Adds Install/Uninstall 'openidea' Command actions. macOS uses the bundle launcher, Windows a user PATH wrapper and named pipe, and Linux the executable/AppImage path with a PATH hint. Files, folders, positions and `diff` go to the running app or start it.

Closes #1286

Co-authored-by: jaw52 <[email protected]>
This commit is contained in:
l0ng-aiandjaw52 authored and GitHub committed 2026-11-08 19:25:50 +08:00
1 parent 25424465c0
commit 0bfd4fa3cc
28 files changed
+1601 -42

No files matched your search

Generated
+15
View File
@@ -4168,6 +4168,20 @@ dependencies = [
"zip",
]
[[package]]
name = "ide-cli"
version = "0.1.39"
dependencies = [
"dirs 6.0.0",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
"windows-sys 0.61.2",
"winreg 0.56.0",
]
[[package]]
name = "ide-clipboard"
version = "0.1.39"
@@ -6370,6 +6384,7 @@ dependencies = [
"ide-analysis",
"ide-android",
"ide-build",
"ide-cli",
"ide-clipboard",
"ide-core",
"ide-coverage",
+1
View File
@@ -45,6 +45,7 @@ ide-host = { path = "crates/ide-host" }
ide-notebook = { path = "crates/ide-notebook" }
ide-extension = { path = "crates/ide-extension" }
ide-plugin = { path = "crates/ide-plugin" }
ide-cli = { path = "crates/ide-cli" }
# serialization & IPC typing
serde = { version = "1", features = ["derive"] }
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "ide-cli"
description = "The `openidea` command: arguments, the request it sends a running app over a local socket, and installing it on PATH"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
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
+234
View File
@@ -0,0 +1,234 @@
//! Command-line arguments of `openidea`.
use crate::Target;
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.
Lines and columns start at 1; --line and --column apply to the next path.
Options:
--line <n> Put the caret on line <n> of the next file
--column <n> Put the caret in column <n> of that line
-h, --help Show this help
-v, --version Show the version
";
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Help,
Version,
Open(Vec<Target>),
Diff(Vec<Target>),
}
/// Parses `args` (without the program name); relative paths resolve against `cwd`.
pub fn parse(args: &[String], cwd: &Path) -> Result<Command, String> {
parse_with(args, cwd, &|p| p.exists())
}
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);
let mut args = args.iter();
while let Some(arg) = args.next() {
let (flag, inline) = match arg.split_once('=') {
Some((flag, value)) if options && flag.starts_with("--") => (flag, Some(value.to_owned())),
_ => (arg.as_str(), None),
};
match flag {
"--" if options => options = false,
"-h" | "--help" if options => return Ok(Command::Help),
"-v" | "--version" if options => return Ok(Command::Version),
"--line" | "--column" if options => {
let value = inline.or_else(|| args.next().cloned()).ok_or(format!("{flag} needs a number"))?;
let n = position(&value).ok_or(format!("{flag}: not a number from 1: {value}"))?;
*if flag == "--line" { &mut line } else { &mut column } = Some(n);
}
a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")),
_ => {
let mut target = target(arg, cwd, exists)?;
if line.is_some() || column.is_some() {
target.line = line.take().or(target.line).or(Some(1));
target.column = column.take();
}
targets.push(target);
}
}
}
if line.is_some() || column.is_some() {
return Err("--line and --column need a file after them".into());
}
Ok(Command::Open(targets))
}
/// `arg` as a path, else as `path:line[:column]` when `path` exists.
fn target(arg: &str, cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Target, String> {
let path = cwd.join(arg);
if exists(&path) {
return Ok(Target { path, line: None, column: None });
}
for (base, line, column) in split_position(arg) {
let path = cwd.join(base);
if exists(&path) {
return Ok(Target { path, line: Some(line), column });
}
}
Err(format!("{arg}: no such file or directory"))
}
/// The readings of `arg` as `path:line:column`, then as `path:line`.
fn split_position(arg: &str) -> Vec<(&str, u32, Option<u32>)> {
let Some((rest, last)) = arg.rsplit_once(':') else { return Vec::new() };
let Some(last) = position(last) else { return Vec::new() };
let mut readings = Vec::new();
if let Some((base, line)) = rest.rsplit_once(':')
&& let Some(line) = position(line)
&& !base.is_empty()
{
readings.push((base, line, Some(last)));
}
if !rest.is_empty() {
readings.push((rest, last, None));
}
readings
}
fn position(s: &str) -> Option<u32> {
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok().filter(|n| *n > 0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| (*s).to_owned()).collect()
}
fn parse_in(list: &[&str], existing: &[&str]) -> Result<Command, String> {
let existing: Vec<PathBuf> = existing.iter().map(PathBuf::from).collect();
parse_with(&args(list), Path::new("/work"), &|p| existing.iter().any(|e| e == p))
}
fn at(path: &str, line: Option<u32>, column: Option<u32>) -> Target {
Target { path: path.into(), line, column }
}
#[test]
fn paths_resolve_against_cwd() {
assert_eq!(
parse_in(&["src", "/abs/a.rs"], &["/work/src", "/abs/a.rs"]),
Ok(Command::Open(vec![at("/work/src", None, None), at("/abs/a.rs", None, None)]))
);
assert_eq!(parse_in(&[], &[]), Ok(Command::Open(vec![])));
}
#[test]
fn positions_split_only_when_the_whole_arg_is_missing() {
let files = ["/work/a.rs"];
assert_eq!(parse_in(&["a.rs:12"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), None)])));
assert_eq!(parse_in(&["a.rs:12:5"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5))])));
assert!(parse_in(&["a.rs:0"], &files).is_err());
assert!(parse_in(&["a.rs:x"], &files).is_err());
assert!(parse_in(&["missing.rs:3"], &files).is_err());
}
// Windows file names cannot contain `:`, and `b:2` reads as a path on drive B.
#[cfg(unix)]
#[test]
fn an_existing_name_with_a_colon_wins_over_a_position() {
let files = ["/work/b:2"];
assert_eq!(parse_in(&["b:2"], &files), Ok(Command::Open(vec![at("/work/b:2", None, None)])));
assert_eq!(parse_in(&["b:2:7"], &files), Ok(Command::Open(vec![at("/work/b:2", Some(7), None)])));
}
#[test]
fn windows_drive_colon_is_not_a_position() {
assert_eq!(split_position(r"C:\a.rs:10"), vec![(r"C:\a.rs", 10, None)]);
assert_eq!(split_position(r"C:\a.rs:10:2"), vec![(r"C:\a.rs", 10, Some(2)), (r"C:\a.rs:10", 2, None)]);
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"];
assert_eq!(
parse_in(&["--line", "12", "--column=5", "a.rs", "b.rs"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5)), at("/work/b.rs", None, None)]))
);
assert_eq!(
parse_in(&["--line=3", "a.rs:9:9"], &files),
Ok(Command::Open(vec![at("/work/a.rs", Some(3), None)]))
);
assert!(parse_in(&["--line", "0", "a.rs"], &files).is_err());
assert!(parse_in(&["--line"], &files).is_err());
assert!(parse_in(&["a.rs", "--line", "2"], &files).is_err());
}
#[test]
fn options() {
assert_eq!(parse_in(&["-h"], &[]), Ok(Command::Help));
assert_eq!(parse_in(&["--version"], &[]), Ok(Command::Version));
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),]))
);
}
}
+476
View File
@@ -0,0 +1,476 @@
//! Installing the `openidea` command: a symlink on `PATH` to the launcher
//! script in a macOS bundle, or a wrapper in a user's bin directory.
use std::ffi::OsStr;
use std::io;
use std::path::{Path, PathBuf};
pub const COMMAND: &str = "openidea";
const LAUNCHER: &str = "Contents/Resources/bin/openidea";
const SYSTEM_DIR: &str = "/usr/local/bin";
/// The launcher script of the bundle whose executable is `exe`; `None` outside a bundle.
pub fn bundled_launcher(exe: &Path) -> Option<PathBuf> {
let launcher = exe.parent()?.parent()?.parent()?.join(LAUNCHER);
launcher.is_file().then_some(launcher)
}
/// macOS runs a quarantined app from a random read-only copy until it is moved:
/// a link there would break on the next launch.
pub fn translocated(path: &Path) -> bool {
path.to_string_lossy().contains("/AppTranslocation/")
}
/// Where the link goes: `~/.local/bin` when it is on `path_var` (no administrator
/// rights needed), else `/usr/local/bin`.
pub fn link_dir(path_var: Option<&OsStr>, home: Option<&Path>) -> PathBuf {
let local = home.map(|h| h.join(".local/bin"));
match (path_var, local) {
(Some(var), Some(local)) if std::env::split_paths(var).any(|p| p == local) => local,
_ => PathBuf::from(SYSTEM_DIR),
}
}
/// Directories an earlier install may have used.
pub fn link_dirs(home: Option<&Path>) -> Vec<PathBuf> {
home.map(|h| h.join(".local/bin")).into_iter().chain([PathBuf::from(SYSTEM_DIR)]).collect()
}
/// The installed links among `dirs` (any OpenIDEA bundle's launcher).
pub fn installed(dirs: &[PathBuf]) -> Vec<PathBuf> {
dirs.iter().map(|d| d.join(COMMAND)).filter(|l| is_launcher_link(l)).collect()
}
fn is_launcher_link(link: &Path) -> bool {
std::fs::read_link(link).is_ok_and(|target| target.ends_with(LAUNCHER))
}
/// Links `dir/openidea` to `launcher`, asking for administrator rights when `dir`
/// is not writable. Refuses to replace an `openidea` that is not ours.
pub fn install(launcher: &Path, dir: &Path) -> io::Result<PathBuf> {
let link = dir.join(COMMAND);
if link.symlink_metadata().is_ok() && !is_launcher_link(&link) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} already exists and is not an OpenIDEA command", link.display()),
));
}
let direct = std::fs::create_dir_all(dir).and_then(|()| {
if link.symlink_metadata().is_ok() {
std::fs::remove_file(&link)?;
}
symlink(launcher, &link)
});
match direct {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
privileged(&format!("mkdir -p {} && ln -sfn {} {}", quote(dir), quote(launcher), quote(&link)))?
}
result => result?,
}
Ok(link)
}
/// Removes `links`, asking for administrator rights where needed.
pub fn uninstall(links: &[PathBuf]) -> io::Result<()> {
for link in links {
match std::fs::remove_file(link) {
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => privileged(&format!("rm -f {}", quote(link)))?,
result => result?,
}
}
Ok(())
}
#[cfg(unix)]
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(not(unix))]
fn symlink(_target: &Path, _link: &Path) -> io::Result<()> {
Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on this platform"))
}
/// Runs `command` as root after the system's password prompt. Cancelling it is
/// [`io::ErrorKind::Interrupted`].
fn privileged(command: &str) -> io::Result<()> {
if !cfg!(target_os = "macos") {
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "administrator rights required"));
}
let script = format!("do shell script \"{}\" with administrator privileges", applescript_escape(command));
let output = std::process::Command::new("/usr/bin/osascript").arg("-e").arg(script).output()?;
if output.status.success() {
return Ok(());
}
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
// "User canceled. (-128)"
let kind = if message.contains("(-128)") { io::ErrorKind::Interrupted } else { io::ErrorKind::PermissionDenied };
Err(io::Error::new(kind, message))
}
fn quote(path: &Path) -> String {
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
}
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 ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"_openidea_codepage=%%c\"\r\n\"%SystemRoot%\\System32\\chcp.com\" 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\n\"%SystemRoot%\\System32\\chcp.com\" %_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(&current, &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();
std::fs::write(dir.path().join("chcp.cmd"), "@echo malicious-chcp\r\nexit /b 99\r\n").unwrap();
let output = std::process::Command::new("cmd.exe")
.current_dir(dir.path())
.args(["/d", "/s", "/c"])
.raw_arg(format!("\"\"{}\" \"a b\" \"中文\" \"x&y\"\"", path.display()))
.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 ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') 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")
.current_dir(dir.path())
.args(["/d", "/s", "/c"])
.raw_arg(format!("\"\"{}\"\"", check.display()))
.output()
.unwrap();
assert_eq!(output.status.code(), Some(7));
assert!(!String::from_utf8_lossy(&output.stdout).contains("malicious-chcp"));
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
fn bundle(root: &Path) -> (PathBuf, PathBuf) {
let app = root.join("OpenIDEA.app");
let exe = app.join("Contents/MacOS/openidea");
let launcher = app.join(LAUNCHER);
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
std::fs::write(&exe, "").unwrap();
std::fs::write(&launcher, "").unwrap();
(exe, launcher)
}
#[test]
fn finds_the_launcher_of_a_bundle_only() {
let dir = tempfile::tempdir().unwrap();
let (exe, launcher) = bundle(dir.path());
assert_eq!(bundled_launcher(&exe), Some(launcher));
assert_eq!(bundled_launcher(&dir.path().join("target/debug/openidea")), None);
assert!(translocated(Path::new("/private/var/folders/x/AppTranslocation/ABC/d/OpenIDEA.app")));
}
#[test]
fn prefers_local_bin_on_path() {
let home = Path::new("/Users/u");
let path = std::env::join_paths(["/usr/bin", "/Users/u/.local/bin"]).unwrap();
assert_eq!(link_dir(Some(&path), Some(home)), home.join(".local/bin"));
assert_eq!(link_dir(Some(OsStr::new("/usr/bin")), Some(home)), PathBuf::from(SYSTEM_DIR));
assert_eq!(link_dir(None, Some(home)), PathBuf::from(SYSTEM_DIR));
}
#[test]
fn install_replaces_its_own_link_but_not_other_files() {
let dir = tempfile::tempdir().unwrap();
let (_, launcher) = bundle(dir.path());
let bin = dir.path().join("bin");
let link = install(&launcher, &bin).unwrap();
assert_eq!(std::fs::read_link(&link).unwrap(), launcher);
assert_eq!(install(&launcher, &bin).unwrap(), link);
assert_eq!(installed(std::slice::from_ref(&bin)), vec![link.clone()]);
uninstall(&installed(std::slice::from_ref(&bin))).unwrap();
assert!(installed(std::slice::from_ref(&bin)).is_empty());
std::fs::write(&link, "someone else's").unwrap();
assert_eq!(install(&launcher, &bin).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
}
#[test]
fn quoting() {
assert_eq!(quote(Path::new("/a b/it's")), r"'/a b/it'\''s'");
assert_eq!(applescript_escape(r#"ln "a\b""#), r#"ln \"a\\b\""#);
}
}
+72
View File
@@ -0,0 +1,72 @@
//! The `openidea` command.
//!
//! The command is the app's own executable run in CLI mode: it parses its
//! arguments ([`args`]), sends one [`Request`] to the running app over a local
//! socket ([`socket`]) and exits. [`install`] links the bundle's launcher
//! script into a directory on `PATH`.
pub mod args;
pub mod install;
pub mod socket;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Bumped when [`Request`] changes incompatibly; an app still running an older
/// version answers with an error instead of misreading it.
pub const PROTOCOL_VERSION: u32 = 1;
/// A file or folder to open, with an optional 1-based position in a file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Target {
/// Absolute.
pub path: PathBuf,
pub line: Option<u32>,
pub column: Option<u32>,
}
/// What the command asks the running app to do. No targets: bring the app to the front.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
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, diff: false }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
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);
}
}
}
}
+266
View File
@@ -0,0 +1,266 @@
//! The local socket the running app listens on: one JSON [`Request`] line in,
//! 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))))
}
fn fnv1a(s: &str) -> u64 {
s.bytes().fold(0xcbf29ce484222325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3))
}
#[cfg(unix)]
mod imp {
use super::*;
use std::io::{BufRead, BufReader, Read, Write};
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
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).take(MAX_MESSAGE)).read_line(&mut reply)?;
Ok(serde_json::from_str(&reply)?)
}
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
let listener = bind(socket)?;
std::thread::Builder::new().name("cli-socket".into()).spawn(move || {
for stream in listener.incoming().flatten() {
if let Err(e) = serve_one(&stream, &handle) {
tracing::warn!("cli request failed: {e}");
}
}
})?;
Ok(())
}
/// Binds `socket`, replacing a stale file no one answers on.
fn bind(socket: &Path) -> io::Result<UnixListener> {
if let Some(dir) = socket.parent() {
std::fs::create_dir_all(dir)?;
}
let listener = match UnixListener::bind(socket) {
Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
if !std::fs::symlink_metadata(socket)?.file_type().is_socket() || UnixStream::connect(socket).is_ok() {
return Err(e);
}
std::fs::remove_file(socket)?;
UnixListener::bind(socket)?
}
bound => bound?,
};
std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
Ok(listener)
}
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.take(MAX_MESSAGE)).read_line(&mut line)?;
write_line(stream, &answer(&line, handle))
}
fn write_line(mut stream: &UnixStream, value: &impl serde::Serialize) -> io::Result<()> {
let mut line = serde_json::to_string(value)?;
line.push('\n');
stream.write_all(line.as_bytes())
}
}
#[cfg(windows)]
mod imp {
use super::*;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions};
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> {
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<()> {
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"))?
}
}
/// Sends `request` to the app listening on `socket` and waits for its answer.
pub use imp::send;
/// Answers requests on `socket` with `handle`, on a thread of its own.
pub use imp::serve;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn socket_names_separate_identifiers_and_stay_short() {
let release = socket_path("ai.l0ng.openidea").unwrap();
let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap();
assert_ne!(release, dev);
assert!(dev.as_os_str().len() < 100 || cfg!(unix));
}
#[test]
fn answers_only_its_protocol_version() {
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":[]}"#;
assert!(matches!(answer(old, &ok), Response::Error { .. }));
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() {
let dir = tempfile::tempdir().unwrap();
let socket = dir.path().join("cli.sock");
// A file left by an app that exited without removing it.
drop(std::os::unix::net::UnixListener::bind(&socket).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
serve(&socket, move |request| {
tx.send(request).unwrap();
Response::Ok
})
.unwrap();
let target = crate::Target { path: "/p/a.rs".into(), line: Some(3), column: None };
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
assert_eq!(rx.recv().unwrap().targets, vec![target]);
// 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");
}
}
+2
View File
@@ -214,6 +214,8 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
cmd,
"app_info"
| "app_build_info"
| "app_cli_install"
| "app_cli_uninstall"
| "app_log_location"
| "app_check_update"
| "app_startup_update_check"
+2
View File
@@ -33,6 +33,8 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
for command in [
"app_info",
"app_build_info",
"app_cli_install",
"app_cli_uninstall",
"app_log_location",
"app_check_update",
"app_startup_update_check",
+41
View File
@@ -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.
+1
View File
@@ -37,6 +37,7 @@ tracing-subscriber.workspace = true
dirs.workspace = true
ide-core.workspace = true
ide-cli.workspace = true
dunce.workspace = true
ide-clipboard.workspace = true
ide-fonts.workspace = true
+13
View File
@@ -0,0 +1,13 @@
#!/bin/sh
# The `openidea` command, linked onto PATH by "Install 'openidea' Command".
# Runs the app executable of this bundle in CLI mode (src-tauri/src/cli.rs).
self=$0
while [ -L "$self" ]; do
link=$(readlink "$self")
case $link in
/*) self=$link ;;
*) self=$(dirname "$self")/$link ;;
esac
done
contents=$(cd "$(dirname "$self")/../.." && pwd -P) || exit 1
exec "$contents/MacOS/openidea" --cli "$@"
+169
View File
@@ -0,0 +1,169 @@
//! The `openidea` command: this executable in CLI mode (started by the bundle's
//! launcher script with [`CLI_FLAG`]), the socket the app answers it on, and
//! installing the command on `PATH`.
use ide_cli::args::{self, Command};
use ide_cli::{Request, Response, socket};
use ide_core::{IdeError, IdeResult, IpcPath};
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use tauri::AppHandle;
/// First argument from `Contents/Resources/bin/openidea`.
const CLI_FLAG: &str = "--cli";
/// How long a starting app has to open its socket.
const START_TIMEOUT: Duration = Duration::from_secs(30);
/// Runs as the `openidea` command when started with [`CLI_FLAG`]; returns the exit code.
pub fn run(identifier: &str) -> Option<i32> {
let mut args = std::env::args().skip(1);
if args.next().as_deref() != Some(CLI_FLAG) {
return None;
}
let args: Vec<String> = args.collect();
#[cfg(windows)]
ide_cli::attach_console();
Some(match client(identifier, &args) {
Ok(()) => 0,
Err(message) => {
eprintln!("openidea: {message}");
1
}
})
}
fn client(identifier: &str, args: &[String]) -> Result<(), String> {
let cwd = std::env::current_dir().map_err(|e| format!("no working directory: {e}"))?;
let command = args::parse(args, &cwd)?;
let diff = matches!(command, Command::Diff(_));
let targets = match command {
Command::Help => {
print!("{}", args::USAGE);
return Ok(());
}
Command::Version => {
println!("OpenIDEA {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
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 { diff, ..Request::open(targets) };
let response = match socket::send(&socket, &request) {
Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::ConnectionRefused) => {
start_app()?;
send_when_started(&socket, &request)?
}
sent => sent.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?,
};
match response {
Response::Ok => Ok(()),
Response::Error { message } => Err(message),
}
}
/// Starts the app detached from this terminal: through Launch Services for a
/// 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");
open.arg("-a").arg(bundle);
open
}
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}"))
}
fn send_when_started(socket: &Path, request: &Request) -> Result<Response, String> {
let start = Instant::now();
loop {
match socket::send(socket, request) {
Err(e) if matches!(e.kind(), ErrorKind::NotFound | ErrorKind::ConnectionRefused) => {
if start.elapsed() > START_TIMEOUT {
return Err("OpenIDEA did not start in time".into());
}
std::thread::sleep(Duration::from_millis(100));
}
sent => return sent.map_err(|e| format!("cannot reach OpenIDEA: {e}")),
}
}
}
/// `….app` of the bundle executable `exe` (`….app/Contents/MacOS/openidea`).
fn app_bundle(exe: &Path) -> Option<PathBuf> {
let bundle = exe.parent()?.parent()?.parent()?;
(cfg!(target_os = "macos") && bundle.extension().is_some_and(|e| e == "app")).then(|| bundle.to_path_buf())
}
/// Answers the `openidea` command for this app: its targets open like paths from Finder.
pub fn serve(app: &AppHandle, identifier: &str) {
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 || {
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}") },
}
});
if let Err(e) = served {
tracing::warn!("cannot listen for the openidea command on {}: {e}", socket.display());
}
}
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<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::uninstall_current().map_err(install_error)?;
Ok(links.into_iter().map(Into::into).collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundle_of_the_app_executable() {
let exe = Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea");
let expected = cfg!(target_os = "macos").then(|| PathBuf::from("/Applications/OpenIDEA.app"));
assert_eq!(app_bundle(exe), expected);
assert_eq!(app_bundle(Path::new("/repo/target/debug/openidea")), None);
}
}
+2
View File
@@ -445,6 +445,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
window::menu_model,
window::menu_set_disabled,
window::app_log_location,
window::app_cli_install,
window::app_cli_uninstall,
window::app_build_info,
window::app_check_update,
window::app_startup_update_check,
+21
View File
@@ -313,6 +313,27 @@ pub fn app_log_location() -> IdeResult<LogLocation> {
Ok(LogLocation { dir: dir.into(), file: crate::logging::latest_log(dir).map(Into::into) })
}
#[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<CliInstallation> {
tauri::async_runtime::spawn_blocking(crate::cli::install).await.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Removes the installed `openidea` command; returns the links removed.
#[tauri::command]
#[specta::specta]
pub async fn app_cli_uninstall() -> IdeResult<Vec<ide_core::IpcPath>> {
tauri::async_runtime::spawn_blocking(crate::cli::uninstall).await.map_err(|e| IdeError::internal(e.to_string()))?
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct BuildInfo {
+6
View File
@@ -4,6 +4,7 @@
//! adapter from IPC to a backend crate; no domain logic lives here.
mod application_focus;
mod cli;
mod commands;
#[cfg(debug_assertions)]
mod dev;
@@ -72,6 +73,10 @@ pub fn run() {
let context = tauri::generate_context!();
#[cfg(debug_assertions)]
let context = dev::configure(context);
// The `openidea` command: answer and exit, no UI.
if let Some(code) = cli::run(&context.config().identifier) {
std::process::exit(code);
}
// Before the first window: one taskbar identity for every launch path.
jump_list::set_app_id(&context.config().identifier);
logging::init(&context.config().identifier);
@@ -196,6 +201,7 @@ pub fn run() {
#[cfg(target_os = "macos")]
application_focus::observe(app.handle());
app.manage(menu_state);
cli::serve(app.handle(), &app.config().identifier);
if let Some(paths) = setup_opens.lock().take() {
windows::open_paths_at_launch(app.handle(), paths);
}
+41 -34
View File
@@ -191,6 +191,9 @@ pub fn spec(os: Os) -> Vec<Entry> {
act("app.about", "About OpenIDEA"),
act("app.checkForUpdates", "Check for Updates…"),
SEP,
act("app.installCli", "Install 'openidea' Command"),
act("app.uninstallCli", "Uninstall 'openidea' Command"),
SEP,
act("file.settings", "Settings…"),
SEP,
Entry::Native(Native::Services),
@@ -702,41 +705,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 {
+32 -8
View File
@@ -1152,19 +1152,43 @@ pub fn on_second_instance(app: &AppHandle, args: Vec<String>, cwd: String) {
remember_cli_position(&app.state::<WindowsState>(), &args, &path);
open_path(app, &path)
}
None => {
if let Some(w) = target_window(app) {
focus(&w);
} else {
spawn_window(app, None);
}
}
None => focus_any(app),
}
}
/// Focuses the window the user last looked at, else opens a new one.
fn focus_any(app: &AppHandle) {
if let Some(w) = target_window(app) {
focus(&w);
} else {
spawn_window(app, None);
}
}
/// Opens what the `openidea` command asked for, like [`open_paths`]; files open at their
/// 1-based line and column. Without targets, focuses a window.
pub fn open_targets(app: &AppHandle, targets: Vec<ide_cli::Target>) {
if targets.is_empty() {
return focus_any(app);
}
let windows = app.state::<WindowsState>();
let paths = targets
.into_iter()
.map(|t| {
if let Some(line) = t.line
&& !t.path.is_dir()
{
windows.set_position(&t.path, (line.saturating_sub(1), t.column.map_or(0, |c| c.saturating_sub(1))));
}
t.path
})
.collect();
open_paths(app, paths);
}
/// `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())
+5
View File
@@ -1,5 +1,10 @@
{
"bundle": {
"macOS": {
"files": {
"Resources/bin/openidea": "./cli/openidea"
}
},
"fileAssociations": [
{
"name": "Text and Source Code",
+9
View File
@@ -979,6 +979,10 @@ export const commands = {
*/
menuSetDisabled: (ids: string[]) => __TAURI_INVOKE<void>("menu_set_disabled", { ids }),
appLogLocation: () => typedError<LogLocation, IdeError>(__TAURI_INVOKE("app_log_location")),
/** 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"),
appCheckUpdate: (source: UpdateSource) => typedError<UpdateCheck, IdeError>(__TAURI_INVOKE("app_check_update", { source })),
/**
@@ -2284,6 +2288,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,
+20
View File
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { registerFileEditorProvider } from "@/workbench/contributions";
import { EditorController, openFilePaths, useTabs } from "./controller";
import { createMemoryBackend } from "./backend";
import { storeSession } from "./model/session";
import { initialTabsState } from "./model/tabs";
import { useProject } from "@/platform/project";
@@ -63,6 +64,25 @@ function tabFor(path: string) {
}
describe("file open order", () => {
it("keeps the caret and activation of a file opened while the project's session restores", async () => {
const before = setup();
before.restoreSession("/p");
await before.openFile("/p/b.ts", { focus: false });
before.stop();
useTabs.setState(initialTabsState(), true);
try {
const controller = setup();
const open = controller.openFile("/p/a.ts", { selection: { line: 1, col: 2 }, focus: false });
controller.restoreSession("/p");
await open;
expect(openFilePaths(useTabs.getState()).sort()).toEqual(["/p/a.ts", "/p/b.ts"]);
expect(activePath()).toBe("/p/a.ts");
expect(controller.slotFor(tabFor("/p/a.ts")!.id)?.caret()).toMatchObject({ line: 1, col: 2 });
} finally {
storeSession("/p", null);
}
});
it.each([1, 30])("keeps the newer file active after an earlier probe (tab limit: %s)", async (tabLimit) => {
useProject.setState({ settings: { editor: { tabs: { tabLimit } } } as never });
const { started, response } = delayProvider();
@@ -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();
});
+73
View File
@@ -0,0 +1,73 @@
/** Install / Uninstall the `openidea` shell command. */
import type { Action } from "@/platform/actions";
import { tx } from "@/platform/i18n";
import { call, commands, isCancelled } from "@/platform/ipc";
import { notifications } from "@/workbench/contributions";
const KEY = "app.cli";
function errorText(e: unknown) {
return e instanceof Error ? e.message : String(e);
}
async function installCli(): Promise<void> {
try {
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: tx("Cannot install the openidea command"),
body: errorText(e),
key: KEY,
});
}
}
async function uninstallCli(): Promise<void> {
try {
const links = await call(commands.appCliUninstall());
notifications().notify({
level: "info",
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: tx("Cannot uninstall the openidea command"),
body: errorText(e),
key: KEY,
});
}
}
export function cliActions(): Action[] {
return [
{
id: "app.installCli",
title: tx("Install 'openidea' Command"),
category: tx("Tools"),
keywords: ["cli", "shell", "terminal", "path", "command line", "launcher"],
run: installCli,
},
{
id: "app.uninstallCli",
title: tx("Uninstall 'openidea' Command"),
category: tx("Tools"),
keywords: ["cli", "shell", "terminal", "path", "command line", "launcher"],
run: uninstallCli,
},
];
}
+2
View File
@@ -6,6 +6,7 @@ import { registerOverlay, registerStatusItem } from "@/workbench/contributions";
import { AboutOverlay } from "./AboutDialog";
import { appActions, updateActions } from "./appActions";
import { checkForUpdatesOnStartup } from "./updates";
import { cliActions } from "./cliCommand";
import { installCloseGuard } from "./closeGuard";
import { TerminationOverlay } from "./termination";
import { InvalidateCachesOverlay, registerInvalidateCachesActions } from "./InvalidateCaches";
@@ -31,6 +32,7 @@ export function activate(): () => void {
const disposers = [
installLocaleSync(),
registerLocalizedActions(() => [...appActions(), ...windowActions()]),
registerLocalizedActions(cliActions),
registerOverlay({ id: "app.about", component: AboutOverlay }),
registerOverlay({ id: "app.keymapReference", component: KeymapReferenceOverlay }),
registerOverlay({ id: "app.invalidateCaches", component: InvalidateCachesOverlay }),
+2
View File
@@ -100,7 +100,9 @@
"menu.app": "OpenIDEA",
"menu.app.about": "About OpenIDEA",
"menu.app.checkForUpdates": "Check for Updates…",
"menu.app.installCli": "Install 'openidea' Command",
"menu.app.quit": "{os, select, macos {Quit OpenIDEA} other {Exit}}",
"menu.app.uninstallCli": "Uninstall 'openidea' Command",
"menu.appearance": "Appearance",
"menu.code": "Code",
"menu.code.commentBlock": "Comment with Block Comment",
+9
View File
@@ -130,6 +130,15 @@
"WAR Archive": "WAR Archive",
"WAR (exploded)": "WAR (exploded)",
"No project is currently open.": "No project is currently open.",
"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.",
"Default SDK used by modules, builds, and run configurations unless overridden at the module level.": "Default SDK used by modules, builds, and run configurations unless overridden at the module level.",
"Project SDK": "Project SDK",
"Default Java language level for source code assistance and compilation across project modules.": "Default Java language level for source code assistance and compilation across project modules.",
+9
View File
@@ -1715,6 +1715,15 @@
"WAR Archive": "WAR 归档",
"WAR (exploded)": "WAR(展开)",
"No project is currently open.": "当前未打开任何项目。",
"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 开头,然后打开新终端。",
"Default SDK used by modules, builds, and run configurations unless overridden at the module level.": "模块、构建和运行配置默认使用的 SDK(除非在模块级别覆盖)。",
"Project SDK": "项目 SDK",
"Default Java language level for source code assistance and compilation across project modules.": "项目各模块用于源码辅助和编译的默认 Java 语言级别。",
+2
View File
@@ -100,7 +100,9 @@
"menu.app": "OpenIDEA",
"menu.app.about": "关于 OpenIDEA",
"menu.app.checkForUpdates": "检查更新…",
"menu.app.installCli": "安装 'openidea' 命令",
"menu.app.quit": "{os, select, macos {退出 OpenIDEA} other {退出}}",
"menu.app.uninstallCli": "卸载 'openidea' 命令",
"menu.appearance": "外观",
"menu.code": "代码",
"menu.code.commentBlock": "使用块注释",