From 2dd57d095f33c3e7f566ebfcb0b66aba250d766d Mon Sep 17 00:00:00 2001 From: Hacbit <107751081+hacbit@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:03:37 +0800 Subject: [PATCH] feat(unity): open scripts from external editor arguments (#2296) * feat(app): support Unity external editor navigation * docs(unity): explain manual external editor setup --- docs/unity-external-editor.md | 42 ++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/window.rs | 8 + src-tauri/src/lib.rs | 27 +- src-tauri/src/windows.rs | 680 +++++++++++++++------ src-tauri/src/windows/cli.rs | 227 +++++++ src-tauri/src/windows/single_instance.rs | 41 ++ src/bindings.ts | 6 + src/features/app/__tests__/windows.test.ts | 266 +++++++- src/features/app/windows.ts | 83 ++- 10 files changed, 1141 insertions(+), 240 deletions(-) create mode 100644 docs/unity-external-editor.md create mode 100644 src-tauri/src/windows/cli.rs create mode 100644 src-tauri/src/windows/single_instance.rs diff --git a/docs/unity-external-editor.md b/docs/unity-external-editor.md new file mode 100644 index 000000000..854deb9c4 --- /dev/null +++ b/docs/unity-external-editor.md @@ -0,0 +1,42 @@ +# Open scripts from Unity + +OpenIDEA can open scripts and Console source locations without a Unity plugin. + +1. In Unity, open **Edit > Preferences > External Tools** (on macOS, + **Unity > Settings/Preferences > External Tools**). +2. Under **External Script Editor**, choose **Browse** and select OpenIDEA's + executable (or its `.app` on macOS). +3. Set **External Script Editor Args** to: + + ```text + $(ProjectPath) --file $(File) --line $(Line) --column $(Column) + ``` + +Unity 2022.3 quotes the expanded project and file paths itself, including paths +with spaces. Do not add another pair of quotes around these placeholders. + +Double-click a script in the Project window or a source location in the Console. +OpenIDEA opens the project and file, or focuses its existing project window, then +places the caret at the supplied location. Other open tabs stay in place. + +The project argument makes cold launches open the Unity project rather than a +standalone file. Without it, `--file` opens in an existing matching project window +or in LightEdit. Relative file paths resolve against the supplied project folder, +or against the launch working directory when no project is supplied. + +For a direct invocation of the application executable, quote paths as required +by your shell. The separately installed `openidea` command has its own syntax: + +```text +/path/to/openidea "/path/to/My Game" --file "/path/to/My Game/Assets/Player.cs" --line 12 --column 5 +``` + +`--file=...`, `--line=...` and `--column=...` also work. Lines and columns are +1-based; Unity's `0` and `-1` mean an unknown position. An omitted or unknown +column uses the start of the line. Without a known line, the file opens without +an explicit caret position. Invalid arguments and missing files report an error. + +On Windows, use the same permissions for Unity and OpenIDEA when possible. +If a running instance does not receive a request, restart both applications with +matching permissions and retry. This integration opens local projects; it does +not generate C# project files or attach a debugger. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8b183cee1..7c33cdfa1 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -447,6 +447,7 @@ pub fn builder() -> tauri_specta::Builder { 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, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 6923ae91b..8d7b703d8 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -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 { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f4a5f7373..80e6712ea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::(); if let Ok(cwd) = std::env::current_dir() { - let args = std::env::args().collect::>(); - 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::>(), &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::()); - 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::().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::().is_some() { + if app.try_state::().is_some() { windows::open_paths(app, paths); } else { early_opens.lock().get_or_insert_default().extend(paths); diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index c8443d313..423952fc3 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -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>, + /// 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, @@ -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>>, launches: Mutex>, - /// Caret positions asked for on the command line, by canonical file path, until a window takes the file. - positions: Mutex>, + /// A taken launch still owns its window until the frontend finishes opening it. + opening: Mutex>, + cli: Mutex, /// Most recently focused window label (menu events go there). last_focused: Mutex>, /// 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 { - 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) { + fn add_files_at(&self, label: &str, files: Vec, 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 { - 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 Option { - 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 { - 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> { + 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 { - 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 ` and `--column ` (1-based, also as `--line=`) 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::().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) { - app.state::().add_files(window.label(), files); + send_files_at(app, window, files, &[]); +} + +fn send_files_at(app: &AppHandle, window: &WebviewWindow, files: Vec, positions: &[FilePosition]) { + app.state::().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) { } fn spawn_window_with(app: &AppHandle, project: Option, files: Vec) { + 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, 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, 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) -> Vec { 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) -> 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) { + 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) { +fn open_paths_at(app: &AppHandle, paths: Vec, 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::>().join("\n"); @@ -1066,7 +1085,11 @@ pub fn open_paths(app: &AppHandle, paths: Vec) { }); } 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) { let main = pending_holds(app.state::().pending(MAIN).as_ref()); run_open(app, plan_open(resolve(paths), &[(MAIN.to_owned(), main)])); } fn run_open(app: &AppHandle, steps: Vec) { + 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, + positions: &[FilePosition], + wait_for_window: bool, +) -> IdeResult<()> { let windows = app.state::(); for step in steps { match step { @@ -1095,30 +1130,32 @@ fn run_open(app: &AppHandle, steps: Vec) { } } 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, cwd: String) { + if app.state::().cli.lock().push(args, cwd) { + drain_cli(app); + } +} + +fn dispatch_cli(app: &AppHandle, args: Vec, 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, 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::(), &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) { if targets.is_empty() { return focus_any(app); } - let windows = app.state::(); - 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 { + 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::().set_launch(window.label(), launch); + app.state::().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 { + 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::().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::().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"); diff --git a/src-tauri/src/windows/cli.rs b/src-tauri/src/windows/cli.rs new file mode 100644 index 000000000..a307e07ca --- /dev/null +++ b/src-tauri/src/windows/cli.rs @@ -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)>, + ready: bool, + running: bool, +} + +impl Queue { + pub fn push(&mut self, args: Vec, 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)> { + 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, + pub file: Option, + 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> { + 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 { + 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, String> { + // Unity supplies 0/-1 when an asset has no source position. + match value.parse::() { + 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 { + parse( + &std::iter::once("openidea").chain(args.iter().copied()).map(str::to_owned).collect::>(), + 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::>() + }; + 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))); + } + } +} diff --git a/src-tauri/src/windows/single_instance.rs b/src-tauri/src/windows/single_instance.rs new file mode 100644 index 000000000..134dfc771 --- /dev/null +++ b/src-tauri/src/windows/single_instance.rs @@ -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 = format!("{identifier}-sic").encode_utf16().chain(Some(0)).collect(); + let title: Vec = 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(()) +} diff --git a/src/bindings.ts b/src/bindings.ts index d0cb62b75..6c53018d3 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -965,9 +965,13 @@ export const commands = { comparison?: ComparisonLaunch | null, /** `openidea diff a.txt b.txt`: the two files to open in the diff viewer (boxed: most launches have none). */ diff?: DiffFilesLaunch | null, + /** Files requested after the pending diff take focus after it opens. */ + diffBeforeFiles?: boolean, /** Deployment windows: the server and local selection to compare. */ deployment?: DeploymentLaunch | null, } | null>("window_take_launch"), + /** Release the opening window's reservation after navigation or cancellation. */ + windowCompleteLaunch: () => typedError(__TAURI_INVOKE("window_complete_launch")), windowCloseContext: () => __TAURI_INVOKE("window_close_context"), /** Projects open in project windows, in window order (title bar project widget). */ windowOpenProjects: () => typedError(__TAURI_INVOKE("window_open_projects")), @@ -5517,6 +5521,8 @@ export type Launch = { comparison?: ComparisonLaunch | null, /** `openidea diff a.txt b.txt`: the two files to open in the diff viewer (boxed: most launches have none). */ diff?: DiffFilesLaunch | null, + /** Files requested after the pending diff take focus after it opens. */ + diffBeforeFiles?: boolean, /** Deployment windows: the server and local selection to compare. */ deployment?: DeploymentLaunch | null, }; diff --git a/src/features/app/__tests__/windows.test.ts b/src/features/app/__tests__/windows.test.ts index d8d65a161..ea9c9c5ec 100644 --- a/src/features/app/__tests__/windows.test.ts +++ b/src/features/app/__tests__/windows.test.ts @@ -6,9 +6,14 @@ const projectCurrent = vi.fn(); const windowOpenProject = vi.fn(); const windowOpenFiles = vi.fn(); const windowTakeLaunch = vi.fn(); +const windowCompleteLaunch = vi.fn(); const wslResolve = vi.fn(); const cancelClose = vi.fn(); const notify = vi.fn(); +const listen = vi.fn(); +vi.mock("@tauri-apps/api/webviewWindow", () => ({ + getCurrentWebviewWindow: () => ({ listen }), +})); vi.mock("@/platform/remote", async (original) => ({ ...(await original()), isRemoteWindow: vi.fn(() => false), @@ -36,6 +41,7 @@ vi.mock("@/platform/ipc", async (orig) => { windowOpenProject: (...a: unknown[]) => windowOpenProject(...a), windowOpenFiles: (...a: unknown[]) => windowOpenFiles(...a), windowTakeLaunch: () => windowTakeLaunch(), + windowCompleteLaunch: () => windowCompleteLaunch(), wslResolve: (p: string) => wslResolve(p), lightEditOpen: (p: string) => lightEditOpen(p), fsReadDir: (p: string) => fsReadDir(p), @@ -47,18 +53,20 @@ vi.mock("@/platform/project", async (orig) => { return { ...real, openProject: (p: string) => openProject(p) }; }); const diffs: unknown[] = []; -vi.mock("@/features/vcs/diffInput", () => ({ openVcsDiff: (data: unknown) => void diffs.push(data) })); +const openDiff = vi.fn(async (data: unknown) => void diffs.push(data)); +vi.mock("@/features/vcs/diffInput", () => ({ openVcsDiff: (data: unknown) => openDiff(data) })); const { openProjectInWindow, consumeLaunch, + installLaunchHandling, openFiles, openPaths, openLightEditFileAsProject, openProjectWithPolicy, windowActions, } = await import("../windows"); -const { useProject } = await import("@/platform/project"); +const { useProject, startupLaunchHandled } = await import("@/platform/project"); const { provideEditorService, provideNotificationService } = await import("@/workbench/contributions"); const { isRemoteWindow, connectRemote } = await import("@/platform/remote"); const { IpcError } = await import("@/platform/ipc"); @@ -66,11 +74,14 @@ provideNotificationService({ notify }); const opened: string[] = []; let activePath: string | null = null; -provideEditorService({ - openFile: async (path, options) => +const openEditorFile = vi.fn( + async (path, options) => void opened.push( `${path}${options?.focus ? " (focus)" : ""}${options?.selection ? ` @${options.selection.line}:${options.selection.col}` : ""}`, ), +); +provideEditorService({ + openFile: openEditorFile, openInput: async () => {}, activePath: () => activePath, activeCursor: () => null, @@ -81,18 +92,29 @@ provideEditorService({ const ok = (data: unknown) => ({ status: "ok", data }); const projectState = (root: string) => ({ project: { root, name: root, trusted: true, isGit: false, settings: {} } }); +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => (resolve = done)); + return { promise, resolve }; +} + beforeEach(() => { for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch, wslResolve]) f.mockReset(); + windowTakeLaunch.mockResolvedValue(null); + windowCompleteLaunch.mockReset().mockResolvedValue(ok(null)); wslResolve.mockResolvedValue(null); vi.mocked(connectRemote).mockClear(); for (const f of [lightEditOpen, fsReadDir, openProject]) f.mockClear(); cancelClose.mockClear(); notify.mockClear(); + listen.mockReset(); + openEditorFile.mockClear(); + openDiff.mockClear(); vi.mocked(isRemoteWindow).mockReturnValue(false); opened.length = 0; diffs.length = 0; activePath = null; - useProject.setState({ project: null, lightEdit: false, settings: null } as never); + useProject.setState({ project: null, lightEdit: false, settings: null, opening: false, openingPath: null } as never); }); describe("openProjectInWindow", () => { @@ -272,12 +294,160 @@ describe("Open Containing Folder as Project", () => { }); describe("launch", () => { + it("subscribes before taking a launch received while the listener is registering", async () => { + const registered = deferred(); + const unlisten = vi.fn(); + listen.mockImplementationOnce(async () => { + await registered.promise; + windowTakeLaunch.mockResolvedValueOnce({ project: "/p", files: ["/p/late.cs"] }); + return unlisten; + }); + const dispose = installLaunchHandling(); + try { + await vi.waitFor(() => expect(listen).toHaveBeenCalledOnce()); + expect(windowTakeLaunch).not.toHaveBeenCalled(); + registered.resolve(); + await startupLaunchHandled(); + expect(opened).toEqual(["/p/late.cs (focus)"]); + } finally { + registered.resolve(); + await startupLaunchHandled(); + dispose(); + } + expect(unlisten).toHaveBeenCalledOnce(); + }); + + it("does not consume a launch after disposal while the listener is registering", async () => { + const registered = deferred(); + const unlisten = vi.fn(); + listen.mockImplementationOnce(async () => { + await registered.promise; + return unlisten; + }); + windowTakeLaunch.mockResolvedValueOnce({ project: "/p", files: ["/p/a.cs"] }); + const dispose = installLaunchHandling(); + await vi.waitFor(() => expect(listen).toHaveBeenCalledOnce()); + dispose(); + registered.resolve(); + await startupLaunchHandled(); + expect(unlisten).toHaveBeenCalledOnce(); + expect(windowTakeLaunch).not.toHaveBeenCalled(); + expect(opened).toEqual([]); + }); + + it("serializes launch events while their project is opening", async () => { + const openedProject = deferred(); + openProject.mockImplementationOnce(async (path) => { + await openedProject.promise; + const state = projectState(path); + useProject.setState({ ...state, lightEdit: false } as never); + return state.project; + }); + let onLaunch: (() => void) | undefined; + listen.mockImplementationOnce(async (_event, handler: () => void) => { + onLaunch = handler; + return () => {}; + }); + windowTakeLaunch + .mockResolvedValueOnce({ project: "/p", files: ["/p/a.cs"] }) + .mockResolvedValueOnce({ files: ["/p/b.cs"], positions: [{ path: "/p/b.cs", line: 10, column: 3 }] }); + const dispose = installLaunchHandling(); + try { + await vi.waitFor(() => expect(openProject).toHaveBeenCalledOnce()); + onLaunch!(); + openedProject.resolve(); + await startupLaunchHandled(); + await consumeLaunch(); + expect(lightEditOpen).not.toHaveBeenCalled(); + expect(opened).toEqual(["/p/a.cs (focus)", "/p/b.cs (focus) @10:3"]); + expect(openProject).toHaveBeenCalledOnce(); + } finally { + openedProject.resolve(); + await startupLaunchHandled(); + dispose(); + } + }); + + it("waits for an existing project restore before opening a received file", async () => { + useProject.setState({ opening: true, openingPath: "/p" }); + windowTakeLaunch.mockResolvedValueOnce({ + files: ["/p/a.cs"], + positions: [{ path: "/p/a.cs", line: 8, column: 2 }], + }); + const request = consumeLaunch(); + try { + await vi.waitFor(() => expect(windowTakeLaunch).toHaveBeenCalledOnce()); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(opened).toEqual([]); + expect(lightEditOpen).not.toHaveBeenCalled(); + expect(windowCompleteLaunch).not.toHaveBeenCalled(); + } finally { + useProject.setState({ ...projectState("/p"), opening: false, openingPath: null } as never); + await request; + } + expect(openProject).not.toHaveBeenCalled(); + expect(lightEditOpen).not.toHaveBeenCalled(); + expect(opened).toEqual(["/p/a.cs (focus) @8:2"]); + expect(windowCompleteLaunch).toHaveBeenCalledOnce(); + }); + + it("keeps the open project and applies later locations in order", async () => { + useProject.setState(projectState("/p") as never); + for (const line of [5, 8, 12]) { + windowTakeLaunch.mockResolvedValueOnce({ + project: "/p", + files: ["/p/a.cs"], + positions: [{ path: "/p/a.cs", line, column: 0 }], + }); + } + await Promise.all([consumeLaunch(), consumeLaunch(), consumeLaunch()]); + expect(openProject).not.toHaveBeenCalled(); + expect(opened).toEqual(["/p/a.cs (focus) @5:0", "/p/a.cs (focus) @8:0", "/p/a.cs (focus) @12:0"]); + }); + + it("reports a missing file and still handles the next launch", async () => { + useProject.setState(projectState("/p") as never); + openEditorFile.mockRejectedValueOnce(new Error("File not found")); + windowTakeLaunch.mockResolvedValueOnce({ files: ["/p/missing.cs"] }).mockResolvedValueOnce({ files: ["/p/a.cs"] }); + await Promise.all([consumeLaunch(), consumeLaunch()]); + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", body: "File not found" })); + expect(opened).toEqual(["/p/a.cs (focus)"]); + expect(windowCompleteLaunch).toHaveBeenCalledTimes(2); + }); + + it.each([ + new Error("Project unavailable"), + new IpcError({ kind: "cancelled", message: "Project opening cancelled" }), + ])("completes a failed or cancelled project launch and handles the next: %s", async (error) => { + openProject.mockRejectedValueOnce(error); + windowTakeLaunch + .mockResolvedValueOnce({ project: "/p", files: ["/p/a.cs"] }) + .mockResolvedValueOnce({ project: "/q", files: ["/q/b.cs"] }); + await Promise.all([consumeLaunch(), consumeLaunch()]); + expect(windowCompleteLaunch).toHaveBeenCalledTimes(2); + expect(opened).toEqual(["/q/b.cs (focus)"]); + expect(useProject.getState().project?.root).toBe("/q"); + }); + + it("keeps handling launches after completing a launch fails", async () => { + useProject.setState(projectState("/p") as never); + windowCompleteLaunch.mockRejectedValueOnce(new Error("Connection interrupted")); + windowTakeLaunch.mockResolvedValueOnce({ files: ["/p/a.cs"] }).mockResolvedValueOnce({ files: ["/p/b.cs"] }); + const first = consumeLaunch(); + const second = consumeLaunch(); + await expect(first).rejects.toThrow("Connection interrupted"); + await second; + expect(windowCompleteLaunch).toHaveBeenCalledTimes(2); + expect(opened).toEqual(["/p/a.cs (focus)", "/p/b.cs (focus)"]); + }); + it("opens the pending project once", async () => { windowTakeLaunch.mockResolvedValueOnce({ project: "/p/launch", files: [] }).mockResolvedValueOnce(null); await consumeLaunch(); await consumeLaunch(); expect(openProject).toHaveBeenCalledTimes(1); expect(openProject).toHaveBeenCalledWith("/p/launch"); + expect(windowCompleteLaunch).toHaveBeenCalledOnce(); }); it("opens launched files in LightEdit without a project", async () => { @@ -310,6 +480,7 @@ describe("launch", () => { windowTakeLaunch.mockResolvedValueOnce({ project: null, files: [], diff: { left: "/x/a.txt", right: "/x/b.txt" } }); await consumeLaunch(); expect(lightEditOpen).toHaveBeenCalledWith("/x/b.txt"); + expect(windowCompleteLaunch).toHaveBeenCalledOnce(); expect(diffs).toEqual([ { path: "/x/b.txt", @@ -324,6 +495,7 @@ describe("launch", () => { windowTakeLaunch.mockResolvedValueOnce({ project: null, files: [], diff: { left: "/p/a.txt", right: "/p/b.txt" } }); await consumeLaunch(); expect(lightEditOpen).not.toHaveBeenCalled(); + expect(windowCompleteLaunch).toHaveBeenCalledOnce(); expect(diffs).toEqual([ { path: "/p/b.txt", @@ -332,6 +504,90 @@ describe("launch", () => { }, ]); }); + + it("completes a failed CLI diff and handles the next file launch", async () => { + useProject.setState(projectState("/p") as never); + openDiff.mockRejectedValueOnce(new Error("Diff file unavailable")); + windowTakeLaunch + .mockResolvedValueOnce({ files: [], diff: { left: "/p/a.txt", right: "/p/b.txt" } }) + .mockResolvedValueOnce({ files: ["/p/next.cs"], positions: [{ path: "/p/next.cs", line: 8, column: 2 }] }); + const first = consumeLaunch(); + const next = consumeLaunch(); + await expect(first).rejects.toThrow("Diff file unavailable"); + await next; + expect(windowCompleteLaunch).toHaveBeenCalledTimes(2); + expect(opened).toEqual(["/p/next.cs (focus) @8:2"]); + }); + + it("applies a later source location after a pending diff", async () => { + useProject.setState(projectState("/p") as never); + windowTakeLaunch + .mockResolvedValueOnce({ files: [], diff: { left: "/p/a.txt", right: "/p/b.txt" } }) + .mockResolvedValueOnce({ files: ["/p/next.cs"], positions: [{ path: "/p/next.cs", line: 8, column: 2 }] }); + await Promise.all([consumeLaunch(), consumeLaunch()]); + expect(diffs).toHaveLength(1); + expect(opened).toEqual(["/p/next.cs (focus) @8:2"]); + expect(openDiff.mock.invocationCallOrder[0]).toBeLessThan(openEditorFile.mock.invocationCallOrder[0]!); + expect(windowCompleteLaunch).toHaveBeenCalledTimes(2); + }); + + it.each([ + { project: null, diffBeforeFiles: false }, + { project: "/p", diffBeforeFiles: false }, + { project: null, diffBeforeFiles: true }, + { project: "/p", diffBeforeFiles: true }, + ])( + "preserves the last request's focus for $project (diff first: $diffBeforeFiles)", + async ({ project, diffBeforeFiles }) => { + windowTakeLaunch.mockResolvedValueOnce({ + project, + diffBeforeFiles, + files: ["/p/a.cs"], + positions: [{ path: "/p/a.cs", line: 8, column: 2 }], + diff: { left: "/p/left.txt", right: "/p/right.txt" }, + }); + await consumeLaunch(); + expect(opened).toEqual(["/p/a.cs (focus) @8:2"]); + expect(diffs).toHaveLength(1); + const fileCall = openEditorFile.mock.invocationCallOrder[0]!; + const diffCall = openDiff.mock.invocationCallOrder[0]!; + expect(diffBeforeFiles ? diffCall : fileCall).toBeLessThan(diffBeforeFiles ? fileCall : diffCall); + expect(windowCompleteLaunch).toHaveBeenCalledOnce(); + }, + ); + + it("opens later files when their preceding diff fails", async () => { + useProject.setState(projectState("/p") as never); + openDiff.mockRejectedValueOnce(new Error("Diff file unavailable")); + windowTakeLaunch.mockResolvedValueOnce({ + files: ["/p/next.cs"], + positions: [{ path: "/p/next.cs", line: 8, column: 2 }], + diff: { left: "/p/a.txt", right: "/p/b.txt" }, + diffBeforeFiles: true, + }); + await consumeLaunch(); + expect(opened).toEqual(["/p/next.cs (focus) @8:2"]); + expect(notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error", body: "Diff file unavailable" })); + expect(windowCompleteLaunch).toHaveBeenCalledOnce(); + }); + + it.each([false, true])( + "does not open a diff when its project launch is cancelled (diff first: %s)", + async (diffBeforeFiles) => { + openProject.mockRejectedValueOnce(new IpcError({ kind: "cancelled", message: "Project opening cancelled" })); + windowTakeLaunch.mockResolvedValueOnce({ + project: "/p", + diffBeforeFiles, + files: ["/p/a.cs"], + diff: { left: "/p/left.txt", right: "/p/right.txt" }, + }); + await consumeLaunch(); + expect(opened).toEqual([]); + expect(diffs).toEqual([]); + expect(lightEditOpen).not.toHaveBeenCalled(); + expect(windowCompleteLaunch).toHaveBeenCalledOnce(); + }, + ); }); describe("Attach", () => { diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 871ca060f..c8aa56dfc 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -65,10 +65,10 @@ async function openHere(path: string): Promise { } } -/** Opens `files` as editor tabs of this window, focusing the last. */ /** Caret positions of files opened from the command line (`--line` / `--column`), 0-based. */ type Positions = ReadonlyArray<{ path: string; line: number; column: number }>; +/** Opens `files` as editor tabs of this window, focusing the last. */ async function openInEditor(files: readonly string[], positions: Positions = []): Promise { for (const [i, path] of files.entries()) { const at = positions.find((p) => p.path === path); @@ -77,7 +77,7 @@ async function openInEditor(files: readonly string[], positions: Positions = []) focus: i === files.length - 1, ...(at ? { selection: { line: at.line, col: at.column } } : {}), }) - .catch((e) => console.warn(`could not open ${path}`, e)); + .catch((e) => openFailed(path, e)); } } @@ -213,25 +213,53 @@ async function pickFiles(title: string): Promise { } /** Takes this window's pending launch (if any) and opens its project, files or diff pair. */ -export async function consumeLaunch(): Promise { +let launchQueue: Promise = Promise.resolve(); +export function consumeLaunch(): Promise { + const request = launchQueue.then(consumePendingLaunch); + launchQueue = request.catch(() => {}); + return request; +} + +async function consumePendingLaunch(): Promise { if (!isTauri()) return; const launch = await commands.windowTakeLaunch().catch(() => null); - const files = launch?.files ?? []; - const positions = launch?.positions ?? []; - if (launch?.project) { - if (await openHere(launch.project)) await openInEditor(files, positions); - } else if (files.length) await openFilesHere(files, positions); - else if (launch?.diff) await openCliDiff(launch.diff); - else if (launch?.remote) { - const { target, path } = launch.remote; - await connectRemote(target, path).catch((e) => - notifications().notify({ - level: "error", - title: t("app.window.openFailed", { name: basename(path) }), - body: errorText(e), - key: `app.open:${path}`, - }), - ); + if (!launch) return; + try { + // Session restoration may already be opening this window's project. + if (useProject.getState().opening) { + await new Promise((resolve) => { + const off = useProject.subscribe((state) => { + if (!state.opening) { + off(); + resolve(); + } + }); + }); + } + const files = launch.files ?? []; + const positions = launch.positions ?? []; + if (launch.project && !(await openHere(launch.project))) return; + if (launch.diff && launch.diffBeforeFiles) { + const diff = launch.diff; + await openCliDiff(diff).catch((e) => openFailed(diff.right, e)); + } + if (launch.project) { + await openInEditor(files, positions); + } else if (files.length) await openFilesHere(files, positions); + else if (launch.remote) { + const { target, path } = launch.remote; + await connectRemote(target, path).catch((e) => + notifications().notify({ + level: "error", + title: t("app.window.openFailed", { name: basename(path) }), + body: errorText(e), + key: `app.open:${path}`, + }), + ); + } + if (launch.diff && !launch.diffBeforeFiles) await openCliDiff(launch.diff); + } finally { + await call(commands.windowCompleteLaunch()); } } @@ -517,14 +545,19 @@ export function installLaunchHandling(): () => void { if (!isTauri()) return () => {}; let disposed = false; let unlisten: (() => void) | undefined; - const launch = consumeLaunch(); + const launch = import("@tauri-apps/api/webviewWindow").then(async ({ getCurrentWebviewWindow }) => { + const off = await getCurrentWebviewWindow().listen(LAUNCH_EVENT, () => { + if (!disposed) void consumeLaunch().catch(console.warn); + }); + if (disposed) off(); + else { + unlisten = off; + // Subscribe before taking pending files so launches during startup aren't missed. + await consumeLaunch(); + } + }); provideStartupLaunch(launch); holdSplash(launch); - void import("@tauri-apps/api/webviewWindow").then(async ({ getCurrentWebviewWindow }) => { - const off = await getCurrentWebviewWindow().listen(LAUNCH_EVENT, () => void consumeLaunch()); - if (disposed) off(); - else unlisten = off; - }); return () => { disposed = true; unlisten?.();