дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(unity): open scripts from external editor arguments (#2296)
* feat(app): support Unity external editor navigation * docs(unity): explain manual external editor setup
Цей коміт міститься в:
1 джерело
5ab5c8c677
коміт
2dd57d095f
10 files changed
+1141
-240
No files matched your search
@@ -447,6 +447,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
window::window_is_fullscreen,
|
||||
window_drag::window_start_drag,
|
||||
window::window_take_launch,
|
||||
window::window_complete_launch,
|
||||
window::window_close_context,
|
||||
window::window_open_projects,
|
||||
window::app_quit,
|
||||
|
||||
@@ -163,6 +163,14 @@ pub fn window_take_launch(window: WebviewWindow, windows: State<'_, WindowsState
|
||||
windows.take_launch(window.label())
|
||||
}
|
||||
|
||||
/// Release the opening window's reservation after navigation or cancellation.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn window_complete_launch(window: WebviewWindow, windows: State<'_, WindowsState>) -> IdeResult<()> {
|
||||
windows.complete_launch(window.label());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct OpenProject {
|
||||
|
||||
+12
-15
@@ -102,7 +102,7 @@ pub fn run() {
|
||||
#[cfg(debug_assertions)]
|
||||
export_bindings(&builder);
|
||||
|
||||
let app = tauri::Builder::default();
|
||||
let app = tauri::Builder::default().manage(WindowsState::default());
|
||||
// Single instance first: a second launch focuses this one and opens its path argument.
|
||||
// Debug builds have a separate identifier per worktree.
|
||||
let app = app.plugin(tauri_plugin_single_instance::init(windows::on_second_instance));
|
||||
@@ -176,26 +176,22 @@ pub fn run() {
|
||||
.setup(move |app| {
|
||||
builder.mount_events(app);
|
||||
let state = AppState::new(app.handle().clone())?;
|
||||
let windows = WindowsState::default();
|
||||
let windows = app.state::<WindowsState>();
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
let args = std::env::args().collect::<Vec<_>>();
|
||||
match windows::cli_launch(&args, &cwd) {
|
||||
Some(windows::CliLaunch::Path(path)) => {
|
||||
windows::remember_cli_position(&windows, &args, &path);
|
||||
windows.set_launch("main", windows::path_launch(path));
|
||||
}
|
||||
// `openidea diff a.txt b.txt`: the main window opens the pair in the diff viewer.
|
||||
Some(windows::CliLaunch::Diff(diff)) => {
|
||||
windows.set_launch("main", windows::Launch { diff: Some(Box::new(diff)), ..Default::default() });
|
||||
}
|
||||
None => {}
|
||||
match windows::cli_launch(&std::env::args().collect::<Vec<_>>(), &cwd) {
|
||||
Ok(Some(launch)) => windows.set_launch("main", launch),
|
||||
Ok(None) => {}
|
||||
Err(error) => windows::report_cli_error(app.handle(), error),
|
||||
}
|
||||
}
|
||||
let menu_state = menu::MenuState::default();
|
||||
menu::install(app.handle(), &menu_state, &state.recent.list());
|
||||
app.manage(state);
|
||||
commands::mcp::apply(&app.state::<AppState>());
|
||||
app.manage(windows);
|
||||
#[cfg(windows)]
|
||||
if let Err(error) = windows::allow_external_launches(app.handle()) {
|
||||
windows::report_cli_error(app.handle(), error);
|
||||
}
|
||||
app.manage(application_focus::ApplicationFocus::default());
|
||||
#[cfg(target_os = "macos")]
|
||||
macos::intercept_system_quit(app.handle());
|
||||
@@ -212,6 +208,7 @@ pub fn run() {
|
||||
}
|
||||
app.on_menu_event(on_menu_event);
|
||||
windows::create_main_window(app.handle())?;
|
||||
windows::ready_for_cli(app.handle());
|
||||
jump_list::refresh(app.handle(), app.state::<AppState>().recent.list());
|
||||
Ok(())
|
||||
})
|
||||
@@ -262,7 +259,7 @@ pub fn run() {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Opened { urls } = &event {
|
||||
let paths = windows::opened_paths(urls);
|
||||
if app.try_state::<WindowsState>().is_some() {
|
||||
if app.try_state::<AppState>().is_some() {
|
||||
windows::open_paths(app, paths);
|
||||
} else {
|
||||
early_opens.lock().get_or_insert_default().extend(paths);
|
||||
|
||||
+485
-195
@@ -10,6 +10,12 @@
|
||||
//! [`AppState::project`]). Their labels name the kind and the parent
|
||||
//! (`settings-main`, `comparison-project-2-3`); their [`Launch`] says what to show.
|
||||
|
||||
mod cli;
|
||||
#[cfg(windows)]
|
||||
mod single_instance;
|
||||
#[cfg(windows)]
|
||||
pub use single_instance::allow_external_launches;
|
||||
|
||||
use crate::remote_dev::RemoteTarget;
|
||||
use crate::state::AppState;
|
||||
use crate::theme_frame::builtin_frame;
|
||||
@@ -60,6 +66,9 @@ pub struct Launch {
|
||||
/// `openidea diff a.txt b.txt`: the two files to open in the diff viewer (boxed: most launches have none).
|
||||
#[serde(default)]
|
||||
pub diff: Option<Box<DiffFilesLaunch>>,
|
||||
/// Files requested after the pending diff take focus after it opens.
|
||||
#[serde(default)]
|
||||
pub diff_before_files: bool,
|
||||
/// Deployment windows: the server and local selection to compare.
|
||||
#[serde(default)]
|
||||
pub deployment: Option<DeploymentLaunch>,
|
||||
@@ -112,6 +121,15 @@ pub struct FilePosition {
|
||||
pub column: u32,
|
||||
}
|
||||
|
||||
impl Launch {
|
||||
fn add_positions(&mut self, positions: &[FilePosition]) {
|
||||
for position in positions.iter().filter(|p| self.files.contains(&p.path)) {
|
||||
self.positions.retain(|p| p.path != position.path);
|
||||
self.positions.push(position.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A project folder on an SSH host, in a WSL distribution or in a dev container.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -134,8 +152,9 @@ pub struct WindowsState {
|
||||
/// read before this run first overwrites the file, so restoring still sees it.
|
||||
pub groups_persist: tokio::sync::Mutex<Option<Vec<ide_workspace::window_groups::SavedWindows>>>,
|
||||
launches: Mutex<HashMap<String, Launch>>,
|
||||
/// Caret positions asked for on the command line, by canonical file path, until a window takes the file.
|
||||
positions: Mutex<HashMap<PathBuf, (u32, u32)>>,
|
||||
/// A taken launch still owns its window until the frontend finishes opening it.
|
||||
opening: Mutex<HashMap<String, Launch>>,
|
||||
cli: Mutex<cli::Queue>,
|
||||
/// Most recently focused window label (menu events go there).
|
||||
last_focused: Mutex<Option<String>>,
|
||||
/// Set by Quit while windows close one by one.
|
||||
@@ -174,36 +193,44 @@ impl WindowsState {
|
||||
/// Whether the window `label` is created to open a project (local or remote).
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn opens_project(&self, label: &str) -> bool {
|
||||
self.launches.lock().get(label).is_some_and(|l| l.project.is_some() || l.remote.is_some())
|
||||
self.pending(label).is_some_and(|l| l.project.is_some() || l.remote.is_some())
|
||||
}
|
||||
|
||||
pub fn take_launch(&self, label: &str) -> Option<Launch> {
|
||||
let mut launch = self.launches.lock().remove(label)?;
|
||||
let mut positions = self.positions.lock();
|
||||
for file in &launch.files {
|
||||
let path = dunce::canonicalize(&file.0).unwrap_or_else(|_| file.0.clone());
|
||||
if let Some((line, column)) = positions.remove(&path) {
|
||||
launch.positions.push(FilePosition { path: file.clone(), line, column });
|
||||
}
|
||||
let mut launches = self.launches.lock();
|
||||
let launch = launches.remove(label)?;
|
||||
if launch.project.is_some() || launch.remote.is_some() || !launch.files.is_empty() || launch.diff.is_some() {
|
||||
self.opening.lock().insert(label.to_owned(), launch.clone());
|
||||
}
|
||||
Some(launch)
|
||||
}
|
||||
|
||||
/// The caret goes to `position` (0-based line, column) when a window opens `file`.
|
||||
pub fn set_position(&self, file: &Path, position: (u32, u32)) {
|
||||
let path = dunce::canonicalize(file).unwrap_or_else(|_| file.to_path_buf());
|
||||
self.positions.lock().insert(path, position);
|
||||
pub fn complete_launch(&self, label: &str) {
|
||||
self.opening.lock().remove(label);
|
||||
}
|
||||
|
||||
/// Adds `files` to `label`'s pending launch, so files sent before its frontend took the last batch aren't lost.
|
||||
pub fn add_files(&self, label: &str, files: Vec<PathBuf>) {
|
||||
fn add_files_at(&self, label: &str, files: Vec<PathBuf>, positions: &[FilePosition]) {
|
||||
let mut launches = self.launches.lock();
|
||||
let launch = launches.entry(label.to_owned()).or_default();
|
||||
if !files.is_empty() && launch.diff.is_some() {
|
||||
launch.diff_before_files = true;
|
||||
}
|
||||
launch.files.extend(files.into_iter().map(Into::into));
|
||||
launch.add_positions(positions);
|
||||
}
|
||||
|
||||
fn add_diff(&self, label: &str, diff: DiffFilesLaunch) {
|
||||
let mut launches = self.launches.lock();
|
||||
let launch = launches.entry(label.to_owned()).or_default();
|
||||
launch.diff = Some(Box::new(diff));
|
||||
launch.diff_before_files = false;
|
||||
}
|
||||
|
||||
pub(crate) fn pending(&self, label: &str) -> Option<Launch> {
|
||||
self.launches.lock().get(label).cloned()
|
||||
let launches = self.launches.lock();
|
||||
let opening = self.opening.lock();
|
||||
opening.get(label).or_else(|| launches.get(label)).cloned()
|
||||
}
|
||||
|
||||
pub fn focused(&self, label: &str) {
|
||||
@@ -212,7 +239,12 @@ impl WindowsState {
|
||||
|
||||
pub fn forget(&self, label: &str) {
|
||||
self.close_settled(label);
|
||||
self.launches.lock().remove(label);
|
||||
{
|
||||
let mut launches = self.launches.lock();
|
||||
let mut opening = self.opening.lock();
|
||||
launches.remove(label);
|
||||
opening.remove(label);
|
||||
}
|
||||
let mut last = self.last_focused.lock();
|
||||
if last.as_deref() == Some(label) {
|
||||
*last = None;
|
||||
@@ -391,97 +423,70 @@ pub fn cycle_target(sorted: &[String], current: &str, forward: bool) -> Option<S
|
||||
Some(sorted[j].clone())
|
||||
}
|
||||
|
||||
/// Flags followed by a value (`--line 12`).
|
||||
const VALUE_FLAGS: [&str; 2] = ["--line", "--column"];
|
||||
|
||||
/// The subcommand opening two files side by side (`openidea diff a.txt b.txt`).
|
||||
const DIFF_ARG: &str = "diff";
|
||||
|
||||
/// The project path from command-line arguments (`openidea /path/to/project`):
|
||||
/// the first argument after the binary that is not a flag or a flag's value, resolved against `cwd`.
|
||||
/// The first path, for ordinary opening and the Windows recent-project shortcut.
|
||||
pub fn cli_path(args: &[String], cwd: &Path) -> Option<PathBuf> {
|
||||
let mut rest = args.iter().skip(1);
|
||||
while let Some(a) = rest.next() {
|
||||
if VALUE_FLAGS.contains(&a.as_str()) {
|
||||
rest.next();
|
||||
continue;
|
||||
}
|
||||
if a.starts_with('-') || a.is_empty() || a.starts_with("psn_") {
|
||||
continue;
|
||||
}
|
||||
let p = PathBuf::from(a);
|
||||
return Some(if p.is_absolute() { p } else { cwd.join(p) });
|
||||
}
|
||||
None
|
||||
cli::parse(args, cwd).ok().and_then(|r| r.path.or(r.file))
|
||||
}
|
||||
|
||||
/// What the command line asks to open (arguments after the binary, resolved against `cwd`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CliLaunch {
|
||||
/// `openidea diff a.txt b.txt`: the two files side by side in the diff viewer.
|
||||
Diff(DiffFilesLaunch),
|
||||
/// A project folder or a file (`openidea /path/to/project`).
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
/// What the command line asks to open; see [`CliLaunch`].
|
||||
pub fn cli_launch(args: &[String], cwd: &Path) -> Option<CliLaunch> {
|
||||
if args.get(1).map(String::as_str) == Some(DIFF_ARG) {
|
||||
return match cli_diff_paths(args, cwd).as_slice() {
|
||||
[left, right, ..] => {
|
||||
Some(CliLaunch::Diff(DiffFilesLaunch { left: left.clone().into(), right: right.clone().into() }))
|
||||
/// A positional folder plus `--file` opens that file in its project. Without a folder,
|
||||
/// ordinary files retain LightEdit behavior; an existing project window can claim them.
|
||||
pub fn cli_launch(args: &[String], cwd: &Path) -> IdeResult<Option<Launch>> {
|
||||
if let Some(paths) = cli::diff_paths(args, cwd) {
|
||||
return Ok(match paths.as_slice() {
|
||||
[left, right, ..] => Some(Launch {
|
||||
diff: Some(Box::new(DiffFilesLaunch { left: left.clone().into(), right: right.clone().into() })),
|
||||
..Default::default()
|
||||
}),
|
||||
[one] => {
|
||||
let mut file_args = args.to_vec();
|
||||
file_args.remove(1);
|
||||
let position = cli::parse(&file_args, cwd).map_err(IdeError::invalid)?.position;
|
||||
Some(position_launch(path_launch(one.clone()), position))
|
||||
}
|
||||
// One file is no pair: open it like any path.
|
||||
[one] => Some(CliLaunch::Path(one.clone())),
|
||||
// `openidea diff` with no files: like starting without arguments.
|
||||
[] => None,
|
||||
};
|
||||
});
|
||||
}
|
||||
cli_path(args, cwd).map(CliLaunch::Path)
|
||||
}
|
||||
|
||||
/// The file paths of a `diff` subcommand (everything after it that is not a flag or a flag's
|
||||
/// value), resolved against `cwd`.
|
||||
fn cli_diff_paths(args: &[String], cwd: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
let mut rest = args.iter().skip(2);
|
||||
while let Some(a) = rest.next() {
|
||||
if VALUE_FLAGS.contains(&a.as_str()) {
|
||||
rest.next();
|
||||
continue;
|
||||
}
|
||||
if a.starts_with('-') || a.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let p = PathBuf::from(a);
|
||||
paths.push(if p.is_absolute() { p } else { cwd.join(p) });
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
/// `--line <n>` and `--column <n>` (1-based, also as `--line=<n>`) as a 0-based position;
|
||||
/// without `--line` there is none, without `--column` it is the line's start.
|
||||
pub fn cli_position(args: &[String]) -> Option<(u32, u32)> {
|
||||
let value = |flag: &str| {
|
||||
let prefix = format!("{flag}=");
|
||||
args.iter().enumerate().skip(1).find_map(|(i, a)| match a.strip_prefix(&prefix) {
|
||||
Some(v) => Some(v.to_owned()),
|
||||
None if a == flag => args.get(i + 1).cloned(),
|
||||
None => None,
|
||||
})
|
||||
let request = cli::parse(args, cwd).map_err(IdeError::invalid)?;
|
||||
let canonical = |path: &Path| {
|
||||
dunce::canonicalize(path).map_err(|e| IdeError::invalid(format!("Cannot open {}: {e}", path.display())))
|
||||
};
|
||||
let number = |flag: &str| value(flag).and_then(|v| v.trim().parse::<u32>().ok());
|
||||
let line = number("--line")?;
|
||||
Some((line.saturating_sub(1), number("--column").unwrap_or(1).saturating_sub(1)))
|
||||
let launch = if let Some(file) = request.file {
|
||||
let file = canonical(&file)?;
|
||||
if !file.is_file() {
|
||||
return Err(IdeError::invalid("--file must name an existing file"));
|
||||
}
|
||||
let project = request.path.map(|p| canonical(&p)).transpose()?;
|
||||
if project.as_ref().is_some_and(|p| !p.is_dir()) {
|
||||
return Err(IdeError::invalid("The positional path before --file must be a project folder"));
|
||||
}
|
||||
if project.as_ref().is_some_and(|p| wsl_launch(p).is_some()) {
|
||||
return Err(IdeError::invalid("External file locations require a local project folder"));
|
||||
}
|
||||
path_launch_of(project, vec![file])
|
||||
} else if let Some(path) = request.path {
|
||||
path_launch(canonical(&path)?)
|
||||
} else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(position_launch(launch, request.position)))
|
||||
}
|
||||
|
||||
/// Remembers the command line's `--line` / `--column` for `path` (a file) before it is opened.
|
||||
pub fn remember_cli_position(windows: &WindowsState, args: &[String], path: &Path) {
|
||||
if let Some(position) = cli_position(args)
|
||||
&& !path.is_dir()
|
||||
{
|
||||
windows.set_position(path, position);
|
||||
fn position_launch(mut launch: Launch, position: Option<(u32, u32)>) -> Launch {
|
||||
if let (Some((line, column)), Some(file)) = (position, launch.files.first()) {
|
||||
launch.positions.push(FilePosition { path: file.clone(), line, column });
|
||||
}
|
||||
launch
|
||||
}
|
||||
|
||||
/// CLI failures also need to be visible before the webview has subscribed to notifications.
|
||||
pub fn report_cli_error(app: &AppHandle, error: IdeError) {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
tracing::warn!("external open failed: {}", error.message);
|
||||
app.dialog()
|
||||
.message(error.message)
|
||||
.title("OpenIDEA")
|
||||
.kind(tauri_plugin_dialog::MessageDialogKind::Error)
|
||||
.show(|_| {});
|
||||
}
|
||||
|
||||
pub fn focus(window: &WebviewWindow) {
|
||||
@@ -523,7 +528,11 @@ pub fn find_project_window(app: &AppHandle, root: &Path) -> Option<WebviewWindow
|
||||
|
||||
/// Hands `files` to the already loaded `window` (its frontend takes them on [`LAUNCH_EVENT`]) and focuses it.
|
||||
fn send_files(app: &AppHandle, window: &WebviewWindow, files: Vec<PathBuf>) {
|
||||
app.state::<WindowsState>().add_files(window.label(), files);
|
||||
send_files_at(app, window, files, &[]);
|
||||
}
|
||||
|
||||
fn send_files_at(app: &AppHandle, window: &WebviewWindow, files: Vec<PathBuf>, positions: &[FilePosition]) {
|
||||
app.state::<WindowsState>().add_files_at(window.label(), files, positions);
|
||||
let _ = app.emit_to(window.label(), LAUNCH_EVENT, ());
|
||||
focus(window);
|
||||
}
|
||||
@@ -759,12 +768,22 @@ pub fn spawn_window(app: &AppHandle, project: Option<PathBuf>) {
|
||||
}
|
||||
|
||||
fn spawn_window_with(app: &AppHandle, project: Option<PathBuf>, files: Vec<PathBuf>) {
|
||||
let title = project.as_deref().or(files.first().map(PathBuf::as_path)).and_then(Path::file_name);
|
||||
let title = title.map(|n| n.to_string_lossy().into_owned());
|
||||
let _ = open_launch_window(app, title, path_launch_of(project, files), false);
|
||||
}
|
||||
|
||||
fn open_launch_window(app: &AppHandle, title: Option<String>, launch: Launch, wait: bool) -> IdeResult<()> {
|
||||
if wait {
|
||||
return create_window_for(app, title, launch, false).map(|_| ());
|
||||
}
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = create_window_with(&app, project, files, false) {
|
||||
if let Err(e) = create_window_for(&app, title, launch, false) {
|
||||
tracing::warn!("cannot open window: {}", e.message);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How `open_project_window` satisfied the request.
|
||||
@@ -819,7 +838,7 @@ pub fn open_file_in_new_window(app: &AppHandle, project: Option<PathBuf>, file:
|
||||
}
|
||||
|
||||
/// Loose local `files` asked for by window `caller` (File | Open File, a drop on the Welcome
|
||||
/// screen), routed like [`open_paths`]; returns the files `caller` opens itself.
|
||||
/// screen), routed by [`plan_open`]; returns the files `caller` opens itself.
|
||||
pub fn open_files_window(app: &AppHandle, caller: &str, files: Vec<PathBuf>) -> Vec<ide_core::IpcPath> {
|
||||
let files = files.into_iter().map(|f| dunce::canonicalize(&f).unwrap_or(f)).collect();
|
||||
let (steps, here) = plan_open_files(caller, files, &window_holds(app, Some(caller)));
|
||||
@@ -900,7 +919,7 @@ fn pending_holds(launch: Option<&Launch>) -> Holds {
|
||||
match launch {
|
||||
Some(Launch { project: Some(root), .. }) => Holds::Project(root.to_path_buf()),
|
||||
Some(Launch { remote: Some(remote), .. }) => Holds::of_remote(remote),
|
||||
Some(l) if !l.files.is_empty() => Holds::LightEdit,
|
||||
Some(l) if !l.files.is_empty() || l.diff.is_some() => Holds::LightEdit,
|
||||
_ => Holds::Nothing,
|
||||
}
|
||||
}
|
||||
@@ -1048,14 +1067,14 @@ fn resolve(paths: Vec<PathBuf>) -> Vec<(PathBuf, bool)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Opens `path` from the command line / a second instance; see [`open_paths`].
|
||||
pub fn open_path(app: &AppHandle, path: &Path) {
|
||||
open_paths(app, vec![path.to_path_buf()]);
|
||||
/// Opens paths from Finder's Open With or the Dock:
|
||||
/// see [`plan_open`]; the focused window is the preferred free one.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn open_paths(app: &AppHandle, paths: Vec<PathBuf>) {
|
||||
open_paths_at(app, paths, &[]);
|
||||
}
|
||||
|
||||
/// Opens paths from outside the app (a second instance, Finder's Open With, the Dock):
|
||||
/// see [`plan_open`]; the focused window is the preferred free one.
|
||||
pub fn open_paths(app: &AppHandle, paths: Vec<PathBuf>) {
|
||||
fn open_paths_at(app: &AppHandle, paths: Vec<PathBuf>, positions: &[FilePosition]) {
|
||||
let (existing, missing): (Vec<_>, Vec<_>) = paths.into_iter().partition(|path| path.exists());
|
||||
if !missing.is_empty() {
|
||||
let paths = missing.iter().map(|path| path.display().to_string()).collect::<Vec<_>>().join("\n");
|
||||
@@ -1066,7 +1085,11 @@ pub fn open_paths(app: &AppHandle, paths: Vec<PathBuf>) {
|
||||
});
|
||||
}
|
||||
let preferred = target_window(app).map(|w| w.label().to_owned());
|
||||
run_open(app, plan_open(resolve(existing), &window_holds(app, preferred.as_deref())));
|
||||
if let Err(error) =
|
||||
run_open_at(app, plan_open(resolve(existing), &window_holds(app, preferred.as_deref())), positions, false)
|
||||
{
|
||||
tracing::warn!("cannot open window: {}", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// What each window holds, `preferred` first.
|
||||
@@ -1079,13 +1102,25 @@ fn window_holds(app: &AppHandle, preferred: Option<&str>) -> Vec<(String, Holds)
|
||||
labels.into_iter().map(|l| (l.clone(), holds(app, &l))).collect()
|
||||
}
|
||||
|
||||
/// [`open_paths`] while the app is starting, before the `main` window exists: it takes them as its launch.
|
||||
/// Opens external paths while the app is starting, before `main` exists: it takes them as its launch.
|
||||
pub fn open_paths_at_launch(app: &AppHandle, paths: Vec<PathBuf>) {
|
||||
let main = pending_holds(app.state::<WindowsState>().pending(MAIN).as_ref());
|
||||
run_open(app, plan_open(resolve(paths), &[(MAIN.to_owned(), main)]));
|
||||
}
|
||||
|
||||
fn run_open(app: &AppHandle, steps: Vec<OpenStep>) {
|
||||
if let Err(error) = run_open_at(app, steps, &[], false) {
|
||||
tracing::warn!("cannot open window: {}", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/// CLI workers wait for new windows so the next request can route to them.
|
||||
pub(crate) fn run_open_at(
|
||||
app: &AppHandle,
|
||||
steps: Vec<OpenStep>,
|
||||
positions: &[FilePosition],
|
||||
wait_for_window: bool,
|
||||
) -> IdeResult<()> {
|
||||
let windows = app.state::<WindowsState>();
|
||||
for step in steps {
|
||||
match step {
|
||||
@@ -1095,30 +1130,32 @@ fn run_open(app: &AppHandle, steps: Vec<OpenStep>) {
|
||||
}
|
||||
}
|
||||
OpenStep::Files(label, files) => match app.get_webview_window(&label) {
|
||||
Some(w) => send_files(app, &w, files),
|
||||
None => windows.add_files(&label, files),
|
||||
Some(w) => send_files_at(app, &w, files, positions),
|
||||
None => windows.add_files_at(&label, files, positions),
|
||||
},
|
||||
OpenStep::Launch(label, launch) => {
|
||||
OpenStep::Launch(label, mut launch) => {
|
||||
launch.add_positions(positions);
|
||||
windows.set_launch(&label, launch);
|
||||
if let Some(w) = app.get_webview_window(&label) {
|
||||
let _ = app.emit_to(&label, LAUNCH_EVENT, ());
|
||||
focus(&w);
|
||||
}
|
||||
}
|
||||
OpenStep::New(project, files) => spawn_window_with(app, project, files),
|
||||
OpenStep::New(project, files) => {
|
||||
let title = project.as_deref().or(files.first().map(PathBuf::as_path)).and_then(Path::file_name);
|
||||
let title = title.map(|n| n.to_string_lossy().into_owned());
|
||||
let mut launch = path_launch_of(project, files);
|
||||
launch.add_positions(positions);
|
||||
open_launch_window(app, title, launch, wait_for_window)?;
|
||||
}
|
||||
OpenStep::NewRemote(remote) => {
|
||||
let app = app.clone();
|
||||
// Off the event-loop thread, like `spawn_window`.
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let title = remote.path.trim_end_matches('/').rsplit('/').next().map(str::to_owned);
|
||||
let launch = Launch { remote: Some(remote), ..Default::default() };
|
||||
if let Err(e) = create_window_for(&app, title, launch, false) {
|
||||
tracing::warn!("cannot open window: {}", e.message);
|
||||
}
|
||||
});
|
||||
let title = remote.path.trim_end_matches('/').rsplit('/').next().map(str::to_owned);
|
||||
let launch = Launch { remote: Some(remote), ..Default::default() };
|
||||
open_launch_window(app, title, launch, wait_for_window)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Local files and folders of the URLs macOS asks the app to open (Finder's Open With, the Dock).
|
||||
@@ -1147,6 +1184,12 @@ fn launch_for(path: PathBuf, is_dir: bool) -> Launch {
|
||||
|
||||
/// Second instance launched: focus this one and open its path argument.
|
||||
pub fn on_second_instance(app: &AppHandle, args: Vec<String>, cwd: String) {
|
||||
if app.state::<WindowsState>().cli.lock().push(args, cwd) {
|
||||
drain_cli(app);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_cli(app: &AppHandle, args: Vec<String>, cwd: String) {
|
||||
if args.iter().any(|arg| arg == crate::jump_list::OPEN_RECENT_ARG)
|
||||
&& let Some(path) = cli_path(&args, Path::new(&cwd)).filter(|path| path.exists())
|
||||
{
|
||||
@@ -1167,12 +1210,21 @@ pub fn on_second_instance(app: &AppHandle, args: Vec<String>, cwd: String) {
|
||||
}
|
||||
}
|
||||
match cli_launch(&args, Path::new(&cwd)) {
|
||||
Some(CliLaunch::Diff(diff)) => open_diff_files(app, diff),
|
||||
Some(CliLaunch::Path(path)) => {
|
||||
remember_cli_position(&app.state::<WindowsState>(), &args, &path);
|
||||
open_path(app, &path)
|
||||
Ok(Some(Launch { diff: Some(diff), .. })) => {
|
||||
if let Err(error) = open_diff_files_at(app, *diff, true) {
|
||||
report_cli_error(app, error);
|
||||
}
|
||||
}
|
||||
None => focus_any(app),
|
||||
Ok(Some(launch)) => {
|
||||
let preferred = target_window(app).map(|w| w.label().to_owned());
|
||||
let positions = launch.positions.clone();
|
||||
let plan = plan_cli_launch(launch, &window_holds(app, preferred.as_deref()));
|
||||
if let Err(error) = run_open_at(app, plan, &positions, true) {
|
||||
report_cli_error(app, error);
|
||||
}
|
||||
}
|
||||
Ok(None) => focus_any(app),
|
||||
Err(error) => report_cli_error(app, error),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1185,48 +1237,110 @@ fn focus_any(app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens what the `openidea` command asked for, like [`open_paths`]; files open at their
|
||||
/// 1-based line and column. Without targets, focuses a window.
|
||||
/// Opens the installed command's targets, carrying each file's position with its launch.
|
||||
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);
|
||||
let positions = target_positions(&targets);
|
||||
open_paths_at(app, targets.into_iter().map(|t| t.path).collect(), &positions);
|
||||
}
|
||||
|
||||
/// `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 target_positions(targets: &[ide_cli::Target]) -> Vec<FilePosition> {
|
||||
targets
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
let line = t.line?;
|
||||
if t.path.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let path = dunce::canonicalize(&t.path).unwrap_or_else(|_| t.path.clone());
|
||||
Some(FilePosition {
|
||||
path: path.into(),
|
||||
line: line.saturating_sub(1),
|
||||
column: t.column.map_or(0, |c| c.saturating_sub(1)),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Opens the command's local diff pair in a local project window, never an auxiliary or remote window.
|
||||
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())
|
||||
.or_else(|| project_window_labels(app).into_iter().find_map(|label| app.get_webview_window(&label)));
|
||||
if let Err(error) = open_diff_files_at(app, diff, false) {
|
||||
tracing::warn!("cannot open window: {}", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn open_diff_files_at(app: &AppHandle, diff: DiffFilesLaunch, wait_for_window: bool) -> IdeResult<()> {
|
||||
let local = |window: &WebviewWindow| {
|
||||
auxiliary_parent(window.label()).is_none()
|
||||
&& matches!(holds(app, window.label()), Holds::Nothing | Holds::Project(_) | Holds::LightEdit)
|
||||
};
|
||||
let target = target_window(app).filter(local).or_else(|| {
|
||||
project_window_labels(app).into_iter().filter_map(|label| app.get_webview_window(&label)).find(local)
|
||||
});
|
||||
if let Some(window) = target {
|
||||
app.state::<WindowsState>().set_launch(window.label(), launch);
|
||||
app.state::<WindowsState>().add_diff(window.label(), diff);
|
||||
let _ = app.emit_to(window.label(), LAUNCH_EVENT, ());
|
||||
focus(&window);
|
||||
Ok(())
|
||||
} else {
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = create_window_for(&app, None, launch, false) {
|
||||
tracing::warn!("cannot open window: {}", e.message);
|
||||
}
|
||||
});
|
||||
let launch = Launch { diff: Some(Box::new(diff)), ..Default::default() };
|
||||
open_launch_window(app, None, launch, wait_for_window)
|
||||
}
|
||||
}
|
||||
|
||||
/// An explicit project owns the requested files, including sources outside its root.
|
||||
fn plan_cli_launch(launch: Launch, windows: &[(String, Holds)]) -> Vec<OpenStep> {
|
||||
let files: Vec<_> = launch.files.into_iter().map(|f| f.0).collect();
|
||||
let Some(project) = launch.project else {
|
||||
if let Some(remote) = launch.remote {
|
||||
return vec![match windows.iter().find(|(_, h)| h.is_wsl_folder(&remote)) {
|
||||
Some((label, _)) => OpenStep::Focus(label.clone()),
|
||||
None => match windows.iter().find(|(_, h)| *h == Holds::Nothing) {
|
||||
Some((label, _)) => {
|
||||
OpenStep::Launch(label.clone(), Launch { remote: Some(remote), ..Default::default() })
|
||||
}
|
||||
None => OpenStep::NewRemote(remote),
|
||||
},
|
||||
}];
|
||||
}
|
||||
return plan_open(files.into_iter().map(|f| (f, false)).collect(), windows);
|
||||
};
|
||||
plan_open(vec![(project.0, true)], windows)
|
||||
.into_iter()
|
||||
.map(|step| match step {
|
||||
OpenStep::Focus(label) if !files.is_empty() => OpenStep::Files(label, files.clone()),
|
||||
OpenStep::Launch(label, mut pending) => {
|
||||
pending.files.extend(files.iter().cloned().map(Into::into));
|
||||
OpenStep::Launch(label, pending)
|
||||
}
|
||||
OpenStep::New(root, _) => OpenStep::New(root, files.clone()),
|
||||
step => step,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drain requests received while plugins and the first window were being initialized.
|
||||
pub fn ready_for_cli(app: &AppHandle) {
|
||||
if app.state::<WindowsState>().cli.lock().ready() {
|
||||
drain_cli(app);
|
||||
}
|
||||
}
|
||||
|
||||
fn drain_cli(app: &AppHandle) {
|
||||
let app = app.clone();
|
||||
// Creating a webview must not block the event-loop thread. Finish each creation
|
||||
// before routing the next request so a not-yet-visible window can be reused.
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
loop {
|
||||
let request = app.state::<WindowsState>().cli.lock().next();
|
||||
let Some((args, cwd)) = request else { break };
|
||||
dispatch_cli(&app, args, cwd);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn cycle(app: &AppHandle, current: &str, forward: bool) {
|
||||
let windows = app.webview_windows();
|
||||
let sorted = project_window_labels(app);
|
||||
@@ -1645,46 +1759,89 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_launch_parses_the_diff_subcommand() {
|
||||
fn cli_launch_keeps_diff_pairs_and_single_file_fallback() {
|
||||
let cwd = Path::new("/work");
|
||||
// Two paths: a diff pair; relative paths resolve against the cwd.
|
||||
for args in
|
||||
[vec!["openidea", "diff", "a.txt", "b.txt"], vec!["openidea", "diff", "--line", "3", "a.txt", "b.txt"]]
|
||||
{
|
||||
let launch = cli_launch(&s(&args), cwd).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
launch.diff,
|
||||
Some(Box::new(DiffFilesLaunch { left: "/work/a.txt".into(), right: "/work/b.txt".into() }))
|
||||
);
|
||||
assert!(launch.files.is_empty());
|
||||
}
|
||||
let launch = cli_launch(&s(&["openidea", "diff", "/abs/my a.txt", "my b.txt"]), cwd).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
cli_launch(&s(&["openidea", "diff", "a.txt", "b.txt"]), cwd),
|
||||
Some(CliLaunch::Diff(DiffFilesLaunch { left: "/work/a.txt".into(), right: "/work/b.txt".into() }))
|
||||
);
|
||||
// Absolute paths stay absolute; a path with spaces is one argument (the shell unquotes it
|
||||
// before the arguments reach us).
|
||||
assert_eq!(
|
||||
cli_launch(&s(&["openidea", "diff", "/abs/my a.txt", "my b.txt"]), cwd),
|
||||
Some(CliLaunch::Diff(DiffFilesLaunch { left: "/abs/my a.txt".into(), right: "/work/my b.txt".into() }))
|
||||
);
|
||||
// Flags and their values are not file paths.
|
||||
assert_eq!(
|
||||
cli_launch(&s(&["openidea", "diff", "--line", "3", "a.txt", "b.txt"]), cwd),
|
||||
Some(CliLaunch::Diff(DiffFilesLaunch { left: "/work/a.txt".into(), right: "/work/b.txt".into() }))
|
||||
);
|
||||
// One file is no pair: it opens like any path.
|
||||
assert_eq!(
|
||||
cli_launch(&s(&["openidea", "diff", "a.txt"]), cwd),
|
||||
Some(CliLaunch::Path(PathBuf::from("/work/a.txt")))
|
||||
);
|
||||
// No files at all: like starting without arguments.
|
||||
assert_eq!(cli_launch(&s(&["openidea", "diff"]), cwd), None);
|
||||
// Only a leading `diff` is the subcommand; the rest still opens a path.
|
||||
assert_eq!(
|
||||
cli_launch(&s(&["openidea", "proj", "diff", "a.txt"]), cwd),
|
||||
Some(CliLaunch::Path(PathBuf::from("/work/proj")))
|
||||
launch.diff,
|
||||
Some(Box::new(DiffFilesLaunch { left: "/abs/my a.txt".into(), right: "/work/my b.txt".into() }))
|
||||
);
|
||||
let launch = cli_launch(&s(&["openidea", "diff", "a.txt", "--line=3"]), cwd).unwrap().unwrap();
|
||||
assert_eq!(launch.files, [ide_core::IpcPath::from("/work/a.txt")]);
|
||||
assert_eq!(launch.positions, [FilePosition { path: "/work/a.txt".into(), line: 2, column: 0 }]);
|
||||
assert!(launch.diff.is_none());
|
||||
assert_eq!(cli_launch(&s(&["openidea", "diff"]), cwd).unwrap(), None);
|
||||
assert_eq!(cli_path(&s(&["openidea", "proj", "diff", "a.txt"]), cwd), Some(PathBuf::from("/work/proj")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_position_is_one_based_on_the_command_line() {
|
||||
assert_eq!(cli_position(&s(&["openidea", "--line", "120", "--column", "8", "f"])), Some((119, 7)));
|
||||
assert_eq!(cli_position(&s(&["openidea", "--line=5", "f"])), Some((4, 0)));
|
||||
assert_eq!(cli_position(&s(&["openidea", "--line", "0", "f"])), Some((0, 0)));
|
||||
assert_eq!(cli_position(&s(&["openidea", "--column", "8", "f"])), None);
|
||||
assert_eq!(cli_position(&s(&["openidea", "--line", "x", "f"])), None);
|
||||
assert_eq!(cli_position(&s(&["openidea", "f"])), None);
|
||||
fn installed_command_positions_follow_canonical_file_paths() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(temp.path()).unwrap();
|
||||
let first = root.join("first.cs");
|
||||
let second = root.join("second.cs");
|
||||
std::fs::write(&first, "").unwrap();
|
||||
std::fs::write(&second, "").unwrap();
|
||||
let targets = vec![
|
||||
ide_cli::Target { path: root.join("./first.cs"), line: Some(12), column: Some(5) },
|
||||
ide_cli::Target { path: second.clone(), line: Some(3), column: None },
|
||||
ide_cli::Target { path: root, line: Some(9), column: Some(2) },
|
||||
ide_cli::Target { path: first.clone(), line: None, column: None },
|
||||
];
|
||||
let positions = target_positions(&targets);
|
||||
assert_eq!(
|
||||
positions,
|
||||
[
|
||||
FilePosition { path: first.clone().into(), line: 11, column: 4 },
|
||||
FilePosition { path: second.clone().into(), line: 2, column: 0 },
|
||||
]
|
||||
);
|
||||
let state = WindowsState::default();
|
||||
state.add_files_at("main", vec![first], &positions);
|
||||
state.add_files_at("project-1", vec![second], &positions);
|
||||
assert_eq!(state.take_launch("main").unwrap().positions, positions[..1]);
|
||||
assert_eq!(state.take_launch("project-1").unwrap().positions, positions[1..]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_and_file_requests_keep_each_others_pending_data() {
|
||||
let state = WindowsState::default();
|
||||
let diff = DiffFilesLaunch { left: "/a.txt".into(), right: "/b.txt".into() };
|
||||
let position = FilePosition { path: "/project/a.cs".into(), line: 8, column: 2 };
|
||||
for diff_first in [true, false] {
|
||||
state.set_launch("main", path_launch_of(Some(p("/project")), vec![]));
|
||||
if diff_first {
|
||||
state.add_diff("main", diff.clone());
|
||||
}
|
||||
state.add_files_at("main", vec![p("/project/a.cs")], std::slice::from_ref(&position));
|
||||
if !diff_first {
|
||||
state.add_diff("main", diff.clone());
|
||||
}
|
||||
let launch = state.take_launch("main").unwrap();
|
||||
assert_eq!(launch.project, Some("/project".into()));
|
||||
assert_eq!(launch.files, [ide_core::IpcPath::from("/project/a.cs")]);
|
||||
assert_eq!(launch.positions, std::slice::from_ref(&position));
|
||||
assert_eq!(launch.diff, Some(Box::new(diff.clone())));
|
||||
assert_eq!(launch.diff_before_files, diff_first);
|
||||
state.complete_launch("main");
|
||||
}
|
||||
state.add_diff("main", diff.clone());
|
||||
state.add_files_at("main", vec![], &[]);
|
||||
assert!(!state.pending("main").unwrap().diff_before_files);
|
||||
state.add_files_at("main", vec![p("/project/a.cs")], &[]);
|
||||
assert!(state.pending("main").unwrap().diff_before_files);
|
||||
state.add_diff("main", diff);
|
||||
assert!(!state.take_launch("main").unwrap().diff_before_files);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1692,16 +1849,100 @@ mod tests {
|
||||
let st = WindowsState::default();
|
||||
let file = std::env::current_exe().unwrap();
|
||||
let other = std::env::temp_dir().join("openidea-not-opened.txt");
|
||||
st.set_position(&file, (11, 4));
|
||||
st.set_position(&other, (1, 0));
|
||||
st.add_files("main", vec![file.clone()]);
|
||||
st.add_files_at(
|
||||
"main",
|
||||
vec![file.clone()],
|
||||
&[
|
||||
FilePosition { path: file.clone().into(), line: 11, column: 4 },
|
||||
FilePosition { path: other.into(), line: 1, column: 0 },
|
||||
],
|
||||
);
|
||||
let launch = st.take_launch("main").unwrap();
|
||||
assert_eq!(launch.positions, vec![FilePosition { path: file.into(), line: 11, column: 4 }]);
|
||||
// Taken once: the next launch of that file opens it where it was.
|
||||
st.add_files("main", vec![std::env::current_exe().unwrap()]);
|
||||
st.add_files_at("main", vec![std::env::current_exe().unwrap()], &[]);
|
||||
assert!(st.take_launch("main").unwrap().positions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn later_positions_replace_an_unconsumed_cold_launch_position() {
|
||||
let st = WindowsState::default();
|
||||
let file = std::env::current_exe().unwrap();
|
||||
st.set_launch(
|
||||
"main",
|
||||
Launch {
|
||||
files: vec![file.clone().into()],
|
||||
positions: vec![FilePosition { path: file.clone().into(), line: 1, column: 0 }],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
st.add_files_at("main", vec![file.clone()], &[FilePosition { path: file.clone().into(), line: 9, column: 3 }]);
|
||||
let launch = st.take_launch("main").unwrap();
|
||||
assert_eq!(launch.positions, vec![FilePosition { path: file.into(), line: 9, column: 3 }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_keep_independent_positions_for_the_same_external_file() {
|
||||
let st = WindowsState::default();
|
||||
let file = std::env::temp_dir().join("shared-package/Type.cs");
|
||||
let first = FilePosition { path: file.clone().into(), line: 11, column: 4 };
|
||||
let second = FilePosition { path: file.clone().into(), line: 33, column: 8 };
|
||||
st.set_launch(
|
||||
"main",
|
||||
Launch {
|
||||
project: Some("/project-a".into()),
|
||||
files: vec![file.clone().into()],
|
||||
positions: vec![first.clone()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
st.set_launch("project-1", Launch { project: Some("/project-b".into()), ..Default::default() });
|
||||
st.add_files_at("project-1", vec![file.clone()], std::slice::from_ref(&second));
|
||||
let launch = st.take_launch("main").unwrap();
|
||||
assert_eq!(launch.project, Some("/project-a".into()));
|
||||
assert_eq!(launch.positions, vec![first]);
|
||||
let launch = st.take_launch("project-1").unwrap();
|
||||
assert_eq!(launch.project, Some("/project-b".into()));
|
||||
assert_eq!(launch.positions, vec![second]);
|
||||
st.add_files_at("main", vec![file], &[]);
|
||||
assert!(st.take_launch("main").unwrap().positions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_open_has_an_explicit_project_and_validates_paths() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(temp.path()).unwrap();
|
||||
let project = root.join("Game with spaces");
|
||||
std::fs::create_dir_all(project.join("Assets")).unwrap();
|
||||
let file = project.join("Assets/角色.cs");
|
||||
std::fs::write(&file, "class Player {}\n").unwrap();
|
||||
let args = s(&["openidea", project.to_str().unwrap(), "--file", "Assets/角色.cs", "--line=12", "--column=5"]);
|
||||
let launch = cli_launch(&args, &root).unwrap().unwrap();
|
||||
assert_eq!(launch.project, Some(project.clone().into()));
|
||||
assert_eq!(launch.files, [ide_core::IpcPath::from(file.clone())]);
|
||||
assert_eq!(launch.positions, [FilePosition { path: file.into(), line: 11, column: 4 }]);
|
||||
assert!(cli_launch(&s(&["openidea", "--file", project.to_str().unwrap()]), &root).is_err());
|
||||
assert!(cli_launch(&s(&["openidea", "--file", "missing.cs"]), &root).is_err());
|
||||
assert!(cli_launch(&s(&["openidea", "--file", "--line", "2"]), &root).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_project_routes_external_files_to_the_requested_window() {
|
||||
let project = PathBuf::from("/game");
|
||||
let file = PathBuf::from("/package-cache/Package.cs");
|
||||
let launch = || path_launch_of(Some(project.clone()), vec![file.clone()]);
|
||||
let windows = vec![
|
||||
("main".into(), Holds::Project(PathBuf::from("/package-cache"))),
|
||||
("project-1".into(), Holds::Project(project.clone())),
|
||||
];
|
||||
assert_eq!(plan_cli_launch(launch(), &windows), vec![OpenStep::Files("project-1".into(), vec![file.clone()])]);
|
||||
assert_eq!(plan_cli_launch(launch(), &[]), vec![OpenStep::New(Some(project.clone()), vec![file.clone()])]);
|
||||
assert_eq!(
|
||||
plan_cli_launch(launch(), &[("main".into(), Holds::Nothing)]),
|
||||
vec![OpenStep::Launch("main".into(), launch())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_line_folders_open_as_projects_and_files_in_light_edit() {
|
||||
let dir = std::env::temp_dir();
|
||||
@@ -1758,7 +1999,7 @@ mod tests {
|
||||
);
|
||||
let st = WindowsState::default();
|
||||
st.set_launch("main", cli);
|
||||
st.add_files("main", vec![p("/x/b.txt")]);
|
||||
st.add_files_at("main", vec![p("/x/b.txt")], &[]);
|
||||
assert_eq!(st.take_launch("main").unwrap().files, [p("/x/a.txt"), p("/x/b.txt")]);
|
||||
}
|
||||
|
||||
@@ -1907,6 +2148,55 @@ mod tests {
|
||||
assert_eq!(st.take_launch("project-1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_taken_launch_owns_its_window_until_completion() {
|
||||
for launch in [
|
||||
path_launch_of(Some(p("/project")), vec![p("/project/a.cs")]),
|
||||
path_launch_of(None, vec![p("/loose/a.cs")]),
|
||||
Launch {
|
||||
diff: Some(Box::new(DiffFilesLaunch { left: "/a.txt".into(), right: "/b.txt".into() })),
|
||||
..Default::default()
|
||||
},
|
||||
Launch {
|
||||
remote: Some(RemoteLaunch {
|
||||
target: RemoteTarget::Wsl { distro: "Ubuntu".into() },
|
||||
path: "/project".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
let st = WindowsState::default();
|
||||
let expected = pending_holds(Some(&launch));
|
||||
st.set_launch("main", launch.clone());
|
||||
assert_eq!(st.take_launch("main"), Some(launch));
|
||||
assert!(st.take_launch("main").is_none());
|
||||
assert_eq!(pending_holds(st.pending("main").as_ref()), expected);
|
||||
let windows = vec![("main".into(), pending_holds(st.pending("main").as_ref()))];
|
||||
assert_eq!(
|
||||
plan_open(vec![(p("/other-project"), true)], &windows),
|
||||
vec![OpenStep::New(Some(p("/other-project")), vec![])]
|
||||
);
|
||||
st.complete_launch("main");
|
||||
assert_eq!(pending_holds(st.pending("main").as_ref()), Holds::Nothing);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completing_a_launch_keeps_later_requests_and_forgetting_clears_both() {
|
||||
let st = WindowsState::default();
|
||||
st.set_launch("main", path_launch_of(Some(p("/project")), vec![]));
|
||||
st.take_launch("main").unwrap();
|
||||
st.add_files_at("main", vec![p("/project/late.cs")], &[]);
|
||||
assert_eq!(pending_holds(st.pending("main").as_ref()), Holds::Project(p("/project")));
|
||||
st.complete_launch("main");
|
||||
let late = st.take_launch("main").unwrap();
|
||||
assert_eq!(late.files, [ide_core::IpcPath::from(p("/project/late.cs"))]);
|
||||
st.add_files_at("main", vec![p("/project/later.cs")], &[]);
|
||||
st.forget("main");
|
||||
assert!(st.pending("main").is_none());
|
||||
assert!(st.take_launch("main").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auxiliary_windows_map_to_their_parent() {
|
||||
assert_eq!(settings_label("project-2"), "settings-project-2");
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! External file-opening arguments, shared by cold and second-instance launches.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Guarded by WindowsState's mutex. One worker drains requests in arrival order.
|
||||
#[derive(Default)]
|
||||
pub struct Queue {
|
||||
requests: VecDeque<(Vec<String>, String)>,
|
||||
ready: bool,
|
||||
running: bool,
|
||||
}
|
||||
|
||||
impl Queue {
|
||||
pub fn push(&mut self, args: Vec<String>, cwd: String) -> bool {
|
||||
self.requests.push_back((args, cwd));
|
||||
self.start()
|
||||
}
|
||||
|
||||
pub fn ready(&mut self) -> bool {
|
||||
self.ready = true;
|
||||
self.start()
|
||||
}
|
||||
|
||||
fn start(&mut self) -> bool {
|
||||
if self.ready && !self.running && !self.requests.is_empty() {
|
||||
self.running = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&mut self) -> Option<(Vec<String>, String)> {
|
||||
let request = self.requests.pop_front();
|
||||
if request.is_none() {
|
||||
self.running = false;
|
||||
}
|
||||
request
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct Request {
|
||||
pub path: Option<PathBuf>,
|
||||
pub file: Option<PathBuf>,
|
||||
pub position: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
/// Preserve the application's `diff` subcommand; a non-leading `diff` remains a path.
|
||||
pub fn diff_paths(args: &[String], cwd: &Path) -> Option<Vec<PathBuf>> {
|
||||
if args.get(1).map(String::as_str) != Some("diff") {
|
||||
return None;
|
||||
}
|
||||
let mut paths = Vec::new();
|
||||
let mut args = args.iter().skip(2);
|
||||
let mut positional = false;
|
||||
while let Some(arg) = args.next() {
|
||||
if !positional && arg == "--" {
|
||||
positional = true;
|
||||
} else if !positional && matches!(arg.as_str(), "--line" | "--column") {
|
||||
args.next();
|
||||
} else if !arg.is_empty() && (positional || !arg.starts_with('-')) {
|
||||
paths.push(absolute(cwd, Path::new(arg)));
|
||||
}
|
||||
}
|
||||
Some(paths)
|
||||
}
|
||||
|
||||
pub fn parse(args: &[String], cwd: &Path) -> Result<Request, String> {
|
||||
let (mut path, mut file, mut line, mut column) = (None, None, None, None);
|
||||
let mut positional = false;
|
||||
let mut args = args.iter().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if !positional && arg == "--" {
|
||||
positional = true;
|
||||
continue;
|
||||
}
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg.as_str(), None), |(k, v)| (k, Some(v)));
|
||||
if !positional && matches!(flag, "--file" | "--line" | "--column") {
|
||||
let value = inline
|
||||
.or_else(|| args.next().map(String::as_str))
|
||||
.filter(|s| !s.is_empty() && !s.starts_with("--"))
|
||||
.ok_or_else(|| format!("{flag} requires a value"))?;
|
||||
match flag {
|
||||
"--file" => {
|
||||
if file.replace(PathBuf::from(value)).is_some() {
|
||||
return Err("--file may only be supplied once".into());
|
||||
}
|
||||
}
|
||||
"--line" => line = coordinate(flag, value)?,
|
||||
"--column" => column = coordinate(flag, value)?,
|
||||
_ => unreachable!(),
|
||||
}
|
||||
} else if !arg.is_empty() && (positional || (!arg.starts_with('-') && !arg.starts_with("psn_"))) {
|
||||
// Preserve ordinary CLI opening: the first positional path is the target.
|
||||
if path.is_none() {
|
||||
path = Some(absolute(cwd, Path::new(arg)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let base = path.as_deref().filter(|p| p.is_dir()).unwrap_or(cwd);
|
||||
let file = file.map(|p| absolute(base, &p));
|
||||
Ok(Request { path, file, position: line.map(|l| (l - 1, column.unwrap_or(1) - 1)) })
|
||||
}
|
||||
|
||||
fn absolute(base: &Path, path: &Path) -> PathBuf {
|
||||
if path.is_absolute() { path.to_owned() } else { base.join(path) }
|
||||
}
|
||||
|
||||
fn coordinate(flag: &str, value: &str) -> Result<Option<u32>, String> {
|
||||
// Unity supplies 0/-1 when an asset has no source position.
|
||||
match value.parse::<i64>() {
|
||||
Ok(-1 | 0) => Ok(None),
|
||||
Ok(n @ 1..=4294967295) => Ok(Some(n as u32)),
|
||||
_ => Err(format!("{flag} expects a 1-based position (or 0/-1 for an unknown position)")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn request(args: &[&str]) -> Result<Request, String> {
|
||||
parse(
|
||||
&std::iter::once("openidea").chain(args.iter().copied()).map(str::to_owned).collect::<Vec<_>>(),
|
||||
Path::new("/work"),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_paths_keep_flags_out_and_honor_end_of_options() {
|
||||
let args = |tail: &[&str]| {
|
||||
std::iter::once("openidea").chain(tail.iter().copied()).map(str::to_owned).collect::<Vec<_>>()
|
||||
};
|
||||
let cwd = Path::new("/work");
|
||||
assert_eq!(
|
||||
diff_paths(&args(&["diff", "--line", "12", "--column=3", "a.txt", "b.txt"]), cwd),
|
||||
Some(vec![cwd.join("a.txt"), cwd.join("b.txt")])
|
||||
);
|
||||
assert_eq!(
|
||||
diff_paths(&args(&["diff", "--", "-a.txt", "-b.txt"]), cwd),
|
||||
Some(vec![cwd.join("-a.txt"), cwd.join("-b.txt")])
|
||||
);
|
||||
assert_eq!(diff_paths(&args(&["--", "diff"]), cwd), None);
|
||||
assert_eq!(diff_paths(&args(&["project", "diff"]), cwd), None);
|
||||
assert_eq!(diff_paths(&args(&["diff"]), cwd), Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_launches_stay_in_order_across_startup_and_later_arrivals() {
|
||||
let mut queue = Queue::default();
|
||||
assert!(!queue.push(vec!["first".into()], "cwd1".into()));
|
||||
assert!(!queue.push(vec!["second".into()], "cwd2".into()));
|
||||
assert!(queue.ready());
|
||||
assert!(!queue.push(vec!["third".into()], "cwd3".into()));
|
||||
assert_eq!(queue.next(), Some((vec!["first".into()], "cwd1".into())));
|
||||
assert_eq!(queue.next(), Some((vec!["second".into()], "cwd2".into())));
|
||||
assert_eq!(queue.next(), Some((vec!["third".into()], "cwd3".into())));
|
||||
assert_eq!(queue.next(), None);
|
||||
assert!(queue.push(vec!["later".into()], "cwd4".into()));
|
||||
assert!(!queue.ready());
|
||||
assert_eq!(queue.next(), Some((vec!["later".into()], "cwd4".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_files_positions_and_unicode() {
|
||||
for args in [
|
||||
vec!["--file", "Game Scripts/角色.cs", "--line", "42", "--column", "7"],
|
||||
vec!["--column=7", "--line=42", "--file=Game Scripts/角色.cs"],
|
||||
] {
|
||||
assert_eq!(
|
||||
request(&args).unwrap(),
|
||||
Request {
|
||||
path: None,
|
||||
file: Some(PathBuf::from("/work/Game Scripts/角色.cs")),
|
||||
position: Some((41, 6)),
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(request(&["--line=5", "file.cs"]).unwrap().position, Some((4, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_positions_and_malformed_flags() {
|
||||
for value in ["0", "-1"] {
|
||||
assert_eq!(request(&["--file", "a.cs", "--line", value, "--column", value]).unwrap().position, None);
|
||||
assert_eq!(request(&["--file", "a.cs", "--line", "5", "--column", value]).unwrap().position, Some((4, 0)));
|
||||
}
|
||||
for args in [
|
||||
vec!["--file"],
|
||||
vec!["--file="],
|
||||
vec!["--file", "--line", "2"],
|
||||
vec!["--line", "bad", "a.cs"],
|
||||
vec!["--line=4294967296", "a.cs"],
|
||||
vec!["--column=-2", "a.cs"],
|
||||
vec!["--file=a.cs", "--file=b.cs"],
|
||||
] {
|
||||
assert!(request(&args).is_err(), "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positional_opening_and_end_of_options() {
|
||||
assert_eq!(request(&["--verbose", "proj"]).unwrap().path, Some(PathBuf::from("/work/proj")));
|
||||
assert_eq!(request(&["--", "-notes.txt"]).unwrap().path, Some(PathBuf::from("/work/-notes.txt")));
|
||||
assert_eq!(request(&["-psn_0_123"]).unwrap(), Request::default());
|
||||
assert_eq!(request(&["--column", "7", "a.cs"]).unwrap().position, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_relative_file_is_independent_of_invocation_directory() {
|
||||
let project = std::env::temp_dir();
|
||||
let args = vec!["openidea".into(), project.to_string_lossy().into_owned(), "--file=Assets/A.cs".into()];
|
||||
let r = parse(&args, Path::new("/unrelated")).unwrap();
|
||||
assert_eq!(r.path, Some(project.clone()));
|
||||
assert_eq!(r.file, Some(project.join("Assets/A.cs")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn windows_absolute_paths_are_not_prefixed() {
|
||||
for file in [r"D:\Game Scripts\角色.cs", r"\\server\share\Game\A.cs", "D:/Game Scripts/A.cs"] {
|
||||
assert_eq!(request(&["--file", file]).unwrap().file, Some(PathBuf::from(file)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Receive external file-open requests when the editor has a higher integrity level.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use tauri::AppHandle;
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
||||
ChangeWindowMessageFilterEx, FindWindowW, GetWindowThreadProcessId, MSGFLT_ALLOW, WM_COPYDATA,
|
||||
};
|
||||
|
||||
/// Called after the single-instance plugin creates its receiver and app state is ready.
|
||||
pub fn allow_external_launches(app: &AppHandle) -> IdeResult<()> {
|
||||
// tauri-plugin-single-instance names its receiver using the configured identifier,
|
||||
// including the worktree suffix in debug builds. Never change a webview's filter.
|
||||
let identifier = &app.config().identifier;
|
||||
let class: Vec<u16> = format!("{identifier}-sic").encode_utf16().chain(Some(0)).collect();
|
||||
let title: Vec<u16> = format!("{identifier}-siw").encode_utf16().chain(Some(0)).collect();
|
||||
// SAFETY: both strings are NUL-terminated. Check ownership before allowing the
|
||||
// plugin's argument-transfer message on this one window.
|
||||
unsafe {
|
||||
let receiver = FindWindowW(class.as_ptr(), title.as_ptr());
|
||||
if receiver.is_null() {
|
||||
return Err(IdeError::unavailable("Cannot find the single-instance receiver for external editor requests"));
|
||||
}
|
||||
let mut owner = 0;
|
||||
if GetWindowThreadProcessId(receiver, &mut owner) == 0 {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"Cannot identify the single-instance receiver: {}",
|
||||
std::io::Error::last_os_error()
|
||||
)));
|
||||
}
|
||||
if owner != std::process::id() {
|
||||
return Err(IdeError::unavailable("The single-instance receiver belongs to another process"));
|
||||
}
|
||||
if ChangeWindowMessageFilterEx(receiver, WM_COPYDATA, MSGFLT_ALLOW, std::ptr::null_mut()) == 0 {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"Cannot enable external editor requests: {}. Run Unity and OpenIDEA with the same permissions.",
|
||||
std::io::Error::last_os_error()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача