diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8ac6fb995..8ba8e5f9b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -148,6 +148,7 @@ pub fn run() { if let Ok(cwd) = std::env::current_dir() && let Some(path) = windows::cli_path(&std::env::args().collect::>(), &cwd) { + windows::remember_cli_position(&windows, &std::env::args().collect::>(), &path); windows.set_launch("main", windows::path_launch(path)); } let menu_state = menu::MenuState::default(); diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 124e6b618..ecc5021ec 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -47,6 +47,18 @@ pub struct Launch { pub settings_page: Option, /// Settings windows: edit the global layer only ("Settings for New Projects"). pub new_projects: bool, + /// Where to place the caret in some of `files` (`openidea --line 12 --column 5 `). + #[serde(default)] + pub positions: Vec, +} + +/// A caret position for a file opened from the command line: 0-based line and column. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct FilePosition { + pub path: ide_core::IpcPath, + pub line: u32, + pub column: u32, } /// A project folder on the host of an SSH configuration. @@ -63,6 +75,8 @@ pub struct WindowsState { pub groups_restored: AtomicBool, 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>, /// Most recently focused window label (menu events go there). last_focused: Mutex>, /// Set by Quit while windows close one by one. @@ -79,7 +93,21 @@ impl WindowsState { } pub fn take_launch(&self, label: &str) -> Option { - self.launches.lock().remove(label) + 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 }); + } + } + 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); } /// Adds `files` to `label`'s pending launch, so files sent before its frontend took the last batch aren't lost. @@ -190,13 +218,50 @@ pub fn cycle_target(sorted: &[String], current: &str, forward: bool) -> Option Option { - args.iter().skip(1).find(|a| !a.starts_with('-') && !a.is_empty() && !a.starts_with("psn_")).map(|a| { + 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); - if p.is_absolute() { p } else { cwd.join(p) } - }) + return Some(if p.is_absolute() { p } else { cwd.join(p) }); + } + None +} + +/// `--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 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))) +} + +/// 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); + } } pub fn focus(window: &WebviewWindow) { @@ -752,7 +817,10 @@ pub fn path_launch(path: PathBuf) -> Launch { /// Second instance launched: focus this one and open its path argument. pub fn on_second_instance(app: &AppHandle, args: Vec, cwd: String) { match cli_path(&args, Path::new(&cwd)) { - Some(path) => open_path(app, &path), + Some(path) => { + remember_cli_position(&app.state::(), &args, &path); + open_path(app, &path) + } None => { if let Some(w) = target_window(app) { focus(&w); @@ -1070,6 +1138,38 @@ mod tests { assert_eq!(cli_path(&s(&["openidea", "/abs/p"]), cwd), Some(PathBuf::from("/abs/p"))); assert_eq!(cli_path(&s(&["openidea"]), cwd), None); assert_eq!(cli_path(&s(&["openidea", "-psn_0_123"]), cwd), None); + // A flag's value is not the path. + let args = s(&["openidea", "--line", "120", "--column", "8", "src/Main.java"]); + assert_eq!(cli_path(&args, cwd), Some(PathBuf::from("/work/src/Main.java"))); + assert_eq!( + cli_path(&s(&["openidea", "--line=3", "a b/文件.rs"]), cwd), + Some(PathBuf::from("/work/a b/文件.rs")) + ); + } + + #[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); + } + + #[test] + fn a_launch_carries_the_positions_of_its_files() { + 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()]); + 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()]); + assert!(st.take_launch("main").unwrap().positions.is_empty()); } #[test] diff --git a/src/bindings.ts b/src/bindings.ts index 5e89fae48..6f8c3abba 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -774,6 +774,8 @@ export const commands = { settingsPage: string | null, /** Settings windows: edit the global layer only ("Settings for New Projects"). */ newProjects: boolean, + /** Where to place the caret in some of `files` (`openidea --line 12 --column 5 `). */ + positions?: FilePosition[], } | null>("window_take_launch"), windowCloseContext: () => __TAURI_INVOKE("window_close_context"), /** Projects open in project windows, in window order (title bar project widget). */ @@ -3336,6 +3338,13 @@ export type FileMove = { to: IpcPath, }; +/** A caret position for a file opened from the command line: 0-based line and column. */ +export type FilePosition = { + path: IpcPath, + line: number, + column: number, +}; + /** What the editor needs to know before choosing a viewer for a file. */ export type FileProbe = { size: number, @@ -4398,6 +4407,8 @@ export type Launch = { settingsPage: string | null, /** Settings windows: edit the global layer only ("Settings for New Projects"). */ newProjects: boolean, + /** Where to place the caret in some of `files` (`openidea --line 12 --column 5 `). */ + positions?: FilePosition[], }; export type LaunchConfig = { diff --git a/src/features/app/__tests__/windows.test.ts b/src/features/app/__tests__/windows.test.ts index c2b41fcb3..786cc76fe 100644 --- a/src/features/app/__tests__/windows.test.ts +++ b/src/features/app/__tests__/windows.test.ts @@ -62,7 +62,10 @@ provideNotificationService({ notify }); const opened: string[] = []; let activePath: string | null = null; provideEditorService({ - openFile: async (path, options) => void opened.push(`${path}${options?.focus ? " (focus)" : ""}`), + openFile: async (path, options) => + void opened.push( + `${path}${options?.focus ? " (focus)" : ""}${options?.selection ? ` @${options.selection.line}:${options.selection.col}` : ""}`, + ), openInput: async () => {}, activePath: () => activePath, activeCursor: () => null, @@ -272,6 +275,16 @@ describe("launch", () => { expect(lightEditOpen).not.toHaveBeenCalled(); expect(opened).toEqual(["/x/notes/a.txt (focus)"]); }); + + it("places the caret where the command line asked (openidea --line --column)", async () => { + windowTakeLaunch.mockResolvedValueOnce({ + project: "/x/notes", + files: ["/x/notes/a.txt", "/x/notes/b.txt"], + positions: [{ path: "/x/notes/a.txt", line: 119, column: 7 }], + }); + await consumeLaunch(); + expect(opened).toEqual(["/x/notes/a.txt @119:7", "/x/notes/b.txt (focus)"]); + }); }); describe("Attach", () => { diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 0125e4690..dd183db65 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -63,15 +63,23 @@ async function openHere(path: string): Promise { } /** Opens `files` as editor tabs of this window, focusing the last. */ -async function openInEditor(files: readonly string[]): Promise { - for (const [i, path] of files.entries()) +/** Caret positions of files opened from the command line (`--line` / `--column`), 0-based. */ +type Positions = ReadonlyArray<{ path: string; line: number; column: number }>; + +async function openInEditor(files: readonly string[], positions: Positions = []): Promise { + for (const [i, path] of files.entries()) { + const at = positions.find((p) => p.path === path); await editorService() - .openFile(path, { focus: i === files.length - 1 }) + .openFile(path, { + focus: i === files.length - 1, + ...(at ? { selection: { line: at.line, col: at.column } } : {}), + }) .catch((e) => console.warn(`could not open ${path}`, e)); + } } /** Opens `files` in this window: in its project's editor or, without one, in LightEdit. */ -async function openFilesHere(files: readonly string[]): Promise { +async function openFilesHere(files: readonly string[], positions: Positions = []): Promise { const s = useProject.getState(); if (!s.project && !s.lightEdit) { try { @@ -80,7 +88,7 @@ async function openFilesHere(files: readonly string[]): Promise { return openFailed(files[0]!, e); } } - await openInEditor(files); + await openInEditor(files, positions); } /** @@ -206,9 +214,10 @@ export async function consumeLaunch(): 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); - } else if (files.length) await openFilesHere(files); + if (await openHere(launch.project)) await openInEditor(files, positions); + } else if (files.length) await openFilesHere(files, positions); else if (launch?.remote) { const { sshConfig, path } = launch.remote; await connectRemote(sshConfig, path).catch((e) =>