дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
@@ -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)])));
|
||||
}
|
||||
}
|
||||
@@ -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\""#);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Executable
+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 "$@"
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
{
|
||||
"bundle": {
|
||||
"macOS": {
|
||||
"files": {
|
||||
"Resources/bin/openidea": "./cli/openidea"
|
||||
}
|
||||
},
|
||||
"fileAssociations": [
|
||||
{
|
||||
"name": "Text and Source Code",
|
||||
|
||||
@@ -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 })),
|
||||
/**
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "使用块注释",
|
||||
|
||||
Reference in new issue
Заблокувати користувача