feat(unity): open scripts from external editor arguments (#2296)

* feat(app): support Unity external editor navigation

* docs(unity): explain manual external editor setup
Цей коміт міститься в:
Hacbit authored and GitHub committed 2026-11-09 16:03:37 +08:00
1 джерело 5ab5c8c677
коміт 2dd57d095f
10 files changed
+1141 -240

No files matched your search

+42
Переглянути файл
@@ -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.
+1
Переглянути файл
@@ -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,
+8
Переглянути файл
@@ -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");
+227
Переглянути файл
@@ -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)));
}
}
}
+41
Переглянути файл
@@ -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(())
}
+6
Переглянути файл
@@ -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<null, IdeError>(__TAURI_INVOKE("window_complete_launch")),
windowCloseContext: () => __TAURI_INVOKE<CloseContext>("window_close_context"),
/** Projects open in project windows, in window order (title bar project widget). */
windowOpenProjects: () => typedError<OpenProject[], IdeError>(__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,
};
+261 -5
Переглянути файл
@@ -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<typeof import("@/platform/remote")>()),
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<import("@/workbench/contributions").EditorService["openFile"]>(
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<void>((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", () => {
+58 -25
Переглянути файл
@@ -65,10 +65,10 @@ async function openHere(path: string): Promise<boolean> {
}
}
/** 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<void> {
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<string[]> {
}
/** Takes this window's pending launch (if any) and opens its project, files or diff pair. */
export async function consumeLaunch(): Promise<void> {
let launchQueue: Promise<void> = Promise.resolve();
export function consumeLaunch(): Promise<void> {
const request = launchQueue.then(consumePendingLaunch);
launchQueue = request.catch(() => {});
return request;
}
async function consumePendingLaunch(): Promise<void> {
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<void>((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?.();