feat(app): an openidea shell command that opens files and folders in the running app (macOS)

The bundle ships Contents/Resources/bin/openidea, a script that runs the app
executable in CLI mode. It sends its targets (--line/--column, or
path:line:column) to the running app over a per-identifier Unix socket, or
starts the app and waits for it. Install/Uninstall 'openidea' Command in the
app menu links it onto PATH.

Restoring a project's editor session no longer counts as the user picking a
tab, so a file opened with the project keeps its caret and activation.

Closes #1286

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Цей коміт міститься в:
jawтаClaude Opus 5.5 authored and l0ng-ai committed 2026-11-07 21:57:29 +08:00
1 джерело 90cca59462
коміт be0a6b4359
22 files changed
+916 -8

No files matched your search

Створено
+12
Переглянути файл
@@ -4168,6 +4168,17 @@ dependencies = [
"zip",
]
[[package]]
name = "ide-cli"
version = "0.1.35"
dependencies = [
"dirs 6.0.0",
"serde",
"serde_json",
"tempfile",
"tracing",
]
[[package]]
name = "ide-clipboard"
version = "0.1.38"
@@ -6367,6 +6378,7 @@ dependencies = [
"ide-analysis",
"ide-android",
"ide-build",
"ide-cli",
"ide-clipboard",
"ide-core",
"ide-coverage",
+1
Переглянути файл
@@ -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"] }
+15
Переглянути файл
@@ -0,0 +1,15 @@
[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
[dev-dependencies]
tempfile.workspace = true
+176
Переглянути файл
@@ -0,0 +1,176 @@
//! 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]] ...]
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>),
}
/// 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> {
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 let Some(line) = line.take() {
target.line = Some(line);
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", "/work/b:2"];
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_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)])));
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());
}
#[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());
}
#[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)])));
}
}
+172
Переглянути файл
@@ -0,0 +1,172 @@
//! Installing the `openidea` command: a symlink on `PATH` to the launcher
//! script in the app bundle (`Contents/Resources/bin/openidea`). macOS only.
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('"', "\\\"")
}
#[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\""#);
}
}
+46
Переглянути файл
@@ -0,0 +1,46 @@
//! 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>,
}
impl Request {
pub fn open(targets: Vec<Target>) -> Self {
Self { version: PROTOCOL_VERSION, targets }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum Response {
Ok,
Error { message: String },
}
+154
Переглянути файл
@@ -0,0 +1,154 @@
//! 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.
use crate::{PROTOCOL_VERSION, Request, Response};
use std::io;
use std::path::{Path, PathBuf};
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(10);
/// 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> {
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))
}
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}") },
}
}
#[cfg(unix)]
mod imp {
use super::*;
use std::io::{BufRead, BufReader, Write};
use std::os::unix::fs::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))?;
write_line(&stream, request)?;
let mut reply = String::new();
BufReader::new(&stream).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 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))?;
let mut line = String::new();
BufReader::new(stream).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(not(unix))]
mod imp {
use super::*;
fn unsupported() -> io::Error {
io::Error::new(io::ErrorKind::Unsupported, "the openidea command is not available on this platform yet")
}
pub fn send(_socket: &Path, _request: &Request) -> io::Result<Response> {
Err(unsupported())
}
pub fn serve(_socket: &Path, _handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
Err(unsupported())
}
}
/// 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::*;
use crate::Target;
#[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_eq!(dev.file_name().unwrap().len(), "openidea-0123456789abcdef.sock".len());
}
#[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(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 = 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);
}
}
+1
Переглянути файл
@@ -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
Переглянути файл
@@ -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 "$@"
+163
Переглянути файл
@@ -0,0 +1,163 @@
//! 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();
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 targets = match args::parse(args, &cwd)? {
Command::Help => {
print!("{}", args::USAGE);
return Ok(());
}
Command::Version => {
println!("OpenIDEA {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
Command::Open(targets) => targets,
};
let socket = socket::socket_path(identifier).ok_or("no cache directory for the OpenIDEA socket")?;
let request = 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 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());
#[cfg(unix)]
std::os::unix::process::CommandExt::process_group(&mut command, 0);
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) {
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)) {
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());
}
}
/// 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())
}
/// 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)?;
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
Переглянути файл
@@ -441,6 +441,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,
+14
Переглянути файл
@@ -295,6 +295,20 @@ 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.
#[tauri::command]
#[specta::specta]
pub async fn app_cli_install() -> IdeResult<ide_core::IpcPath> {
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
Переглянути файл
@@ -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;
@@ -69,6 +70,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);
@@ -189,6 +194,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);
}
+3
Переглянути файл
@@ -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),
+31 -7
Переглянути файл
@@ -1127,16 +1127,40 @@ 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) {
+5
Переглянути файл
@@ -1,5 +1,10 @@
{
"bundle": {
"macOS": {
"files": {
"Resources/bin/openidea": "./cli/openidea"
}
},
"fileAssociations": [
{
"name": "Text and Source Code",
+4
Переглянути файл
@@ -952,6 +952,10 @@ 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")),
/** 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 })),
/**
+20
Переглянути файл
@@ -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();
+55
Переглянути файл
@@ -0,0 +1,55 @@
/** Install / Uninstall the `openidea` shell command (macOS app bundle only). */
import type { Action } from "@/platform/actions";
import { t } 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 link = await call(commands.appCliInstall());
notifications().notify({ level: "info", title: t("app.cli.installed"), body: link, key: KEY });
} catch (e) {
if (!isCancelled(e))
notifications().notify({ level: "error", title: t("app.cli.installFailed"), body: errorText(e), key: KEY });
}
}
async function uninstallCli(): Promise<void> {
try {
const links = await call(commands.appCliUninstall());
notifications().notify({
level: "info",
title: t(links.length ? "app.cli.uninstalled" : "app.cli.notInstalled"),
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 });
}
}
export function cliActions(): Action[] {
return [
{
id: "app.installCli",
title: t("app.action.installCli"),
category: t("app.category.tools"),
keywords: ["cli", "shell", "terminal", "path", "command line", "launcher"],
run: installCli,
},
{
id: "app.uninstallCli",
title: t("app.action.uninstallCli"),
category: t("app.category.tools"),
keywords: ["cli", "shell", "terminal", "path", "command line", "launcher"],
run: uninstallCli,
},
];
}
+3 -1
Переглянути файл
@@ -6,10 +6,11 @@ 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";
import { buildInfo } from "./host";
import { buildInfo, guessOs } from "./host";
import { KeymapReferenceOverlay } from "./KeymapReference";
import { LightEditStatus } from "./LightEditStatus";
import { installLocaleSync, registerLocalizedActions } from "./locale";
@@ -31,6 +32,7 @@ export function activate(): () => void {
const disposers = [
installLocaleSync(),
registerLocalizedActions(() => [...appActions(), ...windowActions()]),
registerLocalizedActions(() => (guessOs() === "macos" ? cliActions() : [])),
registerOverlay({ id: "app.about", component: AboutOverlay }),
registerOverlay({ id: "app.keymapReference", component: KeymapReferenceOverlay }),
registerOverlay({ id: "app.invalidateCaches", component: InvalidateCachesOverlay }),
+10
Переглянути файл
@@ -17,6 +17,7 @@
"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",
@@ -32,11 +33,18 @@
"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",
@@ -100,7 +108,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",
+10
Переглянути файл
@@ -17,6 +17,7 @@
"app.action.checkForUpdates": "检查更新…",
"app.action.copy": "复制",
"app.action.cut": "剪切",
"app.action.installCli": "安装 'openidea' 命令",
"app.action.keymapReference": "快捷键参考",
"app.action.lightEditOpenProject": "将所在文件夹作为项目打开",
"app.action.newWindow": "新建窗口",
@@ -32,11 +33,18 @@
"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": "不再询问",
@@ -100,7 +108,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": "使用块注释",